mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
address review findings on voice outlines
- remove the unused Nominatim reserve(); spacing tests now measure the actual transport start across processes - start each queued building's 2 s ground-sampling budget when it reaches the head of the queue, and request a frame on promotion - accept Nominatim and grounds outlines that cross the antimeridian - let an unnamed school, university, hospital, cemetery, stadium or zoo area answer a grounds ask whose head noun names that kind, using only the part that holds the point; compare grounds names symmetrically - charge shared OpenFreeMap consumers their own slice and keep the charged budget at the previous 64 MiB - share one place-name fold, point-in-polygon, line stitching and nextTask helper instead of duplicates - exclude Who's On First sources licensed only as "CC BY (equivalent)" or "CC0 (equivalent)"; rebuild the pack from the pinned archives - whole-word neighbourhood alias guard; correct the outline size-limit comment - restore DATA_SOURCES.md table formatting, keeping only real edits - test behaviour instead of private engine fields
This commit is contained in:
1 parent
5a1cfa6800
commit
2e82425d62
37 files changed
+921
-481
No files matched your search
@@ -14,28 +14,45 @@ export function ringAreaM2(ring) {
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
export function stitchLine(segments) {
|
||||
const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2;
|
||||
/**
|
||||
* Join line pieces whose ends meet (within `toleranceM`) into as few chains as
|
||||
* possible. Each chain grows greedily from the first unused piece.
|
||||
*/
|
||||
export function stitchLines(
|
||||
segments,
|
||||
{ toleranceM = 2, maxChains = Infinity } = {},
|
||||
) {
|
||||
const same = (a, b) =>
|
||||
approximateDistanceM(a[1], a[0], b[1], b[0]) < toleranceM;
|
||||
const remaining = segments.map((s) => s.slice());
|
||||
let line = remaining.shift();
|
||||
let advanced = true;
|
||||
while (advanced && remaining.length) {
|
||||
advanced = false;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const s = remaining[i];
|
||||
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
|
||||
else if (same(line[line.length - 1], s[s.length - 1]))
|
||||
line = line.concat(s.slice(0, -1).reverse());
|
||||
else if (same(line[0], s[s.length - 1]))
|
||||
line = s.slice(0, -1).concat(line);
|
||||
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
|
||||
else continue;
|
||||
remaining.splice(i, 1);
|
||||
advanced = true;
|
||||
break;
|
||||
const chains = [];
|
||||
while (remaining.length && chains.length < maxChains) {
|
||||
let line = remaining.shift();
|
||||
let advanced = true;
|
||||
while (advanced && remaining.length) {
|
||||
advanced = false;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const s = remaining[i];
|
||||
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
|
||||
else if (same(line[line.length - 1], s[s.length - 1]))
|
||||
line = line.concat(s.slice(0, -1).reverse());
|
||||
else if (same(line[0], s[s.length - 1]))
|
||||
line = s.slice(0, -1).concat(line);
|
||||
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
|
||||
else continue;
|
||||
remaining.splice(i, 1);
|
||||
advanced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
chains.push(line);
|
||||
}
|
||||
return line;
|
||||
return chains;
|
||||
}
|
||||
|
||||
/** The chain grown from the first segment. */
|
||||
export function stitchLine(segments) {
|
||||
return stitchLines(segments, { maxChains: 1 })[0];
|
||||
}
|
||||
|
||||
export function bufferCorridor(line, halfWidthM) {
|
||||
|
||||
@@ -23,7 +23,7 @@ const ROAD_TYPES = Object.freeze({
|
||||
|
||||
const SHARED_DECODED_TILES = createSharedDecodedTileCache({
|
||||
maxEntries: 192,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
maxCacheBytes: 32 * 1024 * 1024,
|
||||
});
|
||||
const TILE_PROJECTIONS = new Map();
|
||||
const MAX_PROJECTED_NUMBERS = 500_000;
|
||||
@@ -343,6 +343,10 @@ export function createOpenFreeMapSource(options = {}) {
|
||||
...sourceOptions
|
||||
} = options;
|
||||
const shared = !decode;
|
||||
// Shared consumers hold only their slice of each tile. The shared decode
|
||||
// (32 MiB) plus traffic and installations (12 MiB each) and outlines
|
||||
// (8 MiB) are charged 64 MiB together, the budget of the single OpenFreeMap
|
||||
// source traffic and installations shared before; charges are estimates.
|
||||
return createVectorTileSource({
|
||||
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
||||
decode: shared
|
||||
@@ -351,6 +355,7 @@ export function createOpenFreeMapSource(options = {}) {
|
||||
...(shared
|
||||
? {
|
||||
sharedDecodedCache: sharedDecodedCache || SHARED_DECODED_TILES,
|
||||
selectedSize: estimateOpenFreeMapProjectionBytes,
|
||||
selectDecoded:
|
||||
selectDecoded ||
|
||||
(projection
|
||||
@@ -362,7 +367,7 @@ export function createOpenFreeMapSource(options = {}) {
|
||||
...(selectDecoded ? { selectDecoded } : {}),
|
||||
}),
|
||||
maxEntries: 192,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
maxCacheBytes: (shared ? 12 : 64) * 1024 * 1024,
|
||||
...sourceOptions,
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
createOpenFreeMapSource,
|
||||
estimateOpenFreeMapProjectionBytes,
|
||||
} from './openFreeMap.js';
|
||||
import { createVectorTileSource } from './vectorTiles.js';
|
||||
import {
|
||||
createSharedDecodedTileCache,
|
||||
createVectorTileSource,
|
||||
} from './vectorTiles.js';
|
||||
const fixture = (name) =>
|
||||
readFileSync(new URL(`../data/fixtures/${name}`, import.meta.url));
|
||||
|
||||
@@ -145,6 +148,36 @@ test('Camp Mabry tile emits an unnamed polygon and its centroid marker', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a shared consumer is charged only its own slice of each tile', async () => {
|
||||
const bytes = fixture('ofm-austin-14-3743-6745.pbf');
|
||||
const sharedDecodedCache = createSharedDecodedTileCache({
|
||||
maxEntries: 4,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
const source = createOpenFreeMapSource({
|
||||
template: 'https://tiles.openfreemap.org/planet/v/test/{z}/{x}/{y}.pbf',
|
||||
sharedDecodedCache,
|
||||
selectDecoded: ({ military }) => ({ military }),
|
||||
fetchImpl: async () => new Response(bytes),
|
||||
});
|
||||
try {
|
||||
const box = { south: 30.267, north: 30.268, west: -97.744, east: -97.743 };
|
||||
const { tiles } = await source.fetchBounds(box, { zoom: 14 });
|
||||
const stats = source.getStats();
|
||||
assert.equal(stats.cacheEntries, 1);
|
||||
assert.equal(
|
||||
stats.cacheBytes,
|
||||
estimateOpenFreeMapProjectionBytes(tiles[0]),
|
||||
'the slice estimate, not the whole decoded tile',
|
||||
);
|
||||
assert.ok(stats.cacheBytes < bytes.byteLength * 4);
|
||||
assert.ok(sharedDecodedCache.stats().bytes >= bytes.byteLength * 4);
|
||||
} finally {
|
||||
source.clear();
|
||||
sharedDecodedCache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test('TileJSON is resolved once and decoded tile LRU prevents per-pan refetch', async () => {
|
||||
const calls = [];
|
||||
const source = createOpenFreeMapSource({
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
approximateDistanceM,
|
||||
ringAreaM2,
|
||||
closeRing,
|
||||
stitchLines,
|
||||
} from './featureGeometry.js';
|
||||
import { phaseTiming } from './phaseTiming.js';
|
||||
import { normalizePlaceName } from '../data/placeBoundaries.js';
|
||||
import { foldPlaceName } from '../data/placeNames.js';
|
||||
import { pointInRing, polygonsContain } from '../data/adminBoundaries.js';
|
||||
|
||||
/**
|
||||
* Street and building outlines from the same OpenFreeMap z14 vector tiles the
|
||||
@@ -219,7 +222,8 @@ function decodePolygons(
|
||||
? geometry.coordinates
|
||||
: [];
|
||||
// A tile building feature may aggregate unrelated structures. Validate its
|
||||
// selected component independently; named grounds retain whole topology.
|
||||
// selected component independently; grounds keep the feature's parts, and
|
||||
// a match by class alone is narrowed to its own part when selected.
|
||||
const incompleteParts = polygons.map(
|
||||
(rings) =>
|
||||
!Array.isArray(rings) ||
|
||||
@@ -252,6 +256,7 @@ function decodePolygons(
|
||||
clipped: componentLocal
|
||||
? clippedParts[part]
|
||||
: clippedParts.some(Boolean),
|
||||
partClipped: clippedParts[part],
|
||||
incomplete: componentLocal
|
||||
? incompleteParts[part]
|
||||
: incompleteParts.some(Boolean),
|
||||
@@ -267,7 +272,7 @@ export function createOpenFreeMapOutlineSource(options = {}) {
|
||||
return createOpenFreeMapSource({
|
||||
projection: 'outlines',
|
||||
maxEntries: 32,
|
||||
maxCacheBytes: 16 * 1024 * 1024,
|
||||
maxCacheBytes: 8 * 1024 * 1024,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -307,18 +312,11 @@ const STREET_WORDS = Object.freeze({
|
||||
|
||||
const DIRECTIONS = new Set(['north', 'south', 'east', 'west']);
|
||||
|
||||
/** Comparable street name: Latin accents off, abbreviations expanded. */
|
||||
/** Comparable street name: the shared place-name fold, abbreviations expanded. */
|
||||
export function normalizeStreetName(value) {
|
||||
return String(value ?? '')
|
||||
.split(',')[0]
|
||||
.normalize('NFKD')
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase('en-US')
|
||||
.replace(/[\p{P}\p{S}]+/gu, ' ')
|
||||
.split(/\s+/)
|
||||
return foldPlaceName(String(value ?? '').split(',')[0])
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.filter((word, index) => !(index === 0 && word === 'the'))
|
||||
.map((word) => STREET_WORDS[word] || word)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -350,39 +348,6 @@ const lengthM = (line) => {
|
||||
return total;
|
||||
};
|
||||
|
||||
const near = (a, b) =>
|
||||
approximateDistanceM(a[1], a[0], b[1], b[0]) <= JOIN_TOLERANCE_M;
|
||||
|
||||
/** Join line pieces whose ends meet into as few chains as possible. */
|
||||
export function mergeLineSegments(segments) {
|
||||
const pending = segments.filter((s) => s.length >= 2).map((s) => s.slice());
|
||||
const chains = [];
|
||||
while (pending.length) {
|
||||
let chain = pending.shift();
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const s = pending[i];
|
||||
const head = chain[0];
|
||||
const tail = chain[chain.length - 1];
|
||||
if (near(tail, s[0])) chain = chain.concat(s.slice(1));
|
||||
else if (near(tail, s[s.length - 1]))
|
||||
chain = chain.concat(s.slice(0, -1).reverse());
|
||||
else if (near(head, s[s.length - 1]))
|
||||
chain = s.slice(0, -1).concat(chain);
|
||||
else if (near(head, s[0])) chain = s.slice(1).reverse().concat(chain);
|
||||
else continue;
|
||||
pending.splice(i, 1);
|
||||
grew = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
chains.push(chain);
|
||||
}
|
||||
return chains;
|
||||
}
|
||||
|
||||
/**
|
||||
* The asked street near a point as a bounded set of merged polylines, longest
|
||||
* first, or null when no tile street carries that name.
|
||||
@@ -416,7 +381,10 @@ export function streetFromTiles(
|
||||
}
|
||||
}
|
||||
if (!pieces.length) return null;
|
||||
const chains = mergeLineSegments(pieces)
|
||||
const chains = stitchLines(
|
||||
pieces.filter((piece) => piece.length >= 2),
|
||||
{ toleranceM: JOIN_TOLERANCE_M },
|
||||
)
|
||||
.map((line) => ({ line, length: lengthM(line) }))
|
||||
.filter((entry) => entry.length >= STREET_MIN_CHAIN_M)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
@@ -431,28 +399,6 @@ export function streetFromTiles(
|
||||
return lines.length ? { name: matchedName, lines } : null;
|
||||
}
|
||||
|
||||
function insideRing(lon, lat, ring) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (
|
||||
yi > lat !== yj > lat &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi
|
||||
)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Inside the outer ring and outside every hole. */
|
||||
function insidePolygon(lon, lat, rings) {
|
||||
if (!insideRing(lon, lat, rings[0])) return false;
|
||||
for (let i = 1; i < rings.length; i++)
|
||||
if (insideRing(lon, lat, rings[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Distance (m) from a point to the nearest edge of any ring of a polygon. */
|
||||
function distanceToPolygonM(lon, lat, rings) {
|
||||
return Math.min(...rings.map((ring) => distanceToLineM(lon, lat, ring)));
|
||||
@@ -504,12 +450,12 @@ export function buildingFromTiles(
|
||||
Number.isFinite(point[0]) &&
|
||||
Number.isFinite(point[1]),
|
||||
) &&
|
||||
insideRing(lon, lat, outer)
|
||||
pointInRing(outer, lat, lon)
|
||||
)
|
||||
fragmentContains = true;
|
||||
continue;
|
||||
}
|
||||
if (insidePolygon(lon, lat, building.rings)) {
|
||||
if (polygonsContain([building.rings], lat, lon)) {
|
||||
// A fragment cut at a tile edge is never the whole footprint.
|
||||
if (building.clipped) {
|
||||
fragmentContains = true;
|
||||
@@ -545,28 +491,102 @@ export function buildingFromTiles(
|
||||
};
|
||||
}
|
||||
|
||||
/** Head nouns that say what kind of grounds is meant, and the classes that answer them. */
|
||||
const GROUNDS_CLASS_HEADS = Object.freeze([
|
||||
[/\b(?:school|academy|elementary)$/, ['school']],
|
||||
[
|
||||
/\b(?:university|college)$|^(?:university|college) of\b/,
|
||||
['university', 'college'],
|
||||
],
|
||||
[/\b(?:hospital|medical cent(?:er|re))$/, ['hospital']],
|
||||
[/\b(?:cemetery|graveyard)$/, ['cemetery']],
|
||||
[/\bstadium$/, ['stadium']],
|
||||
[/\bzoo$/, ['zoo']],
|
||||
]);
|
||||
|
||||
/**
|
||||
* A grounds name for comparison: folded, possessive " s" dropped and grounds
|
||||
* words removed wherever they stand, as the outline rung cuts them from the ask
|
||||
* ("Mueller Sports Complex", "Campus Martius Park", "Lincoln School's").
|
||||
*/
|
||||
function groundsKey(value) {
|
||||
return normalizePlaceName(value)
|
||||
.replace(/ s(?= |$)/g, '')
|
||||
.replace(GROUNDS_WORDS_RE, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.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))+$/;
|
||||
|
||||
/**
|
||||
* Area classes an unnamed tile feature may answer for a grounds ask, from the
|
||||
* kind of place the ask names: its head noun once grounds words are removed
|
||||
* ("Lincoln High School campus" → school, "University of Texas grounds" →
|
||||
* university). "Campus" alone names no kind ("Google campus"), and a class word
|
||||
* elsewhere in a name ("University Avenue Park", "Zoo Lake") does not count.
|
||||
* Tile landuse for schools, hospitals and universities is usually unnamed.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function groundsClassesFor(text) {
|
||||
// "Lincoln School's grounds" folds to "lincoln school s grounds".
|
||||
const head = foldPlaceName(String(text ?? '').split(',')[0])
|
||||
.replace(/ s(?= |$)/g, '')
|
||||
.replace(GROUNDS_SUFFIX_RE, '');
|
||||
const classes = new Set();
|
||||
for (const [pattern, matches] of GROUNDS_CLASS_HEADS)
|
||||
if (pattern.test(head)) for (const c of matches) classes.add(c);
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open area enclosing grounds at a point: the smallest park, landuse or
|
||||
* park-like cover feature that contains it and is at least grounds-sized.
|
||||
* Containment alone is not identity: only a matching named feature can be an
|
||||
* exact answer to the requested grounds, and holes are not filled grounds.
|
||||
* Containment alone is not identity: a feature named as asked answers first;
|
||||
* otherwise only an unnamed feature of a class the ask itself names (a school
|
||||
* for "… School grounds") may answer. A differently named feature never does,
|
||||
* and holes are not filled grounds.
|
||||
*/
|
||||
export function enclosingAreaFromTiles(
|
||||
tiles,
|
||||
{ lat, lon, name, minAreaM2 = 20_000, maxAreaM2 = 60e6 },
|
||||
{
|
||||
lat,
|
||||
lon,
|
||||
name,
|
||||
unnamedClasses = null,
|
||||
minAreaM2 = 20_000,
|
||||
maxAreaM2 = 60e6,
|
||||
},
|
||||
) {
|
||||
const query = normalizePlaceName(name);
|
||||
const query = groundsKey(name);
|
||||
if (!query) return null;
|
||||
let best = null;
|
||||
let named = null;
|
||||
let unnamed = null;
|
||||
for (const tile of tiles || []) {
|
||||
for (const area of tile?.areas || []) {
|
||||
const isNamed = Boolean(area.name) && groundsKey(area.name) === query;
|
||||
const isClassed = !area.name && Boolean(unnamedClasses?.has(area.class));
|
||||
// A class match stands for its own part, so only that part's cut counts.
|
||||
const clipped = isNamed
|
||||
? area.clipped
|
||||
: (area.partClipped ?? area.clipped);
|
||||
if (
|
||||
area.clipped ||
|
||||
normalizePlaceName(area.name) !== query ||
|
||||
!insidePolygon(lon, lat, area.rings)
|
||||
clipped ||
|
||||
!(isNamed || isClassed) ||
|
||||
!polygonsContain([area.rings], lat, lon)
|
||||
)
|
||||
continue;
|
||||
const size = (area.featureParts || [area.rings]).reduce(
|
||||
// Only a name ties a feature's other parts to the asked place; an
|
||||
// unnamed class match stands for the part holding the point.
|
||||
const parts = isNamed ? area.featureParts || [area.rings] : [area.rings];
|
||||
const size = parts.reduce(
|
||||
(sum, [outer, ...holes]) =>
|
||||
sum +
|
||||
ringAreaM2(outer) -
|
||||
@@ -574,11 +594,15 @@ export function enclosingAreaFromTiles(
|
||||
0,
|
||||
);
|
||||
if (size < minAreaM2 || size > maxAreaM2) continue;
|
||||
if (!best || size < best.size) best = { area, size };
|
||||
if (isNamed) {
|
||||
if (!named || size < named.size) named = { area, size, parts };
|
||||
} else if (!unnamed || size < unnamed.size)
|
||||
unnamed = { area, size, parts };
|
||||
}
|
||||
}
|
||||
const best = named || unnamed;
|
||||
if (!best) return null;
|
||||
const parts = best.area.featureParts || [best.area.rings];
|
||||
const parts = best.parts;
|
||||
const polygons = [
|
||||
best.area.rings,
|
||||
...parts.filter((part) => part !== best.area.rings),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
createOpenFreeMapOutlineSource,
|
||||
decodeOpenFreeMapOutlineTile,
|
||||
enclosingAreaFromTiles,
|
||||
mergeLineSegments,
|
||||
normalizeStreetName,
|
||||
streetFromTiles,
|
||||
streetNameMatches,
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
registerOpenFreeMapProjection,
|
||||
} from './openFreeMap.js';
|
||||
import { createSharedDecodedTileCache } from './vectorTiles.js';
|
||||
import { approximateDistanceM } from './featureGeometry.js';
|
||||
import { approximateDistanceM, stitchLines } from './featureGeometry.js';
|
||||
|
||||
const fixture = (y) =>
|
||||
decodeOpenFreeMapOutlineTile(
|
||||
@@ -112,7 +111,7 @@ test('merge joins reversed and out-of-order pieces and keeps gaps apart', () =>
|
||||
[0.01, 0],
|
||||
[0.011, 0],
|
||||
]; // separate
|
||||
const chains = mergeLineSegments([b, c, a]);
|
||||
const chains = stitchLines([b, c, a], { toleranceM: 3 });
|
||||
assert.equal(chains.length, 2);
|
||||
assert.equal(chains.find((ch) => ch.length === 3).length, 3);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildingFromTiles,
|
||||
decodeOpenFreeMapOutlineTile,
|
||||
enclosingAreaFromTiles,
|
||||
groundsClassesFor,
|
||||
streetFromTiles,
|
||||
} from './openFreeMapOutlines.js';
|
||||
|
||||
@@ -237,3 +238,209 @@ test('street proximity uses segments and clips to the search extent', () => {
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('unnamed school, hospital and campus grounds answer only an ask of that kind', () => {
|
||||
assert.deepEqual(
|
||||
[...groundsClassesFor('Lincoln High School grounds')],
|
||||
['school'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...groundsClassesFor('Lincoln High School campus')],
|
||||
['school'],
|
||||
);
|
||||
assert.deepEqual([...groundsClassesFor('City College campus site')].sort(), [
|
||||
'college',
|
||||
'university',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...groundsClassesFor('St. Mary Medical Center')],
|
||||
['hospital'],
|
||||
);
|
||||
assert.deepEqual([...groundsClassesFor('Texas Capitol grounds')], []);
|
||||
assert.deepEqual(
|
||||
[...groundsClassesFor('University of Texas grounds')].sort(),
|
||||
['college', 'university'],
|
||||
);
|
||||
for (const name of [
|
||||
'University Avenue Park grounds',
|
||||
'Zoo Lake grounds',
|
||||
'School Street Playground grounds',
|
||||
'Academy of Sciences grounds',
|
||||
'Stadium Station',
|
||||
'Google campus',
|
||||
'Mayo Clinic campus',
|
||||
'Google campus',
|
||||
'Mayo Clinic campus',
|
||||
])
|
||||
assert.deepEqual([...groundsClassesFor(name)], [], name);
|
||||
// About 1.2 km on a side: grounds-sized.
|
||||
const school = {
|
||||
rings: [square(0, 0, 0.01)],
|
||||
clipped: false,
|
||||
class: 'school',
|
||||
name: null,
|
||||
};
|
||||
const otherSchool = {
|
||||
...school,
|
||||
rings: [square(-0.001, -0.001, 0.02)],
|
||||
name: 'Other Academy',
|
||||
};
|
||||
const park = { ...school, class: 'park' };
|
||||
const at = { lat: 0.005, lon: 0.005, name: 'Lincoln High School' };
|
||||
const asked = {
|
||||
...at,
|
||||
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
|
||||
};
|
||||
assert.deepEqual(
|
||||
enclosingAreaFromTiles([{ areas: [school] }], asked)?.class,
|
||||
'school',
|
||||
);
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [school] }], at),
|
||||
null,
|
||||
'no class from the ask, no unnamed answer',
|
||||
);
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [park] }], asked),
|
||||
null,
|
||||
'another class never answers',
|
||||
);
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [otherSchool] }], asked),
|
||||
null,
|
||||
'a differently named school never answers',
|
||||
);
|
||||
const named = {
|
||||
...school,
|
||||
rings: [square(-0.002, -0.002, 0.03)],
|
||||
name: 'Lincoln High School',
|
||||
};
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [school, named] }], asked).name,
|
||||
'Lincoln High School',
|
||||
'a named match answers first',
|
||||
);
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [{ ...school, clipped: true }] }], asked),
|
||||
null,
|
||||
'clipped unnamed grounds are not grounds',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unnamed class match is only the part holding the point', () => {
|
||||
// A school feature with a second, distant part: only a name could tie them.
|
||||
const near = square(0, 0, 0.002); // about 49,000 m²
|
||||
const far = square(0.02, 0.02, 0.002);
|
||||
const tiny = square(0, 0, 0.0005); // about 3,000 m², below the floor
|
||||
const ask = {
|
||||
lat: 0.001,
|
||||
lon: 0.001,
|
||||
name: 'Lincoln High School',
|
||||
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
|
||||
};
|
||||
// As decoded: `rings` is the same array as its entry in `featureParts`.
|
||||
const area = (rings, others) => ({
|
||||
rings,
|
||||
featureParts: [rings, ...others],
|
||||
clipped: false,
|
||||
class: 'school',
|
||||
name: null,
|
||||
});
|
||||
const found = enclosingAreaFromTiles(
|
||||
[{ areas: [area([near], [[far]])] }],
|
||||
ask,
|
||||
);
|
||||
assert.deepEqual(
|
||||
found.polygons,
|
||||
[[near]],
|
||||
'the distant part is not included',
|
||||
);
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles(
|
||||
[{ areas: [area([tiny], [[far], [square(-0.02, 0, 0.003)]])] }],
|
||||
{ ...ask, lat: 0.0002, lon: 0.0002 },
|
||||
),
|
||||
null,
|
||||
'other parts cannot lift a small part over the size floor',
|
||||
);
|
||||
const named = enclosingAreaFromTiles(
|
||||
[
|
||||
{
|
||||
areas: [{ ...area([near], [[far]]), name: 'Lincoln High School' }],
|
||||
},
|
||||
],
|
||||
ask,
|
||||
);
|
||||
assert.equal(named.polygons.length, 2, 'a named feature keeps its parts');
|
||||
});
|
||||
|
||||
test('grounds names keep their own grounds words; possessives and part cuts', () => {
|
||||
assert.deepEqual(
|
||||
[...groundsClassesFor("Lincoln School's grounds")],
|
||||
['school'],
|
||||
);
|
||||
const complex = {
|
||||
rings: [square(0, 0, 0.002)],
|
||||
clipped: false,
|
||||
class: 'pitch',
|
||||
name: 'Mueller Sports Complex',
|
||||
};
|
||||
const named = (name) => ({
|
||||
rings: [square(0, 0, 0.002)],
|
||||
clipped: false,
|
||||
class: 'park',
|
||||
name,
|
||||
});
|
||||
const at = { lat: 0.001, lon: 0.001 };
|
||||
for (const [ask, area] of [
|
||||
["Lincoln School's", 'Lincoln School'],
|
||||
['Martius Park', 'Campus Martius Park'],
|
||||
['Mueller Sports Field', 'Mueller Sports Complex Field'],
|
||||
])
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [named(area)] }], { ...at, name: ask })
|
||||
?.name,
|
||||
area,
|
||||
`${ask} → ${area}`,
|
||||
);
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [named('Lincoln Park Zoo')] }], {
|
||||
...at,
|
||||
name: 'Lincoln Park',
|
||||
}),
|
||||
null,
|
||||
'a longer, different name never answers',
|
||||
);
|
||||
// The ask arrives with its grounds words cut by the rung.
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles([{ areas: [complex] }], {
|
||||
lat: 0.001,
|
||||
lon: 0.001,
|
||||
name: 'Mueller Sports',
|
||||
})?.name,
|
||||
'Mueller Sports Complex',
|
||||
);
|
||||
// A sibling part cut at the tile edge does not refuse a class match's own part.
|
||||
const school = {
|
||||
rings: [square(0, 0, 0.002)],
|
||||
clipped: true,
|
||||
partClipped: false,
|
||||
class: 'school',
|
||||
name: null,
|
||||
};
|
||||
const ask = {
|
||||
lat: 0.001,
|
||||
lon: 0.001,
|
||||
name: 'Lincoln High School',
|
||||
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
|
||||
};
|
||||
assert.ok(enclosingAreaFromTiles([{ areas: [school] }], ask));
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles(
|
||||
[{ areas: [{ ...school, partClipped: true }] }],
|
||||
ask,
|
||||
),
|
||||
null,
|
||||
'its own cut part is still refused',
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export function createWorkTrace() {
|
||||
dropped: 0,
|
||||
probes: 0,
|
||||
samplerStart: null,
|
||||
samplerHeadAt: null,
|
||||
samplerStatus: null,
|
||||
rendererStatus: null,
|
||||
status: 'pending',
|
||||
|
||||
@@ -40,8 +40,8 @@ function tileJsonOrigin(tileJsonUrl) {
|
||||
const DECODER_IDS = new WeakMap();
|
||||
let nextDecoderId = 1;
|
||||
|
||||
// Shared completion must not run every consumer's selection in one task.
|
||||
const nextTask = () =>
|
||||
/** Give the page a turn; shared completion must not run every consumer's selection in one task. */
|
||||
export const nextTask = () =>
|
||||
globalThis.scheduler?.yield
|
||||
? globalThis.scheduler.yield()
|
||||
: new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -198,6 +198,7 @@ export function createVectorTileSource({
|
||||
now = Date.now,
|
||||
sharedDecodedCache = null,
|
||||
selectDecoded = (value) => value,
|
||||
selectedSize = null,
|
||||
}) {
|
||||
const cache = new Map();
|
||||
const active = new Set();
|
||||
@@ -498,7 +499,16 @@ export function createVectorTileSource({
|
||||
const selected = selectDecoded(decodedEntry.value, tile);
|
||||
// Shared entries stay authoritative; consumers own their nested arrays.
|
||||
const value = sharedDecodedCache ? structuredClone(selected) : selected;
|
||||
const size = decodedEntry.size;
|
||||
// A consumer of a shared tile retains only its own slice; charge that,
|
||||
// not the whole decoded tile, which the shared cache already counts.
|
||||
let size = decodedEntry.size;
|
||||
if (sharedDecodedCache && selectedSize) {
|
||||
try {
|
||||
size = selectedSize(value);
|
||||
} catch {
|
||||
size = decodedEntry.size;
|
||||
}
|
||||
}
|
||||
if (epoch === generation && size <= maxCacheBytes) {
|
||||
if (cache.has(key)) cacheBytes -= cache.get(key).size;
|
||||
cache.delete(key);
|
||||
|
||||
Reference in new issue
Block a user