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