diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ff5afb1..a93296425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 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 0ef1d3157..dc9a9278b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. 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 (`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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 5b10693ab..497be5b08 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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.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/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/idSwitches.js b/src/data/idSwitches.js new file mode 100644 index 000000000..0fe8f8de0 --- /dev/null +++ b/src/data/idSwitches.js @@ -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; +} diff --git a/src/data/layerState.js b/src/data/layerState.js index 92a18179c..373eac20d 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 1ece6750f..08120a604 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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(); + } +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 86b6af8e7..687b87dfb 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -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; diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 95e827416..5a02d85cf 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -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); diff --git a/src/layers/streetLevel/params.js b/src/layers/streetLevel/params.js index 1d87b1ebc..4aed3ad8f 100644 --- a/src/layers/streetLevel/params.js +++ b/src/layers/streetLevel/params.js @@ -1,13 +1,19 @@ 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 - * 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 = encodeIdSwitches( + entries.filter(([id]) => id !== 'mapillary'), + ); + if (switches) params.providerSwitches = switches; params.pano = filter.pano; params.sinceDays = filter.sinceDays; return params; @@ -23,8 +29,13 @@ 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 = decodeIdSwitches(source.providerSwitches); + 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 = {}; 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..ffe56272f 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.test.mjs b/src/layers/streetLevel/providerSwitches.test.mjs new file mode 100644 index 000000000..03a161dcb --- /dev/null +++ b/src/layers/streetLevel/providerSwitches.test.mjs @@ -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, + ); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index b6b81fe35..29ec24869 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,3 +1,5 @@ +import { encodeIdSwitches } from '../../data/idSwitches.js'; + /** * The contract an imagery provider registers with the Street Level layer. * @@ -125,6 +127,19 @@ export function validateProviders(providers) { ids.add(provider.id); 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]); }