mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into fix/right-rail-css-hidden-panel-20261003
This commit is contained in:
commit
fba4517ce1
93 files changed
+14946
-1408
No files matched your search
@@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => {
|
||||
);
|
||||
assert.equal(layerKeyRequirementTooltip(ungated), '');
|
||||
});
|
||||
|
||||
test('a key-gated layer with another source ready says how to use it', () => {
|
||||
const text = layerKeyRequirementTooltip({
|
||||
requiresKeyId: 'mapillary',
|
||||
stats: {
|
||||
keyRequired: true,
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
text,
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { syncRowList } from './rowList.js';
|
||||
import { layerFeedState } from '../data/feedState.js';
|
||||
export { layerFeedState } from '../data/feedState.js';
|
||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
import { createWeatherPanel } from './weatherPanel.js';
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
nominal: 'ON',
|
||||
@@ -100,7 +103,11 @@ function panelLabel(layer) {
|
||||
export function layerKeyRequirementTooltip(layer = {}) {
|
||||
if (layer?.stats?.keyRequired !== true) return '';
|
||||
const requiresKeyId = String(layer.requiresKeyId || '').trim();
|
||||
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
|
||||
// A layer with several sources also names the ones it could use instead.
|
||||
return keySetupWithAlternatives(
|
||||
requiresKeyId ? keySetupRequirement(requiresKeyId) : '',
|
||||
layer.stats.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||
|
||||
@@ -66,10 +66,14 @@ export class StreetLevelControls {
|
||||
status: byId('sl-status'),
|
||||
controls: byId('sl-controls'),
|
||||
providerChips: byId('sl-provider-chips'),
|
||||
nearestBtn: byId('sl-nearest-btn'),
|
||||
error: byId('sl-error'),
|
||||
errorText: byId('sl-error-text'),
|
||||
panoRow: byId('sl-pano-row'),
|
||||
sinceRow: byId('sl-since-row'),
|
||||
sinceRange: byId('sl-since'),
|
||||
sinceLabel: byId('sl-since-label'),
|
||||
sinceNote: byId('sl-since-note'),
|
||||
legend: byId('sl-legend'),
|
||||
followBtn: byId('sl-follow-btn'),
|
||||
viewerWrap: byId('sl-viewer-wrap'),
|
||||
@@ -103,9 +107,11 @@ export class StreetLevelControls {
|
||||
this.listen(el.providerChips, 'click', (event) => {
|
||||
const button = event.target?.closest?.('.data-toggle-chip');
|
||||
if (!button || button.disabled) return;
|
||||
// Without a key the chips stay focusable for their tooltip; a click
|
||||
// says the same thing rather than switching on a layer that cannot draw.
|
||||
if (this._view?.controlsDisabled) {
|
||||
// A keyless chip shows its tooltip instead of switching; others switch.
|
||||
const chip = this._view?.providers.find(
|
||||
(entry) => entry.id === button.dataset.chipId,
|
||||
);
|
||||
if (chip?.state === 'error') {
|
||||
if (button.title) this.actions.showToast?.(button.title);
|
||||
return;
|
||||
}
|
||||
@@ -129,6 +135,15 @@ export class StreetLevelControls {
|
||||
this.listen(el.sinceRange, 'change', () =>
|
||||
this._setParams({ sinceDays: sinceDays() }),
|
||||
);
|
||||
this.listen(el.nearestBtn, 'click', () => {
|
||||
const nearest = this._view?.nearest;
|
||||
if (!nearest?.visible || nearest.disabled) return;
|
||||
this.layer.openNearest?.(undefined, {
|
||||
providerIds: nearest.providerIds,
|
||||
frame: false,
|
||||
aim: 'screen-centre',
|
||||
});
|
||||
});
|
||||
this.listen(el.followBtn, 'click', () => {
|
||||
this.layer.setFollow?.(!(this._state?.street?.follow === true));
|
||||
});
|
||||
@@ -238,24 +253,23 @@ export class StreetLevelControls {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighting a chip turns the layer on; darkening the last lit one turns the
|
||||
* layer off but keeps the provider on, so the layer comes back with it.
|
||||
* Lighting a chip turns the layer on and darkens the others (one source at
|
||||
* a time); darkening the lit one turns the layer off, keeping the provider.
|
||||
*/
|
||||
async _toggleProvider(providerId) {
|
||||
const chip = this._view?.providers.find((entry) => entry.id === providerId);
|
||||
if (!chip) return;
|
||||
if (!chip.active) {
|
||||
this._setParams({ [providerId]: true });
|
||||
// Every registered provider in one update (those hidden on this map
|
||||
// too), so the share link records the swap.
|
||||
const ids =
|
||||
this.layer.providerIds || this._view.providers.map((entry) => entry.id);
|
||||
this._setParams(
|
||||
Object.fromEntries(ids.map((id) => [id, id === providerId])),
|
||||
);
|
||||
await this._ensureEnabled();
|
||||
return;
|
||||
}
|
||||
const othersLit = this._view.providers.some(
|
||||
(entry) => entry.id !== providerId && entry.active,
|
||||
);
|
||||
if (othersLit) {
|
||||
this._setParams({ [providerId]: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.actions.setEnabled?.(false);
|
||||
} catch (error) {
|
||||
@@ -466,6 +480,7 @@ export class StreetLevelControls {
|
||||
this._renderFilters(view);
|
||||
this._renderViewer(view);
|
||||
this._renderMeta(view);
|
||||
this._renderNearest(view);
|
||||
this._reactToTransitions(view, state);
|
||||
}
|
||||
|
||||
@@ -510,6 +525,14 @@ export class StreetLevelControls {
|
||||
|
||||
_renderFilters(view) {
|
||||
const el = this._elements;
|
||||
const shows = view.filterControls || { pano: true, since: true };
|
||||
setProp(el.panoRow, 'hidden', !shows.pano);
|
||||
setProp(el.sinceRow, 'hidden', !shows.since);
|
||||
// A lit source that dates by upload says so under the slider.
|
||||
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
|
||||
setProp(el.sinceNote, 'hidden', !view.sinceNote);
|
||||
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
|
||||
setProp(el.legend, 'hidden', view.legend.length === 0);
|
||||
this._renderRadios(
|
||||
el.panoButtons,
|
||||
(button) => button.dataset.slPano === view.filter.pano,
|
||||
@@ -577,6 +600,9 @@ export class StreetLevelControls {
|
||||
el.renderButtons,
|
||||
(button) => button.dataset.slRender === viewer.renderMode,
|
||||
);
|
||||
// Google's viewer always fills its frame: no FIT/FILL to offer.
|
||||
const renderGroup = el.renderButtons[0]?.parentElement;
|
||||
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
|
||||
if (!viewer.open) return;
|
||||
setProp(el.imageBy, 'textContent', viewer.captionLeft);
|
||||
setProp(el.imageWhen, 'textContent', viewer.captionRight);
|
||||
@@ -592,6 +618,17 @@ export class StreetLevelControls {
|
||||
setProp(this._elements.coverageMeta, 'textContent', view.meta);
|
||||
}
|
||||
|
||||
_renderNearest(view) {
|
||||
const button = this._elements.nearestBtn;
|
||||
if (!button) return;
|
||||
const { nearest } = view;
|
||||
setProp(button, 'hidden', !nearest.visible);
|
||||
if (!nearest.visible) return;
|
||||
setProp(button, 'textContent', nearest.label);
|
||||
setProp(button, 'title', nearest.title);
|
||||
setProp(button, 'disabled', nearest.disabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the panel on a switch-on or when an image opens. Never persisted:
|
||||
* the stored collapse state stays the user's own choice.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { StreetLevelControls } from './streetLevelControls.js';
|
||||
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
|
||||
@@ -307,12 +308,17 @@ function panelDom() {
|
||||
add(settings, 'div', { id });
|
||||
// A missing key gates the filters only; the chips stay outside the gate.
|
||||
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
|
||||
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
|
||||
for (const pano of ['all', 'pano', 'flat'])
|
||||
add(controls, 'button', { dataset: { slPano: pano } });
|
||||
add(controls, 'input', { id: 'sl-since' });
|
||||
add(controls, 'output', { id: 'sl-since-label' });
|
||||
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, 'small', { id: 'sl-since-note' });
|
||||
add(settings, 'ul', { id: 'sl-legend' });
|
||||
add(settings, 'div', { id: 'sl-coverage-meta' });
|
||||
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
|
||||
add(metaRow, 'div', { id: 'sl-coverage-meta' });
|
||||
add(metaRow, 'button', { id: 'sl-nearest-btn' });
|
||||
// The globe's canvas is focusable too (tabindex=0).
|
||||
const globe = add(
|
||||
add(document.body, 'div', { id: 'cesiumContainer' }),
|
||||
@@ -1136,3 +1142,142 @@ test('a <body> child added while the viewer is expanded goes inert with the rest
|
||||
assert.equal(observer.watching(), false);
|
||||
}),
|
||||
));
|
||||
|
||||
test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
state.providers.push({
|
||||
...state.providers[0],
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
groundClick: true,
|
||||
count: 0,
|
||||
});
|
||||
state.groundClickReady = true;
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const asked = [];
|
||||
layer.openNearest = (point, options) => asked.push([point, options]);
|
||||
const button = dom.root.querySelector('#sl-nearest-btn');
|
||||
assert.equal(button.hidden, false);
|
||||
assert.equal(button.textContent, 'OPEN STREET VIEW');
|
||||
button.click();
|
||||
assert.deepEqual(asked, [
|
||||
[
|
||||
undefined,
|
||||
{ providerIds: ['google'], frame: false, aim: 'screen-centre' },
|
||||
],
|
||||
]);
|
||||
// Too high to mean a street: disabled, and a click does nothing.
|
||||
layer.publish({ ...state, groundClickReady: false });
|
||||
assert.equal(button.disabled, true);
|
||||
button.click();
|
||||
assert.equal(asked.length, 1);
|
||||
// Off Google 3D the provider is not listed: no button.
|
||||
layer.publish({ ...state, providers: [state.providers[0]] });
|
||||
assert.equal(button.hidden, true);
|
||||
}));
|
||||
|
||||
test('a refused Street View key cannot lock Mapillary out; its chip only explains itself', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState({ on: false, keyRequired: true });
|
||||
state.providers.push({
|
||||
...state.providers[0],
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
on: true,
|
||||
keyRequired: true,
|
||||
keyRejected: true,
|
||||
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
|
||||
});
|
||||
state.providers[0].keyRequired = false;
|
||||
const { calls, layer } = stubPanel(dom, state);
|
||||
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
|
||||
chips()[0].click(); // Mapillary: configured, dark
|
||||
await settle();
|
||||
// One source at a time: lighting Mapillary darkens Street View too.
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true, google: false }, { origin: 'user' }],
|
||||
]);
|
||||
// Now dark, the refused chip says why instead of lighting.
|
||||
state.providers[0].on = true;
|
||||
state.providers[1].on = false;
|
||||
state.keyRequired = false;
|
||||
layer.publish({ ...state });
|
||||
chips()[1].click();
|
||||
await settle();
|
||||
assert.equal(calls.setParams.length, 1);
|
||||
assert.deepEqual(calls.toasts, [
|
||||
dom.root.querySelector('#sl-provider-chips').children[1].title,
|
||||
]);
|
||||
}));
|
||||
|
||||
test('a swap names every registered provider, also those hidden on this map', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState({ on: false });
|
||||
const { calls, layer, chip } = stubPanel(dom, state);
|
||||
// Off Google 3D the Street View chip is not listed, but stays registered.
|
||||
layer.providerIds = ['mapillary', 'google'];
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true, google: false }, { origin: 'user' }],
|
||||
]);
|
||||
}));
|
||||
|
||||
test('a note under SINCE says what date it compares, only while one is given', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const note = () => dom.root.querySelector('#sl-since-note');
|
||||
assert.equal(note().hidden, true, 'none for Mapillary');
|
||||
layer.publish({ ...state, sinceNote: 'KartaView: by upload date' });
|
||||
assert.equal(note().hidden, false);
|
||||
assert.equal(note().textContent, 'KartaView: by upload date');
|
||||
layer.publish(state);
|
||||
assert.equal(note().hidden, true);
|
||||
// The panel's own markup has it, read out with the slider.
|
||||
const template = fs.readFileSync(
|
||||
new URL('./templates/layer-panels.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
|
||||
assert.match(template, /aria-describedby="sl-since-label sl-since-note"/);
|
||||
}));
|
||||
|
||||
test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () =>
|
||||
withDom(async (dom) => {
|
||||
const state = uiState();
|
||||
const { layer } = stubPanel(dom, state);
|
||||
const shown = () => ({
|
||||
pano: !dom.root.querySelector('#sl-pano-row').hidden,
|
||||
since: !dom.root.querySelector('#sl-since-row').hidden,
|
||||
controls: !dom.root.querySelector('#sl-controls').hidden,
|
||||
legend: !dom.root.querySelector('#sl-legend').hidden,
|
||||
});
|
||||
assert.deepEqual(shown(), {
|
||||
pano: true,
|
||||
since: true,
|
||||
controls: true,
|
||||
legend: true,
|
||||
});
|
||||
layer.publish({
|
||||
...state,
|
||||
filterControls: { pano: false, since: false },
|
||||
legend: [],
|
||||
});
|
||||
assert.deepEqual(shown(), {
|
||||
pano: false,
|
||||
since: false,
|
||||
controls: false,
|
||||
legend: false,
|
||||
});
|
||||
layer.publish(state);
|
||||
assert.deepEqual(shown(), {
|
||||
pano: true,
|
||||
since: true,
|
||||
controls: true,
|
||||
legend: true,
|
||||
});
|
||||
}));
|
||||
@@ -1,4 +1,7 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
keySetupRequirement,
|
||||
keySetupWithAlternatives,
|
||||
} from '../keySetupCore.mjs';
|
||||
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
@@ -44,22 +47,43 @@ function presentSince(days, now) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(ms) {
|
||||
/** YYYY-MM-DD, or YYYY-MM for a provider that dates images by month. */
|
||||
function formatDate(ms, precision = 'day') {
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
try {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the key-gated layer needs: each switched-on provider's key, and the
|
||||
* switched-off ones that would draw with a key already set.
|
||||
*/
|
||||
function keyGuidance(state) {
|
||||
const needs = (state.providers || [])
|
||||
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
|
||||
.map((p) => keySetupRequirement(p.requiresKeyId))
|
||||
.filter(Boolean);
|
||||
return keySetupWithAlternatives(
|
||||
[...new Set(needs)].join('; '),
|
||||
state.keyAlternatives || [],
|
||||
);
|
||||
}
|
||||
|
||||
function presentStatus(state) {
|
||||
const pressed = state.enabled === true;
|
||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||
if (state.keyRejected)
|
||||
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
|
||||
if (state.keyRequired)
|
||||
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
|
||||
return {
|
||||
text: 'KEY REQUIRED',
|
||||
tone: 'warn',
|
||||
pressed,
|
||||
title: keyGuidance(state) || title,
|
||||
};
|
||||
if (state.coverage.loading)
|
||||
return { text: 'LOADING', tone: 'busy', pressed, title };
|
||||
return pressed
|
||||
@@ -104,12 +128,17 @@ function presentViewer(state) {
|
||||
if (street.isPano) right.push('360°');
|
||||
if (Number.isFinite(street.bearing))
|
||||
right.push(`${Math.round(street.bearing)}°`);
|
||||
if (street.capturedAt) right.push(formatDate(street.capturedAt));
|
||||
if (street.capturedAt)
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
if (street.title) left.push(street.title);
|
||||
if (street.creator) left.push(`Image by ${street.creator}`);
|
||||
return {
|
||||
open: street.open === true,
|
||||
loading: street.loading === true && !street.imageId,
|
||||
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
|
||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||
renderModes: street.renderModes !== false,
|
||||
captionLeft: left.join(' · '),
|
||||
captionRight: right.join(' · '),
|
||||
link: street.externalUrl || null,
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
@@ -124,16 +153,52 @@ function presentViewer(state) {
|
||||
};
|
||||
}
|
||||
|
||||
/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */
|
||||
function presentNearest(state) {
|
||||
const targets = (state.providers || []).filter(
|
||||
(provider) =>
|
||||
provider.on && provider.groundClick && provider.keyRequired !== true,
|
||||
);
|
||||
if (state.enabled !== true || !targets.length)
|
||||
return {
|
||||
visible: false,
|
||||
label: '',
|
||||
title: '',
|
||||
disabled: true,
|
||||
providerIds: [],
|
||||
};
|
||||
const names = targets.map((provider) => provider.name).join(' or ');
|
||||
const ready = state.groundClickReady === true;
|
||||
return {
|
||||
visible: true,
|
||||
label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`,
|
||||
title: ready
|
||||
? `Open ${names} at the centre of the view`
|
||||
: `Zoom in to a street to open ${names} here`,
|
||||
disabled: !ready || state.street.loading === true,
|
||||
providerIds: targets.map((provider) => provider.id),
|
||||
};
|
||||
}
|
||||
|
||||
function presentMeta(state) {
|
||||
if (state.providers.length === 0) return state.coverage.hint || '';
|
||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||
// Key-gated with another source ready: say how to use it.
|
||||
if (state.keyRequired && state.keyAlternatives?.length)
|
||||
return keyGuidance(state);
|
||||
if (state.sequence.selectedId)
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
|
||||
const hint = state.coverage.hint || '';
|
||||
if (state.coverage.count > 0)
|
||||
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
|
||||
return state.coverage.hint || '';
|
||||
return [
|
||||
`${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`,
|
||||
hint,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return hint;
|
||||
}
|
||||
|
||||
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
||||
@@ -154,10 +219,16 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
? null
|
||||
: state.street.error || state.coverage.error || null,
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
filterControls: {
|
||||
pano: state.filterControls?.pano !== false,
|
||||
since: state.filterControls?.since !== false,
|
||||
},
|
||||
since: presentSince(filter.sinceDays, now),
|
||||
sinceNote: state.sinceNote || null,
|
||||
legend: state.legend || [],
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
nearest: presentNearest(state),
|
||||
wantsOpen: state.street.open === true,
|
||||
};
|
||||
}
|
||||
@@ -405,3 +405,137 @@ test('FOLLOW is disabled off Google 3D and says where to switch', () => {
|
||||
);
|
||||
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
|
||||
});
|
||||
|
||||
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
street: {
|
||||
open: true,
|
||||
providerId: 'google',
|
||||
providerName: 'Google Street View',
|
||||
providerLabel: 'STREET VIEW',
|
||||
imageId: 'pano-1',
|
||||
title: '1014 10th St',
|
||||
creator: 'Google',
|
||||
capturedAt: Date.UTC(2023, 0, 1),
|
||||
capturedAtPrecision: 'month',
|
||||
bearing: 0,
|
||||
isPano: true,
|
||||
renderModes: false,
|
||||
externalUrl:
|
||||
'https://www.google.com/maps/@?api=1&map_action=pano&pano=pano-1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(view.viewer.captionLeft, '1014 10th St · Image by Google');
|
||||
assert.equal(view.viewer.captionRight, '360° · 0° · 2023-01');
|
||||
assert.equal(view.viewer.linkLabel, 'STREET VIEW ↗');
|
||||
assert.equal(view.viewer.renderModes, false);
|
||||
});
|
||||
|
||||
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
coverage: {
|
||||
loading: false,
|
||||
count: 2514,
|
||||
hint: 'click a street to open Street View',
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
view.meta,
|
||||
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
|
||||
);
|
||||
});
|
||||
|
||||
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
|
||||
const streetView = provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
groundClick: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
});
|
||||
const view = (overrides) =>
|
||||
presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
providers: [provider(), streetView],
|
||||
...overrides,
|
||||
}),
|
||||
).nearest;
|
||||
const ready = view({ groundClickReady: true });
|
||||
assert.deepEqual(ready, {
|
||||
visible: true,
|
||||
label: 'OPEN STREET VIEW',
|
||||
title: 'Open Google Street View at the centre of the view',
|
||||
disabled: false,
|
||||
providerIds: ['google'],
|
||||
});
|
||||
const high = view({ groundClickReady: false });
|
||||
assert.equal(high.disabled, true);
|
||||
assert.equal(
|
||||
high.title,
|
||||
'Zoom in to a street to open Google Street View here',
|
||||
);
|
||||
// Hidden with Street View off, without its key, or with the layer off
|
||||
// (off Google 3D the provider is not listed at all).
|
||||
assert.equal(
|
||||
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
|
||||
.visible,
|
||||
false,
|
||||
);
|
||||
assert.equal(view({ providers: [provider()] }).visible, false);
|
||||
assert.equal(
|
||||
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
|
||||
.nearest.visible,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [
|
||||
provider({ keyRequired: true, configured: false }),
|
||||
provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
label: 'STREET VIEW',
|
||||
on: false,
|
||||
configured: true,
|
||||
requiresKeyId: 'google-maps',
|
||||
}),
|
||||
],
|
||||
keyAlternatives: [
|
||||
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const guidance =
|
||||
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
|
||||
assert.equal(view.status.text, 'KEY REQUIRED');
|
||||
assert.equal(view.status.title, guidance);
|
||||
assert.equal(view.meta, guidance);
|
||||
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
|
||||
const plain = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled: true,
|
||||
keyRequired: true,
|
||||
providers: [provider({ keyRequired: true, configured: false })],
|
||||
keyAlternatives: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(plain.status.text, 'KEY REQUIRED');
|
||||
assert.notEqual(plain.meta, guidance);
|
||||
});
|
||||
@@ -4,6 +4,9 @@
|
||||
#street-level-panel,
|
||||
.sl-viewer-wrap {
|
||||
--sl-green: #05cb63;
|
||||
/* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */
|
||||
--sl-kartaview: #635bff;
|
||||
--sl-kartaview-text: #8f8aff;
|
||||
--sl-text: 0.62rem;
|
||||
--sl-text-small: 0.6rem;
|
||||
}
|
||||
@@ -174,6 +177,11 @@ button.sl-status:focus-visible {
|
||||
);
|
||||
}
|
||||
|
||||
/* #635bff text on its own tint is 3.7:1 at this size; the lighter one is ~6:1. */
|
||||
.sl-chips .data-toggle-chip.active[data-chip-id='kartaview'] {
|
||||
color: var(--sl-kartaview-text);
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -183,6 +191,28 @@ button.sl-status:focus-visible {
|
||||
border-color: rgba(255, 179, 0, 0.55);
|
||||
}
|
||||
|
||||
/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */
|
||||
.sl-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sl-meta-row .sl-meta {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scoped to outrank .scene-btn's flex: 1, which squeezed the button to its
|
||||
min-width beside a long coverage line and clipped its one-line label. */
|
||||
.sl-meta-row .sl-nearest-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sl-nearest-btn[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||
.sl-error {
|
||||
font-family: var(--font-sans);
|
||||
@@ -207,6 +237,14 @@ button.sl-status:focus-visible {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sl-seg[hidden],
|
||||
.sl-controls[hidden],
|
||||
.sl-filters[hidden],
|
||||
.sl-since[hidden],
|
||||
.sl-legend[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-seg-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
@@ -261,6 +299,17 @@ button.sl-status:focus-visible {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.sl-since-note {
|
||||
grid-column: 2;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sl-since-note[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-since-label {
|
||||
grid-column: 2;
|
||||
font-family: var(--font-mono);
|
||||
@@ -480,7 +529,9 @@ button.sl-status:focus-visible {
|
||||
/* A dragged window keeps its natural height, so bound it to the viewport:
|
||||
the rail's max-height: 100% does not bind an auto-height parent.
|
||||
Id-qualified to outrank that rule. */
|
||||
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
|
||||
#right-context-rail
|
||||
> #street-level-panel.panel-floating
|
||||
> .street-level-panel-inner {
|
||||
max-height: calc(100vh - 12px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -581,6 +632,97 @@ button.sl-status:focus-visible {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* ── KartaView photo viewer ──────────────────────────────────────────────── */
|
||||
.sl-kv-viewer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sl-kv-viewer:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The photo and canvas fill the viewer and would cover a ring drawn on it:
|
||||
draw the ring on a layer above them (clicks pass through). */
|
||||
.sl-kv-viewer:focus-visible::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border: 2px solid var(--sl-kartaview-text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sl-kv-photo,
|
||||
.sl-kv-pano {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sl-kv-photo {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sl-kv-pano {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sl-kv-pano.sl-kv-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sl-kv-photo[hidden],
|
||||
.sl-kv-pano[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-kv-step {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
width: 34px;
|
||||
height: 44px;
|
||||
margin-top: -22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 10, 15, 0.62);
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sl-kv-step-prev {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.sl-kv-step-next {
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.sl-kv-step:hover:not(:disabled),
|
||||
.sl-kv-step:focus-visible {
|
||||
background: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sl-kv-step:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Nothing to step through until a photo shows. */
|
||||
.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Short viewports ─────────────────────────────────────────────────────── */
|
||||
@media (max-height: 640px) {
|
||||
.street-level-panel-inner {
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
<!-- A missing key gates the filters only: the chips above stay
|
||||
focusable so their tooltip can say how to add the key. -->
|
||||
<fieldset id="sl-controls" class="sl-controls">
|
||||
<div class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters">
|
||||
<span class="cctv-summary-label">IMAGERY</span>
|
||||
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
|
||||
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
|
||||
@@ -126,14 +126,19 @@
|
||||
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-since">
|
||||
<div id="sl-since-row" class="sl-since">
|
||||
<label class="cctv-summary-label" for="sl-since">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" />
|
||||
<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">ANY DATE</output>
|
||||
<small id="sl-since-note" class="sl-since-note" hidden></small>
|
||||
</div>
|
||||
</fieldset>
|
||||
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<!-- The hint says how to open imagery; the button does it without a pointer. -->
|
||||
<div class="sl-meta-row">
|
||||
<div id="sl-coverage-meta" class="sl-meta"></div>
|
||||
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user