mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
197 lines
5.6 KiB
JavaScript
197 lines
5.6 KiB
JavaScript
/**
|
||
* Pins for discourse and capture (DESIGN.md §11.3–11.4): posts, sealed
|
||
* witness captures, public camera eyes, live broadcasts and holograms. Eyes
|
||
* and live pins draw their view frustum as a translucent wedge on the ground
|
||
* along the camera heading. Pins are few (tens), so the wedge primitive is
|
||
* simply rebuilt on change.
|
||
*/
|
||
import * as Cesium from 'cesium';
|
||
import { glyph } from './glyphs.js';
|
||
import { offsetLonLat } from './geo.js';
|
||
|
||
export const PIN_KINDS = Object.freeze([
|
||
'post',
|
||
'witness',
|
||
'eye',
|
||
'live',
|
||
'hologram',
|
||
]);
|
||
|
||
const PIN_COLORS = Object.freeze({
|
||
post: '#E8EEF7',
|
||
witness: '#FFC857',
|
||
eye: '#FFD166',
|
||
live: '#FF4D6D',
|
||
hologram: '#C9A7FF',
|
||
});
|
||
|
||
const PIN_TITLES = Object.freeze({
|
||
post: 'Post',
|
||
witness: 'Sealed witness capture',
|
||
eye: 'Public camera',
|
||
live: 'Live broadcast',
|
||
hologram: 'Hologram',
|
||
});
|
||
|
||
export function createPins({ scene, requestRender }) {
|
||
const root = scene.primitives.add(new Cesium.PrimitiveCollection());
|
||
const billboards = root.add(new Cesium.BillboardCollection());
|
||
const edges = root.add(new Cesium.PolylineCollection());
|
||
let wedges = null;
|
||
const pins = new Map();
|
||
let pulse = 0;
|
||
|
||
function rebuildWedges() {
|
||
if (wedges) root.remove(wedges);
|
||
wedges = null;
|
||
edges.removeAll();
|
||
const instances = [];
|
||
for (const p of pins.values()) {
|
||
if (p.kind !== 'eye' && p.kind !== 'live') continue;
|
||
if (!Number.isFinite(p.headingDeg)) continue;
|
||
const half = (p.fovDeg ?? 60) / 2;
|
||
const ring = [[p.lon, p.lat]];
|
||
for (let a = -half; a <= half + 0.01; a += Math.max(2, half / 6))
|
||
ring.push(offsetLonLat(p.lon, p.lat, p.headingDeg + a, p.rangeM));
|
||
const css = p.color || PIN_COLORS[p.kind];
|
||
instances.push(
|
||
new Cesium.GeometryInstance({
|
||
id: { layer: 'pins', key: p.id },
|
||
geometry: new Cesium.PolygonGeometry({
|
||
polygonHierarchy: new Cesium.PolygonHierarchy(
|
||
Cesium.Cartesian3.fromDegreesArray(ring.flat()),
|
||
),
|
||
height: (p.altM || 0) + 3,
|
||
vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
|
||
}),
|
||
attributes: {
|
||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||
Cesium.Color.fromCssColorString(css).withAlpha(0.28),
|
||
),
|
||
},
|
||
}),
|
||
);
|
||
edges.add({
|
||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(
|
||
[...ring, ring[0]].flatMap(([lo, la]) => [lo, la, (p.altM || 0) + 4]),
|
||
),
|
||
width: 1.5,
|
||
material: Cesium.Material.fromType('Color', {
|
||
color: Cesium.Color.fromCssColorString(css).withAlpha(0.85),
|
||
}),
|
||
});
|
||
}
|
||
if (instances.length)
|
||
wedges = root.add(
|
||
new Cesium.Primitive({
|
||
geometryInstances: instances,
|
||
appearance: new Cesium.PerInstanceColorAppearance({
|
||
flat: true,
|
||
translucent: true,
|
||
}),
|
||
asynchronous: true,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function rebuildBillboards() {
|
||
billboards.removeAll();
|
||
for (const p of pins.values()) {
|
||
const g = glyph(p.kind);
|
||
p._bb = billboards.add({
|
||
position: Cesium.Cartesian3.fromDegrees(
|
||
p.lon,
|
||
p.lat,
|
||
(p.altM || 0) + 8,
|
||
),
|
||
image: g.image,
|
||
imageId: g.imageId,
|
||
color: Cesium.Color.fromCssColorString(p.color || PIN_COLORS[p.kind]),
|
||
scale: 0.62,
|
||
scaleByDistance: new Cesium.NearFarScalar(2e3, 1.15, 8e6, 0.55),
|
||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||
disableDepthTestDistance: 2e5,
|
||
id: { layer: 'pins', key: p.id },
|
||
});
|
||
}
|
||
}
|
||
|
||
function changed() {
|
||
rebuildBillboards();
|
||
rebuildWedges();
|
||
requestRender();
|
||
}
|
||
|
||
return {
|
||
add(pin) {
|
||
if (!pin || pin.id == null) throw new TypeError('addPin needs an id');
|
||
if (!Number.isFinite(pin.lon) || !Number.isFinite(pin.lat))
|
||
throw new TypeError('addPin needs lon and lat');
|
||
const kind = PIN_KINDS.includes(pin.kind) ? pin.kind : 'post';
|
||
const p = {
|
||
id: String(pin.id),
|
||
lon: pin.lon,
|
||
lat: pin.lat,
|
||
kind,
|
||
headingDeg: Number.isFinite(pin.headingDeg) ? pin.headingDeg : null,
|
||
fovDeg: Number.isFinite(pin.fovDeg) ? pin.fovDeg : 60,
|
||
rangeM: Number.isFinite(pin.rangeM) ? pin.rangeM : 350,
|
||
color: pin.color || null,
|
||
label: pin.label || '',
|
||
altM: Number.isFinite(pin.altM) ? pin.altM : 0,
|
||
};
|
||
pins.set(p.id, p);
|
||
changed();
|
||
return p.id;
|
||
},
|
||
remove(id) {
|
||
const ok = pins.delete(String(id));
|
||
if (ok) changed();
|
||
return ok;
|
||
},
|
||
list() {
|
||
return [...pins.values()].map(({ _bb, ...p }) => ({ ...p }));
|
||
},
|
||
locate(id) {
|
||
const p = pins.get(String(id));
|
||
return p ? [p.lon, p.lat, p.altM || 0] : null;
|
||
},
|
||
pick(id) {
|
||
const p = pins.get(String(id));
|
||
if (!p) return null;
|
||
return {
|
||
kind: 'pin',
|
||
pinKind: p.kind,
|
||
id: p.id,
|
||
title: p.label || PIN_TITLES[p.kind],
|
||
subtitle: PIN_TITLES[p.kind],
|
||
lon: p.lon,
|
||
lat: p.lat,
|
||
meta: {
|
||
layer: 'pins',
|
||
headingDeg: p.headingDeg,
|
||
fovDeg: p.fovDeg,
|
||
label: p.label,
|
||
},
|
||
};
|
||
},
|
||
/** Live pins breathe once a second; returns true when anything changed. */
|
||
tick() {
|
||
let any = false;
|
||
pulse = (pulse + 1) % 2;
|
||
for (const p of pins.values())
|
||
if (p.kind === 'live' && p._bb) {
|
||
p._bb.scale = pulse ? 0.74 : 0.6;
|
||
any = true;
|
||
}
|
||
return any;
|
||
},
|
||
get billboards() {
|
||
return billboards;
|
||
},
|
||
destroy() {
|
||
scene.primitives.remove(root);
|
||
},
|
||
};
|
||
}
|