mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 19:34:01 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e494d04dc6 | ||
|
|
17395749d4 | ||
|
|
5657668265 | ||
|
|
78fc5262f4 | ||
|
|
fce328b7cd | ||
|
|
44d9455738 | ||
|
|
3fc731c98b | ||
|
|
9b76ecfe03 | ||
|
|
bdf3848dbc |
No files matched your search
@@ -425,7 +425,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.
|
**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`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Street Level panel: SINCE is a dropdown of its nine windows (the tooltip
|
||||||
|
names the cut-off date), IMAGERY and FIT / FILL are plain toggle buttons,
|
||||||
|
and the panel opens by itself only when a photo opens or a click fails to
|
||||||
|
load one, no longer when the layer is switched on. FIT / FILL starts at
|
||||||
|
FIT each session (gekh, #1241).
|
||||||
+10
-3
@@ -3536,7 +3536,13 @@ Mapillary token, Street View on Google 3D with a Google key), the layer
|
|||||||
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
|
||||||
line and the layer row's tooltip say to light it. The
|
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
|
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, or a click fails to open one or to load its sequence (the
|
||||||
|
result, or its error, would otherwise be out of sight), never when the layer
|
||||||
|
is switched on.
|
||||||
|
|
||||||
MapillaryJS loads on the first photo. Opening one frames it with a single
|
MapillaryJS loads on the first photo. Opening one frames it with a single
|
||||||
camera flight through the application's deferred navigation (`begin` /
|
camera flight through the application's deferred navigation (`begin` /
|
||||||
@@ -3545,7 +3551,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
|
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
|
iPhone, which has no element fullscreen, the viewer opens as a top-layer
|
||||||
popover instead (hidden where neither API exists). Clean View, recording and
|
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.
|
viewer height comes from the rail band so the whole photo fits.
|
||||||
|
|
||||||
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
||||||
@@ -3638,7 +3645,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
|
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
|
each photo's projection and upload date. The provider declares
|
||||||
`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by
|
`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
|
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
|
||||||
KartaView is lit (`createTileCache`, dropped when it is switched off); one
|
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
|
that does not answer in 20 s is asked once more, and one that is not a line
|
||||||
|
|||||||
@@ -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, /onEntered: \(\) => this\.enterPanels\(\)/);
|
||||||
assert.match(ui, /enterPanels: \(\) => this\._panelChrome\.enterCockpit\(\)/);
|
assert.match(
|
||||||
|
ui,
|
||||||
|
/enterPanels: \(\) => \{[^}]*this\._panelChrome\.enterCockpit\(\);\s*\}/,
|
||||||
|
);
|
||||||
const callback = { 1: shellMethod('enterCockpit').toString() };
|
const callback = { 1: shellMethod('enterCockpit').toString() };
|
||||||
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
assert.ok(callback, 'Cockpit onEntered callback is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -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"/);
|
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('each radiogroup is one tab stop, on its checked button', () => {
|
test('the segmented filters are toggle buttons, each its own tab stop', () => {
|
||||||
for (const [group, checked] of [
|
for (const [group, chosen] of [
|
||||||
['data-sl-render', 'letterbox'],
|
['data-sl-render', 'letterbox'],
|
||||||
['data-sl-pano', 'all'],
|
['data-sl-pano', 'all'],
|
||||||
]) {
|
]) {
|
||||||
const radios = [
|
const buttons = [
|
||||||
...html.matchAll(
|
...html.matchAll(
|
||||||
new RegExp(`<button [^>]*${group}="([a-z]+)"[^>]*>`, 'g'),
|
new RegExp(`<button [^>]*${group}="([a-z]+)"[^>]*>`, 'g'),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
assert.ok(radios.length >= 2, group);
|
assert.ok(buttons.length >= 2, group);
|
||||||
for (const [tag, value] of radios)
|
for (const [tag, value] of buttons) {
|
||||||
assert.equal(
|
assert.match(tag, new RegExp(`aria-pressed="${value === chosen}"`));
|
||||||
/tabindex="-1"/.test(tag),
|
assert.doesNotMatch(tag, /role="radio"|tabindex=/, `${group}="${value}"`);
|
||||||
value !== checked,
|
|
||||||
`${group}="${value}" roving tab stop`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(
|
||||||
|
html,
|
||||||
|
/role="radiogroup"[^>]*(Image fit|Panorama filter)/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the Cyber theme frames the Street Level panel like its rail peers (P3)', () => {
|
test('the Cyber theme frames the Street Level panel like its rail peers (P3)', () => {
|
||||||
|
|||||||
@@ -402,7 +402,11 @@ export class StyleManager extends ShellFacade {
|
|||||||
readDataManager: () => this._dataManager,
|
readDataManager: () => this._dataManager,
|
||||||
readContext: () => this.getContextModeState(),
|
readContext: () => this.getContextModeState(),
|
||||||
readActiveStyle: () => this.activeStyle,
|
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(),
|
exitPanels: () => this._panelChrome.exitCockpit(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1108,12 +1112,6 @@ export class StyleManager extends ShellFacade {
|
|||||||
setPanelCollapsed: (collapsed, options) =>
|
setPanelCollapsed: (collapsed, options) =>
|
||||||
this.setPanelCollapsed('street-level-panel', collapsed, options),
|
this.setPanelCollapsed('street-level-panel', collapsed, options),
|
||||||
showToast: (message) => this._showToast(message),
|
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();
|
this._streetLevelControls.connect();
|
||||||
@@ -1236,6 +1234,8 @@ export class StyleManager extends ShellFacade {
|
|||||||
? forceEnabled
|
? forceEnabled
|
||||||
: !document.body.classList.contains('ui-clean-view');
|
: !document.body.classList.contains('ui-clean-view');
|
||||||
document.body.classList.toggle('ui-clean-view', shouldEnable);
|
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) {
|
if (this._cleanViewBtn) {
|
||||||
this._cleanViewBtn.classList.toggle('active', shouldEnable);
|
this._cleanViewBtn.classList.toggle('active', shouldEnable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
|||||||
* the layout-reset notice and the documented key.
|
* the layout-reset notice and the documented key.
|
||||||
*/
|
*/
|
||||||
const PANEL_POSITION_STORAGE_VERSION = 'v8';
|
const PANEL_POSITION_STORAGE_VERSION = 'v8';
|
||||||
/** Floating panels' stored positions and hint flag: nothing reads them now. */
|
/**
|
||||||
|
* Floating panels' stored positions and hint flag, and Street Level's saved
|
||||||
|
* FIT / FILL (each session now starts at FIT): nothing reads them now.
|
||||||
|
*/
|
||||||
const RETIRED_PANEL_KEY_PREFIXES = [
|
const RETIRED_PANEL_KEY_PREFIXES = [
|
||||||
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`,
|
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`,
|
||||||
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
|
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
|
||||||
|
'gev:street-level:render-mode',
|
||||||
];
|
];
|
||||||
export class PanelPositionControls {
|
export class PanelPositionControls {
|
||||||
constructor({ syncPanelCollapseButton, showToast }) {
|
constructor({ syncPanelCollapseButton, showToast }) {
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ function fakeStorage(entries) {
|
|||||||
return storage;
|
return storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('the floating panels’ stored positions and hint flag are dropped; collapse state stays', (t) => {
|
test('retired panel and FIT / FILL keys are dropped; collapse state stays', (t) => {
|
||||||
const saved = globalThis.localStorage;
|
const saved = globalThis.localStorage;
|
||||||
globalThis.localStorage = fakeStorage({
|
globalThis.localStorage = fakeStorage({
|
||||||
'godsEyeView.v8.panelPos.cctv-panel': '{"x":1}',
|
'godsEyeView.v8.panelPos.cctv-panel': '{"x":1}',
|
||||||
'godsEyeView.v8.panelFloatHintShown': '1',
|
'godsEyeView.v8.panelFloatHintShown': '1',
|
||||||
|
'gev:street-level:render-mode': 'fill',
|
||||||
'godsEyeView.v6.panelCollapsed.cctv-panel': '1',
|
'godsEyeView.v6.panelCollapsed.cctv-panel': '1',
|
||||||
'godsEyeView.v8.layoutResetNotified': '1',
|
'godsEyeView.v8.layoutResetNotified': '1',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1182,6 +1182,9 @@ export class ShellFacade {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
setRecordingMode(enabled, options) {
|
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);
|
return this._recording.setRecordingMode(enabled, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+58
-124
@@ -1,4 +1,3 @@
|
|||||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
|
||||||
import { syncChipGroup } from './chipGroup.js';
|
import { syncChipGroup } from './chipGroup.js';
|
||||||
import {
|
import {
|
||||||
presentStreetLevelPanel,
|
presentStreetLevelPanel,
|
||||||
@@ -6,16 +5,6 @@ import {
|
|||||||
} from './streetLevelPresentation.js';
|
} from './streetLevelPresentation.js';
|
||||||
import { t, translateKnown } from '../i18n/index.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
|
// Renders run on every layer notification, and the rail's MutationObserver
|
||||||
// relays out on each attribute write: write only what changed.
|
// relays out on each attribute write: write only what changed.
|
||||||
const setProp = (node, key, value) => {
|
const setProp = (node, key, value) => {
|
||||||
@@ -39,11 +28,11 @@ export class StreetLevelControls {
|
|||||||
this._unsubscribe = null;
|
this._unsubscribe = null;
|
||||||
this._state = null;
|
this._state = null;
|
||||||
this._view = null;
|
this._view = null;
|
||||||
this._wasEnabled = null;
|
|
||||||
this._wasOpen = false;
|
this._wasOpen = false;
|
||||||
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
|
/** The error last shown: the same one never reopens the panel. */
|
||||||
this._explicitEnable = false;
|
this._streetError = null;
|
||||||
this._unsubscribeEnableRequests = null;
|
/** The SINCE option for a day count between the stops, while one is set. */
|
||||||
|
this._customSince = null;
|
||||||
this._resizeQueued = false;
|
this._resizeQueued = false;
|
||||||
this._elements = this._collect();
|
this._elements = this._collect();
|
||||||
this._bind();
|
this._bind();
|
||||||
@@ -63,8 +52,7 @@ export class StreetLevelControls {
|
|||||||
errorText: byId('sl-error-text'),
|
errorText: byId('sl-error-text'),
|
||||||
panoRow: byId('sl-pano-row'),
|
panoRow: byId('sl-pano-row'),
|
||||||
sinceRow: byId('sl-since-row'),
|
sinceRow: byId('sl-since-row'),
|
||||||
sinceRange: byId('sl-since'),
|
since: byId('sl-since'),
|
||||||
sinceLabel: byId('sl-since-label'),
|
|
||||||
sinceNote: byId('sl-since-note'),
|
sinceNote: byId('sl-since-note'),
|
||||||
viewerWrap: byId('sl-viewer-wrap'),
|
viewerWrap: byId('sl-viewer-wrap'),
|
||||||
viewerExpand: byId('sl-viewer-expand'),
|
viewerExpand: byId('sl-viewer-expand'),
|
||||||
@@ -111,18 +99,15 @@ export class StreetLevelControls {
|
|||||||
this._setParams({ pano: button.dataset.slPano }),
|
this._setParams({ pano: button.dataset.slPano }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this._bindRadioGroup(el.panoButtons);
|
// The stops, in the UI language (a language change reloads the page).
|
||||||
this._bindRadioGroup(el.renderButtons);
|
for (const stop of SINCE_STOPS) {
|
||||||
// The readout follows the thumb; coverage is rebuilt once, on release.
|
const option = document.createElement('option');
|
||||||
const sinceDays = () =>
|
option.value = String(stop.days);
|
||||||
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
|
option.textContent = t(stop.label);
|
||||||
this.listen(el.sinceRange, 'input', () => {
|
el.since?.appendChild(option);
|
||||||
const label = t(SINCE_STOPS[Number(el.sinceRange.value) || 0].label);
|
}
|
||||||
if (el.sinceLabel) el.sinceLabel.textContent = label;
|
this.listen(el.since, 'change', () =>
|
||||||
el.sinceRange.setAttribute('aria-valuetext', label);
|
this._setParams({ sinceDays: Number(el.since.value) || 0 }),
|
||||||
});
|
|
||||||
this.listen(el.sinceRange, 'change', () =>
|
|
||||||
this._setParams({ sinceDays: sinceDays() }),
|
|
||||||
);
|
);
|
||||||
this.listen(el.nearestBtn, 'click', () => {
|
this.listen(el.nearestBtn, 'click', () => {
|
||||||
const nearest = this._view?.nearest;
|
const nearest = this._view?.nearest;
|
||||||
@@ -160,24 +145,10 @@ export class StreetLevelControls {
|
|||||||
this._exitExpanded();
|
this._exitExpanded();
|
||||||
});
|
});
|
||||||
} else setProp(el.viewerExpand, 'hidden', true);
|
} else setProp(el.viewerExpand, 'hidden', true);
|
||||||
for (const button of el.renderButtons) {
|
for (const button of el.renderButtons)
|
||||||
this.listen(button, 'click', () => {
|
this.listen(button, 'click', () =>
|
||||||
const mode = button.dataset.slRender;
|
this.layer.setViewerRenderMode?.(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 */
|
|
||||||
}
|
|
||||||
// MapillaryJS only tracks window resizes; the panel resizes on its own.
|
// MapillaryJS only tracks window resizes; the panel resizes on its own.
|
||||||
if (typeof ResizeObserver === 'function' && el.viewer) {
|
if (typeof ResizeObserver === 'function' && el.viewer) {
|
||||||
this._resizeObserver = new ResizeObserver(() => this._requestResize());
|
this._resizeObserver = new ResizeObserver(() => this._requestResize());
|
||||||
@@ -185,24 +156,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
|
* 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.
|
* ask for tiles at z=NaN; the observer fires again once it has a size.
|
||||||
@@ -315,32 +268,18 @@ export class StreetLevelControls {
|
|||||||
this._syncExpanded();
|
this._syncExpanded();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clean View, recording and the cockpit hide every panel, so they end EXPAND too. */
|
/**
|
||||||
_watchPanelHidingModes(on) {
|
* Shrink an expanded photo. Clean View, recording and the cockpit hide
|
||||||
const body = document.body;
|
* every panel and call it, so the photo does not stay full screen (or, on
|
||||||
if (!on || !body) {
|
* iPhone, as a popover) over them.
|
||||||
this._modesObserver?.disconnect();
|
*/
|
||||||
this._modesObserver = null;
|
shrinkViewer() {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const leaveIfHiding = () => {
|
|
||||||
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
|
|
||||||
this._exitExpanded();
|
this._exitExpanded();
|
||||||
};
|
|
||||||
if (!this._modesObserver && typeof MutationObserver === 'function') {
|
|
||||||
this._modesObserver = new MutationObserver(leaveIfHiding);
|
|
||||||
this._modesObserver.observe(body, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
leaveIfHiding();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirror the expanded state on EXPAND, watch the panel-hiding modes, refit. */
|
/** Mirror the expanded state on EXPAND and refit. */
|
||||||
_syncExpanded() {
|
_syncExpanded() {
|
||||||
const on = this.isViewerExpanded();
|
const on = this.isViewerExpanded();
|
||||||
this._watchPanelHidingModes(on);
|
|
||||||
const button = this._elements.viewerExpand;
|
const button = this._elements.viewerExpand;
|
||||||
if (button) {
|
if (button) {
|
||||||
setProp(
|
setProp(
|
||||||
@@ -363,12 +302,6 @@ export class StreetLevelControls {
|
|||||||
this._unsubscribe?.();
|
this._unsubscribe?.();
|
||||||
this._unsubscribe = null;
|
this._unsubscribe = null;
|
||||||
if (this.destroyed || !this.root) return;
|
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));
|
this._unsubscribe = this.layer.subscribe?.((state) => this.render(state));
|
||||||
if (this.layer.getUIState) this.render(this.layer.getUIState());
|
if (this.layer.getUIState) this.render(this.layer.getUIState());
|
||||||
}
|
}
|
||||||
@@ -416,12 +349,12 @@ export class StreetLevelControls {
|
|||||||
setProp(el.errorText, 'textContent', view.error || '');
|
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) {
|
for (const button of buttons) {
|
||||||
const checked = isChecked(button);
|
const chosen = isChosen(button);
|
||||||
button.classList.toggle('is-active', checked);
|
button.classList.toggle('is-active', chosen);
|
||||||
setAttr(button, 'aria-checked', String(checked));
|
setAttr(button, 'aria-pressed', String(chosen));
|
||||||
setAttr(button, 'tabindex', checked ? '0' : '-1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,23 +367,25 @@ export class StreetLevelControls {
|
|||||||
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
||||||
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
||||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||||
this._renderRadios(
|
this._renderPressed(
|
||||||
el.panoButtons,
|
el.panoButtons,
|
||||||
(button) => button.dataset.slPano === view.filter.pano,
|
(button) => button.dataset.slPano === view.filter.pano,
|
||||||
);
|
);
|
||||||
if (el.sinceRange) {
|
if (el.since) {
|
||||||
// Never move the thumb under the user's hand; once the committed value
|
// A link may carry a day count between the stops: it gets an option.
|
||||||
// matches the thumb, show the full readout with its cut-off date.
|
if (!view.since.exact) {
|
||||||
const index = String(view.since.index);
|
this._customSince ||= el.since.appendChild(
|
||||||
if (
|
document.createElement('option'),
|
||||||
document.activeElement !== el.sinceRange &&
|
);
|
||||||
el.sinceRange.value !== index
|
setProp(this._customSince, 'value', String(view.since.days));
|
||||||
)
|
setProp(this._customSince, 'textContent', view.since.window);
|
||||||
el.sinceRange.value = index;
|
} else if (this._customSince) {
|
||||||
if (el.sinceRange.value === index) {
|
this._customSince.remove();
|
||||||
setProp(el.sinceLabel, 'textContent', view.since.label);
|
this._customSince = null;
|
||||||
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
|
|
||||||
}
|
}
|
||||||
|
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 +402,7 @@ export class StreetLevelControls {
|
|||||||
setProp(wrap, 'hidden', !viewer.open);
|
setProp(wrap, 'hidden', !viewer.open);
|
||||||
}
|
}
|
||||||
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
|
||||||
this._renderRadios(
|
this._renderPressed(
|
||||||
el.renderButtons,
|
el.renderButtons,
|
||||||
(button) => button.dataset.slRender === viewer.renderMode,
|
(button) => button.dataset.slRender === viewer.renderMode,
|
||||||
);
|
);
|
||||||
@@ -497,22 +432,24 @@ export class StreetLevelControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the panel on a switch-on or when an image opens. Never persisted:
|
* Open the panel when an image opens, or when a click fails to open one or
|
||||||
* the stored collapse state stays the user's own choice.
|
* to load its sequence (the result, or its error, would otherwise be out of
|
||||||
|
* sight). Never persisted: the stored collapse state stays the user's own
|
||||||
|
* choice.
|
||||||
*/
|
*/
|
||||||
_reactToTransitions(view) {
|
_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
|
// Other size changes reach the viewer through the ResizeObserver; opening
|
||||||
// asks for one resize because the element may not have a size yet.
|
// asks for one resize because the element may not have a size yet.
|
||||||
const open = view.viewer.open;
|
const open = view.viewer.open;
|
||||||
if (open && !this._wasOpen) this.setCollapsed(false, { persist: false });
|
// A photo that failed to open (a line or ground click, OPEN), or a
|
||||||
|
// Mapillary line whose sequence failed to load, says why in the panel:
|
||||||
|
// it opens for that too, or the click would seem to do nothing.
|
||||||
|
const error =
|
||||||
|
this._state?.street?.error || this._state?.sequence?.error || null;
|
||||||
|
if ((open && !this._wasOpen) || (error && error !== this._streetError))
|
||||||
|
this.setCollapsed(false, { persist: false });
|
||||||
this._wasOpen = open;
|
this._wasOpen = open;
|
||||||
|
this._streetError = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
@@ -523,14 +460,11 @@ export class StreetLevelControls {
|
|||||||
// would hide the viewer even inside the panel.
|
// would hide the viewer even inside the panel.
|
||||||
if (this._expandMode === 'popover')
|
if (this._expandMode === 'popover')
|
||||||
this._elements.viewerWrap?.removeAttribute('popover');
|
this._elements.viewerWrap?.removeAttribute('popover');
|
||||||
this._watchPanelHidingModes(false);
|
|
||||||
this.listeners.abort();
|
this.listeners.abort();
|
||||||
this._resizeObserver?.disconnect();
|
this._resizeObserver?.disconnect();
|
||||||
this._resizeObserver = null;
|
this._resizeObserver = null;
|
||||||
this._unsubscribe?.();
|
this._unsubscribe?.();
|
||||||
this._unsubscribe = null;
|
this._unsubscribe = null;
|
||||||
this._unsubscribeEnableRequests?.();
|
|
||||||
this._unsubscribeEnableRequests = null;
|
|
||||||
this.layer.attachViewerHost?.(null);
|
this.layer.attachViewerHost?.(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,8 +255,7 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
|
|||||||
for (const pano of ['all', 'pano', 'flat'])
|
for (const pano of ['all', 'pano', 'flat'])
|
||||||
add(panoRow, 'button', { dataset: { slPano: pano } });
|
add(panoRow, 'button', { dataset: { slPano: pano } });
|
||||||
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
|
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
|
||||||
add(sinceRow, 'input', { id: 'sl-since' });
|
add(sinceRow, 'select', { id: 'sl-since' });
|
||||||
add(sinceRow, 'output', { id: 'sl-since-label' });
|
|
||||||
add(sinceRow, 'small', { id: 'sl-since-note' });
|
add(sinceRow, 'small', { id: 'sl-since-note' });
|
||||||
const metaRow = add(root, 'div', { classes: ['sl-meta-row'] });
|
const metaRow = add(root, 'div', { classes: ['sl-meta-row'] });
|
||||||
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
||||||
@@ -344,7 +343,6 @@ async function productionPanel(dom) {
|
|||||||
manager.setLayerParams('street-level', params, options),
|
manager.setLayerParams('street-level', params, options),
|
||||||
setPanelCollapsed() {},
|
setPanelCollapsed() {},
|
||||||
showToast() {},
|
showToast() {},
|
||||||
subscribeEnableRequests: () => () => {},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
controls.connect();
|
controls.connect();
|
||||||
@@ -380,11 +378,11 @@ test('a 360° click reaches the share link through the data manager', () =>
|
|||||||
coordinator.destroy();
|
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) => {
|
withDom(async (dom) => {
|
||||||
const { coordinator, controls, share } = await productionPanel(dom);
|
const { coordinator, controls, share } = await productionPanel(dom);
|
||||||
const since = dom.root.querySelector('#sl-since');
|
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'));
|
since.dispatchEvent(new Event('change'));
|
||||||
await settle();
|
await settle();
|
||||||
const days =
|
const days =
|
||||||
@@ -424,23 +422,31 @@ function uiState({
|
|||||||
on = true,
|
on = true,
|
||||||
open = false,
|
open = false,
|
||||||
pano = 'all',
|
pano = 'all',
|
||||||
|
sinceDays = 0,
|
||||||
renderMode = 'letterbox',
|
renderMode = 'letterbox',
|
||||||
keyRequired = false,
|
keyRequired = false,
|
||||||
|
streetError = null,
|
||||||
|
sequenceError = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
providers: [providerSnapshot({ on, keyRequired })],
|
providers: [providerSnapshot({ on, keyRequired })],
|
||||||
keyRequired,
|
keyRequired,
|
||||||
keyRejected: false,
|
keyRejected: false,
|
||||||
filter: { pano, sinceDays: 0 },
|
filter: { pano, sinceDays },
|
||||||
coverage: { count: 3, loading: false, hint: '', error: null },
|
coverage: { count: 3, loading: false, hint: '', error: null },
|
||||||
sequence: { selectedId: null, images: 0, loading: false },
|
sequence: {
|
||||||
|
selectedId: null,
|
||||||
|
images: 0,
|
||||||
|
loading: false,
|
||||||
|
error: sequenceError,
|
||||||
|
},
|
||||||
street: {
|
street: {
|
||||||
open,
|
open,
|
||||||
loading: false,
|
loading: false,
|
||||||
renderMode,
|
renderMode,
|
||||||
imageId: open ? 'img-1' : null,
|
imageId: open ? 'img-1' : null,
|
||||||
error: null,
|
error: streetError,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -449,8 +455,6 @@ function stubPanel(dom, state, extraActions = {}) {
|
|||||||
const layer = stubLayer();
|
const layer = stubLayer();
|
||||||
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
|
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
|
||||||
let enabled = state.enabled;
|
let enabled = state.enabled;
|
||||||
/** Reports a switch-on request's origin, as the data manager does. */
|
|
||||||
let announce = null;
|
|
||||||
layer.publish(state);
|
layer.publish(state);
|
||||||
const controls = new StreetLevelControls({
|
const controls = new StreetLevelControls({
|
||||||
root: dom.root,
|
root: dom.root,
|
||||||
@@ -465,12 +469,6 @@ function stubPanel(dom, state, extraActions = {}) {
|
|||||||
setPanelCollapsed: (collapsed, options) =>
|
setPanelCollapsed: (collapsed, options) =>
|
||||||
calls.collapsed.push([collapsed, options]),
|
calls.collapsed.push([collapsed, options]),
|
||||||
showToast: (message) => calls.toasts.push(message),
|
showToast: (message) => calls.toasts.push(message),
|
||||||
subscribeEnableRequests: (listener) => {
|
|
||||||
announce = listener;
|
|
||||||
return () => {
|
|
||||||
announce = null;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
...extraActions,
|
...extraActions,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -479,8 +477,6 @@ function stubPanel(dom, state, extraActions = {}) {
|
|||||||
layer,
|
layer,
|
||||||
calls,
|
calls,
|
||||||
controls,
|
controls,
|
||||||
announce: (origin) => announce?.(origin),
|
|
||||||
subscribed: () => announce !== null,
|
|
||||||
chip: () => dom.root.querySelector('#sl-provider-chips').children[0],
|
chip: () => dom.root.querySelector('#sl-provider-chips').children[0],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -745,50 +741,84 @@ function fakeMutationObserver() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
|
test('shrinkViewer takes the photo out of full screen (Clean View, recording, the cockpit)', () =>
|
||||||
test(`entering ${mode} takes the photo out of full screen`, () =>
|
|
||||||
withDom(async (dom) => {
|
withDom(async (dom) => {
|
||||||
const saved = globalThis.MutationObserver;
|
|
||||||
const watch = fakeMutationObserver();
|
|
||||||
globalThis.MutationObserver = watch.FakeObserver;
|
|
||||||
try {
|
|
||||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||||
dom.root.querySelector('#sl-viewer-expand').click();
|
dom.root.querySelector('#sl-viewer-expand').click();
|
||||||
await settle();
|
await settle();
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(dom.document.fullscreenElement, dom.wrap);
|
assert.equal(dom.document.fullscreenElement, dom.wrap);
|
||||||
assert.equal(watch.observers.size, 1, 'watching while expanded');
|
controls.shrinkViewer();
|
||||||
dom.document.body.classList.toggle(mode, true);
|
|
||||||
watch.fire();
|
|
||||||
await settle();
|
await settle();
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(dom.document.fullscreenElement, null);
|
assert.equal(dom.document.fullscreenElement, null);
|
||||||
assert.equal(watch.observers.size, 0, 'and not after');
|
controls.shrinkViewer(); // nothing expanded: nothing to do
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
} finally {
|
|
||||||
globalThis.MutationObserver = saved;
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────── */
|
/* ── 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) => {
|
withDom(async (dom) => {
|
||||||
const { layer, calls, controls, announce, subscribed } = stubPanel(
|
const { layer, calls, controls } = stubPanel(
|
||||||
dom,
|
dom,
|
||||||
uiState({ enabled: false }),
|
uiState({ enabled: false }),
|
||||||
);
|
);
|
||||||
announce('restore');
|
|
||||||
layer.publish(uiState({ enabled: true }));
|
layer.publish(uiState({ enabled: true }));
|
||||||
assert.deepEqual(calls.collapsed, [], 'a restore leaves the panel be');
|
assert.deepEqual(calls.collapsed, [], 'a switch-on leaves the panel be');
|
||||||
layer.publish(uiState({ enabled: false }));
|
|
||||||
announce('user');
|
|
||||||
layer.publish(uiState({ enabled: true }));
|
|
||||||
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
|
|
||||||
layer.publish(uiState({ enabled: true, open: true }));
|
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();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('a photo that fails to open opens the panel too, so its error shows', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { layer, calls, controls } = stubPanel(dom, uiState());
|
||||||
|
// A line click whose lookup KartaView never answered.
|
||||||
|
layer.publish(uiState({ streetError: 'KartaView did not answer in time' }));
|
||||||
|
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
|
||||||
|
// The same error again opens nothing more; a new one does.
|
||||||
|
layer.publish(uiState({ streetError: 'KartaView did not answer in time' }));
|
||||||
|
assert.equal(calls.collapsed.length, 1);
|
||||||
|
layer.publish(uiState());
|
||||||
|
layer.publish(uiState({ streetError: 'No street-level imagery here' }));
|
||||||
|
assert.equal(calls.collapsed.length, 2);
|
||||||
|
controls.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('a sequence that fails to load opens the panel, so its error shows', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { layer, calls, controls } = stubPanel(dom, uiState());
|
||||||
|
// A Mapillary line click whose sequence lookup the Graph API refused.
|
||||||
|
layer.publish(uiState({ sequenceError: 'Mapillary did not answer' }));
|
||||||
|
assert.deepEqual(calls.collapsed, [[false, { persist: false }]]);
|
||||||
|
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
|
||||||
|
layer.publish(uiState({ sequenceError: 'Mapillary did not answer' }));
|
||||||
|
assert.equal(calls.collapsed.length, 1, 'the same error opens it once');
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
assert.equal(subscribed(), false, 'destroy unsubscribes');
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
test('FIT / FILL show the selected render mode (P3)', () =>
|
test('FIT / FILL show the selected render mode (P3)', () =>
|
||||||
@@ -796,8 +826,8 @@ test('FIT / FILL show the selected render mode (P3)', () =>
|
|||||||
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||||
layer.publish(uiState({ open: true, renderMode: 'fill' }));
|
layer.publish(uiState({ open: true, renderMode: 'fill' }));
|
||||||
const [fit, fill] = dom.wrap.querySelectorAll('[data-sl-render]');
|
const [fit, fill] = dom.wrap.querySelectorAll('[data-sl-render]');
|
||||||
assert.equal(fill.getAttribute('aria-checked'), 'true');
|
assert.equal(fill.getAttribute('aria-pressed'), 'true');
|
||||||
assert.equal(fit.getAttribute('aria-checked'), 'false');
|
assert.equal(fit.getAttribute('aria-pressed'), 'false');
|
||||||
assert.equal(fill.classList.contains('is-active'), true);
|
assert.equal(fill.classList.contains('is-active'), true);
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
}));
|
}));
|
||||||
@@ -842,35 +872,46 @@ test('identical renders write nothing to the DOM (P3: rail MutationObserver)', (
|
|||||||
controls.destroy();
|
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) => {
|
withDom(async (dom) => {
|
||||||
const { layer, calls, controls } = stubPanel(dom, uiState());
|
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]');
|
const [all, pano, flat] = dom.root.querySelectorAll('[data-sl-pano]');
|
||||||
assert.deepEqual(
|
const pressed = () =>
|
||||||
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
|
[all, pano, flat].map((button) => button.getAttribute('aria-pressed'));
|
||||||
['0', '-1', '-1'],
|
assert.deepEqual(pressed(), ['true', 'false', 'false']);
|
||||||
);
|
pano.click();
|
||||||
all.focus();
|
|
||||||
keydown(all, 'ArrowRight');
|
|
||||||
assert.equal(dom.document.activeElement, pano);
|
|
||||||
assert.deepEqual(calls.setParams.at(-1), [
|
assert.deepEqual(calls.setParams.at(-1), [
|
||||||
{ pano: 'pano' },
|
{ pano: 'pano' },
|
||||||
{ origin: 'user' },
|
{ 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' }));
|
layer.publish(uiState({ pano: 'flat' }));
|
||||||
|
assert.deepEqual(pressed(), ['false', 'false', 'true']);
|
||||||
|
// No roving tab stop: every button is reachable with Tab.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
|
[all, pano, flat].map((button) => button.getAttribute('tabindex')),
|
||||||
['-1', '-1', '0'],
|
[null, null, null],
|
||||||
);
|
);
|
||||||
controls.destroy();
|
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)', () =>
|
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling (P3)', () =>
|
||||||
withDom(async (dom) => {
|
withDom(async (dom) => {
|
||||||
const { calls, chip, controls } = stubPanel(
|
const { calls, chip, controls } = stubPanel(
|
||||||
@@ -992,7 +1033,10 @@ test('a note under SINCE says what date it compares, only while one is given', (
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
|
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', () =>
|
test('Street View lit hides the imagery filters; Mapillary lit shows them', () =>
|
||||||
|
|||||||
@@ -35,7 +35,14 @@ export function sinceStopIndex(days) {
|
|||||||
function presentSince(days, now) {
|
function presentSince(days, now) {
|
||||||
const value = Number(days) || 0;
|
const value = Number(days) || 0;
|
||||||
const index = sinceStopIndex(value);
|
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 stop = SINCE_STOPS[index];
|
||||||
const window =
|
const window =
|
||||||
stop.days === value
|
stop.days === value
|
||||||
@@ -44,6 +51,9 @@ function presentSince(days, now) {
|
|||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
days: value,
|
days: value,
|
||||||
|
/** One of SINCE_STOPS; a link may carry any other day count. */
|
||||||
|
exact: stop.days === value,
|
||||||
|
window,
|
||||||
label: t('{window} · SINCE {date}', {
|
label: t('{window} · SINCE {date}', {
|
||||||
window,
|
window,
|
||||||
date: formatDate(now - value * DAY_MS),
|
date: formatDate(now - value * DAY_MS),
|
||||||
|
|||||||
@@ -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', () => {
|
test('the SINCE readout names the window and the cut-off date it means today', () => {
|
||||||
const now = Date.UTC(2026, 8, 25);
|
const now = Date.UTC(2026, 8, 25);
|
||||||
const any = presentStreetLevelPanel(snapshot(), { now });
|
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(
|
const year = presentStreetLevelPanel(
|
||||||
snapshot({ filter: { pano: 'pano', sinceDays: 365 } }),
|
snapshot({ filter: { pano: 'pano', sinceDays: 365 } }),
|
||||||
{ now },
|
{ now },
|
||||||
@@ -191,6 +197,8 @@ test('the SINCE readout names the window and the cut-off date it means today', (
|
|||||||
assert.deepEqual(year.since, {
|
assert.deepEqual(year.since, {
|
||||||
index: 5,
|
index: 5,
|
||||||
days: 365,
|
days: 365,
|
||||||
|
exact: true,
|
||||||
|
window: 'LAST YEAR',
|
||||||
label: 'LAST YEAR · SINCE 2025-09-25',
|
label: 'LAST YEAR · SINCE 2025-09-25',
|
||||||
});
|
});
|
||||||
const custom = presentStreetLevelPanel(
|
const custom = presentStreetLevelPanel(
|
||||||
@@ -198,6 +206,8 @@ test('the SINCE readout names the window and the cut-off date it means today', (
|
|||||||
{ now },
|
{ now },
|
||||||
);
|
);
|
||||||
assert.equal(custom.since.index, 5);
|
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');
|
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -278,17 +278,23 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
|||||||
gap: 4px 10px;
|
gap: 4px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sl-range {
|
.sl-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
accent-color: var(--sl-green);
|
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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sl-range:focus-visible {
|
.sl-select:focus-visible {
|
||||||
outline: 1px solid var(--accent);
|
outline: 1px solid var(--accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sl-since-note {
|
.sl-since-note {
|
||||||
@@ -302,14 +308,6 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
|
|||||||
display: none;
|
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 ─────────────────────────────────────────────────────── */
|
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
|
||||||
.sl-viewer-wrap {
|
.sl-viewer-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -87,9 +87,9 @@
|
|||||||
<div class="sl-viewer-bar">
|
<div class="sl-viewer-bar">
|
||||||
<div class="sl-viewer-tools">
|
<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>
|
<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>
|
<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" 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 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" 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>
|
<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>
|
</div>
|
||||||
<div class="sl-viewer-tools sl-viewer-tools-right">
|
<div class="sl-viewer-tools sl-viewer-tools-right">
|
||||||
@@ -118,16 +118,15 @@
|
|||||||
<fieldset id="sl-controls" class="sl-controls">
|
<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>
|
<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>
|
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
|
||||||
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
|
<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" role="radio" aria-checked="true" data-i18n>ALL</button>
|
<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" role="radio" aria-checked="false" tabindex="-1">360°</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" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
|
<button type="button" class="sl-seg-btn" data-sl-pano="flat" aria-pressed="false" data-i18n>FLAT</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sl-since-row" class="sl-since">
|
<div id="sl-since-row" class="sl-since">
|
||||||
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
|
<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" />
|
<select id="sl-since" class="sl-select" aria-describedby="sl-since-note"></select>
|
||||||
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
|
|
||||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
Reference in new issue
Block a user