mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
style: format remaining owned runtime modules
This commit is contained in:
1 parent
ae99843c14
commit
2aca7aecbb
94 files changed
+8865
-3151
No files matched your search
+455
-105
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user