diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cabc666ec..c84d878eb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3812,7 +3812,7 @@ Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, * [`docs/TRANSLATORS.md`](./TRANSLATORS.md). - **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera', -'{count} cameras')`, and `msg('Live Flights')` for text stored in data and + '{count} cameras')`, and `msg('Live Flights')` for text stored in data and translated later. Markup opts in with `data-i18n` (element text) and `data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English value). English has no catalog; every other locale is @@ -3924,6 +3924,7 @@ MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception. anchors, recovery, keyboard and mobile states; emergency repainting is covered by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. + Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. Street Level is a docked, collapsible right-rail panel (`#street-level-panel`, @@ -4089,7 +4090,8 @@ that does not answer in 20 s is asked once more, and one that is not a line list is not cached. A click on a line reaches `handlePick(id, {groundPoint})`, and the provider asks the core (`actions.openNearest`) for its nearest showable photo within 50 m, without -framing: many photos are archived (their file answers 409, while a HEAD says 200) or have no file, so the six nearest are probed at once with a GET cut +framing: many photos are archived (their file answers 409, while a HEAD says +200) or have no file, so the six nearest are probed at once with a GET cut off at the headers, and where all are gone the panel says KartaView's photos there are archived. Only a definite answer (403/404/409/410, or 200) is remembered; a storage timeout or 5xx is asked again. The open photo's @@ -5590,6 +5592,7 @@ Vite's development filesystem deny list excludes local `.gev-logs/` and dependency `dist/` assets. Checkout glob characters are escaped. This does not apply to `public/` assets or built-preview serving. + - Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning) - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: diff --git a/src/annotations/outlineRungs.js b/src/annotations/outlineRungs.js index 5d8d3064b..485486d55 100644 --- a/src/annotations/outlineRungs.js +++ b/src/annotations/outlineRungs.js @@ -163,8 +163,14 @@ export function streetFootprint(street) { }; } +// Rungs built without their own tiles share one outline source, so a second +// resolver instance cannot add another outline cache to the tile budget. +let defaultOutlineTiles = null; +const sharedOutlineTiles = () => + (defaultOutlineTiles ??= createOpenFreeMapOutlineSource()); + export function createOutlineRungs({ - tiles = createOpenFreeMapOutlineSource(), + tiles = sharedOutlineTiles(), nominatim = createNominatimOutlineClient(), } = {}) { /** Decoded tiles around a point, or null when the tiles cannot be read. */ diff --git a/src/annotations/outlineRungs.test.mjs b/src/annotations/outlineRungs.test.mjs index dc789d951..945165e2a 100644 --- a/src/annotations/outlineRungs.test.mjs +++ b/src/annotations/outlineRungs.test.mjs @@ -154,6 +154,56 @@ test('a street comes from tiles before the configured ladder, as merged ribbons' assert.equal(nominatim.asks.length, 0, 'streets never reach Nominatim'); }); +test('rungs built without their own tiles share one outline tile cache', async (t) => { + const tileRequests = []; + const bytes = (y) => + readFileSync( + new URL( + `../data/fixtures/ofm-outlines-austin-14-3743-${y}.pbf`, + import.meta.url, + ), + ); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + if (href.endsWith('/planet')) + return Response.json({ + tiles: [ + 'https://tiles.openfreemap.org/planet/shared-test/{z}/{x}/{y}.pbf', + ], + }); + tileRequests.push(href); + const y = href.match(/\/(\d+)\.pbf$/)?.[1]; + return new Response(y === '6746' ? bytes(6746) : bytes(6745)); + }); + const ask = () => + ctx({ + scope: 'street', + target: 'Congress Avenue', + matchName: 'Congress Avenue', + }); + const first = await createOutlineRungs({ + nominatim: fakeNominatim(null), + }).resolve(ask(), ladder(UNAVAILABLE)); + assert.ok(first.ring.length > 6); + const fetched = tileRequests.length; + assert.ok(fetched > 0); + const second = await createOutlineRungs({ + nominatim: fakeNominatim(null), + }).resolve(ask(), ladder(UNAVAILABLE)); + assert.ok(second.ring.length > 6); + assert.equal( + tileRequests.length, + fetched, + 'a second rung set reuses the shared outline tiles', + ); + const own = fixtureTiles(); + await createOutlineRungs({ + tiles: own, + nominatim: fakeNominatim(null), + }).resolve(ask(), ladder(UNAVAILABLE)); + assert.equal(own.asked.length, 1, 'explicit tiles are still used'); +}); + test('a street the tiles do not name falls through to the ladder', async () => { const run = ladder(UNAVAILABLE); const result = await createOutlineRungs({ diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index b2b93ee42..52ebfcefa 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -15,15 +15,18 @@ export function ringAreaM2(ring) { } /** - * Join line pieces whose ends meet (within `toleranceM`) into as few chains as - * possible. Each chain grows greedily from the first unused piece. + * Join line pieces whose ends meet (closer than `toleranceM`, or at most it + * when `inclusive`) into as few chains as possible. Each chain grows greedily + * from the first unused piece. */ export function stitchLines( segments, - { toleranceM = 2, maxChains = Infinity } = {}, + { toleranceM = 2, inclusive = false, maxChains = Infinity } = {}, ) { - const same = (a, b) => - approximateDistanceM(a[1], a[0], b[1], b[0]) < toleranceM; + const same = (a, b) => { + const d = approximateDistanceM(a[1], a[0], b[1], b[0]); + return inclusive ? d <= toleranceM : d < toleranceM; + }; const remaining = segments.map((s) => s.slice()); const chains = []; while (remaining.length && chains.length < maxChains) { diff --git a/src/sources/openFreeMapOutlines.js b/src/sources/openFreeMapOutlines.js index 9afe457b9..87d3a778e 100644 --- a/src/sources/openFreeMapOutlines.js +++ b/src/sources/openFreeMapOutlines.js @@ -383,7 +383,7 @@ export function streetFromTiles( if (!pieces.length) return null; const chains = stitchLines( pieces.filter((piece) => piece.length >= 2), - { toleranceM: JOIN_TOLERANCE_M }, + { toleranceM: JOIN_TOLERANCE_M, inclusive: true }, ) .map((line) => ({ line, length: lengthM(line) })) .filter((entry) => entry.length >= STREET_MIN_CHAIN_M) diff --git a/src/sources/openFreeMapOutlines.test.mjs b/src/sources/openFreeMapOutlines.test.mjs index fb45a75e9..26e563e80 100644 --- a/src/sources/openFreeMapOutlines.test.mjs +++ b/src/sources/openFreeMapOutlines.test.mjs @@ -61,6 +61,14 @@ test('street names compare with abbreviations and a leading direction only', () assert.ok(!streetNameMatches('कुल मार्ग', 'काली मार्ग')); assert.ok(streetNameMatches('काली मार्ग', 'काली मार्ग')); assert.ok(streetNameMatches('Champs-Élysées', 'Champs Elysees')); + // Japanese: dakuten and handakuten are kept, so these stay distinct. + assert.ok(!streetNameMatches('ハシ通り', 'バシ通り')); + assert.ok(!streetNameMatches('ハシ通り', 'パシ通り')); + assert.ok(streetNameMatches('表参道', '表参道')); + // Thai: vowel and tone marks are kept. + assert.ok(!streetNameMatches('ถนนสุขุมวิท', 'ถนนสุขมวท')); + assert.ok(!streetNameMatches('ถนนข้าวสาร', 'ถนนขาวสาร')); + assert.ok(streetNameMatches('ถนนสุขุมวิท', 'ถนนสุขุมวิท')); }); test('same-name segments merge into polylines around the point (Congress Avenue)', () => { @@ -112,6 +120,23 @@ test('merge joins reversed and out-of-order pieces and keeps gaps apart', () => [0.011, 0], ]; // separate const chains = stitchLines([b, c, a], { toleranceM: 3 }); + + test('stitchLines joins at exactly the tolerance only when inclusive', () => { + const a = [ + [0, 0], + [0.001, 0], + ]; + const b = [ + [0.00102, 0], + [0.002, 0], + ]; + const gap = approximateDistanceM(0, 0.001, 0, 0.00102); + assert.equal( + stitchLines([a, b], { toleranceM: gap, inclusive: true }).length, + 1, + ); + assert.equal(stitchLines([a, b], { toleranceM: gap }).length, 2); + }); assert.equal(chains.length, 2); assert.equal(chains.find((ch) => ch.length === 3).length, 3); });