diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a2620b1..b2a4906ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 27a5815e9..696c94d35 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5b10693ab..72a8217c6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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" ], diff --git a/src/data/layerState.js b/src/data/layerState.js index 92a18179c..fad2f112e 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1ece6750f..7377bdaa4 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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, }); diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 95e827416..4b8b23c04 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -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); diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js index 1d87b1ebc..8af631ffc 100644 --- a/src/layers/streetLevel/params.js +++ b/src/layers/streetLevel/params.js @@ -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; diff --git a/src/layers/streetLevel/params.test.mjs b/src/layers/streetLevel/params.test.mjs index 19a89a1ca..856f33d97 100644 --- a/src/layers/streetLevel/params.test.mjs +++ b/src/layers/streetLevel/params.test.mjs @@ -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, + }, ); }); diff --git a/src/layers/streetLevel/providerSwitches.js b/src/layers/streetLevel/providerSwitches.js new file mode 100644 index 000000000..cb1237542 --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.js @@ -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']; + }) + : [], + ); +} diff --git a/src/layers/streetLevel/providerSwitches.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs new file mode 100644 index 000000000..96253dfab --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -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); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index b6b81fe35..aefb14ae1 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -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]); }