mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +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
@@ -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()],
|
||||
|
||||
Reference in new issue
Block a user