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

+4 -3
View File
@@ -1,7 +1,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { foldPlaceName } from '../../../src/data/placeNames.js';
import { normalizePlaceName as foldName } from '../../../src/data/placeNames.js';
const DAY_MS = 86_400_000;
@@ -17,13 +17,14 @@ export const NOMINATIM_CACHE_TTL_MS = Object.freeze({
/**
* Comparable form of a place name for matching results to the ask: the
* shared fold (see `foldPlaceName`), bounded in length.
* shared place-name key (see `normalizePlaceName` in placeNames.js, so
* "St. Paul" and "Saint Paul" agree), bounded in length.
*
* @param {string} value
* @returns {string}
*/
export function normalizePlaceName(value) {
return foldPlaceName(value).slice(0, 200);
return foldName(value).slice(0, 200);
}
/**
+58 -7
View File
@@ -1,6 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { readResponseJsonCapped } from '../common/http.js';
/**
@@ -50,9 +49,34 @@ const DEFAULT_BACKOFF_MS = 60_000;
const BLOCKED_BACKOFF_MS = 3_600_000;
/** Pause after a server error or a failed retry. */
const OUTAGE_BACKOFF_MS = 30_000;
/** How old an unfinished dispatch must be before the next lock owner reclaims it. */
const DISPATCH_RECLAIM_MS = 10 * 60_000;
const DISABLED_VALUES = new Set(['', 'off', 'none', 'disabled', '0']);
/**
* node:sqlite, loaded on first public-instance use. Node 24 still flags it
* experimental and prints that once per process; only that one warning is
* dropped, and only while the module loads.
*/
let sqlite = null;
function sqliteModule() {
if (sqlite) return sqlite;
const emitWarning = process.emitWarning;
process.emitWarning = function (warning, ...rest) {
const type = typeof rest[0] === 'string' ? rest[0] : rest[0]?.type;
if (type === 'ExperimentalWarning' && /SQLite/.test(String(warning)))
return;
return emitWarning.call(this, warning, ...rest);
};
try {
sqlite = process.getBuiltinModule('node:sqlite');
} finally {
process.emitWarning = emitWarning;
}
return sqlite;
}
/**
* Read the search endpoint setting.
*
@@ -257,8 +281,11 @@ export function createGateStateStore({
const dispatchMarkerPrefix = file ? `${path.basename(file)}.dispatch.` : null;
// A crash or persistence failure during dispatch must never reopen admission
// using an older timestamp. Only this dispatch removes its own marker after
// persisting the post-invocation deadline; there is no timed stale reclaim.
// using an older timestamp. A dispatch removes its own marker after
// persisting the post-invocation deadline. A marker is only ever seen under
// the lock after its owner left the lock, so one that names a start older
// than DISPATCH_RECLAIM_MS (far beyond any request's timeout) is reclaimed
// by the next lock owner, which restarts spacing from its own clock.
function pendingDispatches({ strict = false } = {}) {
if (!file) return [];
try {
@@ -273,6 +300,26 @@ export function createGateStateStore({
}
}
/**
* Under the lock: remove dispatch markers whose start is older than
* DISPATCH_RECLAIM_MS and report whether any were. Any other marker (newer,
* or naming no start) still refuses admission.
*/
function reclaimDispatches(at) {
const names = pendingDispatches({ strict: true });
for (const name of names) {
const since = /^since-(\d+)$/.exec(
name.slice(dispatchMarkerPrefix.length).split('.', 1)[0],
);
const started = since ? Number(since[1]) : NaN;
if (!Number.isSafeInteger(started) || at - started < DISPATCH_RECLAIM_MS)
throw new Error('unfinished durable Nominatim dispatch');
}
for (const name of names)
fs.unlinkSync(path.join(path.dirname(file), name));
return names.length > 0;
}
/**
* A refusal must remain visible even while another process owns the SQLite
* transaction. Immutable marker names make that write atomic and monotonic:
@@ -411,7 +458,7 @@ export function createGateStateStore({
const waitMs = Number.isFinite(lockWaitMs)
? Math.max(0, Math.floor(lockWaitMs))
: 500;
const database = new DatabaseSync(`${file}.lock.sqlite`, {
const database = new (sqliteModule().DatabaseSync)(`${file}.lock.sqlite`, {
timeout: waitMs,
});
let transactionOpen = false;
@@ -505,8 +552,7 @@ export function createGateStateStore({
try {
let result;
withLock(() => {
if (pendingDispatches({ strict: true }).length)
throw new Error('unfinished durable Nominatim dispatch');
const reclaimed = reclaimDispatches(clock());
const disk = readDisk({ strict: true });
if (disk) memory = merge(memory, disk);
const pauses = pendingPauses({ strict: true });
@@ -518,6 +564,11 @@ export function createGateStateStore({
),
});
const at = clock();
if (reclaimed) {
state.nextStartAt = Math.max(state.nextStartAt, at + minSpacingMs);
writeDisk(state);
memory = state;
}
if (at > latestStartAt) {
result = { status: 'abandoned' };
return;
@@ -538,7 +589,7 @@ export function createGateStateStore({
: { status: 'wait', waitMs: state.nextStartAt - at };
return;
}
const marker = `${file}.dispatch.${process.pid}.${process.hrtime.bigint()}`;
const marker = `${file}.dispatch.since-${at}.${process.pid}.${process.hrtime.bigint()}`;
fs.writeFileSync(marker, '', { flag: 'wx' });
const next = {
day,
+7 -44
View File
@@ -3,6 +3,10 @@ import {
containsAnchor,
pointInRing,
} from '../../../src/data/adminBoundaries.js';
import {
douglasPeucker,
ringAreaM2,
} from '../../../src/sources/featureGeometry.js';
/**
* Which Nominatim results may answer which voice ask, and how their polygons
@@ -231,11 +235,6 @@ function adminLevelFits(kind, row) {
const BUILDING_MAX_AREA_M2 = 150_000;
/** Approximate area (m²) of a [lon, lat] ring. */
function ringAreaM2(ring) {
const lat0 = ring[0][1];
const k = 111_320 * 111_320 * Math.cos((lat0 * Math.PI) / 180);
return ringArea(ring) * k;
}
/** Whether a result's class/type suits the ask kind. */
export function acceptsOutlineClass(kind, osmClass, osmType) {
@@ -358,12 +357,6 @@ function cleanRing(ring) {
}
/** Planar ring area in squared degrees (only used to rank parts). */
function ringArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++)
sum += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
return Math.abs(sum / 2);
}
/** Polygon/MultiPolygon GeoJSON → `[[outer, ...holes], …]`, largest first. */
function polygonsFromGeoJson(geometry) {
@@ -387,7 +380,7 @@ function polygonsFromGeoJson(geometry) {
if (!outer) return null;
const holes = polygon.slice(1).map(cleanRing);
if (holes.some((hole) => !hole)) return null;
polygons.push({ rings: [outer, ...holes], area: ringArea(outer) });
polygons.push({ rings: [outer, ...holes], area: ringAreaM2(outer) });
}
polygons.sort((a, b) => b.area - a.area);
return polygons.map((entry) => entry.rings);
@@ -396,37 +389,7 @@ function polygonsFromGeoJson(geometry) {
/** Iterative Douglas–Peucker on a closed ring (keeps it closed, ≥ 4 points). */
function simplifyClosedRing(ring, tolerance) {
if (ring.length <= 8 || tolerance <= 0) return ring;
const keep = new Uint8Array(ring.length);
keep[0] = keep[ring.length - 1] = 1;
const stack = [[0, ring.length - 1]];
const tol2 = tolerance * tolerance;
while (stack.length) {
const [start, end] = stack.pop();
const [ax, ay] = ring[start];
const [bx, by] = ring[end];
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
let best = -1;
let bestD = tol2;
for (let i = start + 1; i < end; i++) {
const [px, py] = ring[i];
let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const ex = ax + t * dx - px;
const ey = ay + t * dy - py;
const d = ex * ex + ey * ey;
if (d > bestD) {
bestD = d;
best = i;
}
}
if (best > 0) {
keep[best] = 1;
stack.push([start, best], [best, end]);
}
}
const out = ring.filter((_point, index) => keep[index]);
const out = douglasPeucker(ring, tolerance);
return out.length >= 4 ? out : ring;
}
@@ -480,7 +443,7 @@ function ringsIntersect(a, b) {
function simplifiedTopologyFits(polygons) {
for (const [outer, ...holes] of polygons) {
for (const ring of [outer, ...holes]) {
if (ringArea(ring) === 0) return false;
if (ringAreaM2(ring) === 0) return false;
for (let i = 1; i < ring.length; i++)
for (let j = i + 2; j < ring.length; j++) {
if (i === 1 && j === ring.length - 1) continue;