mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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
@@ -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 answers = (have) => {
|
||||
if (have === want) return true;
|
||||
const [first, ...rest] = have.split(' ');
|
||||
return DIRECTIONS.has(first) && rest.join(' ') === want;
|
||||
};
|
||||
const have = normalizeStreetName(candidate);
|
||||
if (have === want) return true;
|
||||
const [first, ...rest] = have.split(' ');
|
||||
return DIRECTIONS.has(first) && rest.join(' ') === want;
|
||||
if (answers(have)) return true;
|
||||
const words = have.split(' ');
|
||||
return (
|
||||
QUADRANTS.has(words.at(-1)) &&
|
||||
!QUADRANTS.has(want.split(' ').at(-1)) &&
|
||||
answers(words.slice(0, -1).join(' '))
|
||||
);
|
||||
}
|
||||
|
||||
const lengthM = (line) => {
|
||||
@@ -423,6 +458,7 @@ export function buildingFromTiles(
|
||||
) {
|
||||
let containing = null;
|
||||
let fragmentContains = false;
|
||||
let nearestFragmentM = Infinity;
|
||||
let nearest = null;
|
||||
for (const tile of tiles || []) {
|
||||
for (const building of tile?.buildings || []) {
|
||||
@@ -451,9 +487,11 @@ export function buildingFromTiles(
|
||||
const area = ringAreaM2(building.rings[0]);
|
||||
if (!containing || area < containing.area)
|
||||
containing = { building, area };
|
||||
} else if (!building.clipped) {
|
||||
} else {
|
||||
const distance = distanceToPolygonM(lon, lat, building.rings);
|
||||
if (
|
||||
if (building.clipped)
|
||||
nearestFragmentM = Math.min(nearestFragmentM, distance);
|
||||
else if (
|
||||
distance <= maxDistanceM &&
|
||||
(!nearest || distance < nearest.distance)
|
||||
)
|
||||
@@ -461,9 +499,13 @@ export function buildingFromTiles(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The point is on a building only seen in pieces: no neighbour stands in.
|
||||
// The point is on, or nearest to, a building only seen in pieces: no
|
||||
// farther neighbour stands in. A complete copy from a neighbouring tile is
|
||||
// never farther than its fragment, so the centimetre tolerance keeps it.
|
||||
const fragmentNearer =
|
||||
fragmentContains || nearestFragmentM + 0.01 < (nearest?.distance ?? 0);
|
||||
const pick =
|
||||
containing?.building || (fragmentContains ? null : nearest?.building);
|
||||
containing?.building || (fragmentNearer ? null : nearest?.building);
|
||||
if (!pick) return null;
|
||||
const polygons = [pick.rings].map((part) =>
|
||||
part.map((ring) => closeRing(ring.map((p) => [p[0], p[1]]))),
|
||||
@@ -504,12 +546,14 @@ function groundsKey(value) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
const GROUNDS_WORDS_RE =
|
||||
/\b(?:grounds|campus|compound|premises|property|site|area|complex)\b/g;
|
||||
|
||||
/** Words that describe grounds rather than name the place. */
|
||||
const GROUNDS_SUFFIX_RE =
|
||||
/(?:\s+(?:grounds|campus|compound|premises|property|site|area|complex))+$/;
|
||||
const GROUNDS_WORDS =
|
||||
'grounds|campus|compound|premises|property|site|area|complex';
|
||||
|
||||
/** Grounds words wherever they stand, any case (the outline rung cuts these). */
|
||||
export const GROUNDS_WORDS_RE = new RegExp(`\\b(?:${GROUNDS_WORDS})\\b`, 'gi');
|
||||
|
||||
const GROUNDS_SUFFIX_RE = new RegExp(`(?:\\s+(?:${GROUNDS_WORDS}))+$`);
|
||||
|
||||
/**
|
||||
* Area classes an unnamed tile feature may answer for a grounds ask, from the
|
||||
|
||||
@@ -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