Merge branch 'main' into manjunath/hal-769-range-ring-visibility

# Conflicts:
#	scripts/format-scope.json
This commit is contained in:
Sameh Khamis committed 2026-11-03 10:33:19 -08:00
commit 0d32f59b40
117 files changed
+16480 -1005

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
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;
/** Named results that get a map label; the rest are points. */
const LABEL_LIMIT = 48;
const COLOR = '#7fe3ff';
/**
* OSM Places: the results of the last voice OpenStreetMap search ("hospitals
* in Kathmandu"), drawn as points with name labels and queryable by
* analyst_query. It fetches nothing itself; `setResults` replaces the set.
* 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, overpassConfigured?: () => Promise<boolean|null>}} options
*/
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;
let enabled = false;
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;
dataSource.entities.removeAll();
const color = Cesium.Color.fromCssColorString(COLOR);
const overlay = [];
for (const record of records) {
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
dataSource.entities.add({
id: `osm-place:${record.id}`,
position,
point: {
pixelSize: 9,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
// 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)
overlay.push({
id: record.id,
position,
variant: 'label',
title: record.name.slice(0, 40),
accent: COLOR,
priority: LABEL_LIMIT - overlay.length,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 12,
verticalOnly: true,
placement: 'above',
});
}
if (enabled)
overlayHost.setEntries(OVERLAY_SOURCE_ID, overlay, {
cohortLimit: LABEL_LIMIT,
collisionCapacity: LABEL_LIMIT,
moving: false,
});
}
return {
id: OSM_PLACES_LAYER_ID,
name: 'OSM Places',
icon: '⌖',
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);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
},
enable() {
enabled = true;
if (dataSource) dataSource.show = true;
overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
render();
return true;
},
disable() {
enabled = false;
if (dataSource) dataSource.show = false;
overlayHost.clearSource(OVERLAY_SOURCE_ID);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
return true;
},
/** Nothing to poll: results change only through setResults. */
async update() {
return true;
},
destroy() {
enabled = false;
overlayHost.clearSource(OVERLAY_SOURCE_ID);
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
dataSource = null;
viewer = null;
records = [];
meta = null;
lastUpdate = null;
},
/**
* Replace the result set.
* @param {object[]} next Records from `mapOsmFeature`.
* @param {{kind: string, label: string, area: string}} nextMeta
*/
setResults(next, nextMeta) {
records = Array.isArray(next) ? next.slice() : [];
meta = nextMeta ? { ...nextMeta } : null;
lastUpdate = Date.now();
render();
},
/** The current search, for panels and voice. */
getResultsMeta() {
return meta ? { ...meta, count: records.length } : null;
},
getAnalystRecords(maxCount = 2000) {
if (!enabled) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
return records.slice(0, limit).map((record) => ({ ...record }));
},
getStats() {
return {
count: records.length,
lastUpdate,
error: null,
statusMessage: meta
? `${meta.label} · ${meta.area}`
: msg('Voice place search (needs an Overpass server)'),
};
},
};
}
+152
View File
@@ -0,0 +1,152 @@
// OSM Places holds the last voice search's results: queryable while shown,
// labelled through the overlay host, and nothing when switched off.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOsmPlacesLayer } from './index.js';
function fakeOverlayHost() {
const calls = [];
return {
calls,
setEntries: (...args) => calls.push(['setEntries', ...args]),
clearSource: (...args) => calls.push(['clearSource', ...args]),
setVisible: (...args) => calls.push(['setVisible', ...args]),
};
}
const RECORDS = [
{
id: 'node/1',
lat: 27.7,
lon: 85.3,
name: 'Bir Hospital',
kind: 'hospital',
},
{ id: 'node/2', lat: 27.71, lon: 85.31, name: null, kind: 'hospital' },
];
test('results are queryable only while the layer is on, and labels follow names', () => {
const host = fakeOverlayHost();
const layer = createOsmPlacesLayer({ overlayHost: host });
layer.setResults(RECORDS, {
kind: 'hospital',
label: 'hospitals',
area: 'Kathmandu',
});
assert.deepEqual(layer.getAnalystRecords(), [], 'off: nothing to count');
assert.equal(layer.getStats().count, 2);
assert.equal(layer.getStats().statusMessage, 'hospitals · Kathmandu');
layer.enable();
const records = layer.getAnalystRecords();
assert.equal(records.length, 2);
records[0].name = 'changed';
assert.equal(
layer.getAnalystRecords()[0].name,
'Bir Hospital',
'callers get copies',
);
assert.deepEqual(layer.getResultsMeta(), {
kind: 'hospital',
label: 'hospitals',
area: 'Kathmandu',
count: 2,
});
layer.disable();
assert.ok(
host.calls.some(
([name, id]) => name === 'clearSource' && id === 'osm-places',
),
);
});
test('an empty layer tells the operator how to fill it', () => {
const layer = createOsmPlacesLayer({ overlayHost: fakeOverlayHost() });
assert.match(layer.getStats().statusMessage, /needs an Overpass server/);
assert.throws(() => createOsmPlacesLayer(), TypeError);
});
test('destroy and re-init expose a fresh empty session', () => {
const host = fakeOverlayHost();
const layer = createOsmPlacesLayer({ overlayHost: host });
layer.setResults(RECORDS, {
kind: 'hospital',
label: 'hospitals',
area: 'Kathmandu',
});
assert.ok(layer.getStats().lastUpdate);
layer.destroy();
const dataSources = {
added: [],
removed: [],
add(source) {
this.added.push(source);
return source;
},
remove(source, destroy) {
this.removed.push([source, destroy]);
return true;
},
};
layer.init({ dataSources });
layer.enable();
assert.equal(layer.getResultsMeta(), null);
assert.deepEqual(layer.getAnalystRecords(), []);
assert.deepEqual(layer.getStats(), {
count: 0,
lastUpdate: null,
error: null,
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`,
);
});
+111
View File
@@ -0,0 +1,111 @@
/**
* OSM Places records: OpenStreetMap features found by a voice search,
* mapped to plain analyst records. Pure — no Cesium.
* @module layers/osmPlaces/model
*/
export const OSM_PLACES_LAYER_ID = 'osm-places';
/** A node and a way/relation of the same name this close are one site mapped twice. */
const SAME_SITE_M = 75;
/** Longest tag value kept; OSM names are open text that reaches the model. */
const MAX_TEXT = 120;
/**
* A tag value as plain bounded text: control characters (newlines, tabs)
* become spaces; format characters (bidi overrides, zero-width spaces) are
* dropped, except the zero-width (non-)joiners Persian and Indic names spell
* with; runs of whitespace collapse, and long values are cut with an
* ellipsis.
*/
function text(value) {
if (typeof value !== 'string') return null;
const clean = value
.replace(/\p{Cc}+/gu, ' ')
.replace(/(?![\u200c\u200d])\p{Cf}/gu, '')
.replace(/\s+/g, ' ')
.trim();
if (!clean) return null;
// Cut on code points so an emoji's surrogate pair is never split.
const chars = Array.from(clean);
return chars.length <= MAX_TEXT
? clean
: `${chars
.slice(0, MAX_TEXT - 1)
.join('')
.trimEnd()}…`;
}
function distanceM(a, b) {
const toRad = Math.PI / 180;
const x = (b.lon - a.lon) * toRad * Math.cos(((a.lat + b.lat) / 2) * toRad);
const y = (b.lat - a.lat) * toRad;
return Math.hypot(x, y) * 6_371_000;
}
/**
* One feature from `/api/osm/features` → an analyst record.
* @param {{id: string, lat: number, lon: number, tags?: object}} feature
* @param {{id: string, label: string}} preset
* @returns {object|null}
*/
export function mapOsmFeature(feature, preset) {
if (!Number.isFinite(feature?.lat) || !Number.isFinite(feature?.lon))
return null;
const tags = feature.tags || {};
const [osmType] = String(feature.id || '').split('/');
return {
id: String(feature.id),
lat: feature.lat,
lon: feature.lon,
name: text(tags['name:en']) || text(tags.name),
kind: preset.id,
operator: text(tags.operator) || text(tags.brand),
emergency: text(tags.emergency),
wheelchair: text(tags.wheelchair),
openingHours: text(tags.opening_hours),
city: text(tags['addr:city']),
osmType: ['node', 'way', 'relation'].includes(osmType) ? osmType : null,
};
}
/**
* Remove double counting without merging distinct places:
*
* - the same OSM element (identical `type/id`) is counted once;
* - a node and a way/relation with the same name within 75 m are one site
* mapped twice (a point on the building and the building outline), and
* merge;
* - two nodes, or two ways, are always distinct places — two neighbouring
* cafés of one chain are two cafés — and never merge, whatever their names.
*
* @param {object[]} records
* @returns {{records: object[], merged: number}}
*/
export function dedupeOsmRecords(records) {
const kept = [];
const seen = new Set();
let merged = 0;
for (const record of records) {
if (seen.has(record.id)) {
merged += 1;
continue;
}
seen.add(record.id);
const name = record.name?.toLowerCase();
const isNode = record.osmType === 'node';
const sameSite =
name &&
record.osmType &&
kept.some(
(other) =>
other.osmType &&
(other.osmType === 'node') !== isNode &&
other.name?.toLowerCase() === name &&
distanceM(other, record) <= SAME_SITE_M,
);
if (sameSite) merged += 1;
else kept.push(record);
}
return { records: kept, merged };
}
+10
View File
@@ -1209,6 +1209,16 @@ export function createRecentImageryLayer({
notify('alpha');
},
/** 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();
notify();
},
/**
* Forget the box, the candidates, both pins, the preview and any pending
* pick; reset opacity and split and cancel the box tool. Mode and
+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');
});