Merge main into feat/voice-outlines

Adopts main's loadBundledJson(url, {signal}) and builds the Census and
Who's On First pack URLs when they are read, not at import.
This commit is contained in:
Bilawal Sidhu committed 2026-10-03 23:09:03 -07:00
commit 41b2475362
191 files changed
+20227 -1730

No files matched your search

+3 -21
View File
@@ -58,30 +58,18 @@ export function normalizeAdminName(value) {
const PACKS = {
countries: {
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/countries.json', {
with: { type: 'json' },
}),
},
admin1: {
url: new URL(
'./local_data/natural_earth/states_provinces.json',
import.meta.url,
),
importJson: () =>
import('./local_data/natural_earth/states_provinces.json', {
with: { type: 'json' },
}),
},
counties: {
url: new URL(
'./local_data/us_census_counties/counties.json',
import.meta.url,
),
importJson: () =>
import('./local_data/us_census_counties/counties.json', {
with: { type: 'json' },
}),
},
};
@@ -300,7 +288,7 @@ function addKey(index, key, entry, tier) {
const loadAdmin1 = createRetryableLoader(async () => {
packLoads.admin1 += 1;
const pack = await loadBundledJson(PACKS.admin1.url, PACKS.admin1.importJson);
const pack = await loadBundledJson(PACKS.admin1.url);
const decimals = pack.meta?.decimals ?? 3;
const index = new Map();
for (const feature of pack.features || []) {
@@ -326,10 +314,7 @@ const loadAdmin1 = createRetryableLoader(async () => {
const loadCounties = createRetryableLoader(async () => {
packLoads.counties += 1;
const pack = await loadBundledJson(
PACKS.counties.url,
PACKS.counties.importJson,
);
const pack = await loadBundledJson(PACKS.counties.url);
const decimals = pack.meta?.decimals ?? 4;
const byFull = new Map();
const byName = new Map();
@@ -351,10 +336,7 @@ const loadCounties = createRetryableLoader(async () => {
const loadCountries = createRetryableLoader(async () => {
packLoads.countries += 1;
const pack = await loadBundledJson(
PACKS.countries.url,
PACKS.countries.importJson,
);
const pack = await loadBundledJson(PACKS.countries.url);
const index = new Map();
for (const feature of pack.features || []) {
const entry = {
+3
View File
@@ -5,6 +5,9 @@ import { createAisStreamSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
// Resolved against the document's address, which a panel host may serve
// from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
}),
options: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
+22 -17
View File
@@ -4,33 +4,38 @@
* The browser fetches the file from `url` — a `new URL('./….json',
* import.meta.url)` written in the calling module, which Vite serves as-is in
* dev and emits as an asset in the production build. Under Node (node:test)
* that URL is a `file:` URL, which `fetch` cannot read, so the caller's
* JSON-attributed import runs instead.
* that URL is a `file:` URL, which `fetch` cannot read, so the file is read
* from disk instead.
*
* The attributed import cannot serve the browser: the Vite dev server answers
* it with a JavaScript module, which the browser rejects for a JSON import.
*
* A pack split into many files passes no `importJson` (a literal import per
* file would also bundle every file as a script chunk); under Node its file
* is then read directly.
* Nothing here imports the JSON as a module. A JSON-attributed import cannot
* serve the browser (the Vite dev server answers it with a JavaScript module,
* which the browser rejects), and a literal `import('./….json')` makes the
* production build emit a JavaScript copy of every pack that the browser
* never loads.
*
* @module data/bundledJson
*/
/**
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
* @param {(() => Promise<{default: any}>)|null} [importJson] - The caller's
* literal `import('./….json', { with: { type: 'json' } })`, used only under
* Node; without it Node reads the file.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<any>} The parsed JSON.
*/
export async function loadBundledJson(url, importJson, { signal } = {}) {
if (url.protocol === 'file:') {
if (importJson) return (await importJson()).default;
const fs = globalThis.process.getBuiltinModule('node:fs');
return JSON.parse(fs.readFileSync(url, 'utf8'));
}
export async function loadBundledJson(url, { signal } = {}) {
signal?.throwIfAborted();
if (url.protocol === 'file:') {
// Node's built-in, reached without an import, so the browser build never
// bundles or rewrites it; only Node, reading `file:` URLs, gets here. The
// read is synchronous so the result settles as promptly as the cached
// module import it replaces; callers already memoize each pack.
const getBuiltinModule = globalThis.process?.getBuiltinModule;
if (typeof getBuiltinModule !== 'function')
throw new Error(
`God's Eye View needs Node 24.14 or newer to read ${url.pathname.split('/').pop()} (running ${globalThis.process?.version ?? 'an unknown runtime'})`,
);
const { readFileSync } = getBuiltinModule('node:fs');
return JSON.parse(readFileSync(url, 'utf8'));
}
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
+57 -10
View File
@@ -2,13 +2,14 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadBundledJson } from './bundledJson.js';
const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url);
const importMarine = () =>
import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
const MARINE = new URL(
'./local_data/natural_earth/marine.json',
import.meta.url,
);
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
test('bundled JSON: a file URL is read from disk under Node', async (t) => {
const fetchMock = t.mock.method(globalThis, 'fetch');
const pack = await loadBundledJson(MARINE, importMarine);
const pack = await loadBundledJson(MARINE);
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
assert.equal(fetchMock.mock.callCount(), 0);
});
@@ -21,16 +22,62 @@ test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
headers: { 'content-type': 'application/json' },
});
});
const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json');
const importJson = () => assert.fail('the browser path must not import JSON');
assert.deepEqual(await loadBundledJson(url, importJson), { features: [] });
const url = new URL(
'http://localhost/src/data/local_data/natural_earth/marine.json',
);
assert.deepEqual(await loadBundledJson(url), { features: [] });
assert.deepEqual(requests, [url.href]);
});
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
t.mock.method(
globalThis,
'fetch',
async () => new Response('', { status: 404 }),
);
await assert.rejects(
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
loadBundledJson(new URL('http://localhost/missing.json')),
/HTTP 404/,
);
});
test('bundled JSON: an aborted load rejects before reading', async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(loadBundledJson(MARINE, { signal: controller.signal }), {
name: 'AbortError',
});
});
test('bundled JSON: no data pack is imported as a module', async () => {
// A literal JSON import in app code makes the production build emit a
// JavaScript copy of the pack beside the fetched JSON, which the browser
// never loads. Tests are not bundled, so they may still import JSON.
const { spawnSync } = await import('node:child_process');
const result = spawnSync(
'git',
[
'grep',
'-nE',
String.raw`import\(\s*['"][^'"]*local_data/[^'"]*\.json`,
'--',
'src',
':!*.test.mjs',
],
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
);
// git grep exits 1 when nothing matches.
assert.equal(result.status, 1, result.stdout);
});
test('bundled JSON: an older Node says which version is needed', async (t) => {
// Node before 20.16 / 22.3 has no process.getBuiltinModule.
const original = process.getBuiltinModule;
t.after(() => {
process.getBuiltinModule = original;
});
process.getBuiltinModule = undefined;
await assert.rejects(loadBundledJson(MARINE), {
message: /needs Node 24\.14 or newer to read marine\.json/,
});
});
+25
View File
@@ -128,6 +128,14 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
sources.slice(0, 3).map((s) => s.id),
['env-0', 'env-1', 'env-2'],
);
assert.equal(sources[0].pack, 'env');
const [trimmed] = getSources.trimmedPacks();
assert.deepEqual(
{ ...trimmed, region: undefined },
{ pack: 'env', available: 12, served: 9, region: undefined },
);
assert.equal(trimmed.region.south, 30);
assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
@@ -135,3 +143,20 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
Object.assign(process.env, saved);
}
});
test('a pack trimmed to its nearest cameras reports how many it offered', async () => {
const { prioritizeSources } =
await import('../../server/providers/cctv/normalize.js');
const cameras = Array.from({ length: 5 }, (_, i) => ({
id: `c${i}`,
lat: i,
lon: 0,
}));
const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]);
assert.deepEqual(
kept.map((camera) => camera.id),
['c0', 'c1'],
);
assert.equal(kept.available, 5);
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
});
+3 -8
View File
@@ -1,12 +1,5 @@
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
// Resolved by Vite in builds and relative to this module in other consumers.
const datacentersUrl = new URL(
'./local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href;
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
.href;
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
/**
* Create fresh datacenter and dam layers without starting or loading them.
@@ -14,6 +7,8 @@ const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
* @returns {object[]} Datacenters then dams, with stable standalone identities.
*/
export function createInfrastructureLayers(services) {
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
const datacenters = createLocalGeoJsonLayer(
{
id: 'local-datacenters',
+1
View File
@@ -192,6 +192,7 @@ test('consumer build includes only infrastructure code and resolves assets under
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
'infrastructure.js',
'infrastructureData.js',
'infrastructureOverlayEntry.js',
'localGeojsonCore.js',
'localGeojsonLod.js',
+2 -723
View File
@@ -11,8 +11,6 @@ export const LAYER_STATE_TOKEN_ALPHABET =
'0123456789abcdefghijklmnopqrstuvwxyz';
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
const PENDING_TRACKING_POLL_MS = 1_000;
/**
* A tracking ID is a transponder address, not free text: 6 hex digits for an
* ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding
@@ -433,12 +431,6 @@ const OPTION_GROUPS = Object.freeze({
]),
});
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
flights: 'selectedFlightsTrackingId',
military: 'selectedMilitaryTrackingId',
satellites: 'selectedSatTrackingId',
});
export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
flights: Object.freeze({
optionOwner: 'flights',
@@ -740,7 +732,7 @@ export function validateLayerStateAllocations(
return true;
}
const REGISTRY_BY_ID = new Map(
export const REGISTRY_BY_ID = new Map(
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
);
const REGISTRY_BY_TOKEN = new Map(
@@ -752,7 +744,7 @@ const OPTION_OWNER_IDS = Object.freeze([
),
]);
function optionSpecs(ownerId) {
export function optionSpecs(ownerId) {
return OPTION_GROUPS[ownerId] || [];
}
@@ -1055,716 +1047,3 @@ export function layerOptionsForRestore(state, layerId) {
if (!entry?.optionOwner) return null;
return { ...normalizeLayerState(state).options[entry.optionOwner] };
}
function safeStorage() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}
function currentLayerOutcome(dataManager, layerId) {
const state = dataManager.getLayerLifecycleState?.(layerId);
return {
settledEnabled: Boolean(state?.enabled),
lifecycleState: state?.lifecycleState || 'missing',
lifecycleUncertain: Boolean(state?.uncertain),
};
}
/**
* Owns durable user layer preferences independently from transient runtime
* choreography, and coordinates passive post-registration restoration.
*/
export class LayerStateCoordinator {
constructor(
dataManager,
shareLinkManager,
{
storage = safeStorage(),
restoreGate = null,
onDurableStateChange = null,
onTrackingRestoreStatus = null,
now = () => Date.now(),
// Injectable so the pending-window behavior is deterministically testable
// without sleeping out a 90 s expiry.
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (handle) => clearTimeout(handle),
} = {},
) {
if (!dataManager?.registrationsFinalized) {
throw new Error(
'Layer state requires finalized data-layer registrations',
);
}
this.dataManager = dataManager;
this.shareLinkManager = shareLinkManager || null;
this.storage = storage;
this.restoreGate = restoreGate;
this.onDurableStateChange = onDurableStateChange;
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this._durableState = createDefaultLayerState();
this._source = 'defaults';
this._destroyed = false;
this._restoreControllers = new Map();
this._shareCreatedAtMs = null;
this._trackingRestoreController = null;
this._trackingRestoreGeneration = 0;
this._pendingTrackingTimer = null;
this._pendingTrackingContext = null;
this._unsubscribe = this.dataManager.subscribe((change) =>
this._handleManagerChange(change),
);
this._unsubscribeVisibilityRequests =
this.dataManager.subscribeVisibilityRequests((change) =>
this._handleVisibilityRequest(change),
);
this.restorePromise = Promise.resolve([]);
this.lastRestoreResults = [];
}
start({
shareLayerState = null,
allowLocalState = true,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
if (selected) {
this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
? shareCreatedAtMs
: null;
} else if (allowLocalState) {
let stored = null;
try {
stored = parseStoredLayerState(
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
);
} catch {
/* best effort */
}
if (stored) {
selected = stored;
this._source = 'local';
}
} else {
// A valid historical camera/style share with no v2 layer payload keeps
// the exact legacy default-layer behavior. It must not inherit an
// unrelated recipient's saved local layer preferences.
this._source = 'legacy-share';
}
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.setLayerStateProvider?.(() =>
this.getDurableState(),
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
if (!selected) return this.restorePromise;
this.restorePromise = this._restoreSelectedState(
this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
);
return this.restorePromise;
}
get source() {
return this._source;
}
getDurableState() {
return cloneLayerState(this._durableState);
}
_notifyDurableState() {
try {
this.onDurableStateChange?.(this.getDurableState());
} catch {
/* UI sync is best effort */
}
}
_handleVisibilityRequest(change) {
if (!isExplicitLayerStateOrigin(change?.origin)) return;
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
}
/** Revoke every passive restore before explicit navigation can be reclaimed. */
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
for (const controller of this._restoreControllers.values())
controller.abort(reason);
this._revokePendingTrackingWatch(reason);
}
/**
* Revoke a pending shared Follow. Physical navigation may also clear only
* the exact passive selection, without writing recipient preferences.
*/
cancelPendingShareTracking(
reason = 'superseded-by-explicit-navigation',
{ clearSelection = false } = {},
) {
this._revokePendingTrackingWatch(reason);
if (!clearSelection) return false;
const selected = this._selectedShareTrackingTarget();
return selected ? this._passivelyClearTrackingSelection(selected) : false;
}
_handleManagerChange(change) {
if (!change || this._destroyed) return;
// Parameter and visibility ownership are independent. A newer explicit
// option request may replace passive share options, but it must not abort
// the same layer's visibility lifecycle.
if (change.type === 'params-requested') {
if (
isExplicitLayerStateOrigin(change.origin) &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('superseded-by-explicit-params');
}
return;
}
// A layer that goes away takes its latch with it, at ANY origin — a
// programmatic disable or teardown never reaches the explicit-intent path
// below, so revoke here before that early return.
if (
change.type === 'visibility' &&
change.enabled === false &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
const enabled = new Set(this._durableState.enabledLayerIds);
if (change.enabled) enabled.add(change.layerId);
else enabled.delete(change.layerId);
this._commitExplicit({
...this._durableState,
enabledLayerIds: [...enabled],
});
return;
}
if (change.type !== 'params') return;
const entry = REGISTRY_BY_ID.get(change.layerId);
if (!entry?.optionOwner) return;
const ownerId = entry.optionOwner;
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
const requestedParams = change.requestedParams || {};
const trackingOptionKey =
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
let changed = false;
for (const spec of optionSpecs(ownerId)) {
// Only persist keys present in this explicit request. getLayerParams()
// can return a wider live snapshot containing transient or passively
// changed values that this user action did not own. Tracking is the one
// exception: an unrelated explicit option cancels a pending restoration
// in that family, so its wider live value (active ID or null) must replace
// the formerly durable pending ID instead of allowing reload resurrection.
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
changed = true;
continue;
}
if (spec.defaultValue !== null || change.params[spec.key] !== null)
continue;
}
nextOwnerOptions[spec.key] = value;
changed = true;
}
if (!changed) return;
this._commitExplicit({
...this._durableState,
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
});
}
_commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate);
const serialized = serializeStoredLayerState(this._durableState);
try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
}
} catch {
/* storage can be unavailable or quota-limited */
}
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
}
async _waitForRestoreGate() {
if (!this.restoreGate) return;
await (typeof this.restoreGate === 'function'
? this.restoreGate()
: this.restoreGate);
}
async _restoreSelectedState(origin) {
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
}
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
TRACKING_OPTION_KEY_BY_LAYER,
)) {
delete options[trackingKey];
}
}
if (this._destroyed || controller?.signal.aborted) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'reserved',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
errorClass: 'cancelled',
persistenceWrite: false,
succeeded: false,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
const paramsSucceeded =
!options ||
Object.keys(options).length === 0 ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return this.dataManager
.restoreLayerState(
entry.id,
{
enabled: targetEnabled,
params: null,
},
{ origin, signal: controller.signal },
)
.then((result) => ({
...result,
appliedOptions: paramsSucceeded && options ? options : {},
errorClass: paramsSucceeded
? result.errorClass
: 'ParamsRejected',
succeeded: paramsSucceeded && result.succeeded,
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
layerId: entry.id,
targetEnabled: enabled.has(entry.id),
origin,
phase: 'coordinator',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: null,
errorClass: result.reason?.name || 'Error',
error: String(result.reason?.message || result.reason),
persistenceWrite: false,
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
this._notifyDurableState();
}
}
_selectedShareTrackingTarget() {
if (this._source !== 'share') return null;
for (const [layerId, policy] of Object.entries(
SHARE_TRACKING_RESTORE_POLICIES,
)) {
const targetId =
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
if (targetId !== null && targetId !== undefined && targetId !== '') {
return { layerId, targetId, ...policy };
}
}
return null;
}
_passivelyClearTrackingSelection(selected) {
const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
if (String(current) !== String(selected.targetId)) return false;
const ownerOptions = {
...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null,
};
this._durableState = normalizeLayerState({
...this._durableState,
options: {
...this._durableState.options,
[selected.optionOwner]: ownerOptions,
},
});
this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: null },
{ origin: LAYER_RESTORE_ORIGINS.share },
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
return true;
}
/**
* `atMs` is the moment the subject was first found ABSENT, not the moment the
* verdict is delivered. Waiting out the pending window must not by itself
* push a fresh link into the "expired" wording — that word describes the
* SHARE's age, not how long this client watched for the subject.
*/
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
const copiedAt = this._shareCreatedAtMs;
if (!Number.isFinite(copiedAt)) return 'unavailable';
const ageMs = atMs - copiedAt;
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
}
/** Whether the owning layer currently follows the shared subject. */
_trackingTargetLatched(selected) {
const params = this.dataManager.getLayerParams?.(selected.layerId);
const active = params?.[selected.optionKey];
return (
active !== null &&
active !== undefined &&
String(active) === String(selected.targetId)
);
}
/** Stop watching a pending shared subject without deciding its fate. */
_cancelPendingTrackingWatch() {
if (this._pendingTrackingTimer !== null)
this.clearTimer(this._pendingTrackingTimer);
this._pendingTrackingTimer = null;
const pending = this._pendingTrackingContext;
if (pending?.signal && pending.abortHandler) {
pending.signal.removeEventListener('abort', pending.abortHandler);
pending.abortHandler = null;
}
}
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
_publishTrackingRestoreStatus(status) {
try {
this.onTrackingRestoreStatus?.(status);
} catch {
/* status UI is best effort */
}
}
/**
* Revoke a pending shared Follow wholesale.
*
* The watch and the LAYER's own deferred-restore latch are two halves of one
* mechanism, so they must die together. Aborting only the restore controller
* left the timer alive: the controller has already settled by the time the
* watch exists, so the abort was a no-op and the orphaned timer went on to
* announce "Shared … unavailable" for a subject whose latch had been
* cancelled — a notice about work no longer being attempted.
*/
_revokePendingTrackingWatch(reason) {
this._trackingRestoreController?.abort(reason);
this._trackingRestoreGeneration += 1;
this._cancelPendingTrackingWatch();
const pending = this._pendingTrackingContext;
this._pendingTrackingContext = null;
if (pending) {
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(reason || 'cancelled'),
});
this._publishTrackingRestoreStatus({
...pending.probe,
...pending.selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(reason || 'cancelled'),
cleared: false,
});
}
}
/**
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
*
* A recipient's first authoritative refresh routinely lands without a given
* contact — the feed is polled, coverage is partial, and rendering trails the
* snapshot by a poll. Reload-from-local already survives this: its restore
* arms the layer's own deferred-restore latch, which re-attempts on every
* later poll. The shared path used to decide on that single refresh, clear
* the subject from durable state AND from the URL, then post a failure notice
* seconds into startup — so the same link healed on reload but never on the
* share.
*
* Arm the SAME latch, then watch it in the background: the caller is never
* blocked (startup must not wait out a 90 s window before it may write the
* URL again), and the terminal verdict is deferred until the source-specific
* window has genuinely expired. The existing wordings are unchanged.
*/
async _beginPendingTrackingRestore(selected, probe, signal = null) {
const generation = this._trackingRestoreGeneration;
const absentAtMs = this.now();
// Arm the layer's deferred-restore latch under the passive share origin, so
// it re-attempts each poll and never rewrites recipient preferences.
let armed = false;
let armError = null;
try {
armed =
(await this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: selected.targetId },
{ origin: LAYER_RESTORE_ORIGINS.share },
)) === true;
} catch (error) {
armError = error;
}
if (
this._destroyed ||
generation !== this._trackingRestoreGeneration ||
signal?.aborted
) {
if (armed) {
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(signal?.reason || 'superseded'),
});
}
return {
...probe,
...selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(signal?.reason || 'superseded'),
cleared: false,
};
}
if (!armed) {
const terminal = {
...probe,
...selected,
status: 'source-unavailable',
classification: 'source-unavailable',
reason: String(
armError?.message || armError || 'tracking restore latch rejected',
),
cleared: this._passivelyClearTrackingSelection(selected),
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
const deadline = this.now() + selected.expiryWindowMs;
const settle = (terminal) => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._cancelPendingTrackingWatch();
this._pendingTrackingContext = null;
this._publishTrackingRestoreStatus(terminal);
};
const poll = () => {
this._pendingTrackingTimer = null;
if (this._destroyed || generation !== this._trackingRestoreGeneration)
return;
// The layer that owns the latch may have gone away since the last tick
// (disable, teardown, replacement). There is nothing left attempting this
// restore, so abandon it silently rather than announcing a verdict.
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
this._revokePendingTrackingWatch('owner-layer-disabled');
return;
}
if (this._trackingTargetLatched(selected)) {
settle({
...probe,
...selected,
status: 'found',
classification: 'followed',
cleared: false,
});
return;
}
if (this.now() >= deadline) {
// The window really has elapsed — only now does the verdict apply.
const classification =
probe.status === 'missing'
? this._classifyMissingTrackingTarget(selected, absentAtMs)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
settle({ ...probe, ...selected, classification, cleared });
return;
}
this._pendingTrackingTimer = this.setTimer(
poll,
PENDING_TRACKING_POLL_MS,
);
this._pendingTrackingTimer?.unref?.();
};
this._cancelPendingTrackingWatch();
const pending = {
...probe,
...selected,
status: 'pending',
classification: 'pending',
cleared: false,
};
const pendingContext = {
generation,
selected,
probe,
signal,
abortHandler: null,
};
if (signal) {
pendingContext.abortHandler = () => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._revokePendingTrackingWatch(signal.reason || 'aborted');
};
signal.addEventListener('abort', pendingContext.abortHandler, {
once: true,
});
}
this._pendingTrackingContext = pendingContext;
this._publishTrackingRestoreStatus(pending);
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
this._pendingTrackingTimer?.unref?.();
return pending;
}
/**
* Refresh and restore the one shareable tracked target after destination
* camera and ordinary layer restoration have settled.
*/
async restoreShareTrackingSelection({ signal = null } = {}) {
const selected = this._selectedShareTrackingTarget();
if (!selected || this._destroyed)
return { status: 'skipped', reason: 'no-shared-target' };
this._revokePendingTrackingWatch('superseded-by-newer-restore');
const controller = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
this._trackingRestoreController = controller;
const generation = ++this._trackingRestoreGeneration;
let result;
try {
result = await this.dataManager.resolveLayerTrackingTarget(
selected.layerId,
selected.targetId,
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
);
} catch (error) {
result = combinedSignal.aborted
? {
status: 'cancelled',
reason: String(combinedSignal.reason || 'aborted'),
}
: {
status: 'source-unavailable',
reason: String(error?.message || error),
};
}
if (
generation !== this._trackingRestoreGeneration ||
this._destroyed ||
combinedSignal.aborted
) {
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
return {
...result,
status: 'cancelled',
reason: String(combinedSignal.reason || 'superseded'),
};
}
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
if (result.status === 'found') {
const terminal = {
...result,
...selected,
classification: 'followed',
cleared: false,
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
return result;
// A subject that is simply not here YET is not a subject that is gone. Hold
// it on the layer's own deferred-restore latch for its source-specific
// window before any verdict is reached or shown. `unsupported` layers have
// no latch to arm, so they still decide immediately.
if (result.status === 'missing' || result.status === 'source-unavailable') {
return this._beginPendingTrackingRestore(
selected,
result,
combinedSignal,
);
}
const classification =
result.status === 'missing'
? this._classifyMissingTrackingTarget(selected)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
const terminal = { ...result, ...selected, classification, cleared };
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
for (const controller of this._restoreControllers.values())
controller.abort('coordinator-destroyed');
this._restoreControllers.clear();
this._revokePendingTrackingWatch('coordinator-destroyed');
this._trackingRestoreController = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeVisibilityRequests?.();
this._unsubscribeVisibilityRequests = null;
this.shareLinkManager?.setLayerStateProvider?.(null);
this.onDurableStateChange = null;
this.onTrackingRestoreStatus = null;
}
}
+1 -1
View File
@@ -11,7 +11,6 @@ import {
LAYER_STATE_STORAGE_KEY,
LAYER_STATE_TOKEN_ALPHABET,
LAYER_STATE_TOKEN_RESERVATIONS,
LayerStateCoordinator,
REGISTERED_LAYER_IDS,
SHARE_TRACKING_RESTORE_POLICIES,
createDefaultLayerState,
@@ -24,6 +23,7 @@ import {
validateLayerStateAllocations,
validateLayerStateRegistry,
} from './layerState.js';
import { LayerStateCoordinator } from './layerStateCoordinator.js';
import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
+742
View File
@@ -0,0 +1,742 @@
/**
* Applies layer state to the running application: restores shared and stored
* layer choices and persists changes to browser storage.
*/
import {
LAYER_RESTORE_ORIGINS,
LAYER_STATE_REGISTRY,
LAYER_STATE_STORAGE_KEY,
REGISTRY_BY_ID,
SHARE_TRACKING_RESTORE_POLICIES,
cloneLayerState,
createDefaultLayerState,
isExplicitLayerStateOrigin,
layerOptionsForRestore,
normalizeLayerState,
optionSpecs,
parseStoredLayerState,
serializeStoredLayerState,
} from './layerState.js';
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
const PENDING_TRACKING_POLL_MS = 1_000;
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
flights: 'selectedFlightsTrackingId',
military: 'selectedMilitaryTrackingId',
satellites: 'selectedSatTrackingId',
});
function safeStorage() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}
function currentLayerOutcome(dataManager, layerId) {
const state = dataManager.getLayerLifecycleState?.(layerId);
return {
settledEnabled: Boolean(state?.enabled),
lifecycleState: state?.lifecycleState || 'missing',
lifecycleUncertain: Boolean(state?.uncertain),
};
}
/**
* Owns durable user layer preferences independently from transient runtime
* choreography, and coordinates passive post-registration restoration.
*/
export class LayerStateCoordinator {
constructor(
dataManager,
shareLinkManager,
{
storage = safeStorage(),
restoreGate = null,
onDurableStateChange = null,
onTrackingRestoreStatus = null,
now = () => Date.now(),
// Injectable so the pending-window behavior is deterministically testable
// without sleeping out a 90 s expiry.
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (handle) => clearTimeout(handle),
} = {},
) {
if (!dataManager?.registrationsFinalized) {
throw new Error(
'Layer state requires finalized data-layer registrations',
);
}
this.dataManager = dataManager;
this.shareLinkManager = shareLinkManager || null;
this.storage = storage;
this.restoreGate = restoreGate;
this.onDurableStateChange = onDurableStateChange;
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this._durableState = createDefaultLayerState();
this._source = 'defaults';
this._destroyed = false;
this._restoreControllers = new Map();
this._shareCreatedAtMs = null;
this._trackingRestoreController = null;
this._trackingRestoreGeneration = 0;
this._pendingTrackingTimer = null;
this._pendingTrackingContext = null;
this._unsubscribe = this.dataManager.subscribe((change) =>
this._handleManagerChange(change),
);
this._unsubscribeVisibilityRequests =
this.dataManager.subscribeVisibilityRequests((change) =>
this._handleVisibilityRequest(change),
);
this.restorePromise = Promise.resolve([]);
this.lastRestoreResults = [];
}
start({
shareLayerState = null,
allowLocalState = true,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
if (selected) {
this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
? shareCreatedAtMs
: null;
} else if (allowLocalState) {
let stored = null;
try {
stored = parseStoredLayerState(
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
);
} catch {
/* best effort */
}
if (stored) {
selected = stored;
this._source = 'local';
}
} else {
// A valid historical camera/style share with no v2 layer payload keeps
// the exact legacy default-layer behavior. It must not inherit an
// unrelated recipient's saved local layer preferences.
this._source = 'legacy-share';
}
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.setLayerStateProvider?.(() =>
this.getDurableState(),
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
if (!selected) return this.restorePromise;
this.restorePromise = this._restoreSelectedState(
this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
);
return this.restorePromise;
}
get source() {
return this._source;
}
getDurableState() {
return cloneLayerState(this._durableState);
}
_notifyDurableState() {
try {
this.onDurableStateChange?.(this.getDurableState());
} catch {
/* UI sync is best effort */
}
}
_handleVisibilityRequest(change) {
if (!isExplicitLayerStateOrigin(change?.origin)) return;
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
}
/** Revoke every passive restore before explicit navigation can be reclaimed. */
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
for (const controller of this._restoreControllers.values())
controller.abort(reason);
this._revokePendingTrackingWatch(reason);
}
/**
* Revoke a pending shared Follow. Physical navigation may also clear only
* the exact passive selection, without writing recipient preferences.
*/
cancelPendingShareTracking(
reason = 'superseded-by-explicit-navigation',
{ clearSelection = false } = {},
) {
this._revokePendingTrackingWatch(reason);
if (!clearSelection) return false;
const selected = this._selectedShareTrackingTarget();
return selected ? this._passivelyClearTrackingSelection(selected) : false;
}
_handleManagerChange(change) {
if (!change || this._destroyed) return;
// Parameter and visibility ownership are independent. A newer explicit
// option request may replace passive share options, but it must not abort
// the same layer's visibility lifecycle.
if (change.type === 'params-requested') {
if (
isExplicitLayerStateOrigin(change.origin) &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('superseded-by-explicit-params');
}
return;
}
// A layer that goes away takes its latch with it, at ANY origin — a
// programmatic disable or teardown never reaches the explicit-intent path
// below, so revoke here before that early return.
if (
change.type === 'visibility' &&
change.enabled === false &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
const enabled = new Set(this._durableState.enabledLayerIds);
if (change.enabled) enabled.add(change.layerId);
else enabled.delete(change.layerId);
this._commitExplicit({
...this._durableState,
enabledLayerIds: [...enabled],
});
return;
}
if (change.type !== 'params') return;
const entry = REGISTRY_BY_ID.get(change.layerId);
if (!entry?.optionOwner) return;
const ownerId = entry.optionOwner;
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
const requestedParams = change.requestedParams || {};
const trackingOptionKey =
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
let changed = false;
for (const spec of optionSpecs(ownerId)) {
// Only persist keys present in this explicit request. getLayerParams()
// can return a wider live snapshot containing transient or passively
// changed values that this user action did not own. Tracking is the one
// exception: an unrelated explicit option cancels a pending restoration
// in that family, so its wider live value (active ID or null) must replace
// the formerly durable pending ID instead of allowing reload resurrection.
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
changed = true;
continue;
}
if (spec.defaultValue !== null || change.params[spec.key] !== null)
continue;
}
nextOwnerOptions[spec.key] = value;
changed = true;
}
if (!changed) return;
this._commitExplicit({
...this._durableState,
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
});
}
_commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate);
const serialized = serializeStoredLayerState(this._durableState);
try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
}
} catch {
/* storage can be unavailable or quota-limited */
}
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
}
async _waitForRestoreGate() {
if (!this.restoreGate) return;
await (typeof this.restoreGate === 'function'
? this.restoreGate()
: this.restoreGate);
}
async _restoreSelectedState(origin) {
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
}
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
TRACKING_OPTION_KEY_BY_LAYER,
)) {
delete options[trackingKey];
}
}
if (this._destroyed || controller?.signal.aborted) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'reserved',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
errorClass: 'cancelled',
persistenceWrite: false,
succeeded: false,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
const paramsSucceeded =
!options ||
Object.keys(options).length === 0 ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return this.dataManager
.restoreLayerState(
entry.id,
{
enabled: targetEnabled,
params: null,
},
{ origin, signal: controller.signal },
)
.then((result) => ({
...result,
appliedOptions: paramsSucceeded && options ? options : {},
errorClass: paramsSucceeded
? result.errorClass
: 'ParamsRejected',
succeeded: paramsSucceeded && result.succeeded,
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
layerId: entry.id,
targetEnabled: enabled.has(entry.id),
origin,
phase: 'coordinator',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: null,
errorClass: result.reason?.name || 'Error',
error: String(result.reason?.message || result.reason),
persistenceWrite: false,
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
this._notifyDurableState();
}
}
_selectedShareTrackingTarget() {
if (this._source !== 'share') return null;
for (const [layerId, policy] of Object.entries(
SHARE_TRACKING_RESTORE_POLICIES,
)) {
const targetId =
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
if (targetId !== null && targetId !== undefined && targetId !== '') {
return { layerId, targetId, ...policy };
}
}
return null;
}
_passivelyClearTrackingSelection(selected) {
const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
if (String(current) !== String(selected.targetId)) return false;
const ownerOptions = {
...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null,
};
this._durableState = normalizeLayerState({
...this._durableState,
options: {
...this._durableState.options,
[selected.optionOwner]: ownerOptions,
},
});
this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: null },
{ origin: LAYER_RESTORE_ORIGINS.share },
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
return true;
}
/**
* `atMs` is the moment the subject was first found ABSENT, not the moment the
* verdict is delivered. Waiting out the pending window must not by itself
* push a fresh link into the "expired" wording — that word describes the
* SHARE's age, not how long this client watched for the subject.
*/
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
const copiedAt = this._shareCreatedAtMs;
if (!Number.isFinite(copiedAt)) return 'unavailable';
const ageMs = atMs - copiedAt;
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
}
/** Whether the owning layer currently follows the shared subject. */
_trackingTargetLatched(selected) {
const params = this.dataManager.getLayerParams?.(selected.layerId);
const active = params?.[selected.optionKey];
return (
active !== null &&
active !== undefined &&
String(active) === String(selected.targetId)
);
}
/** Stop watching a pending shared subject without deciding its fate. */
_cancelPendingTrackingWatch() {
if (this._pendingTrackingTimer !== null)
this.clearTimer(this._pendingTrackingTimer);
this._pendingTrackingTimer = null;
const pending = this._pendingTrackingContext;
if (pending?.signal && pending.abortHandler) {
pending.signal.removeEventListener('abort', pending.abortHandler);
pending.abortHandler = null;
}
}
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
_publishTrackingRestoreStatus(status) {
try {
this.onTrackingRestoreStatus?.(status);
} catch {
/* status UI is best effort */
}
}
/**
* Revoke a pending shared Follow wholesale.
*
* The watch and the LAYER's own deferred-restore latch are two halves of one
* mechanism, so they must die together. Aborting only the restore controller
* left the timer alive: the controller has already settled by the time the
* watch exists, so the abort was a no-op and the orphaned timer went on to
* announce "Shared … unavailable" for a subject whose latch had been
* cancelled — a notice about work no longer being attempted.
*/
_revokePendingTrackingWatch(reason) {
this._trackingRestoreController?.abort(reason);
this._trackingRestoreGeneration += 1;
this._cancelPendingTrackingWatch();
const pending = this._pendingTrackingContext;
this._pendingTrackingContext = null;
if (pending) {
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(reason || 'cancelled'),
});
this._publishTrackingRestoreStatus({
...pending.probe,
...pending.selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(reason || 'cancelled'),
cleared: false,
});
}
}
/**
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
*
* A recipient's first authoritative refresh routinely lands without a given
* contact — the feed is polled, coverage is partial, and rendering trails the
* snapshot by a poll. Reload-from-local already survives this: its restore
* arms the layer's own deferred-restore latch, which re-attempts on every
* later poll. The shared path used to decide on that single refresh, clear
* the subject from durable state AND from the URL, then post a failure notice
* seconds into startup — so the same link healed on reload but never on the
* share.
*
* Arm the SAME latch, then watch it in the background: the caller is never
* blocked (startup must not wait out a 90 s window before it may write the
* URL again), and the terminal verdict is deferred until the source-specific
* window has genuinely expired. The existing wordings are unchanged.
*/
async _beginPendingTrackingRestore(selected, probe, signal = null) {
const generation = this._trackingRestoreGeneration;
const absentAtMs = this.now();
// Arm the layer's deferred-restore latch under the passive share origin, so
// it re-attempts each poll and never rewrites recipient preferences.
let armed = false;
let armError = null;
try {
armed =
(await this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: selected.targetId },
{ origin: LAYER_RESTORE_ORIGINS.share },
)) === true;
} catch (error) {
armError = error;
}
if (
this._destroyed ||
generation !== this._trackingRestoreGeneration ||
signal?.aborted
) {
if (armed) {
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(signal?.reason || 'superseded'),
});
}
return {
...probe,
...selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(signal?.reason || 'superseded'),
cleared: false,
};
}
if (!armed) {
const terminal = {
...probe,
...selected,
status: 'source-unavailable',
classification: 'source-unavailable',
reason: String(
armError?.message || armError || 'tracking restore latch rejected',
),
cleared: this._passivelyClearTrackingSelection(selected),
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
const deadline = this.now() + selected.expiryWindowMs;
const settle = (terminal) => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._cancelPendingTrackingWatch();
this._pendingTrackingContext = null;
this._publishTrackingRestoreStatus(terminal);
};
const poll = () => {
this._pendingTrackingTimer = null;
if (this._destroyed || generation !== this._trackingRestoreGeneration)
return;
// The layer that owns the latch may have gone away since the last tick
// (disable, teardown, replacement). There is nothing left attempting this
// restore, so abandon it silently rather than announcing a verdict.
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
this._revokePendingTrackingWatch('owner-layer-disabled');
return;
}
if (this._trackingTargetLatched(selected)) {
settle({
...probe,
...selected,
status: 'found',
classification: 'followed',
cleared: false,
});
return;
}
if (this.now() >= deadline) {
// The window really has elapsed — only now does the verdict apply.
const classification =
probe.status === 'missing'
? this._classifyMissingTrackingTarget(selected, absentAtMs)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
settle({ ...probe, ...selected, classification, cleared });
return;
}
this._pendingTrackingTimer = this.setTimer(
poll,
PENDING_TRACKING_POLL_MS,
);
this._pendingTrackingTimer?.unref?.();
};
this._cancelPendingTrackingWatch();
const pending = {
...probe,
...selected,
status: 'pending',
classification: 'pending',
cleared: false,
};
const pendingContext = {
generation,
selected,
probe,
signal,
abortHandler: null,
};
if (signal) {
pendingContext.abortHandler = () => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._revokePendingTrackingWatch(signal.reason || 'aborted');
};
signal.addEventListener('abort', pendingContext.abortHandler, {
once: true,
});
}
this._pendingTrackingContext = pendingContext;
this._publishTrackingRestoreStatus(pending);
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
this._pendingTrackingTimer?.unref?.();
return pending;
}
/**
* Refresh and restore the one shareable tracked target after destination
* camera and ordinary layer restoration have settled.
*/
async restoreShareTrackingSelection({ signal = null } = {}) {
const selected = this._selectedShareTrackingTarget();
if (!selected || this._destroyed)
return { status: 'skipped', reason: 'no-shared-target' };
this._revokePendingTrackingWatch('superseded-by-newer-restore');
const controller = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
this._trackingRestoreController = controller;
const generation = ++this._trackingRestoreGeneration;
let result;
try {
result = await this.dataManager.resolveLayerTrackingTarget(
selected.layerId,
selected.targetId,
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
);
} catch (error) {
result = combinedSignal.aborted
? {
status: 'cancelled',
reason: String(combinedSignal.reason || 'aborted'),
}
: {
status: 'source-unavailable',
reason: String(error?.message || error),
};
}
if (
generation !== this._trackingRestoreGeneration ||
this._destroyed ||
combinedSignal.aborted
) {
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
return {
...result,
status: 'cancelled',
reason: String(combinedSignal.reason || 'superseded'),
};
}
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
if (result.status === 'found') {
const terminal = {
...result,
...selected,
classification: 'followed',
cleared: false,
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
return result;
// A subject that is simply not here YET is not a subject that is gone. Hold
// it on the layer's own deferred-restore latch for its source-specific
// window before any verdict is reached or shown. `unsupported` layers have
// no latch to arm, so they still decide immediately.
if (result.status === 'missing' || result.status === 'source-unavailable') {
return this._beginPendingTrackingRestore(
selected,
result,
combinedSignal,
);
}
const classification =
result.status === 'missing'
? this._classifyMissingTrackingTarget(selected)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
const terminal = { ...result, ...selected, classification, cleared };
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
for (const controller of this._restoreControllers.values())
controller.abort('coordinator-destroyed');
this._restoreControllers.clear();
this._revokePendingTrackingWatch('coordinator-destroyed');
this._trackingRestoreController = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeVisibilityRequests?.();
this._unsubscribeVisibilityRequests = null;
this.shareLinkManager?.setLayerStateProvider?.(null);
this.onDurableStateChange = null;
this.onTrackingRestoreStatus = null;
}
}
+7 -82
View File
@@ -1,4 +1,9 @@
import * as Cesium from 'cesium';
import {
layerTitle,
mapAnalystRecord,
parseGeojsonLines,
} from '../sources/infrastructureData.js';
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
import { isPointerFree } from './inputOwnership.js';
import {
@@ -86,78 +91,6 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -664,11 +597,7 @@ export function createLocalGeoJsonLayer(
}
const text = await response.text();
if (_destroyed) return;
const lines = text
.split('\n')
.filter((l) => l.trim().length > 0);
features = lines.map((line) => JSON.parse(line));
features = parseGeojsonLines(text);
_cachedFeatures = features;
}
@@ -1360,8 +1289,4 @@ function clampCardLine(value) {
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
}
function layerTitle(layerId) {
if (layerId === 'local-datacenters') return 'Datacenter';
if (layerId === 'local-dams') return 'Dam';
return 'Feature';
}
export { mapAnalystRecord };
+57 -57
View File
@@ -1,60 +1,60 @@
// Generated by scripts/build-census-places.mjs; do not edit.
export const INDEX_URL = new URL('./index.json', import.meta.url);
export const INDEX_URL = () => new URL('./index.json', import.meta.url);
export const STATE_URLS = {
AK: new URL('./AK.json', import.meta.url),
AL: new URL('./AL.json', import.meta.url),
AR: new URL('./AR.json', import.meta.url),
AS: new URL('./AS.json', import.meta.url),
AZ: new URL('./AZ.json', import.meta.url),
CA: new URL('./CA.json', import.meta.url),
CO: new URL('./CO.json', import.meta.url),
CT: new URL('./CT.json', import.meta.url),
DC: new URL('./DC.json', import.meta.url),
DE: new URL('./DE.json', import.meta.url),
FL: new URL('./FL.json', import.meta.url),
GA: new URL('./GA.json', import.meta.url),
GU: new URL('./GU.json', import.meta.url),
HI: new URL('./HI.json', import.meta.url),
IA: new URL('./IA.json', import.meta.url),
ID: new URL('./ID.json', import.meta.url),
IL: new URL('./IL.json', import.meta.url),
IN: new URL('./IN.json', import.meta.url),
KS: new URL('./KS.json', import.meta.url),
KY: new URL('./KY.json', import.meta.url),
LA: new URL('./LA.json', import.meta.url),
MA: new URL('./MA.json', import.meta.url),
MD: new URL('./MD.json', import.meta.url),
ME: new URL('./ME.json', import.meta.url),
MI: new URL('./MI.json', import.meta.url),
MN: new URL('./MN.json', import.meta.url),
MO: new URL('./MO.json', import.meta.url),
MP: new URL('./MP.json', import.meta.url),
MS: new URL('./MS.json', import.meta.url),
MT: new URL('./MT.json', import.meta.url),
NC: new URL('./NC.json', import.meta.url),
ND: new URL('./ND.json', import.meta.url),
NE: new URL('./NE.json', import.meta.url),
NH: new URL('./NH.json', import.meta.url),
NJ: new URL('./NJ.json', import.meta.url),
NM: new URL('./NM.json', import.meta.url),
NV: new URL('./NV.json', import.meta.url),
NY: new URL('./NY.json', import.meta.url),
OH: new URL('./OH.json', import.meta.url),
OK: new URL('./OK.json', import.meta.url),
OR: new URL('./OR.json', import.meta.url),
PA: new URL('./PA.json', import.meta.url),
PR: new URL('./PR.json', import.meta.url),
RI: new URL('./RI.json', import.meta.url),
SC: new URL('./SC.json', import.meta.url),
SD: new URL('./SD.json', import.meta.url),
TN: new URL('./TN.json', import.meta.url),
TX: new URL('./TX.json', import.meta.url),
UT: new URL('./UT.json', import.meta.url),
VA: new URL('./VA.json', import.meta.url),
VI: new URL('./VI.json', import.meta.url),
VT: new URL('./VT.json', import.meta.url),
WA: new URL('./WA.json', import.meta.url),
WI: new URL('./WI.json', import.meta.url),
WV: new URL('./WV.json', import.meta.url),
WY: new URL('./WY.json', import.meta.url),
AK: () => new URL('./AK.json', import.meta.url),
AL: () => new URL('./AL.json', import.meta.url),
AR: () => new URL('./AR.json', import.meta.url),
AS: () => new URL('./AS.json', import.meta.url),
AZ: () => new URL('./AZ.json', import.meta.url),
CA: () => new URL('./CA.json', import.meta.url),
CO: () => new URL('./CO.json', import.meta.url),
CT: () => new URL('./CT.json', import.meta.url),
DC: () => new URL('./DC.json', import.meta.url),
DE: () => new URL('./DE.json', import.meta.url),
FL: () => new URL('./FL.json', import.meta.url),
GA: () => new URL('./GA.json', import.meta.url),
GU: () => new URL('./GU.json', import.meta.url),
HI: () => new URL('./HI.json', import.meta.url),
IA: () => new URL('./IA.json', import.meta.url),
ID: () => new URL('./ID.json', import.meta.url),
IL: () => new URL('./IL.json', import.meta.url),
IN: () => new URL('./IN.json', import.meta.url),
KS: () => new URL('./KS.json', import.meta.url),
KY: () => new URL('./KY.json', import.meta.url),
LA: () => new URL('./LA.json', import.meta.url),
MA: () => new URL('./MA.json', import.meta.url),
MD: () => new URL('./MD.json', import.meta.url),
ME: () => new URL('./ME.json', import.meta.url),
MI: () => new URL('./MI.json', import.meta.url),
MN: () => new URL('./MN.json', import.meta.url),
MO: () => new URL('./MO.json', import.meta.url),
MP: () => new URL('./MP.json', import.meta.url),
MS: () => new URL('./MS.json', import.meta.url),
MT: () => new URL('./MT.json', import.meta.url),
NC: () => new URL('./NC.json', import.meta.url),
ND: () => new URL('./ND.json', import.meta.url),
NE: () => new URL('./NE.json', import.meta.url),
NH: () => new URL('./NH.json', import.meta.url),
NJ: () => new URL('./NJ.json', import.meta.url),
NM: () => new URL('./NM.json', import.meta.url),
NV: () => new URL('./NV.json', import.meta.url),
NY: () => new URL('./NY.json', import.meta.url),
OH: () => new URL('./OH.json', import.meta.url),
OK: () => new URL('./OK.json', import.meta.url),
OR: () => new URL('./OR.json', import.meta.url),
PA: () => new URL('./PA.json', import.meta.url),
PR: () => new URL('./PR.json', import.meta.url),
RI: () => new URL('./RI.json', import.meta.url),
SC: () => new URL('./SC.json', import.meta.url),
SD: () => new URL('./SD.json', import.meta.url),
TN: () => new URL('./TN.json', import.meta.url),
TX: () => new URL('./TX.json', import.meta.url),
UT: () => new URL('./UT.json', import.meta.url),
VA: () => new URL('./VA.json', import.meta.url),
VI: () => new URL('./VI.json', import.meta.url),
VT: () => new URL('./VT.json', import.meta.url),
WA: () => new URL('./WA.json', import.meta.url),
WI: () => new URL('./WI.json', import.meta.url),
WV: () => new URL('./WV.json', import.meta.url),
WY: () => new URL('./WY.json', import.meta.url),
};
+219 -219
View File
@@ -1,222 +1,222 @@
// Generated by scripts/build-wof-neighborhoods.py; do not edit.
export const INDEX_URL = new URL('./index.json', import.meta.url);
export const INDEX_URL = () => new URL('./index.json', import.meta.url);
export const TILE_URLS = {
t0: new URL('./0.json', import.meta.url),
t11: new URL('./11.json', import.meta.url),
t12: new URL('./12.json', import.meta.url),
t13110: new URL('./13110.json', import.meta.url),
t13111: new URL('./13111.json', import.meta.url),
t13113: new URL('./13113.json', import.meta.url),
t132: new URL('./132.json', import.meta.url),
t200: new URL('./200.json', import.meta.url),
t201: new URL('./201.json', import.meta.url),
t2030: new URL('./2030.json', import.meta.url),
t20312: new URL('./20312.json', import.meta.url),
t203130: new URL('./203130.json', import.meta.url),
t203131: new URL('./203131.json', import.meta.url),
t203132: new URL('./203132.json', import.meta.url),
t203210: new URL('./203210.json', import.meta.url),
t203211: new URL('./203211.json', import.meta.url),
t20321201: new URL('./20321201.json', import.meta.url),
t20321202: new URL('./20321202.json', import.meta.url),
t20321203: new URL('./20321203.json', import.meta.url),
t2032122: new URL('./2032122.json', import.meta.url),
t20330: new URL('./20330.json', import.meta.url),
t20331: new URL('./20331.json', import.meta.url),
t20332: new URL('./20332.json', import.meta.url),
t20333: new URL('./20333.json', import.meta.url),
t21200: new URL('./21200.json', import.meta.url),
t21202: new URL('./21202.json', import.meta.url),
t212200: new URL('./212200.json', import.meta.url),
t212201: new URL('./212201.json', import.meta.url),
t212202: new URL('./212202.json', import.meta.url),
t212210: new URL('./212210.json', import.meta.url),
t212212: new URL('./212212.json', import.meta.url),
t212220: new URL('./212220.json', import.meta.url),
t212221: new URL('./212221.json', import.meta.url),
t212222: new URL('./212222.json', import.meta.url),
t212223: new URL('./212223.json', import.meta.url),
t2122300: new URL('./2122300.json', import.meta.url),
t2122301: new URL('./2122301.json', import.meta.url),
t2122303: new URL('./2122303.json', import.meta.url),
t212231: new URL('./212231.json', import.meta.url),
t212232: new URL('./212232.json', import.meta.url),
t212233: new URL('./212233.json', import.meta.url),
t213: new URL('./213.json', import.meta.url),
t221010: new URL('./221010.json', import.meta.url),
t221012: new URL('./221012.json', import.meta.url),
t221031: new URL('./221031.json', import.meta.url),
t221033: new URL('./221033.json', import.meta.url),
t2211: new URL('./2211.json', import.meta.url),
t2300100: new URL('./2300100.json', import.meta.url),
t2300101: new URL('./2300101.json', import.meta.url),
t230011: new URL('./230011.json', import.meta.url),
t23111: new URL('./23111.json', import.meta.url),
t231130: new URL('./231130.json', import.meta.url),
t23113111: new URL('./23113111.json', import.meta.url),
t23113112: new URL('./23113112.json', import.meta.url),
t2311311310: new URL('./2311311310.json', import.meta.url),
t2311311311: new URL('./2311311311.json', import.meta.url),
t2311311312: new URL('./2311311312.json', import.meta.url),
t2311311313: new URL('./2311311313.json', import.meta.url),
t231131133: new URL('./231131133.json', import.meta.url),
t2311312: new URL('./2311312.json', import.meta.url),
t2311313: new URL('./2311313.json', import.meta.url),
t231133: new URL('./231133.json', import.meta.url),
t300: new URL('./300.json', import.meta.url),
t30112: new URL('./30112.json', import.meta.url),
t30113: new URL('./30113.json', import.meta.url),
t3013010: new URL('./3013010.json', import.meta.url),
t3013011: new URL('./3013011.json', import.meta.url),
t3013012: new URL('./3013012.json', import.meta.url),
t3013013: new URL('./3013013.json', import.meta.url),
t301303: new URL('./301303.json', import.meta.url),
t301310: new URL('./301310.json', import.meta.url),
t301312: new URL('./301312.json', import.meta.url),
t301320: new URL('./301320.json', import.meta.url),
t301321: new URL('./301321.json', import.meta.url),
t301322: new URL('./301322.json', import.meta.url),
t301323: new URL('./301323.json', import.meta.url),
t301330: new URL('./301330.json', import.meta.url),
t301331: new URL('./301331.json', import.meta.url),
t301332: new URL('./301332.json', import.meta.url),
t3013330: new URL('./3013330.json', import.meta.url),
t3013332: new URL('./3013332.json', import.meta.url),
t30133330: new URL('./30133330.json', import.meta.url),
t30133332: new URL('./30133332.json', import.meta.url),
t30133333: new URL('./30133333.json', import.meta.url),
t3021: new URL('./3021.json', import.meta.url),
t3022: new URL('./3022.json', import.meta.url),
t30230: new URL('./30230.json', import.meta.url),
t302321: new URL('./302321.json', import.meta.url),
t302322: new URL('./302322.json', import.meta.url),
t303000: new URL('./303000.json', import.meta.url),
t303001: new URL('./303001.json', import.meta.url),
t303002: new URL('./303002.json', import.meta.url),
t303003: new URL('./303003.json', import.meta.url),
t30301: new URL('./30301.json', import.meta.url),
t30302: new URL('./30302.json', import.meta.url),
t303100: new URL('./303100.json', import.meta.url),
t303101: new URL('./303101.json', import.meta.url),
t303102: new URL('./303102.json', import.meta.url),
t303103: new URL('./303103.json', import.meta.url),
t303110: new URL('./303110.json', import.meta.url),
t3031110: new URL('./3031110.json', import.meta.url),
t303111100: new URL('./303111100.json', import.meta.url),
t303111101: new URL('./303111101.json', import.meta.url),
t303111102: new URL('./303111102.json', import.meta.url),
t303111103: new URL('./303111103.json', import.meta.url),
t30311111: new URL('./30311111.json', import.meta.url),
t30311112: new URL('./30311112.json', import.meta.url),
t30311113: new URL('./30311113.json', import.meta.url),
t3031112: new URL('./3031112.json', import.meta.url),
t30311130: new URL('./30311130.json', import.meta.url),
t30311131: new URL('./30311131.json', import.meta.url),
t30311132: new URL('./30311132.json', import.meta.url),
t30311133: new URL('./30311133.json', import.meta.url),
t3031120: new URL('./3031120.json', import.meta.url),
t30311210: new URL('./30311210.json', import.meta.url),
t30311211: new URL('./30311211.json', import.meta.url),
t30311212: new URL('./30311212.json', import.meta.url),
t303112130: new URL('./303112130.json', import.meta.url),
t303112131: new URL('./303112131.json', import.meta.url),
t303112132: new URL('./303112132.json', import.meta.url),
t303112133: new URL('./303112133.json', import.meta.url),
t3031122: new URL('./3031122.json', import.meta.url),
t30311230: new URL('./30311230.json', import.meta.url),
t30311231: new URL('./30311231.json', import.meta.url),
t30311232: new URL('./30311232.json', import.meta.url),
t30311233: new URL('./30311233.json', import.meta.url),
t30311300: new URL('./30311300.json', import.meta.url),
t30311301: new URL('./30311301.json', import.meta.url),
t303113020: new URL('./303113020.json', import.meta.url),
t303113021: new URL('./303113021.json', import.meta.url),
t303113022: new URL('./303113022.json', import.meta.url),
t303113023: new URL('./303113023.json', import.meta.url),
t30311303: new URL('./30311303.json', import.meta.url),
t30311310: new URL('./30311310.json', import.meta.url),
t30311311: new URL('./30311311.json', import.meta.url),
t30311312: new URL('./30311312.json', import.meta.url),
t30311313: new URL('./30311313.json', import.meta.url),
t3031132: new URL('./3031132.json', import.meta.url),
t3031133: new URL('./3031133.json', import.meta.url),
t303120: new URL('./303120.json', import.meta.url),
t3031210: new URL('./3031210.json', import.meta.url),
t3031211: new URL('./3031211.json', import.meta.url),
t3031212: new URL('./3031212.json', import.meta.url),
t3031213: new URL('./3031213.json', import.meta.url),
t303122: new URL('./303122.json', import.meta.url),
t303123: new URL('./303123.json', import.meta.url),
t30313: new URL('./30313.json', import.meta.url),
t3033: new URL('./3033.json', import.meta.url),
t310: new URL('./310.json', import.meta.url),
t312000: new URL('./312000.json', import.meta.url),
t31200200: new URL('./31200200.json', import.meta.url),
t31200201: new URL('./31200201.json', import.meta.url),
t312002020: new URL('./312002020.json', import.meta.url),
t312002021: new URL('./312002021.json', import.meta.url),
t312002022: new URL('./312002022.json', import.meta.url),
t312002023: new URL('./312002023.json', import.meta.url),
t312002030: new URL('./312002030.json', import.meta.url),
t312002031: new URL('./312002031.json', import.meta.url),
t312002032: new URL('./312002032.json', import.meta.url),
t312002033: new URL('./312002033.json', import.meta.url),
t3120021: new URL('./3120021.json', import.meta.url),
t3120022: new URL('./3120022.json', import.meta.url),
t3120023: new URL('./3120023.json', import.meta.url),
t312003: new URL('./312003.json', import.meta.url),
t3121010: new URL('./3121010.json', import.meta.url),
t3121012: new URL('./3121012.json', import.meta.url),
t3121013: new URL('./3121013.json', import.meta.url),
t31212: new URL('./31212.json', import.meta.url),
t3123: new URL('./3123.json', import.meta.url),
t313: new URL('./313.json', import.meta.url),
t320000: new URL('./320000.json', import.meta.url),
t320001: new URL('./320001.json', import.meta.url),
t320002: new URL('./320002.json', import.meta.url),
t320003: new URL('./320003.json', import.meta.url),
t32001: new URL('./32001.json', import.meta.url),
t3200200: new URL('./3200200.json', import.meta.url),
t32002010: new URL('./32002010.json', import.meta.url),
t32002011: new URL('./32002011.json', import.meta.url),
t32002012: new URL('./32002012.json', import.meta.url),
t320020130: new URL('./320020130.json', import.meta.url),
t320020131: new URL('./320020131.json', import.meta.url),
t3200201320: new URL('./3200201320.json', import.meta.url),
t3200201321: new URL('./3200201321.json', import.meta.url),
t3200201322: new URL('./3200201322.json', import.meta.url),
t3200201323: new URL('./3200201323.json', import.meta.url),
t320020133: new URL('./320020133.json', import.meta.url),
t32002030: new URL('./32002030.json', import.meta.url),
t3200203100: new URL('./3200203100.json', import.meta.url),
t3200203101: new URL('./3200203101.json', import.meta.url),
t3200203102: new URL('./3200203102.json', import.meta.url),
t3200203103: new URL('./3200203103.json', import.meta.url),
t3200203110: new URL('./3200203110.json', import.meta.url),
t3200203111: new URL('./3200203111.json', import.meta.url),
t3200203112: new URL('./3200203112.json', import.meta.url),
t3200203113: new URL('./3200203113.json', import.meta.url),
t320020312: new URL('./320020312.json', import.meta.url),
t320020313: new URL('./320020313.json', import.meta.url),
t32002033: new URL('./32002033.json', import.meta.url),
t32002100: new URL('./32002100.json', import.meta.url),
t320021020: new URL('./320021020.json', import.meta.url),
t320021022: new URL('./320021022.json', import.meta.url),
t320021023: new URL('./320021023.json', import.meta.url),
t320021200: new URL('./320021200.json', import.meta.url),
t320021201: new URL('./320021201.json', import.meta.url),
t320021202: new URL('./320021202.json', import.meta.url),
t320021203: new URL('./320021203.json', import.meta.url),
t32002121: new URL('./32002121.json', import.meta.url),
t320021220: new URL('./320021220.json', import.meta.url),
t320021221: new URL('./320021221.json', import.meta.url),
t320021222: new URL('./320021222.json', import.meta.url),
t320021223: new URL('./320021223.json', import.meta.url),
t32002123: new URL('./32002123.json', import.meta.url),
t3200213: new URL('./3200213.json', import.meta.url),
t320023: new URL('./320023.json', import.meta.url),
t32003: new URL('./32003.json', import.meta.url),
t3201: new URL('./3201.json', import.meta.url),
t3202: new URL('./3202.json', import.meta.url),
t3203: new URL('./3203.json', import.meta.url),
t0: () => new URL('./0.json', import.meta.url),
t11: () => new URL('./11.json', import.meta.url),
t12: () => new URL('./12.json', import.meta.url),
t13110: () => new URL('./13110.json', import.meta.url),
t13111: () => new URL('./13111.json', import.meta.url),
t13113: () => new URL('./13113.json', import.meta.url),
t132: () => new URL('./132.json', import.meta.url),
t200: () => new URL('./200.json', import.meta.url),
t201: () => new URL('./201.json', import.meta.url),
t2030: () => new URL('./2030.json', import.meta.url),
t20312: () => new URL('./20312.json', import.meta.url),
t203130: () => new URL('./203130.json', import.meta.url),
t203131: () => new URL('./203131.json', import.meta.url),
t203132: () => new URL('./203132.json', import.meta.url),
t203210: () => new URL('./203210.json', import.meta.url),
t203211: () => new URL('./203211.json', import.meta.url),
t20321201: () => new URL('./20321201.json', import.meta.url),
t20321202: () => new URL('./20321202.json', import.meta.url),
t20321203: () => new URL('./20321203.json', import.meta.url),
t2032122: () => new URL('./2032122.json', import.meta.url),
t20330: () => new URL('./20330.json', import.meta.url),
t20331: () => new URL('./20331.json', import.meta.url),
t20332: () => new URL('./20332.json', import.meta.url),
t20333: () => new URL('./20333.json', import.meta.url),
t21200: () => new URL('./21200.json', import.meta.url),
t21202: () => new URL('./21202.json', import.meta.url),
t212200: () => new URL('./212200.json', import.meta.url),
t212201: () => new URL('./212201.json', import.meta.url),
t212202: () => new URL('./212202.json', import.meta.url),
t212210: () => new URL('./212210.json', import.meta.url),
t212212: () => new URL('./212212.json', import.meta.url),
t212220: () => new URL('./212220.json', import.meta.url),
t212221: () => new URL('./212221.json', import.meta.url),
t212222: () => new URL('./212222.json', import.meta.url),
t212223: () => new URL('./212223.json', import.meta.url),
t2122300: () => new URL('./2122300.json', import.meta.url),
t2122301: () => new URL('./2122301.json', import.meta.url),
t2122303: () => new URL('./2122303.json', import.meta.url),
t212231: () => new URL('./212231.json', import.meta.url),
t212232: () => new URL('./212232.json', import.meta.url),
t212233: () => new URL('./212233.json', import.meta.url),
t213: () => new URL('./213.json', import.meta.url),
t221010: () => new URL('./221010.json', import.meta.url),
t221012: () => new URL('./221012.json', import.meta.url),
t221031: () => new URL('./221031.json', import.meta.url),
t221033: () => new URL('./221033.json', import.meta.url),
t2211: () => new URL('./2211.json', import.meta.url),
t2300100: () => new URL('./2300100.json', import.meta.url),
t2300101: () => new URL('./2300101.json', import.meta.url),
t230011: () => new URL('./230011.json', import.meta.url),
t23111: () => new URL('./23111.json', import.meta.url),
t231130: () => new URL('./231130.json', import.meta.url),
t23113111: () => new URL('./23113111.json', import.meta.url),
t23113112: () => new URL('./23113112.json', import.meta.url),
t2311311310: () => new URL('./2311311310.json', import.meta.url),
t2311311311: () => new URL('./2311311311.json', import.meta.url),
t2311311312: () => new URL('./2311311312.json', import.meta.url),
t2311311313: () => new URL('./2311311313.json', import.meta.url),
t231131133: () => new URL('./231131133.json', import.meta.url),
t2311312: () => new URL('./2311312.json', import.meta.url),
t2311313: () => new URL('./2311313.json', import.meta.url),
t231133: () => new URL('./231133.json', import.meta.url),
t300: () => new URL('./300.json', import.meta.url),
t30112: () => new URL('./30112.json', import.meta.url),
t30113: () => new URL('./30113.json', import.meta.url),
t3013010: () => new URL('./3013010.json', import.meta.url),
t3013011: () => new URL('./3013011.json', import.meta.url),
t3013012: () => new URL('./3013012.json', import.meta.url),
t3013013: () => new URL('./3013013.json', import.meta.url),
t301303: () => new URL('./301303.json', import.meta.url),
t301310: () => new URL('./301310.json', import.meta.url),
t301312: () => new URL('./301312.json', import.meta.url),
t301320: () => new URL('./301320.json', import.meta.url),
t301321: () => new URL('./301321.json', import.meta.url),
t301322: () => new URL('./301322.json', import.meta.url),
t301323: () => new URL('./301323.json', import.meta.url),
t301330: () => new URL('./301330.json', import.meta.url),
t301331: () => new URL('./301331.json', import.meta.url),
t301332: () => new URL('./301332.json', import.meta.url),
t3013330: () => new URL('./3013330.json', import.meta.url),
t3013332: () => new URL('./3013332.json', import.meta.url),
t30133330: () => new URL('./30133330.json', import.meta.url),
t30133332: () => new URL('./30133332.json', import.meta.url),
t30133333: () => new URL('./30133333.json', import.meta.url),
t3021: () => new URL('./3021.json', import.meta.url),
t3022: () => new URL('./3022.json', import.meta.url),
t30230: () => new URL('./30230.json', import.meta.url),
t302321: () => new URL('./302321.json', import.meta.url),
t302322: () => new URL('./302322.json', import.meta.url),
t303000: () => new URL('./303000.json', import.meta.url),
t303001: () => new URL('./303001.json', import.meta.url),
t303002: () => new URL('./303002.json', import.meta.url),
t303003: () => new URL('./303003.json', import.meta.url),
t30301: () => new URL('./30301.json', import.meta.url),
t30302: () => new URL('./30302.json', import.meta.url),
t303100: () => new URL('./303100.json', import.meta.url),
t303101: () => new URL('./303101.json', import.meta.url),
t303102: () => new URL('./303102.json', import.meta.url),
t303103: () => new URL('./303103.json', import.meta.url),
t303110: () => new URL('./303110.json', import.meta.url),
t3031110: () => new URL('./3031110.json', import.meta.url),
t303111100: () => new URL('./303111100.json', import.meta.url),
t303111101: () => new URL('./303111101.json', import.meta.url),
t303111102: () => new URL('./303111102.json', import.meta.url),
t303111103: () => new URL('./303111103.json', import.meta.url),
t30311111: () => new URL('./30311111.json', import.meta.url),
t30311112: () => new URL('./30311112.json', import.meta.url),
t30311113: () => new URL('./30311113.json', import.meta.url),
t3031112: () => new URL('./3031112.json', import.meta.url),
t30311130: () => new URL('./30311130.json', import.meta.url),
t30311131: () => new URL('./30311131.json', import.meta.url),
t30311132: () => new URL('./30311132.json', import.meta.url),
t30311133: () => new URL('./30311133.json', import.meta.url),
t3031120: () => new URL('./3031120.json', import.meta.url),
t30311210: () => new URL('./30311210.json', import.meta.url),
t30311211: () => new URL('./30311211.json', import.meta.url),
t30311212: () => new URL('./30311212.json', import.meta.url),
t303112130: () => new URL('./303112130.json', import.meta.url),
t303112131: () => new URL('./303112131.json', import.meta.url),
t303112132: () => new URL('./303112132.json', import.meta.url),
t303112133: () => new URL('./303112133.json', import.meta.url),
t3031122: () => new URL('./3031122.json', import.meta.url),
t30311230: () => new URL('./30311230.json', import.meta.url),
t30311231: () => new URL('./30311231.json', import.meta.url),
t30311232: () => new URL('./30311232.json', import.meta.url),
t30311233: () => new URL('./30311233.json', import.meta.url),
t30311300: () => new URL('./30311300.json', import.meta.url),
t30311301: () => new URL('./30311301.json', import.meta.url),
t303113020: () => new URL('./303113020.json', import.meta.url),
t303113021: () => new URL('./303113021.json', import.meta.url),
t303113022: () => new URL('./303113022.json', import.meta.url),
t303113023: () => new URL('./303113023.json', import.meta.url),
t30311303: () => new URL('./30311303.json', import.meta.url),
t30311310: () => new URL('./30311310.json', import.meta.url),
t30311311: () => new URL('./30311311.json', import.meta.url),
t30311312: () => new URL('./30311312.json', import.meta.url),
t30311313: () => new URL('./30311313.json', import.meta.url),
t3031132: () => new URL('./3031132.json', import.meta.url),
t3031133: () => new URL('./3031133.json', import.meta.url),
t303120: () => new URL('./303120.json', import.meta.url),
t3031210: () => new URL('./3031210.json', import.meta.url),
t3031211: () => new URL('./3031211.json', import.meta.url),
t3031212: () => new URL('./3031212.json', import.meta.url),
t3031213: () => new URL('./3031213.json', import.meta.url),
t303122: () => new URL('./303122.json', import.meta.url),
t303123: () => new URL('./303123.json', import.meta.url),
t30313: () => new URL('./30313.json', import.meta.url),
t3033: () => new URL('./3033.json', import.meta.url),
t310: () => new URL('./310.json', import.meta.url),
t312000: () => new URL('./312000.json', import.meta.url),
t31200200: () => new URL('./31200200.json', import.meta.url),
t31200201: () => new URL('./31200201.json', import.meta.url),
t312002020: () => new URL('./312002020.json', import.meta.url),
t312002021: () => new URL('./312002021.json', import.meta.url),
t312002022: () => new URL('./312002022.json', import.meta.url),
t312002023: () => new URL('./312002023.json', import.meta.url),
t312002030: () => new URL('./312002030.json', import.meta.url),
t312002031: () => new URL('./312002031.json', import.meta.url),
t312002032: () => new URL('./312002032.json', import.meta.url),
t312002033: () => new URL('./312002033.json', import.meta.url),
t3120021: () => new URL('./3120021.json', import.meta.url),
t3120022: () => new URL('./3120022.json', import.meta.url),
t3120023: () => new URL('./3120023.json', import.meta.url),
t312003: () => new URL('./312003.json', import.meta.url),
t3121010: () => new URL('./3121010.json', import.meta.url),
t3121012: () => new URL('./3121012.json', import.meta.url),
t3121013: () => new URL('./3121013.json', import.meta.url),
t31212: () => new URL('./31212.json', import.meta.url),
t3123: () => new URL('./3123.json', import.meta.url),
t313: () => new URL('./313.json', import.meta.url),
t320000: () => new URL('./320000.json', import.meta.url),
t320001: () => new URL('./320001.json', import.meta.url),
t320002: () => new URL('./320002.json', import.meta.url),
t320003: () => new URL('./320003.json', import.meta.url),
t32001: () => new URL('./32001.json', import.meta.url),
t3200200: () => new URL('./3200200.json', import.meta.url),
t32002010: () => new URL('./32002010.json', import.meta.url),
t32002011: () => new URL('./32002011.json', import.meta.url),
t32002012: () => new URL('./32002012.json', import.meta.url),
t320020130: () => new URL('./320020130.json', import.meta.url),
t320020131: () => new URL('./320020131.json', import.meta.url),
t3200201320: () => new URL('./3200201320.json', import.meta.url),
t3200201321: () => new URL('./3200201321.json', import.meta.url),
t3200201322: () => new URL('./3200201322.json', import.meta.url),
t3200201323: () => new URL('./3200201323.json', import.meta.url),
t320020133: () => new URL('./320020133.json', import.meta.url),
t32002030: () => new URL('./32002030.json', import.meta.url),
t3200203100: () => new URL('./3200203100.json', import.meta.url),
t3200203101: () => new URL('./3200203101.json', import.meta.url),
t3200203102: () => new URL('./3200203102.json', import.meta.url),
t3200203103: () => new URL('./3200203103.json', import.meta.url),
t3200203110: () => new URL('./3200203110.json', import.meta.url),
t3200203111: () => new URL('./3200203111.json', import.meta.url),
t3200203112: () => new URL('./3200203112.json', import.meta.url),
t3200203113: () => new URL('./3200203113.json', import.meta.url),
t320020312: () => new URL('./320020312.json', import.meta.url),
t320020313: () => new URL('./320020313.json', import.meta.url),
t32002033: () => new URL('./32002033.json', import.meta.url),
t32002100: () => new URL('./32002100.json', import.meta.url),
t320021020: () => new URL('./320021020.json', import.meta.url),
t320021022: () => new URL('./320021022.json', import.meta.url),
t320021023: () => new URL('./320021023.json', import.meta.url),
t320021200: () => new URL('./320021200.json', import.meta.url),
t320021201: () => new URL('./320021201.json', import.meta.url),
t320021202: () => new URL('./320021202.json', import.meta.url),
t320021203: () => new URL('./320021203.json', import.meta.url),
t32002121: () => new URL('./32002121.json', import.meta.url),
t320021220: () => new URL('./320021220.json', import.meta.url),
t320021221: () => new URL('./320021221.json', import.meta.url),
t320021222: () => new URL('./320021222.json', import.meta.url),
t320021223: () => new URL('./320021223.json', import.meta.url),
t32002123: () => new URL('./32002123.json', import.meta.url),
t3200213: () => new URL('./3200213.json', import.meta.url),
t320023: () => new URL('./320023.json', import.meta.url),
t32003: () => new URL('./32003.json', import.meta.url),
t3201: () => new URL('./3201.json', import.meta.url),
t3202: () => new URL('./3202.json', import.meta.url),
t3203: () => new URL('./3203.json', import.meta.url),
};
+11 -7
View File
@@ -14,10 +14,6 @@ export function createMilitaryNamesLoader({
loadPack = (signal) =>
loadBundledJson(
new URL('./local_data/osm_military_names/names.json', import.meta.url),
() =>
import('./local_data/osm_military_names/names.json', {
with: { type: 'json' },
}),
{ signal },
),
timeoutMs = MILITARY_NAMES_TIMEOUT_MS,
@@ -93,8 +89,16 @@ export function nameMilitaryFragment(fragment, names) {
};
}
/** Area-ranked spatial thinning of visible label points, including dateline views. */
export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
/**
* Area-ranked spatial thinning of visible label points, including dateline
* views. With `thinned` false every point in the box is kept, up to `cap`.
*/
export function militaryNamesInView(
names,
box,
cap = MILITARY_POINT_CAP,
thinned = true,
) {
const width =
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
const height = box.north - box.south;
@@ -107,7 +111,7 @@ export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
continue;
count++;
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
if (cells.has(cell) || records.length >= cap) continue;
if ((thinned && cells.has(cell)) || records.length >= cap) continue;
cells.add(cell);
records.push(record);
}
+10
View File
@@ -217,3 +217,13 @@ test('a close view keeps the parent name inside its polygon when the usual label
hole,
);
});
test('queries can take every named site in a box instead of one per display cell', async () => {
const names = await loadMilitaryNames();
const box = { west: -2, south: 50, east: 2, north: 54 };
const thinned = militaryNamesInView(names, box);
const all = militaryNamesInView(names, box, Infinity, false);
assert.equal(all.count, thinned.count);
assert.equal(all.records.length, all.count);
assert.ok(all.records.length > thinned.records.length);
});
+2 -10
View File
@@ -124,17 +124,9 @@ let _entries = null;
const PACKS = {
regions: {
url: new URL('./local_data/natural_earth/regions.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/regions.json', {
with: { type: 'json' },
}),
},
marine: {
url: new URL('./local_data/natural_earth/marine.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/marine.json', {
with: { type: 'json' },
}),
},
};
@@ -178,8 +170,8 @@ function buildEntries(pack, kind) {
*/
const loadIndex = createRetryableLoader(async () => {
const [regions, marine] = await Promise.all([
loadBundledJson(PACKS.regions.url, PACKS.regions.importJson),
loadBundledJson(PACKS.marine.url, PACKS.marine.importJson),
loadBundledJson(PACKS.regions.url),
loadBundledJson(PACKS.marine.url),
]);
_entries = [
...buildEntries(regions, 'natural'),
+1 -5
View File
@@ -22,10 +22,6 @@ const CITY_FILES = [
'./local_data/neighborhoods/san-francisco.json',
import.meta.url,
),
importJson: () =>
import('./local_data/neighborhoods/san-francisco.json', {
with: { type: 'json' },
}),
},
];
@@ -126,7 +122,7 @@ function cityLoader(city) {
let loader = _cityLoaders.get(city.id);
if (!loader) {
loader = createRetryableLoader(async () => {
const fc = await loadBundledJson(city.url, city.importJson);
const fc = await loadBundledJson(city.url);
return Array.isArray(fc.features) ? fc.features : [];
});
_cityLoaders.set(city.id, loader);
+12 -4
View File
@@ -161,7 +161,7 @@ const UNITED_STATES = new Set([
const loadPlaceIndex = createRetryableLoader(async () => {
packLoads.placeIndex += 1;
const index = await loadBundledJson(placeFiles.INDEX_URL);
const index = await loadBundledJson(placeFiles.INDEX_URL());
const states = index.states.map((state) => ({
...state,
key: normalizePlaceName(state.name),
@@ -218,13 +218,21 @@ export function cachedPackFiles() {
};
}
/** Builds a pack file URL on use (never at import); unknown keys fail loudly. */
function packUrl(urls, key) {
const build = urls[key];
if (typeof build !== 'function')
throw new Error(`No bundled boundary file for ${key}`);
return build();
}
function loadState(st) {
return stateLoaders.load(st, () =>
createRetryableLoader(async () => {
packLoads.placeStates.push(st);
const [{ meta }, pack] = await Promise.all([
loadPlaceIndex(),
loadBundledJson(placeFiles.STATE_URLS[st]),
loadBundledJson(packUrl(placeFiles.STATE_URLS, st)),
]);
const byKey = new Map();
for (const feature of pack.features) {
@@ -253,7 +261,7 @@ let neighborhoodIndex = null;
const loadNeighborhoodIndex = createRetryableLoader(async () => {
packLoads.neighborhoodIndex += 1;
neighborhoodIndex = await loadBundledJson(neighborhoodFiles.INDEX_URL);
neighborhoodIndex = await loadBundledJson(neighborhoodFiles.INDEX_URL());
return neighborhoodIndex;
});
@@ -265,7 +273,7 @@ function loadTile(key) {
packLoads.neighborhoodTiles.push(key);
const [index, tile] = await Promise.all([
loadNeighborhoodIndex(),
loadBundledJson(neighborhoodFiles.TILE_URLS[`t${key}`]),
loadBundledJson(packUrl(neighborhoodFiles.TILE_URLS, `t${key}`)),
]);
const precision = index.meta.precision;
const byKey = new Map();