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

+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');
});