mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
KartaView's server filters by upload date only, so SINCE goes by it throughout for KartaView: the lines (as before), the photo a click opens and the cones, the cut-off taken as its UTC day as KartaView's startDate (passesKartaViewFilter). Providers may declare sinceBasis: 'upload' (validated); while one is lit the panel shows "KartaView: by upload date" under the SINCE slider, read out with it. The gate checks the note, and step 13's picks get 60 s on a loaded machine. Co-Authored-By: Claude Opus 5.5 <[email protected]>
235 lines
8.0 KiB
JavaScript
235 lines
8.0 KiB
JavaScript
import {
|
|
keySetupRequirement,
|
|
keySetupWithAlternatives,
|
|
} from '../keySetupCore.mjs';
|
|
|
|
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
|
|
|
const DAY_MS = 86_400_000;
|
|
|
|
/** SINCE slider stops, in relative days so a share link keeps its meaning. */
|
|
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' }),
|
|
]);
|
|
|
|
/** The exact stop for a day count, 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;
|
|
}
|
|
|
|
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)}`,
|
|
};
|
|
}
|
|
|
|
/** 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, 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: keyGuidance(state) || 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 };
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
});
|
|
}
|
|
|
|
function presentViewer(state) {
|
|
const { street } = state;
|
|
const right = [];
|
|
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, 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',
|
|
renderModes: street.renderModes !== false,
|
|
captionLeft: left.join(' · '),
|
|
captionRight: right.join(' · '),
|
|
link: street.externalUrl || null,
|
|
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
|
follow: {
|
|
pressed: street.follow === true,
|
|
disabled: street.open !== true || street.followAvailable !== true,
|
|
title:
|
|
street.followAvailable === true
|
|
? 'Camera follows view: move the globe camera wherever the street-level view looks'
|
|
: 'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 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()} ${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. */
|
|
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 };
|
|
// A missing key is already shown by the status and chip tooltip; only a
|
|
// rejected key keeps its error line, since that message names the fix.
|
|
const keyMissing = keyRequired && state.keyRejected !== true;
|
|
return {
|
|
enabled,
|
|
keyRequired,
|
|
status: presentStatus(state),
|
|
controlsDisabled: keyRequired || state.providers.length === 0,
|
|
providers: presentProviders(state),
|
|
error: keyMissing
|
|
? 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,
|
|
};
|
|
}
|