mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into upstream/traffic-paired-flow-density
This commit is contained in:
145 files changed
+18208
-1205
No files matched your search
@@ -28,6 +28,29 @@ function services() {
|
||||
};
|
||||
}
|
||||
|
||||
// The application's own scene services, as src/app/layers/militaryFlights.js
|
||||
// composes them; only the surface samplers and registry are stubbed.
|
||||
async function sceneServices() {
|
||||
return {
|
||||
picking: await import('../../data/pickRegistry.js'),
|
||||
sprites: await import('../../data/spriteOrder.js'),
|
||||
trails: await import('../../data/trailRenderer.js'),
|
||||
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
|
||||
camera: await import('../../data/trackedCamera.js'),
|
||||
labels: await import('../../data/detectionDraw.js'),
|
||||
geoid: await import('../../data/geoid.js'),
|
||||
focus: await import('../../data/focusDeemphasis.js'),
|
||||
readout: await import('../../data/trackedReadout.js'),
|
||||
context: await import('../../data/contextStore.js'),
|
||||
render: await import('../../renderGovernor.js'),
|
||||
recession: await import('../../data/aircraftRecession.js'),
|
||||
groundFloor: await import('../../data/groundFloor.js'),
|
||||
groundSnap: await import('../../data/groundSnap.js'),
|
||||
meshFloor: { sampleMeshFloorCells: () => {} },
|
||||
militaryRegistry: { registerMilitaryIcaos: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('military layer instances isolate policy and restoration state without requesting a source', async () => {
|
||||
let requests = 0;
|
||||
const source = {
|
||||
@@ -122,26 +145,7 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
// the next fleet tick jumped it back.
|
||||
const Cesium = await import('cesium');
|
||||
const { RENDER_DELAY_SEC } = await import('./policy.js');
|
||||
// The application's own scene services, as src/app/layers/militaryFlights.js
|
||||
// composes them; only the surface samplers and registry are stubbed.
|
||||
const supplied = {
|
||||
picking: await import('../../data/pickRegistry.js'),
|
||||
sprites: await import('../../data/spriteOrder.js'),
|
||||
trails: await import('../../data/trailRenderer.js'),
|
||||
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
|
||||
camera: await import('../../data/trackedCamera.js'),
|
||||
labels: await import('../../data/detectionDraw.js'),
|
||||
geoid: await import('../../data/geoid.js'),
|
||||
focus: await import('../../data/focusDeemphasis.js'),
|
||||
readout: await import('../../data/trackedReadout.js'),
|
||||
context: await import('../../data/contextStore.js'),
|
||||
render: await import('../../renderGovernor.js'),
|
||||
recession: await import('../../data/aircraftRecession.js'),
|
||||
groundFloor: await import('../../data/groundFloor.js'),
|
||||
groundSnap: await import('../../data/groundSnap.js'),
|
||||
meshFloor: { sampleMeshFloorCells: () => {} },
|
||||
militaryRegistry: { registerMilitaryIcaos: () => {} },
|
||||
};
|
||||
const supplied = await sceneServices();
|
||||
const fixLon = -97.6;
|
||||
const fixLat = 30.3;
|
||||
const speedMps = 200;
|
||||
@@ -213,6 +217,55 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
);
|
||||
});
|
||||
|
||||
test('a vanished military contact fades while position-less rows keep theirs', async () => {
|
||||
// About a third of a live adsb.lol /v2/mil fetch is contacts without a
|
||||
// position. Those rows marked every snapshot partial, so an aircraft that
|
||||
// left the feed stayed at full opacity for five minutes.
|
||||
const Cesium = await import('cesium');
|
||||
const { readsbSnapshot } = await import('../../sources/live/aircraft.js');
|
||||
const nowMs = Date.now();
|
||||
const row = (hex, lon) => ({
|
||||
hex,
|
||||
lat: 30.3,
|
||||
lon,
|
||||
alt_baro: 20_000,
|
||||
gs: 300,
|
||||
track: 90,
|
||||
seen_pos: 1,
|
||||
});
|
||||
const polls = [
|
||||
[row('ae0001', -97.6), row('ae0002', -96.6), row('ae0003', -95.6)],
|
||||
[row('ae0001', -97.5), { hex: 'ae0003' }],
|
||||
];
|
||||
// Every poll also lists a contact that never has a position.
|
||||
for (const poll of polls) poll.push({ hex: 'ae0009', type: 'mode_s' });
|
||||
const layer = createMilitaryFlightLayer({
|
||||
services: await sceneServices(),
|
||||
source: {
|
||||
label: 'Fixture aircraft',
|
||||
getSnapshot: async () =>
|
||||
readsbSnapshot(
|
||||
{ ac: polls.shift() },
|
||||
{ observedAtMs: nowMs, now: nowMs },
|
||||
),
|
||||
},
|
||||
});
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
meta: { rawLat: 0, rawLon: 0, onGround: false },
|
||||
billboard: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0) },
|
||||
billboardCollection: { show: true, add: (options) => ({ ...options }) },
|
||||
viewer,
|
||||
tracked: false,
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.findByQuery('ae0002')?.stale, true, 'vanished: fading');
|
||||
assert.equal(layer.findByQuery('ae0003')?.stale, false, 'still listed');
|
||||
});
|
||||
|
||||
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
|
||||
const stubs = services();
|
||||
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
|
||||
|
||||
@@ -187,9 +187,12 @@ export class MilitaryFlightRecords {
|
||||
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
|
||||
};
|
||||
}
|
||||
absence(id, { complete, likelyLanded }) {
|
||||
absence(id, { complete, retainIds, likelyLanded }) {
|
||||
// Partial admissions and position-less rows do not prove absence, but
|
||||
// retention remains bounded; a likely-landed position-less contact
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
!complete &&
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
|
||||
@@ -99,6 +99,30 @@ test('military records keep bounded partial retention and release metadata only
|
||||
);
|
||||
});
|
||||
|
||||
test('complete military snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
|
||||
const records = store();
|
||||
for (const id of ['aaa001', 'aaa002', 'aaa003'])
|
||||
records.receive(observation({ id, reference: id }), context());
|
||||
// aaa001 and aaa003 are still listed without a position; aaa002 vanished.
|
||||
const absent = (id, likelyLanded = false) =>
|
||||
records.absence(id, {
|
||||
complete: true,
|
||||
retainIds: new Set(['aaa001', 'aaa003']),
|
||||
likelyLanded,
|
||||
});
|
||||
assert.deepEqual(
|
||||
[absent('aaa002'), absent('aaa002'), absent('aaa002')],
|
||||
['stale', 'stale', 'remove'],
|
||||
);
|
||||
assert.equal(absent('aaa001'), 'retain');
|
||||
assert.equal(records.missingPolls.has('aaa001'), false);
|
||||
// A landed contact that lost its position takes the landed fast-cull.
|
||||
assert.equal(absent('aaa003', true), 'remove');
|
||||
// Position-less retention stays bounded like partial admission.
|
||||
records.data.get('aaa001').observedReceiptMs = Date.now() - 300001;
|
||||
assert.equal(absent('aaa001'), 'stale');
|
||||
});
|
||||
|
||||
test('military emergency status follows the latest poll instead of sticking', () => {
|
||||
const records = store();
|
||||
const first = records.receive(
|
||||
|
||||
@@ -259,6 +259,7 @@ export function createMilitarySnapshotRenderer({
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
|
||||
@@ -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)'),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user