mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge branch 'main' into fix/kartaview-impossible-timestamps
This commit is contained in:
11 files changed
+566
-107
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).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Keep an impossible wildfire acquisition date off the detection clock, so a
|
||||||
|
fire is not labeled with a later day it did not happen
|
||||||
|
(Rudy Celekli, @rudycelekli, #1342).
|
||||||
+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
|
lookups preserve the previous analyst cohort and boundary source. Navigation
|
||||||
search keeps a successful geocode, with no search or view-based region recovery.
|
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
|
Overpass reserves each normalized query before its disk-cache lookup. The
|
||||||
delimiter once, skipping prolog and DOCTYPE comments and processing
|
initial disk lookup, upstream quota admission, fetching and memory publication
|
||||||
instructions whole. The scanner is a boundary check only: an opening KML tag
|
are coalesced under that reservation. A caller that joined a reservation
|
||||||
that never closes, or one with raw markup starting inside it, fails before DOM
|
rejected by another client's quota retries admission with its own quota.
|
||||||
parsing, so a file of repeated incomplete `<kml` prefixes cannot trigger
|
Stale fallback runs per response after the reservation is released, and disk
|
||||||
quadratic regex rescans. All other XML grammar, including attribute syntax
|
writes finish asynchronously. Settled and rejected reservations are removed
|
||||||
inside a complete root tag, is left to DOMParser. Namespace declarations are
|
only while they still own their map entry.
|
||||||
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.
|
|
||||||
|
|
||||||
Terrain height requests accept at most 64 points, matching the browser service's
|
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
|
existing batch size and one bounded upstream chunk. Larger requests return HTTP
|
||||||
|
|||||||
@@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) {
|
|||||||
readDisk: () =>
|
readDisk: () =>
|
||||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
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') {
|
if (preflight.source === 'RATE_LIMITED') {
|
||||||
res.writeHead(429, {
|
res.writeHead(429, {
|
||||||
@@ -173,71 +197,20 @@ function overpassProxy({ routing = {} } = {}) {
|
|||||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (preflight.source !== 'UPSTREAM') {
|
if (!overpassPayloadIsData(preflight.payload)) {
|
||||||
// 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)) {
|
|
||||||
const stale = await readStaleOverpass(cacheKey);
|
const stale = await readStaleOverpass(cacheKey);
|
||||||
if (stale) {
|
if (stale) {
|
||||||
sendOverpassResponse(res, stale, 'STALE');
|
sendOverpassResponse(res, stale, 'STALE');
|
||||||
return;
|
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) {
|
} catch (e) {
|
||||||
// Every mirror threw (network-level). Same serve-stale rule.
|
// Every mirror threw (network-level). Same serve-stale rule.
|
||||||
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
|
const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { coalesceProxyRequest } from '../common/http.js';
|
||||||
import { isOverpassBoundaryQuery } from './query.js';
|
import { isOverpassBoundaryQuery } from './query.js';
|
||||||
import {
|
import {
|
||||||
OVERPASS_BOUNDARY_DISK_TTL_MS,
|
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
|
* Resolve every cache/coalescing layer before admitting a request to the local
|
||||||
* upstream rate limiter. The injected limiter callback is invoked exactly once
|
* 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.
|
* Exported so the admission ordering can be tested without a Vite server.
|
||||||
*
|
*
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
@@ -87,6 +90,7 @@ function writeOverpassDisk(cacheKey, payload) {
|
|||||||
* @param {Map<string, Promise<object>>} options.inFlight
|
* @param {Map<string, Promise<object>>} options.inFlight
|
||||||
* @param {()=>Promise<object|null>} options.readDisk
|
* @param {()=>Promise<object|null>} options.readDisk
|
||||||
* @param {()=>boolean} options.allowUpstream
|
* @param {()=>boolean} options.allowUpstream
|
||||||
|
* @param {()=>Promise<object>} options.loadUpstream Fetch and publish the admitted result.
|
||||||
* @param {number} [options.now]
|
* @param {number} [options.now]
|
||||||
* @param {number} [options.cacheMs]
|
* @param {number} [options.cacheMs]
|
||||||
* @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>}
|
* @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>}
|
||||||
@@ -97,22 +101,34 @@ async function resolveOverpassPreflight({
|
|||||||
inFlight,
|
inFlight,
|
||||||
readDisk,
|
readDisk,
|
||||||
allowUpstream,
|
allowUpstream,
|
||||||
|
loadUpstream = async () => null,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
cacheMs = OVERPASS_CACHE_MS,
|
cacheMs = OVERPASS_CACHE_MS,
|
||||||
}) {
|
}) {
|
||||||
const cached = memoryCache.get(cacheKey);
|
for (;;) {
|
||||||
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
const cached = memoryCache.get(cacheKey);
|
||||||
return { source: 'HIT', payload: cached };
|
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
|
||||||
|
return { source: 'HIT', payload: cached };
|
||||||
|
|
||||||
const pending = inFlight.get(cacheKey);
|
// Reserve synchronously before disk I/O. The owner retains the reservation
|
||||||
if (pending) return { source: 'INFLIGHT', payload: await pending };
|
// through its own upstream admission, fetch and cache publication.
|
||||||
|
const request = coalesceProxyRequest(inFlight, cacheKey, async () => {
|
||||||
const disk = await readDisk();
|
const disk = await readDisk();
|
||||||
if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk };
|
if (overpassPayloadIsData(disk)) {
|
||||||
|
memoryCache.set(cacheKey, disk);
|
||||||
return allowUpstream()
|
return { source: 'DISK', payload: disk };
|
||||||
? { source: 'UPSTREAM', payload: null }
|
}
|
||||||
: { source: 'RATE_LIMITED', payload: null };
|
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. */
|
/** Return only last-good Overpass data, regardless of its age. */
|
||||||
|
|||||||
+33
-10
@@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) {
|
|||||||
const cached = cache.get(key);
|
const cached = cache.get(key);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
// Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February
|
||||||
|
// into March, and the fire's age would describe a detection that never happened.
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
cache.set(key, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const timeText = String(time ?? '').trim();
|
||||||
|
if (!/^\d{1,4}$/.test(timeText)) {
|
||||||
|
cache.set(key, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const hhmm = timeText.padStart(4, '0');
|
||||||
const year = Number(date.slice(0, 4));
|
const year = Number(date.slice(0, 4));
|
||||||
const month = Number(date.slice(5, 7));
|
const month = Number(date.slice(5, 7));
|
||||||
const day = Number(date.slice(8, 10));
|
const day = Number(date.slice(8, 10));
|
||||||
const hhmm = String(time ?? '0000').padStart(4, '0');
|
|
||||||
const hours = Number(hhmm.slice(0, 2));
|
const hours = Number(hhmm.slice(0, 2));
|
||||||
const minutes = Number(hhmm.slice(2, 4));
|
const minutes = Number(hhmm.slice(2, 4));
|
||||||
const valid =
|
if (
|
||||||
Number.isFinite(year) &&
|
month < 1 ||
|
||||||
Number.isFinite(month) &&
|
month > 12 ||
|
||||||
Number.isFinite(day) &&
|
day < 1 ||
|
||||||
Number.isFinite(hours) &&
|
day > 31 ||
|
||||||
Number.isFinite(minutes);
|
hours > 23 ||
|
||||||
const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0;
|
minutes > 59
|
||||||
cache.set(key, ms);
|
) {
|
||||||
return ms;
|
cache.set(key, 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const ms = Date.UTC(year, month - 1, day, hours, minutes);
|
||||||
|
const parsed = new Date(ms);
|
||||||
|
const result =
|
||||||
|
parsed.getUTCFullYear() === year &&
|
||||||
|
parsed.getUTCMonth() === month - 1 &&
|
||||||
|
parsed.getUTCDate() === day
|
||||||
|
? ms
|
||||||
|
: 0;
|
||||||
|
cache.set(key, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => {
|
|||||||
|
|
||||||
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => {
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
assert.equal(
|
||||||
|
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||||
|
Date.UTC(2026, 6, 16, 10, 6),
|
||||||
|
);
|
||||||
assert.equal(cache.size, 1);
|
assert.equal(cache.size, 1);
|
||||||
assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6));
|
assert.equal(
|
||||||
|
parseAcquisitionMs('2026-07-16', '1006', cache),
|
||||||
|
Date.UTC(2026, 6, 16, 10, 6),
|
||||||
|
);
|
||||||
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('impossible acquisition dates stay untimed instead of rolling forward', () => {
|
||||||
|
for (const [date, time] of [
|
||||||
|
['2026-02-30', '1200'],
|
||||||
|
['2026-04-31', '1200'],
|
||||||
|
['2026-13-01', '1200'],
|
||||||
|
['2026-07-16', '2400'],
|
||||||
|
['2026-07-16', '1260'],
|
||||||
|
]) {
|
||||||
|
assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`);
|
||||||
|
const [fire] = adaptFirmsRecords([
|
||||||
|
proxyRecord({ acqDate: date, acqTime: time }),
|
||||||
|
]);
|
||||||
|
assert.equal(fire.acqMs, 0, `${date}:${time}`);
|
||||||
|
assert.equal(fire.lat, 38.99488);
|
||||||
|
}
|
||||||
|
const [leap] = adaptFirmsRecords([
|
||||||
|
proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }),
|
||||||
|
]);
|
||||||
|
assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45));
|
||||||
|
});
|
||||||
|
|
||||||
test('daynight "D" → night false', () => {
|
test('daynight "D" → night false', () => {
|
||||||
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]);
|
||||||
assert.equal(fire.night, false);
|
assert.equal(fire.night, false);
|
||||||
@@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () =
|
|||||||
proxyRecord({ lat: 40.1 }),
|
proxyRecord({ lat: 40.1 }),
|
||||||
]);
|
]);
|
||||||
assert.equal(fires.length, 2);
|
assert.equal(fires.length, 2);
|
||||||
assert.deepEqual(fires.map((f) => f.index), [0, 1]);
|
assert.deepEqual(
|
||||||
|
fires.map((f) => f.index),
|
||||||
|
[0, 1],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-finite frp/brightness → 0; empty input → []', () => {
|
test('non-finite frp/brightness → 0; empty input → []', () => {
|
||||||
const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]);
|
const [fire] = adaptFirmsRecords([
|
||||||
|
proxyRecord({ frp: 'n/a', brightness: undefined }),
|
||||||
|
]);
|
||||||
assert.equal(fire.frp, 0);
|
assert.equal(fire.frp, 0);
|
||||||
assert.equal(fire.brightness, 0);
|
assert.equal(fire.brightness, 0);
|
||||||
assert.deepEqual(adaptFirmsRecords([]), []);
|
assert.deepEqual(adaptFirmsRecords([]), []);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
|||||||
const memoryHit = await resolveOverpassPreflight({
|
const memoryHit = await resolveOverpassPreflight({
|
||||||
cacheKey: key,
|
cacheKey: key,
|
||||||
memoryCache: new Map([[key, fresh]]),
|
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; },
|
readDisk: async () => { diskReads += 1; return disk; },
|
||||||
allowUpstream,
|
allowUpstream,
|
||||||
now: 1000,
|
now: 1000,
|
||||||
@@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
|||||||
const inFlightHit = await resolveOverpassPreflight({
|
const inFlightHit = await resolveOverpassPreflight({
|
||||||
cacheKey: key,
|
cacheKey: key,
|
||||||
memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]),
|
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; },
|
readDisk: async () => { diskReads += 1; return disk; },
|
||||||
allowUpstream,
|
allowUpstream,
|
||||||
now: 1000,
|
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(diskReads, 0, 'in-flight join must short-circuit before disk');
|
||||||
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
|
assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota');
|
||||||
|
|
||||||
|
const diskMemory = new Map();
|
||||||
const diskHit = await resolveOverpassPreflight({
|
const diskHit = await resolveOverpassPreflight({
|
||||||
cacheKey: key,
|
cacheKey: key,
|
||||||
memoryCache: new Map(),
|
memoryCache: diskMemory,
|
||||||
inFlight: new Map(),
|
inFlight: new Map(),
|
||||||
readDisk: async () => { diskReads += 1; return disk; },
|
readDisk: async () => { diskReads += 1; return disk; },
|
||||||
allowUpstream,
|
allowUpstream,
|
||||||
@@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo
|
|||||||
assert.equal(diskHit.payload, disk);
|
assert.equal(diskHit.payload, disk);
|
||||||
assert.equal(diskReads, 1);
|
assert.equal(diskReads, 1);
|
||||||
assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota');
|
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({
|
const upstreamMiss = await resolveOverpassPreflight({
|
||||||
cacheKey: key,
|
cacheKey: key,
|
||||||
@@ -214,3 +217,66 @@ function pointSegDistDeg(p, a, b) {
|
|||||||
const t = c1 / c2;
|
const t = c1 / c2;
|
||||||
return Math.hypot(wx - t * vx, wy - t * vy);
|
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 * as Cesium from 'cesium';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
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({
|
export function createLifecycle({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
services,
|
services,
|
||||||
@@ -8,6 +118,7 @@ export function createLifecycle({
|
|||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||||
|
let hover = null;
|
||||||
|
|
||||||
const methods = {
|
const methods = {
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
@@ -45,14 +156,15 @@ export function createLifecycle({
|
|||||||
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
|
layerState._moveHandler = new Cesium.ScreenSpaceEventHandler(
|
||||||
viewer.scene.canvas,
|
viewer.scene.canvas,
|
||||||
);
|
);
|
||||||
layerState._moveHandler.setInputAction((movement) => {
|
hover = createLaunchHover({
|
||||||
if (!layerState._enabled || !layerState._dataSource?.show) return;
|
viewer,
|
||||||
const missionEntity = viewer.scene
|
enabled: () => layerState._enabled && layerState._dataSource?.show,
|
||||||
.drillPick(movement.endPosition, 12)
|
entityLaunchId: parts.selection.entityLaunchId,
|
||||||
.map((picked) => picked?.id)
|
});
|
||||||
.find((candidate) => parts.selection.entityLaunchId(candidate));
|
layerState._moveHandler.setInputAction(
|
||||||
viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : '';
|
hover.move,
|
||||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||||
|
);
|
||||||
// Apply horizon visibility before Cesium traverses and picks the scene.
|
// Apply horizon visibility before Cesium traverses and picks the scene.
|
||||||
// A postRender write is one frame late and can remain visibly stale when
|
// A postRender write is one frame late and can remain visibly stale when
|
||||||
// request-on-demand rendering stops after a globe camera move.
|
// request-on-demand rendering stops after a globe camera move.
|
||||||
@@ -100,6 +212,7 @@ export function createLifecycle({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async disable() {
|
async disable() {
|
||||||
|
hover?.reset();
|
||||||
layerState._sourceController.abort();
|
layerState._sourceController.abort();
|
||||||
layerState._enabled = false;
|
layerState._enabled = false;
|
||||||
releaseContinuousRender('rocket-launches');
|
releaseContinuousRender('rocket-launches');
|
||||||
@@ -122,6 +235,8 @@ export function createLifecycle({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async destroy(viewer) {
|
async destroy(viewer) {
|
||||||
|
hover?.destroy();
|
||||||
|
hover = null;
|
||||||
layerState._sourceController.abort();
|
layerState._sourceController.abort();
|
||||||
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
|
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
|
||||||
layerState._enabled = false;
|
layerState._enabled = false;
|
||||||
|
|||||||
Reference in new issue
Block a user