mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge origin/main: Google Street View, KartaView and Panoramax on the slim Street Level
Main gained three imagery providers (Google Street View, KartaView,
Panoramax), shared line-coverage, cone and photo-viewer modules, one source
lit at a time and the translated interface. Their shared modules still
carried what this branch removes, so the merge ports them onto it:
- lineCoverage.js is this branch's coverage.js made provider-neutral
(draped lines near the ground only, the selection drawn over them, key
and rate-limit blocking); sequenceCones.js is sequences.js the same way.
Mapillary, KartaView and Panoramax all build on them; Mapillary moves
back to providers/mapillary/ beside the others.
- No ground casting, mesh sampling, surface modes, FOLLOW, legend, prewarm,
orbit overview points (Panoramax's density dots included), stale-tile
swaps or water-filled point budget come back.
- Kept from main because the new providers need them: requiresMapStack and
attachMapStackController (Street View on Google 3D only), ground clicks,
openNearest with an optional provider nearestImage, openImage {frame},
reportError, the view-cone marker (now an SVG glyph), filters /
coverageLabel / sinceBasis, OPEN STREET VIEW, keyAlternatives and i18n.
- photoViewer.js treats the viewer as expanded when its wrap is full screen
or the iPhone popover, as EXPAND now is.
- The fixture browser gate keeps its 8 steps and adds one each for
KartaView and Panoramax; catalogs, docs, boundaries and format scope
follow.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
6390a3346f
809 files changed
+81825
-6035
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import { keyStatusLabel } from '../layers/streetLevel/policy.js';
|
||||
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
|
||||
|
||||
// Street Level UI state to panel strings and flags. Pure, so it is testable.
|
||||
|
||||
@@ -7,15 +7,15 @@ 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' }),
|
||||
Object.freeze({ days: 0, label: msg('ANY DATE') }),
|
||||
Object.freeze({ days: 3652, label: msg('LAST 10 YEARS') }),
|
||||
Object.freeze({ days: 1826, label: msg('LAST 5 YEARS') }),
|
||||
Object.freeze({ days: 1095, label: msg('LAST 3 YEARS') }),
|
||||
Object.freeze({ days: 730, label: msg('LAST 2 YEARS') }),
|
||||
Object.freeze({ days: 365, label: msg('LAST YEAR') }),
|
||||
Object.freeze({ days: 182, label: msg('LAST 6 MONTHS') }),
|
||||
Object.freeze({ days: 91, label: msg('LAST 3 MONTHS') }),
|
||||
Object.freeze({ days: 30, label: msg('LAST MONTH') }),
|
||||
]);
|
||||
|
||||
/** The exact stop for a day count, else the nearest one. */
|
||||
@@ -35,35 +35,99 @@ export function sinceStopIndex(days) {
|
||||
function presentSince(days, now) {
|
||||
const value = Number(days) || 0;
|
||||
const index = sinceStopIndex(value);
|
||||
if (value <= 0) return { index, days: 0, label: 'ANY DATE' };
|
||||
if (value <= 0) return { index, days: 0, label: t('ANY DATE') };
|
||||
const stop = SINCE_STOPS[index];
|
||||
const window = stop.days === value ? stop.label : `LAST ${value} DAYS`;
|
||||
const window =
|
||||
stop.days === value
|
||||
? t(stop.label)
|
||||
: tn(value, 'LAST {count} DAY', 'LAST {count} DAYS');
|
||||
return {
|
||||
index,
|
||||
days: value,
|
||||
label: `${window} · SINCE ${formatDate(now - value * DAY_MS)}`,
|
||||
label: t('{window} · SINCE {date}', {
|
||||
window,
|
||||
date: formatDate(now - value * DAY_MS),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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) => keyRequirementText(p.requiresKeyId))
|
||||
.filter(Boolean);
|
||||
return keyGuidanceText(
|
||||
[...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';
|
||||
const key = keyStatusLabel(state);
|
||||
if (key) return { text: key, tone: 'warn', pressed, title };
|
||||
const title = pressed
|
||||
? t('Turn Street Level off')
|
||||
: t('Turn Street Level on');
|
||||
if (state.keyRejected)
|
||||
return { text: t('KEY REJECTED'), tone: 'warn', pressed, title };
|
||||
if (state.keyRequired)
|
||||
return {
|
||||
text: t('KEY REQUIRED'),
|
||||
tone: 'warn',
|
||||
pressed,
|
||||
title: keyGuidance(state) || title,
|
||||
};
|
||||
if (state.coverage.loading)
|
||||
return { text: 'LOADING', tone: 'busy', pressed, title };
|
||||
return { text: t('LOADING'), tone: 'busy', pressed, title };
|
||||
return pressed
|
||||
? { text: 'ON', tone: 'on', pressed, title }
|
||||
: { text: 'OFF', tone: '', pressed, title };
|
||||
? { text: t('ON'), tone: 'on', pressed, title }
|
||||
: { text: t('OFF'), tone: '', pressed, title };
|
||||
}
|
||||
|
||||
// Provider refusals with a number stay English data (kartaview and panoramax
|
||||
// source.js); they go back onto a placeholder here.
|
||||
const ERROR_PATTERNS = [
|
||||
[
|
||||
/^KartaView answered HTTP (\d+)$/,
|
||||
([, status]) => t('KartaView answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^Panoramax answered HTTP (\d+)$/,
|
||||
([, status]) => t('Panoramax answered HTTP {status}', { status }),
|
||||
],
|
||||
[
|
||||
/^KartaView refused the request \(code (-?\d+)\)$/,
|
||||
([, code]) => t('KartaView refused the request (code {code})', { code }),
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* A provider or viewer error in the UI language. Exported for tests.
|
||||
* @param {string} text English error text from a provider.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function translateStreetLevelError(text) {
|
||||
if (typeof text !== 'string' || !text) return text;
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** A chip is lit only while the layer and its provider are both on. */
|
||||
@@ -72,15 +136,18 @@ function presentProviders(state) {
|
||||
return (state.providers || []).map((provider) => {
|
||||
const keyRequired = provider.keyRequired === true;
|
||||
const on = enabled && provider.on === true;
|
||||
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`;
|
||||
let title = on
|
||||
? t('{name} imagery on', { name: provider.name })
|
||||
: t('{name} imagery off', { name: provider.name });
|
||||
if (provider.keyRejected && provider.error)
|
||||
title = `${provider.name}: ${provider.error}`;
|
||||
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
|
||||
else if (keyRequired && provider.requiresKeyId)
|
||||
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
|
||||
else if (provider.error) title = `${provider.name}: ${provider.error}`;
|
||||
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
|
||||
else if (provider.error)
|
||||
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
|
||||
return {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
label: translateKnown(provider.label),
|
||||
color: provider.color || null,
|
||||
title,
|
||||
active: on,
|
||||
@@ -97,53 +164,136 @@ function presentProviders(state) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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));
|
||||
if (street.capturedAt)
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
if (street.title) left.push(street.title);
|
||||
if (street.creator)
|
||||
left.push(t('Image by {creator}', { creator: street.creator }));
|
||||
if (street.license) left.push(street.license);
|
||||
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} ↗` : '',
|
||||
linkLabel: street.providerLabel
|
||||
? `${translateKnown(street.providerLabel)} ↗`
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 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) => translateKnown(provider.name))
|
||||
.join(t(' or '));
|
||||
const labels = targets
|
||||
.map((provider) => translateKnown(provider.label))
|
||||
.join(' / ');
|
||||
const ready = state.groundClickReady === true;
|
||||
return {
|
||||
visible: true,
|
||||
label: t('OPEN {labels}', { labels }),
|
||||
title: ready
|
||||
? t('Open {names} at the centre of the view', { names })
|
||||
: t('Zoom in to a street to open {names} here', { names }),
|
||||
disabled: !ready || state.street.loading === true,
|
||||
providerIds: targets.map((provider) => provider.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider hints are English data joined by " · "; translate each part. */
|
||||
function translateHint(hint) {
|
||||
return hint ? hint.split(' · ').map(translateKnown).join(' · ') : '';
|
||||
}
|
||||
|
||||
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 (providers.length === 0) return translateHint(state.coverage.hint);
|
||||
if (!state.enabled) return t('Switch Street Level on to draw its coverage.');
|
||||
if (!providers.some((p) => p.on))
|
||||
return 'Every provider is switched off: light a chip to draw its coverage.';
|
||||
return t(
|
||||
'Every provider is switched off: light a chip 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`;
|
||||
if (state.coverage.count > 0)
|
||||
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
|
||||
if (state.coverage.hint) return state.coverage.hint;
|
||||
? t('Loading this sequence…')
|
||||
: tn(
|
||||
state.sequence.images,
|
||||
'{n} image in this sequence · Esc clears',
|
||||
'{n} images in this sequence · Esc clears',
|
||||
{ n: formatNumber(state.sequence.images) },
|
||||
);
|
||||
const hint = translateHint(state.coverage.hint);
|
||||
if (state.coverage.count > 0) {
|
||||
const countLine = coverageCountLine(state.coverage);
|
||||
return [countLine, hint].filter(Boolean).join(' · ');
|
||||
}
|
||||
if (hint) return hint;
|
||||
// Still loading, or the error line already says why.
|
||||
if (state.coverage.loading || state.keyRequired || state.coverage.error)
|
||||
return '';
|
||||
const filter = state.filter || {};
|
||||
return (filter.pano || 'all') !== 'all' || Number(filter.sinceDays) > 0
|
||||
? 'No sequences in view match the filter'
|
||||
: 'No coverage in view';
|
||||
? t('No sequences in view match the filter')
|
||||
: t('No coverage in view');
|
||||
}
|
||||
|
||||
/**
|
||||
* The coverage count line. Providers carry their wording as data
|
||||
* (coverageLabel noun/action); each combination needs its own literal tn
|
||||
* pair so every language gets real plural forms — the noun cannot inflect
|
||||
* through a placeholder in Russian. New wording: add a pair here and to the
|
||||
* catalogs (es.json/ru.json).
|
||||
* @param {{count: number, label?: {noun?: string, action?: string}|null}} coverage
|
||||
*/
|
||||
function coverageCountLine(coverage) {
|
||||
const count = coverage.count;
|
||||
const values = { n: formatNumber(count) };
|
||||
if (coverage.label?.action === 'click a line for its nearest photo')
|
||||
return coverage.label.noun === 'streets'
|
||||
? tn(
|
||||
count,
|
||||
'{n} street in view · click a line for its nearest photo',
|
||||
'{n} streets in view · click a line for its nearest photo',
|
||||
values,
|
||||
)
|
||||
: tn(
|
||||
count,
|
||||
'{n} sequence in view · click a line for its nearest photo',
|
||||
'{n} sequences in view · click a line for its nearest photo',
|
||||
values,
|
||||
);
|
||||
return tn(
|
||||
count,
|
||||
'{n} sequence in view · click a line for its photos',
|
||||
'{n} sequences in view · click a line for its photos',
|
||||
values,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
|
||||
@@ -159,14 +309,22 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
|
||||
controlsDisabled: keyRequired || (state.providers || []).length === 0,
|
||||
providers: presentProviders(state),
|
||||
error: keyMissing
|
||||
? keyHint(state)
|
||||
: state.street.error ||
|
||||
state.sequence.error ||
|
||||
state.coverage.error ||
|
||||
null,
|
||||
? keyGuidance(state) || null
|
||||
: translateStreetLevelError(
|
||||
state.street.error ||
|
||||
state.sequence.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,
|
||||
viewer: presentViewer(state),
|
||||
meta: presentMeta(state),
|
||||
nearest: presentNearest(state),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user