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
+420
-126
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
|
||||
# the server stopped mid-request (or its disk filled), it leaves a
|
||||
# state.json.dispatch.* marker and refuses public lookups ("unfinished durable
|
||||
# Nominatim dispatch") rather than risk breaking the spacing. With the server
|
||||
# stopped, delete those marker files to resume.
|
||||
# Nominatim dispatch") for ten minutes rather than risk breaking the spacing;
|
||||
# the next lookup after that removes the marker and resumes with fresh spacing.
|
||||
# PHOTON_URL: keyless place search endpoint used by the browser
|
||||
# (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=
|
||||
|
||||
# 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);
|
||||
|
||||
/** 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) {
|
||||
if (value === undefined) return undefined;
|
||||
const text = String(value ?? '').trim();
|
||||
@@ -34,7 +38,7 @@ function photonEndpointSetting(value) {
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
|
||||
@@ -42,3 +42,10 @@
|
||||
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
|
||||
(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
|
||||
per-install daily allowance and pause state that fail closed when unusable.
|
||||
The shared state lock covers actual fetch invocation and recording the next
|
||||
start. An unfinished dispatch marker disables further public requests until
|
||||
operator recovery; it is never automatically reclaimed. These controls do not
|
||||
establish an application-wide public-service allowance or authorize its use.
|
||||
For manual recovery, stop every process sharing the state file and account for
|
||||
the interrupted dispatch before removing only its confirmed orphan
|
||||
`state.json.dispatch.*` marker. Preserve the state file's count, pause and next
|
||||
start, and wait at least the public spacing interval after the last possible
|
||||
invocation. `stats().persistence.dispatchPending` exposes the marker; an unlocked
|
||||
status read can also briefly see a live dispatch. Do not clear markers while an
|
||||
owner is active or reset usage state to restore access.
|
||||
start. An unfinished dispatch marker (`state.json.dispatch.since-<ms>.*`, left
|
||||
by a crash or a persistence failure) disables further public requests for ten
|
||||
minutes after the start it names, far beyond any request's 9-second timeout.
|
||||
A marker exists only inside the lock, so the next lock owner after that window
|
||||
removes it and restarts spacing from its own clock; the count was persisted
|
||||
before the interrupted invocation. A marker naming no start keeps refusing
|
||||
until removed by hand. These controls do not establish an application-wide
|
||||
public-service allowance or authorize its use. For manual recovery, stop every
|
||||
process sharing the state file before removing only a confirmed orphan marker,
|
||||
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
|
||||
for 30 days (search), 90 days (outline) or 24 hours (genuine no result),
|
||||
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.
|
||||
- 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
|
||||
"Detailed outline unavailable"; location focus reports the same result without
|
||||
retrying. Missing capability never synthesizes an outline from a radius.
|
||||
|
||||
@@ -466,7 +466,8 @@
|
||||
"server/providers/regional/outlineSelect.js",
|
||||
"src/localRequestGate.mjs",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/placeNames.js"
|
||||
"src/data/placeNames.js",
|
||||
"src/sources/featureGeometry.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 have = normalizeStreetName(candidate);
|
||||
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 (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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user