Merge branch 'main' into feat/import-geo-files

This commit is contained in:
kapturner committed 2026-10-11 21:18:37 -05:00
commit d8b4b0d80f
80 files changed
+3829 -184

No files matched your search

+5 -2
View File
@@ -1,3 +1,4 @@
import { createDirectionsSource } from '../layers/directions/source.js';
/**
* @module directions
* @description Service bundle for the Directions layer.
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
});
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer() {
return createDirectionsLayer({ services: directionsServices });
export function createApplicationDirectionsLayer({
source = createDirectionsSource(),
} = {}) {
return createDirectionsLayer({ services: directionsServices, source });
}
export {
+63
View File
@@ -0,0 +1,63 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)-([01])$/;
/** Canonical, bounded boolean switches keyed by stable slugs. */
export function normalizeIdSwitches(value, reservedIds = []) {
if (typeof value !== 'string' || value.length > MAX_SWITCHES_LENGTH)
return null;
if (!value) return '';
const entries = new Map();
for (const field of value.split('*')) {
const match = ENTRY.exec(field);
if (!match || reservedIds.includes(match[1]) || entries.has(match[1]))
return null;
entries.set(match[1], match[2] === '1');
}
return [...entries]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([id, on]) => `${id}-${on ? '1' : '0'}`)
.join('*');
}
/** Encode by stable IDs rather than registration order or generated tokens. */
export function encodeIdSwitches(switches) {
const value = [...switches]
.map(([id, on]) => `${id}-${on === true ? '1' : '0'}`)
.join('*');
if (value.length > MAX_SWITCHES_LENGTH)
throw new RangeError(
`Switch IDs exceed the ${MAX_SWITCHES_LENGTH}-character share-link limit`,
);
const normalized = normalizeIdSwitches(value);
if (normalized === null) throw new TypeError('Invalid boolean switch IDs');
return normalized;
}
/** A Map keeps switch IDs separate from object prototype properties. */
export function decodeIdSwitches(value) {
const normalized = normalizeIdSwitches(value);
return new Map(
normalized
? normalized.split('*').map((entry) => {
const [, id, on] = ENTRY.exec(entry);
return [id, on === '1'];
})
: [],
);
}
/** Persist only explicitly requested switch IDs, not unrelated live changes. */
export function patchRequestedIdSwitches(previous, applied, requested) {
const current = decodeIdSwitches(applied);
const next = new Map(
[...decodeIdSwitches(previous)].filter(([id]) => current.has(id)),
);
let changed = false;
for (const [id, on] of current) {
if (Object.hasOwn(requested, id) && typeof requested[id] === 'boolean') {
next.set(id, on);
changed = true;
}
}
return changed ? encodeIdSwitches(next) : undefined;
}
+11 -1
View File
@@ -1,3 +1,4 @@
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -411,8 +412,17 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('viirs', 'v', false),
]),
'street-level': Object.freeze([
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
// Preserve the published Mapillary token; custom switches use stable IDs.
booleanOption('mapillary', 'm', true),
Object.freeze({
key: 'providerSwitches',
patchRequested: patchRequestedIdSwitches,
token: 'r',
defaultValue: '',
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
encode: normalizeIdSwitches,
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
pano: 'p',
+102 -18
View File
@@ -1,3 +1,4 @@
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
import { readShellSource } from '../testSupport/readShellSource.mjs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
@@ -243,7 +244,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
}
assert.equal(nextLayerStateToken(), '3');
assert.equal(
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
}),
'5',
);
const digitsExhausted = {
@@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
[
...'0123456789',
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`),
[...LAYER_STATE_TOKEN_ALPHABET].map(
(second) => `${first}${second}`,
),
),
].map((token, index) => [`occupied-${index}`, token]),
),
@@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
true,
);
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
),
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '3',
next: '4',
}),
true,
);
assert.throws(
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
() =>
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 3/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
assert.equal(
validateLayerStateAllocations(
beforeLastDigit,
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
),
validateLayerStateAllocations(beforeLastDigit, {
...beforeLastDigit,
futurePair: '00',
futureDigit: '9',
}),
true,
);
assert.equal(
validateLayerStateAllocations(
digitsExhausted,
{ ...digitsExhausted, pairB: '01', pairA: '00' },
),
validateLayerStateAllocations(digitsExhausted, {
...digitsExhausted,
pairB: '01',
pairA: '00',
}),
true,
);
assert.throws(
@@ -2660,12 +2671,14 @@ test('Street Level: every option round-trips through a share link and stored sta
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
};
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded.options['street-level'], {
mapillary: false,
providerSwitches: '',
pano: 'flat',
sinceDays: MAX_SINCE_DAYS,
});
@@ -2693,6 +2706,7 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
@@ -2703,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () =
true,
);
});
test('explicit provider switches reach durable state without adopting unrelated live switches', () => {
const manager = new DataLayerManager({});
const providers = new Map([
['mapillary', true],
['alpha', true],
['beta', true],
]);
const filter = { pano: 'all', sinceDays: 0 };
for (const id of REGISTERED_LAYER_IDS) {
manager.register(
id === 'street-level'
? {
...fakeLayer(id),
getParams: () => encodeParams({ providers, filter }),
setParams(params) {
const decoded = decodeParams(params, {
providerIds: providers.keys(),
filter,
});
for (const [id, on] of decoded.providers) providers.set(id, on);
return true;
},
}
: fakeLayer(id),
);
}
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
const storage = memoryStorage();
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
storage,
});
try {
providers.set('beta', false); // Passive state does not belong to the next action.
assert.equal(
manager.setLayerParams(
'street-level',
{ alpha: false },
{ origin: 'user' },
),
true,
);
let saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(saved.options['street-level'].providerSwitches, 'alpha-0');
const shared = decodeLayerStateParams(new URLSearchParams(encode(saved)));
const restored = decodeParams(shared.options['street-level'], {
providerIds: ['beta', 'alpha'],
filter,
});
assert.deepEqual([...restored.providers], [['alpha', false]]);
manager.setLayerParams('street-level', { beta: true }, { origin: 'user' });
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
manager.setLayerParams(
'street-level',
{ alpha: true },
{ origin: 'local-restore' },
);
saved = parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY));
assert.equal(
saved.options['street-level'].providerSwitches,
'alpha-0*beta-1',
);
} finally {
coordinator.destroy();
}
});
+16 -2
View File
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
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]);
// Packed options can persist only the subkeys owned by this request.
const requestedValue =
explicitlyRequested || implicitTrackingSync
? change.params[spec.key]
: spec.patchRequested?.(
nextOwnerOptions[spec.key],
change.params[spec.key],
requestedParams,
);
if (
!explicitlyRequested &&
!implicitTrackingSync &&
requestedValue === undefined
)
continue;
const value = spec.normalize(requestedValue);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
+6
View File
@@ -98,6 +98,12 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
+23 -1
View File
@@ -74,7 +74,11 @@ function refreshFailureFromStats(stats, label) {
* for real-time data overlays on the CesiumJS globe.
*/
export class LayerLifecycle {
constructor(viewer, { allowQaRegistration = false } = {}) {
constructor(
viewer,
{ allowQaRegistration = false, getSourceAvailability } = {},
) {
this._catalogSourceAvailability = getSourceAvailability;
this.viewer = viewer;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
@@ -228,6 +232,7 @@ export class LayerLifecycle {
_normalizedStats(entry) {
const moduleStats = this._moduleStats(entry);
const availability = this._sourceAvailability(entry.module.id);
const lifecycleLoading =
entry.lifecycleState === 'enabling' ||
entry.lifecycleState === 'disabling';
@@ -235,6 +240,13 @@ export class LayerLifecycle {
count: 0,
lastUpdate: null,
...moduleStats,
...(availability?.available === false
? {
status: 'unavailable',
sourceUnavailable: true,
error: availability.reason,
}
: {}),
loading: lifecycleLoading || moduleStats.loading === true,
refreshing: entry.refreshing || moduleStats.refreshing === true,
managerRefreshError: entry.managerRefreshError,
@@ -2282,7 +2294,17 @@ export class LayerLifecycle {
}
}
_sourceAvailability(layerId) {
return (
this._catalogSourceAvailability?.(layerId) ??
this.layers.get(layerId)?.module.getSourceAvailability?.()
);
}
async _visibilityBlockReason(change) {
const availability = this._sourceAvailability(change.layerId);
if (change.enabled && availability?.available === false)
return availability.reason || 'Data source unavailable';
for (const callback of this._visibilityGuards) {
try {
const result = await callback(change);
+2 -1
View File
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
const v = viewer(t);
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
const layer = createDirectionsLayer({ services: {
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },