mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix(outlines): harden shared cache and provider lifecycle
This commit is contained in:
1 parent
9559e7a282
commit
177b5951a0
27 files changed
+3363
-248
No files matched your search
+174
-5
@@ -1,6 +1,9 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { createVectorTileSource } from './vectorTiles.js';
|
||||
import {
|
||||
createSharedDecodedTileCache,
|
||||
createVectorTileSource,
|
||||
} from './vectorTiles.js';
|
||||
import { clipTileRing, militaryOutlineLines } from './militaryTileGeometry.js';
|
||||
import { tileToBBox } from '../data/tomtomTiles.js';
|
||||
|
||||
@@ -13,6 +16,65 @@ const ROAD_TYPES = Object.freeze({
|
||||
minor: 'residential',
|
||||
});
|
||||
|
||||
const SHARED_DECODED_TILES = createSharedDecodedTileCache({
|
||||
maxEntries: 192,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
const TILE_PROJECTIONS = new Map();
|
||||
const MAX_PROJECTED_NUMBERS = 500_000;
|
||||
|
||||
/** Conservative, deterministic retained-size estimate with a hard geometry cap. */
|
||||
export function estimateOpenFreeMapProjectionBytes(value) {
|
||||
const seen = new Set();
|
||||
const stack = [value];
|
||||
let bytes = 0;
|
||||
let numbers = 0;
|
||||
while (stack.length) {
|
||||
const item = stack.pop();
|
||||
if (item == null) continue;
|
||||
if (typeof item === 'number') {
|
||||
numbers += 1;
|
||||
if (numbers > MAX_PROJECTED_NUMBERS)
|
||||
throw new Error('Vector tile projection coordinate limit exceeded');
|
||||
bytes += 8;
|
||||
} else if (typeof item === 'string') {
|
||||
bytes += 16 + item.length * 2;
|
||||
} else if (typeof item === 'boolean') {
|
||||
bytes += 4;
|
||||
} else if (typeof item === 'object') {
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
if (Array.isArray(item)) {
|
||||
bytes += 24 + item.length * 8;
|
||||
for (let i = 0; i < item.length; i += 1) stack.push(item[i]);
|
||||
} else {
|
||||
const entries = Object.entries(item);
|
||||
bytes += 48 + entries.length * 16;
|
||||
for (const [key, child] of entries) {
|
||||
bytes += key.length * 2;
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Register one consumer view over the parsed OpenFreeMap tile. */
|
||||
export function registerOpenFreeMapProjection(name, project) {
|
||||
if (!name || typeof project !== 'function')
|
||||
throw new TypeError('Invalid OpenFreeMap tile projection');
|
||||
const current = TILE_PROJECTIONS.get(name);
|
||||
if (current && current !== project)
|
||||
throw new Error(`OpenFreeMap tile projection already registered: ${name}`);
|
||||
TILE_PROJECTIONS.set(name, project);
|
||||
}
|
||||
|
||||
/** Explicit test/runtime reset; individual consumers never evict each other. */
|
||||
export function clearSharedOpenFreeMapTiles() {
|
||||
SHARED_DECODED_TILES.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface traffic uses public motor-road classes only. Untyped service ways
|
||||
* include car parks and grounds access, so none are used as through traffic.
|
||||
@@ -142,8 +204,8 @@ export function decodeOpenFreeMapMilitaryTile(bytes, z, x, y) {
|
||||
return decodeLayers(bytes, z, x, y, ['landuse']);
|
||||
}
|
||||
|
||||
function decodeLayers(bytes, z, x, y, layerNames) {
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
function decodeLayers(bytes, z, x, y, layerNames, parsedTile = null) {
|
||||
const tile = parsedTile || new VectorTile(new PbfReader(bytes));
|
||||
const roads = [],
|
||||
military = [];
|
||||
const box = tileToBBox(z, x, y);
|
||||
@@ -214,14 +276,121 @@ function decodeLayers(bytes, z, x, y, layerNames) {
|
||||
return { roads, military };
|
||||
}
|
||||
|
||||
function decodeSharedOpenFreeMapTile(bytes, z, x, y) {
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const base = decodeLayers(
|
||||
bytes,
|
||||
z,
|
||||
x,
|
||||
y,
|
||||
['transportation', 'landuse'],
|
||||
tile,
|
||||
);
|
||||
const projections = new Map();
|
||||
let baseBytes = 0;
|
||||
let projectionBytes = 0;
|
||||
let resizeCacheEntry = null;
|
||||
let cacheBound = false;
|
||||
const idleCallbacks = new Map();
|
||||
const cancelIdleProjection = (name) => {
|
||||
if (!idleCallbacks.has(name)) return;
|
||||
const handle = idleCallbacks.get(name);
|
||||
idleCallbacks.delete(name);
|
||||
globalThis.cancelIdleCallback?.(handle);
|
||||
};
|
||||
const cancelIdleProjections = () => {
|
||||
for (const name of [...idleCallbacks.keys()]) cancelIdleProjection(name);
|
||||
};
|
||||
const project = (name) => {
|
||||
if (projections.has(name)) return projections.get(name);
|
||||
cancelIdleProjection(name);
|
||||
const projector = TILE_PROJECTIONS.get(name);
|
||||
if (!projector)
|
||||
throw new Error(`Unknown OpenFreeMap tile projection: ${name}`);
|
||||
const value = projector(bytes, z, x, y, tile);
|
||||
const retainedBytes = estimateOpenFreeMapProjectionBytes(value);
|
||||
projections.set(name, value);
|
||||
projectionBytes += retainedBytes;
|
||||
resizeCacheEntry?.(baseBytes + projectionBytes);
|
||||
return value;
|
||||
};
|
||||
const scheduleIdleProjections = () => {
|
||||
// Scheduling is optional unless both lifecycle operations are available;
|
||||
// otherwise eviction could not release the callback's decoded-tile closure.
|
||||
if (
|
||||
!cacheBound ||
|
||||
typeof globalThis.requestIdleCallback !== 'function' ||
|
||||
typeof globalThis.cancelIdleCallback !== 'function'
|
||||
)
|
||||
return;
|
||||
for (const name of TILE_PROJECTIONS.keys())
|
||||
if (!projections.has(name) && !idleCallbacks.has(name)) {
|
||||
const handle = globalThis.requestIdleCallback(
|
||||
() => {
|
||||
if (!cacheBound || idleCallbacks.get(name) !== handle) return;
|
||||
idleCallbacks.delete(name);
|
||||
try {
|
||||
project(name);
|
||||
} catch {
|
||||
// The explicit consumer path retries and reports projection errors.
|
||||
}
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
idleCallbacks.set(name, handle);
|
||||
}
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
project,
|
||||
bindSharedCacheEntry(resize, initialBytes) {
|
||||
cancelIdleProjections();
|
||||
resizeCacheEntry = resize;
|
||||
baseBytes = initialBytes;
|
||||
cacheBound = true;
|
||||
resizeCacheEntry(baseBytes + projectionBytes);
|
||||
scheduleIdleProjections();
|
||||
},
|
||||
releaseSharedCacheEntry() {
|
||||
cacheBound = false;
|
||||
cancelIdleProjections();
|
||||
projections.clear();
|
||||
baseBytes = 0;
|
||||
projectionBytes = 0;
|
||||
resizeCacheEntry = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct an immutable-version road/military tile source without starting I/O. */
|
||||
export function createOpenFreeMapSource(options = {}) {
|
||||
const {
|
||||
projection = null,
|
||||
decode,
|
||||
sharedDecodedCache,
|
||||
selectDecoded,
|
||||
...sourceOptions
|
||||
} = options;
|
||||
const shared = !decode && !selectDecoded;
|
||||
return createVectorTileSource({
|
||||
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
||||
allowedOrigin: 'https://tiles.openfreemap.org',
|
||||
decode: decodeOpenFreeMapTile,
|
||||
decode: shared
|
||||
? decodeSharedOpenFreeMapTile
|
||||
: decode || decodeOpenFreeMapTile,
|
||||
...(shared
|
||||
? {
|
||||
sharedDecodedCache: sharedDecodedCache || SHARED_DECODED_TILES,
|
||||
selectDecoded: projection
|
||||
? (value) => value.project(projection)
|
||||
: ({ roads, military }) => ({ roads, military }),
|
||||
}
|
||||
: {
|
||||
...(sharedDecodedCache ? { sharedDecodedCache } : {}),
|
||||
...(selectDecoded ? { selectDecoded } : {}),
|
||||
}),
|
||||
maxEntries: 192,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
...options,
|
||||
...sourceOptions,
|
||||
});
|
||||
}
|
||||
@@ -7,11 +7,38 @@ import {
|
||||
polygonCentroid,
|
||||
clipTileLine,
|
||||
createOpenFreeMapSource,
|
||||
estimateOpenFreeMapProjectionBytes,
|
||||
} from './openFreeMap.js';
|
||||
import { createVectorTileSource } from './vectorTiles.js';
|
||||
const fixture = (name) =>
|
||||
readFileSync(new URL(`../data/fixtures/${name}`, import.meta.url));
|
||||
|
||||
test('projected tile geometry has deterministic accounting and a coordinate cap', () => {
|
||||
const small = {
|
||||
streets: [
|
||||
{
|
||||
name: 'A',
|
||||
coordinates: [
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
estimateOpenFreeMapProjectionBytes(small),
|
||||
estimateOpenFreeMapProjectionBytes(structuredClone(small)),
|
||||
);
|
||||
assert.ok(estimateOpenFreeMapProjectionBytes(small) > 4 * 8);
|
||||
assert.throws(
|
||||
() =>
|
||||
estimateOpenFreeMapProjectionBytes({
|
||||
coordinates: Array.from({ length: 250_001 }, () => [0, 0]),
|
||||
}),
|
||||
/coordinate limit/,
|
||||
);
|
||||
});
|
||||
|
||||
test('real z14 and z12 Austin tiles decode only drivable roads', () => {
|
||||
for (const [z, x, y, name] of [
|
||||
[14, 3743, 6745, 'ofm-austin-14-3743-6745.pbf'],
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { clipTileLine, createOpenFreeMapSource } from './openFreeMap.js';
|
||||
import {
|
||||
clipTileLine,
|
||||
createOpenFreeMapSource,
|
||||
registerOpenFreeMapProjection,
|
||||
} from './openFreeMap.js';
|
||||
import { tileToBBox } from '../data/tomtomTiles.js';
|
||||
import {
|
||||
approximateDistanceM,
|
||||
ringAreaM2,
|
||||
closeRing,
|
||||
} from './featureGeometry.js';
|
||||
import { phaseTiming } from './phaseTiming.js';
|
||||
|
||||
/**
|
||||
* Street and building outlines from the same OpenFreeMap z14 vector tiles the
|
||||
* traffic layer reads. No new tile provider: the source below points at the
|
||||
* same immutable tile URLs, so tiles traffic already loaded come from the
|
||||
* browser cache. Nothing here is fetched without an explicit ask.
|
||||
* same immutable tile URLs and bounded decoded owner. Traffic prepares the
|
||||
* outline projection in idle time; nothing here starts provider I/O without
|
||||
* an explicit traffic or outline ask.
|
||||
*/
|
||||
|
||||
/** Tile zoom used for outlines (the highest OpenMapTiles zoom with buildings). */
|
||||
@@ -54,10 +60,17 @@ const GROUNDS_AREA_CLASSES = Object.freeze({
|
||||
});
|
||||
|
||||
/** Lines of one tile's `transportation_name` layer and polygons of `building`. */
|
||||
export function decodeOpenFreeMapOutlineTile(bytes, z, x, y) {
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
export function decodeOpenFreeMapOutlineTile(
|
||||
bytes,
|
||||
z,
|
||||
x,
|
||||
y,
|
||||
parsedTile = null,
|
||||
) {
|
||||
const tile = parsedTile || new VectorTile(new PbfReader(bytes));
|
||||
const box = tileToBBox(z, x, y);
|
||||
const streets = [];
|
||||
const streetsStart = performance.now();
|
||||
const names = tile.layers.transportation_name;
|
||||
if (names) {
|
||||
if (names.length > LAYER_FEATURE_LIMIT)
|
||||
@@ -85,6 +98,17 @@ export function decodeOpenFreeMapOutlineTile(bytes, z, x, y) {
|
||||
});
|
||||
}
|
||||
}
|
||||
phaseTiming(
|
||||
'street-extract',
|
||||
streetsStart,
|
||||
{
|
||||
tile: `${z}/${x}/${y}`,
|
||||
features: names?.length || 0,
|
||||
streets: streets.length,
|
||||
},
|
||||
'outlines',
|
||||
);
|
||||
const polygonsStart = performance.now();
|
||||
const buildings = decodePolygons(
|
||||
tile.layers.building,
|
||||
z,
|
||||
@@ -115,9 +139,21 @@ export function decodeOpenFreeMapOutlineTile(bytes, z, x, y) {
|
||||
),
|
||||
);
|
||||
}
|
||||
phaseTiming(
|
||||
'polygon-extract',
|
||||
polygonsStart,
|
||||
{
|
||||
tile: `${z}/${x}/${y}`,
|
||||
buildings: buildings.length,
|
||||
areas: areas.length,
|
||||
},
|
||||
'outlines',
|
||||
);
|
||||
return { streets, buildings, areas };
|
||||
}
|
||||
|
||||
registerOpenFreeMapProjection('outlines', decodeOpenFreeMapOutlineTile);
|
||||
|
||||
/** Tile pixel -> longitude/latitude, exactly as vector-tile's toGeoJSON projects. */
|
||||
function pixelToLonLat(px, py, extent, z, x, y) {
|
||||
const size = extent * 2 ** z;
|
||||
@@ -192,7 +228,7 @@ function decodePolygons(layer, z, x, y, keep, describe) {
|
||||
/** Construct the outline tile source: same OpenFreeMap tiles, outline decode. */
|
||||
export function createOpenFreeMapOutlineSource(options = {}) {
|
||||
return createOpenFreeMapSource({
|
||||
decode: decodeOpenFreeMapOutlineTile,
|
||||
projection: 'outlines',
|
||||
maxEntries: 32,
|
||||
maxCacheBytes: 16 * 1024 * 1024,
|
||||
...options,
|
||||
|
||||
@@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
buildingFromTiles,
|
||||
createOpenFreeMapOutlineSource,
|
||||
decodeOpenFreeMapOutlineTile,
|
||||
enclosingAreaFromTiles,
|
||||
mergeLineSegments,
|
||||
@@ -12,6 +13,12 @@ import {
|
||||
streetFromTiles,
|
||||
streetNameMatches,
|
||||
} from './openFreeMapOutlines.js';
|
||||
import {
|
||||
clearSharedOpenFreeMapTiles,
|
||||
createOpenFreeMapSource,
|
||||
registerOpenFreeMapProjection,
|
||||
} from './openFreeMap.js';
|
||||
import { createSharedDecodedTileCache } from './vectorTiles.js';
|
||||
import { approximateDistanceM } from './featureGeometry.js';
|
||||
|
||||
const fixture = (y) =>
|
||||
@@ -43,7 +50,10 @@ test('tiles decode named street lines and building polygons', () => {
|
||||
|
||||
test('street names compare with abbreviations and a leading direction only', () => {
|
||||
assert.equal(normalizeStreetName('Congress Ave.'), 'congress avenue');
|
||||
assert.equal(normalizeStreetName('Lombard St, San Francisco'), 'lombard street');
|
||||
assert.equal(
|
||||
normalizeStreetName('Lombard St, San Francisco'),
|
||||
'lombard street',
|
||||
);
|
||||
assert.ok(streetNameMatches('Congress Avenue', 'congress ave'));
|
||||
assert.ok(streetNameMatches('North Congress Avenue', 'Congress Avenue'));
|
||||
assert.ok(!streetNameMatches('Congress Avenue', 'South Congress Avenue'));
|
||||
@@ -52,21 +62,33 @@ test('street names compare with abbreviations and a leading direction only', ()
|
||||
});
|
||||
|
||||
test('same-name segments merge into polylines around the point (Congress Avenue)', () => {
|
||||
const street = streetFromTiles(TILES, { name: 'Congress Avenue', ...CAPITOL });
|
||||
const street = streetFromTiles(TILES, {
|
||||
name: 'Congress Avenue',
|
||||
...CAPITOL,
|
||||
});
|
||||
assert.equal(street.name, 'Congress Avenue');
|
||||
assert.equal(street.lines.length, 1, 'the tile pieces join into one line');
|
||||
const line = street.lines[0];
|
||||
const [start, end] = [line[0], line.at(-1)];
|
||||
const spanM = approximateDistanceM(start[1], start[0], end[1], end[0]);
|
||||
assert.ok(spanM > 1200, `Congress runs ${spanM.toFixed(0)} m from the river to the Capitol`);
|
||||
assert.ok(
|
||||
spanM > 1200,
|
||||
`Congress runs ${spanM.toFixed(0)} m from the river to the Capitol`,
|
||||
);
|
||||
// South Congress is a different street and stays out.
|
||||
const south = streetFromTiles(TILES, { name: 'South Congress Avenue', ...CAPITOL });
|
||||
const south = streetFromTiles(TILES, {
|
||||
name: 'South Congress Avenue',
|
||||
...CAPITOL,
|
||||
});
|
||||
assert.ok(south);
|
||||
assert.ok(south.lines.every((l) => l.every(([, lat]) => lat < 30.265)));
|
||||
});
|
||||
|
||||
test('street lookups are bounded by radius and answer null for unknown names', () => {
|
||||
assert.equal(streetFromTiles(TILES, { name: 'Lombard Street', ...CAPITOL }), null);
|
||||
assert.equal(
|
||||
streetFromTiles(TILES, { name: 'Lombard Street', ...CAPITOL }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
streetFromTiles(TILES, { name: 'Congress Avenue', lat: 30.4, lon: -97.74 }),
|
||||
null,
|
||||
@@ -75,9 +97,18 @@ test('street lookups are bounded by radius and answer null for unknown names', (
|
||||
});
|
||||
|
||||
test('merge joins reversed and out-of-order pieces and keeps gaps apart', () => {
|
||||
const a = [[0, 0], [0.001, 0]];
|
||||
const b = [[0.002, 0], [0.001, 0]]; // reversed, shares a's end
|
||||
const c = [[0.01, 0], [0.011, 0]]; // separate
|
||||
const a = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
];
|
||||
const b = [
|
||||
[0.002, 0],
|
||||
[0.001, 0],
|
||||
]; // reversed, shares a's end
|
||||
const c = [
|
||||
[0.01, 0],
|
||||
[0.011, 0],
|
||||
]; // separate
|
||||
const chains = mergeLineSegments([b, c, a]);
|
||||
assert.equal(chains.length, 2);
|
||||
assert.equal(chains.find((ch) => ch.length === 3).length, 3);
|
||||
@@ -92,7 +123,13 @@ test('the building containing a point is picked (Texas Capitol)', () => {
|
||||
});
|
||||
|
||||
test('a point beside a building picks the nearest within 40 m, else none', () => {
|
||||
const box = [[0, 0], [0.0002, 0], [0.0002, 0.0002], [0, 0.0002], [0, 0]];
|
||||
const box = [
|
||||
[0, 0],
|
||||
[0.0002, 0],
|
||||
[0.0002, 0.0002],
|
||||
[0, 0.0002],
|
||||
[0, 0],
|
||||
];
|
||||
const tiles = [{ buildings: [{ rings: [box], heightM: null }] }];
|
||||
const near = buildingFromTiles(tiles, { lat: 0.0001, lon: 0.00045 }); // ~28 m east
|
||||
assert.ok(near && !near.contains);
|
||||
@@ -102,7 +139,10 @@ test('a point beside a building picks the nearest within 40 m, else none', () =>
|
||||
test('the open area enclosing the Capitol is its grounds, not a building', () => {
|
||||
const grounds = enclosingAreaFromTiles(TILES, CAPITOL);
|
||||
assert.ok(grounds, 'the park-like cover around the Capitol is found');
|
||||
assert.ok(grounds.areaM2 > 50_000 && grounds.areaM2 < 500_000, `${grounds.areaM2} m²`);
|
||||
assert.ok(
|
||||
grounds.areaM2 > 50_000 && grounds.areaM2 < 500_000,
|
||||
`${grounds.areaM2} m²`,
|
||||
);
|
||||
assert.equal(grounds.class, 'park');
|
||||
assert.equal(
|
||||
enclosingAreaFromTiles(TILES, { ...CAPITOL, minAreaM2: 1e6 }),
|
||||
@@ -110,3 +150,151 @@ test('the open area enclosing the Capitol is its grounds, not a building', () =>
|
||||
'nothing grounds-sized above the floor',
|
||||
);
|
||||
});
|
||||
|
||||
test('traffic idle preparation gives outlines the same decoded tile without another request', async () => {
|
||||
clearSharedOpenFreeMapTiles();
|
||||
const idle = [];
|
||||
const previousIdle = globalThis.requestIdleCallback;
|
||||
const previousCancelIdle = globalThis.cancelIdleCallback;
|
||||
globalThis.requestIdleCallback = (callback) => {
|
||||
idle.push(callback);
|
||||
return idle.length;
|
||||
};
|
||||
globalThis.cancelIdleCallback = () => {};
|
||||
let requests = 0;
|
||||
const bytes = readFileSync(
|
||||
new URL(
|
||||
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const fetchImpl = async () => {
|
||||
requests++;
|
||||
return new Response(bytes);
|
||||
};
|
||||
const tiles = [{ z: 14, x: 3743, y: 6745 }];
|
||||
const box = { south: 30.27, north: 30.28, west: -97.75, east: -97.73 };
|
||||
try {
|
||||
const traffic = createOpenFreeMapSource({
|
||||
template:
|
||||
'https://tiles.openfreemap.org/planet/v/20250910_001001_pt/{z}/{x}/{y}.pbf',
|
||||
fetchImpl,
|
||||
});
|
||||
const outlines = createOpenFreeMapOutlineSource({
|
||||
template:
|
||||
'https://tiles.openfreemap.org/planet/v/20250910_001001_pt/{z}/{x}/{y}.pbf',
|
||||
fetchImpl,
|
||||
});
|
||||
await traffic.fetchBounds(box, { zoom: 14, tiles });
|
||||
assert.ok(idle.length > 0, 'traffic schedules the registered projection');
|
||||
for (const callback of idle)
|
||||
callback({ didTimeout: false, timeRemaining: () => 50 });
|
||||
const result = await outlines.fetchBounds(box, { zoom: 14, tiles });
|
||||
assert.ok(result.tiles[0].buildings.length > 0);
|
||||
assert.equal(requests, 1);
|
||||
} finally {
|
||||
globalThis.requestIdleCallback = previousIdle;
|
||||
globalThis.cancelIdleCallback = previousCancelIdle;
|
||||
clearSharedOpenFreeMapTiles();
|
||||
}
|
||||
});
|
||||
|
||||
test('idle projections are retired on shared eviction, clear, and self-eviction', async () => {
|
||||
const previousIdle = globalThis.requestIdleCallback;
|
||||
const previousCancelIdle = globalThis.cancelIdleCallback;
|
||||
const scheduled = new Map();
|
||||
const cancelled = new Set();
|
||||
let nextHandle = 1;
|
||||
globalThis.requestIdleCallback = (callback) => {
|
||||
const handle = nextHandle++;
|
||||
scheduled.set(handle, callback);
|
||||
return handle;
|
||||
};
|
||||
globalThis.cancelIdleCallback = (handle) => cancelled.add(handle);
|
||||
let projectionRuns = 0;
|
||||
registerOpenFreeMapProjection('idle-lifecycle-test', () => {
|
||||
projectionRuns++;
|
||||
return { coordinates: Array.from({ length: 20_000 }, (_, i) => [i, i]) };
|
||||
});
|
||||
const firstBytes = readFileSync(
|
||||
new URL(
|
||||
'../data/fixtures/ofm-outlines-austin-14-3743-6745.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const secondBytes = readFileSync(
|
||||
new URL(
|
||||
'../data/fixtures/ofm-outlines-austin-14-3743-6746.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const sharedDecodedCache = createSharedDecodedTileCache({
|
||||
maxEntries: 1,
|
||||
maxCacheBytes: 100_000,
|
||||
});
|
||||
const source = createOpenFreeMapSource({
|
||||
template: 'https://tiles.openfreemap.org/planet/v/test/{z}/{x}/{y}.pbf',
|
||||
sharedDecodedCache,
|
||||
fetchImpl: async (url) =>
|
||||
new Response(
|
||||
String(url).includes('/6746.pbf') ? secondBytes : firstBytes,
|
||||
),
|
||||
});
|
||||
const box = { south: 30.24, north: 30.29, west: -97.75, east: -97.73 };
|
||||
try {
|
||||
await source.fetchBounds(box, {
|
||||
zoom: 14,
|
||||
tiles: [{ z: 14, x: 3743, y: 6745 }],
|
||||
});
|
||||
const firstHandles = [...scheduled.keys()];
|
||||
assert.ok(firstHandles.length >= 2);
|
||||
|
||||
await source.fetchBounds(box, {
|
||||
zoom: 14,
|
||||
tiles: [{ z: 14, x: 3743, y: 6746 }],
|
||||
});
|
||||
assert.ok(firstHandles.every((handle) => cancelled.has(handle)));
|
||||
for (const handle of firstHandles) scheduled.get(handle)();
|
||||
assert.equal(projectionRuns, 0, 'late callbacks after eviction are inert');
|
||||
|
||||
const secondHandles = [...scheduled.keys()].filter(
|
||||
(handle) => !firstHandles.includes(handle),
|
||||
);
|
||||
sharedDecodedCache.clear();
|
||||
assert.ok(secondHandles.every((handle) => cancelled.has(handle)));
|
||||
for (const handle of secondHandles) scheduled.get(handle)();
|
||||
assert.equal(projectionRuns, 0, 'late callbacks after clear are inert');
|
||||
|
||||
const selfEvictingCache = createSharedDecodedTileCache({
|
||||
maxEntries: 1,
|
||||
maxCacheBytes: 100_000,
|
||||
});
|
||||
const selfEvictingSource = createOpenFreeMapSource({
|
||||
template: 'https://tiles.openfreemap.org/planet/v/self/{z}/{x}/{y}.pbf',
|
||||
sharedDecodedCache: selfEvictingCache,
|
||||
fetchImpl: async () => new Response(firstBytes),
|
||||
});
|
||||
const beforeSelfEviction = nextHandle;
|
||||
await selfEvictingSource.fetchBounds(box, {
|
||||
zoom: 14,
|
||||
tiles: [{ z: 14, x: 3743, y: 6745 }],
|
||||
});
|
||||
const selfEvictingHandles = [...scheduled.keys()].filter(
|
||||
(handle) => handle >= beforeSelfEviction,
|
||||
);
|
||||
scheduled.get(selfEvictingHandles.at(-1))();
|
||||
assert.equal(projectionRuns, 1);
|
||||
assert.deepEqual(selfEvictingCache.stats(), { entries: 0, bytes: 0 });
|
||||
assert.ok(
|
||||
selfEvictingHandles.slice(0, -1).every((handle) => cancelled.has(handle)),
|
||||
);
|
||||
for (const handle of selfEvictingHandles)
|
||||
if (!cancelled.has(handle)) scheduled.get(handle)();
|
||||
assert.equal(projectionRuns, 1, 'self-eviction retires sibling idle work');
|
||||
} finally {
|
||||
source.clear();
|
||||
sharedDecodedCache.clear();
|
||||
globalThis.requestIdleCallback = previousIdle;
|
||||
globalThis.cancelIdleCallback = previousCancelIdle;
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Optional development phase measurements; no provider payloads or credentials. */
|
||||
export function phaseTiming(phase, start, detail = {}) {
|
||||
export function phaseTiming(phase, start, detail = {}, scope = 'roads') {
|
||||
if (import.meta.env?.DEV)
|
||||
performance.measure(`roads:${phase}`, {
|
||||
performance.measure(`${scope}:${phase}`, {
|
||||
start,
|
||||
end: performance.now(),
|
||||
detail,
|
||||
|
||||
+220
-43
@@ -16,6 +16,131 @@ export function validTileBounds(box) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded decoded ownership shared by compatible vector-tile consumers.
|
||||
* Entries are keyed by the immutable tile template plus XYZ identity; callers
|
||||
* still keep their own consumer-shaped caches and request lifetimes.
|
||||
*/
|
||||
export function createSharedDecodedTileCache({
|
||||
maxEntries = 192,
|
||||
maxCacheBytes = 64 * 1024 * 1024,
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
const flights = new Map();
|
||||
let cacheBytes = 0;
|
||||
let generation = 0;
|
||||
const release = (entry) => {
|
||||
if (!entry || entry.released) return;
|
||||
entry.released = true;
|
||||
entry.value?.releaseSharedCacheEntry?.();
|
||||
};
|
||||
const remove = (key, expected = null) => {
|
||||
const entry = cache.get(key);
|
||||
if (!entry || (expected && entry !== expected)) return false;
|
||||
cache.delete(key);
|
||||
cacheBytes -= entry.size;
|
||||
release(entry);
|
||||
return true;
|
||||
};
|
||||
const evict = () => {
|
||||
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
|
||||
const oldest = cache.keys().next().value;
|
||||
remove(oldest);
|
||||
}
|
||||
};
|
||||
const resize = (key, value, entry, size) => {
|
||||
if (!Number.isFinite(size) || size < 0) return;
|
||||
const previousSize = entry.size;
|
||||
entry.size = size;
|
||||
if (cache.get(key) !== entry || entry.value !== value) return;
|
||||
cacheBytes += size - previousSize;
|
||||
evict();
|
||||
};
|
||||
const store = (key, value, size) => {
|
||||
const entry = { value, size, released: false };
|
||||
if (!Number.isFinite(size) || size < 0 || size > maxCacheBytes)
|
||||
return entry;
|
||||
remove(key);
|
||||
cache.set(key, entry);
|
||||
cacheBytes += size;
|
||||
try {
|
||||
value?.bindSharedCacheEntry?.(
|
||||
(nextSize) => resize(key, value, entry, nextSize),
|
||||
size,
|
||||
);
|
||||
} catch (error) {
|
||||
remove(key, entry);
|
||||
throw error;
|
||||
}
|
||||
evict();
|
||||
return entry;
|
||||
};
|
||||
const subscribe = (flight, signal, key) => {
|
||||
signal?.throwIfAborted();
|
||||
flight.users += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
flight.promise
|
||||
.then(resolve, reject)
|
||||
.finally(() => signal?.removeEventListener('abort', abort));
|
||||
}).finally(() => {
|
||||
flight.users -= 1;
|
||||
if (!flight.users && flights.get(key) === flight) {
|
||||
flights.delete(key);
|
||||
flight.controller.abort();
|
||||
}
|
||||
});
|
||||
};
|
||||
return {
|
||||
get(key) {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
cache.delete(key);
|
||||
cache.set(key, hit);
|
||||
return hit;
|
||||
},
|
||||
set(key, value, size) {
|
||||
return store(key, value, size);
|
||||
},
|
||||
load(key, signal, loader) {
|
||||
const hit = this.get(key);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
let flight = flights.get(key);
|
||||
if (!flight) {
|
||||
flight = {
|
||||
controller: new AbortController(),
|
||||
users: 0,
|
||||
promise: null,
|
||||
generation,
|
||||
};
|
||||
flights.set(key, flight);
|
||||
flight.promise = Promise.resolve()
|
||||
.then(() => loader(flight.controller.signal))
|
||||
.then(({ value, size }) => {
|
||||
flight.controller.signal.throwIfAborted();
|
||||
if (flight.generation !== generation)
|
||||
throw new DOMException('Shared tile cache cleared', 'AbortError');
|
||||
return store(key, value, size);
|
||||
})
|
||||
.finally(() => {
|
||||
if (flights.get(key) === flight) flights.delete(key);
|
||||
});
|
||||
}
|
||||
return subscribe(flight, signal, key);
|
||||
},
|
||||
clear() {
|
||||
generation += 1;
|
||||
for (const flight of flights.values()) flight.controller.abort();
|
||||
flights.clear();
|
||||
for (const entry of cache.values()) release(entry);
|
||||
cache.clear();
|
||||
cacheBytes = 0;
|
||||
},
|
||||
stats: () => ({ entries: cache.size, bytes: cacheBytes }),
|
||||
};
|
||||
}
|
||||
|
||||
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
|
||||
export function createVectorTileSource({
|
||||
tileJsonUrl,
|
||||
@@ -31,6 +156,8 @@ export function createVectorTileSource({
|
||||
maxResponseBytes = 4 * 1024 * 1024,
|
||||
metadataCooldownMs = 5000,
|
||||
now = Date.now,
|
||||
sharedDecodedCache = null,
|
||||
selectDecoded = (value) => value,
|
||||
}) {
|
||||
const cache = new Map();
|
||||
const active = new Set();
|
||||
@@ -44,13 +171,15 @@ export function createVectorTileSource({
|
||||
let fetched = 0;
|
||||
let activeWorkers = 0;
|
||||
const waiters = [];
|
||||
const consumerValue = (value) =>
|
||||
sharedDecodedCache ? structuredClone(value) : value;
|
||||
|
||||
async function request(url, parentSignal, read) {
|
||||
async function request(url, parentSignal, read, trackAsSource = true) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(parentSignal?.reason);
|
||||
parentSignal?.throwIfAborted();
|
||||
parentSignal?.addEventListener('abort', abort, { once: true });
|
||||
active.add(controller);
|
||||
if (trackAsSource) active.add(controller);
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
@@ -88,7 +217,7 @@ export function createVectorTileSource({
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener('abort', abort);
|
||||
active.delete(controller);
|
||||
if (trackAsSource) active.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +361,7 @@ export function createVectorTileSource({
|
||||
const abort = () => reject(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
flight.promise
|
||||
.then(resolve, reject)
|
||||
.then((value) => resolve(consumerValue(value)), reject)
|
||||
.finally(() => signal?.removeEventListener('abort', abort));
|
||||
}).finally(() => {
|
||||
if (--flight.users === 0 && flights.get(key) === flight) {
|
||||
@@ -249,54 +378,102 @@ export function createVectorTileSource({
|
||||
if (hit && now() - hit.at < ttlMs) {
|
||||
cache.delete(key);
|
||||
cache.set(key, hit);
|
||||
return hit.value;
|
||||
return consumerValue(hit.value);
|
||||
}
|
||||
if (hit) {
|
||||
cache.delete(key);
|
||||
cacheBytes -= hit.size;
|
||||
}
|
||||
const shared = sharedDecodedCache?.get(key);
|
||||
if (shared) {
|
||||
// A shared decode is authoritative cache state. Give each consumer its
|
||||
// own value so traffic, outlines, or a future caller cannot mutate the
|
||||
// cached object (including nested coordinate arrays) for everyone else.
|
||||
const value = structuredClone(selectDecoded(shared.value, tile));
|
||||
const size = shared.size;
|
||||
if (epoch === generation && size <= maxCacheBytes) {
|
||||
cache.set(key, { at: now(), value, size });
|
||||
cacheBytes += size;
|
||||
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
|
||||
const oldest = cache.keys().next().value;
|
||||
cacheBytes -= cache.get(oldest).size;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
return consumerValue(value);
|
||||
}
|
||||
const loadDecoded = async (ownedSignal, sharedOwnership = false) => {
|
||||
await acquire(ownedSignal);
|
||||
try {
|
||||
ownedSignal.throwIfAborted();
|
||||
fetched++;
|
||||
const url = meta.template
|
||||
.replace('{z}', tile.z)
|
||||
.replace('{x}', tile.x)
|
||||
.replace('{y}', tile.y);
|
||||
const fetchStart = performance.now();
|
||||
const bytes = await request(
|
||||
url,
|
||||
ownedSignal,
|
||||
async (res, requestSignal) => {
|
||||
const bodyStart = performance.now();
|
||||
const value = await readResponseBytesCapped(
|
||||
res,
|
||||
maxResponseBytes,
|
||||
requestSignal,
|
||||
);
|
||||
phaseTiming('tile-body-read', bodyStart, {
|
||||
source: allowedOrigin,
|
||||
key,
|
||||
bytes: value.byteLength,
|
||||
});
|
||||
return value;
|
||||
},
|
||||
!sharedOwnership,
|
||||
);
|
||||
phaseTiming('tile-fetch', fetchStart, {
|
||||
source: allowedOrigin,
|
||||
key,
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
const decodeStart = performance.now();
|
||||
const decoded = decode(bytes, tile.z, tile.x, tile.y);
|
||||
phaseTiming('decode', decodeStart, { source: allowedOrigin, key });
|
||||
ownedSignal.throwIfAborted();
|
||||
// Body-based decoded-storage estimate; projections may increase it.
|
||||
return { value: decoded, size: bytes.byteLength * 4 };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
let flight = flights.get(key);
|
||||
if (!flight) {
|
||||
flight = { controller: new AbortController(), users: 0 };
|
||||
const ownedSignal = flight.controller.signal;
|
||||
flights.set(key, flight);
|
||||
flight.promise = (async () => {
|
||||
await acquire(ownedSignal);
|
||||
try {
|
||||
ownedSignal.throwIfAborted();
|
||||
if (epoch !== generation)
|
||||
throw new DOMException('Source cleared', 'AbortError');
|
||||
fetched++;
|
||||
const url = meta.template
|
||||
.replace('{z}', tile.z)
|
||||
.replace('{x}', tile.x)
|
||||
.replace('{y}', tile.y);
|
||||
const fetchStart = performance.now();
|
||||
const bytes = await request(url, ownedSignal, (res, requestSignal) =>
|
||||
readResponseBytesCapped(res, maxResponseBytes, requestSignal),
|
||||
);
|
||||
phaseTiming('tile-fetch', fetchStart, {
|
||||
source: allowedOrigin,
|
||||
key,
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
const decodeStart = performance.now();
|
||||
const value = decode(bytes, tile.z, tile.x, tile.y);
|
||||
phaseTiming('decode', decodeStart, { source: allowedOrigin, key });
|
||||
ownedSignal.throwIfAborted();
|
||||
// Body-based decoded-storage estimate; never stringify geometry on the load path.
|
||||
const size = bytes.byteLength * 4;
|
||||
if (epoch === generation && size <= maxCacheBytes) {
|
||||
if (cache.has(key)) cacheBytes -= cache.get(key).size;
|
||||
cache.delete(key);
|
||||
cache.set(key, { at: now(), value, size });
|
||||
cacheBytes += size;
|
||||
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
|
||||
const oldest = cache.keys().next().value;
|
||||
cacheBytes -= cache.get(oldest).size;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
const decodedEntry = sharedDecodedCache
|
||||
? await sharedDecodedCache.load(key, ownedSignal, (sharedSignal) =>
|
||||
loadDecoded(sharedSignal, true),
|
||||
)
|
||||
: await loadDecoded(ownedSignal);
|
||||
ownedSignal.throwIfAborted();
|
||||
const selected = selectDecoded(decodedEntry.value, tile);
|
||||
const value = sharedDecodedCache ? structuredClone(selected) : selected;
|
||||
const currentShared = sharedDecodedCache?.get(key);
|
||||
const size = currentShared?.size ?? decodedEntry.size;
|
||||
if (epoch === generation && size <= maxCacheBytes) {
|
||||
if (cache.has(key)) cacheBytes -= cache.get(key).size;
|
||||
cache.delete(key);
|
||||
cache.set(key, { at: now(), value, size });
|
||||
cacheBytes += size;
|
||||
while (cache.size > maxEntries || cacheBytes > maxCacheBytes) {
|
||||
const oldest = cache.keys().next().value;
|
||||
cacheBytes -= cache.get(oldest).size;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
return value;
|
||||
})().finally(() => {
|
||||
if (flights.get(key) === flight) flights.delete(key);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createVectorTileSource } from './vectorTiles.js';
|
||||
import {
|
||||
createSharedDecodedTileCache,
|
||||
createVectorTileSource,
|
||||
} from './vectorTiles.js';
|
||||
const meta = { tiles: ['https://tiles.example/{z}/{x}/{y}.pbf'] };
|
||||
const box = { south: 30.267, north: 30.268, west: -97.744, east: -97.743 };
|
||||
const options = {
|
||||
@@ -203,3 +206,238 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue
|
||||
'over-budget selection is rejected before fetching',
|
||||
);
|
||||
});
|
||||
|
||||
test('compatible consumers share bounded decoded ownership without sharing local lifetimes', async () => {
|
||||
const sharedDecodedCache = createSharedDecodedTileCache({
|
||||
maxEntries: 2,
|
||||
maxCacheBytes: 100,
|
||||
});
|
||||
let decodes = 0,
|
||||
requests = 0;
|
||||
const common = {
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
sharedDecodedCache,
|
||||
decode: () => {
|
||||
decodes++;
|
||||
return { roads: ['road'], outlines: ['outline'] };
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
requests++;
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
};
|
||||
const roads = createVectorTileSource({
|
||||
...common,
|
||||
selectDecoded: (value) => value.roads,
|
||||
});
|
||||
const outlines = createVectorTileSource({
|
||||
...common,
|
||||
selectDecoded: (value) => value.outlines,
|
||||
});
|
||||
const tiles = [{ z: 14, x: 3743, y: 6745 }];
|
||||
assert.deepEqual((await roads.fetchBounds(box, { zoom: 14, tiles })).tiles, [
|
||||
['road'],
|
||||
]);
|
||||
roads.clear();
|
||||
assert.deepEqual(
|
||||
(await outlines.fetchBounds(box, { zoom: 14, tiles })).tiles,
|
||||
[['outline']],
|
||||
);
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(decodes, 1);
|
||||
assert.deepEqual(sharedDecodedCache.stats(), { entries: 1, bytes: 12 });
|
||||
});
|
||||
|
||||
test('shared decoded tiles are isolated from consumer mutation', async () => {
|
||||
const sharedDecodedCache = createSharedDecodedTileCache();
|
||||
let decodes = 0,
|
||||
requests = 0;
|
||||
const common = {
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
sharedDecodedCache,
|
||||
decode: () => {
|
||||
decodes++;
|
||||
return { roads: [{ name: 'Congress Avenue', coordinates: [[-97, 30]] }] };
|
||||
},
|
||||
selectDecoded: (value) => value.roads,
|
||||
fetchImpl: async () => {
|
||||
requests++;
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
};
|
||||
const first = createVectorTileSource(common);
|
||||
const second = createVectorTileSource(common);
|
||||
const third = createVectorTileSource(common);
|
||||
const tiles = [{ z: 14, x: 3743, y: 6745 }];
|
||||
const firstResult = await first.fetchBounds(box, { zoom: 14, tiles });
|
||||
firstResult.tiles[0][0].name = 'first mutation';
|
||||
firstResult.tiles[0][0].coordinates[0][0] = 0;
|
||||
|
||||
const secondResult = await second.fetchBounds(box, { zoom: 14, tiles });
|
||||
assert.equal(secondResult.tiles[0][0].name, 'Congress Avenue');
|
||||
assert.equal(secondResult.tiles[0][0].coordinates[0][0], -97);
|
||||
secondResult.tiles[0][0].name = 'second mutation';
|
||||
secondResult.tiles[0][0].coordinates[0][0] = 1;
|
||||
|
||||
const thirdResult = await third.fetchBounds(box, { zoom: 14, tiles });
|
||||
assert.equal(thirdResult.tiles[0][0].name, 'Congress Avenue');
|
||||
assert.equal(thirdResult.tiles[0][0].coordinates[0][0], -97);
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(decodes, 1);
|
||||
});
|
||||
|
||||
test('one source isolates in-flight and completed cache results from consumer mutation', async () => {
|
||||
const sharedDecodedCache = createSharedDecodedTileCache();
|
||||
let decodes = 0,
|
||||
requests = 0,
|
||||
releaseResponse;
|
||||
const responseReady = new Promise((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
let requestStarted;
|
||||
const started = new Promise((resolve) => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const source = createVectorTileSource({
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
sharedDecodedCache,
|
||||
decode: () => {
|
||||
decodes++;
|
||||
return { roads: [{ name: 'Congress Avenue', coordinates: [[-97, 30]] }] };
|
||||
},
|
||||
selectDecoded: (value) => value.roads,
|
||||
fetchImpl: async () => {
|
||||
requests++;
|
||||
requestStarted();
|
||||
await responseReady;
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
});
|
||||
const tiles = [{ z: 14, x: 3743, y: 6745 }];
|
||||
const firstPending = source.fetchBounds(box, { zoom: 14, tiles });
|
||||
await started;
|
||||
const concurrentPending = source.fetchBounds(box, { zoom: 14, tiles });
|
||||
releaseResponse();
|
||||
|
||||
const firstResult = await firstPending;
|
||||
const concurrentResult = await concurrentPending;
|
||||
firstResult.tiles[0][0].name = 'first mutation';
|
||||
firstResult.tiles[0][0].coordinates[0][0] = 0;
|
||||
assert.equal(concurrentResult.tiles[0][0].name, 'Congress Avenue');
|
||||
assert.equal(concurrentResult.tiles[0][0].coordinates[0][0], -97);
|
||||
|
||||
concurrentResult.tiles[0][0].name = 'concurrent mutation';
|
||||
concurrentResult.tiles[0][0].coordinates[0][0] = 1;
|
||||
const cachedResult = await source.fetchBounds(box, { zoom: 14, tiles });
|
||||
assert.equal(cachedResult.tiles[0][0].name, 'Congress Avenue');
|
||||
assert.equal(cachedResult.tiles[0][0].coordinates[0][0], -97);
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(decodes, 1);
|
||||
});
|
||||
|
||||
test('compatible consumers share one in-flight fetch and survive one consumer clearing', async () => {
|
||||
const sharedDecodedCache = createSharedDecodedTileCache();
|
||||
let requests = 0,
|
||||
decodes = 0,
|
||||
releaseResponse;
|
||||
const responseReady = new Promise((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
let requestStarted;
|
||||
const started = new Promise((resolve) => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const common = {
|
||||
...options,
|
||||
template: meta.tiles[0],
|
||||
sharedDecodedCache,
|
||||
decode: () => {
|
||||
decodes++;
|
||||
return { roads: ['road'], outlines: ['outline'] };
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
requests++;
|
||||
requestStarted();
|
||||
await responseReady;
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
};
|
||||
const roads = createVectorTileSource({
|
||||
...common,
|
||||
selectDecoded: (value) => value.roads,
|
||||
});
|
||||
const outlines = createVectorTileSource({
|
||||
...common,
|
||||
selectDecoded: (value) => value.outlines,
|
||||
});
|
||||
const tiles = [{ z: 14, x: 3743, y: 6745 }];
|
||||
const roadResult = roads.fetchBounds(box, { zoom: 14, tiles });
|
||||
const roadRejected = assert.rejects(roadResult, { name: 'AbortError' });
|
||||
await started;
|
||||
const outlineResult = outlines.fetchBounds(box, { zoom: 14, tiles });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
roads.clear();
|
||||
releaseResponse();
|
||||
await roadRejected;
|
||||
assert.deepEqual((await outlineResult).tiles, [['outline']]);
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(decodes, 1);
|
||||
});
|
||||
|
||||
test('shared ownership recharges materialized projections and evicts over budget', () => {
|
||||
const shared = createSharedDecodedTileCache({ maxCacheBytes: 100 });
|
||||
let resize,
|
||||
releases = 0;
|
||||
const decoded = {
|
||||
bindSharedCacheEntry(update) {
|
||||
resize = update;
|
||||
},
|
||||
releaseSharedCacheEntry() {
|
||||
releases++;
|
||||
},
|
||||
};
|
||||
const entry = shared.set('tile', decoded, 12);
|
||||
assert.deepEqual(shared.stats(), { entries: 1, bytes: 12 });
|
||||
resize(80);
|
||||
assert.deepEqual(shared.stats(), { entries: 1, bytes: 80 });
|
||||
resize(101);
|
||||
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
|
||||
assert.equal(entry.size, 101, 'the returned entry retains its real size');
|
||||
assert.equal(releases, 1, 'self-eviction releases decoded ownership once');
|
||||
shared.clear();
|
||||
assert.equal(releases, 1, 'clearing after eviction does not release twice');
|
||||
});
|
||||
|
||||
test('shared clear releases entries and prevents a pending loader from refilling', async () => {
|
||||
const shared = createSharedDecodedTileCache();
|
||||
let releases = 0;
|
||||
const decoded = {
|
||||
releaseSharedCacheEntry() {
|
||||
releases++;
|
||||
},
|
||||
};
|
||||
shared.set('resident', decoded, 12);
|
||||
shared.clear();
|
||||
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
|
||||
assert.equal(releases, 1);
|
||||
|
||||
let loaderStarted, resolveLoader;
|
||||
const started = new Promise((resolve) => {
|
||||
loaderStarted = resolve;
|
||||
});
|
||||
const loaderResult = new Promise((resolve) => {
|
||||
resolveLoader = resolve;
|
||||
});
|
||||
const pending = shared.load('late', null, async () => {
|
||||
loaderStarted();
|
||||
return loaderResult;
|
||||
});
|
||||
await started;
|
||||
resolveLoader({ value: {}, size: 16 });
|
||||
shared.clear();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.deepEqual(shared.stats(), { entries: 0, bytes: 0 });
|
||||
});
|
||||
Reference in new issue
Block a user