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.
This commit is contained in:
Manjunath committed 2026-11-03 19:36:48 +05:30
1 parent 34647758fc
commit 40d8839589
22 files changed
+535 -27

No files matched your search

+2 -1
View File
@@ -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:**
+35 -1
View File
@@ -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": "أُلغي البحث"
}
+23 -1
View File
@@ -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"
}
+26 -1
View File
@@ -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"
}
+26 -1
View File
@@ -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"
}
+26 -1
View File
@@ -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"
}
+20 -1
View File
@@ -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": "検索をキャンセルしました"
}
+26 -1
View File
@@ -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"
}
+29 -1
View File
@@ -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": "Поиск отменён"
}
+29 -1
View File
@@ -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": "Пошук скасовано"
}
+20 -1
View File
@@ -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": "搜索已取消"
}
+20 -1
View File
@@ -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": "搜索已取消"
}
+28 -3
View File
@@ -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<boolean|null>}} 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);
+50
View File
@@ -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`,
);
});
+2
View File
@@ -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();
+10
View File
@@ -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');
});
+1
View File
@@ -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'),
+4
View File
@@ -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 } : {}),
});
+13
View File
@@ -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;
+2
View File
@@ -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,
+80 -12
View File
@@ -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 : [],
@@ -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]);
});