mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into codex/fix-launch-hover-throttle
This commit is contained in:
87 files changed
+5775
-280
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createState({ services }) {
|
||||
layerState._stale = false;
|
||||
|
||||
layerState._degraded = false;
|
||||
layerState._rejectedStationCount = 0;
|
||||
|
||||
layerState._error = 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,
|
||||
|
||||
@@ -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' }), '');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user