mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #985 from bilawalsidhu/fix/street-level-provider-sharing
Preserve custom Street Level provider switches in shared state
This commit is contained in:
12 files changed
+416
-29
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Custom Street Level provider switches survive share links and stored state
|
||||||
|
using URL-safe stable provider IDs and a shared boolean-switch codec; existing
|
||||||
|
Mapillary links keep their meaning. Explicit provider toggles reach durable
|
||||||
|
state without adopting unrelated live switch changes.
|
||||||
|
|
||||||
- Directions and Recent Imagery accept replaceable acquisition sources, including
|
- Directions and Recent Imagery accept replaceable acquisition sources, including
|
||||||
route transport, imagery catalog, thumbnails, tile templates and attribution.
|
route transport, imagery catalog, thumbnails, tile templates and attribution.
|
||||||
Explicitly removing either source leaves its layer unavailable; standalone
|
Explicitly removing either source leaves its layer unavailable; standalone
|
||||||
|
|||||||
+11
-2
@@ -26,8 +26,17 @@ all imagery providers. Without that option, Mapillary is registered only when
|
|||||||
with a truthful no-provider state, preserving layer controls and share tokens.
|
with a truthful no-provider state, preserving layer controls and share tokens.
|
||||||
Malformed selected sources and provider definitions still fail validation.
|
Malformed selected sources and provider definitions still fail validation.
|
||||||
An explicit provider list bypasses the unused Mapillary source, including its
|
An explicit provider list bypasses the unused Mapillary source, including its
|
||||||
validation. Layer share identities are preserved; custom provider switches require
|
validation. Layer share identities and the legacy Mapillary `m` option remain
|
||||||
a corresponding serialization contract.
|
stable. Custom provider switches use the `r` option (`example-1*panoramax-0`),
|
||||||
|
with URL-safe separators and a shared layer-neutral boolean-switch codec, sorted by provider ID rather
|
||||||
|
than registration order, and round-trip through links and stored state. A receiver
|
||||||
|
applies only switches for its registered providers; absent custom switches leave
|
||||||
|
current defaults unchanged. Explicit provider toggles persist only their requested
|
||||||
|
switch IDs through the state coordinator; unrelated live values remain transient.
|
||||||
|
The custom switch field is bounded to 256 characters;
|
||||||
|
provider registration rejects compositions that cannot fit rather than truncating
|
||||||
|
identities. A malformed or duplicate entry invalidates the complete custom-switch
|
||||||
|
field. Existing whole-link size limits still apply.
|
||||||
|
|
||||||
## God's Eye View in conversations — October 2, 2026
|
## God's Eye View in conversations — October 2, 2026
|
||||||
|
|
||||||
|
|||||||
@@ -552,6 +552,7 @@
|
|||||||
"src/contextModePolicy.js",
|
"src/contextModePolicy.js",
|
||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -800,6 +801,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -842,6 +844,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
@@ -879,6 +882,7 @@
|
|||||||
"src/data/cockpitContactDot.js",
|
"src/data/cockpitContactDot.js",
|
||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -1204,6 +1208,7 @@
|
|||||||
"src/layers/streetLevel/groundCast.js",
|
"src/layers/streetLevel/groundCast.js",
|
||||||
"src/layers/streetLevel/meshSampler.js",
|
"src/layers/streetLevel/meshSampler.js",
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/registry.js",
|
"src/layers/streetLevel/registry.js",
|
||||||
"src/layers/streetLevel/filter.js",
|
"src/layers/streetLevel/filter.js",
|
||||||
@@ -1249,6 +1254,7 @@
|
|||||||
"exports": ["./layers/radio"],
|
"exports": ["./layers/radio"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/radioCountry.js",
|
"src/data/radioCountry.js",
|
||||||
@@ -1537,6 +1543,7 @@
|
|||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -2286,6 +2293,7 @@
|
|||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/satellitePass.js",
|
"src/data/satellitePass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/localGeojson.js",
|
"src/data/localGeojson.js",
|
||||||
@@ -2807,6 +2815,7 @@
|
|||||||
"exports": ["./ui/share-restoration"],
|
"exports": ["./ui/share-restoration"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateCoordinator.js",
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -3327,6 +3336,7 @@
|
|||||||
"src/tools/queries/brief.js",
|
"src/tools/queries/brief.js",
|
||||||
"src/tools/queries/maritime.js",
|
"src/tools/queries/maritime.js",
|
||||||
"src/tools/queries/share.js",
|
"src/tools/queries/share.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/sources/gbfsStations.js",
|
"src/sources/gbfsStations.js",
|
||||||
@@ -3449,6 +3459,7 @@
|
|||||||
"exports": ["./view"],
|
"exports": ["./view"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/view/index.js",
|
"src/view/index.js",
|
||||||
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json"
|
"src/data/layerStateTokenReservations.json"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||||
|
|
||||||
const VALID_DISPOSITIONS = new Set([
|
const VALID_DISPOSITIONS = new Set([
|
||||||
@@ -411,8 +412,17 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
booleanOption('viirs', 'v', false),
|
booleanOption('viirs', 'v', false),
|
||||||
]),
|
]),
|
||||||
'street-level': Object.freeze([
|
'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),
|
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'], {
|
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||||||
all: 'a',
|
all: 'a',
|
||||||
pano: 'p',
|
pano: 'p',
|
||||||
|
|||||||
+102
-18
@@ -1,3 +1,4 @@
|
|||||||
|
import { encodeParams, decodeParams } from '../layers/streetLevel/params.js';
|
||||||
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||||
import { expandApplicationHtml } from '../../build/application-html.js';
|
import { expandApplicationHtml } from '../../build/application-html.js';
|
||||||
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
|
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(), '3');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
|
nextLayerStateToken({
|
||||||
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
|
alpha: '3',
|
||||||
|
bravo: '4',
|
||||||
|
}),
|
||||||
'5',
|
'5',
|
||||||
);
|
);
|
||||||
const digitsExhausted = {
|
const digitsExhausted = {
|
||||||
@@ -276,7 +281,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
[
|
[
|
||||||
...'0123456789',
|
...'0123456789',
|
||||||
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
|
...[...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]),
|
].map((token, index) => [`occupied-${index}`, token]),
|
||||||
),
|
),
|
||||||
@@ -298,33 +305,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
|
future: '3',
|
||||||
),
|
next: '4',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
() =>
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||||
future: '00',
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
}),
|
future: '00',
|
||||||
|
}),
|
||||||
/next free token 3/,
|
/next free token 3/,
|
||||||
);
|
);
|
||||||
const beforeLastDigit = { ...digitsExhausted };
|
const beforeLastDigit = { ...digitsExhausted };
|
||||||
delete beforeLastDigit['prior-9'];
|
delete beforeLastDigit['prior-9'];
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(beforeLastDigit, {
|
||||||
beforeLastDigit,
|
...beforeLastDigit,
|
||||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
futurePair: '00',
|
||||||
),
|
futureDigit: '9',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(digitsExhausted, {
|
||||||
digitsExhausted,
|
...digitsExhausted,
|
||||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
pairB: '01',
|
||||||
),
|
pairA: '00',
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.throws(
|
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.enabledLayerIds = ['street-level'];
|
||||||
state.options['street-level'] = {
|
state.options['street-level'] = {
|
||||||
mapillary: false,
|
mapillary: false,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'flat',
|
pano: 'flat',
|
||||||
sinceDays: MAX_SINCE_DAYS,
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
};
|
};
|
||||||
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||||
assert.deepEqual(decoded.options['street-level'], {
|
assert.deepEqual(decoded.options['street-level'], {
|
||||||
mapillary: false,
|
mapillary: false,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'flat',
|
pano: 'flat',
|
||||||
sinceDays: MAX_SINCE_DAYS,
|
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', () => {
|
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||||
assert.deepEqual(streetLevelOptions(''), {
|
assert.deepEqual(streetLevelOptions(''), {
|
||||||
mapillary: true,
|
mapillary: true,
|
||||||
|
providerSwitches: '',
|
||||||
pano: 'all',
|
pano: 'all',
|
||||||
sinceDays: 0,
|
sinceDays: 0,
|
||||||
});
|
});
|
||||||
@@ -2703,3 +2717,73 @@ test('Street Level: a link without the provider switch keeps Mapillary on', () =
|
|||||||
true,
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -255,8 +255,22 @@ export class LayerStateCoordinator {
|
|||||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||||
const implicitTrackingSync =
|
const implicitTrackingSync =
|
||||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
// Packed options can persist only the subkeys owned by this request.
|
||||||
const value = spec.normalize(change.params[spec.key]);
|
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 (value === null) {
|
||||||
if (implicitTrackingSync) {
|
if (implicitTrackingSync) {
|
||||||
nextOwnerOptions[spec.key] = null;
|
nextOwnerOptions[spec.key] = null;
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ function withPublishedBase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeCodecFixture(cwd, rows, entries) {
|
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 source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||||
assert.equal(source.split(registryEnd).length, 2);
|
assert.equal(source.split(registryEnd).length, 2);
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { normalizeFilter } from './filter.js';
|
import { normalizeFilter } from './filter.js';
|
||||||
|
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share-link params: one boolean per provider plus the filter. The codec in
|
* Provider params plus the filter. Custom switches share a stable ID-based
|
||||||
* src/data/layerState.js names the same keys, so a new provider needs one there.
|
* field; Mapillary retains its existing boolean and share token.
|
||||||
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
||||||
*/
|
*/
|
||||||
export function encodeParams({ providers, filter }) {
|
export function encodeParams({ providers, filter }) {
|
||||||
const params = {};
|
const params = {};
|
||||||
for (const [id, on] of providers) params[id] = on === true;
|
const entries = [...providers];
|
||||||
|
for (const [id, on] of entries) params[id] = on === true;
|
||||||
|
const switches = encodeIdSwitches(
|
||||||
|
entries.filter(([id]) => id !== 'mapillary'),
|
||||||
|
);
|
||||||
|
if (switches) params.providerSwitches = switches;
|
||||||
params.pano = filter.pano;
|
params.pano = filter.pano;
|
||||||
params.sinceDays = filter.sinceDays;
|
params.sinceDays = filter.sinceDays;
|
||||||
return params;
|
return params;
|
||||||
@@ -23,8 +29,13 @@ export function encodeParams({ providers, filter }) {
|
|||||||
export function decodeParams(params, { providerIds, filter }) {
|
export function decodeParams(params, { providerIds, filter }) {
|
||||||
const providers = new Map();
|
const providers = new Map();
|
||||||
const source = params && typeof params === 'object' ? params : {};
|
const source = params && typeof params === 'object' ? params : {};
|
||||||
for (const id of providerIds)
|
const switches = decodeIdSwitches(source.providerSwitches);
|
||||||
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
|
switches.delete('mapillary');
|
||||||
|
for (const id of providerIds) {
|
||||||
|
if (switches.has(id)) providers.set(id, switches.get(id));
|
||||||
|
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
|
||||||
|
providers.set(id, source[id]);
|
||||||
|
}
|
||||||
const next = {};
|
const next = {};
|
||||||
if ('pano' in source) next.pano = source.pano;
|
if ('pano' in source) next.pano = source.pano;
|
||||||
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
|
|||||||
],
|
],
|
||||||
filter: { pano: 'flat', sinceDays: 365 },
|
filter: { pano: 'flat', sinceDays: 365 },
|
||||||
}),
|
}),
|
||||||
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
|
{
|
||||||
|
mapillary: true,
|
||||||
|
panoramax: false,
|
||||||
|
providerSwitches: 'panoramax-0',
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: 365,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { encodeParams, decodeParams } from './params.js';
|
||||||
|
import {
|
||||||
|
normalizeIdSwitches,
|
||||||
|
patchRequestedIdSwitches,
|
||||||
|
decodeIdSwitches,
|
||||||
|
encodeIdSwitches,
|
||||||
|
} from '../../data/idSwitches.js';
|
||||||
|
import {
|
||||||
|
createDefaultLayerState,
|
||||||
|
encodeLayerStateParams,
|
||||||
|
decodeLayerStateParams,
|
||||||
|
serializeStoredLayerState,
|
||||||
|
parseStoredLayerState,
|
||||||
|
} from '../../data/layerState.js';
|
||||||
|
import { validateProviders } from './registry.js';
|
||||||
|
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
const filter = { pano: 'flat', sinceDays: 365 };
|
||||||
|
|
||||||
|
test('custom switches survive sharing and storage without depending on provider order', () => {
|
||||||
|
const providers = [
|
||||||
|
['panoramax', false],
|
||||||
|
['example', true],
|
||||||
|
['mapillary', false],
|
||||||
|
];
|
||||||
|
const options = encodeParams({ providers, filter });
|
||||||
|
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
|
||||||
|
assert.equal(
|
||||||
|
encodeParams({ providers: [...providers].reverse(), filter })
|
||||||
|
.providerSwitches,
|
||||||
|
options.providerSwitches,
|
||||||
|
);
|
||||||
|
const state = createDefaultLayerState();
|
||||||
|
state.enabledLayerIds = ['street-level'];
|
||||||
|
state.options['street-level'] = options;
|
||||||
|
const params = new URLSearchParams({ v: '2' });
|
||||||
|
encodeLayerStateParams(params, state);
|
||||||
|
assert.match(params.get('lo'), /0\.m\.0/);
|
||||||
|
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
|
||||||
|
assert.ok(
|
||||||
|
params.toString().includes('0.r.example-1*panoramax-0'),
|
||||||
|
'custom switches need no percent escaping',
|
||||||
|
);
|
||||||
|
for (const restored of [
|
||||||
|
decodeLayerStateParams(params),
|
||||||
|
parseStoredLayerState(serializeStoredLayerState(state)),
|
||||||
|
]) {
|
||||||
|
const decoded = decodeParams(restored.options['street-level'], {
|
||||||
|
providerIds: ['example', 'mapillary', 'panoramax'],
|
||||||
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
[...decoded.providers],
|
||||||
|
[
|
||||||
|
['example', true],
|
||||||
|
['mapillary', false],
|
||||||
|
['panoramax', false],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(decoded.filter, filter);
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
...decodeParams(restored.options['street-level'], {
|
||||||
|
providerIds: ['panoramax'],
|
||||||
|
filter,
|
||||||
|
}).providers,
|
||||||
|
],
|
||||||
|
[['panoramax', false]],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
decodeParams(restored.options['street-level'], {
|
||||||
|
providerIds: [],
|
||||||
|
filter,
|
||||||
|
}).providers.size,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
|
||||||
|
for (const [lo, on] of [
|
||||||
|
['', true],
|
||||||
|
['0.m.0', false],
|
||||||
|
]) {
|
||||||
|
const state = decodeLayerStateParams(
|
||||||
|
new URLSearchParams({ v: '2', l: '0', lo }),
|
||||||
|
);
|
||||||
|
const restored = decodeParams(state.options['street-level'], {
|
||||||
|
providerIds: ['mapillary', 'example'],
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
assert.deepEqual([...restored.providers], [['mapillary', on]]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
|
||||||
|
for (const value of [
|
||||||
|
'example-0*example-1',
|
||||||
|
'mapillary-0',
|
||||||
|
'__proto__-1',
|
||||||
|
'a-2',
|
||||||
|
'a-1*',
|
||||||
|
'a.b-1',
|
||||||
|
'a_b-0',
|
||||||
|
`${'a'.repeat(255)}-1`,
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
]) {
|
||||||
|
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
|
||||||
|
}
|
||||||
|
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
|
||||||
|
assert.throws(
|
||||||
|
() => encodeIdSwitches([['a'.repeat(255), true]]),
|
||||||
|
/share-link/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
|
||||||
|
/share-link/,
|
||||||
|
);
|
||||||
|
const decoded = decodeParams(
|
||||||
|
{ providerSwitches: 'mapillary-0', mapillary: true },
|
||||||
|
{ providerIds: ['mapillary'], filter },
|
||||||
|
);
|
||||||
|
assert.equal(decoded.providers.get('mapillary'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
|
||||||
|
const switches = [
|
||||||
|
['provider-0', false],
|
||||||
|
['provider-1', true],
|
||||||
|
['a-b-c', true],
|
||||||
|
];
|
||||||
|
const encoded = encodeIdSwitches(switches);
|
||||||
|
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
|
||||||
|
const decoded = decodeIdSwitches(encoded);
|
||||||
|
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
|
||||||
|
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => {
|
||||||
|
const previous = `${'x'.repeat(254)}-1`;
|
||||||
|
assert.equal(normalizeIdSwitches(previous), previous);
|
||||||
|
assert.equal(
|
||||||
|
patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }),
|
||||||
|
'alpha-0',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { encodeIdSwitches } from '../../data/idSwitches.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The contract an imagery provider registers with the Street Level layer.
|
* The contract an imagery provider registers with the Street Level layer.
|
||||||
*
|
*
|
||||||
@@ -125,6 +127,19 @@ export function validateProviders(providers) {
|
|||||||
ids.add(provider.id);
|
ids.add(provider.id);
|
||||||
prefixes.push(provider.pickPrefix);
|
prefixes.push(provider.pickPrefix);
|
||||||
}
|
}
|
||||||
|
// Validate the complete bounded share field once, before any instance exists.
|
||||||
|
try {
|
||||||
|
encodeIdSwitches(
|
||||||
|
providers
|
||||||
|
.filter(({ id }) => id !== 'mapillary')
|
||||||
|
.map(({ id }) => [id, true]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof RangeError)) throw error;
|
||||||
|
throw new RangeError(
|
||||||
|
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
|
||||||
|
);
|
||||||
|
}
|
||||||
return Object.freeze([...providers]);
|
return Object.freeze([...providers]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user