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

+19 -6
View File
@@ -774,7 +774,8 @@ test('replacement cleanup failure still removes the old route and retains failed
const old = engine.list()[0];
const refresh = engine.annotate([{ type: 'area', target: 'Capitol', label: 'New' }]);
await flushMicrotasks();
const replacement = h.pending.at(-1).anno;
const replacementTask = h.pending.at(-1);
const replacement = replacementTask.anno;
const originalRemove = h.renderer.remove;
const removals = [];
let fail = true;
@@ -785,16 +786,22 @@ test('replacement cleanup failure still removes the old route and retains failed
};
assert.throws(() => engine.clear(), /replacement cleanup failed/);
assert.ok(removals.includes(old), 'old removal is attempted even when replacement removal throws');
assert.equal(old._pendingReplacement, null, 'cleanup failure cannot retain install authority');
assert.ok(old._retiredRoutes.has(replacement));
assert.equal(replacementTask.options.isCurrent(), false, 'cleanup failure cannot retain install authority');
assert.equal(engine.list()[0], old, 'failed cleanup remains reachable');
await flushMicrotasks();
fail = false;
const attempts = removals.filter((anno) => anno === replacement).length;
engine.clear();
assert.ok(
removals.filter((anno) => anno === replacement).length > attempts,
'the failed replacement route is retried on the next clear',
);
await refresh;
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
assert.equal(old._pendingReplacement, null);
replacementTask.finish();
await flushMicrotasks();
assert.equal(h.live.size, 0, 'a revoked install cannot commit after cleanup');
});
@@ -840,8 +847,10 @@ test('successful replacement survives failed old-route retirement and keeps clea
await flushMicrotasks(); h.pending[0].finish(); await initial;
const old = engine.list()[0];
const remove = h.renderer.remove;
const removals = [];
let fail = true;
h.renderer.remove = (anno) => {
removals.push(anno);
if (anno === old && fail) throw new Error('retirement failed');
remove(anno);
};
@@ -853,10 +862,14 @@ test('successful replacement survives failed old-route retirement and keeps clea
assert.equal(completed.results[0].cleanupPending, true);
assert.equal(current.label, 'New');
assert.ok(h.live.has(current.id), 'valid installed replacement is not rolled back by old teardown failure');
assert.equal(current._pendingReplacement, undefined);
assert.ok(current._retiredRoutes.has(old));
assert.equal(h.pending.filter((task) => task.options.isCurrent()).length, 0, 'no install is left pending');
fail = false;
const attempts = removals.filter((anno) => anno === old).length;
engine.clear();
assert.ok(
removals.filter((anno) => anno === old).length > attempts,
'the old route that failed to retire is retried on clear',
);
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
});
@@ -225,7 +225,10 @@ function fakeViewer(lon, lat) {
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: frames.event,
requestRender() {},
renderRequests: 0,
requestRender() {
scene.renderRequests += 1;
},
};
return {
dataSources,
@@ -716,6 +719,32 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
assert.equal(viewer.credits.size, 0);
});
test('a building queued behind others gets its full budget on a slow device', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
viewer.scene.clampToHeight = (position) => position;
const traces = [workTrace(), workTrace(), workTrace()];
const completions = [0, 0.01, 0.02].map((offset, index) =>
renderer.add(buildingAnnotation(`queued-${index}`, offset), {
pendingVisible: false,
workTrace: traces[index],
}));
// Eight probes per building at one probe per frame; 1.5 s passes while
// each of the first two buildings samples, so the third waits 3 s in line.
for (let building = 0; building < 3; building += 1) {
for (let frame = 0; frame < 8; frame += 1) frames.emit();
if (building < 2) t.mock.timers.tick(1500);
}
const results = await Promise.all(completions);
assert.deepEqual(results.map((result) => result.status), ['installed', 'installed', 'installed']);
assert.deepEqual(traces.map((trace) => trace.samplerStatus), ['installed', 'installed', 'installed']);
assert.ok(traces[2].samplerHeadAt >= traces[1].samplerHeadAt);
assert.ok(traces[2].samplerHeadAt >= traces[2].samplerStart);
});
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -745,10 +774,16 @@ test('building cancellation, queued deadline and destroy prevent saved callbacks
frames.emit();
assert.equal(probeCount, 1);
const savedTimeout = frames.snapshot();
// No further renderer frames arrive: both enqueue deadlines must still fire,
// including the tail task that has never had a chance to sample.
// No further renderer frames arrive: the head's deadline fires, then the
// tail gets its own budget from the moment it reaches the head and still
// times out without frames, even though it never had a chance to sample.
const rendersBefore = viewer.scene.renderRequests;
t.mock.timers.tick(2000);
assert.equal((await headCompletion).status, 'timeout');
assert.ok(viewer.scene.renderRequests > rendersBefore, 'a promoted tail asks for a frame');
assert.equal(tailTrace.samplerStatus, undefined, 'queue wait does not spend the tail budget');
assert.ok(Number.isFinite(tailTrace.samplerHeadAt), 'the tail starts its own budget at the head');
t.mock.timers.tick(2000);
const tailResult = await tailCompletion;
assert.equal(tailResult.status, 'timeout');
assert.equal(tailResult.timing.probes, 0);
+47 -3
View File
@@ -13,6 +13,7 @@ import {
buildingFromTiles,
createOpenFreeMapOutlineSource,
enclosingAreaFromTiles,
groundsClassesFor,
streetFromTiles,
} from '../sources/openFreeMapOutlines.js';
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
@@ -80,6 +81,46 @@ export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
return null;
}
/**
* Whether polygons contain a point. A part whose outer ring crosses the
* antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own
* longitude frame, with the point moved into that frame; every other part,
* including one genuinely wider than 180°, is tested as stored. Crossing is
* read from an edge jumping more than 180°, which assumes densely noded rings
* (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or
* a non-crossing ring with a single edge over 180°, is not supported.
*/
export function containsAnchor(polygons, lat, lon) {
if (!Number.isFinite(lon)) return false;
for (const part of polygons || []) {
const outer = part?.[0];
if (!Array.isArray(outer) || !outer.length) continue;
const reference = outer[0][0];
const shift = (x) => x - 360 * Math.round((x - reference) / 360);
const span = (ring, f) => {
let west = Infinity;
let east = -Infinity;
for (const [x] of ring) {
west = Math.min(west, f(x));
east = Math.max(east, f(x));
}
return east - west;
};
// An edge jumping more than 180° wraps the line; a polar ring whose
// edges run along ±180 collapses when reframed and is read as stored.
const jumps = outer.some(
([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180,
);
const framedSpan = jumps ? span(outer, shift) : 0;
const crosses = framedSpan > 0 && framedSpan < 180;
const framed = crosses
? part.map((ring) => ring.map(([x, y]) => [shift(x), y]))
: part;
if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true;
}
return false;
}
/**
* Parts with the one holding the anchor outside its holes first. A boundary
* can be largest at sea (Tokyo's island municipalities), so size alone does
@@ -90,7 +131,7 @@ export function anchorPartFirst(polygons, points) {
for (const { lat, lon } of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const at = polygons.findIndex((polygon) =>
polygonsContain([polygon], lat, lon),
containsAnchor([polygon], lat, lon),
);
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
if (at === 0) return polygons;
@@ -188,9 +229,12 @@ export function createOutlineRungs({
lat: at.lat,
lon: at.lon,
name: outlineQueryText(ctx.target || ctx.matchName),
// The kind the ask names ("… School grounds") lets unnamed tile
// landuse of that class answer; read before grounds words are cut.
unnamedClasses: groundsClassesFor(ctx.target || ctx.matchName),
minAreaM2: GROUNDS_MIN_AREA_M2,
});
if (!area || !polygonsContain(area.polygons, ctx.lat, ctx.lon)) return null;
if (!area || !containsAnchor(area.polygons, ctx.lat, ctx.lon)) return null;
return {
ring: area.ring,
polygons: area.polygons,
@@ -215,7 +259,7 @@ export function createOutlineRungs({
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
// Named outlines must retain the resolved place's authority,
// including holes; a nearby same-name polygon is not the selected place.
if (!polygonsContain(outline.polygons, ctx.lat, ctx.lon)) return null;
if (!containsAnchor(outline.polygons, ctx.lat, ctx.lon)) return null;
const polygons = anchorPartFirst(outline.polygons, [
{ lat: ctx.lat, lon: ctx.lon },
outline.center || {},
+100
View File
@@ -11,6 +11,7 @@ import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import {
containsAnchor,
createOutlineRungs,
isNamedAsk,
nominatimKindFor,
@@ -324,6 +325,71 @@ test('a city with no configured Overpass is outlined by Nominatim', async () =>
assert.deepEqual(credits, ['osm']);
});
test('a Nominatim outline across the antimeridian still holds its anchor', async () => {
const ring = [
[179.9, -16.9],
[-179.9, -16.9],
[-179.9, -16.7],
[179.9, -16.7],
[179.9, -16.9],
];
const hole = [
[179.99, -16.81],
[-179.99, -16.81],
[-179.99, -16.79],
[179.99, -16.79],
[179.99, -16.81],
];
assert.ok(containsAnchor([[ring]], -16.8, 179.95));
assert.ok(
containsAnchor([[ring]], -16.8, -179.95),
'either side of the line',
);
assert.ok(!containsAnchor([[ring, hole]], -16.8, 180), 'holes still exclude');
assert.ok(!containsAnchor([[ring]], -16.8, 179.5), 'outside stays outside');
assert.ok(
!containsAnchor([[ring]], -16.8, 0),
'far side of the globe is outside',
);
const steps = [-170, -130, -90, -50, -10, 20];
const wide = [
...steps.map((x) => [x, 10]),
...steps.toReversed().map((x) => [x, 20]),
[-170, 10],
];
assert.ok(
containsAnchor([[wide]], 15, 5),
'a ring wider than 180° is read as stored',
);
const polar = [
[-180, -60],
[180, -60],
[180, -90],
[-180, -90],
[-180, -60],
];
assert.ok(
containsAnchor([[polar]], -80, 30),
'a polar ring spanning every longitude',
);
const nominatim = fakeNominatim({ polygons: [[ring]], class: 'boundary' });
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({
scope: 'city',
target: 'Dateline Town',
matchName: 'Dateline Town',
lat: -16.8,
lon: 179.95,
}),
ladder(UNAVAILABLE),
);
assert.deepEqual(result.ring, ring, 'the outline is accepted, not refused');
assert.equal(result.outlineCredit, 'osm');
});
test('an empty Nominatim polygon list falls back without throwing', async () => {
const nominatim = fakeNominatim({ polygons: [], class: 'boundary' });
const result = await createOutlineRungs({
@@ -399,6 +465,40 @@ test('named outlines must contain the resolved anchor outside holes', async () =
);
});
test('an unnamed school landuse answers a school grounds ask from tiles', async () => {
const schoolRing = square(CAPITOL.lat, CAPITOL.lon, 0.002);
const tiles = {
fetchBounds: async () => ({
tiles: [
{
areas: [
{
name: null,
rings: [schoolRing],
class: 'school',
clipped: false,
},
],
},
],
}),
};
const ask = (target) =>
createOutlineRungs({ tiles, nominatim: fakeNominatim(null) }).resolve(
ctx({ scope: 'building', target, groundsLike: true }),
ladder(UNAVAILABLE),
);
const school = await ask('Lincoln High School grounds');
assert.equal(school.footprintKind, 'area');
assert.deepEqual(school.polygons, [[schoolRing]]);
assert.equal(school.outlineCredit, 'tiles');
assert.equal(
await ask('Texas Capitol grounds'),
UNAVAILABLE,
'other asks still need a name',
);
});
test('a multi-part city leads with the part holding the anchor, not the largest', async () => {
const sea = square(33.5, 139.5, 0.8); // larger, far from the anchor
const outer = square(CAPITOL.lat, CAPITOL.lon, 0.4);
+3 -2
View File
@@ -1040,11 +1040,12 @@ export function createAnnotationResolver({
// Outside a district ask, an alternate name unrelated to the name is too
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
// Whole words: an alias hit on "York" does not keep "Yorkville".
if (
!district &&
hood?.byAlias &&
!normalizePlaceName(hood.name).includes(
normalizePlaceName(stripQualifiers(query)),
!` ${normalizePlaceName(hood.name)} `.includes(
` ${normalizePlaceName(stripQualifiers(query))} `,
)
)
hood = null;
+27 -6
View File
@@ -60,9 +60,12 @@ export function createWorldAnnotationRenderer(viewer) {
function sampleBuilding(anno, options) {
pendingGround.get(anno)?.finish('cancelled');
return new Promise((resolve, reject) => {
const started = performance.now();
const queuedAt = performance.now();
// The 2 s budget starts when this task reaches the head of the queue, so
// waiting behind other buildings on a slow device never spends it.
let started = null;
const workTrace = options.workTrace;
if (workTrace) workTrace.samplerStart = started;
if (workTrace) workTrace.samplerStart = queuedAt;
let groundDetailSlots = 0;
let groundDetailReady = true;
if (workTrace) {
@@ -83,24 +86,42 @@ export function createWorldAnnotationRenderer(viewer) {
maxSliceMs: 0,
lastSliceMs: 0,
probes: 0,
deadlineMs: started + 2000,
queuedMs: 0,
deadlineMs: null,
};
task.current = () =>
!destroyed &&
!options.signal?.aborted &&
pendingGround.get(anno) === task &&
(options.isCurrent?.() ?? true);
task.begin = () => {
if (started !== null) return;
started = performance.now();
timing.queuedMs = started - queuedAt;
timing.deadlineMs = started + 2000;
if (workTrace) workTrace.samplerHeadAt = started;
task.timer = setTimeout(() => task.finish('timeout'), 2000);
// A task promoted outside a frame (its predecessor timed out or was
// cancelled) still needs frames in request-render mode.
try {
viewer.scene.requestRender?.();
} catch {
/* the enqueue path reports render failures */
}
};
task.finish = (status, error) => {
if (pendingGround.get(anno) !== task) return;
pendingGround.delete(anno);
groundQueue.splice(groundQueue.indexOf(task), 1);
const position = groundQueue.indexOf(task);
groundQueue.splice(position, 1);
clearTimeout(task.timer);
if (position === 0) groundQueue[0]?.begin();
options.signal?.removeEventListener('abort', task.abort);
if (!groundQueue.length && observingGround) {
viewer.scene.postRender.removeEventListener(onGroundFrame);
observingGround = false;
}
timing.elapsedMs = performance.now() - started;
timing.elapsedMs = performance.now() - (started ?? queuedAt);
if (workTrace) {
workTrace.probes = timing.probes;
workTrace.samplerStatus = status;
@@ -230,7 +251,7 @@ export function createWorldAnnotationRenderer(viewer) {
};
pendingGround.set(anno, task);
groundQueue.push(task);
task.timer = setTimeout(() => task.finish('timeout'), 2000);
if (groundQueue[0] === task) task.begin();
options.signal?.addEventListener('abort', task.abort, { once: true });
if (!task.current()) return task.finish('cancelled');
if (!observingGround) {
@@ -1 +1 @@
{"tile":"213","features":[[85798825,"Ajuda","PT","neighbourhood",[-9.20765,38.70695,-9.19281,38.71981],["Ажуда","אג'ודה","أجودا","აჟუდა","アジュダ"],"quattroshapes",[["f_cw@{{hkF?}Pha@??lLrZ?bAk@nJvMoBjLqS??lLeP??vYia@?eDsEv@wEw@wE_KcD?y\\"]],[-9.20009,38.71445]],[85798831,"Alcantara","PT","neighbourhood",[-9.18457,38.68684,-9.16809,38.71552],["Alcántara","Alcântara","Алкантара","Алькантара","אלקנטרה","আলকান্টারা","アルカンタラ","알칸타라"],"quattroshapes",[["hm~v@kfhkFpGsB?_Hja@??|PbPjF?f[qG??vEhI?v@tEoBlC?|GdP??dg@uL?oBlC?nIcPlF?lCsG??tEqG??tEmB?w@rEgDxE}F`DeHaDoL?mImL?qt@dP??yYpG??uEpG??kLqG??wEqG??kLeP??yYvL?lBkC"]],[-9.1777,38.70052]],[85798847,"Alfama","PT","neighbourhood",[-9.14062,38.68623,-9.1129,38.71445],["Αλφάμα","Алфама","الفاما","ალფამა","アルファマ","阿尔法玛","알파마"],"quattroshapes",[["niuv@ilhkFpG??lLrG??tEpG??jLbP??jBrG??kBpG??pDbA\\?pBcA??vOjI?pAc@dC`@@lLbP??xY{E?mBfLyEvG?vCiQ|m@yB^gKd@cPuAePtAeKe@wJqBwIwDoHyF}GuJ}NiFmHwF}GsJ|b@_m@?yYlZ?nBkLw@wEdDeA?mCpG?"]],[-9.1288,38.70052]],[85798859,"Alto do Pina","PT","neighbourhood",[-9.13788,38.72838,-9.12332,38.7498],["Алту-ду-Пина","Алту-ду-Піна","التو دو پینا"],"quattroshapes",[["fcvv@kckkFeP??kLcP??kLeDsEv@wEw@wEdDuE?cg@bP??kLdP??yYha@??dg@dP??bg@eP??xYcP??jLeP?"]],[-9.13101,38.74016]],[85798867,"Bairro Lopes","PT","neighbourhood",[-9.12964,38.71766,-9.11591,38.72838],["B. Lopes"],"quattroshapes",[["lmsv@}ujkFha@??mLnr@??rt@eP??jLia@??kLeP??mLcP?"]],[-9.12277,38.72409]],[85798871,"Madre de Deus","PT","neighbourhood",[-9.12689,38.72623,-9.11041,38.74016],["Bairro da Madre de Deus"],"quattroshapes",[["f|rv@_glkF@_QjGcA|L{PvE?dDtE?jLja@??vb@lBlCtL??jLuL?mBlCsGsBqGrB?~Gia@??mLeP??kLeP??{M"]],[-9.11797,38.73349]],[85798877,"Alvalade","PT","neighbourhood",[-9.1497,38.7498,-9.12415,38.77336],["Bairro de Alvalade","Алваладе"],"quattroshapes",[["nlwv@coskFlIkLzg@?nBjL|E??xYbP??jLfDtEgDtE?jLwHlJ}BhNv@vEeDtEeP??jLeP??jLia@??}P{EyGw|@??qt@jZ?|E{G?uk@"]],[-9.1365,38.76158]],[85798887,"Bairro do Alto da Ajuda","PT","neighbourhood",[-9.19556,38.70052,-9.18182,38.71338],[],"quattroshapes",[["bw`w@sehkF|u@??lLdP??dg@eP??jLeP??jLia@??kLeP??av@~JcD"]],[-9.18869,38.70802]],[85798893,"Boavista","PT","neighbourhood",[-9.21204,38.71766,-9.19445,38.74552],["Bairro da Boa Vista","Bairro da Boavista","Bairro do Alto da Boavista"],"quattroshapes",[["jpcw@usmkF?kLdP??mLtcA??|dCmZ?}EzG?|PkBhCe@tFcAj@a^?mBkLmZ??qfA}EyG|EwY?yY"]],[-9.20352,38.73196]],[85798901,"Baixa","PT","neighbourhood",[-9.14749,38.7107,-9.13513,38.72088],["Baixa Pombalina"],"quattroshapes",[["pewv@ilhkF?aSvL?lBmC?}Gz]?v@wEfI??jBjDnAEdCfC?ZjBzE??tEqG??tEsG??tEmE??pBcAZ?|GqG??jBsG??jByE?DhD}@tCmH?g@u@eD?g@s@sG??mLhC??uE"]],[-9.13993,38.71606]],[85798907,"Beato","PT","neighbourhood",[-9.11316,38.71623,-9.07295,38.73909],["Beato António","Беату"],"quattroshapes",[["bxlv@oklkF~d@cUrBuCba@?nIjLzlB??xYeP??jLcP??jLgv@?kLrGmEdQ_CfEsGrCaJjBqJpOoHvFyIxDuJnBeKf@eKg@wJoByIyDmHwFyFoHwDyIqBuJe@eKd@eKpBwJvDyIxFmHlHyF~Aq@"]],[-9.08534,38.72759]],[85798913,"Belem","PT","neighbourhood",[-9.20984,38.68838,-9.20036,38.70122],["Belemo","Belém","Santa Maria de Belém","Санта-Мария-де-Белен","Санта-Марыя-дэ-Белен","Санта-Марія-де-Белен","Բելեմ","בלן","بلم","サンタマリア・デ・ベレン","贝伦区","산타마리아드벨렝"],"quattroshapes",[["zcew@onekF?cJrGrB?nFfI?v@tEi@jGcAxA?pNv@vEw@tE?|MePhF{XaJkGbAkCwOhCqD?yPhC??aF~Ai@?kBpAiB~Ah@hC{@fCz@hC{@?mC"]],[-9.20517,38.69516]],[85798919,"Benfica","PT","neighbourhood",[-9.20765,38.7441,-9.19556,38.7555],[],"quattroshapes",[["jpcw@qepkFdPiFdPhFbPiF?rb@dDrEw@vEnBjL}EzGcPiF_KbDmIkLuE?gDuEcP??wYbP?"]],[-9.20105,38.75087]],[85798939,"Braço de Prata","PT","neighbourhood",[-9.09943,38.74766,-9.03999,38.78764],["Braco de Prata","Fábrica de Braço de Prata"],"quattroshapes",[["d~gv@_qvkFtJqBdKe@`OfApc@v|AkGtIoHvFyIxDuJpBgKd@}EU}BrNyDxIwFnHoHvFyIvDuJpBeKf@eKg@wJqByIwDmHwFyFoHwDyIqBuJe@eKd@gKpBuJvDyIxFoHlHwFxIwDvJqBdKe@|ET~BuNvDyIvFmHnHwF"],["|`kv@}frkFd@eKpBwJvDyIxFmH`UwFjQvJzSxYja@??jL~J?p@kBzF??jBzK??dApAb@[jBv@tEvL??pt@ka@?eDtEsS?uGtEqE?{SvY}}@?aDqMe@eKp@kLq@mLp@kLq@kLp@kL"]],[-9.05501,38.77377]],[85798949,"Calhariz","PT","neighbourhood",[-9.20188,38.73981,-9.19006,38.74766],["Calharis"],"quattroshapes",[["`nbw@onnkF?kLha@?fDtEtE?lIjLw@vEsS??zMcPjF}E{GmZ??yY"]],[-9.1959,38.74391]],[85798957,"Campanhã","PT","neighbourhood",[-8.5938,41.1415,-8.55261,41.17915],["Campanha","Кампаньян","坎帕尼扬"],"whosonfirst",[["`dls@gubzF|BnCcHuF}AQmb@[m@pAoMH{QrHyBgDaCvBaFyDvGuGsIe@}EoB}BEIoBuEgKgABgAxByDKwBv@Sv@mFo@i@u@~AcAoKlCyF{I[oHwCg@mDp@uEqCqDc@oKyK{CcAeEMg@s@KuFgAcBhB{MyA}CeJS~BaCXoFnGvApQtHfDnEb@pDnl@w_@gAkCRgLz@aHbDqFnIaCpHAzBmAoAeL}PmF_AgAtCuMkJyJtRcNtOwDr@hDbJrCvJkAhPOzFdFrL`G|IdHbDo@~\\z}@|MxPxKtUiOrCE~TxBzCwCl@iB`Dg@hJyLtBfEtLaFtBuB|Line truncated
{"tile":"213","features":[[85798825,"Ajuda","PT","neighbourhood",[-9.20765,38.70695,-9.19281,38.71981],["Ажуда","אג'ודה","أجودا","აჟუდა","アジュダ"],"quattroshapes",[["f_cw@{{hkF?}Pha@??lLrZ?bAk@nJvMoBjLqS??lLeP??vYia@?eDsEv@wEw@wE_KcD?y\\"]],[-9.20009,38.71445]],[85798831,"Alcantara","PT","neighbourhood",[-9.18457,38.68684,-9.16809,38.71552],["Alcántara","Alcântara","Алкантара","Алькантара","אלקנטרה","আলকান্টারা","アルカンタラ","알칸타라"],"quattroshapes",[["hm~v@kfhkFpGsB?_Hja@??|PbPjF?f[qG??vEhI?v@tEoBlC?|GdP??dg@uL?oBlC?nIcPlF?lCsG??tEqG??tEmB?w@rEgDxE}F`DeHaDoL?mImL?qt@dP??yYpG??uEpG??kLqG??wEqG??kLeP??yYvL?lBkC"]],[-9.1777,38.70052]],[85798847,"Alfama","PT","neighbourhood",[-9.14062,38.68623,-9.1129,38.71445],["Αλφάμα","Алфама","الفاما","ალფამა","アルファマ","阿尔法玛","알파마"],"quattroshapes",[["niuv@ilhkFpG??lLrG??tEpG??jLbP??jBrG??kBpG??pDbA\\?pBcA??vOjI?pAc@dC`@@lLbP??xY{E?mBfLyEvG?vCiQ|m@yB^gKd@cPuAePtAeKe@wJqBwIwDoHyF}GuJ}NiFmHwF}GsJ|b@_m@?yYlZ?nBkLw@wEdDeA?mCpG?"]],[-9.1288,38.70052]],[85798859,"Alto do Pina","PT","neighbourhood",[-9.13788,38.72838,-9.12332,38.7498],["Алту-ду-Пина","Алту-ду-Піна","التو دو پینا"],"quattroshapes",[["fcvv@kckkFeP??kLcP??kLeDsEv@wEw@wEdDuE?cg@bP??kLdP??yYha@??dg@dP??bg@eP??xYcP??jLeP?"]],[-9.13101,38.74016]],[85798867,"Bairro Lopes","PT","neighbourhood",[-9.12964,38.71766,-9.11591,38.72838],["B. Lopes"],"quattroshapes",[["lmsv@}ujkFha@??mLnr@??rt@eP??jLia@??kLeP??mLcP?"]],[-9.12277,38.72409]],[85798871,"Madre de Deus","PT","neighbourhood",[-9.12689,38.72623,-9.11041,38.74016],["Bairro da Madre de Deus"],"quattroshapes",[["f|rv@_glkF@_QjGcA|L{PvE?dDtE?jLja@??vb@lBlCtL??jLuL?mBlCsGsBqGrB?~Gia@??mLeP??kLeP??{M"]],[-9.11797,38.73349]],[85798877,"Alvalade","PT","neighbourhood",[-9.1497,38.7498,-9.12415,38.77336],["Bairro de Alvalade","Алваладе"],"quattroshapes",[["nlwv@coskFlIkLzg@?nBjL|E??xYbP??jLfDtEgDtE?jLwHlJ}BhNv@vEeDtEeP??jLeP??jLia@??}P{EyGw|@??qt@jZ?|E{G?uk@"]],[-9.1365,38.76158]],[85798887,"Bairro do Alto da Ajuda","PT","neighbourhood",[-9.19556,38.70052,-9.18182,38.71338],[],"quattroshapes",[["bw`w@sehkF|u@??lLdP??dg@eP??jLeP??jLia@??kLeP??av@~JcD"]],[-9.18869,38.70802]],[85798893,"Boavista","PT","neighbourhood",[-9.21204,38.71766,-9.19445,38.74552],["Bairro da Boa Vista","Bairro da Boavista","Bairro do Alto da Boavista"],"quattroshapes",[["jpcw@usmkF?kLdP??mLtcA??|dCmZ?}EzG?|PkBhCe@tFcAj@a^?mBkLmZ??qfA}EyG|EwY?yY"]],[-9.20352,38.73196]],[85798901,"Baixa","PT","neighbourhood",[-9.14749,38.7107,-9.13513,38.72088],["Baixa Pombalina"],"quattroshapes",[["pewv@ilhkF?aSvL?lBmC?}Gz]?v@wEfI??jBjDnAEdCfC?ZjBzE??tEqG??tEsG??tEmE??pBcAZ?|GqG??jBsG??jByE?DhD}@tCmH?g@u@eD?g@s@sG??mLhC??uE"]],[-9.13993,38.71606]],[85798907,"Beato","PT","neighbourhood",[-9.11316,38.71623,-9.07295,38.73909],["Beato António","Беату"],"quattroshapes",[["bxlv@oklkF~d@cUrBuCba@?nIjLzlB??xYeP??jLcP??jLgv@?kLrGmEdQ_CfEsGrCaJjBqJpOoHvFyIxDuJnBeKf@eKg@wJoByIyDmHwFyFoHwDyIqBuJe@eKd@eKpBwJvDyIxFmHlHyF~Aq@"]],[-9.08534,38.72759]],[85798913,"Belem","PT","neighbourhood",[-9.20984,38.68838,-9.20036,38.70122],["Belemo","Belém","Santa Maria de Belém","Санта-Мария-де-Белен","Санта-Марыя-дэ-Белен","Санта-Марія-де-Белен","Բելեմ","בלן","بلم","サンタマリア・デ・ベレン","贝伦区","산타마리아드벨렝"],"quattroshapes",[["zcew@onekF?cJrGrB?nFfI?v@tEi@jGcAxA?pNv@vEw@tE?|MePhF{XaJkGbAkCwOhCqD?yPhC??aF~Ai@?kBpAiB~Ah@hC{@fCz@hC{@?mC"]],[-9.20517,38.69516]],[85798919,"Benfica","PT","neighbourhood",[-9.20765,38.7441,-9.19556,38.7555],[],"quattroshapes",[["jpcw@qepkFdPiFdPhFbPiF?rb@dDrEw@vEnBjL}EzGcPiF_KbDmIkLuE?gDuEcP??wYbP?"]],[-9.20105,38.75087]],[85798939,"Braço de Prata","PT","neighbourhood",[-9.09943,38.74766,-9.03999,38.78764],["Braco de Prata","Fábrica de Braço de Prata"],"quattroshapes",[["d~gv@_qvkFtJqBdKe@`OfApc@v|AkGtIoHvFyIxDuJpBgKd@}EU}BrNyDxIwFnHoHvFyIvDuJpBeKf@eKg@wJqByIwDmHwFyFoHwDyIqBuJe@eKd@gKpBuJvDyIxFoHlHwFxIwDvJqBdKe@|ET~BuNvDyIvFmHnHwF"],["|`kv@}frkFd@eKpBwJvDyIxFmH`UwFjQvJzSxYja@??jL~J?p@kBzF??jBzK??dApAb@[jBv@tEvL??pt@ka@?eDtEsS?uGtEqE?{SvY}}@?aDqMe@eKp@kLq@mLp@kLq@kLp@kL"]],[-9.05501,38.77377]],[85798949,"Calhariz","PT","neighbourhood",[-9.20188,38.73981,-9.19006,38.74766],["Calharis"],"quattroshapes",[["`nbw@onnkF?kLha@?fDtEtE?lIjLw@vEsS??zMcPjF}E{GmZ??yY"]],[-9.1959,38.74391]],[85798957,"Campanhã","PT","neighbourhood",[-8.5938,41.1415,-8.55261,41.17915],["Campanha","Кампаньян","坎帕尼扬"],"whosonfirst",[["`dls@gubzF|BnCcHuF}AQmb@[m@pAoMH{QrHyBgDaCvBaFyDvGuGsIe@}EoB}BEIoBuEgKgABgAxByDKwBv@Sv@mFo@i@u@~AcAoKlCyF{I[oHwCg@mDp@uEqCqDc@oKyK{CcAeEMg@s@KuFgAcBhB{MyA}CeJS~BaCXoFnGvApQtHfDnEb@pDnl@w_@gAkCRgLz@aHbDqFnIaCpHAzBmAoAeL}PmF_AgAtCuMkJyJtRcNtOwDr@hDbJrCvJkAhPOzFdFrL`G|IdHbDo@~\\z}@|MxPxKtUiOrCE~TxBzCwCl@iB`Dg@hJyLtBfEtLaFtBuB|Line truncated
@@ -0,0 +1 @@
{"tile":"230010","features":[[85784689,"Carp","CA","neighbourhood",[-76.234,45.27179,-75.92221,45.38403],["Карп"],"quattroshapes",[["f{moMi_}sGdqA{`A|zCp}AtqAk_AjvC`}AxjBb_Al{@ze@tFnHkx@tg@nbGnqCa~@lo@cz@|n@}SmKowBd}Aw~A`oAwcB{~@iBIoBjAeB?y{@ud@yKmGgCqIijAom@yL}J}{Aqy@mdBqu@ibJbbAcR~DwJ`E_pAr}@ieAld@mKfG{{BjbBoP`HsN~Ce}@jGuXs@kNyBoL}DszCw`Bhm@}c@{hA_m@tB{Ao@_@i@^yDsBxLwIuo@}\\al@pPkWaN|ZBpHe@tv@uTh\\aOhYwPjOoNjFuHlCaPUq^iEcN_C{Bo_Awg@lqAw_Ar]`R~VcOfj@`Vbh@k\\mj@id@`b@eYrInEl]yNzd@xVbrAe{@n]zOnIAdt@~c@{[rSvkAbt@l`BwiAlOlJlLdD`d@vSbT{OrGeCjHa@lo@h@rP{EvNiGva@uJti@gTfM}@fUoHx[mNvSiSbFuArQg@"]],[-76.08177,45.32803]],[85784827,"Orléans","CA","neighbourhood",[-75.55983,45.41982,-75.44106,45.51061],["Orleans","Орлеан","אורלינס","اورلئانز","奧爾良"],"quattroshapes",[["nh~kMucttGmRVeEy@sCLaBw@mChAkFgAmGHjFq@o@q@eDX|Bg@fJMnZpAlWpCpBnAnAOz[jI|LrHvLfDzd@pQh_@dJ|QnHjh@hOgy@rkBlI`Bap@dwAcOrb@uVoH}w@~yBuf@cBg^JarErSpfAu|B}r@{MvAyCas@sMmHlOgeB{^oPiUvIuTemAsUfi@ijAwq@uLgZwDqCoFhHsEl]{EdDmDj^oItDC`OoEpT{CvEqEtPkKzNq@jFqDbA`@nIB~BmUs@mKxE{E{CiAx@iAlHoBoCeF~JiB|OmYeOoZnFaIhMlDlBhDfJtBtLtSbJbEfAOjDsF`@f@sBpFtEXn@v@pOdBxKoDgCsCyESwA_AuLyB{IHwI{B}E{DmE_IzA_@wEsCkH}Jfc@bY~D`BlDR~GfEhJ~AzMpGu@dInA~DfDvDvBj@lGw@N|ArED`AhA^wAnAKz@^aAhAz@l@`Ew@pDjAlCw@zCdCvC?bChAiAjChC|AlHBvHsBhDpAlGh@b^QKi@kF}AqGIb@kAhYzFjOjAqAPsNwAWZhRlE`IDfFyA}@e@oLg@jVd@hQhIbElDzPdGjFuDvDAzBiArHcAnBsBYo@yU}EiLBjN_AeAe@"],["jzvkMmzvtGgJuLg_@sJsFqCjFj@hYbIrQbHfJxBXkCtHpGha@fSzC`GnHx@pDjGnU~FtBbFpCv@hAeAwDqA`@k@|L|EgCpA~AN`BeAZvAj@q@xh@hMeGZmLiA|Ax@q@Xeu@aOsFmBjL`B}o@}P{MuI~BGbHtCz@Y]m@cGmCsK_@sA{@}@qHy@w@{@h@GbEgBnAkJt@aPMqVkJ}NoJPu@fBZxLtI~SjGXu@yD}AOq@|BMrAgBbDsAyEyAFu@jIXzNtDhBJ~A{@w@_BmD`@"],["t~wkMeuutGaC]C_BqHkDP}@|El@tG|Ei@|@jQjGc@r@rMlBFa@aCq@bBJtChCgKw@_a@gIuN}EgBuCf@S|HdCpAdAGjAnMlC"],["bhzkM}qttGgk@yHu^wKbtAjW|I`CInAg@\\"],["rqukMydwtG`DeDrCVbA~B{A`BiGCiE}@"],["t_|kMalttGoPgDVe@nLdBhICg@w@h@CnBr@{HvDyCWbDK"],["nwvkMibvtGnA`AeGc@q@e@"],["t`wkM_avtGKp@u@QF{@"]],[-75.50233,45.46587]],[85784831,"Outremont","CA","neighbourhood",[-73.62699,45.50406,-73.59021,45.52778],["Утремон","Утрмон","Օտրեմոն","מונטריאול/אוטרמונט","أترمون","ウトルモン","欧特蒙地区"],"quattroshapes",[["phd`MqqxtGwE}E|gDcjAfl@lr@tNrSaAgA_x@nXtFhGyJlDhEdFqJhDdQ~RmCpA{_@ic@ih@~Qq@|B_OfFwEcFgR~G{Bk@yRvH}XaX`PwIgCsCfSwEf@{G|Aa@fHP{E_FsC}@q_@L"]],[-73.60803,45.51601]],[85784837,"Pointe-aux-Trembles","CA","neighbourhood",[-73.54462,45.63287,-73.4762,45.7038],[],"quattroshapes",[["l|v_MmwtuGlgCtaAwoCbaA{wD~\\_DuDlBwCkFkEaBwFgKoKyC?j@e@c@mCbDuINaRaEsQA_HaDy@pAmCvByRy@sAnC}ApCqJXsHxCgBZeBYk@mAFg@gBvBE|BwBOoCxEiOo@E`BmBUwCtAwBX{D}AyCAsc@w@}JmAuBFcGaC}JDmOmBaQsFmPuHiIsG_Bp@iAoNsLmIwJ_EkJ}CkPqEqH_@wFmBs@mBgDBwAaAr@iD}FpA?tAtC_@yBhB@~FzAvEB~KjCtGFpH|AbDE`FlAjGJ|k@nOjDJpYjFjFFrBu@LyBdC_EzBRxCjBnAdCi@}B}DaCiDm@gAoAj@a@`Mk@fXnBd@lQpD|Orx@`~Bj@pMmGdm@v@pP"],["f}p_Msg}uGWyD`CiAlCFpLvDjTbB~@dBjC`AyAbBlAlFuJbAyCm@cDyAm@cEgApA_HoEcIoC"],["|}r_Msp|uGmBtHoAl@eM_A`IuArB_HtCC"],["tro_Mq`ruGyEkEqBwJa@kGrAoBz@pL`F|L"],["dvo_MezquG_C{A`@gAhB_@`AlC"],["|po_MwzquGY_A`ADjCfD"],["fno_My|quG{@@Ho@Y}@"]],[-73.50516,45.66877]],[85784995,"Richmond","CA","neighbourhood",[-75.97996,45.15017,-75.72446,45.29069],["Ρίτσμοντ","Ричмонд","Річмонд","ריצ'מונד","رچمنڈ","ریچموند","रिचमंड","রিচমন্ড","リッチモンド","리치먼드"],"quattroshapes",[["bzdnMeblsG`b@GbHkAxPgM|LwEfaAhi@spAzbAbhAtl@uS|O|P|Ib@]~Ax@tNwMhOfIkbAxv@kFqCwBbBvAr@cXtS`hAtk@|OyL[`XjIA`[wF|JqCtBwArBfArqCawBvwBbjAvKtBnSYxDtN`JjMfMpJz]`Se}EdnDgfAbz@qK~FohC|rBc@tBwHbGsv@rl@iEhBqbAfw@kvArbAoJvCeLRcF}GmBc@qHa@{D}AyIEg@fAiA_AcHq@BcFuAu@}LGoK|CaG}@mC~BgFcC{EQqIjDmU?}D}Bj@cB[yG}Jw@hA`ByGwAiHOiC]wDcCGmMq@eAUt@w@C]iErIqHPwAkC_IkECsCXt@fAi@ZsIb@w@]uAl@cAbDaDvBwE@qDeAsFj@s|@tn@tAdAaRlQxExDwA~B{ChA}kB}`AsCyCaFiAwc@eUqTmDwyBgp@oE@uaCyt@oKyEy}Bqr@sFMwBsKrtBwnC`tA}iCi@KpHoJlNgJlJqDzOiCtt@aF`U{D|M_GxKsIdy@{_B~uAmdB`CsIVyJaBuJ}DwHtvBtn@bEaKneAv[{A`D|fBlh@vt@pGjIo@zIoC~i@}_@|FkB`SqClG}B"]],[-75.85788,45.22831]],[85785125,"Anjou","CA","neighbourhood",[-73.60308,45.5903,-73.53589,45.6342],["Anchú","Andegawenia","Angeow","Angiò","Anjau","Anjev","Anju","Anĵuo","Anžu","Ducado de Anjou","Ανζού","Анжу","אנז'ו","آنژو","أنجو","اونژو","อ็องฌู","ანჟუ","アンジュー","安茹","앙주"],"quattroshapes",[["vry_MotiuGViBk@FbCmKzBe@m@qApXsIsCuDrCaA_BeGdY_GkD{GbJkBeE_HsFoDue@hFkCoKdAe@IoB{HkUziEmvAnhDzqB|QzI`WhHgChCxGtCeaDpeA~@~AmIn@ge@|No@oBa_Cxi@_NqVtIwB_DsDmHrB}KgRbAWz@qF`A_@cA}EhBmAs@oDcXvFiE_F`SyGwCiE"]],[-73.57365,45.61239]],[85785137,"Greely","CA","neighbourhood",[-75.69564,45.18762,-75.51753,45.31002],["格里利"],"quattroshapes",[["xwflMcujsGdmBo|Df|@|WyDvIpy@zVtEs@v_@l@wN~YjcAsW``Bpf@iOx[trAla@~g@nm@h\\rXzP~PzW`SvOhJlmDxgAyJfQqEcAaBjCubEkrAoQiAqlAs^mWrk@sc@f|@{dAm]q{AfsC}|CvsGcDnAqFmGqScImpBym@mQmIm|FklBjpB{hE|AwDc@}BpEcErLoUzE_Fg@oAbgBcpD"]],[-75.57292,45.24081]],[85864423,"Fabreville","CA","Line truncated
@@ -1 +0,0 @@
{"tile":"2300100","features":[[85784689,"Carp","CA","neighbourhood",[-76.234,45.27179,-75.92221,45.38403],["Карп"],"quattroshapes",[["f{moMi_}sGdqA{`A|zCp}AtqAk_AjvC`}AxjBb_Al{@ze@tFnHkx@tg@nbGnqCa~@lo@cz@|n@}SmKowBd}Aw~A`oAwcB{~@iBIoBjAeB?y{@ud@yKmGgCqIijAom@yL}J}{Aqy@mdBqu@ibJbbAcR~DwJ`E_pAr}@ieAld@mKfG{{BjbBoP`HsN~Ce}@jGuXs@kNyBoL}DszCw`Bhm@}c@{hA_m@tB{Ao@_@i@^yDsBxLwIuo@}\\al@pPkWaN|ZBpHe@tv@uTh\\aOhYwPjOoNjFuHlCaPUq^iEcN_C{Bo_Awg@lqAw_Ar]`R~VcOfj@`Vbh@k\\mj@id@`b@eYrInEl]yNzd@xVbrAe{@n]zOnIAdt@~c@{[rSvkAbt@l`BwiAlOlJlLdD`d@vSbT{OrGeCjHa@lo@h@rP{EvNiGva@uJti@gTfM}@fUoHx[mNvSiSbFuArQg@"]],[-76.08177,45.32803]],[85864481,"Corkery","CA","neighbourhood",[-76.1765,45.17292,-75.96985,45.3213],["Huntley"],"quattroshapes",[["fkynMgzisGx{BkbBlKgGheAmd@~oAs}@vJaEbR_EhbJcbAldBpu@|{Apy@xL|JhjAnm@fCpIxKlGx{@td@dB?nBkAhBHvcBz~@oeEfhDkbEj{C_w@jn@qoAl}@{tFxhEwDkByJgBcDwA{FeK_BeH{K}pBeH_UsLmOoJaIiyCm_Be_B_t@y]sQm[oM}bBcaAaJwImIwPcBuMP{JrWekBw@oMzNt@jPWvi@iDfOmBfJ}B"]],[-76.05726,45.24658]],[85864667,"Constance Bay","CA","neighbourhood",[-76.11378,45.4716,-76.06675,45.51143],[],"quattroshapes",[["nfioM_zstGfHqRnA{N_@_OoB}GiE{FlHwAlJEfGuBhToDbH{GfFyLhSqCxDuBvEwFxMoDfDsEzPp@fMhC~e@bDr[bH~GLxGnBhH`H_@zAe\\pRcW`R}VrLcDfFuB`@eQxJ_@`D_GaAgCpEmDdCcAxCcEh@kBdAeAjH}CsDoRmJqbApp@zp@b_@m}@fk@qr@ub@xCg@pGyDbN_c@pAeS"]],[-76.081,45.49142]],[85864673,"Dunrobin","CA","neighbourhood",[-76.11528,45.34631,-75.91448,45.49113],["Данробин"],"quattroshapes",[["nedoM{ystGhFbD~Ca@`CuB_BmCv@MlAjAA|AuB~CdDn@@f@gBhDsInCg@bDzD~CdB\\~BQnC{DfEhBEf@{BP}B~CjGvGeGhTdB|BnP`Hxf@xLzBSvAiBnB\\vDsBjF`BbEyApM|FkHxDu@lDmIrIS|AfLkIvC_GfE}@RqDbf@zYbd@r[sc@d[puAns@`kCh`BrWvHegAnv@iRhHy`B`lAcUxM}cCtaBenAou@enC`jBanBloAarAb~@gs@xc@{d@yVm]xNsIoEab@dYlj@hd@ch@j\\gj@aVgW`OytAyt@kRsDwQyGbFqDct@u`@gNtJg[eQjqCoqBo{CmcBOw@mAByHvEmIeCaJc@{CyBfFoDzNsDbCiBhc@ij@vWiWre@{o@pKmKAiAvAM~OaQ`MwJdJyOzAMVwGpB{Ev[sYzG{CxHoI`@oCzB{BM_AcDm@}DpCqCKlHqCx@oCcAg@JaBlC}AnCWrBqEdHqFt@gEpDoH|BkBl@mI|AoBhPcO|F{AxBkFrD}DtPkMxC{DhHeAfW}SvIiDfDaDdQwIbCwCzr@e`@jf@wNoFaDf@YpChCnAw@uCqBq@Nx@e@bDxBt@e@qCcCpH`F`AMRyArF}CpL]Q}@lJiElDGnE}BlDe@jFCtQzBdRlFbNiD|Ts@vPwH|JaHl@qBtKt@xH[xReJpDg@bEsDhEm@x@yAvReEpGsCvV{@xBrDtGbAnFsBpF}EnDc@tFcD"]],[-76.01077,45.41797]],[85864683,"Munster","CA","neighbourhood",[-76.05229,45.08001,-75.87256,45.23957],["Манстер"],"quattroshapes",[["p{onMck}rGd}EenDl`Adj@pW`Kx]rQd_B~s@hyCl_BnJ`IrLlOdH~TzK|pB~AdHzFdKbDvAxJfBvDjBomH~yFy}D`{C{p@fj@gFpBkB`DwcAxx@sgA~~@gfD_vAkd@mTsdC_eA{xA}rGjvAsbApbAgw@hEiBj`Awt@b@uBnhC}rBpK_G"]],[-75.957,45.15812]],[1125137919,"Fitzroy Harbour","CA","microhood",[-76.22124,45.45898,-76.20039,45.4779],[],"mz",[["z~dpM{kqtGz]vQkKbJe@jDaEn@gDvB{DfE[xBrBnFPaAhGrBeHtFSpB`AdAnFjAdJ_Bws@xl@kdAaj@tC{J|@{@nHmBtKaKrNiEzIi@bByEvEqDfBaHp\\qJ"]],[-76.21025,45.46849]],[1125223987,"Morgan's Grant","CA","neighbourhood",[-75.95165,45.3401,-75.92488,45.35835],[],"mz",[["zsmnMwoysGjV}OvQxGbPjCtx@`c@mqAx_A{sAyv@"]],[-75.93825,45.34975]],[1125978557,"West Carleton","CA","macrohood",[-76.35392,45.17293,-75.91448,45.51661],[],"mz",[["lnlpM_sgtGuMpDoAi@kB`CjEp@bBlArCvFu@~@_FxAu@zBmEvBa@vAsCbAmAjCh@~CxLjClH~GmAhWeDnNeJlTCpD`D~DLrGcAlGeElJ`AhLmAxDAlEhKhLfKbEtFxDlAnC^~FiCjL?rItJjOfp@th@`TxZlHbRrDnBjYnFnKpGg@dCcI|F{PtGaItEo@bGhDzO}FbPtFvHFrE`GnFdHxC~XvBdKfBijC|gB}qEraDcz@|n@}SmKowBd}AyzGvoFo|ErpDig@ta@qoAl}@grFvfEsA`AwEwByI{AqGkEkEyL}LwtB{CmMyK}QsOgNomBidAqx@eb@mqA}k@y]sQqWaKyiBgfAaGaGwE{H}C}JaAwMjX}rBw@oMkNyBoL}DszCu`Bhm@_d@{hA_m@tB{Ao@_@i@^yDsBxLwIuo@}\\al@pPkWaN|ZBpHe@l~@cXzn@k]tSsS`BqClCaPJaUa@oHiEcN_C{Bo_Aug@lqAy_Aux@ac@cPkCwQyGbFoDct@w`@gNtJg[eQjqCoqBo{CmcBOw@mABwHvEoIeCaJc@{CyBfFoDzNsDbCiBhc@ij@vWiWre@{o@pKmKAiAvAM~OaQ`MwJdJyOzAMVwGpB{Ev[sYzG{CzHoI^oCzB{BM_AcDk@}DnCqCKlHqCx@oCcAg@JaBlC}AnCWrBqEdHqFt@gEpDoH|BkBl@mI|AoBhPcO|F{AxBkFrD}DtPkMxC{DhHeAfW}SvIiDfDaDdQwIbCwCzr@e`@jf@wNoFaDf@YpChCpAw@wCqBq@Nx@e@bDxBt@c@qCeCpH`F`AMRyArF}CpL]Q}@lJiElDGnE}BlDe@jFCtQzBdRlFbNgD|Tu@vPwH|JaHl@oBtKr@xH[xReJpDg@dEsDfEm@x@yAvReEpGsCvV{@xBrDtGbAnFsBpF}EnDc@vFcDdLe@rATpCnCbDc@`CuBcAaDhBpAA|AuB~CdDn@@f@gBhDuGjBoBzAp@zCxFlC~BQnC{DfEhBEf@{BR{B|ChGvGuDbKaArIfSpJxf@xLzBSvAiBnB\\vDsBjF`BbEyApM|FkHxDu@lDmIrIF|AjKkIvC_GfE}@RqDmKyGxCg@pGyDbN_c@pAeSfNqQdHgRpAeO_@_OoB}G}DeG`HmAlJEfGuBhToDbH{GfFyLhSqCxDuBvEwFxMoDfDsEzPp@fMhC~e@bDdL`DlN`C~GLxGnB|F~El@zBg]rSg[~TyRtIcDfFuB`@eQxJ_@`D_GaAgCpEmDdCcAxCcEh@kBdAeAjHhDbB|CMtC}BcAuAv@i@~HEtFuB`VsQvTiMxLcKx\\wP|MiIhFeF|XsNnIaIdl@yVvUoGvKmEba@eJhFFfOfClKI|OlH`IKzPhD`Kf@r^_@vPsAlS`@n^sDjR~EzCFtGuFfZrFvEQ~KeD}Cg@sNpBg@g@zB{ArO_EbQExF{CtKmBzEwBhF?zZtCbJi@dGaBtHUlLz@fD|ArWGfQjB~J?fTtQnL[`IzAjZdAxl@pWr@v@gAxGzMrJx@zDkSdZnBpFoHrGIzG|A|FdBlAcCB_CxDgDjTf@~HxBrFjBFQnCaJrE{@fC}FzD_Cb@mNMiF|CwCjJoBIoAdAcM`AyFxEVVlHoElIa@xGsAnAqGzFqFxNR~JuCnFnAoCLwBxG{Ad@m@rF{A~BfArAyBCwIzEA~AwAPaAhBr@~AeDhC{F`AeBhAyGtGe@jDaEn@cJ~HUnEbAh@DrA|@XG_AjG`CgHfFSpB~@dApFjAdJ_B}Ai@wEx@}CEuAgAz@yC~J_Bp@VxIq@hJcEjQyDaA[Nu@bOqDpGaF\\RfEaBzF?bFmEvBHjCw@dCV{@oCnE}Az@aDdB`@q@zAtJ[TdAoHZwYb`@iNlKfEl[jZrN|o@`DXmA`FXzDkAnAM`@j@nCy@tB`BpCHn@i@PxAvDnAvDeAoBg@dAMi@oBvIaBAcAkAa@hAeBrA?\\mA`E@`@xBlBo@Line truncated
@@ -1 +0,0 @@
{"tile":"2300101","features":[[85784827,"Orléans","CA","neighbourhood",[-75.55983,45.41982,-75.44106,45.51061],["Orleans","Орлеан","אורלינס","اورلئانز","奧爾良"],"quattroshapes",[["nh~kMucttGmRVeEy@sCLaBw@mChAkFgAmGHjFq@o@q@eDX|Bg@fJMnZpAlWpCpBnAnAOz[jI|LrHvLfDzd@pQh_@dJ|QnHjh@hOgy@rkBlI`Bap@dwAcOrb@uVoH}w@~yBuf@cBg^JarErSpfAu|B}r@{MvAyCas@sMmHlOgeB{^oPiUvIuTemAsUfi@ijAwq@uLgZwDqCoFhHsEl]{EdDmDj^oItDC`OoEpT{CvEqEtPkKzNq@jFqDbA`@nIB~BmUs@mKxE{E{CiAx@iAlHoBoCeF~JiB|OmYeOoZnFaIhMlDlBhDfJtBtLtSbJbEfAOjDsF`@f@sBpFtEXn@v@pOdBxKoDgCsCyESwA_AuLyB{IHwI{B}E{DmE_IzA_@wEsCkH}Jfc@bY~D`BlDR~GfEhJ~AzMpGu@dInA~DfDvDvBj@lGw@N|ArED`AhA^wAnAKz@^aAhAz@l@`Ew@pDjAlCw@zCdCvC?bChAiAjChC|AlHBvHsBhDpAlGh@b^QKi@kF}AqGIb@kAhYzFjOjAqAPsNwAWZhRlE`IDfFyA}@e@oLg@jVd@hQhIbElDzPdGjFuDvDAzBiArHcAnBsBYo@yU}EiLBjN_AeAe@"],["jzvkMmzvtGgJuLg_@sJsFqCjFj@hYbIrQbHfJxBXkCtHpGha@fSzC`GnHx@pDjGnU~FtBbFpCv@hAeAwDqA`@k@|L|EgCpA~AN`BeAZvAj@q@xh@hMeGZmLiA|Ax@q@Xeu@aOsFmBjL`B}o@}P{MuI~BGbHtCz@Y]m@cGmCsK_@sA{@}@qHy@w@{@h@GbEgBnAkJt@aPMqVkJ}NoJPu@fBZxLtI~SjGXu@yD}AOq@|BMrAgBbDsAyEyAFu@jIXzNtDhBJ~A{@w@_BmD`@"],["t~wkMeuutGaC]C_BqHkDP}@|El@tG|Ei@|@jQjGc@r@rMlBFa@aCq@bBJtChCgKw@_a@gIuN}EgBuCf@S|HdCpAdAGjAnMlC"],["bhzkM}qttGgk@yHu^wKbtAjW|I`CInAg@\\"],["rqukMydwtG`DeDrCVbA~B{A`BiGCiE}@"],["t_|kMalttGoPgDVe@nLdBhICg@w@h@CnBr@{HvDyCWbDK"],["nwvkMibvtGnA`AeGc@q@e@"],["t`wkM_avtGKp@u@QF{@"]],[-75.50233,45.46587]],[85784831,"Outremont","CA","neighbourhood",[-73.62699,45.50406,-73.59021,45.52778],["Утремон","Утрмон","Օտրեմոն","מונטריאול/אוטרמונט","أترمون","ウトルモン","欧特蒙地区"],"quattroshapes",[["phd`MqqxtGwE}E|gDcjAfl@lr@tNrSaAgA_x@nXtFhGyJlDhEdFqJhDdQ~RmCpA{_@ic@ih@~Qq@|B_OfFwEcFgR~G{Bk@yRvH}XaX`PwIgCsCfSwEf@{G|Aa@fHP{E_FsC}@q_@L"]],[-73.60803,45.51601]],[85784837,"Pointe-aux-Trembles","CA","neighbourhood",[-73.54462,45.63287,-73.4762,45.7038],[],"quattroshapes",[["l|v_MmwtuGlgCtaAwoCbaA{wD~\\_DuDlBwCkFkEaBwFgKoKyC?j@e@c@mCbDuINaRaEsQA_HaDy@pAmCvByRy@sAnC}ApCqJXsHxCgBZeBYk@mAFg@gBvBE|BwBOoCxEiOo@E`BmBUwCtAwBX{D}AyCAsc@w@}JmAuBFcGaC}JDmOmBaQsFmPuHiIsG_Bp@iAoNsLmIwJ_EkJ}CkPqEqH_@wFmBs@mBgDBwAaAr@iD}FpA?tAtC_@yBhB@~FzAvEB~KjCtGFpH|AbDE`FlAjGJ|k@nOjDJpYjFjFFrBu@LyBdC_EzBRxCjBnAdCi@}B}DaCiDm@gAoAj@a@`Mk@fXnBd@lQpD|Orx@`~Bj@pMmGdm@v@pP"],["f}p_Msg}uGWyD`CiAlCFpLvDjTbB~@dBjC`AyAbBlAlFuJbAyCm@cDyAm@cEgApA_HoEcIoC"],["|}r_Msp|uGmBtHoAl@eM_A`IuArB_HtCC"],["tro_Mq`ruGyEkEqBwJa@kGrAoBz@pL`F|L"],["dvo_MezquG_C{A`@gAhB_@`AlC"],["|po_MwzquGY_A`ADjCfD"],["fno_My|quG{@@Ho@Y}@"]],[-73.50516,45.66877]],[85784995,"Richmond","CA","neighbourhood",[-75.97996,45.15017,-75.72446,45.29069],["Ρίτσμοντ","Ричмонд","Річмонд","ריצ'מונד","رچمنڈ","ریچموند","रिचमंड","রিচমন্ড","リッチモンド","리치먼드"],"quattroshapes",[["bzdnMeblsG`b@GbHkAxPgM|LwEfaAhi@spAzbAbhAtl@uS|O|P|Ib@]~Ax@tNwMhOfIkbAxv@kFqCwBbBvAr@cXtS`hAtk@|OyL[`XjIA`[wF|JqCtBwArBfArqCawBvwBbjAvKtBnSYxDtN`JjMfMpJz]`Se}EdnDgfAbz@qK~FohC|rBc@tBwHbGsv@rl@iEhBqbAfw@kvArbAoJvCeLRcF}GmBc@qHa@{D}AyIEg@fAiA_AcHq@BcFuAu@}LGoK|CaG}@mC~BgFcC{EQqIjDmU?}D}Bj@cB[yG}Jw@hA`ByGwAiHOiC]wDcCGmMq@eAUt@w@C]iErIqHPwAkC_IkECsCXt@fAi@ZsIb@w@]uAl@cAbDaDvBwE@qDeAsFj@s|@tn@tAdAaRlQxExDwA~B{ChA}kB}`AsCyCaFiAwc@eUqTmDwyBgp@oE@uaCyt@oKyEy}Bqr@sFMwBsKrtBwnC`tA}iCi@KpHoJlNgJlJqDzOiCtt@aF`U{D|M_GxKsIdy@{_B~uAmdB`CsIVyJaBuJ}DwHtvBtn@bEaKneAv[{A`D|fBlh@vt@pGjIo@zIoC~i@}_@|FkB`SqClG}B"]],[-75.85788,45.22831]],[85785125,"Anjou","CA","neighbourhood",[-73.60308,45.5903,-73.53589,45.6342],["Anchú","Andegawenia","Angeow","Angiò","Anjau","Anjev","Anju","Anĵuo","Anžu","Ducado de Anjou","Ανζού","Анжу","אנז'ו","آنژو","أنجو","اونژو","อ็องฌู","ანჟუ","アンジュー","安茹","앙주"],"quattroshapes",[["vry_MotiuGViBk@FbCmKzBe@m@qApXsIsCuDrCaA_BeGdY_GkD{GbJkBeE_HsFoDue@hFkCoKdAe@IoB{HkUziEmvAnhDzqB|QzI`WhHgChCxGtCeaDpeA~@~AmIn@ge@|No@oBa_Cxi@_NqVtIwB_DsDmHrB}KgRbAWz@qF`A_@cA}EhBmAs@oDcXvFiE_F`SyGwCiE"]],[-73.57365,45.61239]],[85785137,"Greely","CA","neighbourhood",[-75.69564,45.18762,-75.51753,45.31002],["格里利"],"quattroshapes",[["xwflMcujsGdmBo|Df|@|WyDvIpy@zVtEs@v_@l@wN~YjcAsW``Bpf@iOx[trAla@~g@nm@h\\rXzP~PzW`SvOhJlmDxgAyJfQqEcAaBjCubEkrAoQiAqlAs^mWrk@sc@f|@{dAm]q{AfsC}|CvsGcDnAqFmGqScImpBym@mQmIm|FklBjpB{hE|AwDc@}BpEcErLoUzE_Fg@oAbgBcpD"]],[-75.57292,45.24081]],[85864423,"Fabreville","CA","neighbourhood",[-73.87252,45.54957,-73.769,45.61083],[],"can-lvlsu",[["h`naM}wjuGtDaOxKcAxKAzI|DvXzGzKxEhHDnRsFtDBxCdA`HnGdB`FuBfX~AvLxBpEzOzIbGzFnG`KjAfRqAnMr@jEfClCfNlFhDxCfBxDtA|JfFrGfKjE`q@lH`GbCxOjMjShJb^`HxGpFlE~PlB|C|UzLd`@tL_jAffAkCs@sBlBcKcHeDk@y@t@aCqCeLlKkEeA}FlFgUaIkJtImTgTcL}BkBlBaEyA{KU{F_N~CqCmBs@rBiBiBaA\\cA`FkFaFwAyY|WyMYaRd@i]wCam@b@gFOqD{AeFc@ipA_h@dP{O{BqDn@iF{JwFcGe@_DjDiYiDg`@gT_W}IiTa@aGwBgPJcTyJ}EeK}IkC~HHpQqAtKaCld@yNbEqEhCeGpJoDzBuMfDT`StF`GJnq@_TpIsEvHaKjEuTkCeJ`BcJnGuCzCaObKeO_EsB"]],[-73.80453,45.58019]],[85864453,"Navan","CA","neighbourhood",[-75.49517,45.41Line truncated
@@ -19,7 +19,6 @@ Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licen
| Zolk Chicago Neighborhoods Map (`zolk`) | 207 | CC BY 3.0 US | [terms](http://chicagomap.zolk.com/about.html) |
| Azavea, Inc. (`azavea`) | 157 | CC BY 3.0 US | [terms](https://www.opendataphilly.org/dataset/philadelphia-neighborhoods) |
| City of Calgary, Corporate Analytics and Innovation (`can-calcai`) | 151 | Open Government Licence - City of Calgary | [terms](https://data.calgary.ca/stories/s/u45n-7awa) |
| Portal de datos abiertos del Ayuntamiento de Madrid (`esp-aytomad`) | 150 | CC0 (equivalent) | [terms](http://datos.madrid.es/egob/catalogo/aviso-legal) |
| City of Winnipeg Department of Planning, Property, and Development (`can-wpgppd`) | 132 | Open Government License - Winnipeg | [terms](https://data.winnipeg.ca/open-data-licence) |
| Atlanta Department of Planning and Community Development (`atldpcd`) | 130 | CC BY 4.0 | [terms](https://www.arcgis.com/home/item.html?id=716f417a1990446389ef7fd2c381d09f) |
| Turku City Government (`tkugov`) | 129 | CC BY 4.0 | [terms](http://www.lounaistieto.fi/blog/2015/08/18/turun-palvelualuejakotilastoalueet/) |
@@ -32,7 +31,6 @@ Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licen
| CartoBCN (`esp-cartobcn`) | 83 | CC BY | [terms](http://w133.bcn.cat/geoportal/descargas/en_gb_cond_us_carto.pdf) |
| Toronto Social Development, Finance & Administration Department (`torsdfa`) | 83 | Open Government Licence - Ontario, v1.0 | [terms](http://www1.toronto.ca/wps/portal/contentonly?vgnextoid=4a37e03bb8d1e310VgnVCM10000071d60f89RCRD) |
| Denver Department of Community Planning and Development (`denvercpd`) | 77 | CC BY 3.0 | [terms](https://www.denvergov.org/opendata/termsofuse) |
| Gatineau Service de l'urbanisme et du developpement durable (SUDD) (`can-gatsudd`) | 74 | CC BY (equivalent) | [terms](http://www3.gatineau.ca/Infoterritoire/WebInterface/help/fr/Content/1_About/1_About_FR.htm) |
| City of New Orleans, Office of Information Technology and Innovation, Enterprise Information Team (`nolagis`) | 71 | CC0 | [terms](https://data.nola.gov/Geographic-Base-Layers/Neighborhoods/92zg-wzkq) |
| Ciudad Autonoma de Buenos Aires, Iniciativa de Datos Publicos y Transparencia (`arg-caba`) | 48 | CC BY | [terms](https://data.buenosaires.gob.ar/tyc) |
| Montreal Service de la Mise en Valeur du Territoire (`can-mtlsmvt`) | 48 | CC BY 4.0 | [terms](https://creativecommons.org/licenses/by/4.0/) |
@@ -265,4 +263,4 @@ Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licen
## Source-specific exclusions
This derived pack excludes 36 `can-ons` records, 54 `can-saskodp` records. Original source-specific redistribution terms have not been established for these records in this derived pack. Original upstream notices remain unchanged.
Sources whose pinned registry licence is only an "equivalent" designation (for example "CC BY (equivalent)" or "CC0 (equivalent)"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack. Original upstream notices remain unchanged.
@@ -12,7 +12,7 @@ DataSF pack in `../neighborhoods/` is used first inside San Francisco.
| `files.js` | every file's URL, so bundlers emit them (generated) |
| `whosonfirst-LICENSE.md`, `quattroshapes-LICENSE.md` | upstream notices, unchanged |
61,665 named neighbourhood, macrohood and microhood polygons in 218 tiles.
61,441 named neighbourhood, macrohood and microhood polygons in 217 tiles.
**Source:** [Who's On First](https://whosonfirst.org/) and contributors:
`whosonfirst-data-{neighbourhood,macrohood,microhood}-latest.tar.bz2` from
@@ -46,10 +46,13 @@ a `[lon, lat]` point inside the largest polygon. A ring is an encoded
polyline string: longitude then latitude at 10^-5 degrees, zigzag varint
deltas, 5-bit groups offset by 63.
**Source-specific exclusions:** This derived pack excludes 36 `can-ons` records, 54 `can-saskodp` records. Original source-specific redistribution terms have not been established for these records in this derived pack. Original upstream notices remain unchanged.
The original pinned records are partitioned first; exact source/ID exclusions
are then applied within those tiles, preserving every remaining tile path.
**Licence exclusions:** only sources whose exact registry licence is on the
reviewed allowlist in `scripts/wof-open-licenses.json` are admitted. A licence
listed only as an "equivalent" of an open licence ("CC BY (equivalent)",
"CC0 (equivalent)") is not admitted, so `can-ons`, `can-saskodp`,
`can-gatsudd` and `esp-aytomad` records are not in this pack: their
redistribution terms have not been established for this derived pack.
Original upstream notices remain unchanged.
**Curation:** current, non-deprecated, named polygon records; simplified to
about 15 m, snapped to 5 decimals and kept valid under GEOS (checked on the
@@ -48,8 +48,7 @@ export const TILE_URLS = {
t221031: () => new URL('./221031.json', import.meta.url),
t221033: () => new URL('./221033.json', import.meta.url),
t2211: () => new URL('./2211.json', import.meta.url),
t2300100: () => new URL('./2300100.json', import.meta.url),
t2300101: () => new URL('./2300101.json', import.meta.url),
t230010: () => new URL('./230010.json', import.meta.url),
t230011: () => new URL('./230011.json', import.meta.url),
t23111: () => new URL('./23111.json', import.meta.url),
t231130: () => new URL('./231130.json', import.meta.url),
@@ -19,11 +19,8 @@
"url": "https://raw.githubusercontent.com/whosonfirst/whosonfirst-sources/e17af153c144fe6e08aa8db2607b4bf8aecd448d/data/sources-spec-latest.json",
"sha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39"
},
"recordExclusions": {
"can-ons": 36,
"can-saskodp": 54
},
"recordExclusionReason": "Original source-specific redistribution terms have not been established for these records in this derived pack.",
"recordExclusions": {},
"recordExclusionReason": "Sources whose pinned registry licence is only an \"equivalent\" designation (for example \"CC BY (equivalent)\" or \"CC0 (equivalent)\"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack.",
"license": "Per source; see sources and ATTRIBUTION.md",
"format": "neighborhood-polylines-v1",
"fields": [
@@ -108,12 +105,6 @@
"terms": "https://www.edmonton.ca/city_government/documents/Web-version2.1-OpenDataAgreement.pdf",
"records": 332
},
"can-gatsudd": {
"name": "Gatineau Service de l'urbanisme et du developpement durable (SUDD)",
"license": "CC BY (equivalent)",
"terms": "http://www3.gatineau.ca/Infoterritoire/WebInterface/help/fr/Content/1_About/1_About_FR.htm",
"records": 74
},
"can-lvlsu": {
"name": "Laval Service de l'urbanisme",
"license": "CC BY",
@@ -174,12 +165,6 @@
"terms": "https://www.denvergov.org/opendata/termsofuse",
"records": 77
},
"esp-aytomad": {
"name": "Portal de datos abiertos del Ayuntamiento de Madrid",
"license": "CC0 (equivalent)",
"terms": "http://datos.madrid.es/egob/catalogo/aviso-legal",
"records": 150
},
"esp-cartobcn": {
"name": "CartoBCN",
"license": "CC BY",
@@ -770,7 +755,7 @@
},
{
"key": "213",
"count": 445,
"count": 295,
"bbox": [
-9.8565,
37.09996,
@@ -829,20 +814,10 @@
]
},
{
"key": "2300100",
"count": 16,
"key": "230010",
"count": 375,
"bbox": [
-76.35559,
45.08001,
-75.87256,
45.51661
]
},
{
"key": "2300101",
"count": 433,
"bbox": [
-76.31305,
44.96177,
-73.33863,
45.71368
+4 -23
View File
@@ -42,6 +42,7 @@ import {
rank,
} from './adminBoundaries.js';
import { loadBundledJson } from './bundledJson.js';
import { foldPlaceName } from './placeNames.js';
import { createRetryableLoader } from './retryableLoad.js';
import * as placeFiles from './local_data/us_census_places/files.js';
import * as neighborhoodFiles from './local_data/wof_neighborhoods/files.js';
@@ -63,17 +64,6 @@ export const packLoads = {
neighborhoodTiles: [],
};
const FOLD = {
ß: 'ss',
ø: 'o',
æ: 'ae',
ł: 'l',
đ: 'd',
ı: 'i',
œ: 'oe',
þ: 'th',
};
/**
* A name as spelled: case and punctuation only. Every mark is kept, so
* "की" and "क", "Й" and "И" stay distinct.
@@ -87,20 +77,11 @@ export function exactPlaceName(value) {
}
/**
* Fold a name for matching: case, punctuation, accents on Latin letters
* only, a leading "the", "saint". Other scripts keep their marks
* ("渋谷", "Кунцево", "बांद्रा").
* Fold a name for matching (see `foldPlaceName`), with "saint" and "sainte"
* shortened the way US place names abbreviate them.
*/
export function normalizePlaceName(value) {
return String(value || '')
.toLowerCase()
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
.normalize('NFD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFC')
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim()
.replace(/^the /, '')
return foldPlaceName(value)
.replace(/\bsaint\b/g, 'st')
.replace(/\bsainte\b/g, 'ste');
}
+12 -17
View File
@@ -379,6 +379,7 @@ test('places pack: every state file, unique ids, sane and valid rings', () => {
});
const WOF_LICENSE_POLICY = readJson(WOF_LICENSES);
const WOF_RECORD_COUNT = 61_441;
const ALLOWED_WOF_LICENSES = new Set(WOF_LICENSE_POLICY.licenses);
const licenceAllowed = (value) =>
typeof value === 'string' && ALLOWED_WOF_LICENSES.has(value);
@@ -455,6 +456,8 @@ test('neighborhood pack ships only explicitly allowed and credited sources', ()
'mapzen',
'can-ons',
'can-saskodp',
'can-gatsudd',
'esp-aytomad',
])
assert.equal(sources[excluded], undefined, excluded);
const listed = readFileSync(new URL('files.js', WOF), 'utf8');
@@ -494,23 +497,15 @@ test('neighborhood pack ships only explicitly allowed and credited sources', ()
}
for (const [key, source] of Object.entries(sources))
assert.equal(used[key], source.records, key);
assert.deepEqual(index.meta.recordExclusions, {
'can-ons': 36,
'can-saskodp': 54,
});
const excludedIds = new Set();
for (const rule of Object.values(
WOF_LICENSE_POLICY.recordExclusions.sources,
)) {
assert.equal(rule.ids.length, rule.count);
for (const id of rule.ids) {
assert.ok(!excludedIds.has(id));
excludedIds.add(id);
assert.ok(!ids.has(id), `${id} excluded from shipped rows`);
}
}
assert.equal(excludedIds.size, 90);
assert.equal(ids.size, 61_665);
// A licence listed only as an "equivalent" of an open licence is not one.
for (const [key, source] of Object.entries(sources))
assert.doesNotMatch(source.license, /equivalent/i, key);
assert.ok(
WOF_LICENSE_POLICY.licenses.every((value) => !/equivalent/i.test(value)),
);
assert.deepEqual(index.meta.recordExclusions, {});
assert.deepEqual(WOF_LICENSE_POLICY.recordExclusions.sources, {});
assert.equal(ids.size, WOF_RECORD_COUNT);
});
test('neighborhoods resolve near the view, in any script', async () => {
+34
View File
@@ -0,0 +1,34 @@
/** Latin letters that Unicode decomposition does not fold to plain ASCII. */
const FOLD = {
ß: 'ss',
ø: 'o',
æ: 'ae',
ł: 'l',
đ: 'd',
ı: 'i',
œ: 'oe',
þ: 'th',
};
/**
* Comparable form of a place name, shared by the bundled packs, the
* OpenFreeMap street and grounds matchers and the Nominatim server: case
* folded, accents removed from Latin letters only, punctuation and symbols
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo",
* "sao paulo" and "SÃO PAULO!" meet.
*
* @param {unknown} value
* @returns {string}
*/
export function foldPlaceName(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
.normalize('NFKD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFKC')
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim()
.replace(/^the /, '');
}
+1 -5
View File
@@ -12,11 +12,7 @@ import {
roadDotBudget,
} from './policy.js';
import { msg } from '../../i18n/index.js';
const nextTask = () =>
globalThis.scheduler?.yield
? globalThis.scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
import { nextTask } from '../../sources/vectorTiles.js';
export function createFlow({ state: layerState, services, parts, source }) {
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
+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);
+36 -21
View File
@@ -147,7 +147,7 @@ test('a sweep removes expired and corrupt entries, never ones it could not read'
);
});
test('processes sharing state reserve public spacing and cap atomically', async (t) => {
test('processes sharing state space public sends and cap atomically', async (t) => {
const dir = tempDir(t);
const module = new URL(
'../../server/providers/regional/nominatimGate.js',
@@ -157,12 +157,24 @@ test('processes sharing state reserve public spacing and cap atomically', async
const script = `
const { createGateStateStore } = await import(${JSON.stringify(module)});
const store = createGateStateStore({ file: ${JSON.stringify(file)} });
console.log(JSON.stringify(store.reserve('2026-09-29', {
now: 1000,
dailyCap: ${dailyCap},
minSpacingMs: 1100,
latestStartAt: 10000,
})));
const latestStartAt = Date.now() + 10000;
let sentAt = null;
for (;;) {
const result = store.dispatch(
'2026-09-29',
{ clock: Date.now, dailyCap: ${dailyCap}, minSpacingMs: 300, latestStartAt },
() => {
sentAt = Date.now();
return Promise.resolve();
},
);
if (result.status === 'wait') {
await new Promise((resolve) => setTimeout(resolve, result.waitMs));
continue;
}
console.log(JSON.stringify({ status: result.status, sentAt }));
break;
}
`;
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [
@@ -183,19 +195,22 @@ test('processes sharing state reserve public spacing and cap atomically', async
};
const spacedFile = path.join(dir, 'spaced.json');
const reservations = await Promise.all([
const sends = await Promise.all([
run(spacedFile, 3),
run(spacedFile, 3),
run(spacedFile, 3),
]);
assert.deepEqual(
reservations.map((result) => result.status),
['reserved', 'reserved', 'reserved'],
);
assert.deepEqual(
reservations.map((result) => result.startAt).sort((a, b) => a - b),
[1000, 2100, 3200],
sends.map((result) => result.status),
['started', 'started', 'started'],
);
// Spacing is measured between actual transport invocations.
const sentAt = sends.map((result) => result.sentAt).sort((a, b) => a - b);
for (let i = 1; i < sentAt.length; i += 1)
assert.ok(
sentAt[i] - sentAt[i - 1] >= 300,
`${sentAt[i] - sentAt[i - 1]}ms apart`,
);
assert.equal(
createGateStateStore({ file: spacedFile }).count('2026-09-29'),
3,
@@ -205,7 +220,7 @@ test('processes sharing state reserve public spacing and cap atomically', async
const capped = await Promise.all([run(cappedFile, 1), run(cappedFile, 1)]);
assert.deepEqual(capped.map((result) => result.status).sort(), [
'cap',
'reserved',
'started',
]);
assert.equal(
createGateStateStore({ file: cappedFile }).count('2026-09-29'),
@@ -427,12 +442,12 @@ test('parseable but invalid public usage state fails closed', async (t) => {
);
const migrated = createGateStateStore({ file });
assert.equal(
migrated.reserve('2026-09-30', {
now: 1000,
dailyCap: 50,
minSpacingMs: 1100,
}).status,
'reserved',
migrated.dispatch(
'2026-09-30',
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
() => Promise.resolve(),
).status,
'started',
'the original state format without nextStartAt remains supported',
);
});
+31 -25
View File
@@ -91,11 +91,12 @@ function contenderScript(stateFile, lockWaitMs) {
onError: () => {},
});
process.stdout.write('ATTEMPTING\\n', () => {
const result = store.reserve('2026-10-01', {
now: 1000,
dailyCap: 50,
minSpacingMs: 1100,
});
const result = store.dispatch(
'2026-10-01',
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
() => Promise.resolve(),
);
delete result.value;
process.stdout.write('RESULT:' + JSON.stringify(result) + '\\n');
});
`;
@@ -153,16 +154,14 @@ test('a crashed lock owner cannot delete or bypass a successor lock', async (t)
successorOwner.child.stdin.end('release\n');
await successorOwner.waitFor('RELEASED');
assert.equal((await successorOwner.exited).code, 0);
const afterRelease = createGateStateStore({ file: stateFile }).reserve(
const released = createGateStateStore({ file: stateFile });
const afterRelease = released.dispatch(
'2026-10-01',
{
now: 2200,
dailyCap: 50,
minSpacingMs: 1100,
},
{ clock: () => 2200, dailyCap: 50, minSpacingMs: 1100 },
() => Promise.resolve(),
);
assert.equal(afterRelease.status, 'reserved');
assert.equal(afterRelease.count, 1);
assert.equal(afterRelease.status, 'started');
assert.equal(released.count('2026-10-01'), 1);
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
});
@@ -211,22 +210,29 @@ test('a public pause stays authoritative while another process owns the lock', a
assert.equal((await owner.exited).code, 0);
const successor = createGateStateStore({ file: stateFile });
let sent = 0;
const send = () => {
sent += 1;
return Promise.resolve();
};
assert.deepEqual(
successor.reserve('2026-10-01', {
now: 1000,
dailyCap: 50,
minSpacingMs: 1100,
}),
successor.dispatch(
'2026-10-01',
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
send,
),
{ status: 'paused', retryAfterMs: 8000 },
'a fresh process observes the pending refusal pause',
);
const resumed = successor.reserve('2026-10-01', {
now: 9001,
dailyCap: 50,
minSpacingMs: 1100,
});
assert.equal(resumed.status, 'reserved');
assert.equal(resumed.count, 2);
assert.equal(sent, 0, 'a paused dispatch never starts the transport');
const resumed = successor.dispatch(
'2026-10-01',
{ clock: () => 9001, dailyCap: 50, minSpacingMs: 1100 },
send,
);
assert.equal(resumed.status, 'started');
assert.equal(sent, 1);
assert.equal(successor.count('2026-10-01'), 2);
assert.equal(
fs.readdirSync(dir).some((name) => name.startsWith('state.json.pause.')),
false,