mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge origin/main: keep the provider registry, slim it
main grew the Street Level provider registry while this branch removed it (#982–#985: injected providers, an empty composition, the `r` switch field). The registry, params, uiState and pick router come back as main has them and the slimmed layer sits behind the provider contract: - The core (index.js) drives providers as on main, without surface modes, ground casting, FOLLOW, prewarm and nearest-image lookups. A provider's key status is asked with a 10 s timeout and asked again on a switch-on while no key is known. - Mapillary is mapillaryProvider.js over the slim coverage, sequences and viewer modules, which read the filter and the active flag from the provider context. - The viewer host mounts one provider adapter at a time, no prewarm. - The panel has a chip per provider again (the pill switches the layer); no legend, EXPAND stays the Fullscreen API. - The contract drops what no provider implements: groundCaster, meshSampler, getSurface/setSurface, nearestImage, prewarm, reportError, the legend and surface fields of the UI snapshot. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
c29ccd55d6
103 files changed
+5469
-522
No files matched your search
+15
-5
@@ -588,7 +588,12 @@ export class LayerPanel {
|
||||
}
|
||||
|
||||
_syncToggleButton(button, layer) {
|
||||
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
|
||||
const unavailable = layer.stats?.sourceUnavailable === true;
|
||||
const feedState = unavailable
|
||||
? 'unavailable'
|
||||
: layer.enabled
|
||||
? layerFeedState(layer.stats)
|
||||
: 'off';
|
||||
const transitioning =
|
||||
layer.lifecycleState === 'enabling' ||
|
||||
layer.lifecycleState === 'disabling';
|
||||
@@ -601,7 +606,7 @@ export class LayerPanel {
|
||||
for (const state of Object.keys(FEED_STATE_LABELS)) {
|
||||
button.classList.toggle(
|
||||
`feed-${state}`,
|
||||
layer.enabled && !uncertain && feedState === state,
|
||||
(layer.enabled || unavailable) && !uncertain && feedState === state,
|
||||
);
|
||||
}
|
||||
button.dataset.feedState = transitioning
|
||||
@@ -613,16 +618,21 @@ export class LayerPanel {
|
||||
// click guard above prevents repeat activation without the focus loss caused
|
||||
// by native `disabled`.
|
||||
button.disabled = false;
|
||||
button.setAttribute('aria-disabled', String(transitioning));
|
||||
button.setAttribute(
|
||||
'aria-disabled',
|
||||
String(transitioning || (unavailable && !layer.enabled)),
|
||||
);
|
||||
button.setAttribute('aria-busy', String(transitioning));
|
||||
button.textContent = transitioning
|
||||
? layer.lifecycleState.toUpperCase()
|
||||
: uncertain
|
||||
? 'UNCERTAIN'
|
||||
: layer.enabled
|
||||
: layer.enabled || unavailable
|
||||
? FEED_STATE_LABELS[feedState]
|
||||
: 'OFF';
|
||||
const keyGuidance = layerKeyRequirementTooltip(layer);
|
||||
const keyGuidance = unavailable
|
||||
? String(layer.stats.error || 'Data source unavailable')
|
||||
: layerKeyRequirementTooltip(layer);
|
||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||
// when the layer needs no key, or already has one.
|
||||
|
||||
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const attrs = new Map();
|
||||
const button = {
|
||||
classList: { toggle() {} },
|
||||
dataset: {},
|
||||
setAttribute: (key, value) => attrs.set(key, value),
|
||||
};
|
||||
const layer = {
|
||||
id: 'flights',
|
||||
name: 'Flights',
|
||||
enabled: false,
|
||||
stats: {
|
||||
sourceUnavailable: true,
|
||||
status: 'unavailable',
|
||||
error: 'Data source not configured: flights',
|
||||
},
|
||||
};
|
||||
LayerPanel.prototype._syncToggleButton(button, layer);
|
||||
assert.equal(button.textContent, 'UNAVAILABLE');
|
||||
assert.equal(attrs.get('aria-disabled'), 'true');
|
||||
assert.match(button.title, /not configured/);
|
||||
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
|
||||
assert.equal(
|
||||
attrs.get('aria-disabled'),
|
||||
'false',
|
||||
'an active layer can always be switched off',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import {
|
||||
presentStreetLevelPanel,
|
||||
SINCE_STOPS,
|
||||
@@ -24,8 +25,8 @@ const setAttr = (node, key, value) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills the Street Level panel body: filters and viewer. The panel chrome
|
||||
* itself belongs to the application shell.
|
||||
* Fills the Street Level panel body: provider chips, filters and viewer. The
|
||||
* panel chrome itself belongs to the application shell.
|
||||
*/
|
||||
export class StreetLevelControls {
|
||||
constructor({ root, layer, actions }) {
|
||||
@@ -36,6 +37,7 @@ export class StreetLevelControls {
|
||||
this.listeners = new AbortController();
|
||||
this._unsubscribe = null;
|
||||
this._state = null;
|
||||
this._view = null;
|
||||
this._wasEnabled = null;
|
||||
this._wasOpen = false;
|
||||
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
|
||||
@@ -53,6 +55,7 @@ export class StreetLevelControls {
|
||||
];
|
||||
return {
|
||||
status: byId('sl-status'),
|
||||
providerChips: byId('sl-provider-chips'),
|
||||
controls: byId('sl-controls'),
|
||||
error: byId('sl-error'),
|
||||
errorText: byId('sl-error-text'),
|
||||
@@ -85,6 +88,17 @@ export class StreetLevelControls {
|
||||
this.layer.attachViewerHost?.(el.viewer);
|
||||
|
||||
this.listen(el.status, 'click', () => this._toggleEnabled());
|
||||
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) {
|
||||
if (button.title) this.actions.showToast?.(button.title);
|
||||
return;
|
||||
}
|
||||
this._toggleProvider(button.dataset.chipId);
|
||||
});
|
||||
for (const button of el.panoButtons) {
|
||||
this.listen(button, 'click', () =>
|
||||
this._setParams({ pano: button.dataset.slPano }),
|
||||
@@ -192,12 +206,6 @@ export class StreetLevelControls {
|
||||
|
||||
async _toggleEnabled() {
|
||||
const enabled = this.actions.isEnabled?.() === true;
|
||||
// The pill is the only switch: it also brings back Mapillary that a link
|
||||
// or tool switched off under a layer that is on.
|
||||
if (this._state?.providerOn === false) {
|
||||
this._setParams({ mapillary: true });
|
||||
if (enabled) return;
|
||||
}
|
||||
try {
|
||||
await this.actions.setEnabled?.(!enabled);
|
||||
} catch (error) {
|
||||
@@ -205,6 +213,45 @@ export class StreetLevelControls {
|
||||
}
|
||||
}
|
||||
|
||||
async _ensureEnabled() {
|
||||
if (this.actions.isEnabled?.()) return true;
|
||||
try {
|
||||
await this.actions.setEnabled?.(true);
|
||||
} catch (error) {
|
||||
this.actions.showToast?.(
|
||||
error?.message || 'Street Level could not start',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return this.actions.isEnabled?.() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async _toggleProvider(providerId) {
|
||||
const chip = this._view?.providers.find((entry) => entry.id === providerId);
|
||||
if (!chip) return;
|
||||
if (!chip.active) {
|
||||
this._setParams({ [providerId]: true });
|
||||
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) {
|
||||
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Through the data manager, so saved state and share links record it. */
|
||||
_setParams(params) {
|
||||
this.actions.setParams(params, { origin: 'user' });
|
||||
@@ -315,8 +362,10 @@ export class StreetLevelControls {
|
||||
if (this.destroyed || !state || !this.root) return;
|
||||
this._state = state;
|
||||
const view = presentStreetLevelPanel(state);
|
||||
this._view = view;
|
||||
this._renderHeader(view);
|
||||
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
|
||||
syncChipGroup(this._elements.providerChips, view.providers);
|
||||
this._renderError(view);
|
||||
this._renderFilters(view);
|
||||
this._renderViewer(view);
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { StreetLevelControls } from './streetLevelControls.js';
|
||||
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../layers/streetLevel/mapillaryProvider.js';
|
||||
import { DataLayerManager } from '../data/manager.js';
|
||||
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||
import {
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
REGISTERED_LAYER_IDS,
|
||||
encodeLayerStateParams,
|
||||
} from '../data/layerState.js';
|
||||
import { fakeMapillarySource } from '../testSupport/streetLevelFakes.mjs';
|
||||
import {
|
||||
fakeMapillarySource,
|
||||
providerSnapshot,
|
||||
} from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
/* ── A small DOM: just what the panel controls touch ───────────────────── */
|
||||
|
||||
@@ -94,6 +98,23 @@ class FakeNode {
|
||||
if (current === this) return true;
|
||||
return false;
|
||||
}
|
||||
closest(selector) {
|
||||
for (let current = this; current; current = current.parent)
|
||||
if (current.matches?.(selector)) return current;
|
||||
return null;
|
||||
}
|
||||
insertBefore(child, before) {
|
||||
child.parent?.children.splice(child.parent.children.indexOf(child), 1);
|
||||
child.parent = this;
|
||||
const at = before ? this.children.indexOf(before) : -1;
|
||||
if (at === -1) this.children.push(child);
|
||||
else this.children.splice(at, 0, child);
|
||||
return child;
|
||||
}
|
||||
remove() {
|
||||
this.parent?.children.splice(this.parent.children.indexOf(this), 1);
|
||||
this.parent = null;
|
||||
}
|
||||
focus() {
|
||||
this.ownerDocument.activeElement = this;
|
||||
}
|
||||
@@ -182,6 +203,7 @@ function panelDom({ fullscreen = true, popover = false } = {}) {
|
||||
const add = (parent, tag, options) =>
|
||||
parent.appendChild(new FakeNode(document, tag, options));
|
||||
add(root, 'button', { id: 'sl-status' });
|
||||
add(root, 'div', { id: 'sl-provider-chips' });
|
||||
const wrap = add(root, 'div', { id: 'sl-viewer-wrap' });
|
||||
if (fullscreen) wrap.requestFullscreen = async () => fullscreenTo(wrap);
|
||||
if (popover) {
|
||||
@@ -280,7 +302,9 @@ const memoryStorage = () => {
|
||||
|
||||
/** The production path: controls → dataManager → layer → durable layer state. */
|
||||
async function productionPanel(dom) {
|
||||
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
|
||||
const layer = createStreetLevelLayer({
|
||||
providers: [createMapillaryProvider({ source: fakeMapillarySource() })],
|
||||
});
|
||||
const manager = new DataLayerManager({});
|
||||
for (const id of REGISTERED_LAYER_IDS)
|
||||
manager.register(id === 'street-level' ? layer : plainLayer(id));
|
||||
@@ -390,7 +414,7 @@ function uiState({
|
||||
} = {}) {
|
||||
return {
|
||||
enabled,
|
||||
providerOn: on,
|
||||
providers: [providerSnapshot({ on, keyRequired })],
|
||||
keyRequired,
|
||||
keyRejected: false,
|
||||
filter: { pano, sinceDays: 0 },
|
||||
@@ -408,7 +432,7 @@ function uiState({
|
||||
|
||||
function stubPanel(dom, state, extraActions = {}) {
|
||||
const layer = stubLayer();
|
||||
const calls = { setParams: [], setEnabled: [], collapsed: [] };
|
||||
const calls = { setParams: [], setEnabled: [], collapsed: [], toasts: [] };
|
||||
let enabled = state.enabled;
|
||||
/** Reports a switch-on request's origin, as the data manager does. */
|
||||
let announce = null;
|
||||
@@ -425,7 +449,7 @@ function stubPanel(dom, state, extraActions = {}) {
|
||||
setParams: (params, options) => calls.setParams.push([params, options]),
|
||||
setPanelCollapsed: (collapsed, options) =>
|
||||
calls.collapsed.push([collapsed, options]),
|
||||
showToast() {},
|
||||
showToast: (message) => calls.toasts.push(message),
|
||||
subscribeEnableRequests: (listener) => {
|
||||
announce = listener;
|
||||
return () => {
|
||||
@@ -442,44 +466,66 @@ function stubPanel(dom, state, extraActions = {}) {
|
||||
controls,
|
||||
announce: (origin) => announce?.(origin),
|
||||
subscribed: () => announce !== null,
|
||||
chip: () => dom.root.querySelector('#sl-provider-chips').children[0],
|
||||
};
|
||||
}
|
||||
|
||||
test('the header pill switches the layer, bringing back Mapillary switched off elsewhere', () =>
|
||||
test('the header pill switches the layer; provider switches are left alone', () =>
|
||||
withDom(async (dom) => {
|
||||
const status = dom.root.querySelector('#sl-status');
|
||||
const on = stubPanel(dom, uiState());
|
||||
const { calls, controls } = stubPanel(dom, uiState());
|
||||
status.click();
|
||||
await settle();
|
||||
assert.deepEqual(on.calls.setEnabled, [false]);
|
||||
assert.deepEqual(on.calls.setParams, [], 'switching off keeps the switch');
|
||||
on.controls.destroy();
|
||||
const dark = stubPanel(dom, uiState({ enabled: false, on: false }));
|
||||
status.click();
|
||||
await settle();
|
||||
assert.deepEqual(dark.calls.setParams, [
|
||||
[{ mapillary: true }, { origin: 'user' }],
|
||||
]);
|
||||
assert.deepEqual(dark.calls.setEnabled, [true]);
|
||||
dark.controls.destroy();
|
||||
assert.deepEqual(calls.setEnabled, [false]);
|
||||
assert.deepEqual(calls.setParams, []);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('a layer that is on with Mapillary switched off reads OFF, and the pill brings Mapillary back', () =>
|
||||
test('darkening the only lit chip turns the layer off and keeps the provider switched on', () =>
|
||||
withDom(async (dom) => {
|
||||
const status = dom.root.querySelector('#sl-status');
|
||||
const { calls, controls } = stubPanel(dom, uiState({ on: false }));
|
||||
assert.equal(status.textContent, 'OFF');
|
||||
assert.equal(status.getAttribute('aria-pressed'), 'false');
|
||||
assert.match(
|
||||
dom.root.querySelector('#sl-coverage-meta').textContent,
|
||||
/Mapillary is off in this view/,
|
||||
const { calls, chip, controls } = stubPanel(dom, uiState());
|
||||
assert.equal(chip().dataset.chipId, 'mapillary');
|
||||
assert.equal(chip().getAttribute('aria-pressed'), 'true');
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setEnabled, [false]);
|
||||
assert.deepEqual(calls.setParams, [], 'the provider switch is untouched');
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('lighting a dark chip switches the provider on as a user params request, then the layer', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip, controls } = stubPanel(
|
||||
dom,
|
||||
uiState({ enabled: false, on: false }),
|
||||
);
|
||||
status.click();
|
||||
assert.equal(chip().getAttribute('aria-pressed'), 'false');
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setParams, [
|
||||
[{ mapillary: true }, { origin: 'user' }],
|
||||
]);
|
||||
assert.deepEqual(calls.setEnabled, [], 'the layer itself stays on');
|
||||
assert.deepEqual(calls.setEnabled, [true]);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
test('under KEY REQUIRED the provider chip stays live and explains the key instead of toggling', () =>
|
||||
withDom(async (dom) => {
|
||||
const { calls, chip, controls } = stubPanel(
|
||||
dom,
|
||||
uiState({ keyRequired: true }),
|
||||
);
|
||||
assert.equal(
|
||||
dom.root.querySelector('#sl-controls').contains(chip()),
|
||||
false,
|
||||
'the chip is outside the disabled fieldset',
|
||||
);
|
||||
chip().click();
|
||||
await settle();
|
||||
assert.deepEqual(calls.setEnabled, []);
|
||||
assert.deepEqual(calls.setParams, []);
|
||||
assert.equal(calls.toasts.length, 1);
|
||||
assert.match(calls.toasts[0], /^Mapillary: Needs /);
|
||||
controls.destroy();
|
||||
}));
|
||||
|
||||
|
||||
@@ -54,11 +54,8 @@ function formatDate(ms) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the layer draws: on, and Mapillary not switched off by a link or tool. */
|
||||
const drawing = (state) => state.enabled === true && state.providerOn !== false;
|
||||
|
||||
function presentStatus(state) {
|
||||
const pressed = drawing(state);
|
||||
const pressed = state.enabled === true;
|
||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||
const key = keyStatusLabel(state);
|
||||
if (key) return { text: key, tone: 'warn', pressed, title };
|
||||
@@ -69,10 +66,44 @@ function presentStatus(state) {
|
||||
: { text: 'OFF', tone: '', pressed, title };
|
||||
}
|
||||
|
||||
/** How to add the missing Mapillary key. */
|
||||
function keyHint() {
|
||||
const requirement = keySetupRequirement('mapillary');
|
||||
return requirement ? `Mapillary: ${requirement}` : null;
|
||||
/** A chip is lit only while the layer and its provider are both on. */
|
||||
function presentProviders(state) {
|
||||
const enabled = state.enabled === true;
|
||||
return (state.providers || []).map((provider) => {
|
||||
const keyRequired = provider.keyRequired === true;
|
||||
const on = enabled && provider.on === true;
|
||||
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`;
|
||||
if (provider.keyRejected && provider.error)
|
||||
title = `${provider.name}: ${provider.error}`;
|
||||
else if (keyRequired && provider.requiresKeyId)
|
||||
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
|
||||
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
||||
return {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
color: provider.color || null,
|
||||
title,
|
||||
active: on,
|
||||
disabled: false,
|
||||
state: keyRequired
|
||||
? 'error'
|
||||
: on && provider.loading
|
||||
? 'loading'
|
||||
: on
|
||||
? 'active'
|
||||
: 'idle',
|
||||
busy: on && provider.loading === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** How to add the key the first switched-on provider is missing. */
|
||||
function keyHint(state) {
|
||||
const provider = (state.providers || []).find(
|
||||
(p) => p.on && p.keyRequired && p.requiresKeyId,
|
||||
);
|
||||
const requirement = provider && keySetupRequirement(provider.requiresKeyId);
|
||||
return requirement ? `${provider.name}: ${requirement}` : null;
|
||||
}
|
||||
|
||||
function presentViewer(state) {
|
||||
@@ -89,14 +120,16 @@ function presentViewer(state) {
|
||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||
captionRight: right.join(' · '),
|
||||
link: street.externalUrl || null,
|
||||
linkLabel: street.externalUrl ? 'MAPILLARY ↗' : '',
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
};
|
||||
}
|
||||
|
||||
function presentMeta(state) {
|
||||
const providers = state.providers || [];
|
||||
if (providers.length === 0) return state.coverage.hint || '';
|
||||
if (!state.enabled) return 'Switch Street Level on to draw its coverage.';
|
||||
if (!drawing(state))
|
||||
return 'Mapillary is off in this view. Switch Street Level on to show it.';
|
||||
if (!providers.some((p) => p.on))
|
||||
return 'Every provider is switched off: light a chip to draw its coverage.';
|
||||
if (state.sequence.selectedId)
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
@@ -110,20 +143,23 @@ function presentMeta(state) {
|
||||
const filter = state.filter || {};
|
||||
return (filter.pano || 'all') !== 'all' || Number(filter.sinceDays) > 0
|
||||
? 'No sequences in view match the filter'
|
||||
: 'No Mapillary coverage in view';
|
||||
: 'No coverage in view';
|
||||
}
|
||||
|
||||
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
||||
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
const keyRequired = state.keyRequired === true;
|
||||
const filter = state.filter || { pano: 'all', sinceDays: 0 };
|
||||
// A missing key says how to add it; a rejected key's own error names the fix.
|
||||
const keyMissing = state.keyRequired === true && state.keyRejected !== true;
|
||||
const keyMissing = keyRequired && state.keyRejected !== true;
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
keyRequired,
|
||||
status: presentStatus(state),
|
||||
controlsDisabled: state.keyRequired === true,
|
||||
controlsDisabled: keyRequired || (state.providers || []).length === 0,
|
||||
providers: presentProviders(state),
|
||||
error: keyMissing
|
||||
? keyHint()
|
||||
? keyHint(state)
|
||||
: state.street.error ||
|
||||
state.sequence.error ||
|
||||
state.coverage.error ||
|
||||
|
||||
@@ -4,10 +4,36 @@ import {
|
||||
presentStreetLevelPanel,
|
||||
sinceStopIndex,
|
||||
} from './streetLevelPresentation.js';
|
||||
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
const provider = providerSnapshot;
|
||||
|
||||
test('an empty composition explains unavailable imagery even while disabled', () => {
|
||||
for (const enabled of [false, true]) {
|
||||
const view = presentStreetLevelPanel(
|
||||
snapshot({
|
||||
enabled,
|
||||
providers: [],
|
||||
coverage: { hint: 'No street-level imagery providers configured' },
|
||||
}),
|
||||
);
|
||||
assert.equal(view.controlsDisabled, true);
|
||||
assert.equal(view.keyRequired, false);
|
||||
assert.deepEqual(view.providers, []);
|
||||
assert.equal(view.meta, 'No street-level imagery providers configured');
|
||||
}
|
||||
});
|
||||
|
||||
function snapshot(overrides = {}) {
|
||||
const base = {
|
||||
enabled: false,
|
||||
providerOn: true,
|
||||
// The one provider's key state follows the summary the test asks for.
|
||||
providers: [
|
||||
provider({
|
||||
keyRequired: overrides.keyRequired === true,
|
||||
keyRejected: overrides.keyRejected === true,
|
||||
}),
|
||||
],
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
filter: { pano: 'all', sinceDays: 0 },
|
||||
@@ -17,6 +43,7 @@ function snapshot(overrides = {}) {
|
||||
open: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
providerLabel: null,
|
||||
imageId: null,
|
||||
position: null,
|
||||
bearing: null,
|
||||
@@ -204,7 +231,7 @@ test('the meta line never mixes the visible-sequence count with the selected seq
|
||||
test('an empty view says so, and whether the filter emptied it', () => {
|
||||
const meta = (overrides) =>
|
||||
presentStreetLevelPanel(snapshot({ enabled: true, ...overrides })).meta;
|
||||
assert.equal(meta({}), 'No Mapillary coverage in view');
|
||||
assert.equal(meta({}), 'No coverage in view');
|
||||
assert.equal(
|
||||
meta({ filter: { pano: 'flat', sinceDays: 0 } }),
|
||||
'No sequences in view match the filter',
|
||||
@@ -225,6 +252,7 @@ test('viewer caption reads "Image by" left, date right, and links to Mapillary',
|
||||
enabled: true,
|
||||
street: {
|
||||
open: true,
|
||||
providerLabel: 'MAPILLARY',
|
||||
imageId: '1814275685699406',
|
||||
creator: 'mapfool',
|
||||
capturedAt: Date.UTC(2023, 9, 8),
|
||||
|
||||
@@ -300,6 +300,43 @@
|
||||
color: rgba(148, 178, 188, 0.42);
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-state {
|
||||
color: rgba(190, 214, 222, 0.7);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.05rem;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.28rem 0.55rem;
|
||||
color: rgba(54, 220, 255, 0.9);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.06rem;
|
||||
background: rgba(54, 220, 255, 0.08);
|
||||
border: 1px solid rgba(54, 220, 255, 0.28);
|
||||
border-radius: 0.32rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-toggle:hover,
|
||||
.key-setup-cloud-auth-toggle:focus-visible {
|
||||
border-color: rgba(54, 220, 255, 0.65);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.key-setup-cloud-auth-toggle[aria-disabled='true'] {
|
||||
cursor: wait;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.key-setup-footer {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,56 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Providers ───────────────────────────────────────────────────────────── */
|
||||
.sl-providers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sl-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* `--chip-color` comes from the provider. */
|
||||
.sl-chips .data-toggle-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color, currentColor);
|
||||
opacity: 0.45;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.active {
|
||||
color: var(--chip-color, var(--accent));
|
||||
border-color: var(--chip-color, var(--accent));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--chip-color, var(--accent)) 14%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sl-chips .data-toggle-chip.chip-error {
|
||||
color: #ffb300;
|
||||
border-color: rgba(255, 179, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ── Errors ──────────────────────────────────────────────────────────────── */
|
||||
.sl-error {
|
||||
font-family: var(--font-sans);
|
||||
|
||||
@@ -107,8 +107,13 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sl-providers" role="group" aria-label="Imagery providers">
|
||||
<span class="cctv-summary-label">PROVIDERS</span>
|
||||
<div id="sl-provider-chips" class="sl-chips"></div>
|
||||
</div>
|
||||
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
|
||||
<!-- A missing key gates the filters; the line above says how to add it. -->
|
||||
<!-- 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">
|
||||
<span class="cctv-summary-label">IMAGERY</span>
|
||||
|
||||
Reference in new issue
Block a user