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:
manjunath committed 2026-10-30 20:47:20 +05:30
1 parent 5a1cfa6800
commit 2e82425d62
37 files changed
+921 -481

No files matched your search

+36 -19
View File
@@ -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) {
+7 -2
View File
@@ -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,
});
}
+34 -1
View File
@@ -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({
+105 -81
View File
@@ -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),
+2 -3
View File
@@ -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',
);
});
+1
View File
@@ -18,6 +18,7 @@ export function createWorkTrace() {
dropped: 0,
probes: 0,
samplerStart: null,
samplerHeadAt: null,
samplerStatus: null,
rendererStatus: null,
status: 'pending',
+13 -3
View File
@@ -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);