tighten the remaining voice outline review items

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

No files matched your search

+2 -1
View File
@@ -97,7 +97,8 @@ export function simplifyRing(ring, tolM) {
return out.length >= 4 ? out : ring;
}
function douglasPeucker(points, tol) {
/** Douglas-Peucker on [lon, lat] points with a tolerance in degrees. */
export function douglasPeucker(points, tol) {
const n = points.length;
if (n < 3) return points.slice();
// Iterative, index-based (no per-call array slicing / recursion), so a large
+24 -9
View File
@@ -283,14 +283,10 @@ function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
const tile = new VectorTile(new PbfReader(bytes));
const base = decodeLayers(
bytes,
z,
x,
y,
['transportation', 'landuse'],
tile,
);
// Each layer is decoded when a consumer first selects it, so installations
// never pay for roads, nor outlines for either.
let roads = null;
let military = null;
const projections = new Map();
let baseBytes = 0;
let projectionBytes = 0;
@@ -309,7 +305,26 @@ function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
};
return {
...base,
get roads() {
return (roads ??= decodeLayers(
bytes,
z,
x,
y,
['transportation'],
tile,
).roads);
},
get military() {
return (military ??= decodeLayers(
bytes,
z,
x,
y,
['landuse'],
tile,
).military);
},
project,
bindSharedCacheEntry(resize, initialBytes) {
resizeCacheEntry = resize;
+66 -22
View File
@@ -189,7 +189,7 @@ function decodePolygons(
if (layer.length > LAYER_FEATURE_LIMIT)
throw new Error('Vector tile feature limit exceeded');
const extent = layer.extent || 4096;
// The cut lines are the layer's extreme coordinates beyond the tile core.
// Cut lines: the layer's extreme coordinates, at or past the tile edge.
let minX = 0;
let minY = 0;
let maxX = extent;
@@ -204,12 +204,14 @@ function decodePolygons(
}
const [westCut, northCut] = pixelToLonLat(minX, minY, extent, z, x, y);
const [eastCut, southCut] = pixelToLonLat(maxX, maxY, extent, z, x, y);
// A side with no buffer was cut at the tile edge itself, so a polygon that
// touches it is a fragment too.
const eps = 1e-9;
const onCut = ([lon, lat]) =>
(minX < 0 && Math.abs(lon - westCut) < eps) ||
(maxX > extent && Math.abs(lon - eastCut) < eps) ||
(minY < 0 && Math.abs(lat - northCut) < eps) ||
(maxY > extent && Math.abs(lat - southCut) < eps);
Math.abs(lon - westCut) < eps ||
Math.abs(lon - eastCut) < eps ||
Math.abs(lat - northCut) < eps ||
Math.abs(lat - southCut) < eps;
const out = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
@@ -305,36 +307,69 @@ const STREET_WORDS = Object.freeze({
pl: 'place',
ct: 'court',
sq: 'square',
nw: 'northwest',
ne: 'northeast',
sw: 'southwest',
se: 'southeast',
});
const STREET_TYPES = new Set(Object.values(STREET_WORDS));
const DIRECTION_LETTERS = Object.freeze({
n: 'north',
s: 'south',
e: 'east',
w: 'west',
});
const DIRECTIONS = new Set(['north', 'south', 'east', 'west']);
const QUADRANTS = new Set(['northwest', 'northeast', 'southwest', 'southeast']);
/** Comparable street name: the shared place-name fold, abbreviations expanded. */
export function normalizeStreetName(value) {
return foldPlaceName(String(value ?? '').split(',')[0])
const words = foldPlaceName(String(value ?? '').split(',')[0])
.split(' ')
.filter(Boolean)
.map((word) => STREET_WORDS[word] || word)
.map((word) => STREET_WORDS[word] || word);
// A lone letter is a direction only as the prefix or suffix of a name it
// qualifies ("N Lamar Blvd", "Main St S"); "E St NW" is the street named E.
const named = words.some(
(word) =>
!DIRECTION_LETTERS[word] &&
!DIRECTIONS.has(word) &&
!QUADRANTS.has(word) &&
!STREET_TYPES.has(word),
);
return words
.map((word, i) =>
named && (i === 0 || i === words.length - 1)
? DIRECTION_LETTERS[word] || word
: word,
)
.join(' ');
}
/**
* Whether a tile street name answers the asked street. Exact after
* normalization; otherwise the same name with only a leading direction added
* ("North Congress Avenue" for "Congress Avenue") — never a different street
* that merely shares a word.
* ("North Congress Avenue" for "Congress Avenue"), or a quadrant suffix the
* ask leaves unsaid ("E Street Northwest" for "E Street") — never a different
* street that merely shares a word.
*/
export function streetNameMatches(candidate, asked) {
const want = normalizeStreetName(asked);
if (!want) return false;
const answers = (have) => {
if (have === want) return true;
const [first, ...rest] = have.split(' ');
return DIRECTIONS.has(first) && rest.join(' ') === want;
};
const have = normalizeStreetName(candidate);
if (have === want) return true;
const [first, ...rest] = have.split(' ');
return DIRECTIONS.has(first) && rest.join(' ') === want;
if (answers(have)) return true;
const words = have.split(' ');
return (
QUADRANTS.has(words.at(-1)) &&
!QUADRANTS.has(want.split(' ').at(-1)) &&
answers(words.slice(0, -1).join(' '))
);
}
const lengthM = (line) => {
@@ -423,6 +458,7 @@ export function buildingFromTiles(
) {
let containing = null;
let fragmentContains = false;
let nearestFragmentM = Infinity;
let nearest = null;
for (const tile of tiles || []) {
for (const building of tile?.buildings || []) {
@@ -451,9 +487,11 @@ export function buildingFromTiles(
const area = ringAreaM2(building.rings[0]);
if (!containing || area < containing.area)
containing = { building, area };
} else if (!building.clipped) {
} else {
const distance = distanceToPolygonM(lon, lat, building.rings);
if (
if (building.clipped)
nearestFragmentM = Math.min(nearestFragmentM, distance);
else if (
distance <= maxDistanceM &&
(!nearest || distance < nearest.distance)
)
@@ -461,9 +499,13 @@ export function buildingFromTiles(
}
}
}
// The point is on a building only seen in pieces: no neighbour stands in.
// The point is on, or nearest to, a building only seen in pieces: no
// farther neighbour stands in. A complete copy from a neighbouring tile is
// never farther than its fragment, so the centimetre tolerance keeps it.
const fragmentNearer =
fragmentContains || nearestFragmentM + 0.01 < (nearest?.distance ?? 0);
const pick =
containing?.building || (fragmentContains ? null : nearest?.building);
containing?.building || (fragmentNearer ? null : nearest?.building);
if (!pick) return null;
const polygons = [pick.rings].map((part) =>
part.map((ring) => closeRing(ring.map((p) => [p[0], p[1]]))),
@@ -504,12 +546,14 @@ function groundsKey(value) {
.trim();
}
const GROUNDS_WORDS_RE =
/\b(?:grounds|campus|compound|premises|property|site|area|complex)\b/g;
/** Words that describe grounds rather than name the place. */
const GROUNDS_SUFFIX_RE =
/(?:\s+(?:grounds|campus|compound|premises|property|site|area|complex))+$/;
const GROUNDS_WORDS =
'grounds|campus|compound|premises|property|site|area|complex';
/** Grounds words wherever they stand, any case (the outline rung cuts these). */
export const GROUNDS_WORDS_RE = new RegExp(`\\b(?:${GROUNDS_WORDS})\\b`, 'gi');
const GROUNDS_SUFFIX_RE = new RegExp(`(?:\\s+(?:${GROUNDS_WORDS}))+$`);
/**
* Area classes an unnamed tile feature may answer for a grounds ask, from the
+15
View File
@@ -71,6 +71,21 @@ test('street names compare with abbreviations and a leading direction only', ()
assert.ok(streetNameMatches('ถนนสุขุมวิท', 'ถนนสุขุมวิท'));
});
test('a lone letter is a direction only beside a name; quadrants may go unsaid', () => {
assert.equal(normalizeStreetName('N Lamar Blvd'), 'north lamar boulevard');
assert.equal(normalizeStreetName('Main St S'), 'main street south');
assert.equal(normalizeStreetName('E St NW'), 'e street northwest');
assert.ok(!streetNameMatches('East Street', 'E Street'));
assert.ok(!streetNameMatches('E Street', 'East Street'));
assert.ok(streetNameMatches('North Lamar Boulevard', 'N Lamar Blvd'));
assert.ok(streetNameMatches('E Street Northwest', 'E Street'));
assert.ok(streetNameMatches('E St NW', 'E Street'));
assert.ok(streetNameMatches('E St NW', 'E Street Northwest'));
assert.ok(!streetNameMatches('E St NE', 'E Street Northwest'));
assert.ok(!streetNameMatches('E Street', 'E Street Northwest'));
assert.ok(!streetNameMatches('F Street Northwest', 'E Street'));
});
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
const street = streetFromTiles(TILES, {
name: 'Congress Avenue',
@@ -156,6 +156,69 @@ test('a clipped fragment is never the building, and no neighbour stands in', ()
);
});
test('a nearer cut fragment stops a farther whole building standing in', () => {
// About 110 m on a side, 2 m east of the point.
const fragment = {
rings: [square(0.00002, 0, 0.001)],
clipped: true,
heightM: null,
};
// Whole, about 30 m west of the point.
const neighbour = {
rings: [square(-0.0004, 0.00045, 0.0001)],
clipped: false,
heightM: null,
};
const point = { lat: 0.0005, lon: 0 };
assert.equal(
buildingFromTiles([{ buildings: [fragment, neighbour] }], point),
null,
);
// The fragment's complete copy from the neighbouring tile is the answer.
const complete = { ...fragment, clipped: false };
assert.deepEqual(
buildingFromTiles(
[{ buildings: [fragment, neighbour] }, { buildings: [complete] }],
point,
)?.polygons,
[fragment.rings],
);
// A fragment farther than the whole building does not block it.
assert.ok(
buildingFromTiles([{ buildings: [fragment, neighbour] }], {
...point,
lon: -0.00028,
}),
);
});
test('grounds touching an unbuffered tile edge are cut, not complete', () => {
// One tile at z0 whose park layer has no buffer: the cut is the edge itself.
const decode = (coordinates) =>
decodeOpenFreeMapOutlineTile(null, 0, 0, 0, {
layers: {
park: {
length: 1,
extent: 4096,
feature: () => ({
properties: { class: 'park', name: 'Edge Park' },
loadGeometry: () => [[{ x: 0, y: 2048 }]],
toGeoJSON: () => ({
geometry: { type: 'Polygon', coordinates },
}),
}),
},
},
});
const ask = { lat: 0.005, lon: -179.995, name: 'Edge Park' };
const cut = decode([square(-180, 0, 0.01)]);
assert.equal(cut.areas[0].clipped, true);
assert.equal(enclosingAreaFromTiles([cut], ask), null);
const inside = decode([square(-179.999, 0, 0.01)]);
assert.equal(inside.areas[0].clipped, false);
assert.ok(enclosingAreaFromTiles([inside], ask));
});
test('a point in a courtyard is not inside the building', () => {
const block = {
rings: [square(0, 0, 0.001), square(0.0004, 0.0004, 0.0002).reverse()],