Merge a9ab0b4cab into 759652207f
This commit is contained in:
commit
54ba7eded0
|
|
@ -0,0 +1,38 @@
|
|||
name: Deploy GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.14.0
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
env:
|
||||
GEV_BASE_PATH: /gods-eye-view/
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
|
@ -5,6 +5,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Opt-in Community presence layer for social-platform hosts, with a public JSON endpoint and a small placement-request bridge. God's Eye View does not collect or store user locations.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mapped-site outages show their scheduled retry countdown and distinguish
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
Updated: August 24, 2026
|
||||
|
||||
## Community presence integration
|
||||
|
||||
Social platforms can add an opt-in people layer without forking the globe internals. Send `{ source: 'social-presence-host', type: 'configure', endpoint }` to the frame, or pass an HTTP(S) `presenceEndpoint` query parameter. The endpoint returns either an array or `{ people: [] }`; records accept `id`/`user_id`, `name`/`username`, `latitude`/`lat`, `longitude`/`lon`/`lng`, and optional HTTP(S) `avatar_url` and `profile_url` fields. Responses are capped at 5,000 records, refreshed once per minute, fetched without credentials, and filtered for invalid coordinates and unsafe URLs.
|
||||
|
||||
The layer never requests device location or writes user data itself. Its `PLACE ME ON THE MAP` control emits `gev:presence-place-requested` locally and, when embedded, posts `{ source: 'gods-eye-view', type: 'presence-place-requested' }` to its parent. The host owns consent, precision, authentication, retention, and deletion, then sends `{ source: 'social-presence-host', type: 'refresh' }` after a successful update.
|
||||
|
||||
## Installations and map-source guidance
|
||||
|
||||
- On an uncached Overpass failure, mapped installations keep their existing
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
import * as Cesium from 'cesium';
|
||||
|
||||
const CONFIG_SOURCE = 'social-presence-host';
|
||||
const APP_SOURCE = 'gods-eye-view';
|
||||
const MAX_PEOPLE = 5000;
|
||||
|
||||
export function safeHttpUrl(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
try {
|
||||
const url = new URL(value, window.location.href);
|
||||
return ['https:', 'http:'].includes(url.protocol) ? url.href : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Normalize one untrusted host record into the layer's small public schema. */
|
||||
export function normalizePresenceRecord(record, index = 0) {
|
||||
const latitude = Number(record?.latitude ?? record?.lat);
|
||||
const longitude = Number(record?.longitude ?? record?.lon ?? record?.lng);
|
||||
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null;
|
||||
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null;
|
||||
const id = String(record?.id ?? record?.user_id ?? `person-${index}`).trim().slice(0, 128);
|
||||
if (!id) return null;
|
||||
const name = String(record?.name ?? record?.username ?? 'Community member').trim().slice(0, 80);
|
||||
return {
|
||||
id,
|
||||
name: name || 'Community member',
|
||||
latitude,
|
||||
longitude,
|
||||
avatarUrl: safeHttpUrl(record?.avatar_url ?? record?.avatarUrl),
|
||||
profileUrl: safeHttpUrl(record?.profile_url ?? record?.profileUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePresencePayload(payload) {
|
||||
const records = Array.isArray(payload) ? payload : payload?.people;
|
||||
if (!Array.isArray(records)) return [];
|
||||
return records.slice(0, MAX_PEOPLE).map(normalizePresenceRecord).filter(Boolean);
|
||||
}
|
||||
|
||||
function initialsSvg(name) {
|
||||
const initials = String(name).split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map((part) => part[0]).join('').toUpperCase() || '•';
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="29" fill="#171717" stroke="#fff" stroke-width="4"/><text x="32" y="39" text-anchor="middle" font-family="system-ui,sans-serif" font-size="23" font-weight="700" fill="#fff">${initials.replace(/[<>&"']/g, '')}</text></svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function createCommunityPresenceLayer() {
|
||||
let dataSource = null;
|
||||
let viewerRef = null;
|
||||
let endpoint = null;
|
||||
let enabled = false;
|
||||
let count = 0;
|
||||
let lastUpdate = null;
|
||||
let lastError = null;
|
||||
let controlsListener = null;
|
||||
|
||||
const configure = (candidate) => {
|
||||
const next = safeHttpUrl(candidate);
|
||||
if (!next) return false;
|
||||
endpoint = next;
|
||||
controlsListener?.();
|
||||
if (enabled && viewerRef) void layer.update(viewerRef);
|
||||
return true;
|
||||
};
|
||||
const onMessage = (event) => {
|
||||
const message = event?.data;
|
||||
if (!message || message.source !== CONFIG_SOURCE) return;
|
||||
if (message.type === 'configure') configure(message.endpoint);
|
||||
if (message.type === 'refresh' && enabled && viewerRef) void layer.update(viewerRef);
|
||||
};
|
||||
|
||||
const layer = {
|
||||
id: 'community-presence',
|
||||
name: 'Community',
|
||||
icon: '◎',
|
||||
source: 'Host-provided, opt-in',
|
||||
updateInterval: 60_000,
|
||||
|
||||
init(viewer) {
|
||||
viewerRef = viewer;
|
||||
dataSource = new Cesium.CustomDataSource('community-presence');
|
||||
dataSource.show = false;
|
||||
viewer.dataSources.add(dataSource);
|
||||
const queryEndpoint = new URLSearchParams(window.location.search).get('presenceEndpoint');
|
||||
if (queryEndpoint) configure(queryEndpoint);
|
||||
window.addEventListener('message', onMessage);
|
||||
},
|
||||
enable() { enabled = true; if (dataSource) dataSource.show = true; },
|
||||
disable() { enabled = false; if (dataSource) dataSource.show = false; },
|
||||
|
||||
async update() {
|
||||
if (!endpoint || !dataSource) {
|
||||
lastError = 'Connect a community endpoint to load people';
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(endpoint, { credentials: 'omit', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const people = normalizePresencePayload(await response.json());
|
||||
dataSource.entities.removeAll();
|
||||
for (const person of people) {
|
||||
dataSource.entities.add({
|
||||
id: `community:${person.id}`,
|
||||
name: person.name,
|
||||
position: Cesium.Cartesian3.fromDegrees(person.longitude, person.latitude, 30),
|
||||
billboard: {
|
||||
image: person.avatarUrl || initialsSvg(person.name),
|
||||
width: 38,
|
||||
height: 38,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 20_000_000),
|
||||
},
|
||||
label: {
|
||||
text: person.name,
|
||||
font: '600 13px system-ui',
|
||||
fillColor: Cesium.Color.WHITE,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 4,
|
||||
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
pixelOffset: new Cesium.Cartesian2(0, -48),
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 2_000_000),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
properties: { profileUrl: person.profileUrl },
|
||||
});
|
||||
}
|
||||
count = people.length;
|
||||
lastUpdate = Date.now();
|
||||
lastError = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = `Community endpoint unavailable: ${error?.message || 'network error'}`;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setParams(params) {
|
||||
if (params?.endpoint) configure(params.endpoint);
|
||||
if (params?.requestPlacement) {
|
||||
const detail = { source: APP_SOURCE, type: 'presence-place-requested' };
|
||||
window.dispatchEvent(new CustomEvent('gev:presence-place-requested', { detail }));
|
||||
if (window.parent !== window) window.parent.postMessage(detail, '*');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
getRowControls() {
|
||||
return { chips: [{
|
||||
id: 'place-me', label: 'PLACE ME ON THE MAP',
|
||||
title: 'Ask the host social platform to share your location',
|
||||
params: { requestPlacement: true }, disabled: !endpoint,
|
||||
}] };
|
||||
},
|
||||
setRowControlsListener(listener) { controlsListener = typeof listener === 'function' ? listener : null; },
|
||||
destroy(viewer) {
|
||||
window.removeEventListener('message', onMessage);
|
||||
if (dataSource) viewer.dataSources.remove(dataSource, true);
|
||||
dataSource = null; viewerRef = null; enabled = false; count = 0;
|
||||
},
|
||||
getStats() { return { count, lastUpdate, error: lastError, available: Boolean(endpoint) }; },
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
|
||||
export default createCommunityPresenceLayer();
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.window = { location: { href: 'https://example.test/' } };
|
||||
const { normalizePresencePayload, normalizePresenceRecord, safeHttpUrl } = await import('./communityPresence.js');
|
||||
|
||||
test('normalizes common social presence fields', () => {
|
||||
assert.deepEqual(normalizePresenceRecord({
|
||||
user_id: '42', username: 'Ada', lat: '51.5', lng: '-0.12', profile_url: '/@ada',
|
||||
}), { id: '42', name: 'Ada', latitude: 51.5, longitude: -0.12,
|
||||
avatarUrl: null, profileUrl: 'https://example.test/@ada' });
|
||||
});
|
||||
|
||||
test('rejects invalid coordinates and unsafe URLs', () => {
|
||||
assert.equal(normalizePresenceRecord({ id: 'x', lat: 91, lon: 0 }), null);
|
||||
assert.equal(safeHttpUrl('javascript:alert(1)'), null);
|
||||
});
|
||||
|
||||
test('accepts arrays and people envelopes', () => {
|
||||
const person = { id: 'x', name: 'X', latitude: 1, longitude: 2 };
|
||||
assert.equal(normalizePresencePayload([person]).length, 1);
|
||||
assert.equal(normalizePresencePayload({ people: [person] }).length, 1);
|
||||
assert.deepEqual(normalizePresencePayload({ features: [] }), []);
|
||||
});
|
||||
|
|
@ -278,6 +278,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
|||
Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv' }),
|
||||
Object.freeze({ id: 'community-presence', token: 'p', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'flights', token: 'f', disposition: 'enabled+options', optionOwner: 'flights' }),
|
||||
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
|
||||
|
|
|
|||
|
|
@ -155,8 +155,8 @@ function encode(state) {
|
|||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 16);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 17);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
|
|
|
|||
31
src/main.js
31
src/main.js
|
|
@ -14,6 +14,7 @@ import bikeshareLayer from './data/bikeshare.js';
|
|||
import aisLiveVesselsLayer from './data/aisLiveVessels.js';
|
||||
import militaryInstallationsLayer from './data/militaryInstallations.js';
|
||||
import militaryAwarenessLayer from './data/militaryAwareness.js';
|
||||
import communityPresenceLayer from './data/communityPresence.js';
|
||||
import localDataLayers from './data/localLayers.js';
|
||||
import { LAYER_STATE_REGISTRY } from './data/layerState.js';
|
||||
import { registerDataCredits } from './data/dataCredits.js';
|
||||
|
|
@ -37,6 +38,26 @@ import { loadPhotorealisticTileset } from './mapStartup.js';
|
|||
|
||||
initLogoGaze();
|
||||
|
||||
function installEmbedExit() {
|
||||
if (window.parent === window) return;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = '← ARCADE';
|
||||
button.setAttribute('aria-label', 'Leave the globe');
|
||||
Object.assign(button.style, {
|
||||
position: 'fixed', top: '12px', left: '12px', zIndex: '10000',
|
||||
border: '1px solid rgba(255,255,255,.3)', borderRadius: '999px',
|
||||
background: 'rgba(0,0,0,.7)', color: '#fff', padding: '8px 12px',
|
||||
font: '600 11px system-ui', letterSpacing: '.08em', cursor: 'pointer',
|
||||
});
|
||||
button.addEventListener('click', () => {
|
||||
window.parent.postMessage({ source: 'gods-eye-view', type: 'exit' }, '*');
|
||||
});
|
||||
document.body.appendChild(button);
|
||||
}
|
||||
|
||||
installEmbedExit();
|
||||
|
||||
/**
|
||||
* Extract a human-readable error message from any thrown value.
|
||||
* Handles Error objects, strings, and plain objects with message/error fields.
|
||||
|
|
@ -81,6 +102,14 @@ async function init() {
|
|||
const googleApiKey = import.meta.env.GOOGLE_MAPS_API_KEY;
|
||||
if (googleApiKey) window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
|
||||
|
||||
// Cesium's default star-field skybox is loaded through HTML image
|
||||
// elements. In a sandboxed iframe without `allow-same-origin`, even files
|
||||
// served by this app have an opaque origin; uploading those images to
|
||||
// WebGL throws a SecurityError and stops the entire render loop. Keep the
|
||||
// full skybox for the standalone app, while embedded hosts fall back to
|
||||
// the texture-free sky atmosphere below.
|
||||
const embedded = window.self !== window.top;
|
||||
|
||||
// Create the Cesium viewer with minimal chrome
|
||||
const viewer = new Cesium.Viewer('cesiumContainer', {
|
||||
timeline: false,
|
||||
|
|
@ -95,6 +124,7 @@ async function init() {
|
|||
selectionIndicator: false,
|
||||
infoBox: false,
|
||||
baseLayer: false,
|
||||
skyBox: embedded ? false : undefined,
|
||||
// Visible attribution container — Google Maps / 3D Tiles credits are
|
||||
// required by Google's Terms of Service, so they must be shown (styled
|
||||
// subtly via #cesium-credits). The credit line stays visible in
|
||||
|
|
@ -220,6 +250,7 @@ async function init() {
|
|||
dataManager.register(aisLiveVesselsLayer);
|
||||
dataManager.register(militaryInstallationsLayer);
|
||||
dataManager.register(militaryAwarenessLayer);
|
||||
dataManager.register(communityPresenceLayer);
|
||||
militaryAwarenessLayer.attachDataManager(dataManager);
|
||||
for (const layer of localDataLayers) {
|
||||
dataManager.register(layer);
|
||||
|
|
|
|||
|
|
@ -7738,6 +7738,7 @@ export default defineConfig(({ mode }) => {
|
|||
const env = { ...process.env };
|
||||
const localAllowedHosts = ['localhost', '127.0.0.1', '.local'];
|
||||
return {
|
||||
base: process.env.GEV_BASE_PATH || '/',
|
||||
plugins: [
|
||||
cesium(),
|
||||
openSkyProxy(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue