This commit is contained in:
mal.eth 2026-09-09 17:05:36 +00:00 committed by GitHub
commit 54ba7eded0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 272 additions and 2 deletions

38
.github/workflows/pages.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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: [] }), []);
});

View File

@ -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' }),

View File

@ -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]]),

View File

@ -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);

View File

@ -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(),