refactor(street-level): the panel cuts deferred until the new sources landed

HAL-530 left five panel cuts until Street View, KartaView and Panoramax
were in:

- SINCE is a select of the nine windows instead of a slider with its own
  thumb-sync code; a link's day count between them gets its own option and
  the tooltip names the cut-off date.
- IMAGERY and FIT/FILL are toggle buttons (aria-pressed), each a tab stop,
  instead of ARIA radiogroups with roving tabindex and arrow-key handling.
- The panel opens by itself only when a photo opens (a map or line click
  would load it out of sight); a switch-on no longer opens it, so the
  enable-origin subscription goes.
- Clean View, recording and the cockpit call shrinkViewer() as they hide
  the panels, instead of a MutationObserver on <body> classes.
- FIT/FILL stays (three providers use it) but is no longer kept in
  localStorage: each session starts at FIT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-19 19:42:44 +04:00
1 parent d983ef23d8
commit bdf3848dbc
12 files changed
+203 -238

No files matched your search

+1 -1
View File
@@ -421,7 +421,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under SINCE; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
+9 -3
View File
@@ -3484,7 +3484,12 @@ Mapillary token, Street View on Google 3D with a Google key), the layer
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
line and the layer row's tooltip say to light it. The
viewer sits under the header with EXPAND, FIT/FILL and close above the
image. SINCE is a stepped slider whose readout names the cut-off date.
image; FIT/FILL starts at FIT each session. IMAGERY and FIT/FILL are rows of
toggle buttons (`aria-pressed`), each its own tab stop. SINCE is a select of
the nine windows; a link's day count between them gets its own option, and
the tooltip names the cut-off date. The panel opens by itself only when a
photo opens (a click on the map or a line would otherwise load it out of
sight), never when the layer is switched on.
MapillaryJS loads on the first photo. Opening one frames it with a single
camera flight through the application's deferred navigation (`begin` /
@@ -3493,7 +3498,8 @@ if newer navigation took the camera while the photo loaded; closing the photo
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
iPhone, which has no element fullscreen, the viewer opens as a top-layer
popover instead (hidden where neither API exists). Clean View, recording and
the cockpit take an expanded photo out of full screen. At phone width the
the cockpit take an expanded photo out of full screen: the shell calls the
panel's `shrinkViewer()` as it enters them. At phone width the
viewer height comes from the rail band so the whole photo fits.
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
@@ -3586,7 +3592,7 @@ lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores
photos are near but none passes, the panel says so), and the cones pass on
each photo's projection and upload date. The provider declares
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and
upload date" under the SINCE select (`sinceNote` in the UI state). Tiles come uncompressed and
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
KartaView is lit (`createTileCache`, dropped when it is switched off); one
that does not answer in 20 s is asked once more, and one that is not a line
+4 -1
View File
@@ -693,7 +693,10 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
}
assert.match(ui, /onEntered: \(\) => this\.enterPanels\(\)/);
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
assert.match(
ui,
/enterPanels: \(\) => \{[^}]*this\._panelChrome\.enterCockpit\(\);\s*\}/,
);
const callback = { 1: shellMethod('enterCockpit').toString() };
assert.ok(callback, 'Cockpit onEntered callback is missing');
assert.match(
+12 -10
View File
@@ -70,24 +70,26 @@ test('the key gate holds every filter, with the error line outside it', () => {
assert.match(html, /id="sl-error"[^>]*role="alert"/);
});
test('each radiogroup is one tab stop, on its checked button', () => {
for (const [group, checked] of [
test('the segmented filters are toggle buttons, each its own tab stop', () => {
for (const [group, chosen] of [
['data-sl-render', 'letterbox'],
['data-sl-pano', 'all'],
]) {
const radios = [
const buttons = [
...html.matchAll(
new RegExp(`<button [^>]*${group}="([a-z]+)"[^>]*>`, 'g'),
),
];
assert.ok(radios.length >= 2, group);
for (const [tag, value] of radios)
assert.equal(
/tabindex="-1"/.test(tag),
value !== checked,
`${group}="${value}" roving tab stop`,
);
assert.ok(buttons.length >= 2, group);
for (const [tag, value] of buttons) {
assert.match(tag, new RegExp(`aria-pressed="${value === chosen}"`));
assert.doesNotMatch(tag, /role="radio"|tabindex=/, `${group}="${value}"`);
}
}
assert.doesNotMatch(
html,
/role="radiogroup"[^>]*(Image fit|Panorama filter)/,
);
});
test('the Cyber theme frames the Street Level panel like its rail peers (P3)', () => {
+7 -7
View File
@@ -403,7 +403,11 @@ export class StyleManager extends ShellFacade {
readDataManager: () => this._dataManager,
readContext: () => this.getContextModeState(),
readActiveStyle: () => this.activeStyle,
enterPanels: () => this._panelChrome.enterCockpit(),
enterPanels: () => {
// The cockpit hides the panels: an expanded street-level photo too.
this._streetLevelControls?.shrinkViewer();
this._panelChrome.enterCockpit();
},
exitPanels: () => this._panelChrome.exitCockpit(),
});
@@ -1109,12 +1113,6 @@ export class StyleManager extends ShellFacade {
setPanelCollapsed: (collapsed, options) =>
this.setPanelCollapsed('street-level-panel', collapsed, options),
showToast: (message) => this._showToast(message),
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
this._dataManager?.subscribeVisibilityRequests?.((change) => {
if (change?.layerId === 'street-level' && change.enabled)
listener(change.origin);
}) || null,
},
});
this._streetLevelControls.connect();
@@ -1250,6 +1248,8 @@ export class StyleManager extends ShellFacade {
? forceEnabled
: !document.body.classList.contains('ui-clean-view');
document.body.classList.toggle('ui-clean-view', shouldEnable);
// Clean View hides the panels: an expanded street-level photo too.
if (shouldEnable) this._streetLevelControls?.shrinkViewer();
if (this._cleanViewBtn) {
this._cleanViewBtn.classList.toggle('active', shouldEnable);
}
+3
View File
@@ -1182,6 +1182,9 @@ export class ShellFacade {
* @returns {void}
*/
setRecordingMode(enabled, options) {
// Recording hides the panels: an expanded street-level photo goes too.
if (enabled && options?.hidePanels !== false)
this._streetLevelControls?.shrinkViewer();
return this._recording.setRecordingMode(enabled, options);
}
+48 -124
View File
@@ -1,4 +1,3 @@
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import { syncChipGroup } from './chipGroup.js';
import {
presentStreetLevelPanel,
@@ -6,16 +5,6 @@ import {
} from './streetLevelPresentation.js';
import { t, translateKnown } from '../i18n/index.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
const RADIO_STEPS = Object.freeze({
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
});
// Renders run on every layer notification, and the rail's MutationObserver
// relays out on each attribute write: write only what changed.
const setProp = (node, key, value) => {
@@ -39,11 +28,9 @@ export class StreetLevelControls {
this._unsubscribe = null;
this._state = null;
this._view = null;
this._wasEnabled = null;
this._wasOpen = false;
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
this._explicitEnable = false;
this._unsubscribeEnableRequests = null;
/** The SINCE option for a day count between the stops, while one is set. */
this._customSince = null;
this._resizeQueued = false;
this._elements = this._collect();
this._bind();
@@ -63,8 +50,7 @@ export class StreetLevelControls {
errorText: byId('sl-error-text'),
panoRow: byId('sl-pano-row'),
sinceRow: byId('sl-since-row'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
since: byId('sl-since'),
sinceNote: byId('sl-since-note'),
viewerWrap: byId('sl-viewer-wrap'),
viewerExpand: byId('sl-viewer-expand'),
@@ -111,18 +97,15 @@ export class StreetLevelControls {
this._setParams({ pano: button.dataset.slPano }),
);
}
this._bindRadioGroup(el.panoButtons);
this._bindRadioGroup(el.renderButtons);
// The readout follows the thumb; coverage is rebuilt once, on release.
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
this.listen(el.sinceRange, 'input', () => {
const label = t(SINCE_STOPS[Number(el.sinceRange.value) || 0].label);
if (el.sinceLabel) el.sinceLabel.textContent = label;
el.sinceRange.setAttribute('aria-valuetext', label);
});
this.listen(el.sinceRange, 'change', () =>
this._setParams({ sinceDays: sinceDays() }),
// The stops, in the UI language (a language change reloads the page).
for (const stop of SINCE_STOPS) {
const option = document.createElement('option');
option.value = String(stop.days);
option.textContent = t(stop.label);
el.since?.appendChild(option);
}
this.listen(el.since, 'change', () =>
this._setParams({ sinceDays: Number(el.since.value) || 0 }),
);
this.listen(el.nearestBtn, 'click', () => {
const nearest = this._view?.nearest;
@@ -160,24 +143,10 @@ export class StreetLevelControls {
this._exitExpanded();
});
} else setProp(el.viewerExpand, 'hidden', true);
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
const mode = button.dataset.slRender;
this.layer.setViewerRenderMode?.(mode);
try {
localStorage.setItem(RENDER_MODE_KEY, mode);
} catch {
/* storage unavailable */
}
});
}
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
this.layer.setViewerRenderMode?.(stored);
} catch {
/* storage unavailable */
}
for (const button of el.renderButtons)
this.listen(button, 'click', () =>
this.layer.setViewerRenderMode?.(button.dataset.slRender),
);
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
this._resizeObserver = new ResizeObserver(() => this._requestResize());
@@ -185,24 +154,6 @@ export class StreetLevelControls {
}
}
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
_bindRadioGroup(buttons) {
buttons.forEach((button, index) => {
this.listen(button, 'keydown', (event) => {
let next = null;
if (event.key in RADIO_STEPS) {
const count = buttons.length;
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
} else if (event.key === 'Home') next = buttons[0];
else if (event.key === 'End') next = buttons[buttons.length - 1];
if (!next) return;
event.preventDefault();
next.focus();
if (next !== button) next.click();
});
});
}
/**
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
* ask for tiles at z=NaN; the observer fires again once it has a size.
@@ -315,32 +266,18 @@ export class StreetLevelControls {
this._syncExpanded();
}
/** Clean View, recording and the cockpit hide every panel, so they end EXPAND too. */
_watchPanelHidingModes(on) {
const body = document.body;
if (!on || !body) {
this._modesObserver?.disconnect();
this._modesObserver = null;
return;
}
const leaveIfHiding = () => {
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
this._exitExpanded();
};
if (!this._modesObserver && typeof MutationObserver === 'function') {
this._modesObserver = new MutationObserver(leaveIfHiding);
this._modesObserver.observe(body, {
attributes: true,
attributeFilter: ['class'],
});
}
leaveIfHiding();
/**
* Shrink an expanded photo. Clean View, recording and the cockpit hide
* every panel and call it, so the photo does not stay full screen (or, on
* iPhone, as a popover) over them.
*/
shrinkViewer() {
this._exitExpanded();
}
/** Mirror the expanded state on EXPAND, watch the panel-hiding modes, refit. */
/** Mirror the expanded state on EXPAND and refit. */
_syncExpanded() {
const on = this.isViewerExpanded();
this._watchPanelHidingModes(on);
const button = this._elements.viewerExpand;
if (button) {
setProp(
@@ -363,12 +300,6 @@ export class StreetLevelControls {
this._unsubscribe?.();
this._unsubscribe = null;
if (this.destroyed || !this.root) return;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests = this.actions.subscribeEnableRequests(
(origin) => {
this._explicitEnable = isExplicitLayerStateOrigin(origin);
},
);
this._unsubscribe = this.layer.subscribe?.((state) => this.render(state));
if (this.layer.getUIState) this.render(this.layer.getUIState());
}
@@ -416,12 +347,12 @@ export class StreetLevelControls {
setProp(el.errorText, 'textContent', view.error || '');
}
_renderRadios(buttons, isChecked) {
/** A segmented control: the chosen button is pressed. */
_renderPressed(buttons, isChosen) {
for (const button of buttons) {
const checked = isChecked(button);
button.classList.toggle('is-active', checked);
setAttr(button, 'aria-checked', String(checked));
setAttr(button, 'tabindex', checked ? '0' : '-1');
const chosen = isChosen(button);
button.classList.toggle('is-active', chosen);
setAttr(button, 'aria-pressed', String(chosen));
}
}
@@ -434,23 +365,25 @@ export class StreetLevelControls {
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
setProp(el.sinceNote, 'hidden', !view.sinceNote);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
this._renderRadios(
this._renderPressed(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
);
if (el.sinceRange) {
// Never move the thumb under the user's hand; once the committed value
// matches the thumb, show the full readout with its cut-off date.
const index = String(view.since.index);
if (
document.activeElement !== el.sinceRange &&
el.sinceRange.value !== index
)
el.sinceRange.value = index;
if (el.sinceRange.value === index) {
setProp(el.sinceLabel, 'textContent', view.since.label);
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
if (el.since) {
// A link may carry a day count between the stops: it gets an option.
if (!view.since.exact) {
this._customSince ||= el.since.appendChild(
document.createElement('option'),
);
setProp(this._customSince, 'value', String(view.since.days));
setProp(this._customSince, 'textContent', view.since.window);
} else if (this._customSince) {
this._customSince.remove();
this._customSince = null;
}
setProp(el.since, 'value', String(view.since.days));
// The cut-off date, for the pointer and for screen readers.
setProp(el.since, 'title', view.since.label);
}
}
@@ -467,7 +400,7 @@ export class StreetLevelControls {
setProp(wrap, 'hidden', !viewer.open);
}
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
this._renderRadios(
this._renderPressed(
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
@@ -497,17 +430,11 @@ export class StreetLevelControls {
}
/**
* Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice.
* Open the panel when an image opens (a click on the map or a line would
* otherwise load it out of sight). Never persisted: the stored collapse
* state stays the user's own choice.
*/
_reactToTransitions(view) {
const enabled = view.enabled;
if (enabled && this._wasEnabled === false) {
// A restore must not reopen a panel the user or a share link kept collapsed.
if (this._explicitEnable) this.setCollapsed(false, { persist: false });
this._explicitEnable = false;
}
this._wasEnabled = enabled;
// Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet.
const open = view.viewer.open;
@@ -523,14 +450,11 @@ export class StreetLevelControls {
// would hide the viewer even inside the panel.
if (this._expandMode === 'popover')
this._elements.viewerWrap?.removeAttribute('popover');
this._watchPanelHidingModes(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests = null;
this.layer.attachViewerHost?.(null);
}
}
+78 -68
View File
@@ -247,8 +247,7 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
for (const pano of ['all', 'pano', 'flat'])
add(panoRow, 'button', { dataset: { slPano: pano } });
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
add(sinceRow, 'input', { id: 'sl-since' });
add(sinceRow, 'output', { id: 'sl-since-label' });
add(sinceRow, 'select', { id: 'sl-since' });
add(sinceRow, 'small', { id: 'sl-since-note' });
const metaRow = add(root, 'div', { classes: ['sl-meta-row'] });
add(metaRow, 'div', { id: 'sl-coverage-meta' });
@@ -336,7 +335,6 @@ async function productionPanel(dom) {
manager.setLayerParams('street-level', params, options),
setPanelCollapsed() {},
showToast() {},
subscribeEnableRequests: () => () => {},
},
});
controls.connect();
@@ -372,11 +370,11 @@ test('a 360° click reaches the share link through the data manager', () =>
coordinator.destroy();
}));
test('releasing the SINCE slider records the window in the share link', () =>
test('choosing a SINCE window records it in the share link', () =>
withDom(async (dom) => {
const { coordinator, controls, share } = await productionPanel(dom);
const since = dom.root.querySelector('#sl-since');
since.value = '5'; // the "last year" stop
since.value = '365'; // the "last year" stop
since.dispatchEvent(new Event('change'));
await settle();
const days =
@@ -416,6 +414,7 @@ function uiState({
on = true,
open = false,
pano = 'all',
sinceDays = 0,
renderMode = 'letterbox',
keyRequired = false,
} = {}) {
@@ -424,7 +423,7 @@ function uiState({
providers: [providerSnapshot({ on, keyRequired })],
keyRequired,
keyRejected: false,
filter: { pano, sinceDays: 0 },
filter: { pano, sinceDays },
coverage: { count: 3, loading: false, hint: '', error: null },
sequence: { selectedId: null, images: 0, loading: false },
street: {
@@ -441,8 +440,6 @@ function stubPanel(dom, state, extraActions = {}) {
const layer = stubLayer();
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
let enabled = state.enabled;
/** Reports a switch-on request's origin, as the data manager does. */
let announce = null;
layer.publish(state);
const controls = new StreetLevelControls({
root: dom.root,
@@ -457,12 +454,6 @@ function stubPanel(dom, state, extraActions = {}) {
setPanelCollapsed: (collapsed, options) =>
calls.collapsed.push([collapsed, options]),
showToast: (message) => calls.toasts.push(message),
subscribeEnableRequests: (listener) => {
announce = listener;
return () => {
announce = null;
};
},
...extraActions,
},
});
@@ -471,8 +462,6 @@ function stubPanel(dom, state, extraActions = {}) {
layer,
calls,
controls,
announce: (origin) => announce?.(origin),
subscribed: () => announce !== null,
chip: () => dom.root.querySelector('#sl-provider-chips').children[0],
};
}
@@ -737,50 +726,57 @@ function fakeMutationObserver() {
};
}
for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
test(`entering ${mode} takes the photo out of full screen`, () =>
withDom(async (dom) => {
const saved = globalThis.MutationObserver;
const watch = fakeMutationObserver();
globalThis.MutationObserver = watch.FakeObserver;
try {
const { controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, dom.wrap);
assert.equal(watch.observers.size, 1, 'watching while expanded');
dom.document.body.classList.toggle(mode, true);
watch.fire();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, null);
assert.equal(watch.observers.size, 0, 'and not after');
controls.destroy();
} finally {
globalThis.MutationObserver = saved;
}
}));
test('shrinkViewer takes the photo out of full screen (Clean View, recording, the cockpit)', () =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
dom.root.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, dom.wrap);
controls.shrinkViewer();
await settle();
await settle();
assert.equal(dom.document.fullscreenElement, null);
controls.shrinkViewer(); // nothing expanded: nothing to do
controls.destroy();
}));
test('the shell shrinks the photo when Clean View, recording or the cockpit hide the panels', () => {
const shell = fs.readFileSync(
new URL('./applicationShell.js', import.meta.url),
'utf8',
);
const facade = fs.readFileSync(
new URL('./shellFacade.js', import.meta.url),
'utf8',
);
assert.match(
shell,
/toggleCleanView[\s\S]*?if \(shouldEnable\) this\._streetLevelControls\?\.shrinkViewer\(\)/,
);
assert.match(
shell,
/enterPanels: \(\) => \{[\s\S]*?this\._streetLevelControls\?\.shrinkViewer\(\);/,
);
assert.match(
facade,
/setRecordingMode\(enabled, options\) \{[\s\S]*?this\._streetLevelControls\?\.shrinkViewer\(\);/,
);
});
/* ── Restores and keyboard rules ───────────────────────────────────────── */
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
test('a photo opening opens the panel, unstored; switching the layer on does not', () =>
withDom(async (dom) => {
const { layer, calls, controls, announce, subscribed } = stubPanel(
const { layer, calls, controls } = stubPanel(
dom,
uiState({ enabled: false }),
);
announce('restore');
layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [], 'a restore leaves the panel be');
layer.publish(uiState({ enabled: false }));
announce('user');
layer.publish(uiState({ enabled: true }));
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
assert.deepEqual(calls.collapsed, [], 'a switch-on leaves the panel be');
layer.publish(uiState({ enabled: true, open: true }));
assert.equal(calls.collapsed.length, 2, 'a photo opening opens it too');
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
controls.destroy();
assert.equal(subscribed(), false, 'destroy unsubscribes');
}));
test('FIT / FILL show the selected render mode (P3)', () =>
@@ -788,8 +784,8 @@ test('FIT / FILL show the selected render mode (P3)', () =>
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
layer.publish(uiState({ open: true, renderMode: 'fill' }));
const [fit, fill] = dom.wrap.querySelectorAll('[data-sl-render]');
assert.equal(fill.getAttribute('aria-checked'), 'true');
assert.equal(fit.getAttribute('aria-checked'), 'false');
assert.equal(fill.getAttribute('aria-pressed'), 'true');
assert.equal(fit.getAttribute('aria-pressed'), 'false');
assert.equal(fill.classList.contains('is-active'), true);
controls.destroy();
}));
@@ -834,35 +830,46 @@ test('identical renders write nothing to the DOM (P3: rail MutationObserver)', (
controls.destroy();
}));
test('arrow keys move the selection within a radiogroup, which has one tab stop (P3)', () =>
test('the imagery filter is a row of toggle buttons, the chosen one pressed', () =>
withDom(async (dom) => {
const { layer, calls, controls } = stubPanel(dom, uiState());
const keydown = (target, key) =>
target.dispatchEvent({ type: 'keydown', bubbles: true, key });
const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]');
assert.deepEqual(
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
['0', '-1', '-1'],
);
all.focus();
keydown(all, 'ArrowRight');
assert.equal(dom.document.activeElement, pano);
const pressed = () =>
[all, pano, flat].map((button) => button.getAttribute('aria-pressed'));
assert.deepEqual(pressed(), ['true', 'false', 'false']);
pano.click();
assert.deepEqual(calls.setParams.at(-1), [
{ pano: 'pano' },
{ origin: 'user' },
]);
keydown(all, 'ArrowLeft'); // wraps to the end
assert.equal(dom.document.activeElement, flat);
keydown(flat, 'Home');
assert.equal(dom.document.activeElement, all);
layer.publish(uiState({ pano: 'flat' }));
assert.deepEqual(pressed(), ['false', 'false', 'true']);
// No roving tab stop: every button is reachable with Tab.
assert.deepEqual(
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
['-1', '-1', '0'],
[null, null, null],
);
controls.destroy();
}));
test('SINCE lists the stops, adds a link’s own day count and names the cut-off date', () =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState());
const since = dom.root.querySelector('#sl-since');
assert.equal(since.children.length, 9, 'the nine stops');
assert.equal(since.children[0].textContent, 'ANY DATE');
layer.publish(uiState({ sinceDays: 365 }));
assert.equal(since.value, '365');
assert.match(since.title, /^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/);
layer.publish(uiState({ sinceDays: 400 }));
assert.equal(since.children.length, 10);
assert.equal(since.children[9].textContent, 'LAST 400 DAYS');
assert.equal(since.value, '400');
layer.publish(uiState({ sinceDays: 30 }));
assert.equal(since.children.length, 9, 'gone once a stop is chosen');
controls.destroy();
}));
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling (P3)', () =>
withDom(async (dom) => {
const { calls, chip, controls } = stubPanel(
@@ -984,7 +991,10 @@ test('a note under SINCE says what date it compares, only while one is given', (
'utf8',
);
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
assert.match(template, /aria-describedby="sl-since-label sl-since-note"/);
assert.match(
template,
/<select id="sl-since"[^>]*aria-describedby="sl-since-note"/,
);
}));
test('Street View lit hides the imagery filters; Mapillary lit shows them', () =>
+11 -1
View File
@@ -35,7 +35,14 @@ export function sinceStopIndex(days) {
function presentSince(days, now) {
const value = Number(days) || 0;
const index = sinceStopIndex(value);
if (value <= 0) return { index, days: 0, label: t('ANY DATE') };
if (value <= 0)
return {
index,
days: 0,
exact: true,
window: t('ANY DATE'),
label: t('ANY DATE'),
};
const stop = SINCE_STOPS[index];
const window =
stop.days === value
@@ -44,6 +51,9 @@ function presentSince(days, now) {
return {
index,
days: value,
/** One of SINCE_STOPS; a link may carry any other day count. */
exact: stop.days === value,
window,
label: t('{window} · SINCE {date}', {
window,
date: formatDate(now - value * DAY_MS),
+11 -1
View File
@@ -182,7 +182,13 @@ test('a SINCE window from a link lands on the nearest slider stop', () => {
test('the SINCE readout names the window and the cut-off date it means today', () => {
const now = Date.UTC(2026, 8, 25);
const any = presentStreetLevelPanel(snapshot(), { now });
assert.deepEqual(any.since, { index: 0, days: 0, label: 'ANY DATE' });
assert.deepEqual(any.since, {
index: 0,
days: 0,
exact: true,
window: 'ANY DATE',
label: 'ANY DATE',
});
const year = presentStreetLevelPanel(
snapshot({ filter: { pano: 'pano', sinceDays: 365 } }),
{ now },
@@ -191,6 +197,8 @@ test('the SINCE readout names the window and the cut-off date it means today', (
assert.deepEqual(year.since, {
index: 5,
days: 365,
exact: true,
window: 'LAST YEAR',
label: 'LAST YEAR · SINCE 2025-09-25',
});
const custom = presentStreetLevelPanel(
@@ -198,6 +206,8 @@ test('the SINCE readout names the window and the cut-off date it means today', (
{ now },
);
assert.equal(custom.since.index, 5);
assert.equal(custom.since.exact, false);
assert.equal(custom.since.window, 'LAST 400 DAYS');
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
});
+11 -13
View File
@@ -278,17 +278,23 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
gap: 4px 10px;
}
.sl-range {
.sl-select {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--sl-green);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
color: var(--text-primary);
padding: 4px 6px;
font-family: var(--font-mono);
font-size: var(--sl-text);
letter-spacing: 0.08em;
cursor: pointer;
}
.sl-range:focus-visible {
.sl-select:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 3px;
outline-offset: 2px;
}
.sl-since-note {
@@ -302,14 +308,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
display: none;
}
.sl-since-label {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text);
letter-spacing: 0.08em;
color: var(--text-primary);
}
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
.sl-viewer-wrap {
position: relative;
+8 -9
View File
@@ -87,9 +87,9 @@
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
<div class="sl-seg" role="group" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" aria-pressed="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" aria-pressed="false" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
</div>
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
@@ -118,16 +118,15 @@
<fieldset id="sl-controls" class="sl-controls">
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
<div class="sl-seg" role="group" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" aria-pressed="true" data-i18n>ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" aria-pressed="false">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" aria-pressed="false" data-i18n>FLAT</button>
</div>
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<select id="sl-since" class="sl-select" aria-describedby="sl-since-note"></select>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>