mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
a38a065c5c
commit
a61d0f4c2a
24 files changed
+422
-128
No files matched your search
+4
-3
@@ -283,11 +283,12 @@ OVERPASS_UPSTREAMS=
|
|||||||
# Public-instance pacing is shared through .gev-cache/nominatim/state.json. If
|
# Public-instance pacing is shared through .gev-cache/nominatim/state.json. If
|
||||||
# the server stopped mid-request (or its disk filled), it leaves a
|
# the server stopped mid-request (or its disk filled), it leaves a
|
||||||
# state.json.dispatch.* marker and refuses public lookups ("unfinished durable
|
# state.json.dispatch.* marker and refuses public lookups ("unfinished durable
|
||||||
# Nominatim dispatch") rather than risk breaking the spacing. With the server
|
# Nominatim dispatch") for ten minutes rather than risk breaking the spacing;
|
||||||
# stopped, delete those marker files to resume.
|
# the next lookup after that removes the marker and resumes with fresh spacing.
|
||||||
# PHOTON_URL: keyless place search endpoint used by the browser
|
# PHOTON_URL: keyless place search endpoint used by the browser
|
||||||
# (e.g. https://photon.example.org/api/). Unset uses photon.komoot.io; set it
|
# (e.g. https://photon.example.org/api/). Unset uses photon.komoot.io; set it
|
||||||
# empty to disable.
|
# empty to disable. Only https endpoints are accepted (the page's
|
||||||
|
# Content-Security-Policy allows no plain-http origin); any other value disables it.
|
||||||
# PHOTON_URL=
|
# PHOTON_URL=
|
||||||
|
|
||||||
# Optional: who may frame the app in embed mode (?embed=1), as CSP
|
# Optional: who may frame the app in embed mode (?embed=1), as CSP
|
||||||
|
|||||||
+6
-2
@@ -25,7 +25,11 @@ export function escapeFsDenyPath(path) {
|
|||||||
|
|
||||||
const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT);
|
const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT);
|
||||||
|
|
||||||
/** Browser-safe form of the optional Photon endpoint build input. */
|
/**
|
||||||
|
* Browser-safe form of the optional Photon endpoint build input. Only https
|
||||||
|
* is accepted: the page's connect-src allows no plain-http origin, so an
|
||||||
|
* http endpoint would only fail later, silently, in the browser.
|
||||||
|
*/
|
||||||
function photonEndpointSetting(value) {
|
function photonEndpointSetting(value) {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
const text = String(value ?? '').trim();
|
const text = String(value ?? '').trim();
|
||||||
@@ -34,7 +38,7 @@ function photonEndpointSetting(value) {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(text);
|
const url = new URL(text);
|
||||||
if (
|
if (
|
||||||
!['http:', 'https:'].includes(url.protocol) ||
|
url.protocol !== 'https:' ||
|
||||||
url.username ||
|
url.username ||
|
||||||
url.password ||
|
url.password ||
|
||||||
url.search ||
|
url.search ||
|
||||||
|
|||||||
@@ -42,3 +42,10 @@
|
|||||||
pick the same-name Nominatim outline that holds the resolved place, and keep
|
pick the same-name Nominatim outline that holds the resolved place, and keep
|
||||||
a new outline when an identical older mark fails to clean up
|
a new outline when an identical older mark fails to clean up
|
||||||
(samehkhamis, #842).
|
(samehkhamis, #842).
|
||||||
|
- Tighten the remaining voice outline review items: one place-name key for
|
||||||
|
server and tiles ("St. Paul" = "Saint Paul"), "E Street" no longer matching
|
||||||
|
"East Street" while "E St NW" answers "E Street", no neighbour standing in
|
||||||
|
for a building cut at the tile edge, no cut area answering grounds, https-only
|
||||||
|
PHOTON_URL, an interrupted Nominatim dispatch reclaimed after ten minutes,
|
||||||
|
no node:sqlite startup warning, and lazily decoded shared OpenFreeMap layers
|
||||||
|
(samehkhamis, #842).
|
||||||
+14
-11
@@ -5380,16 +5380,19 @@ Explicit public-service configuration retains an identifying `User-Agent` and `R
|
|||||||
least 1.1 seconds between public search and outline starts, a shared durable
|
least 1.1 seconds between public search and outline starts, a shared durable
|
||||||
per-install daily allowance and pause state that fail closed when unusable.
|
per-install daily allowance and pause state that fail closed when unusable.
|
||||||
The shared state lock covers actual fetch invocation and recording the next
|
The shared state lock covers actual fetch invocation and recording the next
|
||||||
start. An unfinished dispatch marker disables further public requests until
|
start. An unfinished dispatch marker (`state.json.dispatch.since-<ms>.*`, left
|
||||||
operator recovery; it is never automatically reclaimed. These controls do not
|
by a crash or a persistence failure) disables further public requests for ten
|
||||||
establish an application-wide public-service allowance or authorize its use.
|
minutes after the start it names, far beyond any request's 9-second timeout.
|
||||||
For manual recovery, stop every process sharing the state file and account for
|
A marker exists only inside the lock, so the next lock owner after that window
|
||||||
the interrupted dispatch before removing only its confirmed orphan
|
removes it and restarts spacing from its own clock; the count was persisted
|
||||||
`state.json.dispatch.*` marker. Preserve the state file's count, pause and next
|
before the interrupted invocation. A marker naming no start keeps refusing
|
||||||
start, and wait at least the public spacing interval after the last possible
|
until removed by hand. These controls do not establish an application-wide
|
||||||
invocation. `stats().persistence.dispatchPending` exposes the marker; an unlocked
|
public-service allowance or authorize its use. For manual recovery, stop every
|
||||||
status read can also briefly see a live dispatch. Do not clear markers while an
|
process sharing the state file before removing only a confirmed orphan marker,
|
||||||
owner is active or reset usage state to restore access.
|
and preserve the state file's count, pause and next start.
|
||||||
|
`stats().persistence.dispatchPending` exposes the marker; an unlocked status
|
||||||
|
read can also briefly see a live dispatch. Do not reset usage state to restore
|
||||||
|
access.
|
||||||
Answers are cached
|
Answers are cached
|
||||||
for 30 days (search), 90 days (outline) or 24 hours (genuine no result),
|
for 30 days (search), 90 days (outline) or 24 hours (genuine no result),
|
||||||
one upstream call shared between identical searches
|
one upstream call shared between identical searches
|
||||||
@@ -5463,7 +5466,7 @@ request viewbox and continues searching without that geographic preference.
|
|||||||
|
|
||||||
- Runtime entry: `src/main.js` calls `initAnnotations({ viewer, tileset })`, exposes `window.__gevAnnotations`, and passes the engine into the voice action runner.
|
- Runtime entry: `src/main.js` calls `initAnnotations({ viewer, tileset })`, exposes `window.__gevAnnotations`, and passes the engine into the voice action runner.
|
||||||
- Engine contract: `src/annotations/annotationEngine.js` owns annotation state, TTL/fade lifecycle, concurrent anchor resolution, duplicate detection keyed on geometry, cancellation on clear/newer generations, and a hard cap of 120 live marks. Deferred outline upgrades drain FIFO at concurrency 2; queued work retains the owning abort controller and is discarded on a generation change before it can fetch.
|
- Engine contract: `src/annotations/annotationEngine.js` owns annotation state, TTL/fade lifecycle, concurrent anchor resolution, duplicate detection keyed on geometry, cancellation on clear/newer generations, and a hard cap of 120 live marks. Deferred outline upgrades drain FIFO at concurrency 2; queued work retains the owning abort controller and is discarded on a generation change before it can fetch.
|
||||||
- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. Tile building and grounds answers also need the tile holding the anchor: when that tile fails to load and only neighbours arrive, no neighbouring building or area stands in. Tile grounds answer a named grounds ask only from an area of the same name, or, when the ask's head noun names a kind of grounds (school, university/college, hospital, cemetery, stadium, zoo; "campus" alone names none), from the part of the smallest unnamed area of that class that holds the anchor (a class word elsewhere in a name, as in "University Avenue Park", does not count); a differently named area never answers. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Guarded Nominatim keeps every accepted result for an ask, ranks names holding every asked word first, refuses a partial landmark match whose name lacks the ask's last word (the place around a feature, such as a park around its zoo, is not the feature), and answers each ask with the cached result whose filled area holds the resolved anchor, else the first. A street ribbon that holds neither its ring mean nor the anchor is centred on the street's nearest point to the anchor. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
|
- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. Tile building and grounds answers also need the tile holding the anchor: when that tile fails to load and only neighbours arrive, no neighbouring building or area stands in. Tile grounds answer a named grounds ask only from an area of the same name, or, when the ask's head noun names a kind of grounds (school, university/college, hospital, cemetery, stadium, zoo; "campus" alone names none), from the part of the smallest unnamed area of that class that holds the anchor (a class word elsewhere in a name, as in "University Avenue Park", does not count); a differently named area never answers. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Guarded Nominatim keeps every accepted result for an ask, ranks an exact name first, then a name holding every asked word, then a partial match; a partial landmark match whose name lacks the ask's last word is refused unless that word appears in the result's address or type (the place around a feature, such as a park around its zoo, is not the feature, while "Space Needle Seattle" still finds the Space Needle). Each ask is answered with the cached result whose filled area holds the resolved anchor (read across the antimeridian), else the first. Server and tiles fold place names with one key, so "St. Paul" and "Saint Paul" agree. A street ribbon that holds neither its ring mean nor the anchor is centred on the street's nearest point to the anchor when the street runs within 100 m of it. In tile street names a lone letter is a direction only beside another name word ("N Lamar Blvd"), so "E Street" is not "East Street", and a trailing quadrant ("E St NW") may go unsaid in the ask. A building or area cut at the tile edge (with or without a tile buffer) is not complete: a cut building nearer the point than any whole one leaves the point, and a cut area does not answer grounds. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
|
||||||
- With no detailed-feature capability, annotations retain their pins and say
|
- With no detailed-feature capability, annotations retain their pins and say
|
||||||
"Detailed outline unavailable"; location focus reports the same result without
|
"Detailed outline unavailable"; location focus reports the same result without
|
||||||
retrying. Missing capability never synthesizes an outline from a radius.
|
retrying. Missing capability never synthesizes an outline from a radius.
|
||||||
|
|||||||
@@ -466,7 +466,8 @@
|
|||||||
"server/providers/regional/outlineSelect.js",
|
"server/providers/regional/outlineSelect.js",
|
||||||
"src/localRequestGate.mjs",
|
"src/localRequestGate.mjs",
|
||||||
"src/data/adminBoundaries.js",
|
"src/data/adminBoundaries.js",
|
||||||
"src/data/placeNames.js"
|
"src/data/placeNames.js",
|
||||||
|
"src/sources/featureGeometry.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
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;
|
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
|
* 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
|
* @param {string} value
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function normalizePlaceName(value) {
|
export function normalizePlaceName(value) {
|
||||||
return foldPlaceName(value).slice(0, 200);
|
return foldName(value).slice(0, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
|
||||||
import { readResponseJsonCapped } from '../common/http.js';
|
import { readResponseJsonCapped } from '../common/http.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,9 +49,34 @@ const DEFAULT_BACKOFF_MS = 60_000;
|
|||||||
const BLOCKED_BACKOFF_MS = 3_600_000;
|
const BLOCKED_BACKOFF_MS = 3_600_000;
|
||||||
/** Pause after a server error or a failed retry. */
|
/** Pause after a server error or a failed retry. */
|
||||||
const OUTAGE_BACKOFF_MS = 30_000;
|
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']);
|
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.
|
* Read the search endpoint setting.
|
||||||
*
|
*
|
||||||
@@ -257,8 +281,11 @@ export function createGateStateStore({
|
|||||||
const dispatchMarkerPrefix = file ? `${path.basename(file)}.dispatch.` : null;
|
const dispatchMarkerPrefix = file ? `${path.basename(file)}.dispatch.` : null;
|
||||||
|
|
||||||
// A crash or persistence failure during dispatch must never reopen admission
|
// A crash or persistence failure during dispatch must never reopen admission
|
||||||
// using an older timestamp. Only this dispatch removes its own marker after
|
// using an older timestamp. A dispatch removes its own marker after
|
||||||
// persisting the post-invocation deadline; there is no timed stale reclaim.
|
// 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 } = {}) {
|
function pendingDispatches({ strict = false } = {}) {
|
||||||
if (!file) return [];
|
if (!file) return [];
|
||||||
try {
|
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
|
* A refusal must remain visible even while another process owns the SQLite
|
||||||
* transaction. Immutable marker names make that write atomic and monotonic:
|
* transaction. Immutable marker names make that write atomic and monotonic:
|
||||||
@@ -411,7 +458,7 @@ export function createGateStateStore({
|
|||||||
const waitMs = Number.isFinite(lockWaitMs)
|
const waitMs = Number.isFinite(lockWaitMs)
|
||||||
? Math.max(0, Math.floor(lockWaitMs))
|
? Math.max(0, Math.floor(lockWaitMs))
|
||||||
: 500;
|
: 500;
|
||||||
const database = new DatabaseSync(`${file}.lock.sqlite`, {
|
const database = new (sqliteModule().DatabaseSync)(`${file}.lock.sqlite`, {
|
||||||
timeout: waitMs,
|
timeout: waitMs,
|
||||||
});
|
});
|
||||||
let transactionOpen = false;
|
let transactionOpen = false;
|
||||||
@@ -505,8 +552,7 @@ export function createGateStateStore({
|
|||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
withLock(() => {
|
withLock(() => {
|
||||||
if (pendingDispatches({ strict: true }).length)
|
const reclaimed = reclaimDispatches(clock());
|
||||||
throw new Error('unfinished durable Nominatim dispatch');
|
|
||||||
const disk = readDisk({ strict: true });
|
const disk = readDisk({ strict: true });
|
||||||
if (disk) memory = merge(memory, disk);
|
if (disk) memory = merge(memory, disk);
|
||||||
const pauses = pendingPauses({ strict: true });
|
const pauses = pendingPauses({ strict: true });
|
||||||
@@ -518,6 +564,11 @@ export function createGateStateStore({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
const at = clock();
|
const at = clock();
|
||||||
|
if (reclaimed) {
|
||||||
|
state.nextStartAt = Math.max(state.nextStartAt, at + minSpacingMs);
|
||||||
|
writeDisk(state);
|
||||||
|
memory = state;
|
||||||
|
}
|
||||||
if (at > latestStartAt) {
|
if (at > latestStartAt) {
|
||||||
result = { status: 'abandoned' };
|
result = { status: 'abandoned' };
|
||||||
return;
|
return;
|
||||||
@@ -538,7 +589,7 @@ export function createGateStateStore({
|
|||||||
: { status: 'wait', waitMs: state.nextStartAt - at };
|
: { status: 'wait', waitMs: state.nextStartAt - at };
|
||||||
return;
|
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' });
|
fs.writeFileSync(marker, '', { flag: 'wx' });
|
||||||
const next = {
|
const next = {
|
||||||
day,
|
day,
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
containsAnchor,
|
containsAnchor,
|
||||||
pointInRing,
|
pointInRing,
|
||||||
} from '../../../src/data/adminBoundaries.js';
|
} 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
|
* 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;
|
const BUILDING_MAX_AREA_M2 = 150_000;
|
||||||
|
|
||||||
/** Approximate area (m²) of a [lon, lat] ring. */
|
/** 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. */
|
/** Whether a result's class/type suits the ask kind. */
|
||||||
export function acceptsOutlineClass(kind, osmClass, osmType) {
|
export function acceptsOutlineClass(kind, osmClass, osmType) {
|
||||||
@@ -358,12 +357,6 @@ function cleanRing(ring) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Planar ring area in squared degrees (only used to rank parts). */
|
/** 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. */
|
/** Polygon/MultiPolygon GeoJSON → `[[outer, ...holes], …]`, largest first. */
|
||||||
function polygonsFromGeoJson(geometry) {
|
function polygonsFromGeoJson(geometry) {
|
||||||
@@ -387,7 +380,7 @@ function polygonsFromGeoJson(geometry) {
|
|||||||
if (!outer) return null;
|
if (!outer) return null;
|
||||||
const holes = polygon.slice(1).map(cleanRing);
|
const holes = polygon.slice(1).map(cleanRing);
|
||||||
if (holes.some((hole) => !hole)) return null;
|
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);
|
polygons.sort((a, b) => b.area - a.area);
|
||||||
return polygons.map((entry) => entry.rings);
|
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). */
|
/** Iterative Douglas–Peucker on a closed ring (keeps it closed, ≥ 4 points). */
|
||||||
function simplifyClosedRing(ring, tolerance) {
|
function simplifyClosedRing(ring, tolerance) {
|
||||||
if (ring.length <= 8 || tolerance <= 0) return ring;
|
if (ring.length <= 8 || tolerance <= 0) return ring;
|
||||||
const keep = new Uint8Array(ring.length);
|
const out = douglasPeucker(ring, tolerance);
|
||||||
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]);
|
|
||||||
return out.length >= 4 ? out : ring;
|
return out.length >= 4 ? out : ring;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +443,7 @@ function ringsIntersect(a, b) {
|
|||||||
function simplifiedTopologyFits(polygons) {
|
function simplifiedTopologyFits(polygons) {
|
||||||
for (const [outer, ...holes] of polygons) {
|
for (const [outer, ...holes] of polygons) {
|
||||||
for (const ring of [outer, ...holes]) {
|
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 i = 1; i < ring.length; i++)
|
||||||
for (let j = i + 2; j < ring.length; j++) {
|
for (let j = i + 2; j < ring.length; j++) {
|
||||||
if (i === 1 && j === ring.length - 1) continue;
|
if (i === 1 && j === ring.length - 1) continue;
|
||||||
|
|||||||
@@ -411,6 +411,8 @@ export function createAnnotationEngine({
|
|||||||
const results = [];
|
const results = [];
|
||||||
const ids = [];
|
const ids = [];
|
||||||
const installations = [];
|
const installations = [];
|
||||||
|
// Result slots an installation settles; only these are re-validated after the await.
|
||||||
|
const installed = new Set();
|
||||||
const trackBuildingInstall = (
|
const trackBuildingInstall = (
|
||||||
anno,
|
anno,
|
||||||
resolved,
|
resolved,
|
||||||
@@ -423,6 +425,7 @@ export function createAnnotationEngine({
|
|||||||
ensureTicking();
|
ensureTicking();
|
||||||
const index = results.length;
|
const index = results.length;
|
||||||
results.push(null);
|
results.push(null);
|
||||||
|
installed.add(index);
|
||||||
const record = beginBuildingInstall(anno);
|
const record = beginBuildingInstall(anno);
|
||||||
installations.push(
|
installations.push(
|
||||||
Promise.resolve(completion)
|
Promise.resolve(completion)
|
||||||
@@ -589,6 +592,7 @@ export function createAnnotationEngine({
|
|||||||
}
|
}
|
||||||
const index = results.length;
|
const index = results.length;
|
||||||
results.push(null);
|
results.push(null);
|
||||||
|
installed.add(index);
|
||||||
anno.pendingOutline = true;
|
anno.pendingOutline = true;
|
||||||
installations.push(
|
installations.push(
|
||||||
Promise.resolve(completion)
|
Promise.resolve(completion)
|
||||||
@@ -642,11 +646,12 @@ export function createAnnotationEngine({
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Remove before delete: a failed cleanup stays addressable through the old slot.
|
||||||
|
removeRendered(dup);
|
||||||
annotations.delete(dup.id);
|
annotations.delete(dup.id);
|
||||||
annotations.set(anno.id, anno);
|
annotations.set(anno.id, anno);
|
||||||
syncAnnotationHold();
|
syncAnnotationHold();
|
||||||
try {
|
try {
|
||||||
removeRendered(dup);
|
|
||||||
renderer.add(anno);
|
renderer.add(anno);
|
||||||
} catch (swapError) {
|
} catch (swapError) {
|
||||||
// Same contract as the fresh path: a renderer/WebGL throw must
|
// Same contract as the fresh path: a renderer/WebGL throw must
|
||||||
@@ -690,6 +695,7 @@ export function createAnnotationEngine({
|
|||||||
if (record) {
|
if (record) {
|
||||||
const index = results.length;
|
const index = results.length;
|
||||||
results.push(null);
|
results.push(null);
|
||||||
|
installed.add(index);
|
||||||
installations.push(
|
installations.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
do {
|
do {
|
||||||
@@ -788,7 +794,7 @@ export function createAnnotationEngine({
|
|||||||
await Promise.all(installations);
|
await Promise.all(installations);
|
||||||
for (let index = 0; index < results.length; index += 1) {
|
for (let index = 0; index < results.length; index += 1) {
|
||||||
const result = results[index];
|
const result = results[index];
|
||||||
if (!result?.ok) continue;
|
if (!result?.ok || !installed.has(index)) continue;
|
||||||
const current = annotations.get(result.id);
|
const current = annotations.get(result.id);
|
||||||
if (
|
if (
|
||||||
!current ||
|
!current ||
|
||||||
|
|||||||
@@ -1249,3 +1249,56 @@ test('recaption inherits outline credit from borrowed geometry rather than its n
|
|||||||
assert.equal(engine.list()[0].ring, ring);
|
assert.equal(engine.list()[0].ring, ring);
|
||||||
engine.clear();
|
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);
|
||||||
|
});
|
||||||
@@ -186,12 +186,14 @@ export function createHybridAnnotationRenderer(
|
|||||||
if (entry.screenProxy) screen.remove(entry.screenProxy);
|
if (entry.screenProxy) screen.remove(entry.screenProxy);
|
||||||
entry.screenProxy = null;
|
entry.screenProxy = null;
|
||||||
} else {
|
} else {
|
||||||
|
// The caption conversion already reshaped the group; redraw the pending point.
|
||||||
const pending = liveProxy(anno, {
|
const pending = liveProxy(anno, {
|
||||||
ring: null,
|
ring: null,
|
||||||
pendingOutline: true,
|
pendingOutline: true,
|
||||||
});
|
});
|
||||||
screen.update(pending);
|
if (entry.screenProxy) screen.remove(entry.screenProxy);
|
||||||
entry.screenProxy = pending;
|
entry.screenProxy = pending;
|
||||||
|
screen.add(pending);
|
||||||
}
|
}
|
||||||
if (workTrace) workTrace.rendererStatus = 'timeout';
|
if (workTrace) workTrace.rendererStatus = 'timeout';
|
||||||
return { ...result, status: 'timeout' };
|
return { ...result, status: 'timeout' };
|
||||||
|
|||||||
@@ -757,3 +757,30 @@ test('an asynchronous caption-conversion throw rolls back multipart world geomet
|
|||||||
renderer.remove(anno);
|
renderer.remove(anno);
|
||||||
renderer.destroy();
|
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();
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { containsAnchor } from '../data/adminBoundaries.js';
|
|||||||
import { lonLatToTile } from '../data/tomtomTiles.js';
|
import { lonLatToTile } from '../data/tomtomTiles.js';
|
||||||
import {
|
import {
|
||||||
BUILDING_MAX_DISTANCE_M,
|
BUILDING_MAX_DISTANCE_M,
|
||||||
|
GROUNDS_WORDS_RE,
|
||||||
OUTLINE_TILE_ZOOM,
|
OUTLINE_TILE_ZOOM,
|
||||||
STREET_RADIUS_M,
|
STREET_RADIUS_M,
|
||||||
boxAround,
|
boxAround,
|
||||||
@@ -48,10 +49,6 @@ const GROUNDS_MIN_AREA_M2 = 20_000;
|
|||||||
const DEICTIC_RE =
|
const DEICTIC_RE =
|
||||||
/^(?:(?:this|that|the|my)\s+)?(?:building|house|structure|tower|place|one|thing|spot|here|there|it)$/i;
|
/^(?:(?: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). */
|
/** Whether the ask names a thing (vs. "this building" at a pointer). */
|
||||||
export function isNamedAsk(target) {
|
export function isNamedAsk(target) {
|
||||||
const text = String(target || '').trim();
|
const text = String(target || '').trim();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { recordWorkSlice } from '../sources/phaseTiming.js';
|
import { recordWorkSlice } from '../sources/phaseTiming.js';
|
||||||
|
import { ringCentroid } from '../sources/featureGeometry.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js';
|
||||||
|
|
||||||
@@ -741,14 +742,7 @@ function decimateRing(ring, n) {
|
|||||||
* while scheduling the expensive scene picks across rendered frames.
|
* while scheduling the expensive scene picks across rendered frames.
|
||||||
*/
|
*/
|
||||||
function groundProbePositions(ring) {
|
function groundProbePositions(ring) {
|
||||||
let clon = 0;
|
const { lon: clon, lat: clat } = ringCentroid(ring);
|
||||||
let clat = 0;
|
|
||||||
for (const [lon, lat] of ring) {
|
|
||||||
clon += lon;
|
|
||||||
clat += lat;
|
|
||||||
}
|
|
||||||
clon /= ring.length;
|
|
||||||
clat /= ring.length;
|
|
||||||
const latS = 111320;
|
const latS = 111320;
|
||||||
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
|
const lonS = latS * Math.cos(Cesium.Math.toRadians(clat));
|
||||||
let maxR = 0;
|
let maxR = 0;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
rank,
|
rank,
|
||||||
} from './adminBoundaries.js';
|
} from './adminBoundaries.js';
|
||||||
import { loadBundledJson } from './bundledJson.js';
|
import { loadBundledJson } from './bundledJson.js';
|
||||||
import { foldPlaceName } from './placeNames.js';
|
import { normalizePlaceName } from './placeNames.js';
|
||||||
import { createRetryableLoader } from './retryableLoad.js';
|
import { createRetryableLoader } from './retryableLoad.js';
|
||||||
import * as placeFiles from './local_data/us_census_places/files.js';
|
import * as placeFiles from './local_data/us_census_places/files.js';
|
||||||
import * as neighborhoodFiles from './local_data/wof_neighborhoods/files.js';
|
import * as neighborhoodFiles from './local_data/wof_neighborhoods/files.js';
|
||||||
@@ -76,15 +76,7 @@ export function exactPlaceName(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { normalizePlaceName };
|
||||||
* 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index keys of a name: as spelled (`=`-prefixed), then folded. */
|
/** Index keys of a name: as spelled (`=`-prefixed), then folded. */
|
||||||
function nameVariants(value) {
|
function nameVariants(value) {
|
||||||
|
|||||||
@@ -32,3 +32,13 @@ export function foldPlaceName(value) {
|
|||||||
.trim()
|
.trim()
|
||||||
.replace(/^the /, '');
|
.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');
|
||||||
|
}
|
||||||
@@ -97,7 +97,8 @@ export function simplifyRing(ring, tolM) {
|
|||||||
return out.length >= 4 ? out : ring;
|
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;
|
const n = points.length;
|
||||||
if (n < 3) return points.slice();
|
if (n < 3) return points.slice();
|
||||||
// Iterative, index-based (no per-call array slicing / recursion), so a large
|
// Iterative, index-based (no per-call array slicing / recursion), so a large
|
||||||
|
|||||||
@@ -283,14 +283,10 @@ function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
|
|||||||
|
|
||||||
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
|
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
|
||||||
const tile = new VectorTile(new PbfReader(bytes));
|
const tile = new VectorTile(new PbfReader(bytes));
|
||||||
const base = decodeLayers(
|
// Each layer is decoded when a consumer first selects it, so installations
|
||||||
bytes,
|
// never pay for roads, nor outlines for either.
|
||||||
z,
|
let roads = null;
|
||||||
x,
|
let military = null;
|
||||||
y,
|
|
||||||
['transportation', 'landuse'],
|
|
||||||
tile,
|
|
||||||
);
|
|
||||||
const projections = new Map();
|
const projections = new Map();
|
||||||
let baseBytes = 0;
|
let baseBytes = 0;
|
||||||
let projectionBytes = 0;
|
let projectionBytes = 0;
|
||||||
@@ -309,7 +305,26 @@ function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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,
|
project,
|
||||||
bindSharedCacheEntry(resize, initialBytes) {
|
bindSharedCacheEntry(resize, initialBytes) {
|
||||||
resizeCacheEntry = resize;
|
resizeCacheEntry = resize;
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ function decodePolygons(
|
|||||||
if (layer.length > LAYER_FEATURE_LIMIT)
|
if (layer.length > LAYER_FEATURE_LIMIT)
|
||||||
throw new Error('Vector tile feature limit exceeded');
|
throw new Error('Vector tile feature limit exceeded');
|
||||||
const extent = layer.extent || 4096;
|
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 minX = 0;
|
||||||
let minY = 0;
|
let minY = 0;
|
||||||
let maxX = extent;
|
let maxX = extent;
|
||||||
@@ -204,12 +204,14 @@ function decodePolygons(
|
|||||||
}
|
}
|
||||||
const [westCut, northCut] = pixelToLonLat(minX, minY, extent, z, x, y);
|
const [westCut, northCut] = pixelToLonLat(minX, minY, extent, z, x, y);
|
||||||
const [eastCut, southCut] = pixelToLonLat(maxX, maxY, 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 eps = 1e-9;
|
||||||
const onCut = ([lon, lat]) =>
|
const onCut = ([lon, lat]) =>
|
||||||
(minX < 0 && Math.abs(lon - westCut) < eps) ||
|
Math.abs(lon - westCut) < eps ||
|
||||||
(maxX > extent && Math.abs(lon - eastCut) < eps) ||
|
Math.abs(lon - eastCut) < eps ||
|
||||||
(minY < 0 && Math.abs(lat - northCut) < eps) ||
|
Math.abs(lat - northCut) < eps ||
|
||||||
(maxY > extent && Math.abs(lat - southCut) < eps);
|
Math.abs(lat - southCut) < eps;
|
||||||
const out = [];
|
const out = [];
|
||||||
for (let i = 0; i < layer.length; i++) {
|
for (let i = 0; i < layer.length; i++) {
|
||||||
const feature = layer.feature(i);
|
const feature = layer.feature(i);
|
||||||
@@ -305,36 +307,69 @@ const STREET_WORDS = Object.freeze({
|
|||||||
pl: 'place',
|
pl: 'place',
|
||||||
ct: 'court',
|
ct: 'court',
|
||||||
sq: 'square',
|
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',
|
n: 'north',
|
||||||
s: 'south',
|
s: 'south',
|
||||||
e: 'east',
|
e: 'east',
|
||||||
w: 'west',
|
w: 'west',
|
||||||
});
|
});
|
||||||
|
|
||||||
const DIRECTIONS = new Set(['north', 'south', 'east', '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. */
|
/** Comparable street name: the shared place-name fold, abbreviations expanded. */
|
||||||
export function normalizeStreetName(value) {
|
export function normalizeStreetName(value) {
|
||||||
return foldPlaceName(String(value ?? '').split(',')[0])
|
const words = foldPlaceName(String(value ?? '').split(',')[0])
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.filter(Boolean)
|
.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(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a tile street name answers the asked street. Exact after
|
* Whether a tile street name answers the asked street. Exact after
|
||||||
* normalization; otherwise the same name with only a leading direction added
|
* normalization; otherwise the same name with only a leading direction added
|
||||||
* ("North Congress Avenue" for "Congress Avenue") — never a different street
|
* ("North Congress Avenue" for "Congress Avenue"), or a quadrant suffix the
|
||||||
* that merely shares a word.
|
* ask leaves unsaid ("E Street Northwest" for "E Street") — never a different
|
||||||
|
* street that merely shares a word.
|
||||||
*/
|
*/
|
||||||
export function streetNameMatches(candidate, asked) {
|
export function streetNameMatches(candidate, asked) {
|
||||||
const want = normalizeStreetName(asked);
|
const want = normalizeStreetName(asked);
|
||||||
if (!want) return false;
|
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);
|
const have = normalizeStreetName(candidate);
|
||||||
if (have === want) return true;
|
if (answers(have)) return true;
|
||||||
const [first, ...rest] = have.split(' ');
|
const words = have.split(' ');
|
||||||
return DIRECTIONS.has(first) && rest.join(' ') === want;
|
return (
|
||||||
|
QUADRANTS.has(words.at(-1)) &&
|
||||||
|
!QUADRANTS.has(want.split(' ').at(-1)) &&
|
||||||
|
answers(words.slice(0, -1).join(' '))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lengthM = (line) => {
|
const lengthM = (line) => {
|
||||||
@@ -423,6 +458,7 @@ export function buildingFromTiles(
|
|||||||
) {
|
) {
|
||||||
let containing = null;
|
let containing = null;
|
||||||
let fragmentContains = false;
|
let fragmentContains = false;
|
||||||
|
let nearestFragmentM = Infinity;
|
||||||
let nearest = null;
|
let nearest = null;
|
||||||
for (const tile of tiles || []) {
|
for (const tile of tiles || []) {
|
||||||
for (const building of tile?.buildings || []) {
|
for (const building of tile?.buildings || []) {
|
||||||
@@ -451,9 +487,11 @@ export function buildingFromTiles(
|
|||||||
const area = ringAreaM2(building.rings[0]);
|
const area = ringAreaM2(building.rings[0]);
|
||||||
if (!containing || area < containing.area)
|
if (!containing || area < containing.area)
|
||||||
containing = { building, area };
|
containing = { building, area };
|
||||||
} else if (!building.clipped) {
|
} else {
|
||||||
const distance = distanceToPolygonM(lon, lat, building.rings);
|
const distance = distanceToPolygonM(lon, lat, building.rings);
|
||||||
if (
|
if (building.clipped)
|
||||||
|
nearestFragmentM = Math.min(nearestFragmentM, distance);
|
||||||
|
else if (
|
||||||
distance <= maxDistanceM &&
|
distance <= maxDistanceM &&
|
||||||
(!nearest || distance < nearest.distance)
|
(!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 =
|
const pick =
|
||||||
containing?.building || (fragmentContains ? null : nearest?.building);
|
containing?.building || (fragmentNearer ? null : nearest?.building);
|
||||||
if (!pick) return null;
|
if (!pick) return null;
|
||||||
const polygons = [pick.rings].map((part) =>
|
const polygons = [pick.rings].map((part) =>
|
||||||
part.map((ring) => closeRing(ring.map((p) => [p[0], p[1]]))),
|
part.map((ring) => closeRing(ring.map((p) => [p[0], p[1]]))),
|
||||||
@@ -504,12 +546,14 @@ function groundsKey(value) {
|
|||||||
.trim();
|
.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. */
|
/** Words that describe grounds rather than name the place. */
|
||||||
const GROUNDS_SUFFIX_RE =
|
const GROUNDS_WORDS =
|
||||||
/(?:\s+(?:grounds|campus|compound|premises|property|site|area|complex))+$/;
|
'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
|
* Area classes an unnamed tile feature may answer for a grounds ask, from the
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ test('street names compare with abbreviations and a leading direction only', ()
|
|||||||
assert.ok(streetNameMatches('ถนนสุขุมวิท', 'ถนนสุขุมวิท'));
|
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)', () => {
|
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
|
||||||
const street = streetFromTiles(TILES, {
|
const street = streetFromTiles(TILES, {
|
||||||
name: 'Congress Avenue',
|
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', () => {
|
test('a point in a courtyard is not inside the building', () => {
|
||||||
const block = {
|
const block = {
|
||||||
rings: [square(0, 0, 0.001), square(0.0004, 0.0004, 0.0002).reverse()],
|
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.equal(calls.length, 1);
|
||||||
assert.deepEqual(markers(), orphan);
|
assert.deepEqual(markers(), orphan);
|
||||||
assert.equal(createGateStateStore({ file }).count('2026-09-29'), 1);
|
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) => {
|
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',
|
'non-Latin names survive',
|
||||||
);
|
);
|
||||||
assert.equal(normalizePlaceName('Central Park'), 'central park');
|
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) => {
|
test('small view changes reuse one cached outline', async (t) => {
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
|||||||
'null',
|
'null',
|
||||||
'credentials are never serialized into browser assets',
|
'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(
|
assert.equal(
|
||||||
createBrowserViteConfig({ mapillaryToken: 'MLY|1|abc' }).define[
|
createBrowserViteConfig({ mapillaryToken: 'MLY|1|abc' }).define[
|
||||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN'
|
'import.meta.env.MAPILLARY_CLIENT_TOKEN'
|
||||||
|
|||||||
Reference in new issue
Block a user