tighten the remaining voice outline review items

- One place-name key for server and tiles, so "St. Paul" and "Saint Paul"
  agree in Nominatim name checks.
- Tile street names: a lone letter is a direction only beside another name
  word ("E Street" is not "East Street"), and a trailing quadrant ("E St NW")
  may go unsaid in the ask.
- A building cut at the tile edge that is nearer the point than any whole
  one leaves the point instead of a farther neighbour standing in; an edge
  without a tile buffer counts as a cut, so a cut area never answers grounds.
- A batch building install re-judges only results that installed; a late
  timeout during caption conversion keeps the pending point drawn; a failed
  plain recaption keeps the old mark addressable.
- An interrupted Nominatim dispatch is reclaimed by the next lock owner ten
  minutes after the start its marker names; node:sqlite loads on first use
  without its experimental warning.
- PHOTON_URL accepts only https, which the page's connect-src allows.
- Shared OpenFreeMap tiles decode each layer when a consumer first selects
  it; outline selection reuses the shared ring area and simplification, and
  ground probes the shared ring centroid; the grounds word list lives once.
This commit is contained in:
Sameh Khamis committed 2026-11-02 08:50:05 -08:00
1 parent a38a065c5c
commit a61d0f4c2a
24 files changed
+422 -128

No files matched your search

+8 -2
View File
@@ -411,6 +411,8 @@ export function createAnnotationEngine({
const results = [];
const ids = [];
const installations = [];
// Result slots an installation settles; only these are re-validated after the await.
const installed = new Set();
const trackBuildingInstall = (
anno,
resolved,
@@ -423,6 +425,7 @@ export function createAnnotationEngine({
ensureTicking();
const index = results.length;
results.push(null);
installed.add(index);
const record = beginBuildingInstall(anno);
installations.push(
Promise.resolve(completion)
@@ -589,6 +592,7 @@ export function createAnnotationEngine({
}
const index = results.length;
results.push(null);
installed.add(index);
anno.pendingOutline = true;
installations.push(
Promise.resolve(completion)
@@ -642,11 +646,12 @@ export function createAnnotationEngine({
);
continue;
}
// Remove before delete: a failed cleanup stays addressable through the old slot.
removeRendered(dup);
annotations.delete(dup.id);
annotations.set(anno.id, anno);
syncAnnotationHold();
try {
removeRendered(dup);
renderer.add(anno);
} catch (swapError) {
// Same contract as the fresh path: a renderer/WebGL throw must
@@ -690,6 +695,7 @@ export function createAnnotationEngine({
if (record) {
const index = results.length;
results.push(null);
installed.add(index);
installations.push(
(async () => {
do {
@@ -788,7 +794,7 @@ export function createAnnotationEngine({
await Promise.all(installations);
for (let index = 0; index < results.length; index += 1) {
const result = results[index];
if (!result?.ok) continue;
if (!result?.ok || !installed.has(index)) continue;
const current = annotations.get(result.id);
if (
!current ||
+53
View File
@@ -1249,3 +1249,56 @@ test('recaption inherits outline credit from borrowed geometry rather than its n
assert.equal(engine.list()[0].ring, ring);
engine.clear();
});
test('a building install in the batch does not re-judge marks that never installed', async (t) => {
installAnimationFrameStubs(t);
const h = controlledBuildingRenderer();
const engine = createAnnotationEngine({
viewer: {},
renderer: h.renderer,
resolveTarget: async ({ target }) =>
target === 'Tower' ? { ...BUILDING_FP, lon: -97.7 } : { ...FIXED_POINT },
});
// The recolour replaces the first point within the same batch; without the
// building that point's result stays ok, so the building must not change it.
const batch = engine.annotate([
{ type: 'point', target: 'Capitol', label: 'Capitol' },
{ type: 'area', target: 'Tower', label: 'Tower' },
{ type: 'point', target: 'Capitol', label: 'Capitol', color: 'amber' },
]);
await flushMicrotasks();
h.pending[0].finish();
const completed = await batch;
assert.deepEqual(completed.results.map((result) => result.ok), [true, true, true]);
assert.equal(completed.drawn, 3);
});
test('plain recaption keeps the old mark addressable when its removal throws', async (t) => {
installAnimationFrameStubs(t);
const { renderer } = fakeRenderer();
const engine = createAnnotationEngine({
viewer: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }),
});
await engine.annotate([{ type: 'point', target: 'Capitol', label: 'Capitol' }]);
const old = engine.list()[0];
const removals = [];
let fail = true;
renderer.remove = (anno) => {
removals.push(anno.id);
if (anno.id === old.id && fail) throw new Error('old route removal failed');
};
const swap = await engine.annotate([
{ type: 'point', target: 'Capitol', label: 'Capitol', color: 'amber' },
]);
assert.equal(swap.drawn, 0);
assert.deepEqual(engine.list().map((anno) => anno.id), [old.id], 'failed cleanup remains reachable');
assert.equal(engine.list()[0].cleanupPending, true);
fail = false;
const attempts = removals.filter((id) => id === old.id).length;
engine.clear();
assert.ok(
removals.filter((id) => id === old.id).length > attempts,
'the old route is retried on the next clear',
);
assert.equal(engine.count(), 0);
});
+3 -1
View File
@@ -186,12 +186,14 @@ export function createHybridAnnotationRenderer(
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = null;
} else {
// The caption conversion already reshaped the group; redraw the pending point.
const pending = liveProxy(anno, {
ring: null,
pendingOutline: true,
});
screen.update(pending);
if (entry.screenProxy) screen.remove(entry.screenProxy);
entry.screenProxy = pending;
screen.add(pending);
}
if (workTrace) workTrace.rendererStatus = 'timeout';
return { ...result, status: 'timeout' };
@@ -757,3 +757,30 @@ test('an asynchronous caption-conversion throw rolls back multipart world geomet
renderer.remove(anno);
renderer.destroy();
});
test('a deadline passed during caption conversion leaves a drawn pending point', async (t) => {
installBrowserGlobals(t);
let now = 0;
t.mock.method(globalThis.performance, 'now', () => now);
const { viewer, frames } = fakeViewer(-99, 31);
const { store, renderer } = hybridWithBoard(viewer);
// Unlabeled: the conversion empties the screen group rather than relabelling it.
const anno = { ...buildingAnnotation('late-timeout'), ring: null };
renderer.add(anno);
anno.ring = buildingAnnotation('geometry').ring;
const completion = renderer.update(anno);
for (let frame = 0; frame < 8; frame += 1) frames.emit();
// The clock passes the deadline while the conversion presents the board.
const publish = store.publish;
store.publish = (board) => {
now = 1e9;
return publish(board);
};
assert.equal((await completion).status, 'timeout');
store.publish = publish;
const live = annotationGroups(store).filter((mark) => !mark.className?.includes('gev-out'));
assert.equal(live.length, 1);
assert.ok(childrenWithClass(live[0], 'gev-anno-ring').length > 0, 'the pending point still draws');
renderer.remove(anno);
renderer.destroy();
});
+1 -4
View File
@@ -8,6 +8,7 @@ import { containsAnchor } from '../data/adminBoundaries.js';
import { lonLatToTile } from '../data/tomtomTiles.js';
import {
BUILDING_MAX_DISTANCE_M,
GROUNDS_WORDS_RE,
OUTLINE_TILE_ZOOM,
STREET_RADIUS_M,
boxAround,
@@ -48,10 +49,6 @@ const GROUNDS_MIN_AREA_M2 = 20_000;
const DEICTIC_RE =
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
/** Words that describe grounds rather than belong to the name searched. */
const GROUNDS_WORDS_RE =
/\b(?:grounds|compound|campus|premises|property|site|area|complex)\b/gi;
/** Whether the ask names a thing (vs. "this building" at a pointer). */
export function isNamedAsk(target) {
const text = String(target || '').trim();
+2 -8
View File
@@ -1,4 +1,5 @@
import { recordWorkSlice } from '../sources/phaseTiming.js';
import { ringCentroid } from '../sources/featureGeometry.js';
import * as Cesium from 'cesium';
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
@@ -741,14 +742,7 @@ function decimateRing(ring, n) {
* while scheduling the expensive scene picks across rendered frames.
*/
function groundProbePositions(ring) {
let clon = 0;
let clat = 0;
for (const [lon, lat] of ring) {
clon += lon;
clat += lat;
}
clon /= ring.length;
clat /= ring.length;
const { lon: clon, lat: clat } = ringCentroid(ring);
const latS = 111320;
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
let maxR = 0;
+2 -10
View File
@@ -42,7 +42,7 @@ import {
rank,
} from './adminBoundaries.js';
import { loadBundledJson } from './bundledJson.js';
import { foldPlaceName } from './placeNames.js';
import { normalizePlaceName } from './placeNames.js';
import { createRetryableLoader } from './retryableLoad.js';
import * as placeFiles from './local_data/us_census_places/files.js';
import * as neighborhoodFiles from './local_data/wof_neighborhoods/files.js';
@@ -76,15 +76,7 @@ export function exactPlaceName(value) {
.trim();
}
/**
* Fold a name for matching (see `foldPlaceName`), with "saint" and "sainte"
* shortened the way US place names abbreviate them.
*/
export function normalizePlaceName(value) {
return foldPlaceName(value)
.replace(/\bsaint\b/g, 'st')
.replace(/\bsainte\b/g, 'ste');
}
export { normalizePlaceName };
/** Index keys of a name: as spelled (`=`-prefixed), then folded. */
function nameVariants(value) {
+10
View File
@@ -32,3 +32,13 @@ export function foldPlaceName(value) {
.trim()
.replace(/^the /, '');
}
/**
* Fold a name for matching (see `foldPlaceName`), with "saint" and "sainte"
* shortened the way US place names abbreviate them.
*/
export function normalizePlaceName(value) {
return foldPlaceName(value)
.replace(/\bsaint\b/g, 'st')
.replace(/\bsainte\b/g, 'ste');
}
+2 -1
View File
@@ -97,7 +97,8 @@ export function simplifyRing(ring, tolM) {
return out.length >= 4 ? out : ring;
}
function douglasPeucker(points, tol) {
/** Douglas-Peucker on [lon, lat] points with a tolerance in degrees. */
export function douglasPeucker(points, tol) {
const n = points.length;
if (n < 3) return points.slice();
// Iterative, index-based (no per-call array slicing / recursion), so a large
+24 -9
View File
@@ -283,14 +283,10 @@ function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
const tile = new VectorTile(new PbfReader(bytes));
const base = decodeLayers(
bytes,
z,
x,
y,
['transportation', 'landuse'],
tile,
);
// Each layer is decoded when a consumer first selects it, so installations
// never pay for roads, nor outlines for either.
let roads = null;
let military = null;
const projections = new Map();
let baseBytes = 0;
let projectionBytes = 0;
@@ -309,7 +305,26 @@ function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
};
return {
...base,
get roads() {
return (roads ??= decodeLayers(
bytes,
z,
x,
y,
['transportation'],
tile,
).roads);
},
get military() {
return (military ??= decodeLayers(
bytes,
z,
x,
y,
['landuse'],
tile,
).military);
},
project,
bindSharedCacheEntry(resize, initialBytes) {
resizeCacheEntry = resize;
+66 -22
View File
@@ -189,7 +189,7 @@ function decodePolygons(
if (layer.length > LAYER_FEATURE_LIMIT)
throw new Error('Vector tile feature limit exceeded');
const extent = layer.extent || 4096;
// The cut lines are the layer's extreme coordinates beyond the tile core.
// Cut lines: the layer's extreme coordinates, at or past the tile edge.
let minX = 0;
let minY = 0;
let maxX = extent;
@@ -204,12 +204,14 @@ function decodePolygons(
}
const [westCut, northCut] = pixelToLonLat(minX, minY, extent, z, x, y);
const [eastCut, southCut] = pixelToLonLat(maxX, maxY, extent, z, x, y);
// A side with no buffer was cut at the tile edge itself, so a polygon that
// touches it is a fragment too.
const eps = 1e-9;
const onCut = ([lon, lat]) =>
(minX < 0 && Math.abs(lon - westCut) < eps) ||
(maxX > extent && Math.abs(lon - eastCut) < eps) ||
(minY < 0 && Math.abs(lat - northCut) < eps) ||
(maxY > extent && Math.abs(lat - southCut) < eps);
Math.abs(lon - westCut) < eps ||
Math.abs(lon - eastCut) < eps ||
Math.abs(lat - northCut) < eps ||
Math.abs(lat - southCut) < eps;
const out = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
@@ -305,36 +307,69 @@ const STREET_WORDS = Object.freeze({
pl: 'place',
ct: 'court',
sq: 'square',
nw: 'northwest',
ne: 'northeast',
sw: 'southwest',
se: 'southeast',
});
const STREET_TYPES = new Set(Object.values(STREET_WORDS));
const DIRECTION_LETTERS = Object.freeze({
n: 'north',
s: 'south',
e: 'east',
w: 'west',
});
const DIRECTIONS = new Set(['north', 'south', 'east', 'west']);
const QUADRANTS = new Set(['northwest', 'northeast', 'southwest', 'southeast']);
/** Comparable street name: the shared place-name fold, abbreviations expanded. */
export function normalizeStreetName(value) {
return foldPlaceName(String(value ?? '').split(',')[0])
const words = foldPlaceName(String(value ?? '').split(',')[0])
.split(' ')
.filter(Boolean)
.map((word) => STREET_WORDS[word] || word)
.map((word) => STREET_WORDS[word] || word);
// A lone letter is a direction only as the prefix or suffix of a name it
// qualifies ("N Lamar Blvd", "Main St S"); "E St NW" is the street named E.
const named = words.some(
(word) =>
!DIRECTION_LETTERS[word] &&
!DIRECTIONS.has(word) &&
!QUADRANTS.has(word) &&
!STREET_TYPES.has(word),
);
return words
.map((word, i) =>
named && (i === 0 || i === words.length - 1)
? DIRECTION_LETTERS[word] || word
: word,
)
.join(' ');
}
/**
* Whether a tile street name answers the asked street. Exact after
* normalization; otherwise the same name with only a leading direction added
* ("North Congress Avenue" for "Congress Avenue") — never a different street
* that merely shares a word.
* ("North Congress Avenue" for "Congress Avenue"), or a quadrant suffix the
* ask leaves unsaid ("E Street Northwest" for "E Street") — never a different
* street that merely shares a word.
*/
export function streetNameMatches(candidate, asked) {
const want = normalizeStreetName(asked);
if (!want) return false;
const answers = (have) => {
if (have === want) return true;
const [first, ...rest] = have.split(' ');
return DIRECTIONS.has(first) && rest.join(' ') === want;
};
const have = normalizeStreetName(candidate);
if (have === want) return true;
const [first, ...rest] = have.split(' ');
return DIRECTIONS.has(first) && rest.join(' ') === want;
if (answers(have)) return true;
const words = have.split(' ');
return (
QUADRANTS.has(words.at(-1)) &&
!QUADRANTS.has(want.split(' ').at(-1)) &&
answers(words.slice(0, -1).join(' '))
);
}
const lengthM = (line) => {
@@ -423,6 +458,7 @@ export function buildingFromTiles(
) {
let containing = null;
let fragmentContains = false;
let nearestFragmentM = Infinity;
let nearest = null;
for (const tile of tiles || []) {
for (const building of tile?.buildings || []) {
@@ -451,9 +487,11 @@ export function buildingFromTiles(
const area = ringAreaM2(building.rings[0]);
if (!containing || area < containing.area)
containing = { building, area };
} else if (!building.clipped) {
} else {
const distance = distanceToPolygonM(lon, lat, building.rings);
if (
if (building.clipped)
nearestFragmentM = Math.min(nearestFragmentM, distance);
else if (
distance <= maxDistanceM &&
(!nearest || distance < nearest.distance)
)
@@ -461,9 +499,13 @@ export function buildingFromTiles(
}
}
}
// The point is on a building only seen in pieces: no neighbour stands in.
// The point is on, or nearest to, a building only seen in pieces: no
// farther neighbour stands in. A complete copy from a neighbouring tile is
// never farther than its fragment, so the centimetre tolerance keeps it.
const fragmentNearer =
fragmentContains || nearestFragmentM + 0.01 < (nearest?.distance ?? 0);
const pick =
containing?.building || (fragmentContains ? null : nearest?.building);
containing?.building || (fragmentNearer ? null : nearest?.building);
if (!pick) return null;
const polygons = [pick.rings].map((part) =>
part.map((ring) => closeRing(ring.map((p) => [p[0], p[1]]))),
@@ -504,12 +546,14 @@ function groundsKey(value) {
.trim();
}
const GROUNDS_WORDS_RE =
/\b(?:grounds|campus|compound|premises|property|site|area|complex)\b/g;
/** Words that describe grounds rather than name the place. */
const GROUNDS_SUFFIX_RE =
/(?:\s+(?:grounds|campus|compound|premises|property|site|area|complex))+$/;
const GROUNDS_WORDS =
'grounds|campus|compound|premises|property|site|area|complex';
/** Grounds words wherever they stand, any case (the outline rung cuts these). */
export const GROUNDS_WORDS_RE = new RegExp(`\\b(?:${GROUNDS_WORDS})\\b`, 'gi');
const GROUNDS_SUFFIX_RE = new RegExp(`(?:\\s+(?:${GROUNDS_WORDS}))+$`);
/**
* Area classes an unnamed tile feature may answer for a grounds ask, from the
+15
View File
@@ -71,6 +71,21 @@ test('street names compare with abbreviations and a leading direction only', ()
assert.ok(streetNameMatches('ถนนสุขุมวิท', 'ถนนสุขุมวิท'));
});
test('a lone letter is a direction only beside a name; quadrants may go unsaid', () => {
assert.equal(normalizeStreetName('N Lamar Blvd'), 'north lamar boulevard');
assert.equal(normalizeStreetName('Main St S'), 'main street south');
assert.equal(normalizeStreetName('E St NW'), 'e street northwest');
assert.ok(!streetNameMatches('East Street', 'E Street'));
assert.ok(!streetNameMatches('E Street', 'East Street'));
assert.ok(streetNameMatches('North Lamar Boulevard', 'N Lamar Blvd'));
assert.ok(streetNameMatches('E Street Northwest', 'E Street'));
assert.ok(streetNameMatches('E St NW', 'E Street'));
assert.ok(streetNameMatches('E St NW', 'E Street Northwest'));
assert.ok(!streetNameMatches('E St NE', 'E Street Northwest'));
assert.ok(!streetNameMatches('E Street', 'E Street Northwest'));
assert.ok(!streetNameMatches('F Street Northwest', 'E Street'));
});
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
const street = streetFromTiles(TILES, {
name: 'Congress Avenue',
@@ -156,6 +156,69 @@ test('a clipped fragment is never the building, and no neighbour stands in', ()
);
});
test('a nearer cut fragment stops a farther whole building standing in', () => {
// About 110 m on a side, 2 m east of the point.
const fragment = {
rings: [square(0.00002, 0, 0.001)],
clipped: true,
heightM: null,
};
// Whole, about 30 m west of the point.
const neighbour = {
rings: [square(-0.0004, 0.00045, 0.0001)],
clipped: false,
heightM: null,
};
const point = { lat: 0.0005, lon: 0 };
assert.equal(
buildingFromTiles([{ buildings: [fragment, neighbour] }], point),
null,
);
// The fragment's complete copy from the neighbouring tile is the answer.
const complete = { ...fragment, clipped: false };
assert.deepEqual(
buildingFromTiles(
[{ buildings: [fragment, neighbour] }, { buildings: [complete] }],
point,
)?.polygons,
[fragment.rings],
);
// A fragment farther than the whole building does not block it.
assert.ok(
buildingFromTiles([{ buildings: [fragment, neighbour] }], {
...point,
lon: -0.00028,
}),
);
});
test('grounds touching an unbuffered tile edge are cut, not complete', () => {
// One tile at z0 whose park layer has no buffer: the cut is the edge itself.
const decode = (coordinates) =>
decodeOpenFreeMapOutlineTile(null, 0, 0, 0, {
layers: {
park: {
length: 1,
extent: 4096,
feature: () => ({
properties: { class: 'park', name: 'Edge Park' },
loadGeometry: () => [[{ x: 0, y: 2048 }]],
toGeoJSON: () => ({
geometry: { type: 'Polygon', coordinates },
}),
}),
},
},
});
const ask = { lat: 0.005, lon: -179.995, name: 'Edge Park' };
const cut = decode([square(-180, 0, 0.01)]);
assert.equal(cut.areas[0].clipped, true);
assert.equal(enclosingAreaFromTiles([cut], ask), null);
const inside = decode([square(-179.999, 0, 0.01)]);
assert.equal(inside.areas[0].clipped, false);
assert.ok(enclosingAreaFromTiles([inside], ask));
});
test('a point in a courtyard is not inside the building', () => {
const block = {
rings: [square(0, 0, 0.001), square(0.0004, 0.0004, 0.0002).reverse()],
@@ -407,6 +407,38 @@ test('a persistence failure after fetch invocation aborts it and blocks restarts
assert.equal(calls.length, 1);
assert.deepEqual(markers(), orphan);
assert.equal(createGateStateStore({ file }).count('2026-09-29'), 1);
// Long past any request's timeout, the next lock owner reclaims it.
clock += 10 * 60_000;
const resumed = make(async (url) => {
calls.push(url);
return json([]);
});
assert.deepEqual(
await resumed.requestJson((endpoint) => `${endpoint}?q=z`),
[],
);
assert.equal(calls.length, 2);
assert.deepEqual(markers(), []);
assert.equal(createGateStateStore({ file }).count('2026-09-29'), 2);
});
test('a dispatch marker that names no start keeps refusing', async (t) => {
const file = path.join(tempDir(t), 'state.json');
fs.writeFileSync(`${file}.dispatch.123.456`, '');
const gate = createNominatimGate({
settings: PUBLIC,
usage: createGateStateStore({ file, onError: () => {} }),
now: () => Date.UTC(2026, 8, 29, 12),
sleep: async () => {},
fetchImpl: async () =>
assert.fail('no request while a dispatch is unfinished'),
});
await assert.rejects(
gate.requestJson((endpoint) => `${endpoint}?q=x`),
{
code: 'NOMINATIM_STATE_UNAVAILABLE',
},
);
});
test('parseable but invalid public usage state fails closed', async (t) => {
@@ -719,6 +719,11 @@ test('names are normalized Unicode-aware so small differences share an entry', (
'non-Latin names survive',
);
assert.equal(normalizePlaceName('Central Park'), 'central park');
// One key with the bundled place packs and tile street matching.
assert.equal(
normalizePlaceName('Saint Paul'),
normalizePlaceName('St. Paul'),
);
});
test('small view changes reuse one cached outline', async (t) => {
+7
View File
@@ -89,6 +89,13 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
'null',
'credentials are never serialized into browser assets',
);
assert.equal(
createBrowserViteConfig({ photonUrl: 'http://localhost:2322/api/' }).define[
'import.meta.env.PHOTON_URL'
],
'null',
'plain http would be blocked by connect-src, so it is refused here',
);
assert.equal(
createBrowserViteConfig({ mapillaryToken: 'MLY|1|abc' }).define[
'import.meta.env.MAPILLARY_CLIENT_TOKEN'