From 75979a5434ca55f30de6e477147cf7aaa22bba4d Mon Sep 17 00:00:00 2001 From: maldoteth Date: Wed, 9 Sep 2026 16:26:06 +0100 Subject: [PATCH 1/2] feat: add host-controlled community presence layer --- .github/workflows/pages.yml | 38 +++++++ CHANGELOG.md | 4 + docs/CURRENT-STATE.md | 6 + src/data/communityPresence.js | 165 ++++++++++++++++++++++++++++ src/data/communityPresence.test.mjs | 24 ++++ src/data/layerState.js | 1 + src/data/layerState.test.mjs | 4 +- src/main.js | 22 ++++ vite.config.js | 1 + 9 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 src/data/communityPresence.js create mode 100644 src/data/communityPresence.test.mjs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..e506bc3 --- /dev/null +++ b/.github/workflows/pages.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..ea807ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861..6578df8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/data/communityPresence.js b/src/data/communityPresence.js new file mode 100644 index 0000000..72adc45 --- /dev/null +++ b/src/data/communityPresence.js @@ -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 = `${initials.replace(/[<>&"']/g, '')}`; + 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(); diff --git a/src/data/communityPresence.test.mjs b/src/data/communityPresence.test.mjs new file mode 100644 index 0000000..2aee6b1 --- /dev/null +++ b/src/data/communityPresence.test.mjs @@ -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: [] }), []); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index eb68c14..d1b5008 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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' }), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2c282e7..2cb4ed2 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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]]), diff --git a/src/main.js b/src/main.js index 84fbf12..39a9c42 100644 --- a/src/main.js +++ b/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. @@ -220,6 +241,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); diff --git a/vite.config.js b/vite.config.js index 34dac52..7b4f161 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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(), From a9ab0b4cab3199bd2faf52ec3c4f698907c3a939 Mon Sep 17 00:00:00 2001 From: maldoteth Date: Wed, 9 Sep 2026 18:05:29 +0100 Subject: [PATCH 2/2] fix: keep embedded globe WebGL origin-clean --- src/main.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main.js b/src/main.js index 39a9c42..7cd363d 100644 --- a/src/main.js +++ b/src/main.js @@ -102,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, @@ -116,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