fix(i18n): translate the Street Level and Draw panels

Both panels landed after the original string extraction, so they rendered
English in every locale. Mark their markup and runtime text (status, SINCE
stops, provider tooltips, coverage meta, viewer captions, draw hints and
colours), the layer-list names kept in PANEL_LABELS, and add 90 es and ru
entries. Street Level is «Уличные снимки» / «A pie de calle».

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 18:32:28 +04:00
1 parent eee905e787
commit 6ff7afcc8b
13 files changed
+378 -98

No files matched your search

+5 -1
View File
@@ -1316,7 +1316,11 @@
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs"
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
+16 -8
View File
@@ -1,3 +1,5 @@
import { t, tn } from '../i18n/index.js';
/**
* Manual whiteboard drawing: the pure half.
*
@@ -284,20 +286,26 @@ export function closeRing(pairs) {
/** One line of guidance for the person drawing, by state. */
export function drawHint(session) {
if (!session) return 'Pick a shape, then click the map.';
if (!session) return t('Pick a shape, then click the map.');
const n = session.vertices.length;
if (session.shape === 'pin')
return n
? 'Enter to place the pin, Esc to cancel.'
: 'Click where the pin goes.';
? t('Enter to place the pin, Esc to cancel.')
: t('Click where the pin goes.');
const need = MIN_VERTICES[session.shape] - n;
if (need > 0) return `Click ${need} more point${need === 1 ? '' : 's'}.`;
if (need > 0)
return tn(need, 'Click {count} more point.', 'Click {count} more points.');
if (finishReason(session) === 'degenerate') {
return session.shape === 'area'
? 'Those points are in a line — move one off it to enclose an area.'
: 'That line has no length — click somewhere further away.';
? t('Those points are in a line — move one off it to enclose an area.')
: t('That line has no length — click somewhere further away.');
}
const full =
n >= MAX_VERTICES ? ` · ${MAX_VERTICES}-point limit reached` : '';
return `${formatMeasure(session)} · double-click or Enter to finish, Backspace undoes, Esc cancels.${full}`;
n >= MAX_VERTICES
? t(' · {max}-point limit reached', { max: MAX_VERTICES })
: '';
return t(
'{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}',
{ measure: formatMeasure(session), full },
);
}
+4 -3
View File
@@ -48,6 +48,7 @@ import {
removeLastVertex,
} from './drawMode.js';
import { t } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
@@ -200,7 +201,7 @@ export function initDrawTool({ viewer, annotations }) {
`That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`,
);
else if (reason === 'invalid')
setHint('That point is off the globe — click on the world.');
setHint(t('That point is off the globe — click on the world.'));
};
const onMove = (event) => {
if (!session || session.shape === 'pin' || destroyed) return;
@@ -236,7 +237,7 @@ export function initDrawTool({ viewer, annotations }) {
flyTo: false,
});
if (destroyed || attempt !== generation) return result;
if (result?.drawn === 0) setHint('That shape could not be placed.');
if (result?.drawn === 0) setHint(t('That shape could not be placed.'));
return result;
} catch (error) {
if (destroyed || attempt !== generation) return null;
@@ -258,7 +259,7 @@ export function initDrawTool({ viewer, annotations }) {
if (session) session = createDrawSession(shape);
annotations.clear();
syncPreview();
if (!destroyed && !active) setHint('Board cleared.');
if (!destroyed && !active) setHint(t('Board cleared.'));
};
// ---- keys: only while drawing, never while typing in another field ----
+108 -2
View File
@@ -847,7 +847,7 @@
"Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}",
"Voice model: {pendingId} — click to switch to {target}; applies next session": "Modelo de voz: {pendingId} — haz clic para cambiar a {target}; se aplica en la próxima sesión",
"Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Costo de sesión estimado en {modelId} — {responses} respuesta(s). Avisa a {warn} y cierra la sesión a {cap}.",
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Imágenes y cobertura a nivel de calle en la capa Street Level. Gratis: registra una app en el panel de desarrolladores de Mapillary y pega su Client Token",
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Imágenes y cobertura a nivel de calle en la capa A pie de calle. Gratis: registra una app en el panel de desarrolladores de Mapillary y pega su Client Token",
"Voice control — API key or ChatGPT OAuth": "Control de voz — clave de API u OAuth de ChatGPT",
"WITHIN {km} KM": "A MENOS DE {km} KM",
"Syncing flow · Roads: {source}": "Sincronizando flujo · Vías: {source}",
@@ -873,5 +873,111 @@
"Scanlines": "Líneas de barrido",
"Sensitivity": "Sensibilidad",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow"
"Ironbow": "Ironbow",
"Pick a shape, then click the map.": "Elige una forma y haz clic en el mapa.",
"Enter to place the pin, Esc to cancel.": "Enter para colocar el pin, Esc para cancelar.",
"Click where the pin goes.": "Haz clic donde va el pin.",
"Click {count} more points.": {
"one": "Haz clic en {count} punto más.",
"many": "Haz clic en {count} de puntos más.",
"other": "Haz clic en {count} puntos más."
},
"Those points are in a line — move one off it to enclose an area.": "Esos puntos están en línea: mueve uno fuera de ella para cerrar un área.",
"That line has no length — click somewhere further away.": "Esa línea no tiene longitud: haz clic más lejos.",
" · {max}-point limit reached": " · límite de {max} puntos alcanzado",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · doble clic o Enter para terminar, Backspace deshace, Esc cancela.{full}",
"That point is off the globe — click on the world.": "Ese punto está fuera del globo: haz clic sobre el mundo.",
"That shape could not be placed.": "No se pudo colocar esa forma.",
"Board cleared.": "Tablero borrado.",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Dibuja sobre el mundo: haz clic en los vértices, doble clic o Enter para terminar, Esc para cancelar",
"Draw": "Dibujo",
"Shape": "Forma",
"Shape to draw": "Forma a dibujar",
"Area": "Área",
"Line": "Línea",
"Pin": "Pin",
"Label (optional)": "Etiqueta (opcional)",
"Label for the drawn shape": "Etiqueta de la forma dibujada",
"Colour of the drawn shape": "Color de la forma dibujada",
"Primary": "Principal",
"Amber": "Ámbar",
"Cyan": "Cian",
"Green": "Verde",
"Red": "Rojo",
"Clear": "Borrar",
"Remove every mark from the board": "Quitar todas las marcas del tablero",
"Point the camera at the globe for street-level coverage": "Apunta la cámara al globo para ver la cobertura a pie de calle",
"No street-level imagery providers configured": "No hay proveedores de imágenes a pie de calle configurados",
"Selected": "Seleccionada",
"Live Vessels": "Buques en vivo",
"Bike Share": "Bicis compartidas",
"Cameras": "Cámaras",
"Street Level": "A pie de calle",
"Mapped ALPR Cameras": "Cámaras ALPR cartografiadas",
"Data Centers": "Centros de datos",
"Active Fires": "Incendios activos",
"Street Level could not start": "No se pudo iniciar A pie de calle",
"Street Level toggle failed": "No se pudo cambiar A pie de calle",
"Street-level image": "Imagen a pie de calle",
"SHRINK": "REDUCIR",
"EXPAND": "AMPLIAR",
"Shrink": "Reducir",
"Expand": "Ampliar",
"ANY DATE": "CUALQUIER FECHA",
"LAST 10 YEARS": "ÚLTIMOS 10 AÑOS",
"LAST 5 YEARS": "ÚLTIMOS 5 AÑOS",
"LAST 3 YEARS": "ÚLTIMOS 3 AÑOS",
"LAST 2 YEARS": "ÚLTIMOS 2 AÑOS",
"LAST YEAR": "ÚLTIMO AÑO",
"LAST 6 MONTHS": "ÚLTIMOS 6 MESES",
"LAST 3 MONTHS": "ÚLTIMOS 3 MESES",
"LAST MONTH": "ÚLTIMO MES",
"LAST {count} DAYS": {
"one": "ÚLTIMO {count} DÍA",
"many": "ÚLTIMOS {count} DE DÍAS",
"other": "ÚLTIMOS {count} DÍAS"
},
"{window} · SINCE {date}": "{window} · DESDE {date}",
"Turn Street Level off": "Desactivar A pie de calle",
"Turn Street Level on": "Activar A pie de calle",
"KEY REJECTED": "CLAVE RECHAZADA",
"{name} imagery on": "Imágenes de {name} activadas",
"{name} imagery off": "Imágenes de {name} desactivadas",
"Image by {creator}": "Imagen de {creator}",
"Camera follows view: move the globe camera wherever the street-level view looks": "La cámara sigue la vista: mueve la cámara del globo hacia donde mira la vista a pie de calle",
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Seguir con la cámara requiere el mapa Google 3D: elige Google 3D en FUENTE DEL MAPA",
"Switch a provider on to draw its coverage.": "Activa un proveedor para dibujar su cobertura.",
"Loading this sequence…": "Cargando esta secuencia…",
"{n} images in this sequence · Esc clears": {
"one": "{n} imagen en esta secuencia · Esc la quita",
"many": "{n} de imágenes en esta secuencia · Esc la quita",
"other": "{n} imágenes en esta secuencia · Esc la quita"
},
"{n} sequences in view · click a line for its photos": {
"one": "{n} secuencia a la vista · haz clic en una línea para ver sus fotos",
"many": "{n} de secuencias a la vista · haz clic en una línea para ver sus fotos",
"other": "{n} secuencias a la vista · haz clic en una línea para ver sus fotos"
},
"STREET LEVEL": "A PIE DE CALLE",
"Expand the street-level view (Esc shrinks it)": "Ampliar la vista a pie de calle (Esc la reduce)",
"Image fit": "Ajuste de la imagen",
"FIT": "AJUSTAR",
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajustar: muestra la foto entera, con barras oscuras donde su forma no coincide con el marco",
"FILL": "RELLENAR",
"Fill: fill the frame edge to edge, cropping the photo": "Rellenar: llena el marco de borde a borde, recortando la foto",
"Camera follows view": "La cámara sigue la vista",
"FOLLOW": "SEGUIR",
"Close the image and deselect it on the map": "Cerrar la imagen y quitar la selección en el mapa",
"Close the street-level image": "Cerrar la imagen a pie de calle",
"Street-level viewer": "Visor a pie de calle",
"Open this image on the provider's site": "Abrir esta imagen en el sitio del proveedor",
"Imagery providers": "Proveedores de imágenes",
"PROVIDERS": "PROVEEDORES",
"Imagery filters": "Filtros de imágenes",
"IMAGERY": "IMÁGENES",
"Panorama filter": "Filtro de panoramas",
"ALL": "TODAS",
"FLAT": "PLANAS",
"SINCE": "DESDE",
"Coverage colours": "Colores de cobertura"
}
+112 -2
View File
@@ -73,7 +73,7 @@
"Remove this key from your saved configuration?": "Удалить этот ключ из сохранённой конфигурации?",
"Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.",
"More flight-polling credits (anonymous works without)": "Больше кредитов на опрос рейсов (анонимно работает и без ключа)",
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Street Level». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его Client Token",
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его Client Token",
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
"The photorealistic 3D planet + place search": "Фотореалистичная 3D-планета + поиск мест",
"Places context + Street View fallback; optional separate key": "Контекст Places + резервный Street View; отдельный ключ — по желанию",
@@ -880,5 +880,115 @@
"Scanlines": "Строки развёртки",
"Sensitivity": "Чувствительность",
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow"
"Ironbow": "Ironbow",
"Pick a shape, then click the map.": "Выберите фигуру и щёлкните по карте.",
"Enter to place the pin, Esc to cancel.": "Enter — поставить метку, Esc — отменить.",
"Click where the pin goes.": "Щёлкните, где поставить метку.",
"Click {count} more points.": {
"one": "Щёлкните ещё {count} точку.",
"few": "Щёлкните ещё {count} точки.",
"many": "Щёлкните ещё {count} точек.",
"other": "Щёлкните ещё {count} точки."
},
"Those points are in a line — move one off it to enclose an area.": "Точки лежат на одной линии — сдвиньте одну, чтобы получилась площадь.",
"That line has no length — click somewhere further away.": "У линии нет длины — щёлкните дальше.",
" · {max}-point limit reached": " · достигнут предел в {max} точек",
"{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · двойной щелчок или Enter — завершить, Backspace — отменить точку, Esc — отменить.{full}",
"That point is off the globe — click on the world.": "Эта точка вне глобуса — щёлкните по Земле.",
"That shape could not be placed.": "Не удалось разместить фигуру.",
"Board cleared.": "Доска очищена.",
"Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Рисовать на карте мира — щёлкайте вершины, двойной щелчок или Enter — завершить, Esc — отменить",
"Draw": "Рисование",
"Shape": "Фигура",
"Shape to draw": "Фигура для рисования",
"Area": "Область",
"Line": "Линия",
"Pin": "Метка",
"Label (optional)": "Подпись (необязательно)",
"Label for the drawn shape": "Подпись нарисованной фигуры",
"Colour of the drawn shape": "Цвет нарисованной фигуры",
"Primary": "Основной",
"Amber": "Янтарный",
"Cyan": "Голубой",
"Green": "Зелёный",
"Red": "Красный",
"Clear": "Очистить",
"Remove every mark from the board": "Убрать все пометки с доски",
"Point the camera at the globe for street-level coverage": "Наведите камеру на глобус, чтобы увидеть покрытие уличных снимков",
"No street-level imagery providers configured": "Источники уличных снимков не настроены",
"Selected": "Выбранная",
"Live Vessels": "Суда в реальном времени",
"Bike Share": "Прокат велосипедов",
"Cameras": "Камеры",
"Street Level": "Уличные снимки",
"Mapped ALPR Cameras": "Камеры ALPR на карте",
"Data Centers": "Дата-центры",
"Active Fires": "Активные пожары",
"Street Level could not start": "Не удалось запустить «Уличные снимки»",
"Street Level toggle failed": "Не удалось переключить «Уличные снимки»",
"Street-level image": "Уличный снимок",
"SHRINK": "СВЕРНУТЬ",
"EXPAND": "РАЗВЕРНУТЬ",
"Shrink": "Свернуть",
"Expand": "Развернуть",
"ANY DATE": "ЛЮБАЯ ДАТА",
"LAST 10 YEARS": "ЗА 10 ЛЕТ",
"LAST 5 YEARS": "ЗА 5 ЛЕТ",
"LAST 3 YEARS": "ЗА 3 ГОДА",
"LAST 2 YEARS": "ЗА 2 ГОДА",
"LAST YEAR": "ЗА ГОД",
"LAST 6 MONTHS": "ЗА 6 МЕСЯЦЕВ",
"LAST 3 MONTHS": "ЗА 3 МЕСЯЦА",
"LAST MONTH": "ЗА МЕСЯЦ",
"LAST {count} DAYS": {
"one": "ЗА {count} ДЕНЬ",
"few": "ЗА {count} ДНЯ",
"many": "ЗА {count} ДНЕЙ",
"other": "ЗА {count} ДНЯ"
},
"{window} · SINCE {date}": "{window} · С {date}",
"Turn Street Level off": "Выключить «Уличные снимки»",
"Turn Street Level on": "Включить «Уличные снимки»",
"KEY REJECTED": "КЛЮЧ ОТКЛОНЁН",
"{name} imagery on": "Снимки {name} включены",
"{name} imagery off": "Снимки {name} выключены",
"Image by {creator}": "Автор снимка: {creator}",
"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": "Слежению камеры нужна карта Google 3D: выберите Google 3D в разделе «ИСТОЧНИК КАРТЫ»",
"Switch a provider on to draw its coverage.": "Включите источник, чтобы показать его покрытие.",
"Loading this sequence…": "Загрузка последовательности…",
"{n} images in this sequence · Esc clears": {
"one": "{n} снимок в последовательности · Esc — сбросить",
"few": "{n} снимка в последовательности · Esc — сбросить",
"many": "{n} снимков в последовательности · Esc — сбросить",
"other": "{n} снимка в последовательности · Esc — сбросить"
},
"{n} sequences in view · click a line for its photos": {
"one": "{n} последовательность в кадре · щёлкните линию, чтобы открыть снимки",
"few": "{n} последовательности в кадре · щёлкните линию, чтобы открыть снимки",
"many": "{n} последовательностей в кадре · щёлкните линию, чтобы открыть снимки",
"other": "{n} последовательности в кадре · щёлкните линию, чтобы открыть снимки"
},
"STREET LEVEL": "УЛИЧНЫЕ СНИМКИ",
"Expand the street-level view (Esc shrinks it)": "Развернуть уличный снимок (Esc сворачивает)",
"Image fit": "Масштаб снимка",
"FIT": "ВПИСАТЬ",
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Вписать: показать снимок целиком, с тёмными полосами там, где его форма не совпадает с рамкой",
"FILL": "ЗАПОЛНИТЬ",
"Fill: fill the frame edge to edge, cropping the photo": "Заполнить: растянуть на всю рамку, обрезав снимок",
"Camera follows view": "Камера следует за видом",
"FOLLOW": "СЛЕДИТЬ",
"Close the image and deselect it on the map": "Закрыть снимок и снять выделение на карте",
"Close the street-level image": "Закрыть уличный снимок",
"Street-level viewer": "Просмотр уличных снимков",
"Open this image on the provider's site": "Открыть снимок на сайте источника",
"Imagery providers": "Источники снимков",
"PROVIDERS": "ИСТОЧНИКИ",
"Imagery filters": "Фильтры снимков",
"IMAGERY": "СНИМКИ",
"Panorama filter": "Фильтр панорам",
"ALL": "ВСЕ",
"FLAT": "ПЛОСКИЕ",
"SINCE": "С ДАТЫ",
"Coverage colours": "Цвета покрытия"
}
@@ -33,6 +33,7 @@ import {
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
} from './policy.js';
import { msg } from '../../../../i18n/index.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
@@ -694,8 +695,9 @@ export function createCoverage({ state, source }) {
if (kind === 'overview' && (!bbox || zoom <= 1))
bbox = [-180, -85, 180, 85];
if (zoom == null || !bbox) {
state.coverage.hint =
'Point the camera at the globe for street-level coverage';
state.coverage.hint = msg(
'Point the camera at the globe for street-level coverage',
);
clear();
notify();
return;
+7 -2
View File
@@ -1,5 +1,6 @@
import { COLORS } from './policy.js';
import { msg, t } from '../../i18n/index.js';
/**
* @typedef {object} ProviderSnapshot
* @property {string} id
@@ -29,7 +30,7 @@ export function summarizeCoverage(providers) {
loading: active.some((p) => p.loading),
hint:
providers.length === 0
? 'No street-level imagery providers configured'
? msg('No street-level imagery providers configured')
: active.find((p) => p.hint)?.hint || '',
error: active.find((p) => p.error)?.error || null,
keyRequired:
@@ -63,7 +64,11 @@ export function composeUIState({
color: provider.color,
}));
if (active.length)
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
legend.push({
key: 'selected',
label: t('Selected'),
color: COLORS.selected,
});
return {
enabled,
keyRequired,
+4 -1
View File
@@ -25,7 +25,10 @@ test('Street Level is an ordinary collapsible GEV panel that starts collapsed',
html,
/<button class="panel-collapse-btn" data-collapse-target="street-level-panel"/,
);
assert.match(html, /<span class="panel-title">STREET LEVEL<\/span>/);
assert.match(
html,
/<span class="panel-title" data-i18n>STREET LEVEL<\/span>/,
);
// Provider-neutral header: no vendor mark; the inner restores its scroll.
assert.doesNotMatch(html, /sl-mark|mly-mark/);
assert.match(
+8 -8
View File
@@ -110,19 +110,19 @@ const PANEL_POSITIONS = new Map(
PANEL_ORDER.map(({ id }, index) => [id, index]),
);
const PANEL_LABELS = {
'ais-live-vessels': 'Live Vessels',
bikeshare: 'Bike Share',
cctv: 'Cameras',
'street-level': 'Street Level',
'alpr-cameras': 'Mapped ALPR Cameras',
'local-datacenters': 'Data Centers',
'local-firms': 'Active Fires',
'ais-live-vessels': msg('Live Vessels'),
bikeshare: msg('Bike Share'),
cctv: msg('Cameras'),
'street-level': msg('Street Level'),
'alpr-cameras': msg('Mapped ALPR Cameras'),
'local-datacenters': msg('Data Centers'),
'local-firms': msg('Active Fires'),
};
function panelLabel(layer) {
const key = LAYER_NAME_KEYS[layer.id];
if (key) return t(key);
return PANEL_LABELS[layer.id] || layer.name;
return PANEL_LABELS[layer.id] ? t(PANEL_LABELS[layer.id]) : layer.name;
}
/**
+11 -6
View File
@@ -5,6 +5,7 @@ import {
SINCE_STOPS,
} from './streetLevelPresentation.js';
import { t } from '../i18n/index.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -214,7 +215,7 @@ export class StreetLevelControls {
await this.actions.setEnabled?.(true);
} catch (error) {
this.actions.showToast?.(
error?.message || 'Street Level could not start',
error?.message || t('Street Level could not start'),
);
return false;
}
@@ -226,7 +227,9 @@ export class StreetLevelControls {
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
this.actions.showToast?.(
error?.message || t('Street Level toggle failed'),
);
}
}
@@ -259,7 +262,9 @@ export class StreetLevelControls {
try {
await this.actions.setEnabled?.(false);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
this.actions.showToast?.(
error?.message || t('Street Level toggle failed'),
);
}
}
@@ -311,7 +316,7 @@ export class StreetLevelControls {
wrap.classList.add('sl-viewer-wrap-expanded');
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-label', 'Street-level image');
wrap.setAttribute('aria-label', t('Street-level image'));
wrap.tabIndex = -1;
wrap.focus({ preventScroll: true });
this._watchModal(true);
@@ -343,9 +348,9 @@ export class StreetLevelControls {
const icon = button.querySelector('.sl-btn-icon');
const text = button.querySelector('.sl-btn-text');
if (icon) icon.textContent = on ? '⤡' : '⤢';
if (text) text.textContent = on ? 'SHRINK' : 'EXPAND';
if (text) text.textContent = on ? t('SHRINK') : t('EXPAND');
button.setAttribute('aria-pressed', String(on));
button.setAttribute('aria-label', on ? 'Shrink' : 'Expand');
button.setAttribute('aria-label', on ? t('Shrink') : t('Expand'));
}
if (!on && dock) this.actions.dockPanel?.();
this._requestResize();
+66 -30
View File
@@ -1,4 +1,5 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -6,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. */
@@ -34,13 +35,19 @@ 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),
}),
};
}
@@ -53,18 +60,28 @@ function formatDate(ms) {
}
}
/** The Provider Settings hint for a missing key, or '' for a hidden key. */
function keyRequirement(id) {
const envVars = keySetupRequirementEnvVars(id);
return envVars
? t('Needs {envVars} — add it in Provider Settings', { envVars })
: '';
}
function presentStatus(state) {
const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
const title = pressed
? t('Turn Street Level off')
: t('Turn Street Level on');
if (state.keyRejected)
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
return { text: t('KEY REJECTED'), tone: 'warn', pressed, title };
if (state.keyRequired)
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
return { text: t('KEY REQUIRED'), tone: 'warn', pressed, 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 };
}
/** A chip is lit only while the layer and its provider are both on. */
@@ -73,11 +90,13 @@ 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}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
title = `${provider.name}: ${keyRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
return {
id: provider.id,
@@ -109,7 +128,9 @@ function presentViewer(state) {
open: street.open === true,
loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
captionLeft: street.creator ? `Image by ${street.creator}` : '',
captionLeft: street.creator
? t('Image by {creator}', { creator: street.creator })
: '',
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
@@ -118,22 +139,37 @@ function presentViewer(state) {
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',
? t(
'Camera follows view: move the globe camera wherever the street-level view looks',
)
: t(
'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
),
},
};
}
function presentMeta(state) {
if (state.providers.length === 0) return state.coverage.hint || '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
if (state.providers.length === 0)
return state.coverage.hint ? t(state.coverage.hint) : '';
if (!state.enabled) return t('Switch a provider on to draw its coverage.');
if (state.sequence.selectedId)
return state.sequence.loading
? 'Loading this sequence…'
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
? 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) },
);
if (state.coverage.count > 0)
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
return state.coverage.hint || '';
return tn(
state.coverage.count,
'{n} sequence in view · click a line for its photos',
'{n} sequences in view · click a line for its photos',
{ n: formatNumber(state.coverage.count) },
);
return state.coverage.hint ? t(state.coverage.hint) : '';
}
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
+15 -15
View File
@@ -122,28 +122,28 @@
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false">
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label">Draw</span>
<span class="pp-label" data-i18n>Draw</span>
</button>
<div class="pp-slider-row" id="draw-mode-row">
<span class="pp-slider-mini-label">Shape</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw">
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true">Area</button>
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false">Line</button>
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false">Pin</button>
<span class="pp-slider-mini-label" data-i18n>Shape</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
</div>
</div>
<div class="pp-slider-row" id="draw-label-row">
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape">
<option value="primary">Primary</option>
<option value="amber">Amber</option>
<option value="cyan">Cyan</option>
<option value="green">Green</option>
<option value="red">Red</option>
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
<select class="pp-select draw-color-select" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
<option value="primary" data-i18n>Primary</option>
<option value="amber" data-i18n>Amber</option>
<option value="cyan" data-i18n>Cyan</option>
<option value="green" data-i18n>Green</option>
<option value="red" data-i18n>Red</option>
</select>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board">Clear</button>
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
</div>
</div>
+18 -18
View File
@@ -76,7 +76,7 @@
<div class="panel-glow"></div>
<div class="street-level-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">STREET LEVEL</span>
<span class="panel-title" data-i18n>STREET LEVEL</span>
<span class="panel-divider"></span>
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off">OFF</button>
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel">◀</button>
@@ -86,53 +86,53 @@
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)"><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text">EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit">
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame">FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo">FILL</button>
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame" data-i18n-title data-i18n>FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
</div>
<button id="sl-follow-btn" class="scene-btn" type="button" aria-pressed="false" aria-label="Camera follows view" title="Camera follows view: move the globe camera wherever the street-level view looks"><span class="sl-btn-icon" aria-hidden="true">⌖</span><span class="sl-btn-text">FOLLOW</span></button>
<button id="sl-follow-btn" class="scene-btn" type="button" aria-pressed="false" aria-label="Camera follows view" title="Camera follows view: move the globe camera wherever the street-level view looks" data-i18n-aria-label data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⌖</span><span class="sl-btn-text" data-i18n>FOLLOW</span></button>
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button>
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image" data-i18n-title data-i18n-aria-label>×</button>
</div>
</div>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" hidden></div>
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer"></div>
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer" data-i18n-aria-label></div>
</div>
<div id="sl-image-meta" class="sl-image-meta">
<span id="sl-image-by" class="sl-image-by"></span>
<span class="sl-image-right">
<span id="sl-image-when" class="sl-image-when"></span>
<a id="sl-image-link" class="sl-image-link" href="#" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" hidden></a>
<a id="sl-image-link" class="sl-image-link" href="#" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" data-i18n-title hidden></a>
</span>
</div>
</div>
<div class="sl-settings">
<div class="sl-providers" role="group" aria-label="Imagery providers">
<span class="cctv-summary-label">PROVIDERS</span>
<div class="sl-providers" role="group" aria-label="Imagery providers" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>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 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>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
<div class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
</div>
</div>
<div class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours" data-i18n-aria-label></ul>
<div id="sl-coverage-meta" class="sl-meta"></div>
</div>
</div>