mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
commit
41b2475362
191 files changed
+20227
-1730
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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/,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user