mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge branch 'main' into fix/firms-acquisition-calendar
This commit is contained in:
8 files changed
+494
-93
No files matched your search
@@ -0,0 +1 @@
|
||||
- Reserve singleflight before disk cache reads (asturksever, #1278).
|
||||
@@ -0,0 +1 @@
|
||||
- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279).
|
||||
+7
-10
@@ -9,16 +9,13 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
|
||||
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||
search keeps a successful geocode, with no search or view-based region recovery.
|
||||
|
||||
KML namespace repair locates the opening token once and scans its quoted tag
|
||||
delimiter once, skipping prolog and DOCTYPE comments and processing
|
||||
instructions whole. The scanner is a boundary check only: an opening KML tag
|
||||
that never closes, or one with raw markup starting inside it, fails before DOM
|
||||
parsing, so a file of repeated incomplete `<kml` prefixes cannot trigger
|
||||
quadratic regex rescans. All other XML grammar, including attribute syntax
|
||||
inside a complete root tag, is left to DOMParser. Namespace declarations are
|
||||
recognized only as whole root attributes, never inside quoted values; duplicate
|
||||
namespace repair and missing xsi declarations remain supported. GPX continues
|
||||
through its existing XML parsing path.
|
||||
Overpass reserves each normalized query before its disk-cache lookup. The
|
||||
initial disk lookup, upstream quota admission, fetching and memory publication
|
||||
are coalesced under that reservation. A caller that joined a reservation
|
||||
rejected by another client's quota retries admission with its own quota.
|
||||
Stale fallback runs per response after the reservation is released, and disk
|
||||
writes finish asynchronously. Settled and rejected reservations are removed
|
||||
only while they still own their map entry.
|
||||
|
||||
Terrain height requests accept at most 64 points, matching the browser service's
|
||||
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||
|
||||
@@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
loadUpstream: async () => {
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT)
|
||||
return {
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
retryAfterMs: 2000,
|
||||
body: JSON.stringify({
|
||||
error: 'Overpass proxy busy — try again shortly',
|
||||
}),
|
||||
};
|
||||
_overpassConcurrent++;
|
||||
try {
|
||||
const payload = await fetchOverpassPayload(safeBody);
|
||||
if (overpassPayloadIsData(payload)) {
|
||||
const entry = { ...payload, cachedAt: Date.now() };
|
||||
_overpassCache.set(cacheKey, entry);
|
||||
trimOverpassCache();
|
||||
writeOverpassDisk(cacheKey, entry);
|
||||
}
|
||||
return payload;
|
||||
} finally {
|
||||
_overpassConcurrent--;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (preflight.source === 'RATE_LIMITED') {
|
||||
res.writeHead(429, {
|
||||
@@ -173,71 +197,20 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
if (preflight.source !== 'UPSTREAM') {
|
||||
// A coalesced caller sees the same failure as the original request
|
||||
// and must get the same last-good fallback, not the raw refusal.
|
||||
if (!overpassPayloadIsData(preflight.payload)) {
|
||||
const stale = await readStaleOverpass(cacheKey);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (preflight.source === 'DISK') {
|
||||
_overpassCache.set(cacheKey, preflight.payload);
|
||||
trimOverpassCache();
|
||||
}
|
||||
sendOverpassResponse(res, preflight.payload, preflight.source);
|
||||
return;
|
||||
}
|
||||
|
||||
// From here onward the request is genuinely upstream-bound and has
|
||||
// consumed one local limiter slot. Cache and dedupe hits above do not.
|
||||
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) {
|
||||
res.writeHead(503, {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '2',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'Overpass proxy busy — try again shortly',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_overpassConcurrent += 1;
|
||||
const requestPromise = fetchOverpassPayload(safeBody)
|
||||
.then((payload) => {
|
||||
// Only a 2xx is data. `< 500` cached every 4xx, so one mirror's
|
||||
// refusal was written to memory AND disk — and boundary-class
|
||||
// queries hold a month-long TTL, so a single 406 outlived the
|
||||
// outage that caused it.
|
||||
if (overpassPayloadIsData(payload)) {
|
||||
const entry = { ...payload, cachedAt: Date.now() };
|
||||
_overpassCache.set(cacheKey, entry);
|
||||
trimOverpassCache();
|
||||
writeOverpassDisk(cacheKey, entry);
|
||||
}
|
||||
return payload;
|
||||
})
|
||||
.finally(() => {
|
||||
_overpassConcurrent -= 1;
|
||||
_overpassInFlight.delete(cacheKey);
|
||||
});
|
||||
|
||||
_overpassInFlight.set(cacheKey, requestPromise);
|
||||
const payload = await requestPromise;
|
||||
// Degraded upstream (rate-limited on every mirror / 5xx / runtime
|
||||
// error): last-good roads beat an empty layer — serve stale from
|
||||
// memory or disk at ANY age before surfacing the failure.
|
||||
if (!overpassPayloadIsData(payload)) {
|
||||
if (!overpassPayloadIsData(preflight.payload)) {
|
||||
const stale = await readStaleOverpass(cacheKey);
|
||||
if (stale) {
|
||||
sendOverpassResponse(res, stale, 'STALE');
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendOverpassResponse(res, payload, 'MISS');
|
||||
// Preflight already published the disk hit to memory; only trim.
|
||||
if (preflight.source === 'DISK') trimOverpassCache();
|
||||
sendOverpassResponse(
|
||||
res,
|
||||
preflight.payload,
|
||||
preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source,
|
||||
);
|
||||
} catch (e) {
|
||||
// Every mirror threw (network-level). Same serve-stale rule.
|
||||
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { coalesceProxyRequest } from '../common/http.js';
|
||||
import { isOverpassBoundaryQuery } from './query.js';
|
||||
import {
|
||||
OVERPASS_BOUNDARY_DISK_TTL_MS,
|
||||
@@ -78,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) {
|
||||
/**
|
||||
* Resolve every cache/coalescing layer before admitting a request to the local
|
||||
* upstream rate limiter. The injected limiter callback is invoked exactly once
|
||||
* for a complete cache miss and never for memory, in-flight, or disk hits.
|
||||
* for a complete cache miss and never for memory, in-flight, or disk hits. A
|
||||
* caller that joined a reservation rejected by another client's limiter
|
||||
* retries with its own limiter instead of inheriting that 429.
|
||||
* Exported so the admission ordering can be tested without a Vite server.
|
||||
*
|
||||
* @param {object} options
|
||||
@@ -87,6 +90,7 @@ function writeOverpassDisk(cacheKey, payload) {
|
||||
* @param {Map<string, Promise<object>>} options.inFlight
|
||||
* @param {()=>Promise<object|null>} options.readDisk
|
||||
* @param {()=>boolean} options.allowUpstream
|
||||
* @param {()=>Promise<object>} options.loadUpstream Fetch and publish the admitted result.
|
||||
* @param {number} [options.now]
|
||||
* @param {number} [options.cacheMs]
|
||||
* @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>}
|
||||
@@ -97,22 +101,34 @@ async function resolveOverpassPreflight({
|
||||
inFlight,
|
||||
readDisk,
|
||||
allowUpstream,
|
||||
loadUpstream = async () => null,
|
||||
now = Date.now(),
|
||||
cacheMs = OVERPASS_CACHE_MS,
|
||||
}) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', payload: cached };
|
||||
for (;;) {
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
||||
return { source: 'HIT', payload: cached };
|
||||
|
||||
const pending = inFlight.get(cacheKey);
|
||||
if (pending) return { source: 'INFLIGHT', payload: await pending };
|
||||
|
||||
const disk = await readDisk();
|
||||
if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk };
|
||||
|
||||
return allowUpstream()
|
||||
? { source: 'UPSTREAM', payload: null }
|
||||
: { source: 'RATE_LIMITED', payload: null };
|
||||
// Reserve synchronously before disk I/O. The owner retains the reservation
|
||||
// through its own upstream admission, fetch and cache publication.
|
||||
const request = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
||||
const disk = await readDisk();
|
||||
if (overpassPayloadIsData(disk)) {
|
||||
memoryCache.set(cacheKey, disk);
|
||||
return { source: 'DISK', payload: disk };
|
||||
}
|
||||
if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null };
|
||||
return { source: 'UPSTREAM', payload: await loadUpstream() };
|
||||
});
|
||||
const result = await request.promise;
|
||||
if (!request.shared) return result;
|
||||
// The owner's quota rejection belongs to the owner's client. The rejected
|
||||
// reservation is released before joiners resume, so retry and let this
|
||||
// caller own the next reservation (or join an admitted one) on its quota.
|
||||
if (result.source === 'RATE_LIMITED') continue;
|
||||
return { ...result, source: 'INFLIGHT' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Return only last-good Overpass data, regardless of its age. */
|
||||
|
||||
@@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const memoryHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, fresh]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
const inFlightHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
|
||||
inFlight: new Map([[key, Promise.resolve(joined)]]),
|
||||
inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
now: 1000,
|
||||
@@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk');
|
||||
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
|
||||
|
||||
const diskMemory = new Map();
|
||||
const diskHit = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
memoryCache: new Map(),
|
||||
memoryCache: diskMemory,
|
||||
inFlight: new Map(),
|
||||
readDisk: async () => { diskReads += 1; return disk; },
|
||||
allowUpstream,
|
||||
@@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
||||
assert.equal(diskHit.payload, disk);
|
||||
assert.equal(diskReads, 1);
|
||||
assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota');
|
||||
const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream });
|
||||
assert.equal(publishedDisk.source, 'HIT');
|
||||
|
||||
const upstreamMiss = await resolveOverpassPreflight({
|
||||
cacheKey: key,
|
||||
@@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) {
|
||||
const t = c1 / c2;
|
||||
return Math.hypot(wx - t * vx, wy - t * vy);
|
||||
}
|
||||
|
||||
|
||||
test('cold disk lookup is single-flight through upstream publication and quota admission', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, admissions = 0, upstreams = 0;
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const options = {
|
||||
cacheKey: 'cold', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return disk; },
|
||||
allowUpstream: () => { admissions++; return true; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
};
|
||||
const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options));
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const results = await Promise.all(pending);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(admissions, 1);
|
||||
assert.equal(upstreams, 1);
|
||||
assert.ok(results.every((result) => result.payload === payload));
|
||||
assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']);
|
||||
assert.equal(inFlight.size, 0);
|
||||
const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } };
|
||||
const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]);
|
||||
assert.ok(rejected.every((result) => result.status === 'rejected'));
|
||||
assert.equal(inFlight.size, 0);
|
||||
await resolveOverpassPreflight({ ...options, readDisk: async () => null });
|
||||
assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry');
|
||||
});
|
||||
|
||||
test('a joined client retries admission with its own quota after another client is rate limited', async () => {
|
||||
const inFlight = new Map();
|
||||
let release;
|
||||
const disk = new Promise((resolve) => { release = resolve; });
|
||||
let reads = 0, upstreams = 0;
|
||||
const admissions = { a: 0, b: 0, c: 0 };
|
||||
const quota = { a: false, b: true, c: false };
|
||||
const payload = { status: 200, body: '{"elements":[]}' };
|
||||
const client = (name) => resolveOverpassPreflight({
|
||||
cacheKey: 'quota', memoryCache: new Map(), inFlight,
|
||||
readDisk: () => { reads++; return reads === 1 ? disk : null; },
|
||||
allowUpstream: () => { admissions[name]++; return quota[name]; },
|
||||
loadUpstream: async () => { upstreams++; return payload; },
|
||||
});
|
||||
// A (quota exhausted) owns the cold reservation; B (quota available) and
|
||||
// C (quota exhausted) join it while the disk lookup is pending.
|
||||
const pending = [client('a'), client('b'), client('c')];
|
||||
await Promise.resolve();
|
||||
assert.equal(inFlight.size, 1);
|
||||
release(null);
|
||||
const [a, b, c] = await Promise.all(pending);
|
||||
assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A");
|
||||
assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota');
|
||||
assert.equal(b.payload, payload);
|
||||
assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch");
|
||||
assert.equal(c.payload, payload);
|
||||
assert.deepEqual(admissions, { a: 1, b: 1, c: 0 });
|
||||
assert.equal(upstreams, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user