Use a neutral compact switch codec and clarify provider size limits

This commit is contained in:
Sameh Khamis committed 2026-10-09 18:47:04 -07:00
1 parent fbe118aab9
commit 3ba8c6d61b
11 files changed
+127 -98

No files matched your search

+2 -1
View File
@@ -1,7 +1,8 @@
# Changelog
- Custom Street Level provider switches survive share links and stored state
using stable provider IDs; existing Mapillary links keep their meaning.
using URL-safe stable provider IDs and a shared boolean-switch codec; existing
Mapillary links keep their meaning.
- Directions and Recent Imagery accept replaceable acquisition sources, including
route transport, imagery catalog, thumbnails, tile templates and attribution.
+4 -2
View File
@@ -27,12 +27,14 @@ 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 and the legacy Mapillary `m` option remain
stable. Custom provider switches use the `r` option, sorted by provider ID rather
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. 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.
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
+11 -11
View File
@@ -552,7 +552,7 @@
"src/contextModePolicy.js",
"src/data/detectionPolicy.js",
"src/data/installationFeedback.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -801,7 +801,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -844,7 +844,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/modelVisualAnchor.js",
@@ -882,7 +882,7 @@
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -1208,7 +1208,7 @@
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/filter.js",
@@ -1254,7 +1254,7 @@
"exports": ["./layers/radio"],
"modules": [
"src/data/iconOrientation.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/radioCountry.js",
@@ -1543,7 +1543,7 @@
"src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -2293,7 +2293,7 @@
"src/data/issPass.js",
"src/data/satellitePass.js",
"src/data/labelArbiter.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/data/localGeojson.js",
@@ -2815,7 +2815,7 @@
"exports": ["./ui/share-restoration"],
"modules": [
"src/data/installationFeedback.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateCoordinator.js",
"src/data/layerStateTokenReservations.json",
@@ -3336,7 +3336,7 @@
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js",
@@ -3459,7 +3459,7 @@
"exports": ["./view"],
"modules": [
"src/view/index.js",
"src/layers/streetLevel/providerSwitches.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json"
],
+47
View File
@@ -0,0 +1,47 @@
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'];
})
: [],
);
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { normalizeProviderSwitches } from '../layers/streetLevel/providerSwitches.js';
import { normalizeIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
const VALID_DISPOSITIONS = new Set([
@@ -418,9 +418,9 @@ const OPTION_GROUPS = Object.freeze({
key: 'providerSwitches',
token: 'r',
defaultValue: '',
normalize: normalizeProviderSwitches,
encode: normalizeProviderSwitches,
decode: normalizeProviderSwitches,
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
encode: normalizeIdSwitches,
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
}),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a',
+2 -7
View File
@@ -98,16 +98,11 @@ function withPublishedBase(
}
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(
cwd,
'src/layers/streetLevel/providerSwitches.js',
);
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
writeFileSync(
providerCodec,
readFileSync(
new URL('../layers/streetLevel/providerSwitches.js', import.meta.url),
),
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
+6 -6
View File
@@ -1,8 +1,5 @@
import { normalizeFilter } from './filter.js';
import {
encodeProviderSwitches,
decodeProviderSwitches,
} from './providerSwitches.js';
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
/**
* Provider params plus the filter. Custom switches share a stable ID-based
@@ -13,7 +10,9 @@ export function encodeParams({ providers, filter }) {
const params = {};
const entries = [...providers];
for (const [id, on] of entries) params[id] = on === true;
const switches = encodeProviderSwitches(entries);
const switches = encodeIdSwitches(
entries.filter(([id]) => id !== 'mapillary'),
);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
@@ -30,7 +29,8 @@ export function encodeParams({ providers, filter }) {
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
const switches = decodeProviderSwitches(source.providerSwitches);
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')
+1 -1
View File
@@ -14,7 +14,7 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax=0',
providerSwitches: 'panoramax-0',
pano: 'flat',
sinceDays: 365,
},
@@ -1,45 +0,0 @@
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'];
})
: [],
);
}
@@ -2,9 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeProviderSwitches,
encodeProviderSwitches,
} from './providerSwitches.js';
normalizeIdSwitches,
decodeIdSwitches,
encodeIdSwitches,
} from '../../data/idSwitches.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
@@ -24,7 +25,7 @@ test('custom switches survive sharing and storage without depending on provider
['mapillary', false],
];
const options = encodeParams({ providers, filter });
assert.equal(options.providerSwitches, 'example=1,panoramax=0');
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
assert.equal(
encodeParams({ providers: [...providers].reverse(), filter })
.providerSwitches,
@@ -36,7 +37,11 @@ test('custom switches survive sharing and storage without depending on provider
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.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)),
@@ -91,31 +96,44 @@ test('legacy links retain Mapillary semantics and leave custom defaults alone',
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`,
'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(normalizeIdSwitches(value, ['mapillary']), null);
}
assert.equal(normalizeProviderSwitches('constructor=0'), 'constructor=0');
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
assert.throws(
() => encodeProviderSwitches([['a'.repeat(255), true]]),
/oversized/,
() => encodeIdSwitches([['a'.repeat(255), true]]),
/share-link/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/oversized/,
/share-link/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary=0', mapillary: true },
{ 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);
});
+13 -2
View File
@@ -1,4 +1,4 @@
import { encodeProviderSwitches } from './providerSwitches.js';
import { encodeIdSwitches } from '../../data/idSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
@@ -128,7 +128,18 @@ export function validateProviders(providers) {
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
encodeProviderSwitches(providers.map(({ id }) => [id, true]));
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]);
}