From 40d88395899cce0bf8d1a6b8c6d9b55f6bc6d905 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:36:48 +0530 Subject: [PATCH] fix(voice): localize the area, imagery and OSM cards and tidy OSM Places From review: - Card lines the actions write in English (area basis, part counts, merged duplicates, OSM caveats, the imagery window) are shown in the UI language through the analyst card's note path; the model still gets the English. One OSM place reads "1 hospital". - Same-named clarification choices show their region code (WA, DC). - OSM Places shows as unavailable once the server says no Overpass is configured, and its points are depth-tested past 2.5 km so they never show through the globe. - find_imagery puts the operator's sources and mode back when a box is refused, and a voice search cancelled mid-flight leaves "Search cancelled" instead of an empty strip. - The README lists OSM Places with the other Utilities. --- README.md | 3 +- src/i18n/locales/ar.json | 36 ++++++++- src/i18n/locales/de.json | 24 +++++- src/i18n/locales/es.json | 27 ++++++- src/i18n/locales/fr.json | 27 ++++++- src/i18n/locales/it.json | 27 ++++++- src/i18n/locales/ja.json | 21 ++++- src/i18n/locales/pt.json | 27 ++++++- src/i18n/locales/ru.json | 30 ++++++- src/i18n/locales/uk.json | 30 ++++++- src/i18n/locales/zh-CN.json | 21 ++++- src/i18n/locales/zh-Hant.json | 21 ++++- src/layers/osmPlaces/index.js | 31 +++++++- src/layers/osmPlaces/index.test.mjs | 50 ++++++++++++ src/layers/recentImagery/index.js | 2 + src/layers/recentImagery/index.test.mjs | 10 +++ src/ui/recentImagery.js | 1 + src/voice/imageryActions.js | 4 + src/voice/imageryActions.test.mjs | 13 +++ src/voice/osmActions.js | 2 + src/voice/resultDisplay.js | 92 +++++++++++++++++++--- src/voice/voiceCardPlanSteps.i18n.test.mjs | 63 +++++++++++++++ 22 files changed, 535 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 47cdf9535..4e96532d9 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | +| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | @@ -454,7 +455,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi **Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view. -In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions sits in the separate **Utilities** panel with Draw and Import. +In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Events, Weather and Audio; Directions and OSM Places sit in the separate **Utilities** panel with Draw and Import. **The basemap ladder — what each tier buys you:** diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 9e5e9872d..d00570f9c 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3270,5 +3270,39 @@ "Searching imagery catalog": "جارٍ البحث في فهرس الصور", "Loading imagery": "جارٍ تحميل الصور", "Searching OpenStreetMap": "جارٍ البحث في OpenStreetMap", - "voice area|Area": "منطقة" + "voice area|Area": "منطقة", + "buffer sized to the place": "منطقة تقريبية بحجم المكان", + "enclosing mapped area": "المنطقة المرسومة المحيطة", + "the map was cleared while looking it up": "مُسحت الخريطة أثناء البحث", + "OpenStreetMap coverage varies by place": "تختلف تغطية OpenStreetMap من مكان لآخر", + "the catalog covers the last 30 days": "يغطي الفهرس آخر 30 يومًا", + "there are more": "هناك المزيد", + "{radius} m around the landmark": "{radius} م حول المعلم", + "{count} parts": { + "few": "الأجزاء: {count}", + "many": "الأجزاء: {count}", + "one": "الأجزاء: {count}", + "other": "الأجزاء: {count}", + "two": "الأجزاء: {count}", + "zero": "الأجزاء: {count}" + }, + "{drawn} of {total} parts drawn": { + "few": "الأجزاء المرسومة: {drawn} من {total}", + "many": "الأجزاء المرسومة: {drawn} من {total}", + "one": "الأجزاء المرسومة: {drawn} من {total}", + "other": "الأجزاء المرسومة: {drawn} من {total}", + "two": "الأجزاء المرسومة: {drawn} من {total}", + "zero": "الأجزاء المرسومة: {drawn} من {total}" + }, + "{count} duplicate mappings merged": { + "few": "التكرارات المدمجة: {count}", + "many": "التكرارات المدمجة: {count}", + "one": "التكرارات المدمجة: {count}", + "other": "التكرارات المدمجة: {count}", + "two": "التكرارات المدمجة: {count}", + "zero": "التكرارات المدمجة: {count}" + }, + "listed the first {limit}": "عُرض أول {limit}", + "counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود", + "Search cancelled": "أُلغي البحث" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bbfdea559..acf289099 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2982,5 +2982,27 @@ "Searching imagery catalog": "Bildkatalog wird durchsucht", "Loading imagery": "Bilddaten werden geladen", "Searching OpenStreetMap": "OpenStreetMap wird durchsucht", - "voice area|Area": "Gebiet" + "voice area|Area": "Gebiet", + "buffer sized to the place": "Puffer in Größe des Ortes", + "enclosing mapped area": "umschließendes kartiertes Gebiet", + "the map was cleared while looking it up": "die Karte wurde während der Suche geleert", + "OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich", + "the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage", + "there are more": "es gibt weitere", + "{radius} m around the landmark": "{radius} m um das Wahrzeichen", + "{count} parts": { + "one": "Teile: {count}", + "other": "Teile: {count}" + }, + "{drawn} of {total} parts drawn": { + "one": "Gezeichnete Teile: {drawn} von {total}", + "other": "Gezeichnete Teile: {drawn} von {total}" + }, + "{count} duplicate mappings merged": { + "one": "Zusammengeführte Doppelkartierungen: {count}", + "other": "Zusammengeführte Doppelkartierungen: {count}" + }, + "listed the first {limit}": "die ersten {limit} aufgelistet", + "counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss", + "Search cancelled": "Suche abgebrochen" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 37c887e60..85e57c389 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3054,5 +3054,30 @@ "Searching imagery catalog": "Buscando en el catálogo de imágenes", "Loading imagery": "Cargando imágenes", "Searching OpenStreetMap": "Buscando en OpenStreetMap", - "voice area|Area": "Área" + "voice area|Area": "Área", + "buffer sized to the place": "zona aproximada del tamaño del lugar", + "enclosing mapped area": "zona cartografiada que lo rodea", + "the map was cleared while looking it up": "el mapa se borró durante la búsqueda", + "OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar", + "the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días", + "there are more": "hay más", + "{radius} m around the landmark": "{radius} m alrededor del lugar", + "{count} parts": { + "many": "Partes: {count}", + "one": "Partes: {count}", + "other": "Partes: {count}" + }, + "{drawn} of {total} parts drawn": { + "many": "Partes dibujadas: {drawn} de {total}", + "one": "Partes dibujadas: {drawn} de {total}", + "other": "Partes dibujadas: {drawn} de {total}" + }, + "{count} duplicate mappings merged": { + "many": "Duplicados fusionados: {count}", + "one": "Duplicados fusionados: {count}", + "other": "Duplicados fusionados: {count}" + }, + "listed the first {limit}": "se listaron los primeros {limit}", + "counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno", + "Search cancelled": "Búsqueda cancelada" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 80f0239b6..efc48b3fc 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3054,5 +3054,30 @@ "Searching imagery catalog": "Recherche dans le catalogue d'images", "Loading imagery": "Chargement des images", "Searching OpenStreetMap": "Recherche dans OpenStreetMap", - "voice area|Area": "Zone" + "voice area|Area": "Zone", + "buffer sized to the place": "zone tampon à la taille du lieu", + "enclosing mapped area": "zone cartographiée englobante", + "the map was cleared while looking it up": "la carte a été effacée pendant la recherche", + "OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux", + "the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours", + "there are more": "il y en a d'autres", + "{radius} m around the landmark": "{radius} m autour du lieu", + "{count} parts": { + "many": "Parties : {count}", + "one": "Parties : {count}", + "other": "Parties : {count}" + }, + "{drawn} of {total} parts drawn": { + "many": "Parties tracées : {drawn} sur {total}", + "one": "Parties tracées : {drawn} sur {total}", + "other": "Parties tracées : {drawn} sur {total}" + }, + "{count} duplicate mappings merged": { + "many": "Doublons fusionnés : {count}", + "one": "Doublons fusionnés : {count}", + "other": "Doublons fusionnés : {count}" + }, + "listed the first {limit}": "les {limit} premiers sont listés", + "counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour", + "Search cancelled": "Recherche annulée" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index e92227ce8..8a22e33c7 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3054,5 +3054,30 @@ "Searching imagery catalog": "Ricerca nel catalogo immagini", "Loading imagery": "Caricamento immagini", "Searching OpenStreetMap": "Ricerca in OpenStreetMap", - "voice area|Area": "Area" + "voice area|Area": "Area", + "buffer sized to the place": "zona tampone delle dimensioni del luogo", + "enclosing mapped area": "area mappata circostante", + "the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca", + "OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo", + "the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni", + "there are more": "ce ne sono altri", + "{radius} m around the landmark": "{radius} m attorno al luogo", + "{count} parts": { + "many": "Parti: {count}", + "one": "Parti: {count}", + "other": "Parti: {count}" + }, + "{drawn} of {total} parts drawn": { + "many": "Parti disegnate: {drawn} su {total}", + "one": "Parti disegnate: {drawn} su {total}", + "other": "Parti disegnate: {drawn} su {total}" + }, + "{count} duplicate mappings merged": { + "many": "Duplicati uniti: {count}", + "one": "Duplicati uniti: {count}", + "other": "Duplicati uniti: {count}" + }, + "listed the first {limit}": "elencati i primi {limit}", + "counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno", + "Search cancelled": "Ricerca annullata" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9bc9ffb27..ef064b125 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2910,5 +2910,24 @@ "Searching imagery catalog": "画像カタログを検索中", "Loading imagery": "画像を読み込み中", "Searching OpenStreetMap": "OpenStreetMapを検索中", - "voice area|Area": "エリア" + "voice area|Area": "エリア", + "buffer sized to the place": "場所の大きさに合わせたバッファ", + "enclosing mapped area": "周囲の地図上のエリア", + "the map was cleared while looking it up": "検索中に地図がクリアされました", + "OpenStreetMap coverage varies by place": "OpenStreetMap の網羅度は場所によって異なります", + "the catalog covers the last 30 days": "カタログは過去 30 日分です", + "there are more": "ほかにもあります", + "{radius} m around the landmark": "ランドマークの周囲 {radius} m", + "{count} parts": { + "other": "パーツ数: {count}" + }, + "{drawn} of {total} parts drawn": { + "other": "描画したパーツ: {drawn}/{total}" + }, + "{count} duplicate mappings merged": { + "other": "統合した重複: {count}" + }, + "listed the first {limit}": "最初の {limit} 件を表示", + "counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません", + "Search cancelled": "検索をキャンセルしました" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7bacd7862..0201010a3 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3054,5 +3054,30 @@ "Searching imagery catalog": "Buscando no catálogo de imagens", "Loading imagery": "Carregando imagens", "Searching OpenStreetMap": "Buscando no OpenStreetMap", - "voice area|Area": "Área" + "voice area|Area": "Área", + "buffer sized to the place": "zona de margem do tamanho do local", + "enclosing mapped area": "área mapeada envolvente", + "the map was cleared while looking it up": "o mapa foi limpo durante a busca", + "OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar", + "the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias", + "there are more": "há mais", + "{radius} m around the landmark": "{radius} m ao redor do local", + "{count} parts": { + "many": "Partes: {count}", + "one": "Partes: {count}", + "other": "Partes: {count}" + }, + "{drawn} of {total} parts drawn": { + "many": "Partes desenhadas: {drawn} de {total}", + "one": "Partes desenhadas: {drawn} de {total}", + "other": "Partes desenhadas: {drawn} de {total}" + }, + "{count} duplicate mappings merged": { + "many": "Duplicatas mescladas: {count}", + "one": "Duplicatas mescladas: {count}", + "other": "Duplicatas mescladas: {count}" + }, + "listed the first {limit}": "listados os primeiros {limit}", + "counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno", + "Search cancelled": "Busca cancelada" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index aebcf5c1c..ac9bce9c5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3126,5 +3126,33 @@ "Searching imagery catalog": "Поиск в каталоге снимков", "Loading imagery": "Загрузка снимков", "Searching OpenStreetMap": "Поиск в OpenStreetMap", - "voice area|Area": "Область" + "voice area|Area": "Область", + "buffer sized to the place": "буферная зона по размеру места", + "enclosing mapped area": "охватывающая область на карте", + "the map was cleared while looking it up": "карта была очищена во время поиска", + "OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах", + "the catalog covers the last 30 days": "каталог охватывает последние 30 дней", + "there are more": "есть ещё", + "{radius} m around the landmark": "{radius} м вокруг объекта", + "{count} parts": { + "few": "Частей: {count}", + "many": "Частей: {count}", + "one": "Частей: {count}", + "other": "Частей: {count}" + }, + "{drawn} of {total} parts drawn": { + "few": "Нарисовано частей: {drawn} из {total}", + "many": "Нарисовано частей: {drawn} из {total}", + "one": "Нарисовано частей: {drawn} из {total}", + "other": "Нарисовано частей: {drawn} из {total}" + }, + "{count} duplicate mappings merged": { + "few": "Объединено дублей: {count}", + "many": "Объединено дублей: {count}", + "one": "Объединено дублей: {count}", + "other": "Объединено дублей: {count}" + }, + "listed the first {limit}": "показаны первые {limit}", + "counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру", + "Search cancelled": "Поиск отменён" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7282b42a2..78f625080 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3126,5 +3126,33 @@ "Searching imagery catalog": "Пошук у каталозі знімків", "Loading imagery": "Завантаження знімків", "Searching OpenStreetMap": "Пошук в OpenStreetMap", - "voice area|Area": "Область" + "voice area|Area": "Область", + "buffer sized to the place": "буферна зона за розміром місця", + "enclosing mapped area": "охопна область на карті", + "the map was cleared while looking it up": "карту було очищено під час пошуку", + "OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця", + "the catalog covers the last 30 days": "каталог охоплює останні 30 днів", + "there are more": "є ще", + "{radius} m around the landmark": "{radius} м навколо об’єкта", + "{count} parts": { + "few": "Частин: {count}", + "many": "Частин: {count}", + "one": "Частин: {count}", + "other": "Частин: {count}" + }, + "{drawn} of {total} parts drawn": { + "few": "Намальовано частин: {drawn} з {total}", + "many": "Намальовано частин: {drawn} з {total}", + "one": "Намальовано частин: {drawn} з {total}", + "other": "Намальовано частин: {drawn} з {total}" + }, + "{count} duplicate mappings merged": { + "few": "Об’єднано дублікатів: {count}", + "many": "Об’єднано дублікатів: {count}", + "one": "Об’єднано дублікатів: {count}", + "other": "Об’єднано дублікатів: {count}" + }, + "listed the first {limit}": "показано перші {limit}", + "counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром", + "Search cancelled": "Пошук скасовано" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b0c17f88a..2def4408b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2910,5 +2910,24 @@ "Searching imagery catalog": "正在搜索影像目录", "Loading imagery": "正在加载影像", "Searching OpenStreetMap": "正在搜索 OpenStreetMap", - "voice area|Area": "区域" + "voice area|Area": "区域", + "buffer sized to the place": "按地点大小设置的缓冲区", + "enclosing mapped area": "包含该地点的地图区域", + "the map was cleared while looking it up": "查找期间地图已清空", + "OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异", + "the catalog covers the last 30 days": "目录涵盖最近 30 天", + "there are more": "还有更多", + "{radius} m around the landmark": "地标周围 {radius} 米", + "{count} parts": { + "other": "部分数:{count}" + }, + "{drawn} of {total} parts drawn": { + "other": "已绘制部分:{drawn}/{total}" + }, + "{count} duplicate mappings merged": { + "other": "已合并重复项:{count}" + }, + "listed the first {limit}": "已列出前 {limit} 个", + "counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓", + "Search cancelled": "搜索已取消" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 25d6c980e..d4ffa19ce 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2910,5 +2910,24 @@ "Searching imagery catalog": "正在搜索影像目錄", "Loading imagery": "正在加載影像", "Searching OpenStreetMap": "正在搜索 OpenStreetMap", - "voice area|Area": "區域" + "voice area|Area": "區域", + "buffer sized to the place": "按地點大小設置的緩衝區", + "enclosing mapped area": "包含該地點的地圖區域", + "the map was cleared while looking it up": "查找期間地圖已清空", + "OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異", + "the catalog covers the last 30 days": "目錄涵蓋最近 30 天", + "there are more": "還有更多", + "{radius} m around the landmark": "地標周圍 {radius} 米", + "{count} parts": { + "other": "部分數:{count}" + }, + "{drawn} of {total} parts drawn": { + "other": "已繪製部分:{drawn}/{total}" + }, + "{count} duplicate mappings merged": { + "other": "已合併重複項:{count}" + }, + "listed the first {limit}": "已列出前 {limit} 個", + "counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓", + "Search cancelled": "搜索已取消" } diff --git a/src/layers/osmPlaces/index.js b/src/layers/osmPlaces/index.js index 80be7c23e..389350d09 100644 --- a/src/layers/osmPlaces/index.js +++ b/src/layers/osmPlaces/index.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { msg } from '../../i18n/marker.js'; import { OSM_PLACES_LAYER_ID } from './model.js'; +import { createNominatimStatus } from '../../sources/nominatimStatus.js'; export * from './model.js'; const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID; @@ -15,9 +16,13 @@ const COLOR = '#7fe3ff'; * The search runs only on an operator-configured Overpass, so without one * the layer stays empty. Session-only: never in share links or stored layer * state. - * @param {{overlayHost: object}} options + * @param {{overlayHost: object, overpassConfigured?: () => Promise}} options */ -export function createOsmPlacesLayer({ overlayHost } = {}) { +export function createOsmPlacesLayer({ + overlayHost, + // The same once-per-page capability probe as Nominatim's, on Overpass. + overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }), +} = {}) { if (!overlayHost) throw new TypeError('OSM Places requires an overlay host'); let viewer = null; let dataSource = null; @@ -25,6 +30,8 @@ export function createOsmPlacesLayer({ overlayHost } = {}) { let records = []; let meta = null; let lastUpdate = null; + /** true / false from the server's probe, null while unknown. */ + let configured = null; function render() { if (!dataSource) return; @@ -42,7 +49,9 @@ export function createOsmPlacesLayer({ overlayHost } = {}) { outlineColor: Cesium.Color.BLACK.withAlpha(0.8), outlineWidth: 2, heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - disableDepthTestDistance: Number.POSITIVE_INFINITY, + // On top only up close (as bikeshare stations): beyond that the + // depth test keeps far-side points from showing through the globe. + disableDepthTestDistance: 2500, }, }); if (record.name && overlay.length < LABEL_LIMIT) @@ -79,8 +88,24 @@ export function createOsmPlacesLayer({ overlayHost } = {}) { source: 'OpenStreetMap', updateInterval: 0, + /** Unavailable only once the server says no Overpass is configured. */ + getSourceAvailability() { + return configured === false + ? { + available: false, + source: OSM_PLACES_LAYER_ID, + reason: 'Needs an operator-configured Overpass server', + } + : { available: true, source: OSM_PLACES_LAYER_ID, reason: null }; + }, + init(nextViewer) { viewer = nextViewer; + Promise.resolve(overpassConfigured?.()) + .then((answer) => { + if (typeof answer === 'boolean') configured = answer; + }) + .catch(() => {}); dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID); dataSource.show = false; viewer.dataSources.add(dataSource); diff --git a/src/layers/osmPlaces/index.test.mjs b/src/layers/osmPlaces/index.test.mjs index 92f8804c2..8fb89e6b4 100644 --- a/src/layers/osmPlaces/index.test.mjs +++ b/src/layers/osmPlaces/index.test.mjs @@ -100,3 +100,53 @@ test('destroy and re-init expose a fresh empty session', () => { statusMessage: 'Voice place search (needs an Overpass server)', }); }); + +test('without an operator Overpass the layer shows as unavailable', async () => { + const added = []; + const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } }; + const off = createOsmPlacesLayer({ + overlayHost: fakeOverlayHost(), + overpassConfigured: async () => false, + }); + assert.equal( + off.getSourceAvailability().available, + true, + 'unknown until asked', + ); + off.init(viewer); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(off.getSourceAvailability().available, false); + const unknown = createOsmPlacesLayer({ + overlayHost: fakeOverlayHost(), + overpassConfigured: async () => null, + }); + unknown.init(viewer); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + unknown.getSourceAvailability().available, + true, + 'an unknown answer keeps it listed', + ); +}); + +test('places are depth-tested past close range, so they never show through the globe', () => { + const added = []; + const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } }; + const layer = createOsmPlacesLayer({ + overlayHost: fakeOverlayHost(), + overpassConfigured: async () => true, + }); + layer.init(viewer); + layer.enable(); + layer.setResults(RECORDS, { + kind: 'hospital', + label: 'hospitals', + area: 'Kathmandu', + }); + const point = added[0].entities.values[0].point; + const distance = point.disableDepthTestDistance.getValue(); + assert.ok( + Number.isFinite(distance) && distance <= 5000, + `depth test off only within ${distance} m`, + ); +}); diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index 482c0f482..5ab8e05cc 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -1211,6 +1211,8 @@ export function createRecentImageryLayer({ /** Stop catalog/preview work owned by a cancelled voice action. */ cancelPendingSearch() { + // Without a note the emptied strip would read as "no imagery". + if (_searching) _notice = msg('Search cancelled'); cancelSearch(); clearPending(); syncRender(); diff --git a/src/layers/recentImagery/index.test.mjs b/src/layers/recentImagery/index.test.mjs index 7d8c70bd3..90dbf3a52 100644 --- a/src/layers/recentImagery/index.test.mjs +++ b/src/layers/recentImagery/index.test.mjs @@ -1385,3 +1385,13 @@ test('with SELECT BOX armed, Escape clears the preview first and the next Escape assert.equal(f.snap().toolActive, false); await tool.destroy(); }); + +test('a voice search cancelled mid-flight says so instead of looking empty', async () => { + const f = fixture(); + f.layer.enable(); + f.layer.setBox(BOX); + assert.equal(f.snap().searching, true); + f.layer.cancelPendingSearch(); + assert.equal(f.snap().searching, false); + assert.equal(f.snap().notice, 'Search cancelled'); +}); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index b79fc14a4..307a3c518 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -37,6 +37,7 @@ const ESRI_NOTE = msg('Imagery on Esri · Google 3D returns when cleared'); const LAYER_NOTICES = new Set([ ESRI_NOTE, msg('Box and images cleared'), + msg('Search cancelled'), msg('Esri map unavailable · no swipe'), msg('Swipe needs a globe map'), msg('Imagery stays on Esri · CLEAR to use Google 3D'), diff --git a/src/voice/imageryActions.js b/src/voice/imageryActions.js index 2fdf8dbd7..50d361f8f 100644 --- a/src/voice/imageryActions.js +++ b/src/voice/imageryActions.js @@ -268,6 +268,8 @@ export async function findImagery( : args.source === 'any' ? { hls: true, viirs: true } : { hls: true }; + // The operator's own settings, put back if the box is refused. + const before = layer.getSnapshot(); layer.setSources(sources); layer.setMode?.('image'); @@ -279,6 +281,8 @@ export async function findImagery( if (!accepted) { const reason = layer.getSnapshot().boxError || 'That area cannot be searched.'; + if (before?.sources) layer.setSources(before.sources); + if (before?.mode) layer.setMode?.(before.mode); return fail('BOX_REFUSED', `${reason}. Zoom in or pick a smaller area.`, { ...(areaName ? { area: areaName } : {}), }); diff --git a/src/voice/imageryActions.test.mjs b/src/voice/imageryActions.test.mjs index d0fef8bbd..8c80fb32c 100644 --- a/src/voice/imageryActions.test.mjs +++ b/src/voice/imageryActions.test.mjs @@ -47,6 +47,8 @@ function fakeLayer({ pins: { a: { key: null } }, shown: { a: null }, notes: [], + sources: { hls: false, viirs: true }, + mode: 'basemap', }; const listeners = new Set(); const calls = []; @@ -92,9 +94,11 @@ function fakeLayer({ }, setSources(sources) { calls.push(['setSources', sources]); + set({ sources: { ...sources } }); }, setMode(mode) { calls.push(['setMode', mode]); + set({ mode }); }, setAssignment(slot, key) { calls.push(['setAssignment', slot, key]); @@ -271,6 +275,15 @@ test('a box the layer refuses is refused with its reason, and the layer is turne assert.deepEqual(toggles, [true]); }); +test('a refused box leaves the operator panel sources and mode as they were', async () => { + const layer = fakeLayer({ refuse: 'Box is 1,200 km wide · limit 1,000 km' }); + const result = await findImagery(deps(layer).value, {}); + assert.equal(result.code, 'BOX_REFUSED'); + const snap = layer.getSnapshot(); + assert.deepEqual(snap.sources, { hls: false, viirs: true }); + assert.equal(snap.mode, 'basemap'); +}); + test('a superseded search is cancelled before anything is pinned', async () => { const layer = fakeLayer(); let current = true; diff --git a/src/voice/osmActions.js b/src/voice/osmActions.js index 9adeb4757..4f862924f 100644 --- a/src/voice/osmActions.js +++ b/src/voice/osmActions.js @@ -211,6 +211,7 @@ export async function osmQuery( ok: true, action: 'osm_query', kind: preset.label, + kindSingular: singularLabel(preset.label), count, complete: false, countScope: 'bounding-box', @@ -290,6 +291,7 @@ export async function osmQuery( ok: true, action: 'osm_query', kind: preset.label, + kindSingular: singularLabel(preset.label), count, complete, area: areaName, diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 3d44bf95e..112200f7f 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -293,6 +293,14 @@ const FIXED_NOTES = new Map( msg( 'the count is a lower bound because at least one layer reached its retained limit.', ), + // Area, imagery and OSM card lines (areaActions, imageryActions, + // osmActions); the model gets the English. + msg('buffer sized to the place'), + msg('enclosing mapped area'), + msg('the map was cleared while looking it up'), + msg('OpenStreetMap coverage varies by place'), + msg('the catalog covers the last 30 days'), + msg('there are more'), ].map((text) => [text, text]), ); @@ -302,7 +310,58 @@ const LAYER_STATUS_NOTES = Object.freeze({ error: (label) => t('{label} error', { label }), }); +const countOf = (text) => Number(String(text).replace(/,/g, '')); + const NOTE_PATTERNS = [ + [ + /^([\d,]+) m around the landmark$/, + ([, radius]) => + t('{radius} m around the landmark', { + radius: formatNumber(countOf(radius)), + }), + ], + [ + /^([\d,]+) parts$/, + ([, n]) => + tn(countOf(n), '{count} part', '{count} parts', { + count: formatNumber(countOf(n)), + }), + ], + [ + /^([\d,]+) of ([\d,]+) parts drawn$/, + ([, drawn, total]) => + tn( + countOf(total), + '{drawn} of {total} part drawn', + '{drawn} of {total} parts drawn', + { + drawn: formatNumber(countOf(drawn)), + total: formatNumber(countOf(total)), + }, + ), + ], + [ + /^([\d,]+) duplicate mappings merged$/, + ([, n]) => + tn( + countOf(n), + '{count} duplicate mapping merged', + '{count} duplicate mappings merged', + { count: formatNumber(countOf(n)) }, + ), + ], + [ + /^listed the first ([\d,]+)$/, + ([, limit]) => + t('listed the first {limit}', { limit: formatNumber(countOf(limit)) }), + ], + [ + /^counted over the bounding box of (.+), not its outline$/, + ([, area]) => + t('counted over the bounding box of {area}, not its outline', { + area: spokenLabel(area, 48), + }), + ], [ /^counted the first ([\d,]+) loaded records$/, ([, returned]) => @@ -373,6 +432,11 @@ const NOTE_PATTERNS = [ ], ]; +/** A card line from a producer's English text, or null when absent. */ +function shownLine(text) { + return text ? shownNote(String(text)) : null; +} + function shownNote(text) { if (FIXED_NOTES.has(text)) return t(FIXED_NOTES.get(text)); for (const [pattern, show] of NOTE_PATTERNS) { @@ -465,18 +529,18 @@ function areaCard(result, args = {}) { title: `${result.name ? spokenLabel(result.name, 48) : t('Area', { context: 'voice area' })}${result.country ? `, ${spokenLabel(result.country, 24)}` : ''}`, lines: [ Number.isFinite(km2) && km2 > 0 ? `${formatNumber(km2)} km²` : null, - display.basis, - display.parts, - display.drawnParts, + shownLine(display.basis), + shownLine(display.parts), + shownLine(display.drawnParts), result.notDrawn - ? t('Not drawn: {parts}', { parts: result.notDrawn }) + ? t('Not drawn: {parts}', { parts: shownNote(result.notDrawn) }) : null, ].filter(Boolean), chips: [ ...(result.approximate ? [{ label: t('approximate') }] : []), ...(result.partial ? [{ label: shownFeedState('partial') }] : []), ], - notes: notesOf(display.caveat), + notes: notesOf(display.caveat).map(shownNote), sources: display.source ? [{ label: display.source }] : [], }, referents: [], @@ -509,7 +573,7 @@ function imageryCard(result) { total: formatNumber(Number(result.total) || 0), }, ), - capitalized(display.window), + capitalized(shownLine(display.window)), ] : [ tn( @@ -518,7 +582,7 @@ function imageryCard(result) { '{n} acquisitions searched', { n: formatNumber(Number(result.total) || 0) }, ), - capitalized(display.window), + capitalized(shownLine(display.window)), ]; return { display: { @@ -528,7 +592,7 @@ function imageryCard(result) { lines: lines.filter(Boolean), chips: shown && !shown.onMap ? [{ label: shownFeedState('loading') }] : [], - notes: notesOf(display.caveat), + notes: notesOf(display.caveat).map(shownNote), }, referents: [], }; @@ -538,8 +602,12 @@ function osmCard(result) { if (!result?.ok) return null; const display = result.display || {}; const count = Number(result.count); - // The kind is the preset's English noun, the model's data. - const kind = spokenLabel(result.kind || 'places', 32); + // The kind is the preset's English noun, the model's data; one place takes + // its singular ("1 hospital"). + const kind = spokenLabel( + (count === 1 && result.kindSingular) || result.kind || 'places', + 32, + ); const values = { count: formatNumber(count), kind }; const title = !Number.isFinite(count) ? t('Unknown number of {kind}', { kind }) @@ -555,9 +623,9 @@ function osmCard(result) { result.area ? t('In {area}', { area: spokenLabel(result.area, 48) }) : null, - display.merged, + shownLine(display.merged), ].filter(Boolean), - notes: notesOf(display.caveat), + notes: notesOf(display.caveat).map(shownNote), sources: display.source ? [{ label: display.source }] : [], }, referents: Array.isArray(result.referents) ? result.referents : [], diff --git a/src/voice/voiceCardPlanSteps.i18n.test.mjs b/src/voice/voiceCardPlanSteps.i18n.test.mjs index 31b971c92..4ac15394c 100644 --- a/src/voice/voiceCardPlanSteps.i18n.test.mjs +++ b/src/voice/voiceCardPlanSteps.i18n.test.mjs @@ -280,3 +280,66 @@ test('every locationId the voice tools accept is a preset city', () => { assert.ok(ids.size > 0, 'found the locationId enums'); for (const id of ids) assert.ok(Object.hasOwn(CITY_POIS, id), id); }); + +test('area, imagery and OSM card lines are shown in the UI language', () => { + const area = { + ok: true, + name: 'Ferry Building', + approximate: true, + display: { + basis: '250 m around the landmark', + parts: '3 parts', + drawnParts: '2 of 3 parts drawn', + source: 'approximate outline', + }, + notDrawn: 'the map was cleared while looking it up', + }; + const areaLines = inLocale('de', () => presentResult('resolve_area', area).display.lines); + assert.ok(areaLines.includes('250 m um das Wahrzeichen'), areaLines.join(' | ')); + assert.ok(areaLines.includes('Teile: 3')); + assert.ok(areaLines.includes('Gezeichnete Teile: 2 von 3')); + assert.ok(areaLines.some((line) => line.includes('die Karte wurde während der Suche geleert'))); + const osm = { + ok: true, + kind: 'hospitals', + kindSingular: 'hospital', + count: 1, + complete: false, + area: 'Kathmandu', + display: { + source: 'OpenStreetMap', + merged: '2 duplicate mappings merged', + caveat: 'listed the first 500; there are more', + }, + }; + const osmCard = inLocale('de', () => presentResult('osm_query', osm).display); + assert.match(osmCard.title, /\bhospital\b/); + assert.doesNotMatch(osmCard.title, /hospitals/, 'one place takes the singular'); + assert.equal(presentResult('osm_query', { ...osm, complete: true }).display.title, '1 hospital'); + assert.ok(osmCard.lines.includes('Zusammengeführte Doppelkartierungen: 2')); + assert.deepEqual(osmCard.notes, ['die ersten 500 aufgelistet', 'es gibt weitere']); + const imagery = { + ok: true, + total: 4, + count: 0, + display: { window: 'the catalog covers the last 30 days' }, + }; + const imageryLines = inLocale('de', () => presentResult('find_imagery', imagery).display.lines); + assert.ok(imageryLines.includes('Der Katalog umfasst die letzten 30 Tage'), imageryLines.join(' | ')); +}); + +test('same-named clarification choices show their region code', () => { + const asked = { + ok: false, + needsClarification: true, + code: 'AMBIGUOUS_AREA', + candidates: [ + { candidateId: 'ne:state:united-states-of-america-washington-wa', name: 'Washington', level: 'admin1', region: 'WA', country: 'United States of America' }, + { candidateId: 'ne:state:united-states-of-america-washington-dc', name: 'Washington', level: 'admin1', region: 'DC', country: 'United States of America' }, + ], + }; + const lines = presentResult('resolve_area', asked, { query: 'Washington' }).display.lines; + assert.match(lines[0], /\bWA\b/); + assert.match(lines[1], /\bDC\b/); + assert.notEqual(lines[0], lines[1]); +});