Preserve custom Street Level provider switches in shared state

This commit is contained in:
Sameh Khamis committed 2026-10-09 17:53:47 -07:00
1 parent 3b840ebdf3
commit 7b1b2df764
11 files changed
+238 -9

No files matched your search

+3
View File
@@ -1,5 +1,8 @@
# Changelog
- Custom Street Level provider switches survive share links and stored state
using stable provider IDs; existing Mapillary links keep their meaning.
- Directions and Recent Imagery accept replaceable acquisition sources, including
route transport, imagery catalog, thumbnails, tile templates and attribution.
Explicitly removing either source leaves its layer unavailable; standalone
+7 -2
View File
@@ -24,8 +24,13 @@ all imagery providers. Without that option, Mapillary is registered only when
with a truthful no-provider state, preserving layer controls and share tokens.
Malformed selected sources and provider definitions still fail validation.
An explicit provider list bypasses the unused Mapillary source, including its
validation. Layer share identities are preserved; custom provider switches require
a corresponding serialization contract.
validation. Layer share identities and the legacy Mapillary `m` option remain
stable. Custom provider switches use the `r` option, 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. The custom switch field is bounded to 256 characters;
provider registration rejects compositions that cannot fit rather than truncating
identities. Existing whole-link size limits still apply.
## God's Eye View in conversations — October 2, 2026
+11
View File
@@ -552,6 +552,7 @@
"src/contextModePolicy.js",
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -800,6 +801,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -842,6 +844,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -879,6 +882,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -1204,6 +1208,7 @@
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/providerSwitches.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/filter.js",
@@ -1249,6 +1254,7 @@
"exports": ["./layers/radio"],
"modules": [
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js",
@@ -1537,6 +1543,7 @@
"src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -2286,6 +2293,7 @@
"src/data/issPass.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/localGeojson.js",
@@ -2807,6 +2815,7 @@
"exports": ["./ui/share-restoration"],
"modules": [
"src/data/installationFeedback.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -3327,6 +3336,7 @@
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
@@ -3449,6 +3459,7 @@
"exports": ["./view"],
"modules": [
"src/view/index.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json"
],
+10 -1
View File
@@ -1,3 +1,4 @@
import { normalizeProviderSwitches } from '../layers/streetLevel/providerSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -411,8 +412,16 @@ 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',
token: 'r',
defaultValue: '',
normalize: normalizeProviderSwitches,
encode: normalizeProviderSwitches,
decode: normalizeProviderSwitches,
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
pano: 'p',
+3
View File
@@ -2660,12 +2660,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 +2695,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,
});
+11
View File
@@ -98,6 +98,17 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(
cwd,
'src/layers/streetLevel/providerSwitches.js',
);
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(
new URL('../layers/streetLevel/providerSwitches.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);
+16 -5
View File
@@ -1,13 +1,20 @@
import { normalizeFilter } from './filter.js';
import {
encodeProviderSwitches,
decodeProviderSwitches,
} from './providerSwitches.js';
/**
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* Provider params plus the filter. Custom switches share a stable ID-based
* field; Mapillary retains its existing boolean and share token.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
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 = encodeProviderSwitches(entries);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
@@ -23,8 +30,12 @@ export function encodeParams({ providers, filter }) {
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
for (const id of providerIds)
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
const switches = decodeProviderSwitches(source.providerSwitches);
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 = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
+7 -1
View File
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
],
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,45 @@
const MAX_SWITCHES_LENGTH = 256;
const ENTRY = /^([a-z][a-z0-9-]*)=([01])$/;
/** Canonical, bounded provider switches; Mapillary retains its legacy token. */
export function normalizeProviderSwitches(value) {
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 || match[1] === 'mapillary' || 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 encodeProviderSwitches(providers) {
const value = [...providers]
.filter(([id]) => id !== 'mapillary')
.map(([id, on]) => `${id}=${on === true ? '1' : '0'}`)
.join(',');
const normalized = normalizeProviderSwitches(value);
if (normalized === null)
throw new TypeError('Invalid or oversized Street Level provider switches');
return normalized;
}
/** A Map keeps provider IDs separate from object prototype properties. */
export function decodeProviderSwitches(value) {
const normalized = normalizeProviderSwitches(value);
return new Map(
normalized
? normalized.split(',').map((entry) => {
const [id, on] = entry.split('=');
return [id, on === '1'];
})
: [],
);
}
@@ -0,0 +1,121 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeProviderSwitches,
encodeProviderSwitches,
} from './providerSwitches.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/);
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(normalizeProviderSwitches(value), null);
}
assert.equal(normalizeProviderSwitches('constructor=0'), 'constructor=0');
assert.throws(
() => encodeProviderSwitches([['a'.repeat(255), true]]),
/oversized/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/oversized/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary=0', mapillary: true },
{ providerIds: ['mapillary'], filter },
);
assert.equal(decoded.providers.get('mapillary'), true);
});
+4
View File
@@ -1,3 +1,5 @@
import { encodeProviderSwitches } from './providerSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -125,6 +127,8 @@ export function validateProviders(providers) {
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
encodeProviderSwitches(providers.map(({ id }) => [id, true]));
return Object.freeze([...providers]);
}