mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(street-level): movable, simplified panel with the photo first
- The viewer opens at the top of the panel, under the header, so the photo is on screen without scrolling; its tools (EXPAND, FIT/FILL, FOLLOW, close) sit in a bar above the image, clear of the provider's own overlays, and the caption wraps instead of truncating. - No separate ON/OFF or OPEN NEAREST PHOTO buttons: the header pill is the layer switch, and a provider chip is lit only while the layer is on (the last lit chip switches the layer off). - SINCE is a stepped slider (any date to the last month) whose readout names the cut-off date and is announced via aria-valuetext. - The panel floats and resizes (portable spec); in a resized window the viewer takes the spare height and only the settings scroll. SHRINK after EXPAND, collapsing, or a header double-click docks it at its default size. - FIT/FILL tooltips say what they do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c69dd2e89c
commit
5877f24f0b
11 files changed
+740
-208
No files matched your search
@@ -5,15 +5,53 @@ import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
|
||||
*/
|
||||
|
||||
/** Relative "captured since" windows the panel offers, in days. */
|
||||
export const SINCE_OPTIONS = Object.freeze([
|
||||
Object.freeze({ days: 0, label: 'any date' }),
|
||||
Object.freeze({ days: 365, label: 'last year' }),
|
||||
Object.freeze({ days: 730, label: '2 years' }),
|
||||
Object.freeze({ days: 1826, label: '5 years' }),
|
||||
Object.freeze({ days: 3652, label: '10 years' }),
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Stops of the SINCE slider, oldest window on the left: position 0 shows
|
||||
* every capture, the right end only the last month. Stored as relative days
|
||||
* so a share link keeps its meaning over time.
|
||||
*/
|
||||
export const SINCE_STOPS = Object.freeze([
|
||||
Object.freeze({ days: 0, label: 'ANY DATE' }),
|
||||
Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }),
|
||||
Object.freeze({ days: 1826, label: 'LAST 5 YEARS' }),
|
||||
Object.freeze({ days: 1095, label: 'LAST 3 YEARS' }),
|
||||
Object.freeze({ days: 730, label: 'LAST 2 YEARS' }),
|
||||
Object.freeze({ days: 365, label: 'LAST YEAR' }),
|
||||
Object.freeze({ days: 182, label: 'LAST 6 MONTHS' }),
|
||||
Object.freeze({ days: 91, label: 'LAST 3 MONTHS' }),
|
||||
Object.freeze({ days: 30, label: 'LAST MONTH' }),
|
||||
]);
|
||||
|
||||
/** Slider position for a day count: the exact stop, else the nearest one. */
|
||||
export function sinceStopIndex(days) {
|
||||
const value = Number(days) || 0;
|
||||
if (value <= 0) return 0;
|
||||
let best = 1;
|
||||
for (let i = 1; i < SINCE_STOPS.length; i++)
|
||||
if (
|
||||
Math.abs(SINCE_STOPS[i].days - value) <
|
||||
Math.abs(SINCE_STOPS[best].days - value)
|
||||
)
|
||||
best = i;
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Readout beside the slider: the window, plus the cut-off date it means today. */
|
||||
function presentSince(days, now) {
|
||||
const value = Number(days) || 0;
|
||||
const index = sinceStopIndex(value);
|
||||
if (value <= 0) return { index, days: 0, label: 'ANY DATE' };
|
||||
const stop = SINCE_STOPS[index];
|
||||
const window = stop.days === value ? stop.label : `LAST ${value} DAYS`;
|
||||
return {
|
||||
index,
|
||||
days: value,
|
||||
label: `${window} · SINCE ${formatDate(now - value * DAY_MS)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(ms) {
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
try {
|
||||
@@ -23,17 +61,30 @@ function formatDate(ms) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The header pill doubles as the layer's on/off switch. */
|
||||
function presentStatus(state) {
|
||||
if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' };
|
||||
if (state.coverage.loading) return { text: 'LOADING', tone: 'busy' };
|
||||
return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' };
|
||||
const pressed = state.enabled === true;
|
||||
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
|
||||
if (state.keyRequired)
|
||||
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
|
||||
if (state.coverage.loading)
|
||||
return { text: 'LOADING', tone: 'busy', pressed, title };
|
||||
return pressed
|
||||
? { text: 'ON', tone: 'on', pressed, title }
|
||||
: { text: 'OFF', tone: '', pressed, title };
|
||||
}
|
||||
|
||||
/** One chip per registered provider; a keyless provider reads as an error chip. */
|
||||
/**
|
||||
* One chip per registered provider; a keyless provider reads as an error
|
||||
* chip. A chip is lit only while the layer is on and that provider is
|
||||
* switched on, so with a single provider the chip is the layer's switch.
|
||||
*/
|
||||
function presentProviders(state) {
|
||||
const enabled = state.enabled === true;
|
||||
return (state.providers || []).map((provider) => {
|
||||
const keyRequired = provider.keyRequired === true;
|
||||
let title = `${provider.name} imagery ${provider.on ? 'on' : 'off'}`;
|
||||
const on = enabled && provider.on === true;
|
||||
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`;
|
||||
if (keyRequired && provider.requiresKeyId)
|
||||
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
|
||||
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
||||
@@ -41,16 +92,16 @@ function presentProviders(state) {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
title,
|
||||
active: provider.on === true,
|
||||
active: on,
|
||||
disabled: false,
|
||||
state: keyRequired
|
||||
? 'error'
|
||||
: provider.loading
|
||||
: on && provider.loading
|
||||
? 'loading'
|
||||
: provider.on
|
||||
: on
|
||||
? 'active'
|
||||
: 'idle',
|
||||
busy: provider.loading === true,
|
||||
busy: on && provider.loading === true,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -78,7 +129,7 @@ function presentViewer(state) {
|
||||
}
|
||||
|
||||
function presentMeta(state) {
|
||||
if (!state.enabled) return '';
|
||||
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
|
||||
if (state.sequence.selectedId)
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
@@ -90,9 +141,10 @@ function presentMeta(state) {
|
||||
|
||||
/**
|
||||
* @param {object} state Snapshot from the layer's `getUIState()`.
|
||||
* @param {{now?: number}} [options] Clock for the SINCE readout (tests pin it).
|
||||
* @returns {object} Everything the panel needs, already worded.
|
||||
*/
|
||||
export function presentStreetLevelPanel(state) {
|
||||
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
const enabled = state.enabled === true;
|
||||
const keyRequired = state.keyRequired === true;
|
||||
const filter = state.filter || { pano: 'all', sinceDays: 0 };
|
||||
@@ -101,13 +153,10 @@ export function presentStreetLevelPanel(state) {
|
||||
keyRequired,
|
||||
status: presentStatus(state),
|
||||
controlsDisabled: keyRequired,
|
||||
enableButton: {
|
||||
text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF',
|
||||
pressed: enabled,
|
||||
},
|
||||
providers: presentProviders(state),
|
||||
error: state.street.error || state.coverage.error || null,
|
||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||
since: presentSince(filter.sinceDays, now),
|
||||
legend: state.legend || [],
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
|
||||
Reference in new issue
Block a user