style: format remaining owned runtime modules

This commit is contained in:
Sameh Khamis committed 2026-09-15 07:26:32 -07:00
1 parent ae99843c14
commit 2aca7aecbb
94 files changed
+8865 -3151

No files matched your search

+455 -105
View File
@@ -1,12 +1,20 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
import { isRateLimitedOutcome, resolveAnnotationTarget, sampleGroundHeight } from './annotationResolver.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
import {
isRateLimitedOutcome,
resolveAnnotationTarget,
sampleGroundHeight,
} from './annotationResolver.js';
import { ringCentroid } from './drawMode.js';
// Dev convenience: expose the app's Cesium instance for console/preview probing
// (single shared module instance — avoids dual-Cesium state bugs when testing).
if (typeof window !== 'undefined' && !window.__CESIUM__) window.__CESIUM__ = Cesium;
if (typeof window !== 'undefined' && !window.__CESIUM__)
window.__CESIUM__ = Cesium;
/**
* Annotation engine — the voice agent's "whiteboard" over the 3D world.
@@ -25,7 +33,13 @@ const COLORS = new Set(['primary', 'amber', 'cyan', 'green', 'red']);
// Entity FACTS the voice model may attach to an annotation (annotate_map.entityKind) —
// what kind of thing the target IS, routing the resolver (e.g. point_feature keeps a
// monument point-first). Unknown values are dropped, never guessed.
const ENTITY_KINDS = new Set(['building', 'compound', 'district', 'street', 'point_feature']);
const ENTITY_KINDS = new Set([
'building',
'compound',
'district',
'street',
'point_feature',
]);
const DEFAULT_TTL_MS = 22_000;
const FADE_MS = 1200;
// Hard ceiling on simultaneously-live marks. Protects against a runaway voice
@@ -51,11 +65,14 @@ const OUTLINE_UPGRADE_CONCURRENCY = 2;
* retrying immediately. A thrown resolver counts as definitive — the pre-retry
* catch→point behavior. Exported for tests.
*/
export async function resolveOutlineWithRetry(resolveOutline, {
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
} = {}) {
export async function resolveOutlineWithRetry(
resolveOutline,
{
delaysMs = OUTLINE_RETRY_DELAYS_MS,
isStale = () => false,
waitFn = wait,
} = {},
) {
let retriedRateLimit = false;
for (let attempt = 0; ; attempt += 1) {
let fp;
@@ -68,7 +85,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
// A throttle gets one deliberately spaced retry. If that retry is throttled
// too, stop this mark's outline task instead of replaying the batch storm.
if (retriedRateLimit || attempt >= delaysMs.length) return undefined;
const retryAfterMs = Number.isFinite(fp.retryAfterMs) ? fp.retryAfterMs : 0;
const retryAfterMs = Number.isFinite(fp.retryAfterMs)
? fp.retryAfterMs
: 0;
await waitFn(Math.max(retryAfterMs, delaysMs[attempt]));
if (isStale()) return undefined;
retriedRateLimit = true;
@@ -89,7 +108,9 @@ export async function resolveOutlineWithRetry(resolveOutline, {
* geometry is unknown. Exported for tests.
*/
export function normalizeTargetKey(target) {
const raw = String(target ?? '').trim().toLowerCase();
const raw = String(target ?? '')
.trim()
.toLowerCase();
if (!raw) return null;
const head = raw.split(',')[0].trim();
return head || raw;
@@ -123,12 +144,22 @@ export function createAnnotationEngine({
function emitOutlineEvent(evt) {
for (const listener of outlineListeners) {
try { listener(evt); } catch { /* a listener error must never break the upgrade */ }
try {
listener(evt);
} catch {
/* a listener error must never break the upgrade */
}
}
}
function abortPending() {
for (const c of activeControllers) { try { c.abort(); } catch { /* no-op */ } }
for (const c of activeControllers) {
try {
c.abort();
} catch {
/* no-op */
}
}
activeControllers.clear();
}
@@ -174,7 +205,9 @@ export function createAnnotationEngine({
function rollbackRendererState(anno) {
try {
renderer.remove(anno);
} catch { /* the renderer is already in a bad way; the original error wins */ }
} catch {
/* the renderer is already in a bad way; the original error wins */
}
}
function ensureTicking() {
@@ -226,7 +259,15 @@ export function createAnnotationEngine({
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
if (destroyed) return { ok: false, drawn: 0, failed: 0, ids: [], results: [], error: 'destroyed' };
if (destroyed)
return {
ok: false,
drawn: 0,
failed: 0,
ids: [],
results: [],
error: 'destroyed',
};
const list = Array.isArray(requests) ? requests : [requests];
if (opts.clearPrevious) clear(); // bumps generation + aborts older pending work
@@ -247,7 +288,11 @@ export function createAnnotationEngine({
// allSettled gives per-item error isolation (one failed item never aborts the batch); the
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
// order are all preserved exactly as the old serial loop had them.
const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true)));
const settled = await Promise.allSettled(
list.map((spec) =>
resolveSpec(spec, controller.signal, opts.flyTo === true),
),
);
try {
for (let i = 0; i < list.length; i += 1) {
@@ -270,8 +315,15 @@ export function createAnnotationEngine({
// stacked. De-dup runs BEFORE the cap so a re-narration still refreshes when full.
const dup = findDuplicate(anno);
if (dup) {
const labelChanged = String(dup.label || '').trim().toLowerCase() !== String(anno.label || '').trim().toLowerCase();
const colorChanged = (dup.color || 'primary') !== (anno.color || 'primary');
const labelChanged =
String(dup.label || '')
.trim()
.toLowerCase() !==
String(anno.label || '')
.trim()
.toLowerCase();
const colorChanged =
(dup.color || 'primary') !== (anno.color || 'primary');
if (labelChanged || colorChanged) {
// Same place + shape, but a NEW label and/or color ("Marina" → "Marina District",
// or a recolor) → replace in place so the latest caption/color wins (count
@@ -305,13 +357,26 @@ export function createAnnotationEngine({
ids.push(anno.id);
if (!firstAnchor) firstAnchor = anno;
if (typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
}
results.push({ ...okResult(anno, resolved, anno.id), duplicate: true });
results.push({
...okResult(anno, resolved, anno.id),
duplicate: true,
});
} else {
// Identical re-narration → refresh the existing mark's lifecycle in place (keep
// it visible whether persistent or TTL-based, and revive a fading-out one).
if (persist) { dup.ttlMs = null; } else { dup.createdAt = performance.now(); dup.ttlMs = anno.ttlMs; }
if (persist) {
dup.ttlMs = null;
} else {
dup.createdAt = performance.now();
dup.ttlMs = anno.ttlMs;
}
dup.expiring = false;
dup.fadeStart = null;
ids.push(dup.id);
@@ -319,11 +384,19 @@ export function createAnnotationEngine({
// A re-narration is also a RETRY: if the existing mark never got its outline
// (transient Overpass miss), attach this call's deferred resolver to it.
if (!dup.ring && typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(dup, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
dup,
resolved.resolveOutline,
myGen,
controller,
);
}
// Same result builder as the fresh path (full metadata) but the existing id —
// route fallback / approximate honesty fields survive a re-narration.
results.push({ ...okResult(anno, resolved, dup.id), duplicate: true });
results.push({
...okResult(anno, resolved, dup.id),
duplicate: true,
});
}
continue;
}
@@ -350,12 +423,23 @@ export function createAnnotationEngine({
if (!firstAnchor) firstAnchor = anno;
// Progressive: the point is on the board; trace the outline behind the narration.
if (typeof resolved.resolveOutline === 'function') {
startOutlineUpgrade(anno, resolved.resolveOutline, myGen, controller);
startOutlineUpgrade(
anno,
resolved.resolveOutline,
myGen,
controller,
);
}
results.push(okResult(anno, resolved, anno.id));
} catch (error) {
if (superseded()) break;
results.push(failResult(spec, error?.message || 'annotation failed', Array.isArray(error?.failedTargets) ? error.failedTargets : null));
results.push(
failResult(
spec,
error?.message || 'annotation failed',
Array.isArray(error?.failedTargets) ? error.failedTargets : null,
),
);
}
}
} finally {
@@ -365,7 +449,14 @@ export function createAnnotationEngine({
// A clear/new-topic landed mid-flight: drop everything this call produced and
// do not touch the renderer or camera — the board now belongs to a newer call.
if (superseded()) {
return { ok: false, drawn: 0, failed: results.length, ids: [], results, aborted: true };
return {
ok: false,
drawn: 0,
failed: results.length,
ids: [],
results,
aborted: true,
};
}
if (ids.length) {
@@ -393,7 +484,8 @@ export function createAnnotationEngine({
if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);
if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : [];
if (points.length < 2) throw new Error('a route needs at least 2 waypoints');
if (points.length < 2)
throw new Error('a route needs at least 2 waypoints');
const resolvedPts = [];
const failed = [];
for (const pt of points) {
@@ -425,7 +517,12 @@ export function createAnnotationEngine({
}
// Real street-following route (OSM/OSRM), mode-aware.
const mode = normalizeMode(spec.mode);
const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch);
const routed = await fetchRoute(
resolvedPts.map((p) => [p.lon, p.lat]),
mode,
signal,
placeSearch,
);
if (routed) {
return {
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
@@ -439,8 +536,16 @@ export function createAnnotationEngine({
// Routing unavailable: do NOT pass off straight segments as a real route. Draw
// a clearly-labeled direct line with great-circle distance and NO travel time.
let straight = 0;
for (let i = 1; i < resolvedPts.length; i += 1) straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
return { path: resolvedPts, distanceM: straight, durationS: null, mode, source: resolvedPts[0].source, fallback: true };
for (let i = 1; i < resolvedPts.length; i += 1)
straight += greatCircleM(resolvedPts[i - 1], resolvedPts[i]);
return {
path: resolvedPts,
distanceM: straight,
durationS: null,
mode,
source: resolvedPts[0].source,
fallback: true,
};
}
if (type === 'arrow') {
const from = await resolveTarget({
@@ -479,9 +584,15 @@ export function createAnnotationEngine({
err.failedTargets = failed;
throw err;
}
return { from, to, distanceM: greatCircleM(from, to), source: from.source };
return {
from,
to,
distanceM: greatCircleM(from, to),
source: from.source,
};
}
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
const wantFootprint =
type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
return resolveTarget({
placeSearch,
viewer,
@@ -491,7 +602,8 @@ export function createAnnotationEngine({
screenX: spec.screenX,
screenY: spec.screenY,
footprint: wantFootprint,
intent: spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
intent:
spec.intent === 'around_the_thing' ? 'around_the_thing' : 'the_thing',
entityKind: ENTITY_KINDS.has(spec?.entityKind) ? spec.entityKind : null,
// The label often carries the ask's true shape when the target omits it
// ("target: Texas State Capitol" + "label: Capitol grounds") — a resolver HINT only.
@@ -525,11 +637,15 @@ export function createAnnotationEngine({
}
function drainOutlineQueue() {
while (activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY && outlineQueue.length) {
while (
activeOutlineUpgrades < OUTLINE_UPGRADE_CONCURRENCY &&
outlineQueue.length
) {
const task = outlineQueue.shift();
const stale = task.myGen !== generation
|| task.controller.signal.aborted
|| !annotations.has(task.anno.id);
const stale =
task.myGen !== generation ||
task.controller.signal.aborted ||
!annotations.has(task.anno.id);
if (stale) {
if (task.anno._outlineTask === task) task.anno._outlineTask = null;
releaseController(task.controller);
@@ -549,7 +665,10 @@ export function createAnnotationEngine({
// proxy caches the late completion, so the retry is usually a warm cache hit.
fp = await resolveOutlineWithRetry(resolveOutline, {
delaysMs: outlineRetryDelaysMs,
isStale: () => myGen !== generation || controller.signal.aborted || !annotations.has(anno.id),
isStale: () =>
myGen !== generation ||
controller.signal.aborted ||
!annotations.has(anno.id),
});
if (myGen !== generation || controller.signal.aborted) return; // board superseded
if (!annotations.has(anno.id)) return; // mark replaced/removed while resolving
@@ -577,11 +696,22 @@ export function createAnnotationEngine({
// wins, exactly like the add-time replace path). Not an auto-clear — it removes
// a second representation of the same mark, never a distinct one.
for (const other of annotations.values()) {
if (other.id === anno.id || other.type !== 'area' || other.pendingOutline) continue;
if (!other.anchor || Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4
|| Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4) continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized)) continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null)) continue;
if (
other.id === anno.id ||
other.type !== 'area' ||
other.pendingOutline
)
continue;
if (
!other.anchor ||
Math.abs(other.anchor.lon - anno.anchor.lon) >= 5e-4 ||
Math.abs(other.anchor.lat - anno.anchor.lat) >= 5e-4
)
continue;
if (Boolean(other.synthesized) !== Boolean(anno.synthesized))
continue;
if ((other.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!ringsEqual(other.ring, anno.ring)) continue;
annotations.delete(other.id);
renderer.remove(other);
@@ -629,7 +759,12 @@ export function createAnnotationEngine({
// Route-specific signal so the voice layer can be honest about an OSRM outage:
// fallback=true means a straight direct line, not a real route.
...(anno.type === 'route'
? { fallback: Boolean(anno.fallback), mode: anno.mode, distanceM: anno.distanceM, durationS: anno.durationS }
? {
fallback: Boolean(anno.fallback),
mode: anno.mode,
distanceM: anno.distanceM,
durationS: anno.durationS,
}
: {}),
};
}
@@ -643,13 +778,18 @@ export function createAnnotationEngine({
* degenerate geocode stacks on one point (several Capitol monuments) would erase each other. */
function findDuplicate(anno) {
if (!anno.anchor) return null;
const near = (a, b) => a && b && Math.abs(a.lon - b.lon) < 5e-4 && Math.abs(a.lat - b.lat) < 5e-4;
const near = (a, b) =>
a &&
b &&
Math.abs(a.lon - b.lon) < 5e-4 &&
Math.abs(a.lat - b.lat) < 5e-4;
// FULL per-vertex geometry comparison (the live cap is only 120 and this runs solely for
// candidates that already matched type+label+anchor, so it's cheap) — so two DIFFERENT
// same-length rings/paths at the same anchor are never falsely merged.
const ringSame = ringsEqual;
const pathSame = (a, b) => {
const la = a ? a.length : 0; const lb = b ? b.length : 0;
const la = a ? a.length : 0;
const lb = b ? b.length : 0;
if (la !== lb) return false;
for (let i = 0; i < la; i++) if (!near(a[i], b[i])) return false;
return true;
@@ -674,10 +814,12 @@ export function createAnnotationEngine({
// the identity; the target is only the stand-in while geometry is unknown.
if (!anno.targetKey || !ex.targetKey) continue;
if (anno.targetKey !== ex.targetKey) continue;
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing')) continue;
if ((ex.intentKey || 'thing') !== (anno.intentKey || 'thing'))
continue;
} else {
if (Boolean(ex.synthesized) !== Boolean(anno.synthesized)) continue;
if ((ex.footprintKind || null) !== (anno.footprintKind || null)) continue;
if ((ex.footprintKind || null) !== (anno.footprintKind || null))
continue;
if (!ringSame(ex.ring, anno.ring)) continue;
}
} else if (anno.type === 'arrow') {
@@ -691,7 +833,13 @@ export function createAnnotationEngine({
// point (e.g. several Texas Capitol monuments all geocoding to the dome centroid) — NOT a
// duplicate. Only collapse a genuine re-narration of the SAME point (same label); distinct
// labels stay as separate marks (their callouts de-collide in the renderer).
const sameLabel = String(ex.label || '').trim().toLowerCase() === String(anno.label || '').trim().toLowerCase();
const sameLabel =
String(ex.label || '')
.trim()
.toLowerCase() ===
String(anno.label || '')
.trim()
.toLowerCase();
if (!sameLabel) continue;
}
return ex;
@@ -703,7 +851,11 @@ export function createAnnotationEngine({
const type = normalizeType(spec?.type);
const id = `anno-${++_seq}`;
const color = COLORS.has(spec?.color) ? spec.color : 'primary';
const label = cleanLabel(spec?.label) || resolved?.label || resolved?.from?.label || null;
const label =
cleanLabel(spec?.label) ||
resolved?.label ||
resolved?.from?.label ||
null;
const now = performance.now();
const base = {
@@ -712,17 +864,27 @@ export function createAnnotationEngine({
color,
label,
createdAt: now,
ttlMs: persist ? null : (Number(spec?.ttlMs) || DEFAULT_TTL_MS),
ttlMs: persist ? null : Number(spec?.ttlMs) || DEFAULT_TTL_MS,
alpha: 0, // animate in
bornAt: now,
expiring: false,
};
if (type === 'route') {
const path = resolved.path.map((p) => ({ lon: p.lon, lat: p.lat, height: p.height }));
const path = resolved.path.map((p) => ({
lon: p.lon,
lat: p.lat,
height: p.height,
}));
return {
...base,
label: composeRouteLabel(base.label, resolved.distanceM, resolved.durationS, resolved.mode, resolved.fallback),
label: composeRouteLabel(
base.label,
resolved.distanceM,
resolved.durationS,
resolved.mode,
resolved.fallback,
),
anchor: path[0],
to: null,
path,
@@ -738,8 +900,16 @@ export function createAnnotationEngine({
return {
...base,
label: appendDistance(base.label, resolved.distanceM),
anchor: { lon: resolved.from.lon, lat: resolved.from.lat, height: resolved.from.height },
to: { lon: resolved.to.lon, lat: resolved.to.lat, height: resolved.to.height },
anchor: {
lon: resolved.from.lon,
lat: resolved.from.lat,
height: resolved.from.height,
},
to: {
lon: resolved.to.lon,
lat: resolved.to.lat,
height: resolved.to.height,
},
ring: null,
};
}
@@ -814,13 +984,17 @@ export function createAnnotationEngine({
if (m.to) points.push(m.to);
}
if (!points.length || points.some((p) => isPointOnScreen(p))) return;
console.log(`[Annotations] auto-framing ${marks.length} off-screen mark(s)`);
console.log(
`[Annotations] auto-framing ${marks.length} off-screen mark(s)`,
);
assistFlightUntil = performance.now() + 2600; // ~flight duration + settle
if (marks.length === 1) {
frameAnnotation(marks[0]);
return;
}
const cart = points.map((p) => Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0));
const cart = points.map((p) =>
Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
);
const sphere = Cesium.BoundingSphere.fromPoints(cart);
viewer.camera.flyToBoundingSphere(sphere, {
offset: new Cesium.HeadingPitchRange(
@@ -841,9 +1015,20 @@ export function createAnnotationEngine({
try {
const camera = viewer.camera;
const pos = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0);
const cv = camera.frustum.computeCullingVolume(camera.position, camera.direction, camera.up);
if (cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === Cesium.Intersect.OUTSIDE) return false;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera.position);
const cv = camera.frustum.computeCullingVolume(
camera.position,
camera.direction,
camera.up,
);
if (
cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) ===
Cesium.Intersect.OUTSIDE
)
return false;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.position,
);
return occluder.isPointVisible(pos);
} catch {
return true;
@@ -853,15 +1038,24 @@ export function createAnnotationEngine({
function frameAnnotation(anno) {
try {
const target = anno.to
? { lon: (anno.anchor.lon + anno.to.lon) / 2, lat: (anno.anchor.lat + anno.to.lat) / 2 }
? {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
}
: anno.anchor;
// No ring yet (progressive outline still resolving) → size the flight from the
// Places viewport box when we have one, so a big compound isn't framed at
// building scale while its outline is traced. Never re-fly when the ring lands.
const range = anno.ring ? ringRange(anno.ring) : (viewportRange(anno.viewport) || 600);
const range = anno.ring
? ringRange(anno.ring)
: viewportRange(anno.viewport) || 600;
viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(target.lon, target.lat, anno.anchor.height || 0),
Cesium.Cartesian3.fromDegrees(
target.lon,
target.lat,
anno.anchor.height || 0,
),
range,
),
{
@@ -884,7 +1078,9 @@ export function createAnnotationEngine({
destroy() {
if (destroyed) return;
destroyed = true;
try { clear(); } finally {
try {
clear();
} finally {
if (tickHandle != null) cancelAnimationFrame(tickHandle);
tickHandle = null;
outlineListeners.clear();
@@ -912,12 +1108,36 @@ export function createAnnotationEngine({
* Lays down a small San Francisco "tour" the way the voice agent would.
*/
async demo() {
return annotate([
{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' },
{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio (former Army base)', color: 'green', footprint: true },
{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'cyan' },
{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', label: 'next to the Marina' },
], { flyTo: true, clearPrevious: true, persist: true });
return annotate(
[
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
label: 'Palace of Fine Arts',
color: 'amber',
},
{
type: 'area',
target: 'Presidio of San Francisco',
label: 'The Presidio (former Army base)',
color: 'green',
footprint: true,
},
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
label: 'ILM / Lucasfilm',
color: 'cyan',
},
{
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina District, San Francisco',
label: 'next to the Marina',
},
],
{ flyTo: true, clearPrevious: true, persist: true },
);
},
/**
@@ -927,34 +1147,107 @@ export function createAnnotationEngine({
*/
async tour() {
clear();
flyTo({ lon: -122.4486, lat: 37.7960, height: 520, heading: 0, pitch: -26, duration: 3 });
flyTo({
lon: -122.4486,
lat: 37.796,
height: 520,
heading: 0,
pitch: -26,
duration: 3,
});
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'highlight', target: 'Palace of Fine Arts, San Francisco', label: 'Palace of Fine Arts', color: 'amber' }], { persist: true });
await annotate(
[
{
type: 'highlight',
target: 'Palace of Fine Arts, San Francisco',
label: 'Palace of Fine Arts',
color: 'amber',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', label: 'next to the Marina', color: 'cyan' }], { persist: true });
await annotate(
[
{
type: 'arrow',
target: 'Palace of Fine Arts, San Francisco',
toTarget: 'Marina Green, San Francisco',
label: 'next to the Marina',
color: 'cyan',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
flyTo({ lon: -122.4545, lat: 37.7880, height: 1500, heading: 18, pitch: -32, duration: 3 });
flyTo({
lon: -122.4545,
lat: 37.788,
height: 1500,
heading: 18,
pitch: -32,
duration: 3,
});
if (destroyed) return { ok: false };
await wait(3200);
await annotate([{ type: 'area', target: 'Presidio of San Francisco', label: 'The Presidio — a former Army base', color: 'green', footprint: true }], { persist: true });
await annotate(
[
{
type: 'area',
target: 'Presidio of San Francisco',
label: 'The Presidio — a former Army base',
color: 'green',
footprint: true,
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2800);
await annotate([{ type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', label: 'ILM / Lucasfilm', color: 'red' }], { persist: true });
await annotate(
[
{
type: 'pin',
target: 'Letterman Digital Arts Center, San Francisco',
label: 'ILM / Lucasfilm',
color: 'red',
},
],
{ persist: true },
);
if (destroyed) return { ok: false };
await wait(2600);
await annotate([{ type: 'route', color: 'amber', label: 'Crissy Field shoreline', points: [
{ target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' },
] }], { persist: true });
await annotate(
[
{
type: 'route',
color: 'amber',
label: 'Crissy Field shoreline',
points: [
{ target: 'Palace of Fine Arts, San Francisco' },
{ target: 'Crissy Field, San Francisco' },
{ target: 'Fort Point, San Francisco' },
],
},
],
{ persist: true },
);
return { ok: true, steps: 6 };
},
};
function flyTo({ lon, lat, height, heading = 0, pitch = -30, duration = 2.5 }) {
function flyTo({
lon,
lat,
height,
heading = 0,
pitch = -30,
duration = 2.5,
}) {
if (destroyed) return;
try {
viewer.camera.flyTo({
@@ -984,7 +1277,11 @@ function ringsEqual(a, b) {
const lb = b ? b.length : 0;
if (la !== lb) return false;
for (let i = 0; i < la; i++) {
if (Math.abs(a[i][0] - b[i][0]) >= 1e-5 || Math.abs(a[i][1] - b[i][1]) >= 1e-5) return false;
if (
Math.abs(a[i][0] - b[i][0]) >= 1e-5 ||
Math.abs(a[i][1] - b[i][1]) >= 1e-5
)
return false;
}
return true;
}
@@ -1010,7 +1307,8 @@ function specTargets(spec) {
* arrow endpoint) when known; otherwise it derives them from the spec.
*/
function failResult(spec, error, failedTargets) {
const targets = (failedTargets && failedTargets.length) ? failedTargets : specTargets(spec);
const targets =
failedTargets && failedTargets.length ? failedTargets : specTargets(spec);
return {
ok: false,
label: spec?.label || spec?.target || targets[0] || null,
@@ -1022,7 +1320,8 @@ function failResult(spec, error, failedTargets) {
function normalizeType(type) {
const t = String(type || '').toLowerCase();
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound') return 'area';
if (t === 'area' || t === 'polygon' || t === 'outline' || t === 'compound')
return 'area';
if (t === 'route' || t === 'path' || t === 'line') return 'route';
if (t === 'arrow' || t === 'vector' || t === 'connector') return 'arrow';
if (t === 'label' || t === 'callout' || t === 'text') return 'label';
@@ -1036,9 +1335,10 @@ function computeAlpha(anno, now) {
if (anno.ttlMs == null && !anno.expiring) return fadeIn;
const fadeStart = anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
const fadeStart =
anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
if (now < fadeStart) return fadeIn;
const out = 1 - (now - fadeStart) / FADE_MS;
if (out <= 0) anno.expiring = true;
@@ -1052,7 +1352,10 @@ function pendingAnimation(anno, now) {
if (now - anno.bornAt < 260) return true; // fading in
if (anno.expiring) return true; // fading out
if (anno.ttlMs != null) {
const fadeStart = anno.fadeStart != null ? anno.fadeStart : anno.createdAt + (anno.ttlMs || 0);
const fadeStart =
anno.fadeStart != null
? anno.fadeStart
: anno.createdAt + (anno.ttlMs || 0);
return now < fadeStart + FADE_MS; // TTL fade still ahead or in progress
}
return false; // persistent + faded in → stable
@@ -1110,16 +1413,30 @@ function round5(n) {
/** A spec whose geometry was supplied by hand (drawTool.js) rather than by a name. */
function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false;
if (type === 'route') return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'route')
return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
return Number.isFinite(Number(spec.latitude)) && Number.isFinite(Number(spec.longitude));
return (
Number.isFinite(Number(spec.latitude)) &&
Number.isFinite(Number(spec.longitude))
);
}
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) {
return (Array.isArray(list) ? list : [])
.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)]))
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180);
.map((p) =>
Array.isArray(p)
? [Number(p[0]), Number(p[1])]
: [Number(p?.lon ?? p?.longitude), Number(p?.lat ?? p?.latitude)],
)
.filter(
([lon, lat]) =>
Number.isFinite(lon) &&
Number.isFinite(lat) &&
Math.abs(lat) <= 90 &&
Math.abs(lon) <= 180,
);
}
/**
@@ -1132,15 +1449,28 @@ function resolveManualSpec(spec, type, viewer) {
// height 0 is a placeholder, not a placement: the world renderer draws a
// route with clampToGround + CESIUM_3D_TILE classification, so the line is
// draped onto the photoreal surface whatever this number says.
const pts = manualPairs(spec.path).map(([lon, lat]) => ({ lon, lat, height: 0 }));
const pts = manualPairs(spec.path).map(([lon, lat]) => ({
lon,
lat,
height: 0,
}));
if (pts.length < 2) throw new Error('a drawn line needs at least 2 points');
let distanceM = 0;
for (let i = 1; i < pts.length; i += 1) distanceM += greatCircleM(pts[i - 1], pts[i]);
return { path: pts, distanceM, durationS: null, mode: 'manual', source: 'manual', fallback: false };
for (let i = 1; i < pts.length; i += 1)
distanceM += greatCircleM(pts[i - 1], pts[i]);
return {
path: pts,
distanceM,
durationS: null,
mode: 'manual',
source: 'manual',
fallback: false,
};
}
if (type === 'area') {
const ring = manualPairs(spec.ring);
if (ring.length < 3) throw new Error('a drawn area needs at least 3 points');
if (ring.length < 3)
throw new Error('a drawn area needs at least 3 points');
// Averaged through ringCentroid, which unwraps longitudes first: a ring
// straddling the antimeridian has coordinates like [179.999, -179.999], and
// a raw mean of those puts the anchor on the Greenwich meridian, half a
@@ -1151,13 +1481,25 @@ function resolveManualSpec(spec, type, viewer) {
const lon = centre.lon;
const lat = centre.lat;
return {
lon, lat, height: sampleGroundHeight(viewer, lon, lat),
ring, footprintKind: 'area', buildingHeight: null, synthesized: false, source: 'manual',
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring,
footprintKind: 'area',
buildingHeight: null,
synthesized: false,
source: 'manual',
};
}
const lon = Number(spec.longitude);
const lat = Number(spec.latitude);
return { lon, lat, height: sampleGroundHeight(viewer, lon, lat), ring: null, source: 'manual' };
return {
lon,
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring: null,
source: 'manual',
};
}
/** The ring's distinct positions: a closing vertex that repeats the first is dropped. */
@@ -1165,20 +1507,26 @@ function closedRingWithoutRepeat(ring) {
if (ring.length < 2) return ring;
const [firstLon, firstLat] = ring[0];
const [lastLon, lastLat] = ring[ring.length - 1];
return firstLon === lastLon && firstLat === lastLat ? ring.slice(0, -1) : ring;
return firstLon === lastLon && firstLat === lastLat
? ring.slice(0, -1)
: ring;
}
function normalizeMode(m) {
const t = String(m || '').toLowerCase();
if (t === 'car' || t === 'drive' || t === 'driving') return 'car';
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle') return 'bike';
if (t === 'bike' || t === 'cycle' || t === 'cycling' || t === 'bicycle')
return 'bike';
return 'foot';
}
/** Route failure remains an explicitly labelled direct line in the caller. */
async function fetchRoute(coordPairs, mode, signal, service) {
try { return await service.route?.(coordPairs, mode, { signal }) || null; }
catch { return null; }
try {
return (await service.route?.(coordPairs, mode, { signal })) || null;
} catch {
return null;
}
}
function greatCircleM(a, b) {
@@ -1188,7 +1536,9 @@ function greatCircleM(a, b) {
const dLon = toRad(b.lon - a.lon);
const la1 = toRad(a.lat);
const la2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
+15 -2
View File
@@ -1,4 +1,17 @@
import { createAnnotationResolver } from './resolver.js';
import { applicationServices } from '../services/application.js';
const resolver = createAnnotationResolver({ boundarySource: applicationServices.boundaries });
export const { resolveAnnotationTarget, refineScope, isRateLimitedOutcome, isGroundsLikeAsk, selectFootprint, viewportBias, placesNearViewRecovery, resolveRegionRingForQuery, pickWorldFromScreen, sampleGroundHeight } = resolver;
const resolver = createAnnotationResolver({
boundarySource: applicationServices.boundaries,
});
export const {
resolveAnnotationTarget,
refineScope,
isRateLimitedOutcome,
isGroundsLikeAsk,
selectFootprint,
viewportBias,
placesNearViewRecovery,
resolveRegionRingForQuery,
pickWorldFromScreen,
sampleGroundHeight,
} = resolver;
+10 -2
View File
@@ -42,7 +42,11 @@ export function createHybridAnnotationRenderer(viewer) {
entry.screenProxy = liveProxy(anno, { type: 'label', ring: null });
screen.add(entry.screenProxy);
}
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Drape the path on the tiles (world), caption it with a screen callout
// at the path midpoint.
entry.worldProxy = liveProxy(anno, { label: null });
@@ -86,7 +90,11 @@ export function createHybridAnnotationRenderer(viewer) {
} else if (anno.label) {
screen.add(screenProxy);
}
entry.screenProxy = entry.screenProxy ? screenProxy : (anno.label ? screenProxy : null);
entry.screenProxy = entry.screenProxy
? screenProxy
: anno.label
? screenProxy
: null;
}
/**
+17 -3
View File
@@ -10,13 +10,27 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches.
*/
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
export function initAnnotations({
viewer,
tileset = null,
placeSearch,
resolver,
}) {
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ }
try {
tileset.enableCollision = true;
} catch {
/* older tileset */
}
}
const renderer = createHybridAnnotationRenderer(viewer);
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
const engine = createAnnotationEngine({
viewer,
renderer,
placeSearch,
resolveTarget: resolver?.resolveAnnotationTarget,
});
window.__gevAnnotations = engine;
return engine;
}
+254 -71
View File
@@ -37,9 +37,9 @@ const PALETTE = {
// Altitude-scaled marker geometry. Zoomed in (≤ NEAR_H) reticles are full-size; pulling
// back shrinks them toward small recon dots (MIN floor) so many world-anchored marks no
// longer collapse into an overlapping blob when their anchors project close together.
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const MARK_SCALE_NEAR_H = 800; // m: at/below this, full-size reticles
const MARK_SCALE_FAR_H = 6000; // m: at/above this, minimum size
const MARK_SCALE_MIN = 0.32; // floor so dots never vanish
const RING_OUTER_R = 34;
const RING_INNER_R = 18;
const DOT_R = 5;
@@ -51,16 +51,22 @@ function markScale(h) {
return 1 - t * (1 - MARK_SCALE_MIN);
}
export function createScreenAnnotationRenderer(viewer, {
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {}) {
export function createScreenAnnotationRenderer(
viewer,
{
overlayPaintRect = getOverlayPaintRect,
activeTrackedReadoutId = getActiveTrackedReadoutId,
} = {},
) {
injectStyles();
const { layer, svg, defs } = buildOverlay();
document.body.appendChild(layer);
const scene = viewer.scene;
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, scene.camera.positionWC);
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
scene.camera.positionWC,
);
const records = new Map(); // anno.id -> { anno, group, parts }
const scratch = new Cesium.Cartesian2();
const scratchDir = new Cesium.Cartesian3();
@@ -112,8 +118,13 @@ export function createScreenAnnotationRenderer(viewer, {
return cached.h;
}
try {
if (scene.clampToHeightSupported && typeof scene.clampToHeight === 'function') {
const c = scene.clampToHeight(Cesium.Cartesian3.fromDegrees(lon, lat, 0));
if (
scene.clampToHeightSupported &&
typeof scene.clampToHeight === 'function'
) {
const c = scene.clampToHeight(
Cesium.Cartesian3.fromDegrees(lon, lat, 0),
);
if (c) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) {
@@ -123,7 +134,9 @@ export function createScreenAnnotationRenderer(viewer, {
}
}
}
} catch { /* tiles not ready */ }
} catch {
/* tiles not ready */
}
const fallback = cached ? cached.h : 0;
heightCache.set(key, { h: fallback, settled: false, gen: projGen });
trimHeightCache();
@@ -140,7 +153,8 @@ export function createScreenAnnotationRenderer(viewer, {
// "draw-on" gesture, so they never masquerade as an authoritative boundary.
parts.poly = svgEl('polygon', {
class: anno.synthesized ? 'gev-anno-area' : 'gev-anno-area gev-draw',
fill: c, stroke: c,
fill: c,
stroke: c,
'fill-opacity': anno.synthesized ? '0.08' : '0.14',
'stroke-width': '2.5',
...(anno.synthesized ? { 'stroke-dasharray': '9 7' } : {}),
@@ -151,25 +165,41 @@ export function createScreenAnnotationRenderer(viewer, {
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'arrow' && anno.to) {
parts.path = svgEl('path', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.path);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// Multi-waypoint path: a drawn-on polyline with a dot at each waypoint.
parts.poly = svgEl('polyline', {
class: 'gev-anno-arrow gev-draw', fill: 'none', stroke: c,
'stroke-width': '3', 'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
class: 'gev-anno-arrow gev-draw',
fill: 'none',
stroke: c,
'stroke-width': '3',
'marker-end': `url(#gev-arrow-${anno.color || 'primary'})`,
filter: 'url(#gev-sketch)',
});
ensureArrowMarker(defs, anno.color || 'primary', c);
group.appendChild(parts.poly);
parts.dots = anno.path.map(() => {
const dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: '4' });
const dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: '4',
});
group.appendChild(dot);
return dot;
});
@@ -178,14 +208,39 @@ export function createScreenAnnotationRenderer(viewer, {
} else {
// pin / highlight / label — pulsing target rings + a marker dot + callout
if (anno.type !== 'label') {
parts.ringOuter = svgEl('circle', { class: 'gev-anno-ring gev-pulse', fill: 'none', stroke: c, 'stroke-width': '2', r: '34' });
parts.ringInner = svgEl('circle', { class: 'gev-anno-ring', fill: c, 'fill-opacity': '0.12', stroke: c, 'stroke-width': '2.5', r: '18', filter: 'url(#gev-sketch)' });
parts.ringOuter = svgEl('circle', {
class: 'gev-anno-ring gev-pulse',
fill: 'none',
stroke: c,
'stroke-width': '2',
r: '34',
});
parts.ringInner = svgEl('circle', {
class: 'gev-anno-ring',
fill: c,
'fill-opacity': '0.12',
stroke: c,
'stroke-width': '2.5',
r: '18',
filter: 'url(#gev-sketch)',
});
group.appendChild(parts.ringOuter);
group.appendChild(parts.ringInner);
}
parts.dot = svgEl('circle', { class: 'gev-anno-dot', fill: c, stroke: '#06121c', 'stroke-width': '2', r: anno.type === 'label' ? '4' : '5' });
parts.dot = svgEl('circle', {
class: 'gev-anno-dot',
fill: c,
stroke: '#06121c',
'stroke-width': '2',
r: anno.type === 'label' ? '4' : '5',
});
group.appendChild(parts.dot);
parts.leader = svgEl('line', { class: 'gev-anno-leader', stroke: c, 'stroke-width': '1.5', 'stroke-opacity': '0.7' });
parts.leader = svgEl('line', {
class: 'gev-anno-leader',
stroke: c,
'stroke-width': '1.5',
'stroke-opacity': '0.7',
});
group.appendChild(parts.leader);
parts.label = makeCallout(anno.label, c);
if (parts.label) group.appendChild(parts.label.node);
@@ -209,18 +264,26 @@ export function createScreenAnnotationRenderer(viewer, {
// any length. Guarded on the specific property so an unrelated transition
// (none here, but defensive) doesn't clear it early.
for (const drawEl of group.querySelectorAll('.gev-draw')) {
drawEl.addEventListener('transitionend', (e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
}, { once: true });
drawEl.addEventListener(
'transitionend',
(e) => {
if (e.propertyName === 'stroke-dashoffset') {
drawEl.style.strokeDasharray = 'none';
}
},
{ once: true },
);
}
projectAll();
} catch (error) {
// Hard, immediate unwind — a mark that never finished has nothing to
// fade out, and the caller (engine rollback) must find a clean board.
records.delete(anno.id);
try { group.remove(); } catch { /* never inserted */ }
try {
group.remove();
} catch {
/* never inserted */
}
if (records.size === 0) heightCache.clear();
throw error;
}
@@ -269,13 +332,22 @@ export function createScreenAnnotationRenderer(viewer, {
// Anchor at the real surface height (cached + validated), so marks sit on
// the ground/building instead of at sea level. The settle-once cache keeps
// it stable (no per-frame jitter that earlier broke nearby projections).
const world = Cesium.Cartesian3.fromDegrees(lon, lat, groundHeight(lon, lat));
const world = Cesium.Cartesian3.fromDegrees(
lon,
lat,
groundHeight(lon, lat),
);
// Reject points behind the camera (they produce wrapped/extreme coords).
Cesium.Cartesian3.subtract(world, scene.camera.positionWC, scratchDir);
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0) return null;
if (Cesium.Cartesian3.dot(scratchDir, scene.camera.directionWC) <= 0)
return null;
// Reject points beyond the globe horizon.
if (!occluder.isPointVisible(world)) return null;
const win = Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, scratch);
const win = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
scratch,
);
if (!win || !Number.isFinite(win.x) || !Number.isFinite(win.y)) return null;
// Reject absurd off-screen projections (anchor not in view).
const w = scene.canvas.clientWidth || scene.canvas.width;
@@ -291,9 +363,9 @@ export function createScreenAnnotationRenderer(viewer, {
// tracked subject's screen FOOTPRINT is faded. The complete card footprint comes
// from the host's ACTUAL painted rectangle after final layout; the billboard extent
// is unioned for aircraft/satellites that also own a native tracked graphic.
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const TRACKED_BBOX_MARGIN = 8; // px buffer added around the footprint
const TRACKED_FADE_EASE = 0.22; // per-frame ease toward hidden(0) / visible(1)
const TRACKED_HYSTERESIS_PX = 18; // dead-band so the overlap test can't flip-flop
const _scratchTrackedWin = new Cesium.Cartesian2();
// Evaluate a NearFarScalar (billboard scaleByDistance) at a camera distance.
@@ -317,11 +389,16 @@ export function createScreenAnnotationRenderer(viewer, {
const ent = viewer.trackedEntity;
const now = Cesium.JulianDate.now();
const world = typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const world =
typeof ent?.gevDisplayPosition === 'function'
? ent.gevDisplayPosition()
: null;
const win = world
? Cesium.SceneTransforms.worldToWindowCoordinates(scene, world, _scratchTrackedWin)
? Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
world,
_scratchTrackedWin,
)
: null;
// Billboard box — centered on the anchor, magnified by scaleByDistance.
@@ -331,8 +408,13 @@ export function createScreenAnnotationRenderer(viewer, {
const baseH = bb.height?.getValue?.(now) ?? 28;
let scale = 1;
const sbd = bb.scaleByDistance?.getValue?.(now);
if (sbd) scale = nearFarValue(sbd, Cesium.Cartesian3.distance(scene.camera.positionWC, world));
const hw = (baseW * scale) / 2; const hh = (baseH * scale) / 2;
if (sbd)
scale = nearFarValue(
sbd,
Cesium.Cartesian3.distance(scene.camera.positionWC, world),
);
const hw = (baseW * scale) / 2;
const hh = (baseH * scale) / 2;
const bbLeft = win.x - hw;
const bbRight = win.x + hw;
const bbTop = win.y - hh;
@@ -345,7 +427,12 @@ export function createScreenAnnotationRenderer(viewer, {
if (![left, right, top, bottom].every(Number.isFinite)) return null;
const m = TRACKED_BBOX_MARGIN;
return { left: left - m, right: right + m, top: top - m, bottom: bottom + m };
return {
left: left - m,
right: right + m,
top: top - m,
bottom: bottom + m,
};
}
function projectAll() {
@@ -357,7 +444,9 @@ export function createScreenAnnotationRenderer(viewer, {
const w = scene.canvas.clientWidth || scene.canvas.width;
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
// Shrink point-marker geometry as the camera pulls back (declutter the blob).
const mScale = markScale(viewer.camera.positionCartographic?.height ?? 1000);
const mScale = markScale(
viewer.camera.positionCartographic?.height ?? 1000,
);
for (const rec of records.values()) {
const { anno, group, parts } = rec;
@@ -369,17 +458,22 @@ export function createScreenAnnotationRenderer(viewer, {
if (anno.type === 'area' && parts.poly) {
const pts = [];
let cx = 0; let cy = 0; let n = 0;
let cx = 0;
let cy = 0;
let n = 0;
for (const [lon, lat] of anno.ring) {
const p = project(lon, lat);
if (!p) continue;
pts.push(`${p.x.toFixed(1)},${p.y.toFixed(1)}`);
cx += p.x; cy += p.y; n++;
cx += p.x;
cy += p.y;
n++;
}
if (pts.length >= 3) {
parts.poly.setAttribute('points', pts.join(' '));
group.style.display = '';
if (parts.label) positionCallout(parts.label, cx / n, cy / n - 6, true);
if (parts.label)
positionCallout(parts.label, cx / n, cy / n - 6, true);
} else {
group.style.display = 'none';
}
@@ -388,16 +482,27 @@ export function createScreenAnnotationRenderer(viewer, {
const b = project(anno.to.lon, anno.to.lat);
if (a && b) {
// gentle arc so the connector reads as a drawn gesture, not a ruler line
const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2;
const dx = b.x - a.x; const dy = b.y - a.y;
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy) || 1;
const bow = Math.min(60, len * 0.18);
const ctrlX = mx - (dy / len) * bow;
const ctrlY = my + (dx / len) * bow;
parts.path.setAttribute('d', `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`);
parts.path.setAttribute(
'd',
`M ${a.x.toFixed(1)} ${a.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${b.x.toFixed(1)} ${b.y.toFixed(1)}`,
);
group.style.display = '';
// Anchor the label to the (stable) endpoint midpoint, lifted toward the bow.
if (parts.label) positionCallout(parts.label, mx + (ctrlX - mx) * 0.5, my + (ctrlY - my) * 0.5 - 8, true);
if (parts.label)
positionCallout(
parts.label,
mx + (ctrlX - mx) * 0.5,
my + (ctrlY - my) * 0.5 - 8,
true,
);
} else {
group.style.display = 'none';
}
@@ -412,13 +517,22 @@ export function createScreenAnnotationRenderer(viewer, {
// dots at each resolvable waypoint; hide the rest
parts.dots.forEach((dot, i) => {
const p = projected[i];
if (p) { dot.setAttribute('cx', p.x.toFixed(1)); dot.setAttribute('cy', p.y.toFixed(1)); dot.style.display = ''; }
else { dot.style.display = 'none'; }
if (p) {
dot.setAttribute('cx', p.x.toFixed(1));
dot.setAttribute('cy', p.y.toFixed(1));
dot.style.display = '';
} else {
dot.style.display = 'none';
}
});
group.style.display = '';
// label at the middle waypoint
const mid = projected.filter(Boolean)[Math.floor(projected.filter(Boolean).length / 2)];
if (parts.label && mid) positionCallout(parts.label, mid.x, mid.y - 12, true);
const mid =
projected.filter(Boolean)[
Math.floor(projected.filter(Boolean).length / 2)
];
if (parts.label && mid)
positionCallout(parts.label, mid.x, mid.y - 12, true);
} else {
group.style.display = 'none';
}
@@ -427,16 +541,40 @@ export function createScreenAnnotationRenderer(viewer, {
if (p) {
group.style.display = '';
const baseDotR = anno.type === 'label' ? LABEL_DOT_R : DOT_R;
if (parts.dot) { parts.dot.setAttribute('cx', p.x.toFixed(1)); parts.dot.setAttribute('cy', p.y.toFixed(1)); parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1)); }
if (parts.ringOuter) { parts.ringOuter.setAttribute('cx', p.x.toFixed(1)); parts.ringOuter.setAttribute('cy', p.y.toFixed(1)); parts.ringOuter.setAttribute('r', (RING_OUTER_R * mScale).toFixed(1)); }
if (parts.ringInner) { parts.ringInner.setAttribute('cx', p.x.toFixed(1)); parts.ringInner.setAttribute('cy', p.y.toFixed(1)); parts.ringInner.setAttribute('r', (RING_INNER_R * mScale).toFixed(1)); }
if (parts.dot) {
parts.dot.setAttribute('cx', p.x.toFixed(1));
parts.dot.setAttribute('cy', p.y.toFixed(1));
parts.dot.setAttribute('r', (baseDotR * mScale).toFixed(1));
}
if (parts.ringOuter) {
parts.ringOuter.setAttribute('cx', p.x.toFixed(1));
parts.ringOuter.setAttribute('cy', p.y.toFixed(1));
parts.ringOuter.setAttribute(
'r',
(RING_OUTER_R * mScale).toFixed(1),
);
}
if (parts.ringInner) {
parts.ringInner.setAttribute('cx', p.x.toFixed(1));
parts.ringInner.setAttribute('cy', p.y.toFixed(1));
parts.ringInner.setAttribute(
'r',
(RING_INNER_R * mScale).toFixed(1),
);
}
if (parts.label) {
// Keep the callout tucked near the (now smaller) reticle by scaling its offset.
const lx = p.x + 18 * mScale; const ly = p.y - 34 * mScale;
const lx = p.x + 18 * mScale;
const ly = p.y - 34 * mScale;
positionCallout(parts.label, lx, ly, false);
if (parts.leader) {
parts.leader.setAttribute('x1', p.x.toFixed(1)); parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1)); parts.leader.setAttribute('y2', (ly + parts.label.height).toFixed(1));
parts.leader.setAttribute('x1', p.x.toFixed(1));
parts.leader.setAttribute('y1', p.y.toFixed(1));
parts.leader.setAttribute('x2', lx.toFixed(1));
parts.leader.setAttribute(
'y2',
(ly + parts.label.height).toFixed(1),
);
parts.label._leader = parts.leader; // so de-collision can re-point it
}
}
@@ -450,7 +588,12 @@ export function createScreenAnnotationRenderer(viewer, {
const placed = [];
for (const rec of records.values()) {
const cal = rec.parts.label;
if (cal && cal.sized && rec.group.style.display !== 'none' && Number.isFinite(cal._x)) {
if (
cal &&
cal.sized &&
rec.group.style.display !== 'none' &&
Number.isFinite(cal._x)
) {
placed.push(cal);
}
}
@@ -471,7 +614,11 @@ export function createScreenAnnotationRenderer(viewer, {
let overlapping = false;
if (trackedRect) {
let bb = null;
try { bb = group.getBBox(); } catch { bb = null; }
try {
bb = group.getBBox();
} catch {
bb = null;
}
if (bb && (bb.width !== 0 || bb.height !== 0)) {
// HYSTERESIS: a mark that's already hidden must clear the rect by
// TRACKED_HYSTERESIS_PX before it starts showing again; one that's visible fades
@@ -479,8 +626,11 @@ export function createScreenAnnotationRenderer(viewer, {
// frame-to-frame (the pulsing reticle stroke + de-collided cards keep the bbox in
// motion), which was the source of the flicker.
const m = cur < 0.5 ? TRACKED_HYSTERESIS_PX : 0;
overlapping = bb.x <= trackedRect.right + m && bb.x + bb.width >= trackedRect.left - m
&& bb.y <= trackedRect.bottom + m && bb.y + bb.height >= trackedRect.top - m;
overlapping =
bb.x <= trackedRect.right + m &&
bb.x + bb.width >= trackedRect.left - m &&
bb.y <= trackedRect.bottom + m &&
bb.y + bb.height >= trackedRect.top - m;
}
}
const target = overlapping ? 0 : 1;
@@ -490,7 +640,10 @@ export function createScreenAnnotationRenderer(viewer, {
// (The CSS `transition: opacity` was removed from .gev-anno so this per-frame JS ease
// is the ONLY easing — previously the two fought and produced an opacity oscillation.)
if (rec._trackedFade !== 1) {
group.setAttribute('opacity', String((anno.alpha ?? 1) * rec._trackedFade));
group.setAttribute(
'opacity',
String((anno.alpha ?? 1) * rec._trackedFade),
);
}
}
}
@@ -501,7 +654,13 @@ export function createScreenAnnotationRenderer(viewer, {
rec.group.classList.remove('gev-in');
rec.group.classList.add('gev-out');
const node = rec.group;
window.setTimeout(() => { try { node.remove(); } catch { /* gone */ } }, 360);
window.setTimeout(() => {
try {
node.remove();
} catch {
/* gone */
}
}, 360);
records.delete(anno.id);
// Board emptied → drop accumulated height samples (natural reset point).
if (records.size === 0) heightCache.clear();
@@ -513,8 +672,16 @@ export function createScreenAnnotationRenderer(viewer, {
}
function destroy() {
try { scene.postRender.removeEventListener(onPostRender); } catch { /* torn down */ }
try { layer.remove(); } catch { /* gone */ }
try {
scene.postRender.removeEventListener(onPostRender);
} catch {
/* torn down */
}
try {
layer.remove();
} catch {
/* gone */
}
records.clear();
heightCache.clear();
}
@@ -562,7 +729,15 @@ function makeCallout(text, c) {
node.appendChild(rect);
node.appendChild(accent);
node.appendChild(t);
const callout = { node, rect, text: t, accent, width: 0, height: 26, sized: false };
const callout = {
node,
rect,
text: t,
accent,
width: 0,
height: 26,
sized: false,
};
// size the card to the text once it is in the DOM
requestAnimationFrame(() => sizeCallout(callout));
return callout;
@@ -581,7 +756,9 @@ function sizeCallout(callout) {
callout.width = w;
callout.height = h;
callout.sized = true;
} catch { /* not laid out yet */ }
} catch {
/* not laid out yet */
}
}
function positionCallout(callout, x, y, center) {
@@ -589,7 +766,10 @@ function positionCallout(callout, x, y, center) {
const tx = center ? x - callout.width / 2 : x;
callout._x = tx;
callout._y = y;
callout.node.setAttribute('transform', `translate(${tx.toFixed(1)}, ${y.toFixed(1)})`);
callout.node.setAttribute(
'transform',
`translate(${tx.toFixed(1)}, ${y.toFixed(1)})`,
);
}
/**
@@ -607,7 +787,10 @@ function decollideCallouts(callouts) {
const overlapY = a._y < b._y + b.height && a._y + a.height > b._y;
if (overlapX && overlapY) {
a._y = b._y + b.height + 5;
a.node.setAttribute('transform', `translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`);
a.node.setAttribute(
'transform',
`translate(${a._x.toFixed(1)}, ${a._y.toFixed(1)})`,
);
if (a._leader) {
a._leader.setAttribute('x2', a._x.toFixed(1));
a._leader.setAttribute('y2', a._y.toFixed(1));
+200 -106
View File
@@ -58,7 +58,9 @@ export function createWorldAnnotationRenderer(viewer) {
ensureFlowFabricRegistered();
function colorFor(anno) {
return Cesium.Color.fromCssColorString(PALETTE[anno.color] || PALETTE.primary);
return Cesium.Color.fromCssColorString(
PALETTE[anno.color] || PALETTE.primary,
);
}
// Target-ring radius (meters) scaled to camera height so it reads at any
@@ -86,7 +88,11 @@ export function createWorldAnnotationRenderer(viewer) {
const entities = [];
anno._entities = entities;
if (anno.ring && anno.ring.length >= 3 && anno.footprintKind === 'building') {
if (
anno.ring &&
anno.ring.length >= 3 &&
anno.footprintKind === 'building'
) {
// Single building → the PRIMARY highlight is an extruded CLASSIFICATION
// volume that tints the real photogrammetry mesh (dome, walls, roof —
// everything inside the column) amber and pulses. A flat footprint
@@ -94,93 +100,134 @@ export function createWorldAnnotationRenderer(viewer) {
// actual tiles. A faint wireframe cage rides on top as a secondary cue.
const buffered = bufferRing(anno.ring, 3);
const positions = Cesium.Cartesian3.fromDegreesArray(buffered.flat());
const groundH = sampleGroundOutside(viewer.scene, anno.ring)
?? (Number.isFinite(anno.anchor.height) ? anno.anchor.height - (anno.buildingHeight || 25) : 0);
const groundH =
sampleGroundOutside(viewer.scene, anno.ring) ??
(Number.isFinite(anno.anchor.height)
? anno.anchor.height - (anno.buildingHeight || 25)
: 0);
const baseH = groundH - 3;
// Tall headroom is free: classification only colors where tiles exist, so
// empty air above the roof isn't tinted — this just guarantees we cover
// under-tagged building heights.
const topH = groundH + Math.max(18, anno.buildingHeight || 25) + 24;
// 1) Classification volume — tints the mesh amber, pulsing.
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.45, pulse: true })),
},
}));
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(positions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
classificationType: CLASSIFY,
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.45, pulse: true }),
),
},
}),
);
// 2) Faint, SIMPLIFIED wireframe cage — secondary, lower opacity. Decimate
// the footprint so the extruded outline draws ~14 clean verticals, not one
// per (often 100+) ring vertex.
const cagePositions = Cesium.Cartesian3.fromDegreesArray(decimateRing(buffered, 14).flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}));
const cagePositions = Cesium.Cartesian3.fromDegreesArray(
decimateRing(buffered, 14).flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(cagePositions),
height: baseH,
extrudedHeight: topH,
perPositionHeight: false,
fill: false,
outline: true,
outlineColor: liveColor(anno, base, { alpha: 0.32, pulse: true }),
},
}),
);
// 3) Crisp draped base outline so the footprint reads on the ground.
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.25, color: liveColor(anno, base, { alpha: 0.7 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 3,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.25,
color: liveColor(anno, base, { alpha: 0.7 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.ring && anno.ring.length >= 3) {
// Larger area (district / compound / park) → flat fill draped on the tiles
// + a glowing outline. Draping is right here: you can't extrude a whole
// neighbourhood, and the GIS overlay shows the boundary clearly.
const fillPositions = Cesium.Cartesian3.fromDegreesArray(anno.ring.flat());
entities.push(dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: anno.synthesized ? 0.10 : 0.20, pulse: true })),
classificationType: CLASSIFY,
},
}));
entities.push(dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({ color: liveColor(anno, base, { alpha: 0.95 }), dashLength: 24 })
: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.35, color: liveColor(anno, base, { alpha: 1 }) }),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
const fillPositions = Cesium.Cartesian3.fromDegreesArray(
anno.ring.flat(),
);
entities.push(
dataSource.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(fillPositions),
// Synthesized (approximate) areas get a fainter fill so they don't read as solid.
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, {
alpha: anno.synthesized ? 0.1 : 0.2,
pulse: true,
}),
),
classificationType: CLASSIFY,
},
}),
);
entities.push(
dataSource.entities.add({
polyline: {
positions: fillPositions,
width: 6,
// Synthesized → DASHED outline (signals "approximate, not an authoritative
// boundary", research §8.4/§8.6); real footprints → solid glow.
material: anno.synthesized
? new Cesium.PolylineDashMaterialProperty({
color: liveColor(anno, base, { alpha: 0.95 }),
dashLength: 24,
})
: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.35,
color: liveColor(anno, base, { alpha: 1 }),
}),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'route' && Array.isArray(anno.path) && anno.path.length >= 2) {
} else if (
anno.type === 'route' &&
Array.isArray(anno.path) &&
anno.path.length >= 2
) {
// A real path (street-following) draped on the 3D tiles, with dashes that
// FLOW toward the destination (custom st.s + time material; works on the
// clamped/classified ground polyline).
const positions = Cesium.Cartesian3.fromDegreesArray(
anno.path.flatMap((p) => [p.lon, p.lat]),
);
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(PALETTE[anno.color] || PALETTE.primary),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 9,
material: new FlowMaterialProperty(
PALETTE[anno.color] || PALETTE.primary,
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) entities.push(labelMarker(anno, base, { point: false }));
} else if (anno.type === 'arrow' && anno.to) {
// Connector draped across the ground from origin to destination.
@@ -188,24 +235,30 @@ export function createWorldAnnotationRenderer(viewer) {
Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
Cesium.Cartesian3.fromDegrees(anno.to.lon, anno.to.lat),
];
entities.push(dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(liveColor(anno, base, { alpha: 0.95 })),
clampToGround: true,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
polyline: {
positions,
width: 16,
material: new Cesium.PolylineArrowMaterialProperty(
liveColor(anno, base, { alpha: 0.95 }),
),
clampToGround: true,
classificationType: CLASSIFY,
},
}),
);
if (anno.label) {
const mid = {
lon: (anno.anchor.lon + anno.to.lon) / 2,
lat: (anno.anchor.lat + anno.to.lat) / 2,
};
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(mid.lon, mid.lat),
label: labelGraphic(anno, base),
}),
);
}
} else {
// pin / highlight / label — a camera-proportional target ring + a marker.
@@ -213,16 +266,23 @@ export function createWorldAnnotationRenderer(viewer) {
// Radius scales with camera height so the ring reads at any altitude.
// semiMajor === semiMinor is required AND must hold every frame, so both
// axes read the SAME ringRadius() (camera height is constant per frame).
entities.push(dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(liveColor(anno, base, { alpha: 0.38, pulse: true })),
outline: false,
classificationType: CLASSIFY,
},
}));
entities.push(
dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(
anno.anchor.lon,
anno.anchor.lat,
),
ellipse: {
semiMajorAxis: new Cesium.CallbackProperty(ringRadius, false),
semiMinorAxis: new Cesium.CallbackProperty(ringRadius, false),
material: new Cesium.ColorMaterialProperty(
liveColor(anno, base, { alpha: 0.38, pulse: true }),
),
outline: false,
classificationType: CLASSIFY,
},
}),
);
}
entities.push(labelMarker(anno, base, { point: true }));
}
@@ -232,14 +292,16 @@ export function createWorldAnnotationRenderer(viewer) {
function labelMarker(anno, base, { point }) {
return dataSource.entities.add({
position: Cesium.Cartesian3.fromDegrees(anno.anchor.lon, anno.anchor.lat),
point: point ? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
} : undefined,
point: point
? {
pixelSize: anno.type === 'label' ? 8 : 14,
color: liveColor(anno, base, { alpha: 1 }),
outlineColor: liveColor(anno, Cesium.Color.WHITE, { alpha: 0.95 }),
outlineWidth: 3,
heightReference: CLAMP,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}
: undefined,
label: anno.label ? labelGraphic(anno, base) : undefined,
});
}
@@ -256,7 +318,10 @@ export function createWorldAnnotationRenderer(viewer) {
pixelOffset: new Cesium.Cartesian2(0, -16),
showBackground: true,
backgroundColor: new Cesium.CallbackProperty(
() => Cesium.Color.fromCssColorString('#0b1622').withAlpha(0.72 * (anno.alpha ?? 1)),
() =>
Cesium.Color.fromCssColorString('#0b1622').withAlpha(
0.72 * (anno.alpha ?? 1),
),
false,
),
backgroundPadding: new Cesium.Cartesian2(8, 5),
@@ -357,8 +422,16 @@ export function FlowMaterialProperty(colorCss) {
this._definitionChanged = new Cesium.Event();
}
Object.defineProperties(FlowMaterialProperty.prototype, {
isConstant: { get() { return false; } }, // re-evaluated each frame → it animates
definitionChanged: { get() { return this._definitionChanged; } },
isConstant: {
get() {
return false;
},
}, // re-evaluated each frame → it animates
definitionChanged: {
get() {
return this._definitionChanged;
},
},
});
FlowMaterialProperty.prototype.getType = function getType() {
return 'GevRouteFlow';
@@ -394,10 +467,17 @@ function decimateRing(ring, n) {
* tiles under those points aren't loaded yet.
*/
function sampleGroundOutside(scene, ring) {
if (!scene?.clampToHeightSupported || typeof scene.clampToHeight !== 'function') return null;
if (
!scene?.clampToHeightSupported ||
typeof scene.clampToHeight !== 'function'
)
return null;
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latS = 111320;
@@ -407,7 +487,16 @@ function sampleGroundOutside(scene, ring) {
maxR = Math.max(maxR, Math.hypot((lon - clon) * lonS, (lat - clat) * latS));
}
const out = maxR * 1.5 + 12;
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.7, 0.7], [-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7]];
const dirs = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
[0.7, 0.7],
[-0.7, -0.7],
[0.7, -0.7],
[-0.7, 0.7],
];
const samples = [];
for (const [dx, dy] of dirs) {
const lon = clon + (dx * out) / lonS;
@@ -418,7 +507,9 @@ function sampleGroundOutside(scene, ring) {
const h = Cesium.Cartographic.fromCartesian(c).height;
if (Number.isFinite(h) && h > -430 && h < 9000) samples.push(h);
}
} catch { /* tile not ready */ }
} catch {
/* tile not ready */
}
}
if (!samples.length) return null;
samples.sort((a, b) => a - b);
@@ -433,7 +524,10 @@ function sampleGroundOutside(scene, ring) {
function bufferRing(ring, meters) {
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) { clon += lon; clat += lat; }
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const latScale = 111320;