Files
gods-eye-view/src/ui/streetLevelPresentation.js
T
Sameh Khamis 0e90a03de7 Round every heading readout through one shared helper
The transit, ALPR, vessel, CCTV and flight/military context fixes each
wrapped 359.5° to north with an inline Math.round(x) % 360. They now call
roundHeadingDegrees in src/data/headingDegrees.js, which also wraps negative
and multi-turn bearings. normalizeHeading moves there with it, and
cockpitMath re-exports it so existing imports keep working. It now adds a
turn only to a negative remainder, so a bearing just under half a degree
keeps its precision.

The awareness, Local ADS-B, cockpit wind, cockpit heading and Street Level
readouts use the same helper. The Cockpit's TRACK ACQUIRED signal rounded
without wrapping and could read COURSE 360°; it now uses
formatHeadingReadout, which shows --- for a track that is not finite.
2026-11-01 21:41:49 -08:00

345 lines
12 KiB
JavaScript

import {
formatNumber,
isolate,
msg,
t,
tn,
translateKnown,
} from '../i18n/index.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
import { roundHeadingDegrees } from '../data/headingDegrees.js';
// 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: 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. */
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: t('ANY DATE') };
const stop = SINCE_STOPS[index];
const window =
stop.days === value
? t(stop.label)
: tn(value, 'LAST {count} DAY', 'LAST {count} DAYS');
return {
index,
days: value,
label: t('{window} · SINCE {date}', {
window,
date: 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) => keyRequirementText(p.requiresKeyId))
.filter(Boolean);
return keyGuidanceText(
[...new Set(needs)].join('; '),
state.keyAlternatives || [],
);
}
function presentStatus(state) {
const pressed = state.enabled === true;
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: t('LOADING'), tone: 'busy', pressed, title };
return pressed
? { 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. */
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 = on
? t('{name} imagery on', { name: provider.name })
: t('{name} imagery off', { name: provider.name });
if (provider.keyRejected && provider.error)
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
else if (provider.error)
title = `${provider.name}: ${translateStreetLevelError(provider.error)}`;
return {
id: provider.id,
label: translateKnown(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(`${roundHeadingDegrees(street.bearing)}°`);
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
// A provider address mixes Latin and the page language ("602 Congress Ave,
// أوستن"); each part is isolated so a house number stays with its street
// in right-to-left text. A no-op in left-to-right languages.
if (street.title) left.push(street.title.split(', ').map(isolate).join(', '));
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',
renderModes: street.renderModes !== false,
captionLeft: left.join(' · '),
captionRight: right.join(' · '),
link: street.externalUrl || null,
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 translateHint(state.coverage.hint);
if (!state.enabled) return t('Switch Street Level on to draw its coverage.');
// A lit source that cannot run on this map is not listed: the core's hint
// says which map it needs.
if (!providers.some((p) => p.on))
return (
translateHint(state.coverage.hint) ||
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
? 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
? 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. */
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 = keyRequired && state.keyRejected !== true;
return {
enabled: state.enabled === true,
keyRequired,
status: presentStatus(state),
controlsDisabled: keyRequired || (state.providers || []).length === 0,
providers: presentProviders(state),
error: keyMissing
? 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),
};
}