Merge branch 'main' into codex/fix-hidden-tab-polling

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-24 23:51:23 +01:00
commit 60ec1f962b
110 files changed
+6641 -460

No files matched your search

+4 -2
View File
@@ -47,7 +47,7 @@ export function createTracking({
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
const { applyTrackedCameraFrame } = services.camera;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
const flightsLayer = layer;
function _emitAwarenessEvent(type, detail) {
@@ -1174,7 +1174,9 @@ export function createTracking({
// first-person reference. A globe click must not fall through to the
// normal empty-space deselection path; cockpit has explicit exit controls.
if (document.body.classList.contains('cockpit-mode')) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
if (picked) {
// Clicking the tracked entity itself — ignore (don't deselect)
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Event } from 'cesium';
import { createLaunchHover } from './lifecycle.js';
function fakeTimers() {
const timers = new Map();
let nextId = 1;
const api = {
setTimer(fn, ms) {
const id = nextId++;
timers.set(id, { fn, ms });
return id;
},
clearTimer(id) {
timers.delete(id);
},
pending: () => [...timers.values()],
flush() {
const due = [...timers.entries()];
timers.clear();
for (const [, timer] of due) timer.fn();
},
};
return api;
}
function hoverRig({ overMarker }) {
let at = 0,
picks = 0,
enabled = true,
free = true;
const timers = fakeTimers();
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: { cursor: '' } },
drillPick(position) {
picks++;
return overMarker(position) ? [{ id: 'launch' }] : [];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => enabled,
entityLaunchId: (id) => id === 'launch',
now: () => at,
documentRef: { hidden: false },
pointerFree: () => free,
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
return {
hover,
timers,
viewer,
cursor: () => viewer.scene.canvas.style.cursor,
picks: () => picks,
setTime: (value) => (at = value),
setEnabled: (value) => (enabled = value),
setFree: (value) => (free = value),
};
}
const onMarker = (position) => position?.x === 1;
const marker = () => ({ endPosition: { x: 1, y: 1 } });
const empty = () => ({ endPosition: { x: 9, y: 9 } });
test('mission hover runs one trailing check for a pointer that stops on empty space', () => {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(marker());
assert.equal(rig.cursor(), 'pointer');
rig.setTime(50);
const movement = empty();
rig.hover.move(movement);
// Cesium reuses its movement object; the trailing check must use a copy.
movement.endPosition.x = 1;
assert.equal(rig.picks(), 1);
assert.deepEqual(
rig.timers.pending().map((timer) => timer.ms),
[70],
);
rig.setTime(80);
rig.hover.move(empty());
assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time');
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 2);
assert.equal(rig.cursor(), '');
assert.equal(rig.timers.pending().length, 0);
rig.hover.destroy();
});
test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(empty());
assert.equal(rig.cursor(), '');
rig.setTime(50);
rig.hover.move(marker());
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 2);
assert.equal(rig.cursor(), 'pointer');
rig.hover.destroy();
assert.equal(rig.cursor(), '');
});
test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => {
for (const cancel of [
(rig) => rig.hover.reset(),
(rig) => rig.hover.destroy(),
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
]) {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(empty());
rig.setTime(50);
rig.hover.move(marker());
assert.equal(rig.timers.pending().length, 1);
cancel(rig);
assert.equal(rig.timers.pending().length, 0);
assert.equal(rig.picks(), 1);
assert.equal(rig.cursor(), '');
rig.hover.destroy();
}
});
test('mission hover trailing check rechecks eligibility before picking', () => {
for (const block of [
(rig) => rig.setEnabled(false),
(rig) => rig.setFree(false),
]) {
const rig = hoverRig({ overMarker: onMarker });
rig.hover.move(marker());
rig.setTime(50);
rig.hover.move(marker());
block(rig);
rig.setTime(120);
rig.timers.flush();
assert.equal(rig.picks(), 1);
assert.equal(rig.cursor(), '');
rig.hover.destroy();
}
});
test('mission hover throttles, clears on motion/hidden, and releases listeners', () => {
let at = 0,
picks = 0,
enabled = true;
const listeners = new Set();
const doc = {
hidden: false,
addEventListener: (_n, fn) => listeners.add(fn),
removeEventListener: (_n, fn) => listeners.delete(fn),
};
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: {} },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => enabled,
entityLaunchId: (id) => id === 'launch',
now: () => at,
documentRef: doc,
pointerFree: () => true,
...fakeTimers(),
});
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
hover.move({ endPosition: {} });
assert.equal(picks, 2);
viewer.camera.moveStart.raiseEvent();
at = 500;
hover.move({ endPosition: {} });
assert.equal(picks, 2);
assert.equal(viewer.scene.canvas.style.cursor, '');
viewer.camera.moveEnd.raiseEvent();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
doc.hidden = true;
for (const fn of listeners) fn();
hover.move({ endPosition: {} });
assert.equal(picks, 3);
assert.equal(viewer.scene.canvas.style.cursor, '');
doc.hidden = false;
enabled = false;
hover.move({ endPosition: {} });
assert.equal(picks, 3);
hover.destroy();
assert.equal(listeners.size, 0);
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
});
test('mission hover preserves the cursor of a pointer-owning tool', () => {
let free = true,
picks = 0;
const viewer = {
camera: { moveStart: new Event(), moveEnd: new Event() },
scene: {
canvas: { style: { cursor: '' } },
drillPick() {
picks++;
return [{ id: 'launch' }];
},
},
};
const hover = createLaunchHover({
viewer,
enabled: () => true,
entityLaunchId: () => true,
documentRef: null,
pointerFree: () => free,
});
hover.move({ endPosition: {} });
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
free = false;
viewer.scene.canvas.style.cursor = 'crosshair';
hover.move({ endPosition: {} });
viewer.camera.moveStart.raiseEvent();
hover.destroy();
assert.equal(picks, 1);
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
});
+123 -8
View File
@@ -1,6 +1,116 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
const HOVER_PICK_INTERVAL_MS = 120;
/**
* Bound hover picking independently of precise click selection. A move inside
* the throttle window is not dropped: its position is kept and one trailing
* check runs when the window ends, so a pointer that stops is still resolved.
*/
export function createLaunchHover({
viewer,
enabled,
entityLaunchId,
now = () => performance.now(),
documentRef = globalThis.document,
pointerFree = isPointerFree,
setTimer = globalThis.setTimeout,
clearTimer = globalThis.clearTimeout,
}) {
let moving = false;
let lastPick = -Infinity;
let ownsCursor = false;
let pendingPosition = null;
let trailingTimer = null;
const clearCursor = () => {
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
viewer.scene.canvas.style.cursor = '';
ownsCursor = false;
};
const cancelTrailing = () => {
if (trailingTimer !== null) clearTimer(trailingTimer);
trailingTimer = null;
pendingPosition = null;
};
const reset = () => {
cancelTrailing();
clearCursor();
lastPick = -Infinity;
};
const start = () => {
moving = true;
reset();
};
const end = () => {
moving = false;
reset();
};
const visibility = () => {
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
reset();
};
const eligible = () =>
enabled() &&
!moving &&
!documentRef?.hidden &&
documentRef?.visibilityState !== 'hidden' &&
pointerFree();
const pick = (position, at) => {
lastPick = at;
const found = viewer.scene
.drillPick(position, 12)
.some((picked) => entityLaunchId(picked?.id));
if (found) {
viewer.scene.canvas.style.cursor = 'pointer';
ownsCursor = true;
} else clearCursor();
};
const trailing = () => {
trailingTimer = null;
const position = pendingPosition;
pendingPosition = null;
if (!position) return;
// Eligibility may have changed while the check waited.
if (!eligible()) {
reset();
return;
}
pick(position, now());
};
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
documentRef?.addEventListener?.('visibilitychange', visibility);
return {
move(movement) {
if (!eligible()) {
reset();
return;
}
const at = now();
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
if (wait > 0) {
// Cesium reuses the movement object, so keep a copy of the position.
pendingPosition = Cesium.Cartesian2.clone(
movement.endPosition,
pendingPosition ?? undefined,
);
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
return;
}
cancelTrailing();
pick(movement.endPosition, at);
},
reset,
destroy() {
reset();
removeStart?.();
removeEnd?.();
documentRef?.removeEventListener?.('visibilitychange', visibility);
},
};
}
export function createLifecycle({
state: layerState,
services,
@@ -8,6 +118,7 @@ export function createLifecycle({
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null;
const methods = {
init(viewer) {
@@ -45,14 +156,15 @@ export function createLifecycle({
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
layerState._moveHandler.setInputAction((movement) => {
if (!layerState._enabled || !layerState._dataSource?.show) return;
const missionEntity = viewer.scene
.drillPick(movement.endPosition, 12)
.map((picked) => picked?.id)
.find((candidate) => parts.selection.entityLaunchId(candidate));
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
hover = createLaunchHover({
viewer,
enabled: () => layerState._enabled && layerState._dataSource?.show,
entityLaunchId: parts.selection.entityLaunchId,
});
layerState._moveHandler.setInputAction(
hover.move,
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
// Apply horizon visibility before Cesium traverses and picks the scene.
// A postRender write is one frame late and can remain visibly stale when
// request-on-demand rendering stops after a globe camera move.
@@ -100,6 +212,7 @@ export function createLifecycle({
},
async disable() {
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
releaseContinuousRender('rocket-launches');
@@ -122,6 +235,8 @@ export function createLifecycle({
},
async destroy(viewer) {
hover?.destroy();
hover = null;
layerState._sourceController.abort();
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false;
+5 -3
View File
@@ -47,7 +47,7 @@ export function createTracking({
const { ensureGeoidReady, geoidHeight } = services.geoid;
const { clearFocusTarget } = services.focus;
const { applyTrackedCameraFrame } = services.camera;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
function _emitAwarenessEvent(type, detail) {
if (
@@ -593,7 +593,7 @@ export function createTracking({
}
/**
* Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
* stride-thinned to the remaining vertex budget, then spliced strictly older
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
@@ -1079,7 +1079,9 @@ export function createTracking({
// first-person reference. Empty globe clicks are inert until the user
// exits with C, Escape, or the dedicated button.
if (document.body.classList.contains('cockpit-mode')) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
if (picked) {
// Clicking the tracked entity itself -- ignore (don't deselect)
+22 -6
View File
@@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js';
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
RADIO_REJECTED_STATION_COUNT_MAX,
} from './policy.js';
// Directory errors stay English in layer state and stats; the layer row and
@@ -64,17 +65,24 @@ export function createIngestion({
typeof body.degraded !== 'boolean'
)
throw new Error('Radio directory freshness metadata was malformed');
// Rows the server skipped as structurally malformed. Older envelopes
// omit the field; a present value must be a bounded count.
const serverRejectedStationCount =
body.rejectedStationCount === undefined
? 0
: body.rejectedStationCount;
if (
!Number.isSafeInteger(serverRejectedStationCount) ||
serverRejectedStationCount < 0 ||
serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX
)
throw new Error('Radio directory diagnostic metadata was malformed');
const rows = body.stations;
const acceptedRows = rows.filter(
parts.catalogModel.isValidRadioDirectoryStation,
);
if (!acceptedRows.length)
throw new Error('Radio directory returned no usable stations');
if (acceptedRows.length !== rows.length) {
throw new Error(
'Radio directory response contained malformed stations',
);
}
const acceptedGeneration = body.acceptedGeneration;
if (
acceptedGeneration !== null &&
@@ -147,7 +155,15 @@ export function createIngestion({
}
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
}
layerState._degraded = body.degraded;
// Combine server-side structural rejections with rows this client
// refused. The count is diagnostic: it marks coverage degraded but does
// not undo the generation admitted above.
layerState._rejectedStationCount = Math.min(
RADIO_REJECTED_STATION_COUNT_MAX,
serverRejectedStationCount + rows.length - acceptedRows.length,
);
layerState._degraded =
body.degraded || layerState._rejectedStationCount > 0;
layerState._stale = body.stale;
layerState._error = preservingWarmCatalog
? ERROR_REFRESH_DEGRADED
+1
View File
@@ -108,6 +108,7 @@ export function createLifecycle({
layerState._loading = false;
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
layerState._updatedAt = null;
layerState._managerLifecyclePresentation = null;
+4
View File
@@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
// Bound for the server's `rejectedStationCount` envelope field and the
// combined client counter; matches server/providers/radio/constants.js.
export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000;
export const RADIO_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+1
View File
@@ -20,6 +20,7 @@ export function createPresentation({
loading: layerState._loading,
stale: layerState._stale,
degraded: layerState._degraded,
rejectedStationCount: layerState._rejectedStationCount,
error: layerState._error,
updatedAt: layerState._updatedAt,
filter: layerState._filter,
+1
View File
@@ -25,6 +25,7 @@ export function createState({ services }) {
layerState._stale = false;
layerState._degraded = false;
layerState._rejectedStationCount = 0;
layerState._error = null;
@@ -222,9 +222,32 @@ export function kartaViewTimeMs(value) {
String(value ?? ''),
);
if (!match) return null;
const [, y, mo, d, h = '0', mi = '0', s = '0'] = match;
if (Number(y) < 1990) return null;
const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hours = Number(match[4] ?? 0);
const minutes = Number(match[5] ?? 0);
const seconds = Number(match[6] ?? 0);
if (year < 1990) return null;
if (
month < 1 ||
month > 12 ||
day < 1 ||
hours > 23 ||
minutes > 59 ||
seconds > 59
)
return null;
// Date.UTC rolls 31 February into March. A photo would then pass or fail
// the day filter on the wrong calendar day, so an impossible date is unknown.
const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds);
const parsed = new Date(ms);
if (
parsed.getUTCFullYear() !== year ||
parsed.getUTCMonth() !== month - 1 ||
parsed.getUTCDate() !== day
)
return null;
return Number.isFinite(ms) ? ms : null;
}
@@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () =>
kartaViewTimeMs('2022-03-21T06:42:59'),
Date.UTC(2022, 2, 21, 6, 42, 59),
);
assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null);
assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null);
assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null);
assert.equal(kartaViewTimeMs('2022-04-31'), null);
assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null);
assert.equal(kartaViewTimeMs(''), null);
assert.equal(kartaViewTimeMs(null), null);
@@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) {
return payload;
}
/** `endpoint` with exactly one trailing slash, or '' when unset. */
function endpointBase(endpoint) {
const text = String(endpoint || '').trim();
return text ? text.replace(/\/*$/, '/') : '';
}
/**
* Mapillary network access: tiles via the local proxy, which adds the token
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
* With `graphEndpoint` (a Graph API base that adds the token itself, such as
* a same-origin proxy), the source's Graph lookups go there through
* `fetchImpl` instead, and the photo viewer sends its own lookups there too;
* no token is sent or needed.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object,
* graphEndpoint?: string}} options
*/
export function createMapillarySource({
token = '',
fetchImpl = (...args) => globalThis.fetch(...args),
endpoints = {},
graphEndpoint = '',
} = {}) {
const graphBase = endpointBase(graphEndpoint);
const urls = {
status: '/api/mapillary/status',
tiles: '/api/mapillary/tiles',
@@ -54,6 +67,13 @@ export function createMapillarySource({
};
async function graph(path, params = {}, { signal } = {}) {
if (graphBase) {
const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
signal,
});
return readJsonOrThrow(response, 'Mapillary graph');
}
if (!token)
throw new MapillarySourceError('Mapillary token not configured', {
status: 503,
@@ -70,6 +90,10 @@ export function createMapillarySource({
get token() {
return token;
},
/** The Graph API base that adds the token itself, or '' (direct). */
get graphEndpoint() {
return graphBase;
},
hasToken: () => Boolean(token),
async getStatus({ signal } = {}) {
@@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
'the Retry-After header wins over the body',
);
});
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph',
fetchImpl,
});
assert.equal(source.graphEndpoint, '/api/mapillary/graph/');
assert.equal(source.hasToken(), false);
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url, 'http://localhost');
assert.equal(url.pathname, '/api/mapillary/graph/images');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.equal(url.searchParams.has('access_token'), false);
});
test('Graph endpoint errors keep their status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({
graphEndpoint: '/api/mapillary/graph/',
fetchImpl,
});
await assert.rejects(
() => source.getSequenceImages('1'),
(error) => error.status === 503 && error.keyRequired === true,
);
});
@@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js';
* @property {string} externalUrl Deep link to the image on mapillary.com.
*/
/**
* The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS
* resolves its lookup paths against it; '' when the source has none.
*/
export function graphEndpointUrl(source) {
if (!source?.graphEndpoint) return '';
const url = new URL(
source.graphEndpoint,
globalThis.location?.href || 'http://localhost/',
);
url.pathname = url.pathname.replace(/\/*$/, '/');
return url.href;
}
/**
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
* a pose on every image, view or position change.
@@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) {
async function buildViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
const { Viewer, GraphDataProvider } = await ensureLibrary();
// Unmounted (layer off) while the library loaded: a viewer built now
// would hold a WebGL context nobody releases.
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
const endpoint = graphEndpointUrl(source);
viewer = new Viewer({
accessToken: source.token,
// A source with its own Graph endpoint (which adds the token itself)
// answers the viewer's lookups, sent without a token; otherwise
// MapillaryJS calls the Graph API with the client token.
...(endpoint
? { dataProvider: new GraphDataProvider({ endpoint }) }
: { accessToken: source.token }),
container: host,
component: {
cover: false,
+55 -3
View File
@@ -22,13 +22,16 @@ registerHooks({
const source = url.includes('/library?')
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
'export const Viewer = lib.Viewer;\n' +
'export const RenderMode = lib.RenderMode;\n'
'export const RenderMode = lib.RenderMode;\n' +
'export const GraphDataProvider = lib.GraphDataProvider;\n'
: 'export default {};\n';
return { format: 'module', shortCircuit: true, source };
},
});
const { createMapillaryViewer } =
const { createMapillaryViewer, graphEndpointUrl } =
await import('./providers/mapillary/viewer.js');
const { createMapillarySource } =
await import('./providers/mapillary/source.js');
/**
* A viewer host over `adapter`. `marker` records where the position marker
@@ -345,7 +348,7 @@ function fakeLibrary() {
}
globalThis.__gevMapillaryLibrary = async () => {
await gate.promise;
return { Viewer, RenderMode: {} };
return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider };
};
return { gate, viewers };
}
@@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets
host.close();
assert.equal(state.street.license, null);
});
/** MapillaryJS 4's Graph provider, recording what it was built with. */
class FakeGraphDataProvider {
constructor(options = {}) {
this.options = options;
}
}
test('without a Graph endpoint the viewer gets the client token, as before', async () => {
const library = fakeLibrary();
library.gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, 't');
assert.equal(viewer.options.dataProvider, undefined);
});
test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => {
const library = fakeLibrary();
library.gate.resolve();
const source = createMapillarySource({
graphEndpoint: 'https://app.example/api/mapillary/graph',
fetchImpl: async () => {
throw new Error('the viewer does not use the source transport');
},
});
const adapter = createMapillaryViewer({ source });
await adapter.mount({});
const [viewer] = library.viewers.instances;
assert.equal(viewer.options.accessToken, undefined);
const provider = viewer.options.dataProvider;
assert.ok(provider instanceof FakeGraphDataProvider);
assert.deepEqual(provider.options, {
endpoint: 'https://app.example/api/mapillary/graph/',
});
});
test('a relative Graph endpoint resolves against the page, with one trailing slash', () => {
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(
graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }),
'http://localhost/api/mapillary/graph/',
);
assert.equal(graphEndpointUrl({ token: 't' }), '');
});
+28 -1
View File
@@ -20,6 +20,23 @@ export function createAnimation({
parts,
source,
}) {
function syncRenderDemand() {
const needed = Boolean(
layerState._enabled &&
(layerState._dots.length || layerState._heatJamPrim),
);
// Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever.
if (!needed && layerState._heightPending) parts.retention.finishHeights();
if (needed === Boolean(layerState._renderHeld)) return;
layerState._renderHeld = needed;
if (needed) {
layerState._lastAnimTime = 0;
services.render.holdContinuousRender('traffic');
layerState._viewer?.scene?.requestRender?.();
} else services.render.releaseContinuousRender('traffic');
}
/**
* Spawn animated dot primitives along a single road.
*
@@ -188,6 +205,7 @@ export function createAnimation({
: null,
});
}
syncRenderDemand();
}
/**
@@ -206,6 +224,8 @@ export function createAnimation({
*/
function animate() {
syncRenderDemand();
if (!layerState._renderHeld) return;
const now = Date.now();
// Delta time in seconds, capped to avoid jumps when returning from background tab
const dt = layerState._lastAnimTime
@@ -339,6 +359,13 @@ export function createAnimation({
layerState._count = 0;
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
layerState._closedRoads = 0;
syncRenderDemand();
}
return { spawnDotsForRoad, animate, maybeStopLight, clearDots };
return {
spawnDotsForRoad,
animate,
maybeStopLight,
clearDots,
syncRenderDemand,
};
}
+2 -3
View File
@@ -12,7 +12,6 @@ export function createLifecycle({
parts,
source,
}) {
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { resetFlowTileCache } = source;
const methods = {
@@ -81,7 +80,7 @@ export function createLifecycle({
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
holdContinuousRender('traffic'); // per-frame animator (perf wave 2)
parts.animation.syncRenderDemand();
layerState._lastAnimTime = 0;
layerState._pointCollection.show = true;
@@ -142,7 +141,7 @@ export function createLifecycle({
disable(viewer) {
layerState._enabled = false;
services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic');
releaseContinuousRender('traffic');
parts.animation.syncRenderDemand();
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
+39 -1
View File
@@ -37,7 +37,14 @@ function setup(t) {
visibleRoadsForAltitude: (roads) => roads,
},
};
const ctx = { state, services: {}, parts, source: {} };
const ctx = {
state,
services: {
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
},
parts,
source: {},
};
parts.style = createStyle(ctx);
parts.model = createModel(ctx);
parts.animation = createAnimation(ctx);
@@ -558,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling
assert.notEqual(forward.waypoints, reverse.waypoints);
});
test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => {
const h = setup(t);
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
assert.equal(h.state._renderHeld, true);
const road = h.state._roads[0];
// Republishing the sole road starts a height correction.
const corrected = h.roads(0, 120);
h.parts.retention.reconcile(corrected, 450, true);
assert.equal(h.state._heightPending, true);
assert.equal(road.heightMoving, true);
// The closure arrives before the next population pass, which then removes
// the final points after their fade, long before the correction finishes.
corrected[0].flow = { level: 0, closure: true };
for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame();
assert.equal(h.state._dots.length, 0);
assert.equal(h.state._heatJamPrim, null);
assert.equal(h.state._renderHeld, false);
assert.equal(h.state._heightPending, false);
assert.equal(h.state._populationPending, false);
assert.equal(road.heightMoving, false);
assert.ok(
Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) <
0.01,
);
// Frames driven by other layers stay idle and cannot revive the work.
h.frame();
assert.equal(h.state._heightPending, false);
assert.equal(h.state._renderHeld, false);
});
test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => {
const h = setup(t);
h.state._viewer.scene = {
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAnimation } from './animation.js';
test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => {
const calls = [];
const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } };
const animation = createAnimation({
state,
services: {
render: {
holdContinuousRender: (id) => calls.push(['hold', id]),
releaseContinuousRender: (id) => calls.push(['release', id]),
},
},
parts: {
retention: { clear() {} },
rendering: {
removeHeatLines() {
state._heatJamPrim = null;
},
},
},
});
animation.syncRenderDemand();
assert.deepEqual(calls, []);
state._dots.push({});
animation.syncRenderDemand();
animation.syncRenderDemand();
assert.deepEqual(calls, [['hold', 'traffic']]);
animation.clearDots();
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
state._heatJamPrim = {};
animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['hold', 'traffic']);
state._enabled = false;
animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
state._enabled = true;
animation.clearDots();
assert.equal(state._renderHeld, false);
state._dots.push({});
animation.syncRenderDemand();
assert.equal(state._renderHeld, true);
});
+2
View File
@@ -50,6 +50,7 @@ export function createRendering({
layerState._heatSlowPrim = null;
}
layerState._heatLineCount = 0;
parts.animation.syncRenderDemand?.();
}
/**
@@ -152,6 +153,7 @@ export function createRendering({
}
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand?.();
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
+25 -1
View File
@@ -125,6 +125,7 @@ export function createRetention({ state, parts }) {
count++;
}
state._count = count;
parts.animation.syncRenderDemand?.();
}
function reconcile(roads, height, replace) {
altitude = height;
@@ -241,6 +242,29 @@ export function createRetention({ state, parts }) {
road.heightMoving = moving;
}
}
/**
* Settle every retained road on its corrected heights at once. Easing only
* runs inside the held animation frame, so once nothing is left to animate
* the remaining correction would otherwise stay pending indefinitely.
* Published roads whose record was just dropped are settled too, so a later
* held frame cannot revive their correction.
*/
function finishHeights() {
const roads = new Set(state._roads);
for (const record of records.values()) roads.add(record.road);
for (const road of roads) {
if (!road.heightMoving) continue;
for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]);
for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance(
road.waypoints[i],
road.waypoints[i + 1],
);
road.heightMoving = false;
}
state._heightPending = false;
}
function clear() {
if (timer !== null) {
if (typeof cancelAnimationFrame === 'function')
@@ -252,5 +276,5 @@ export function createRetention({ state, parts }) {
state._populationPending = false;
state._heightPending = false;
}
return { reconcile, easeHeights, clear, refreshCounts };
return { reconcile, easeHeights, finishHeights, clear, refreshCounts };
}
+1 -1
View File
@@ -3574,7 +3574,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
source: {},
parts: {
flow: { ensureFlowStatus: noop },
animation: { animate: noop, clearDots: noop },
animation: { animate: noop, clearDots: noop, syncRenderDemand: noop },
viewport: { onCameraChanged: noop },
ingestion: { cancelActiveFetch: noop },
},
+4 -2
View File
@@ -10,7 +10,7 @@ export function createSelection({
options,
}) {
const { state } = vesselState;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
const { requestWorldFocus } = services.worldFocus;
const {
selectEntityContext,
@@ -37,7 +37,9 @@ export function createSelection({
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.feed.enabled) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
const rawId = picked?.id ?? picked?.primitive?.id;