diff --git a/app/changelog.json b/app/changelog.json index 89cbe942..5dc68ab4 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -90,6 +90,20 @@ "title": "Export the repeaters you heard as a picture", "body": "Export in the top bar makes a 1200 × 1200 PNG of every repeater in view and where it was heard, ready to share." }, + { + "id": "2026-09-27-export-reach", + "date": "2026-09-27", + "where": "map", + "title": "Export one repeater's reach as a picture", + "body": "Export can now draw one repeater: where it was heard, where not, and where it likely reaches." + }, + { + "id": "2026-09-27-export-selection", + "date": "2026-09-27", + "where": "map", + "title": "Export what your filters select as a picture", + "body": "Export draws what the filters select in view: where it was heard, and where you drove without it." + }, { "id": "2026-09-27-dutchmeshcore-by-default", "date": "2026-09-27", diff --git a/app/src/signal.js b/app/src/signal.js index cdddfbe3..c4330a80 100644 --- a/app/src/signal.js +++ b/app/src/signal.js @@ -79,6 +79,11 @@ export function rssiToPct(rssi, offset = 0) { // at all, so dropping it converges the two surfaces (#644) rather than porting // a third rule across. +// The tiers and the dBm each one starts at, strongest first; below the last +// is 'faint'. Data rather than a ladder of ifs so a legend (the exports, +// #720) can spell the same bands the map colours by. +export const TIER_BANDS = [['hot', -80], ['warm', -90], ['mid', -100], ['cool', -110], ['cold', -115]] + // Fixed RSSI dBm bands (iteration 2): hot = strong = close. `offset` is an // optional per-device calibration value (dBm) added before banding. // @@ -90,11 +95,7 @@ export function rssiToPct(rssi, offset = 0) { export function rssiTier(rssi, offset = 0) { if (rssi == null) return 'none' const v = rssi + offset - if (v >= -80) return 'hot' - if (v >= -90) return 'warm' - if (v >= -100) return 'mid' - if (v >= -110) return 'cool' - if (v >= -115) return 'cold' + for (const [tier, floor] of TIER_BANDS) if (v >= floor) return tier return 'faint' } diff --git a/changelog.d/2026-09-27-10-export-reach.json b/changelog.d/2026-09-27-10-export-reach.json new file mode 100644 index 00000000..60956787 --- /dev/null +++ b/changelog.d/2026-09-27-10-export-reach.json @@ -0,0 +1,7 @@ +{ + "id": "2026-09-27-export-reach", + "date": "2026-09-27", + "where": "map", + "title": "Export one repeater's reach as a picture", + "body": "Export can now draw one repeater: where it was heard, where not, and where it likely reaches." +} diff --git a/changelog.d/2026-09-27-11-export-selection.json b/changelog.d/2026-09-27-11-export-selection.json new file mode 100644 index 00000000..a7632887 --- /dev/null +++ b/changelog.d/2026-09-27-11-export-selection.json @@ -0,0 +1,7 @@ +{ + "id": "2026-09-27-export-selection", + "date": "2026-09-27", + "where": "map", + "title": "Export what your filters select as a picture", + "body": "Export draws what the filters select in view: where it was heard, and where you drove without it." +} diff --git a/docs/2026-09-27-export-reach.md b/docs/2026-09-27-export-reach.md new file mode 100644 index 00000000..995114f8 --- /dev/null +++ b/docs/2026-09-27-export-reach.md @@ -0,0 +1,65 @@ +# Export: Reach of one repeater (#720) + +**Date:** 2026-09-27 +**Status:** decided (Kasper, 27 September 2026, on renders from real data), built in `web/exportreach.js`, `web/exportterrain.js`, `web/exportrender.js` and `web/map.js`. +**Related:** #665 (the exports epic), #666 (export 1, repeaters heard), #603 (the reach layer), #723 (a Discover-heard star under its own ▲), #396 (the terrain tiles) + +## The problem + +A hunter who wants to show how far one repeater reaches has only the map. The reach layer draws every repeater at once, and a URL only shows the same thing to someone with an account and the same filters. + +## What it is + +A second item in the Export sheet: **Reach of one repeater**, a 1200×1200 PNG of the selected star, downloaded as `mesh-hunter-reach--YYYY-MM-DD.png`. A star is selected by tapping its ▲ or by picking it as target. Without a selection the item is disabled and says: "Select one repeater on the map: tap its ▲, or pick it as target." A merged target row is one repeater under several ids; when its hearings form two stars (a key the registry does not place, say), the star with the most hearings is drawn (Kasper, 30 September 2026). The cells use the type and hop filters of the map: with a type filter on, a grey cell means not heard as that packet type (Kasper, 30 September 2026). + +## What the picture shows + +- **Heard cells** (the export's cell of #666, res 13 since #734, about 330 m point to point) in the RSSI tier colour of their strongest hearing. A strong cell is more opaque than a faint one. +- **A ray** from the origin to the strongest hearing in each heard cell, in its tier colour, and a dot at every hearing. +- **Mapped, not heard:** the cells the same hunters drove through without hearing this repeater, in grey. +- **Filled-in cells**, lighter: where the repeater reaches between its hearings (the next section). Their colour is the mean RSSI of the 6 nearest hearings, weighted by one over the distance squared. +- A ▲ "adv." at the advertised position and a ● "est." at the RSSI estimate, with a dashed line between them. The rays leave from the ▲, or from the ● when the registry has no position. +- **The relief**, shaded at the exaggeration from Settings, as the 3D view and the Repeaters heard export shade it, with "relief 7× exaggerated" beside the scale bar. The fill is checked against the real heights, not the exaggerated ones. +- **The band:** the repeater's name, the window and the hunters, three numbers (receptions, strongest, farthest) and a legend with the RSSI tiers, mapped not heard, the ▲ and the ●. + +## What is filled in + +Thought from the repeater (Kasper). The signal goes out in straight lines: + +1. **Lines.** One line from the repeater to the strongest hearing in each heard cell. +2. **Stretches.** A line is reached from the repeater, or from a heard cell on it, up to the next heard cell. A cell driven through without hearing it is a shadow: the line is dark from there until it is heard again. Heard at 1, 2 and 3, not at 4 and 5, and again at 6, 7 and 8 fills 1 to 3 and 6 to 8. The repeater's own cell never darkens a line. +3. **One line, several places.** A line to a cell that a longer line passes through is part of that longer line. +4. **Between neighbouring lines**, the area where both lines are reached is filled, out to the ends of their stretches. This holds when those ends are at most **2 km** apart, no silent cell lies in the area, and the two lines are less than 180° apart. From the repeater, that area is the wedge between the two lines. +5. **Terrain.** A cell is filled only where the repeater sees it over the ground. The ground comes from the Terrarium tiles the 3D view draws, at zoom 10 (pixels of about 95 m). The line runs from 30 m above the repeater's ground, the height the 3D rays already leave from (`RAY_ALT_M`), to 1.5 m above the cell. The earth bulges between them, with its radius times 4/3 for the bending of the air. The Fresnel zone is not kept clear. +6. **Without the terrain**, the lines alone decide. That happens when a tile does not load within 8 s, or when the star spreads over more than 64 tiles. It also happens when the browser alters what a canvas reads back, as some do against fingerprinting: one bit of red off is 256 m of ground. MapLibre checks for the same with `isOffscreenCanvasDistorted`. + +Heard and mapped cells are measurements and are never filled. + +## Measured on real data + +Receptions around Nijmegen and Arnhem, 8 August to 8 September 2026. Filled cells on flat ground and over the real terrain: + +| Repeater | Heard cells | Filled, flat ground | Filled, with terrain | +|---|---|---|---| +| NL-NIJ-Dikkeboom | 85 | 45 | 40 | +| Stollenberg | 23 | 83 | 77 | + +Of the 29 stars with at least 20 hearings, the terrain removes cells for 4. For Stollenberg, on the moraine east of Nijmegen, they are 6 cells on the Waal side of the city centre. There the ridge of the centre (26 to 33 m) is 0.7 to 3.2 m higher than the line from the mast. + +## What was tried and not chosen + +- **Triangles between hearings** (Delaunay, sides up to 2, 2.5, 3, 3.5 or 5 km): fills across places the repeater may never reach, without a line from the repeater. +- **A silent cell breaks the whole line:** fills 12 of Dikkeboom's cells, and nothing past a single shadow. +- **Filled cells over mapped ones at half the opacity:** a mapped cell is a measurement and stays one. +- **The route line:** the heard and mapped cells already show where the hunters drove. +- **A legend item for the filled cells:** three wordings; none is used. +- **A note on the exaggeration in the legend:** it sits beside the scale bar instead, with the other facts about the map. +- **Nothing filled without the terrain:** the lines alone decide instead. + +## How it is drawn + +As for #666: a second MapLibre instance offscreen, and a canvas for the glyphs, the scale bar and the band. The model fetches the terrain tiles for each export: the tiles under the box around the repeater and its hearings, padded by 0.01°. The relief is MapLibre's hillshade layer on the same tiles, added once the camera is on the repeater. + +## Who can use it + +A member, as for #666. diff --git a/docs/2026-09-27-export-repeaters-heard.md b/docs/2026-09-27-export-repeaters-heard.md index 9047ffac..0beb1f3b 100644 --- a/docs/2026-09-27-export-repeaters-heard.md +++ b/docs/2026-09-27-export-repeaters-heard.md @@ -19,10 +19,11 @@ An **Export** button in #bar, next to Start mapping, opens a sheet with one item - **Strength as a gradient within the hue**, per ray: strong full and thick, weak lighter (mixed up to 55% towards white) and thinner. Width and opacity are the map's (`rayStyle`). - A dot at every hearing in the same tint. A ▲ in the hue at the advertised position, a ● where there is only an RSSI estimate. - **Names** as text on a white halo, in the hue. A key without a registry name reads the resolver's name, a relay hash never does (AGENTS.md §7 rule 2) and reads `#id`. Most-heard first; a name that would print over another (4 px margin) or off the image is dropped, the ▲ never. -- **Mapped cells** in grey: the resolution of `hexgrid.js` (now copied whole into web/ and pinned) whose size is closest to 360 Mercator units, about 445 m point to point at 52°N (res 8 of today's table), so the cell stays when the table changes; and the **route** in grey, broken on a gap of more than 5 minutes, more than 3 km, or another hunter. +- **Mapped cells** in grey: the resolution of `hexgrid.js` (now copied whole into web/ and pinned) whose size is closest to 360 Mercator units, about 445 m point to point at 52°N. Since #734 the closest is res 13 (268 units, about 330 m point to point); and the **route** in grey, broken on a gap of more than 5 minutes, more than 3 km, or another hunter. - A star whose nearest hearing is more than **30 km** from its origin is left off and named in the band. - **The band:** the mark and mesh-hunter.eu, "Repeaters heard", the window as dates and up to three hunters by name, what was left off, three numbers (repeaters, receptions, farthest in km), a legend, and the tile attribution on the map. - The **light theme**, whatever the page shows: it is a picture passed on to people who never chose a theme. +- **The relief** under it all (added with #720, Kasper, 27 September): shaded from the Terrarium tiles at the exaggeration from Settings, as the 3D view shades it, with "relief 7× exaggerated" beside the scale bar. At 1× the relief is true to scale and the note is left out. The tiles' attribution joins the map's. ## What was tried and not chosen diff --git a/docs/2026-09-27-export-selection.md b/docs/2026-09-27-export-selection.md new file mode 100644 index 00000000..714dc862 --- /dev/null +++ b/docs/2026-09-27-export-selection.md @@ -0,0 +1,45 @@ +# Export: Heard with these filters (#728) + +**Date:** 2026-09-27 +**Status:** decided (Kasper, 27 September 2026, on renders from real data), built in `web/exportselection.js`, `web/exportrender.js` and `web/map.js`. +**Related:** #665 (the exports epic), #666 (repeaters heard), #720 (reach of one repeater), #661 (attribution by reach), #723 (a Discover prefix on its node) + +## The problem + +The Export sheet draws every repeater in view (#666) or one repeater's reach (#720). What the filters select cannot be exported, such as channel messages with a 1-byte sender, or two hunted nodes together. The map shows where the selection was heard, but not where the hunters drove without hearing it. + +## What it is + +A third item, **Heard with these filters**: a 1200×1200 PNG of what the map's filters select in view, downloaded as `mesh-hunter-selection--YYYY-MM-DD.png`. It works for any filter: targets, packet types, id sizes, time and hunters. Nothing has to be selected on the map. + +## What the picture shows + +- **Heard cells** in the tier of the strongest selected reception, finer than the cells the map draws, on purpose (Kasper, 30 September 2026): the sizes the app's zoom bands gave before #734, kept as sizes (`selectionCellSize`), 180 Mercator units at z11 and 90 at z14, about a sixth and two thirds of a map cell across. The map's own cells come from the server at about 28 px at every zoom, so the numbers count finer cells than the hex layer shows. +- **Grey cells** where the same hunters drove in the window without a selected reception. They come from all the hunters' receptions, with the senders, packet types, id sizes and hops left out of the query. +- A dot at every selected reception. +- **The nodes it was heard from**, ▲ with names. A relay hash of 1 to 3 bytes is placed by reach (#661), a key or a prefix of one on the node it alone starts (#723). A picked target is placed by the same rules: a picked relay id is never placed on the one key in view it starts. A node without a name reads as its key's prefix. Only nodes in the frame are drawn and in the legend. Names that collide are dropped, the most-heard kept; a picked target keeps its name. +- **The relief** at the exaggeration from Settings, as in #720. +- **The band:** a title from the filter, the window and the hunters, and three numbers: receptions, cells heard, and the share of the cells driven that heard the selection. The title names what the stand applies: the targets or a typed prefix ("Starts with db1"), No path, then the packet types and id sizes in the bar's words, for example "Channel · 1 byte". Under the ticker's "all" the sender inputs are dropped, and so are the targets in the title. When the query for the cells driven reaches the cap, the band says so. With nothing narrowing it, the title is "Everything heard". + +The map's view decides which receptions count, and the picture is fitted to them: zoom in on the map and the picture zooms with it. A node outside that frame is left off. + +## What is not in it + +- **No fill and no lines.** Without one repeater there is no origin to think from, and #720's model needs one. +- **Everything heard is not refused.** With nothing narrowing the selection, every cell driven heard something, and the share is 100%. +- **No larger cells when zoomed out.** The cell size follows the zoom, so at z11 a cell is a few pixels (Kasper). + +## Measured on real data + +Channel messages with a 1-byte sender, Nijmegen and Arnhem, 28 August to 27 September 2026: + +| View | Receptions | Cells heard | Share of the cells driven | +|---|---|---|---| +| z11 | 344 | 96 | 28% | +| z14, centre of Nijmegen | 238 | 68 | 47% | + +In that window no channel data (GroupData) with a 1-byte sender was heard in that area. + +## Who can use it + +A member, as for #666. diff --git a/web/changelog.json b/web/changelog.json index 89cbe942..5dc68ab4 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -90,6 +90,20 @@ "title": "Export the repeaters you heard as a picture", "body": "Export in the top bar makes a 1200 × 1200 PNG of every repeater in view and where it was heard, ready to share." }, + { + "id": "2026-09-27-export-reach", + "date": "2026-09-27", + "where": "map", + "title": "Export one repeater's reach as a picture", + "body": "Export can now draw one repeater: where it was heard, where not, and where it likely reaches." + }, + { + "id": "2026-09-27-export-selection", + "date": "2026-09-27", + "where": "map", + "title": "Export what your filters select as a picture", + "body": "Export draws what the filters select in view: where it was heard, and where you drove without it." + }, { "id": "2026-09-27-dutchmeshcore-by-default", "date": "2026-09-27", diff --git a/web/e2e/export.spec.js b/web/e2e/export.spec.js index 6c6e628b..7ced4155 100644 --- a/web/e2e/export.spec.js +++ b/web/e2e/export.spec.js @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { test, expect } from './fixtures.js' +import { tilePixel } from '../exportterrain.js' // The Export sheet (#666): "Repeaters heard" draws the reach layer's stars // into a 1200×1200 PNG and downloads it. The basemap is the fixtures' bare @@ -79,3 +80,103 @@ test('Export sits in the menu below 900px and opens the sheet from there', async await page.setViewportSize({ width: 1024, height: 768 }) await expect(page.locator('#bar #export-btn')).toBeVisible() }) + +// #720: Reach of one repeater works on the selected star. The target picked +// in the URL selects it; without a selection the row says what to do. +const REACH_URL = `/?mode=points&lat=51.84&lon=5.86&z=12&from=2026-09-07T00:00&to=2026-09-08T00:00&senders=${encodeURIComponent(JSON.stringify(['db11db11f7808b97']))}` + +test('Reach of one repeater downloads a PNG named after the selected repeater', async ({ page }) => { + await stub(page, 'member') + await page.goto(REACH_URL) + await expect(async () => { + if (await page.locator('#export-modal').isHidden()) await page.click('#export-btn') + await expect(page.locator('#export-modal')).toBeVisible() + }).toPass({ timeout: 15000 }) + await expect(page.locator('#ex-reach')).toBeEnabled() + const [download] = await Promise.all([page.waitForEvent('download', { timeout: 60000 }), page.click('#ex-reach')]) + expect(download.suggestedFilename()).toMatch(/^mesh-hunter-reach-nl-nij-dikkeboom-\d{4}-\d{2}-\d{2}\.png$/) + const png = readFileSync(await download.path()) + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([1200, 1200]) +}) + +test('Reach of one repeater asks for a repeater when none is selected', async ({ page }) => { + await stub(page, 'member') + await page.goto('/?mode=points&lat=51.84&lon=5.86&z=12') + await expect(async () => { + if (await page.locator('#export-modal').isHidden()) await page.click('#export-btn') + await expect(page.locator('#export-modal')).toBeVisible() + }).toPass({ timeout: 15000 }) + await expect(page.locator('#ex-reach')).toBeDisabled() + await expect(page.locator('#ex-reach-status')).toHaveText('Select one repeater on the map: tap its ▲, or pick it as target.') + await expect(page.locator('#ex-heard')).toBeEnabled() +}) + +// A Terrarium tile with the ground at `metres` everywhere but one pixel, +// `hole`, at 0 m. Drawn in the page, which has the PNG encoder. +async function terrariumTile(page, metres, hole = null) { + const v = metres + 32768 + const bytes = await page.evaluate(async ({ r, g, hole }) => { + const c = new OffscreenCanvas(256, 256) + const ctx = c.getContext('2d') + ctx.fillStyle = `rgb(${r},${g},0)` + ctx.fillRect(0, 0, 256, 256) + if (hole) { ctx.fillStyle = 'rgb(128,0,0)'; ctx.fillRect(hole.px, hole.py, 1, 1) } + return [...new Uint8Array(await (await c.convertToBlob({ type: 'image/png' })).arrayBuffer())] + }, { r: Math.floor(v / 256), g: v % 256, hole }) + return Buffer.from(bytes) +} + +test('Reach of one repeater fills along the lines, and leaves out what the terrain hides', async ({ page }) => { + await stub(page, 'member') + await page.goto(REACH_URL) + await expect(async () => { + if (await page.locator('#export-modal').isHidden()) await page.click('#export-btn') + await expect(page.locator('#export-modal')).toBeVisible() + }).toPass({ timeout: 15000 }) + const reach = async () => { + await Promise.all([page.waitForEvent('download', { timeout: 60000 }), page.click('#ex-reach')]) + return page.evaluate(() => window.__lastReach()) + } + // The fixtures block the DEM host: the lines alone decide. + const open = await reach() + expect(open.filled).toBeGreaterThan(0) + // The repeater in a pit, 500 m of ground all round it: the lines are there, + // the repeater sees none of them. + const at = tilePixel(51.84, 5.86, 10) + const wall = await terrariumTile(page, 500) + const pit = await terrariumTile(page, 500, at) + await page.route('**/elevation-tiles-prod/**', (r) => r.fulfill({ status: 200, contentType: 'image/png', + headers: { 'access-control-allow-origin': '*' }, body: r.request().url().endsWith(`/10/${at.x}/${at.y}.png`) ? pit : wall })) + const hidden = await reach() + expect(hidden.heard).toBe(open.heard) + expect(hidden.filled).toBeLessThan(open.filled) +}) + +// #728: Heard with these filters. The filters here are the bar's Channel and +// 1 byte; the server answers a query that narrows by type or id size with the +// channel messages, any other with all of the hunter's receptions. +test('Heard with these filters draws what the filters select, against all the cells the hunter drove', async ({ page }) => { + await stub(page, 'member') + const rx = (lat, lon, rssi, over) => ({ lat, lon, rssi, snr: 4, hops: 1, hunter_name: 'kas', rx_at: '2026-09-07T12:00:00Z', ...over }) + const channel = { sender_id: 'db', sender_kind: 'path_hash', packet_type: 'GroupText' } + const selected = [rx(51.845, 5.865, -80, channel), rx(51.8451, 5.8651, -85, channel), rx(52.5, 6.5, -90, channel)] + const drove = [rx(51.85, 5.87, -95, { sender_id: 'ffee', sender_kind: 'relay', packet_type: 'Control' }), + rx(51.835, 5.85, -99, { sender_id: 'ffee', sender_kind: 'relay', packet_type: 'Control' })] + await page.route('**/api/points*', (r) => { + const q = new URL(r.request().url()).searchParams + r.fulfill({ json: { points: q.get('types') || q.get('idclass') ? selected : [...selected, ...drove] } }) + }) + await page.goto('/?mode=points&lat=51.84&lon=5.86&z=12&from=2026-09-07T00:00&to=2026-09-08T00:00&types=GroupText&idclass=1b') + await expect(async () => { + if (await page.locator('#export-modal').isHidden()) await page.click('#export-btn') + await expect(page.locator('#export-modal')).toBeVisible() + }).toPass({ timeout: 15000 }) + const [download] = await Promise.all([page.waitForEvent('download', { timeout: 60000 }), page.click('#ex-sel')]) + expect(download.suggestedFilename()).toMatch(/^mesh-hunter-selection-channel-1-byte-\d{4}-\d{2}-\d{2}\.png$/) + const png = readFileSync(await download.path()) + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([1200, 1200]) + // The reception far out of view is left out; the two cells the hunter + // drove without a channel message count against the one that heard them; + // the 1-byte id is placed on the node it alone can be within reach. + expect(await page.evaluate(() => window.__lastSelection())).toEqual({ receptions: 2, heardCells: 1, drivenCells: 3, share: 33, nodes: ['NL-NIJ-Dikkeboom'] }) +}) diff --git a/web/exportheard.js b/web/exportheard.js index 65085a54..e7ba497a 100644 --- a/web/exportheard.js +++ b/web/exportheard.js @@ -12,6 +12,7 @@ import { assignHues, rayStyle, rayStrength } from './coverage.js' import { hexCellAt, hexBoundary, hexSizeForRes } from './hexgrid.js' import { haversineM } from './locate.js' import { isHashIdKind } from './names.js' +import { TIER_BANDS } from './signal.js' export const EXPORT_W = 1200 export const EXPORT_H = 1200 @@ -28,11 +29,12 @@ export const ROUTE_GAP_KM = 3 // The cell of #720's reference, by its size: 360 Mercator units from centre // to corner, about 445 m point to point on the ground at 52°N. The resolution -// is whichever the grid gives closest to it, so the export keeps its cell -// when the grid's table changes (#734). +// is whichever the grid gives closest to it: since #734 that is res 13, 268 +// units, about 330 m point to point. export const CELL_SIZE = 360 -export const CELL_RES = closestRes(CELL_SIZE) -function closestRes(size) { +export const CELL_RES = resForSize(CELL_SIZE) +// resForSize: the resolution whose hexSizeForRes is closest to a size. +export function resForSize(size) { let best = 0 for (let res = 1; res <= 21; res++) if (Math.abs(hexSizeForRes(res) - size) < Math.abs(hexSizeForRes(best) - size)) best = res return best @@ -65,22 +67,43 @@ export function fitText(text, maxW, measure) { return s + '…' } -const inside = (v, p) => p.lat >= v.south && p.lat <= v.north && p.lon >= v.west && p.lon <= v.east +// starName: how a star is named on a picture. The registry's name first; +// the resolver's (cachedNameOf) for a key only, since a relay hash with no +// candidate in reach is not named by a resolver (AGENTS.md §7 rule 2); +// otherwise idLabel's reading. +export function starName(star, { nameOf = () => null, cachedNameOf = () => null } = {}) { + const kind = star.points[0] && star.points[0].sender_kind + return nameOf(star.id) || (!isHashId(star.id, kind) && cachedNameOf(star.id)) || idLabel(star.id, kind) +} + +export const inside = (v, p) => p.lat >= v.south && p.lat <= v.north && p.lon >= v.west && p.lon <= v.east + +// nearestKm: how far a star hangs from its nearest hearing. Farther than +// FAR_KM, its position cannot be right (heardModel leaves it off, #720's +// export refuses it). +export function nearestKm(star) { + return Math.min(...star.points.map((p) => kmBetween(star.origin, p))) +} + +// tierLegend: the RSSI tiers as [tier, words], from the bands rssiTier +// draws by, so the legend on a picture cannot drift from the map's colours. +export function tierLegend() { + const out = TIER_BANDS.map(([tier, floor], i) => [tier, i === 0 ? `≥ ${floor}` : `${TIER_BANDS[i - 1][1] - 1} … ${floor}`]) + out.push(['faint', `< ${TIER_BANDS[TIER_BANDS.length - 1][1]}`]) + return out +} // heardModel: what the image draws, from the stars the map builds // (coverageStars) and the view it has. A star is drawn when its origin is in // view and its nearest hearing is within FAR_KM; the numbers count what is -// drawn. nameOf(id) is the registry's name; cachedNameOf(id) the resolver's, -// asked for a key only, since a relay hash with no candidate in reach is not -// named by a resolver (AGENTS.md §7 rule 2); otherwise idLabel's reading. +// drawn. Each star is named by starName. export function heardModel({ stars, view, nameOf = () => null, cachedNameOf = () => null }) { const drawn = [] const offMap = [] for (const s of stars || []) { if (!inside(view, s.origin)) continue - const nearest = Math.min(...s.points.map((p) => kmBetween(s.origin, p))) - const kind = s.points[0] && s.points[0].sender_kind - const name = nameOf(s.id) || (!isHashId(s.id, kind) && cachedNameOf(s.id)) || idLabel(s.id, kind) + const nearest = nearestKm(s) + const name = starName(s, { nameOf, cachedNameOf }) if (nearest > FAR_KM) { offMap.push({ name, km: Math.round(nearest) }); continue } drawn.push({ id: s.id, name, origin: s.origin, n: s.points.length, @@ -114,7 +137,7 @@ export function routeSegments(points) { return out } -// mappedCells: one res-8 cell per place a reception was taken, as a closed +// mappedCells: one export cell (CELL_RES) per place a reception was taken, as a closed // [lon, lat] ring. export function mappedCells(points, res = CELL_RES) { const ids = new Set() @@ -174,10 +197,20 @@ export function huntersText(names) { return `${list.length} ${list.length === 1 ? 'hunter' : 'hunters'}: ${list.join(', ')}` } -export function exportFileName(nowMs) { +// exportFileName: the export's kind, a subject when it has one (the repeater +// of #720, in letters, digits and hyphens only), and the day. +export function exportFileName(nowMs, kind = 'repeaters-heard', subject = '') { const d = new Date(nowMs) const pad = (v) => String(v).padStart(2, '0') - return `mesh-hunter-repeaters-heard-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.png` + const slug = String(subject).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + return `mesh-hunter-${kind}${slug ? `-${slug}` : ''}-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.png` +} + +// reliefNote: the words beside the scale bar when the relief is shaded +// steeper than it is, at the exaggeration from Settings. None at 1×, where +// it is true to scale. +export function reliefNote(exaggeration) { + return exaggeration > 1 ? `relief ${exaggeration}× exaggerated` : '' } // scaleBar: the longest round length that fits in `maxPx`, at `metresPerPx`. diff --git a/web/exportheard.test.js b/web/exportheard.test.js index 1aeee3db..0c918c6d 100644 --- a/web/exportheard.test.js +++ b/web/exportheard.test.js @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest' -import { heardModel, routeSegments, mappedCells, placeLabels, tint, windowText, huntersText, exportFileName, scaleBar, idLabel, fitText, FAR_KM, CELL_SIZE, CELL_RES } from './exportheard.js' +import { heardModel, routeSegments, mappedCells, placeLabels, tint, windowText, huntersText, exportFileName, scaleBar, idLabel, fitText, nearestKm, tierLegend, FAR_KM, CELL_SIZE, CELL_RES, reliefNote } from './exportheard.js' import { hexSizeForRes } from './hexgrid.js' import { coverageStars, rayStyle, rayStrength } from './coverage.js' +import { TIER_BANDS, rssiTier } from './signal.js' // #666: the "Repeaters heard" export. The image shows the reach layer's stars // as the map has them; these are the rules that decide what goes on it. @@ -90,6 +91,16 @@ describe('heardModel (#666)', () => { expect(heardModel({ stars, view }).drawn[0].name).toBe('db11db11') }) + // #720 applies the same 30 km rule to one star: nearestKm is what both + // exports ask. + it('measures how far a star hangs from its nearest hearing', () => { + // aa's two hearings 1.77 km out, and one 11 km out: the nearest counts. + const [aa] = starsOf([...pts.slice(0, 2), hearing('aa', 51.9, 5.9, -115)], positions) + expect(nearestKm(aa)).toBeCloseTo(1.77, 1) + const [far] = starsOf([hearing('bb', 51.91, 5.91, -90)], { bb: { lat: 51.55, lon: 5.9 } }) + expect(nearestKm(far)).toBeCloseTo(40, 0) + }) + it('says nothing is heard when no star is in view', () => { const m = heardModel({ stars: [], view }) expect(m.drawn).toEqual([]) @@ -148,7 +159,7 @@ describe('fitText (#666)', () => { }) describe('mappedCells (#666)', () => { - it('gives one res-8 cell per place driven, as a closed [lon, lat] ring', () => { + it('gives one export cell per place driven, as a closed [lon, lat] ring', () => { const cells = mappedCells([{ lat: 51.8, lon: 5.8 }, { lat: 51.80001, lon: 5.80001 }, { lat: 51.9, lon: 5.9 }]) expect(cells).toHaveLength(2) const ring = cells[0] @@ -199,6 +210,24 @@ describe('the small rules of the band (#666)', () => { it('names the file after the export and the day', () => { expect(exportFileName(Date.UTC(2026, 8, 27, 12))).toBe('mesh-hunter-repeaters-heard-2026-09-27.png') }) + it('names a one-repeater export after the repeater, in characters a file name takes', () => { + expect(exportFileName(Date.UTC(2026, 8, 27, 12), 'reach', 'NL-NIJ-Dikkeboom 🌳')).toBe('mesh-hunter-reach-nl-nij-dikkeboom-2026-09-27.png') + expect(exportFileName(Date.UTC(2026, 8, 27, 12), 'reach', 'DTIS | NL NIJ | 6525HT')).toBe('mesh-hunter-reach-dtis-nl-nij-6525ht-2026-09-27.png') + }) + it('says how much the relief is exaggerated, and nothing when it is true to scale', () => { + expect(reliefNote(7)).toBe('relief 7× exaggerated') + expect(reliefNote(2)).toBe('relief 2× exaggerated') + expect(reliefNote(1)).toBe('') + }) + // The legend's tiers come from signal.js's bands, so the words on the + // picture cannot drift from the colours the map draws. + it('spells the RSSI tiers from the bands rssiTier draws by', () => { + expect(tierLegend()).toEqual([['hot', '≥ -80'], ['warm', '-81 … -90'], ['mid', '-91 … -100'], ['cool', '-101 … -110'], ['cold', '-111 … -115'], ['faint', '< -115']]) + for (const [tier, floor] of TIER_BANDS) { + expect(rssiTier(floor)).toBe(tier) + expect(rssiTier(floor - 1)).not.toBe(tier) + } + }) it('picks the longest round scale length that fits', () => { expect(scaleBar(20)).toEqual({ px: 100, label: '2 km' }) expect(scaleBar(3)).toEqual({ px: 100, label: '300 m' }) diff --git a/web/exportreach.js b/web/exportreach.js new file mode 100644 index 00000000..758b2af7 --- /dev/null +++ b/web/exportreach.js @@ -0,0 +1,272 @@ +// The "Reach of one repeater" export (#720): one star of the reach layer on +// the export's cell (CELL_SIZE), as a 1200×1200 PNG. Where it was heard, +// where the hunters drove without hearing it, and what the repeater reaches +// in between. This module decides what goes on the image; exportrender.js +// draws it. +// +// What is filled in is thought from the repeater (Kasper, 27 September). +// The signal travels in straight lines, so there has to be a line from the +// repeater to a place it was heard before anything is filled. On that line +// a cell driven through without hearing it is a shadow: the line is reached +// from the repeater, or from a cell where it was heard, up to the next cell +// where it was heard, and not across a shadow. Heard at 1, 2 and 3, not at 4 +// and 5, and again at 6, 7 and 8, fills 1 to 3 and 6 to 8. Between two +// neighbouring lines the area where both are reached is filled, out to their +// ends, when those lie close together and no silent cell lies in it. Terrain +// can be in the way too, so a cell is filled only where the repeater, up its +// mast, sees it over the ground; when the ground cannot be read, the lines +// alone decide. Heard and silent cells are measurements and are always +// drawn. +import { estimateFor } from './nodelayer.js' +import { hexCellAt, hexBoundary, hexSizeForRes } from './hexgrid.js' +import { rssiTier } from './signal.js' +import { RAY_ALT_M, starSelected } from './coverage.js' +import { kmBetween, starName, CELL_RES, CELL_SIZE } from './exportheard.js' + +// Two neighbouring lines are close together when their ends lie within +// this; wider apart, the area between them is not filled. Chosen by Kasper +// on 27 September, from 2, 2.5, 3 and 3.5 km, for the fill as it was then; +// it holds for the lines. +export const REACH_EDGE_KM = 2 +// A filled-in cell takes the inverse-distance mean of this many nearest +// hearings, so a crowd of strong hearings far off cannot outvote the weak +// ones next to it. +export const IDW_NEAREST = 6 +// Antenna heights for the line of sight. Neither is known: the registry has +// no mast height, so the repeater's is the 30 m the 3D rays already leave +// from (RAY_ALT_M, coverage.js), and a hunter's antenna is on a car or in a +// hand. +export const REPEATER_MAST_M = RAY_ALT_M +export const HUNTER_ANTENNA_M = 1.5 +// The standard atmosphere bends a radio path over the curve: the earth's +// radius times 4/3 is the usual effective radius. The Fresnel zone is not +// kept clear; this is the geometric line only. +const EFFECTIVE_R_M = 6371000 * (4 / 3) + +// A local plane in km around `lat0`, enough at the scale of one repeater. +const planar = (lat0) => (p) => [p.lon * 111.32 * Math.cos((lat0 * Math.PI) / 180), p.lat * 110.574] + +function inTriangle(p, [a, b, c], toXY) { + const [px, py] = toXY(p), [ax, ay] = toXY(a), [bx, by] = toXY(b), [cx, cy] = toXY(c) + const s1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax) + const s2 = (cx - bx) * (py - by) - (cy - by) * (px - bx) + const s3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx) + return (s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0) +} + +// idwRssi: the inverse-distance-squared mean of the IDW_NEAREST nearest +// hearings' RSSI at `at`. +export function idwRssi(at, hearings, k = IDW_NEAREST) { + const near = hearings.map((h) => [kmBetween(at, h) ** 2 + 1e-9, h.rssi]).sort((a, b) => a[0] - b[0]).slice(0, k) + let num = 0, den = 0 + for (const [d2, v] of near) { num += v / d2; den += 1 / d2 } + return num / den +} + +// lineOfSight: whether an antenna `fromM` above the ground at `from` sees +// one `toM` above the ground at `to` over the terrain, with the earth +// bulging between them. elevationAt(lat, lon) answers metres, or null for +// ground it did not read (off the tiles): that decides nothing, as no +// terrain at all decides nothing, rather than counting as sea level. +export function lineOfSight(from, to, elevationAt, { fromM = REPEATER_MAST_M, toM = HUNTER_ANTENNA_M, stepKm = 0.1 } = {}) { + const e0 = elevationAt(from.lat, from.lon), e1 = elevationAt(to.lat, to.lon) + if (e0 == null || e1 == null) return true + const d = kmBetween(from, to) * 1000 + const steps = Math.max(2, Math.ceil(d / (stepKm * 1000))) + const h0 = e0 + fromM, h1 = e1 + toM + for (let i = 1; i < steps; i++) { + const t = i / steps + const ground = elevationAt(from.lat + (to.lat - from.lat) * t, from.lon + (to.lon - from.lon) * t) + if (ground == null) continue + const bulge = (t * d) * ((1 - t) * d) / (2 * EFFECTIVE_R_M) + if (ground + bulge > h0 + (h1 - h0) * t) return false + } + return true +} + +// sampleSteps: how finely a line is walked and an area sampled, in +// proportion to the cell at `res`: 80 m along a line and 0.0011° by 0.0017° +// over an area for the export's cell (CELL_SIZE, 360 Mercator units), all +// inside its inner radius on the ground up to about 65°N, so no cell is +// stepped over. +export function sampleSteps(res) { + const k = hexSizeForRes(res) / CELL_SIZE + return { walkKm: 0.08 * k, latDeg: 0.0011 * k, lonDeg: 0.0017 * k } +} + +// walk: the cells a straight segment passes through, in order from `from` +// to `to`, each with how far along it lies in km (the middle of its part of +// the segment), sampled by sampleSteps. +function walk(from, to, res) { + const total = kmBetween(from, to) + const steps = Math.max(1, Math.ceil(total / sampleSteps(res).walkKm)) + const out = [] + for (let i = 0; i <= steps; i++) { + const t = i / steps + const id = hexCellAt(from.lat + (to.lat - from.lat) * t, from.lon + (to.lon - from.lon) * t, res) + const last = out[out.length - 1] + if (last && last.id === id) last.b = t * total + else out.push({ id, a: t * total, b: t * total }) + } + return out.map(({ id, a, b }) => ({ id, km: (a + b) / 2 })) +} + +// stretchesOf: the stretches of a line, as [from, to] in km from the +// repeater, that count as reached: from the repeater or a heard cell to the +// next heard cell, with no silent cell between. The repeater's own cell +// never darkens the line. +function stretchesOf(cells, heardBy, silentIds) { + const out = [] + let from = 0 + cells.forEach(({ id, km }, i) => { + if (i > 0 && silentIds.has(id)) { from = null; return } + if (!heardBy.has(id)) return + if (from != null) { + const prev = out[out.length - 1] + if (prev && prev[1] === from) prev[1] = km + else out.push([from, km]) + } + from = km + }) + return out +} + +const centreOf = (id) => { + const r = hexBoundary(id).slice(0, 6) + return { lat: r.reduce((s, q) => s + q[0], 0) / 6, lon: r.reduce((s, q) => s + q[1], 0) / 6 } +} +const ringOf = (id) => hexBoundary(id).map(([lat, lon]) => [lon, lat]) + +// lineCandidates: the cells on the reached stretches of the lines from the +// origin and in the areas between neighbouring ones (see the top of the +// file), before the terrain has its say. Heard and silent cells are never +// candidates. +function lineCandidates({ origin, heardBy, silentIds, res }) { + const toXY = planar(origin.lat) + const [ox, oy] = toXY(origin) + const out = new Set() + const lines = [] + const longest = new Map() // cell id → the longest line through it, in km + for (const { id, best } of heardBy.values()) { + const total = kmBetween(origin, best) + const cells = walk(origin, best, res) + const stretches = stretchesOf(cells, heardBy, silentIds) + for (const c of cells) { + if (stretches.some(([a, b]) => c.km >= a && c.km <= b)) out.add(c.id) + longest.set(c.id, Math.max(longest.get(c.id) || 0, total)) + } + const [x, y] = toXY(best) + lines.push({ id, end: best, total, stretches, a: Math.atan2(y - oy, x - ox) }) + } + // A line to a cell a longer line passes through is part of that line, so + // the areas are between the outer lines: heard at 1, 2 and 3 in a row is + // one line with three places on it. + const outer = lines.filter((l) => longest.get(l.id) <= l.total).sort((p, q) => p.a - q.a) + const at = (l, km) => { + const t = l.total ? km / l.total : 0 + return { lat: origin.lat + (l.end.lat - origin.lat) * t, lon: origin.lon + (l.end.lon - origin.lon) * t } + } + const silentCentres = [...silentIds].map(centreOf) + const areas = [] + for (let i = 0; outer.length > 1 && i < outer.length; i++) { + const A = outer[i], B = outer[(i + 1) % outer.length] + let span = B.a - A.a + if (span < 0) span += 2 * Math.PI + if (span >= Math.PI) continue + // From where both lines are reached out to the ends of their stretches: + // from the repeater, that is the wedge between the two. + for (const [a1, a2] of A.stretches) { + for (const [b1, b2] of B.stretches) { + const from = Math.max(a1, b1) + if (from >= Math.min(a2, b2)) continue + const quad = [at(A, from), at(A, a2), at(B, b2), at(B, from)] + if (kmBetween(quad[1], quad[2]) > REACH_EDGE_KM) continue + // From the repeater both near corners are the repeater: a triangle. + const tris = from > 0 ? [[quad[0], quad[1], quad[2]], [quad[0], quad[2], quad[3]]] : [[quad[0], quad[1], quad[2]]] + if (silentCentres.some((c) => tris.some((t) => inTriangle(c, t, toXY)))) continue + areas.push(...tris) + } + } + } + if (areas.length) { + const lats = areas.flat().map((p) => p.lat), lons = areas.flat().map((p) => p.lon) + const s = Math.min(...lats), n = Math.max(...lats), w = Math.min(...lons), e = Math.max(...lons) + const seen = new Set() + // Sampled finer than a cell, so every cell under an area is visited. + const { latDeg, lonDeg } = sampleSteps(res) + for (let lat = s; lat <= n + latDeg; lat += latDeg) { + for (let lon = w; lon <= e + lonDeg; lon += lonDeg) { + const id = hexCellAt(lat, lon, res) + if (seen.has(id)) continue + seen.add(id) + if (areas.some((t) => inTriangle(centreOf(id), t, toXY))) out.add(id) + } + } + } + for (const id of heardBy.keys()) out.delete(id) + for (const id of silentIds) out.delete(id) + return out +} + +// pickReachStar: the star "Reach of one repeater" draws, from the stars and +// the selected ids (the ▲ taps and the target rows). A merged target row is +// one repeater under ids where one starts the other (targetpicker.js), and +// its hearings can form two stars: that is one pick, and the star with the +// most hearings is drawn. Two picks answer { picked: 2 }, none { picked: 0 }. +export function pickReachStar(stars, selected) { + const groups = [] + for (const id of [...selected].sort((a, b) => a.length - b.length)) { + const g = groups.find((ids) => ids.some((x) => id.startsWith(x))) + if (g) g.push(id); else groups.push([id]) + } + const hits = groups.map((ids) => stars.filter((st) => starSelected(st, new Set(ids)))).filter((h) => h.length) + if (hits.length !== 1) return { picked: hits.length } + return { star: hits[0].reduce((best, st) => (st.points.length > best.points.length ? st : best)) } +} + +// reachModel: what the image draws for one star (coverageStars' shape). +// `mapped` is every reception of the hunters in the window, for the cells +// they drove through; the star is named by starName (nameOf, cachedNameOf); elevationAt(lat, +// lon) reads the ground (exportterrain.js), null when it could not be +// read. +export function reachModel({ star, mapped, nameOf = () => null, cachedNameOf = () => null, elevationAt = null, estimate = estimateFor, res = CELL_RES }) { + const pts = star.points + const heardBy = new Map() + for (const p of pts) { + const id = hexCellAt(p.lat, p.lon, res) + const c = heardBy.get(id) + if (!c || p.rssi > c.best.rssi) heardBy.set(id, { id, best: p }) + } + const heard = [...heardBy.values()].map(({ id, best }) => ({ id, ring: ringOf(id), rssi: best.rssi, tier: rssiTier(best.rssi) })) + const from = { lat: star.origin.lat, lon: star.origin.lon } + const rays = [...heardBy.values()].map(({ best }) => ({ from, lat: best.lat, lon: best.lon, tier: rssiTier(best.rssi) })) + const dots = pts.map((p) => ({ lat: p.lat, lon: p.lon, rssi: p.rssi, tier: rssiTier(p.rssi) })) + + const mappedIds = new Set() + for (const p of mapped) if (Number.isFinite(p.lat) && Number.isFinite(p.lon)) mappedIds.add(hexCellAt(p.lat, p.lon, res)) + const silentIds = new Set([...mappedIds].filter((id) => !heardBy.has(id))) + const silent = [...silentIds].map((id) => ({ id, ring: ringOf(id) })) + + const filled = [] + for (const id of lineCandidates({ origin: from, heardBy, silentIds, res })) { + const c = centreOf(id) + if (elevationAt && !lineOfSight(from, c, elevationAt)) continue + const rssi = idwRssi(c, pts) + filled.push({ id, ring: ringOf(id), rssi, tier: rssiTier(rssi) }) + } + + const est = estimate(pts.map((p) => ({ lat: p.lat, lon: p.lon, rssi: p.rssi }))) + return { + id: star.id, + name: starName(star, { nameOf, cachedNameOf }), + origin: star.origin, + advertised: star.origin.kind === 'advertised' ? from : null, + estimate: est && est.centroid ? { lat: est.centroid.lat, lon: est.centroid.lon } : null, + heard, rays, dots, silent, filled, + numbers: { + receptions: pts.length, + strongest: Math.max(...pts.map((p) => p.rssi)), + farthestKm: Math.max(...pts.map((p) => kmBetween(from, p))), + }, + } +} diff --git a/web/exportreach.test.js b/web/exportreach.test.js new file mode 100644 index 00000000..4cfc6d01 --- /dev/null +++ b/web/exportreach.test.js @@ -0,0 +1,352 @@ +import { describe, it, expect } from 'vitest' +import { reachModel, idwRssi, lineOfSight, sampleSteps, pickReachStar, REACH_EDGE_KM, IDW_NEAREST, REPEATER_MAST_M } from './exportreach.js' +import { coverageStars, RAY_ALT_M } from './coverage.js' +import { hexCellAt, hexBoundary, hexSizeForRes } from './hexgrid.js' +import { kmBetween, CELL_RES } from './exportheard.js' + +// #720: "Reach of one repeater". One star, drawn on the export's cell (CELL_RES): where it +// was heard, where the hunters drove without hearing it, and what the +// repeater reaches in between. That last part is thought from the repeater +// (Kasper, 27 September): along unbroken lines to where it was heard, in the +// wedges between neighbouring lines, and only where it sees over the ground. + +const hearing = (lat, lon, rssi, min = 0) => ({ sender_id: 'db11db11f7808b97', sender_kind: 'discover_pubkey', sender_role: 'Repeater', + lat, lon, rssi, hunter_name: 'kas', rx_at: `2026-09-07T12:${String(min).padStart(2, '0')}:00Z` }) +// A reception of another repeater: the hunters drove through its cell. +const drove = (lat, lon) => ({ sender_id: 'ffee0011aabbccdd', sender_kind: 'discover_pubkey', sender_role: 'Repeater', lat, lon, rssi: -90, hunter_name: 'kas', rx_at: '2026-09-07T12:00:00Z' }) +const node = { lat: 51.84, lon: 5.86 } +const flat = () => 10 + +// A ring of hearings around the node, about 2 km across. +const ring = [ + hearing(51.849, 5.86, -70), hearing(51.84, 5.875, -95), hearing(51.831, 5.86, -105), + hearing(51.84, 5.845, -112), hearing(51.846, 5.869, -85), hearing(51.834, 5.851, -100), +] +// Three hearings in one cell 1 km east of the node: a single line. +const east = [1, 2, 3].map((min) => hearing(51.84, 5.875, -94 - min, min)) +// Two lines, 2.4 km north and 2.2 km north-east, whose ends are 1.6 km apart. +const wedge = [hearing(51.862, 5.86, -80), hearing(51.855, 5.88, -85), hearing(51.862, 5.86, -81)] + +const starOf = (pts) => coverageStars(pts, { positionOf: () => node })[0] +// The place `km` from `o` at `deg` degrees from east, counter-clockwise. +const toward = (o, km, deg) => ({ + lat: o.lat + (km * Math.sin((deg * Math.PI) / 180)) / 110.574, + lon: o.lon + (km * Math.cos((deg * Math.PI) / 180)) / (111.32 * Math.cos((o.lat * Math.PI) / 180)), +}) +const ids = (cells) => cells.map((c) => c.id) +// The cells a straight segment passes through, in order, sampled every +// 80 m like the model's own lines. +const cellsAlong = (from, to, res = CELL_RES) => { + const steps = Math.max(1, Math.ceil(kmBetween(from, to) / sampleSteps(res).walkKm)) + const out = [] + for (let i = 0; i <= steps; i++) { + const id = hexCellAt(from.lat + ((to.lat - from.lat) * i) / steps, from.lon + ((to.lon - from.lon) * i) / steps, res) + if (out[out.length - 1] !== id) out.push(id) + } + return out +} +const centreOf = (id) => { + const r = hexBoundary(id).slice(0, 6) + return { lat: r.reduce((s, q) => s + q[0], 0) / 6, lon: r.reduce((s, q) => s + q[1], 0) / 6 } +} +// A lattice one resolution finer than the export's cell (LATTICE) around a repeater at the centre of cell (0, 0): lines +// east, (k, 0), north-east, (0, k), and south-east, (k, -k), run through +// the centres of their cells, about 140 m apart at 52°N. +const LATTICE = CELL_RES + 1 +const [, q0, r0] = hexCellAt(51.842, 5.86, LATTICE).split(':').map(Number) +const cell = (i, j) => `${LATTICE}:${q0 + i}:${r0 + j}` +const at = (i, j) => centreOf(cell(i, j)) +const latticeModel = (pts, dark = []) => { + const star = coverageStars(pts, { positionOf: () => at(0, 0) })[0] + return reachModel({ star, mapped: [...pts, ...dark], elevationAt: flat, res: LATTICE }).filled +} + +// The filled cells that lie on no line from the repeater: the wedges. +const offTheLines = (m) => { + const on = new Set(m.rays.flatMap((r) => cellsAlong(r.from, r))) + return m.filled.filter((c) => !on.has(c.id)) +} + +describe('reachModel: what was measured (#720)', () => { + it('gives each heard cell the tier of its strongest hearing', () => { + const pts = [hearing(51.849, 5.86, -70), hearing(51.8491, 5.8601, -100), ...ring.slice(1)] + const m = reachModel({ star: starOf(pts), mapped: pts }) + expect(m.heard.find((c) => c.id === hexCellAt(51.849, 5.86, CELL_RES))).toMatchObject({ rssi: -70, tier: 'hot' }) + }) + + it('draws one ray per heard cell, from the origin to its strongest hearing', () => { + const pts = [hearing(51.849, 5.86, -100), hearing(51.8491, 5.8601, -70), ...ring.slice(1)] + const m = reachModel({ star: starOf(pts), mapped: pts }) + expect(m.rays).toHaveLength(m.heard.length) + const toCell = m.rays.find((r) => hexCellAt(r.lat, r.lon, CELL_RES) === hexCellAt(51.849, 5.86, CELL_RES)) + expect(toCell).toMatchObject({ lat: 51.8491, lon: 5.8601, tier: 'hot' }) + }) + + it('marks a cell the hunters drove through without hearing it as mapped, not heard', () => { + const m = reachModel({ star: starOf(ring), mapped: [...ring, drove(51.95, 5.95)] }) + expect(ids(m.silent)).toEqual([hexCellAt(51.95, 5.95, CELL_RES)]) + }) + + it('counts what the map counts for the star', () => { + const star = starOf(ring) + const m = reachModel({ star, mapped: ring }) + expect(m.numbers.receptions).toBe(star.points.length) + expect(m.numbers.strongest).toBe(-70) + // The farthest hearings from 51.84,5.86: 1.0 km north and south, 1.03 km east and west. + expect(m.numbers.farthestKm).toBeCloseTo(1.03, 1) + }) + + // A relay-hash star has no registry name: it reads as the ticker reads it, + // '#' and the id (AGENTS.md §5.4 item 6), in the title and the file name. + it('names a relay-hash star with a #', () => { + const relay = ring.map((h) => ({ ...h, sender_id: 'db', sender_kind: 'path_hash' })) + const star = coverageStars(relay, { positionOf: () => null })[0] + expect(reachModel({ star, mapped: relay }).name).toBe('#db') + }) + // AGENTS.md §7 rule 2: the resolver names 2-byte ids for the points layer, + // but a relay id with no candidate in reach is not named by it. + it('does not name a 2-byte relay-hash star from the resolver cache, and does name a key', () => { + const relay = ring.map((h) => ({ ...h, sender_id: 'db11', sender_kind: 'path_hash' })) + const star = coverageStars(relay, { positionOf: () => null })[0] + expect(reachModel({ star, mapped: relay, cachedNameOf: () => 'Resolved' }).name).toBe('#db11') + expect(reachModel({ star: starOf(ring), mapped: ring, cachedNameOf: () => 'Resolved' }).name).toBe('Resolved') + }) + + it('keeps the advertised position and the estimate apart, and hangs the rays from the advertised one', () => { + const m = reachModel({ star: starOf(ring), mapped: ring }) + expect(m.advertised).toEqual({ lat: node.lat, lon: node.lon }) + expect(m.estimate).not.toBeNull() + expect(m.rays.every((r) => r.from.lat === node.lat && r.from.lon === node.lon)).toBe(true) + }) + + it('hangs the rays from the estimate when there is no advertised position', () => { + const star = coverageStars(ring, { positionOf: () => null })[0] + const m = reachModel({ star, mapped: ring }) + expect(m.advertised).toBeNull() + expect(m.rays[0].from).toEqual({ lat: star.origin.lat, lon: star.origin.lon }) + }) +}) + +describe('reachModel: what is filled in, thought from the repeater (#720)', () => { + it('fills the cells on the line from the repeater to where it was heard', () => { + const m = reachModel({ star: starOf(east), mapped: east, elevationAt: flat }) + const line = cellsAlong(node, { lat: 51.84, lon: 5.875 }) + const heard = new Set(ids(m.heard)) + expect(line.length).toBeGreaterThan(2) + expect(ids(m.filled).sort()).toEqual(line.filter((c) => !heard.has(c)).sort()) + }) + + it('does not fill across a cell driven through without hearing it', () => { + const between = cellsAlong(node, { lat: 51.84, lon: 5.875 }).at(-2) + const c = centreOf(between) + const m = reachModel({ star: starOf(east), mapped: [...east, drove(c.lat, c.lon)], elevationAt: flat }) + expect(ids(m.silent)).toEqual([between]) + expect(m.filled).toEqual([]) + }) + + it('fills a line again past a stretch where it was not heard (Kasper: heard at 1 2 3, not at 4 5, heard at 6 7 8)', () => { + // A row of cells straight east of the repeater, through their centres. + const start = centreOf(hexCellAt(51.842, 5.86, CELL_RES)) + const row = cellsAlong(start, { lat: start.lat, lon: start.lon + 0.07 }).map(centreOf) + const pts = [2, 4, 9, 11].map((k) => hearing(row[k].lat, row[k].lon, -100, k)) + const star = coverageStars(pts, { positionOf: () => start })[0] + const m = reachModel({ star, mapped: [...pts, drove(row[6].lat, row[6].lon), drove(row[7].lat, row[7].lon)], elevationAt: flat }) + const cell = (k) => hexCellAt(row[k].lat, row[k].lon, CELL_RES) + // Heard at 2, 4, 9 and 11, not at 6 and 7: 0 to 4 and 9 to 11 are + // reached; 5 and 8 lie between a shadow and a hearing, and stay open. + expect(ids(m.filled).sort()).toEqual([0, 1, 3, 10].map(cell).sort()) + }) + + it('fills between two lines past where neither was heard, and not in the shadow', () => { + // Lines east and north-east, each heard at 1, 2 and at 7 to 10 cells + // out, and not at 4 and 5. Their ends, 1.9 km out, are 1.9 km apart. + const heardAt = [1, 2, 7, 8, 9, 10], shadow = [4, 5] + const pts = heardAt.flatMap((k) => [at(k, 0), at(0, k)]).map((p, n) => hearing(p.lat, p.lon, -100, n)) + const dark = shadow.flatMap((k) => [at(k, 0), at(0, k)]).map((p) => drove(p.lat, p.lon)) + const filled = new Set(ids(latticeModel(pts, dark))) + // 8 and 9 cells out, between the lines: reached on both. + expect([cell(4, 4), cell(4, 5), cell(5, 4), cell(3, 5)].every((c) => filled.has(c))).toBe(true) + // 4 to 6 cells out, between the lines: the shadow, and the gap after it. + expect([cell(2, 2), cell(2, 3), cell(3, 2), cell(3, 3)].some((c) => filled.has(c))).toBe(false) + // On the lines themselves: reached up to 2 and from 7, open at 3 and 6. + expect([cell(0, 0), cell(3, 0), cell(0, 3), cell(6, 0), cell(0, 6)].map((c) => filled.has(c))).toEqual([true, false, false, false, false]) + }) + + it('takes a line to a cell on a longer line as part of that line', () => { + // A short line to 2 cells east, a little north of the long east line + // but in a cell it passes: the area runs between the long line and the + // north-east one, not between the short one and the north-east one. + const short = { lat: at(2, 0).lat + 0.0003, lon: at(2, 0).lon } + const pts = [at(10, 0), at(0, 10), short].map((p, n) => hearing(p.lat, p.lon, -100, n)) + expect(hexCellAt(short.lat, short.lon, LATTICE)).toBe(cell(2, 0)) + expect(ids(latticeModel(pts))).toContain(cell(4, 4)) + }) + + it('leaves the cell past the end of a line open, beside a longer one', () => { + // Lines south-east (8 cells) and east (3): the wedge between them is + // filled; the next cell east, past the east line's end, is not. + const pts = [at(8, -8), at(3, 0)].map((p, n) => hearing(p.lat, p.lon, -100, n)) + const filled = ids(latticeModel(pts)) + expect(filled).toContain(cell(3, -1)) + expect(filled).not.toContain(cell(4, 0)) + }) + + it('fills the wedge between two neighbouring lines whose ends are close', () => { + const m = reachModel({ star: starOf(wedge), mapped: wedge, elevationAt: flat }) + expect(m.heard).toHaveLength(2) + expect(offTheLines(m).length).toBeGreaterThan(0) + }) + + it('leaves the gap between two lines whose ends are more than 2 km apart', () => { + // 3.3 km north and 3.1 km east: the ends are 4.5 km apart. + const apart = [hearing(51.87, 5.86, -80), hearing(51.84, 5.905, -85), hearing(51.8702, 5.8602, -81)] + const m = reachModel({ star: starOf(apart), mapped: apart, elevationAt: flat }) + expect(REACH_EDGE_KM).toBe(2) + expect(m.filled.length).toBeGreaterThan(0) + expect(offTheLines(m)).toEqual([]) + }) + + it('does not fill a wedge that a cell driven through without hearing it lies in', () => { + // A cell in the middle of the wedge, on neither line. + const inWedge = hexCellAt(51.85395, 5.8646, CELL_RES) + const c = centreOf(inWedge) + const m = reachModel({ star: starOf(wedge), mapped: [...wedge, drove(c.lat, c.lon)], elevationAt: flat }) + expect(ids(m.silent)).toEqual([inWedge]) + expect(m.filled.length).toBeGreaterThan(0) + expect(offTheLines(m)).toEqual([]) + }) + + it('fills between neighbouring lines only, never the long way round', () => { + // Lines east (1.5 km), at 30° (0.5 km) and at 60° (1.5 km), from the + // centre of a cell so the east line keeps to one row. The outer two ends + // are 1.5 km apart, but the short line lies between them: the cell past + // its end is in no wedge. + const start = centreOf(hexCellAt(51.842, 5.86, CELL_RES)) + const fan = [toward(start, 1.5, 0), toward(start, 0.5, 30), toward(start, 1.5, 60)].map((p, n) => hearing(p.lat, p.lon, -90, n)) + const star = coverageStars(fan, { positionOf: () => start })[0] + const m = reachModel({ star, mapped: fan, elevationAt: flat }) + const past = toward(start, 0.97, 30) + expect(m.heard).toHaveLength(3) + expect(ids(m.filled)).not.toContain(hexCellAt(past.lat, past.lon, CELL_RES)) + }) + + it('leaves a cell behind a ridge unfilled, and still draws the hearing', () => { + const ridge = (lat, lon) => (lon > 5.862 && lon < 5.866 ? 80 : 10) + const open = reachModel({ star: starOf(east), mapped: east, elevationAt: flat }) + const hilly = reachModel({ star: starOf(east), mapped: east, elevationAt: ridge }) + expect(hilly.filled.length).toBeLessThan(open.filled.length) + expect(hilly.heard).toEqual(open.heard) + }) + + it('never fills a cell the hunters drove through without hearing it', () => { + // The repeater's own cell, which no line passes through between its ends. + const own = hexCellAt(node.lat, node.lon, CELL_RES) + const m = reachModel({ star: starOf(east), mapped: [...east, drove(node.lat, node.lon)], elevationAt: flat }) + expect(ids(m.silent)).toEqual([own]) + expect(m.filled.length).toBeGreaterThan(0) + expect(ids(m.filled)).not.toContain(own) + }) + + it('fills along the lines alone when the ground cannot be read', () => { + const ridge = (lat, lon) => (lon > 5.862 && lon < 5.866 ? 80 : 10) + const blind = reachModel({ star: starOf(east), mapped: east, elevationAt: null }) + const open = reachModel({ star: starOf(east), mapped: east, elevationAt: flat }) + expect(blind.filled.length).toBeGreaterThan(0) + expect(ids(blind.filled)).toEqual(ids(open.filled)) + expect(reachModel({ star: starOf(east), mapped: east, elevationAt: ridge }).filled.length).toBeLessThan(blind.filled.length) + }) + + it('colours a filled cell from the hearings nearest to it', () => { + const m = reachModel({ star: starOf(east), mapped: east, elevationAt: flat }) + expect(m.filled.every((c) => c.rssi <= -95 && c.rssi >= -97 && c.tier)).toBe(true) + }) +}) + +describe('lineOfSight (#720)', () => { + const R = { lat: 51.84, lon: 5.86 } + const far = { lat: 51.84, lon: 5.86 + 0.08 } // about 5.5 km east + // Ground that was not read (null, off the tiles) decides nothing: the line + // is open there, as it is without any terrain at all. + it('treats ground it cannot read as open, not as sea level', () => { + expect(lineOfSight(R, far, () => null)).toBe(true) + // The repeater's own ground unknown, 500 m everywhere else: read as sea + // level it would sit 470 m under the ground it looks over. + expect(lineOfSight(R, far, (lat, lon) => (lon < 5.87 ? null : 500))).toBe(true) + expect(lineOfSight(R, far, (lat, lon) => (lon < 5.87 ? 10 : 500))).toBe(false) + }) + it('sees over flat ground', () => { + expect(lineOfSight(R, far, flat)).toBe(true) + }) + it('does not see past a ridge higher than the line', () => { + const ridge = (lat, lon) => (Math.abs(lon - 5.90) < 0.003 ? 60 : 10) // 60 m high, halfway + expect(lineOfSight(R, far, ridge)).toBe(false) + }) + it('sees over a ridge the mast clears', () => { + const low = (lat, lon) => (Math.abs(lon - 5.90) < 0.003 ? 20 : 10) + expect(lineOfSight(R, far, low)).toBe(true) + expect(REPEATER_MAST_M).toBe(RAY_ALT_M) + }) + it('counts the earth bulging up between the two ends', () => { + // 20 km over flat ground. Two antennas at the ground are hidden from + // each other by the curve alone; a 30 m mast and a 1.5 m antenna see + // each other up to 27.6 km apart. + const a = { lat: 51.84, lon: 5.86 }, b = { lat: 51.84, lon: 5.86 + 0.29 } + expect(lineOfSight(a, b, () => 0, { fromM: 0, toM: 0 })).toBe(false) + expect(lineOfSight(a, b, () => 0)).toBe(true) + }) +}) + +describe('idwRssi (#720)', () => { + it('uses the 6 nearest hearings only', () => { + expect(IDW_NEAREST).toBe(6) + const at = { lat: 51.84, lon: 5.86 } + const six = Array.from({ length: 6 }, (_, i) => ({ lat: 51.84 + 0.001 * (i + 1), lon: 5.86, rssi: -80 })) + const far = { lat: 52.5, lon: 6.5, rssi: -130 } + expect(idwRssi(at, [...six, far])).toBeCloseTo(-80, 5) + }) + it('leans towards the nearer hearing', () => { + const at = { lat: 51.84, lon: 5.86 } + const v = idwRssi(at, [{ lat: 51.841, lon: 5.86, rssi: -80 }, { lat: 51.85, lon: 5.86, rssi: -110 }]) + expect(v).toBeGreaterThan(-90) + }) +}) + +// The walk along a line and the sampler over an area step finer than a cell, +// so no cell is stepped over; the steps follow the cell's size, so they stay +// right when the export's cell changes. +describe('sampleSteps (#720)', () => { + const lat = 52 + const innerKm = (res) => (hexSizeForRes(res) * Math.cos((lat * Math.PI) / 180) * Math.sqrt(3) / 2) / 1000 + it('steps inside a cell\'s inner radius at 52°N, at every resolution the grid has', () => { + for (const res of [6, 8, 10, 12]) { + const st = sampleSteps(res) + expect(st.walkKm, `res ${res}`).toBeLessThan(innerKm(res)) + expect(st.latDeg * 110.574, `res ${res}`).toBeLessThan(innerKm(res)) + expect(st.lonDeg * 111.32 * Math.cos((lat * Math.PI) / 180), `res ${res}`).toBeLessThan(innerKm(res)) + } + }) + it('scales with the cell', () => { + const r = hexSizeForRes(7) / hexSizeForRes(8) + expect(sampleSteps(7).walkKm / sampleSteps(8).walkKm).toBeCloseTo(r, 6) + }) +}) + +// Which star "Reach of one repeater" draws. A merged target row is one +// repeater under several ids, a prefix and the keys it starts; its hearings +// can form two stars (a key the registry does not place, say). That is one +// pick, and the star with the most hearings is the one drawn. +describe('pickReachStar (#720)', () => { + const star = (id, n, senders = [id]) => ({ id, origin: node, points: Array.from({ length: n }, (_, i) => ({ sender_id: senders[i % senders.length] })) }) + const key = 'db11db11f7808b97' + 'a'.repeat(48) + it('takes the star with the most hearings when one row picks two', () => { + const stars = [star(key, 3), star('db11db11f7808b97', 5), star('ffee0011', 4)] + expect(pickReachStar(stars, new Set(['db11db11f7808b97', key]))).toEqual({ star: stars[1] }) + }) + it('counts two repeaters picked apart as two', () => { + const stars = [star(key, 3), star('ffee0011', 4)] + expect(pickReachStar(stars, new Set([key, 'ffee0011']))).toEqual({ picked: 2 }) + }) + it('says none when nothing picked has a star', () => { + expect(pickReachStar([star(key, 3)], new Set(['ffee0011']))).toEqual({ picked: 0 }) + }) +}) diff --git a/web/exportrender.js b/web/exportrender.js index 5550f98e..9a463db8 100644 --- a/web/exportrender.js +++ b/web/exportrender.js @@ -8,7 +8,8 @@ // // Canvas and MapLibre glue, verified in the browser and by the e2e export // test; the rules it draws by are unit-tested in exportheard.test.js. -import { EXPORT_W, EXPORT_H, EXPORT_MAP_H, placeLabels, tint, scaleBar, fitText } from './exportheard.js' +import { EXPORT_W, EXPORT_H, EXPORT_MAP_H, placeLabels, tint, scaleBar, fitText, reliefNote, tierLegend } from './exportheard.js' +import { DEM_TILES, DEM_ENCODING, DEM_MAX_ZOOM, DEM_ATTRIBUTION, DEFAULT_EXAGGERATION, hillshadeFor, reportMapError } from './terrain.js' import { HUE_COUNT } from './coverage.js' import { STYLES } from './mapcore.js' @@ -28,6 +29,7 @@ export function lightTokens(doc = document) { const t = { bg: get('--ch-bg'), text: get('--ch-text'), muted: get('--ch-muted'), border: get('--ch-border'), accent: get('--ch-accent'), none: get('--ch-sig-none'), hot: get('--ch-sig-hot'), warm: get('--ch-sig-warm'), cool: get('--ch-sig-cool'), + tiers: Object.fromEntries(['hot', 'warm', 'mid', 'cool', 'cold', 'faint'].map((t) => [t, get(`--ch-sig-${t}`)])), hues: Array.from({ length: HUE_COUNT }, (_, i) => get(`--ch-hue-${i}`)), } probe.remove() @@ -41,10 +43,16 @@ const once = (map, ev, ms) => new Promise((resolve) => { map.once(ev, () => { clearTimeout(timer); resolve(true) }) }) -// renderHeardImage returns the finished 1200×1200 canvas. -// `text` is { title, sub, off }: the band's words, worked out by the caller. -export async function renderHeardImage({ model, routes, cells, text, tokens, maplib = globalThis.maplibregl, timeoutMs = 20000 }) { - const hue = (slot) => tokens.hues[slot] || tokens.text +const fc = (features) => ({ type: 'FeatureCollection', features }) +const poly = (ring, properties = {}) => ({ type: 'Feature', properties, geometry: { type: 'Polygon', coordinates: [ring] } }) +const line = (coordinates, properties = {}) => ({ type: 'Feature', properties, geometry: { type: 'LineString', coordinates } }) +const dot = (lon, lat, properties = {}) => ({ type: 'Feature', properties, geometry: { type: 'Point', coordinates: [lon, lat] } }) + +// withMap runs `draw(map)` on an offscreen map in the light style and +// removes the map afterwards, however `draw` ends. The hosted style, or the +// bare ground when it cannot be had: the lines and the band still say what +// was heard. +async function withMap({ tokens, maplib, timeoutMs }, draw) { const box = document.createElement('div') box.style.cssText = `position:fixed;left:-${EXPORT_W * 10}px;top:0;width:${EXPORT_W}px;height:${EXPORT_MAP_H}px;pointer-events:none;` document.body.append(box) @@ -52,19 +60,56 @@ export async function renderHeardImage({ model, routes, cells, text, tokens, map container: box, style: EXPORT_STYLE, interactive: false, attributionControl: false, fadeDuration: 0, preserveDrawingBuffer: true, pixelRatio: 1, }) + // As on the live map: a DEM tile that does not arrive is not worth a + // console line (terrain.js). + map.on('error', reportMapError) try { - // The hosted style, or the bare ground when it cannot be had: the lines - // and the band still say what was heard. if (!(await once(map, 'load', timeoutMs / 2))) { map.setStyle(bareStyle(tokens.bg)) await once(map, 'styledata', timeoutMs / 2) } - const fc = (features) => ({ type: 'FeatureCollection', features }) - const poly = (ring) => ({ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [ring] } }) + return await draw(map) + } finally { + map.remove() + box.remove() + } +} + +// addRelief shades the terrain under the data, below `beforeId`, at the +// exaggeration from Settings, as the 3D view shades it (terrain.js). Called +// once the camera is on the subject, so no tile is fetched for the world +// view the map starts on. +function addRelief(map, exaggeration, beforeId) { + map.addSource('dem', { type: 'raster-dem', tileSize: 256, maxzoom: DEM_MAX_ZOOM, encoding: DEM_ENCODING, tiles: [DEM_TILES] }) + map.addLayer({ id: 'hillshade', type: 'hillshade', source: 'dem', paint: { 'hillshade-exaggeration': hillshadeFor(exaggeration) } }, beforeId) +} + +// snapshot waits for the map to settle and starts the image with it: the +// paper ground, the map on top, and the scale bar and attribution over it. +async function snapshot(map, tokens, timeoutMs) { + await once(map, 'idle', timeoutMs) + const canvas = document.createElement('canvas') + canvas.width = EXPORT_W + canvas.height = EXPORT_H + const ctx = canvas.getContext('2d') + ctx.fillStyle = tokens.bg + ctx.fillRect(0, 0, EXPORT_W, EXPORT_H) + ctx.drawImage(map.getCanvas(), 0, 0, EXPORT_W, EXPORT_MAP_H) + const c = map.getCenter() + const mPerPx = 40075016.686 * Math.cos((c.lat * Math.PI) / 180) / (512 * 2 ** map.getZoom()) + return { canvas, ctx, bar: scaleBar(mPerPx) } +} + +// renderHeardImage returns the finished 1200×1200 canvas. +// `text` is { title, sub, off }: the band's words, worked out by the caller; +// `exaggeration` is the relief's, from Settings. +export function renderHeardImage({ model, routes, cells, text, tokens, exaggeration = DEFAULT_EXAGGERATION, maplib = globalThis.maplibregl, timeoutMs = 20000 }) { + const hue = (slot) => tokens.hues[slot] || tokens.text + return withMap({ tokens, maplib, timeoutMs }, async (map) => { map.addSource('cells', { type: 'geojson', data: fc(cells.map(poly)) }) map.addLayer({ id: 'cells', type: 'fill', source: 'cells', paint: { 'fill-color': tokens.none, 'fill-opacity': 0.3 } }) map.addLayer({ id: 'cells-line', type: 'line', source: 'cells', paint: { 'line-color': tokens.none, 'line-width': 0.8, 'line-opacity': 0.8 } }) - map.addSource('route', { type: 'geojson', data: fc(routes.map((c) => ({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: c } }))) }) + map.addSource('route', { type: 'geojson', data: fc(routes.map((c) => line(c))) }) map.addLayer({ id: 'route', type: 'line', source: 'route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': tokens.none, 'line-width': 2.5, 'line-opacity': 0.75 } }) // Thin rays first, so a strong one is never drawn under a weak one. @@ -72,8 +117,8 @@ export async function renderHeardImage({ model, routes, cells, text, tokens, map for (const s of model.drawn) { for (const r of s.rays) { const c = tint(hue(s.slot), r.s) - rays.push({ type: 'Feature', properties: { c, w: r.w * 0.6, op: r.op }, geometry: { type: 'LineString', coordinates: [[s.origin.lon, s.origin.lat], [r.lon, r.lat]] } }) - dots.push({ type: 'Feature', properties: { c, v: r.rssi }, geometry: { type: 'Point', coordinates: [r.lon, r.lat] } }) + rays.push(line([[s.origin.lon, s.origin.lat], [r.lon, r.lat]], { c, w: r.w * 0.6, op: r.op })) + dots.push(dot(r.lon, r.lat, { c, v: r.rssi })) } } rays.sort((a, b) => a.properties.w - b.properties.w) @@ -90,25 +135,162 @@ export async function renderHeardImage({ model, routes, cells, text, tokens, map for (const s of model.drawn) { b.extend([s.origin.lon, s.origin.lat]); for (const r of s.rays) b.extend([r.lon, r.lat]) } map.fitBounds(b, { padding: { top: 60, bottom: 60, left: 60, right: 200 }, maxZoom: 14, animate: false }) } - await once(map, 'idle', timeoutMs) - - const canvas = document.createElement('canvas') - canvas.width = EXPORT_W - canvas.height = EXPORT_H - const ctx = canvas.getContext('2d') - ctx.fillStyle = tokens.bg - ctx.fillRect(0, 0, EXPORT_W, EXPORT_H) - ctx.drawImage(map.getCanvas(), 0, 0, EXPORT_W, EXPORT_MAP_H) + addRelief(map, exaggeration, 'cells') + const { canvas, ctx, bar } = await snapshot(map, tokens, timeoutMs) drawGlyphsAndNames(ctx, map, model, hue, tokens) - const c = map.getCenter() - const mPerPx = 40075016.686 * Math.cos((c.lat * Math.PI) / 180) / (512 * 2 ** map.getZoom()) - drawMapFurniture(ctx, scaleBar(mPerPx), tokens) - drawBand(ctx, model, text, tokens) + drawMapFurniture(ctx, bar, tokens, exaggeration) + const cols = [ + [String(model.numbers.repeaters), 'repeaters'], + [String(model.numbers.receptions), 'receptions'], + [`${model.numbers.farthestKm.toFixed(1)} km`, 'farthest'], + ] + heardLegend(ctx, drawBand(ctx, { ...text, cols }, tokens), tokens) return canvas - } finally { - map.remove() - box.remove() + }) +} + +// How strongly a heard cell is filled, by its tier: a strong cell reads +// first, a faint one stays a tint. The reference render's values. +const HEARD_OPACITY = { hot: 0.7, warm: 0.58, mid: 0.46, cool: 0.34, cold: 0.26, faint: 0.19 } + +// addCells draws the measured cells on the RSSI tiers: not heard in grey, +// heard in the tier of its strongest reception, stronger more opaque. The +// reach export's filled-in cells go between the two, lighter than heard: +// what the repeater reaches, not what was measured. +function addCells(map, { silent, filled, heard }, tokens) { + const tierC = (t) => tokens.tiers[t] || tokens.none + map.addSource('silent', { type: 'geojson', data: fc(silent.map((c) => poly(c.ring))) }) + map.addLayer({ id: 'silent', type: 'fill', source: 'silent', paint: { 'fill-color': tokens.none, 'fill-opacity': 0.32 } }) + map.addLayer({ id: 'silent-line', type: 'line', source: 'silent', paint: { 'line-color': tokens.none, 'line-width': 0.8, 'line-opacity': 0.8 } }) + if (filled) { + map.addSource('filled', { type: 'geojson', data: fc(filled.map((c) => poly(c.ring, { c: tierC(c.tier) }))) }) + map.addLayer({ id: 'filled', type: 'fill', source: 'filled', paint: { 'fill-color': ['get', 'c'], 'fill-opacity': 0.3 } }) + map.addLayer({ id: 'filled-line', type: 'line', source: 'filled', paint: { 'line-color': tokens.bg, 'line-width': 0.8, 'line-opacity': 0.7 } }) } + map.addSource('heard', { type: 'geojson', data: fc(heard.map((c) => poly(c.ring, { c: tierC(c.tier), a: HEARD_OPACITY[c.tier] || 0.3 }))) }) + map.addLayer({ id: 'heard', type: 'fill', source: 'heard', paint: { 'fill-color': ['get', 'c'], 'fill-opacity': ['get', 'a'] } }) + map.addLayer({ id: 'heard-line', type: 'line', source: 'heard', paint: { 'line-color': ['get', 'c'], 'line-width': 1, 'line-opacity': 0.9 } }) +} + +// addDots draws a dot at every reception in its tier, the weakest first so a +// strong one is never under a weak one. +function addDots(map, dots, tokens) { + const tierC = (t) => tokens.tiers[t] || tokens.none + const sorted = dots.slice().sort((a, b) => a.rssi - b.rssi) + map.addSource('dots', { type: 'geojson', data: fc(sorted.map((d) => dot(d.lon, d.lat, { c: tierC(d.tier) }))) }) + map.addLayer({ id: 'dots', type: 'circle', source: 'dots', paint: { 'circle-color': ['get', 'c'], 'circle-radius': 3.2, 'circle-stroke-color': tokens.bg, 'circle-stroke-width': 0.8 } }) +} + +// renderReachImage (#720) returns the finished 1200×1200 canvas for one +// repeater: exportreach.js's model on the RSSI tiers, over the relief. The +// relief is exaggerated; the fill was checked against the real heights. +export function renderReachImage({ model, text, tokens, exaggeration = DEFAULT_EXAGGERATION, maplib = globalThis.maplibregl, timeoutMs = 20000 }) { + const tierC = (t) => tokens.tiers[t] || tokens.none + return withMap({ tokens, maplib, timeoutMs }, async (map) => { + addCells(map, model, tokens) + map.addSource('rays', { type: 'geojson', data: fc(model.rays.map((r) => line([[r.from.lon, r.from.lat], [r.lon, r.lat]], { c: tierC(r.tier) }))) }) + map.addLayer({ id: 'rays', type: 'line', source: 'rays', paint: { 'line-color': ['get', 'c'], 'line-width': 1.4, 'line-opacity': 0.75 } }) + addDots(map, model.dots, tokens) + if (model.advertised && model.estimate) { + map.addSource('offset', { type: 'geojson', data: line([[model.advertised.lon, model.advertised.lat], [model.estimate.lon, model.estimate.lat]]) }) + map.addLayer({ id: 'offset', type: 'line', source: 'offset', paint: { 'line-color': tokens.text, 'line-width': 1.5, 'line-dasharray': [2, 2] } }) + } + const o = model.origin + const b = new maplib.LngLatBounds([o.lon, o.lat], [o.lon, o.lat]) + for (const d of model.dots) b.extend([d.lon, d.lat]) + if (model.estimate) b.extend([model.estimate.lon, model.estimate.lat]) + map.fitBounds(b, { padding: 80, maxZoom: 14, animate: false }) + addRelief(map, exaggeration, 'silent') + + const { canvas, ctx, bar } = await snapshot(map, tokens, timeoutMs) + drawPositions(ctx, map, model, tokens) + drawMapFurniture(ctx, bar, tokens, exaggeration) + const cols = [ + [String(model.numbers.receptions), 'receptions'], + [`${model.numbers.strongest} dBm`, 'strongest'], + [`${model.numbers.farthestKm.toFixed(1)} km`, 'farthest'], + ] + reachLegend(ctx, drawBand(ctx, { ...text, cols }, tokens), tokens) + return canvas + }) +} + +// renderSelectionImage returns the finished 1200×1200 canvas for what the +// map's filters select (exportselection.js): heard and not heard, the nodes +// it was heard from where the registry places them, over the relief. Fitted +// to the receptions: a node outside that frame is left off. +export function renderSelectionImage({ model, text, tokens, exaggeration = DEFAULT_EXAGGERATION, maplib = globalThis.maplibregl, timeoutMs = 20000 }) { + return withMap({ tokens, maplib, timeoutMs }, async (map) => { + addCells(map, model, tokens) + addDots(map, model.dots, tokens) + const first = model.dots[0] + const b = new maplib.LngLatBounds([first.lon, first.lat], [first.lon, first.lat]) + for (const d of model.dots) b.extend([d.lon, d.lat]) + map.fitBounds(b, { padding: 80, maxZoom: 14, animate: false }) + addRelief(map, exaggeration, 'silent') + + const { canvas, ctx, bar } = await snapshot(map, tokens, timeoutMs) + drawNodes(ctx, map, model.nodes, tokens) + drawMapFurniture(ctx, bar, tokens, exaggeration) + const cols = [ + [String(model.numbers.receptions), 'receptions'], + [String(model.numbers.heardCells), 'cells heard'], + [`${model.numbers.share}%`, 'of the cells driven'], + ] + reachLegend(ctx, drawBand(ctx, { ...text, cols }, tokens), tokens, { targets: model.nodes.length > 0, estimate: false }) + return canvas + }) +} + +// A ▲ at `p`, the mark of an advertised position. +function triangle(ctx, p, tokens) { + ctx.beginPath(); ctx.moveTo(p.x, p.y - 10); ctx.lineTo(p.x + 10, p.y + 8); ctx.lineTo(p.x - 10, p.y + 8); ctx.closePath() + ctx.fillStyle = tokens.text; ctx.strokeStyle = tokens.bg; ctx.lineWidth = 2 + ctx.fill(); ctx.stroke() +} + +// Each node inside the frame: its ▲, and its name where it fits +// (placeLabels, the most-heard first). +function drawNodes(ctx, map, nodes, tokens) { + const at = nodes.map((n) => ({ n, p: map.project([n.lon, n.lat]) })) + .filter(({ p }) => p.x >= 0 && p.x <= EXPORT_W && p.y >= 0 && p.y <= EXPORT_MAP_H) + ctx.save() + ctx.font = `600 14px ${FONT}` + ctx.textBaseline = 'middle' + ctx.lineJoin = 'round' + for (const { p } of at) triangle(ctx, p, tokens) + const kept = placeLabels(at.map(({ n, p }) => ({ id: n.id, x: p.x, y: p.y, label: n.name, n: n.n })), + { measure: (t) => ctx.measureText(t).width, width: EXPORT_W, height: EXPORT_MAP_H, dx: 14 }) + ctx.fillStyle = tokens.text + for (const { n, p } of at) { + if (!kept.has(n.id)) continue + halo(ctx, tokens.bg, (how) => (how === 'stroke' ? ctx.strokeText(n.name, p.x + 14, p.y) : ctx.fillText(n.name, p.x + 14, p.y))) + } + ctx.restore() +} + +// The ▲ labelled "adv." at the advertised position and the ● labelled +// "est." at the RSSI estimate; without an advertised position the ● is +// where the rays start. +function drawPositions(ctx, map, model, tokens) { + ctx.save() + ctx.font = `14px ${FONT}` + ctx.textBaseline = 'middle' + ctx.lineJoin = 'round' + if (model.advertised) { + const p = map.project([model.advertised.lon, model.advertised.lat]) + triangle(ctx, p, tokens) + halo(ctx, tokens.bg, (how) => (how === 'stroke' ? ctx.strokeText('adv.', p.x + 14, p.y) : ctx.fillText('adv.', p.x + 14, p.y))) + } + if (model.estimate) { + const p = map.project([model.estimate.lon, model.estimate.lat]) + ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, Math.PI * 2) + ctx.fillStyle = tokens.text; ctx.strokeStyle = tokens.bg; ctx.lineWidth = 2 + ctx.fill(); ctx.stroke() + ctx.textAlign = 'right' + halo(ctx, tokens.bg, (how) => (how === 'stroke' ? ctx.strokeText('est.', p.x - 12, p.y) : ctx.fillText('est.', p.x - 12, p.y))) + } + ctx.restore() } // A halo in the paper's colour (tokens.bg) under text or a glyph, so it @@ -151,7 +333,9 @@ function drawGlyphsAndNames(ctx, map, model, hue, tokens) { ctx.textBaseline = 'alphabetic' } -function drawMapFurniture(ctx, bar, tokens) { +// The scale bar, with how much the relief is exaggerated beside it, and the +// attribution over the map, the terrain tiles' included. +function drawMapFurniture(ctx, bar, tokens, exaggeration) { const y = EXPORT_MAP_H - 16 ctx.save() ctx.strokeStyle = tokens.text @@ -162,7 +346,9 @@ function drawMapFurniture(ctx, bar, tokens) { ctx.font = `13px ${FONT}` ctx.fillStyle = tokens.text halo(ctx, tokens.bg, (how) => (how === 'stroke' ? ctx.strokeText(bar.label, 20, y - 12) : ctx.fillText(bar.label, 20, y - 12))) - const attr = '© OpenFreeMap © OpenMapTiles © OpenStreetMap contributors' + const note = reliefNote(exaggeration) + if (note) halo(ctx, tokens.bg, (how) => (how === 'stroke' ? ctx.strokeText(note, 32 + bar.px, y + 4) : ctx.fillText(note, 32 + bar.px, y + 4))) + const attr = `© OpenFreeMap © OpenMapTiles © OpenStreetMap contributors · ${DEM_ATTRIBUTION}` ctx.font = `11px ${FONT}` ctx.fillStyle = tokens.muted ctx.textAlign = 'right' @@ -198,8 +384,9 @@ function drawMark(ctx, x, y, tokens) { } // The band under the map: the mark and the site, the title, the window and -// the hunters, what was left off, three numbers, and the legend. -function drawBand(ctx, model, text, tokens) { +// the hunters, what was left off, and three numbers as [number, word] pairs. +// Returns the baseline the legend starts on; each export draws its own. +function drawBand(ctx, { title, sub, off, cols }, tokens) { const top = EXPORT_MAP_H const L = 44, R = EXPORT_W - 44 ctx.save() @@ -212,22 +399,17 @@ function drawBand(ctx, model, text, tokens) { ctx.font = `700 15px ${FONT}` ctx.fillText('mesh-hunter.eu', L + 30, top + 39) ctx.font = `700 36px ${FONT}` - ctx.fillText(text.title, L, top + 92) + ctx.fillText(title, L, top + 92) ctx.fillStyle = tokens.muted ctx.font = `16px ${FONT}` - ctx.fillText(text.sub, L, top + 120) + ctx.fillText(sub, L, top + 120) let y = top + 146 - if (text.off) { + if (off) { ctx.font = `14px ${FONT}` - ctx.fillText(fitText(text.off, R - L, (t) => ctx.measureText(t).width), L, top + 142) + ctx.fillText(fitText(off, R - L, (t) => ctx.measureText(t).width), L, top + 142) y = top + 170 } // Three numbers, right-aligned as a group, each centred over its word. - const cols = [ - [String(model.numbers.repeaters), 'repeaters'], - [String(model.numbers.receptions), 'receptions'], - [`${model.numbers.farthestKm.toFixed(1)} km`, 'farthest'], - ] const widths = cols.map(([num, lab]) => { ctx.font = `700 34px ${FONT}`; const a = ctx.measureText(num).width ctx.font = `15px ${FONT}`; const b = ctx.measureText(lab).width @@ -246,8 +428,26 @@ function drawBand(ctx, model, text, tokens) { x -= widths[i] + 44 } ctx.textAlign = 'left' - // The legend: a ▲ with a strong and a weak ray, the route, a mapped cell - // and the ● of an estimate. + ctx.restore() + return y +} + +// A hex swatch for a legend, 14×16, its top at y - 12. +function hexSwatch(ctx, x, y, colour, alpha) { + ctx.save() + ctx.fillStyle = colour + ctx.globalAlpha = alpha + ctx.beginPath() + ;[[7, 0], [14, 4], [14, 12], [7, 16], [0, 12], [0, 4]].forEach(([px, py], i) => (i ? ctx.lineTo(x + px, y - 12 + py) : ctx.moveTo(x + px, y - 12 + py))) + ctx.closePath(); ctx.fill() + ctx.restore() +} + +// Repeaters heard: a ▲ with a strong and a weak ray, the route, a mapped +// cell and the ● of an estimate. +function heardLegend(ctx, y, tokens) { + const L = 44 + ctx.save() ctx.font = `13px ${FONT}` ctx.fillStyle = tokens.muted let lx = L @@ -263,13 +463,7 @@ function drawBand(ctx, model, text, tokens) { const say = (t) => { ctx.fillText(t, lx, y); lx += ctx.measureText(t).width + 18 } lx += 46 say('a repeater in its own colour, a line to every place it was heard: strong full and thick, weak light and thin') - // A mapped cell. - ctx.fillStyle = tokens.none - ctx.globalAlpha = 0.45 - ctx.beginPath() - ;[[7, 0], [14, 4], [14, 12], [7, 16], [0, 12], [0, 4]].forEach(([px, py], i) => (i ? ctx.lineTo(lx + px, y - 12 + py) : ctx.moveTo(lx + px, y - 12 + py))) - ctx.closePath(); ctx.fill() - ctx.globalAlpha = 1 + hexSwatch(ctx, lx, y, tokens.none, 0.45) lx += 20 ctx.fillStyle = tokens.muted say('mapped') @@ -284,3 +478,45 @@ function drawBand(ctx, model, text, tokens) { say('estimated position (RSSI)') ctx.restore() } + + +// Reach of one repeater: the RSSI tiers on the first row; mapped not heard, +// the ▲ and the ● on the second. No route, and no line for the filled-in +// cells: where the hunters drove shows in the heard and the mapped cells, +// and the lighter cells speak for themselves (Kasper, 27 September). +// The selection export has no ●, and a ▲ only for targets the registry +// places: `targets` and `estimate` leave those out. +function reachLegend(ctx, y, tokens, { targets = true, estimate = true } = {}) { + let lx = 44 + ctx.save() + ctx.font = `13px ${FONT}` + ctx.fillStyle = tokens.muted + const say = (t, gap = 14) => { ctx.fillStyle = tokens.muted; ctx.fillText(t, lx, y); lx += ctx.measureText(t).width + gap } + say('RSSI') + for (const [tier, label] of tierLegend()) { + ctx.fillStyle = tokens.tiers[tier] + ctx.beginPath(); ctx.arc(lx + 6, y - 5, 6, 0, Math.PI * 2); ctx.fill() + ctx.strokeStyle = tokens.bg; ctx.lineWidth = 1.5; ctx.stroke() + lx += 17 + say(label) + } + say('dBm') + y += 26 + lx = 44 + hexSwatch(ctx, lx, y, tokens.none, 0.45) + lx += 20 + say('mapped, not heard', 18) + if (targets) { + ctx.fillStyle = tokens.text + ctx.beginPath(); ctx.moveTo(lx + 6, y - 11); ctx.lineTo(lx + 12, y); ctx.lineTo(lx, y); ctx.closePath(); ctx.fill() + lx += 18 + say('advertised position', 18) + } + if (estimate) { + ctx.fillStyle = tokens.text + ctx.beginPath(); ctx.arc(lx + 5, y - 5, 5, 0, Math.PI * 2); ctx.fill() + lx += 16 + say('estimated position (RSSI)') + } + ctx.restore() +} diff --git a/web/exportselection.js b/web/exportselection.js new file mode 100644 index 00000000..9b75d36d --- /dev/null +++ b/web/exportselection.js @@ -0,0 +1,107 @@ +// The third export: what the map's filters select (targets, time window, +// hunters, packet types) in cells finer than the map's (selectionCellSize). +// Where it was heard, in the tier +// of the strongest reception per cell, and where the same hunters drove +// without hearing it. It does not matter what kind of node was heard, and +// nothing is filled in: without one repeater there is no origin to think +// from, as #720 does (exportreach.js). +import { hexCellAt, hexBoundary } from './hexgrid.js' +import { rssiTier } from './signal.js' +import { CELL_RES, idLabel, isHashId, resForSize } from './exportheard.js' +import { FILTER_PACKET_TYPES, SENDER_ID_CLASSES } from './packettypes.js' + +const ringOf = (id) => hexBoundary(id).map(([lat, lon]) => [lon, lat]) +const placed = (p) => Number.isFinite(p.lat) && Number.isFinite(p.lon) + +// The cell size, in Mercator units from centre to corner, by the map's +// Leaflet zoom: the sizes the app's zoom bands gave before #734. Finer than +// the cells the map draws, on purpose (Kasper, 30 September 2026): about a +// sixth of a map cell across at zoom 11 and two thirds at zoom 14, so a +// picture of a drive shows the street it was heard on. Kept as sizes, so a +// change to the grid's table does not change the picture. +const SELECTION_CELL = [[19, 3], [18, 5], [17, 10], [16, 20], [15, 40], [13, 90], [11, 180], [9, 360], [7, 720], [-Infinity, 1500]] +export function selectionCellSize(leafletZoom) { + return SELECTION_CELL.find(([z]) => leafletZoom >= z)[1] +} +export const selectionRes = (leafletZoom) => resForSize(selectionCellSize(leafletZoom)) + +// selectionModel: `points` are the receptions the filters select; `mapped` +// every reception of the hunters who heard any of them, in the same window, +// for the cells they drove through; `nodes` the nodes to draw: the picked +// targets and the nodes the receptions were placed on (heardNodes). +export function selectionModel({ points, mapped, nodes = [], res = CELL_RES }) { + const best = new Map() + for (const p of points) { + if (!placed(p)) continue + const id = hexCellAt(p.lat, p.lon, res) + const b = best.get(id) + if (!b || p.rssi > b.rssi) best.set(id, p) + } + const heard = [...best].map(([id, p]) => ({ id, ring: ringOf(id), rssi: p.rssi, tier: rssiTier(p.rssi) })) + const driven = new Set(best.keys()) + for (const p of mapped) if (placed(p)) driven.add(hexCellAt(p.lat, p.lon, res)) + const silent = [...driven].filter((id) => !best.has(id)).map((id) => ({ id, ring: ringOf(id) })) + const dots = points.filter(placed).map((p) => ({ lat: p.lat, lon: p.lon, rssi: p.rssi, tier: rssiTier(p.rssi) })) + return { + heard, silent, dots, nodes, + numbers: { + receptions: dots.length, + heardCells: heard.length, + drivenCells: driven.size, + share: driven.size ? Math.round((100 * heard.length) / driven.size) : 0, + }, + } +} + +// heardNodes: the registry nodes the receptions were placed on, with how +// many each. A relay hash of 1 to 3 bytes is placed by reach +// (attributeReception, #661), a key or a prefix of one by the node it alone +// starts (registryMatcher, #723): the same rules the map's layers use. +export function heardNodes(points, { attributionOf, registryNodeOf }) { + const out = new Map() + for (const p of points) { + const reach = attributionOf(p) + const node = reach ? (reach.rule === 'node' ? reach.node : null) : registryNodeOf(p) + if (!node) continue + const id = String(node.pubkey).toLowerCase() + const had = out.get(id) + if (had) had.n++ + else out.set(id, { id, name: node.name || idLabel(id), lat: node.lat, lon: node.lon, n: 1 }) + } + return [...out.values()] +} + +// targetPositions: the picked target ids the registry places, each on the +// one node it names by the rules the map pairs by (registryNodeOf, a +// registryMatcher): a key exactly, a prefix from 2 bytes when it starts one +// key only. A relay id of 1 to 3 bytes is placed by reach, per hearing +// (AGENTS.md §7), so it is never placed here; heardNodes carries it. `pick` +// is the id as picked, `id` the node's key. +export function targetPositions(ids, registryNodeOf) { + const out = [] + for (const id of ids) { + const pick = String(id).toLowerCase() + if (isHashId(pick)) continue + const node = registryNodeOf({ sender_id: pick, sender_kind: pick.length === 64 ? 'advert_pubkey' : 'discover_pubkey' }) + if (!node) continue + const key = String(node.pubkey).toLowerCase() + out.push({ id: key, pick, name: node.name || idLabel(key), lat: node.lat, lon: node.lon }) + } + return out +} + +// selectionTitle: what the picture shows, in the filter's own words: the +// targets by name or the typed prefix, No path, then the packet types and id +// sizes picked in the bar, with the bar's labels. Nothing picked is +// everything that was heard. +export function selectionTitle({ names = [], prefix = '', noPath = false, types = '', idClasses = '' }) { + const label = (list, v) => (list.find((o) => o.value === v) || { label: v }).label + const parts = [ + names.join(' + '), + prefix ? `Starts with ${prefix}` : '', + noPath ? 'No path' : '', + ...String(types).split(',').filter(Boolean).map((v) => label(FILTER_PACKET_TYPES, v)), + ...String(idClasses).split(',').filter(Boolean).map((v) => label(SENDER_ID_CLASSES, v)), + ].filter(Boolean) + return parts.length ? parts.join(' · ') : 'Everything heard' +} diff --git a/web/exportselection.test.js b/web/exportselection.test.js new file mode 100644 index 00000000..db612307 --- /dev/null +++ b/web/exportselection.test.js @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import { selectionModel, targetPositions, selectionTitle, heardNodes, selectionCellSize, selectionRes } from './exportselection.js' +import { hexCellAt, hexSizeForRes } from './hexgrid.js' +import { registryMatcher } from './nodelayer.js' +import { CELL_RES } from './exportheard.js' + +// The third export: what the map's filters select, heard and not heard. +// It does not matter what kind of node was heard. + +const rx = (lat, lon, rssi, sender = 'aa11', hunter = 'kas') => ({ lat, lon, rssi, sender_id: sender, hunter_name: hunter }) +const cell = (lat, lon) => hexCellAt(lat, lon, CELL_RES) + +describe('selectionModel', () => { + it('gives each heard cell the tier of its strongest reception', () => { + const m = selectionModel({ points: [rx(51.84, 5.86, -105), rx(51.8401, 5.8601, -78)], mapped: [] }) + expect(m.heard).toHaveLength(1) + expect(m.heard[0]).toMatchObject({ id: cell(51.84, 5.86), rssi: -78, tier: 'hot' }) + }) + + it('marks the cells the hunters drove through without a selected reception as not heard', () => { + const points = [rx(51.84, 5.86, -90)] + const mapped = [rx(51.84, 5.86, -95, 'bb22'), rx(51.87, 5.90, -95, 'bb22')] + const m = selectionModel({ points, mapped }) + expect(m.silent.map((c) => c.id)).toEqual([cell(51.87, 5.90)]) + }) + + it('counts the receptions, the cells heard and the share of the cells driven', () => { + const points = [rx(51.84, 5.86, -90), rx(51.8401, 5.8601, -91), rx(51.86, 5.88, -100)] + const mapped = [...points, rx(51.87, 5.90, -95, 'bb22'), rx(51.89, 5.92, -95, 'bb22')] + const m = selectionModel({ points, mapped }) + expect(m.numbers).toEqual({ receptions: 3, heardCells: 2, drivenCells: 4, share: 50 }) + }) + + it('leaves out a reception without a position', () => { + const m = selectionModel({ points: [rx(51.84, 5.86, -90), { rssi: -80, sender_id: 'aa11' }], mapped: [{ rssi: -80 }] }) + expect(m.numbers).toMatchObject({ receptions: 1, heardCells: 1, drivenCells: 1 }) + }) + + it('has nothing heard and a share of 0 when the filters select nothing', () => { + const m = selectionModel({ points: [], mapped: [rx(51.84, 5.86, -90, 'bb22')] }) + expect(m.heard).toEqual([]) + expect(m.numbers.share).toBe(0) + // Nothing in view at all: nothing driven either, and still a number. + expect(selectionModel({ points: [], mapped: [] }).numbers.share).toBe(0) + }) +}) + +describe('targetPositions', () => { + const nodes = [ + { pubkey: 'DB11db11f7808b97' + 'a'.repeat(48), name: 'Dikkeboom', lat: 51.84, lon: 5.86 }, + { pubkey: 'db12' + 'b'.repeat(60), name: 'Other', lat: 51.9, lon: 5.9 }, + { pubkey: 'ee77' + 'c'.repeat(60), lat: 51.7, lon: 5.7 }, + ] + const place = (ids) => targetPositions(ids, registryMatcher(nodes)) + it('places a target on the one node its id starts', () => { + expect(place(['db11db11f7808b97'])).toEqual([{ id: nodes[0].pubkey.toLowerCase(), pick: 'db11db11f7808b97', name: 'Dikkeboom', lat: 51.84, lon: 5.86 }]) + }) + it('leaves out a target that starts no node, or more than one', () => { + expect(place(['ffee0011aabb'])).toEqual([]) + expect(place(['db1'])).toEqual([]) + }) + // AGENTS.md §7: a relay id is placed by reach, per hearing, never by the + // one key in view it happens to start. + it('does not place a relay id of 1 to 3 bytes, even on the one node it starts', () => { + expect(place(['ee'])).toEqual([]) + expect(place(['ee77cc'])).toEqual([]) + }) + it('reads a nameless node by its key\'s prefix, never as "undefined"', () => { + expect(place(['ee77cccccccc'])[0].name).toBe('ee77cccc') + }) +}) + +// The export's cells are finer than the map's on purpose (Kasper, 30 +// September 2026): the sizes by zoom the app's bands gave before #734, as a +// size, so a change to the grid's table does not change the picture. +describe('the selection export\'s cell', () => { + const mapCell = (z) => (14 * 156543.03392) / 2 ** z // the server's size at Leaflet zoom z + it('is the size of the app\'s old band at the map\'s zoom', () => { + expect(selectionCellSize(11)).toBe(180) + expect(selectionCellSize(14)).toBe(90) + expect(selectionCellSize(19)).toBe(3) + expect(selectionCellSize(2)).toBe(1500) + }) + it('is finer than the cell the map draws, at every zoom', () => { + for (let z = 3; z <= 18; z++) expect(selectionCellSize(z), `zoom ${z}`).toBeLessThan(mapCell(z)) + }) + it('is binned at the resolution closest to that size', () => { + for (let z = 3; z <= 19; z++) { + const off = (res) => Math.abs(hexSizeForRes(res) - selectionCellSize(z)) + for (let res = 0; res <= 21; res++) expect(off(selectionRes(z)), `zoom ${z} res ${res}`).toBeLessThanOrEqual(off(res)) + } + }) +}) + +describe('selectionTitle', () => { + it('names the picture after the targets, then the types and id sizes, in the bar\'s words', () => { + expect(selectionTitle({ names: ['NL-NIJ-Dikkeboom', 'NL-NIJ-NIMBUS'] })).toBe('NL-NIJ-Dikkeboom + NL-NIJ-NIMBUS') + expect(selectionTitle({ types: 'GroupText', idClasses: '1b' })).toBe('Channel · 1 byte') + expect(selectionTitle({ names: ['NL-NIJ-Dikkeboom'], types: 'GroupData,Advert' })).toBe('NL-NIJ-Dikkeboom · Channel data · Advert') + }) + it('calls the picture everything heard when nothing narrows it', () => { + expect(selectionTitle({})).toBe('Everything heard') + }) + it('names a typed prefix and No path, which narrow the points too', () => { + expect(selectionTitle({ prefix: 'db1', noPath: true })).toBe('Starts with db1 · No path') + expect(selectionTitle({ names: ['Dikkeboom'], noPath: true, types: 'Advert' })).toBe('Dikkeboom · No path · Advert') + }) +}) + +describe('heardNodes', () => { + const dikkeboom = { pubkey: 'DB11' + 'a'.repeat(60), name: 'Dikkeboom', lat: 51.84, lon: 5.86 } + const nimbus = { pubkey: '23f8' + 'b'.repeat(60), name: 'Nimbus', lat: 51.85, lon: 5.87 } + // A 1-byte relay hash is placed by reach, anything else by its key. + const attributionOf = (p) => (p.sender_id.length === 2 ? (p.sender_id === 'db' ? { rule: 'node', node: dikkeboom } : { rule: 'collision', count: 2 }) : null) + const registryNodeOf = (p) => (p.sender_id.startsWith('23f8') ? nimbus : null) + it('counts the receptions placed on each node, by reach or by key', () => { + const pts = [rx(51.84, 5.86, -90, 'db'), rx(51.84, 5.86, -91, 'db'), rx(51.84, 5.86, -92, '23f8c8cb64e1aa97'), rx(51.84, 5.86, -93, 'e0')] + expect(heardNodes(pts, { attributionOf, registryNodeOf })).toEqual([ + { id: dikkeboom.pubkey.toLowerCase(), name: 'Dikkeboom', lat: 51.84, lon: 5.86, n: 2 }, + { id: nimbus.pubkey, name: 'Nimbus', lat: 51.85, lon: 5.87, n: 1 }, + ]) + }) + it('names a node without a registry name by its prefix', () => { + const nameless = { pubkey: 'C0FFEE' + 'c'.repeat(58), lat: 51.8, lon: 5.8 } + expect(heardNodes([rx(51.84, 5.86, -90, 'c0ffee')], { attributionOf: () => null, registryNodeOf: () => nameless })[0].name).toBe('c0ffeecc') + }) + it('places nothing where reach finds two candidates, even if a key would match', () => { + const pts = [rx(51.84, 5.86, -90, 'e0')] + expect(heardNodes(pts, { attributionOf, registryNodeOf: () => dikkeboom })).toEqual([]) + }) +}) diff --git a/web/exportsheet.js b/web/exportsheet.js index 726b7cf3..9363b251 100644 --- a/web/exportsheet.js +++ b/web/exportsheet.js @@ -3,28 +3,30 @@ // Built like the settings sheet: the .lc-modal shell, focus kept inside while // open and handed back to the button on close. // -// The first item is "Repeaters heard". `heard` is the caller's async export: -// it returns { blob, fileName }, or { empty: text } when there is nothing to -// draw, and throws when it cannot finish. `reason()` answers why the item -// cannot be used by this account, or null. +// Each item is { button, status, run, reason }: the ids of its button and +// its status line, the async export, which returns { blob, fileName }, or +// { empty: text } when there is nothing to draw, and throws when it cannot +// finish; and reason(), why the item cannot be used now, or null. import { trapFocus } from './focustrap.js' -export function initExportSheet({ heard, reason = () => null }) { +export function initExportSheet({ items }) { const btn = document.getElementById('export-btn') const modal = document.getElementById('export-modal') const close = document.getElementById('ex-close') - const item = document.getElementById('ex-heard') - const status = document.getElementById('ex-heard-status') - if (!btn || !modal || !item) return + if (!btn || !modal) return trapFocus(modal.querySelector('.lc-card')) - let busy = false - const say = (text) => { status.textContent = text; status.hidden = !text } - - function sync() { - const why = reason() - item.disabled = busy || !!why - if (!busy) say(why || '') + const rows = items.map((it) => ({ + ...it, + el: document.getElementById(it.button), + line: document.getElementById(it.status), + busy: false, + })).filter((r) => r.el && r.line) + const say = (row, text) => { row.line.textContent = text; row.line.hidden = !text } + const sync = (row) => { + const why = row.reason() + row.el.disabled = row.busy || !!why + if (!row.busy) say(row, why || '') } function open() { @@ -34,7 +36,7 @@ export function initExportSheet({ heard, reason = () => null }) { if (settings && !settings.hidden) settings.hidden = true modal.hidden = false btn.setAttribute('aria-expanded', 'true') - sync() + rows.forEach(sync) close.focus() } // Focus goes back to the button, or, below 900px where the button sits in @@ -52,24 +54,26 @@ export function initExportSheet({ heard, reason = () => null }) { modal.addEventListener('click', (e) => { if (e.target === modal) hide() }) document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !modal.hidden) hide() }) - item.addEventListener('click', async () => { - if (busy) return - busy = true - item.disabled = true - say('Drawing the map…') - try { - const out = await heard() - if (out.empty) { say(out.empty); return } - download(out.blob, out.fileName) - say(`Saved as ${out.fileName}`) - } catch (err) { - console.error('[export]', err) - say('The picture could not be made. Try again in a moment.') - } finally { - busy = false - item.disabled = !!reason() - } - }) + for (const row of rows) { + row.el.addEventListener('click', async () => { + if (row.busy) return + row.busy = true + row.el.disabled = true + say(row, 'Drawing the map…') + try { + const out = await row.run() + if (out.empty) { say(row, out.empty); return } + download(out.blob, out.fileName) + say(row, `Saved as ${out.fileName}`) + } catch (err) { + console.error('[export]', err) + say(row, 'The picture could not be made. Try again in a moment.') + } finally { + row.busy = false + row.el.disabled = !!row.reason() + } + }) + } } // A download the browser saves under `name`. The object URL outlives the diff --git a/web/exportterrain.js b/web/exportterrain.js new file mode 100644 index 00000000..eec89472 --- /dev/null +++ b/web/exportterrain.js @@ -0,0 +1,94 @@ +// The ground under the reach export (#720): the Terrarium tiles the map's 3D +// view draws (terrain.js), fetched at the same zoom and read pixel by pixel, +// so exportreach.js can ask how high the ground is anywhere near one +// repeater. At zoom 10 a pixel is about 95 m: the landform that blocks a +// path, not the house in front of the antenna. +import { DEM_TILES, DEM_MAX_ZOOM } from './terrain.js' + +// A star spread over more tiles than this, 8 by 8 at zoom 10 or about +// 200 km across, is not one repeater's reach: its ground is not fetched. +export const MAX_TILES = 64 +const TILE = 256 + +// terrariumElevation: a Terrarium pixel in metres. +export function terrariumElevation(r, g, b) { + return r * 256 + g + b / 256 - 32768 +} + +// tilePixel: the slippy-map tile at zoom `z` a place falls in, and the pixel +// of its 256×256 image. +export function tilePixel(lat, lon, z) { + const n = 2 ** z + const fx = ((lon + 180) / 360) * n + const rad = (lat * Math.PI) / 180 + const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n + const x = Math.floor(fx), y = Math.floor(fy) + return { x, y, px: Math.floor((fx - x) * TILE), py: Math.floor((fy - y) * TILE) } +} + +// tilesFor: the zoom-`z` tiles under the box around `places`, padded by +// `padDeg` so a cell at its edge has ground under it too. +export function tilesFor(places, z = DEM_MAX_ZOOM, padDeg = 0.01) { + const lats = places.map((p) => p.lat), lons = places.map((p) => p.lon) + const nw = tilePixel(Math.max(...lats) + padDeg, Math.min(...lons) - padDeg, z) + const se = tilePixel(Math.min(...lats) - padDeg, Math.max(...lons) + padDeg, z) + const out = [] + for (let x = nw.x; x <= se.x; x++) for (let y = nw.y; y <= se.y; y++) out.push({ z, x, y }) + return out +} + +// elevationFrom: elevationAt(lat, lon) over decoded tiles, a Map from +// "x/y" to their 256×256 RGBA pixels. Null off the tiles: ground that was +// not read. +export function elevationFrom(tiles, z = DEM_MAX_ZOOM) { + return (lat, lon) => { + const t = tilePixel(lat, lon, z) + const px = tiles.get(`${t.x}/${t.y}`) + if (!px) return null + const i = (t.py * TILE + t.px) * 4 + return terrariumElevation(px[i], px[i + 1], px[i + 2]) + } +} + +// loadTerrain: elevationAt for the ground under `places`, or null when it +// cannot all be read: a tile that does not arrive, or a browser that alters +// what a canvas reads back (Brave, Firefox against fingerprinting), where +// one bit of red off is 256 m of ground. The export then fills along the +// lines alone rather than leave holes where a tile is missing. +// A DEM host that does not answer must not hold the export on "Drawing the +// map…": a tile that takes longer counts as missing, and the export fills +// along the lines alone. +export const TILE_TIMEOUT_MS = 8000 +export async function loadTerrain(places) { + const want = tilesFor(places) + if (want.length > MAX_TILES || canvasAltered()) return null + const tiles = new Map() + await Promise.all(want.map(async ({ z, x, y }) => { + try { + const res = await fetch(DEM_TILES.replace('{z}', z).replace('{x}', x).replace('{y}', y), { signal: AbortSignal.timeout(TILE_TIMEOUT_MS) }) + if (!res.ok) return + const bmp = await createImageBitmap(await res.blob(), { colorSpaceConversion: 'none', premultiplyAlpha: 'none' }) + const ctx = context(TILE) + ctx.drawImage(bmp, 0, 0) + tiles.set(`${x}/${y}`, ctx.getImageData(0, 0, TILE, TILE).data) + } catch (_) { /* counted below */ } + })) + return tiles.size === want.length ? elevationFrom(tiles) : null +} + +// MapLibre's own test for it (isOffscreenCanvasDistorted): paint known +// bytes, read them back. +function canvasAltered() { + const n = 5 + const ctx = context(n) + for (let i = 0; i < n * n; i++) { + ctx.fillStyle = `rgb(${i * 4},${i * 4 + 1},${i * 4 + 2})` + ctx.fillRect(i % n, Math.floor(i / n), 1, 1) + } + return ctx.getImageData(0, 0, n, n).data.some((v, i) => i % 4 !== 3 && v !== i) +} +function context(size) { + const c = document.createElement('canvas') + c.width = c.height = size + return c.getContext('2d', { willReadFrequently: true }) +} diff --git a/web/exportterrain.test.js b/web/exportterrain.test.js new file mode 100644 index 00000000..8fffa653 --- /dev/null +++ b/web/exportterrain.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { terrariumElevation, tilePixel, tilesFor, elevationFrom, MAX_TILES } from './exportterrain.js' +import { DEM_MAX_ZOOM } from './terrain.js' + +// #720: the ground under the reach export, read from the Terrarium tiles +// the 3D view draws. + +describe('reading the Terrarium tiles (#720)', () => { + it('decodes a pixel to metres', () => { + expect(terrariumElevation(128, 0, 0)).toBe(0) + expect(terrariumElevation(128, 100, 128)).toBe(100.5) + expect(terrariumElevation(127, 255, 0)).toBe(-1) + }) + + it('finds the tile and the pixel a place falls in', () => { + // 0,0 is where the four zoom-1 tiles meet; the far corners of the world + // fall in the first and the last tile. + expect(tilePixel(0, 0, 1)).toEqual({ x: 1, y: 1, px: 0, py: 0 }) + expect(tilePixel(85.05, -179.999, 3)).toMatchObject({ x: 0, y: 0, px: 0, py: 0 }) + expect(tilePixel(-85.05, 179.999, 3)).toMatchObject({ x: 7, y: 7, px: 255, py: 255 }) + }) + + it('fetches the tiles under a repeater and its hearings, at the 3D view\'s zoom', () => { + const one = tilesFor([{ lat: 51.84, lon: 5.86 }]) + expect(one.every((t) => t.z === DEM_MAX_ZOOM)).toBe(true) + expect(one).toContainEqual({ z: 10, ...xy(tilePixel(51.84, 5.86, 10)) }) + // A hearing 30 km east takes the tiles in between along. + const wide = tilesFor([{ lat: 51.84, lon: 5.86 }, { lat: 51.84, lon: 6.3 }]) + const xs = [...new Set(wide.map((t) => t.x))].sort((a, b) => a - b) + expect(xs).toEqual(range(tilePixel(51.84, 5.85, 10).x, tilePixel(51.84, 6.31, 10).x)) + expect(MAX_TILES).toBe(64) + }) + + it('takes the tile next door along when a place lies at a tile\'s edge', () => { + // Zoom-10 tile 528 starts at 5.625° east; a place 350 m inside it has + // cells reaching over the edge. + expect(tilePixel(51.84, 5.63, 10).x).toBe(528) + expect(tilesFor([{ lat: 51.84, lon: 5.63 }]).map((t) => t.x)).toContain(527) + }) + + it('reads the ground at a place from its tile, and nothing off the tiles', () => { + const t = tilePixel(51.84, 5.86, 10) + const px = new Uint8ClampedArray(256 * 256 * 4) + const i = (t.py * 256 + t.px) * 4 + px.set([128, 42, 128, 255], i) + const at = elevationFrom(new Map([[`${t.x}/${t.y}`, px]])) + expect(at(51.84, 5.86)).toBe(42.5) + expect(at(52.5, 7.5)).toBeNull() + }) +}) + +const xy = ({ x, y }) => ({ x, y }) +const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i) diff --git a/web/index.html b/web/index.html index 83122721..661568ef 100644 --- a/web/index.html +++ b/web/index.html @@ -527,6 +527,22 @@ <h2 id="ex-title">Export</h2> </div> <button id="ex-heard" class="ex-btn" type="button">Download PNG</button> </div> + <div class="ex-item"> + <div class="ex-text"> + <div class="ex-name">Reach of one repeater</div> + <div class="ex-desc">The selected repeater: where it was heard, where it was not, and where it likely reaches. A 1200 × 1200 PNG.</div> + <div class="ex-status" id="ex-reach-status" role="status" hidden></div> + </div> + <button id="ex-reach" class="ex-btn" type="button">Download PNG</button> + </div> + <div class="ex-item"> + <div class="ex-text"> + <div class="ex-name">Heard with these filters</div> + <div class="ex-desc">What the filters select in view: where it was heard, and where the hunters drove without hearing it. A 1200 × 1200 PNG.</div> + <div class="ex-status" id="ex-sel-status" role="status" hidden></div> + </div> + <button id="ex-sel" class="ex-btn" type="button">Download PNG</button> + </div> </div> </div> <div id="map"></div> diff --git a/web/map.js b/web/map.js index 38f24957..1ae5901d 100644 --- a/web/map.js +++ b/web/map.js @@ -5,8 +5,11 @@ import { leafletZoom, mapZoomFromLeaflet, zoomParam, pointFeatures, hexFeatures, import { coverageStars, coverageFeatures, assignHues, selectionDim, starKeyOf, starSelected } from './coverage.js' import { advertFeature, dotFeature, fc as glyphFc, NODE_GLYPH_SOURCE, NODE_DOT_SOURCE } from './nodeglyphs.js' import { registryIndex, attributeReception, REACH_CAP_KM } from './attribution.js' -import { heardModel, routeSegments, mappedCells, windowText, huntersText, exportFileName, FAR_KM } from './exportheard.js' -import { renderHeardImage, lightTokens } from './exportrender.js' +import { heardModel, routeSegments, mappedCells, windowText, huntersText, exportFileName, nearestKm, idLabel, isHashId, FAR_KM, inside } from './exportheard.js' +import { reachModel, pickReachStar } from './exportreach.js' +import { loadTerrain } from './exportterrain.js' +import { selectionModel, targetPositions, selectionTitle, heardNodes, selectionRes } from './exportselection.js' +import { renderHeardImage, renderReachImage, renderSelectionImage, lightTokens } from './exportrender.js' import { initExportSheet } from './exportsheet.js' import { createRowAttributor } from './rowattribution.js' import { EXAGGERATION_STEPS, DEFAULT_EXAGGERATION } from './terrain.js' @@ -1176,10 +1179,10 @@ function clearCoverageLayer() { coverageStarList = [] starCache.clear() } -// The "Repeaters heard" export (#666): the stars the reach layer would draw +// The stars an export draws (#666, #720): the ones the reach layer would draw // for this view, from the same registry slice, the same window and the same -// attribution, whether the layer is on or not, drawn as a 1200×1200 PNG. -async function heardExport() { +// attribution, whether the layer is on or not. +async function exportStars() { const view = wm.getBounds() const [registry, pointsRes] = await Promise.all([fetchNodeRegistry(view), windowPoints(standFilters())]) const nodes = registry && registry.status === 'ok' ? registry.nodes : [] @@ -1190,27 +1193,110 @@ async function heardExport() { const positionOf = (id) => { const n = byKey.get(id); return n ? { lat: n.lat, lon: n.lon } : null } const stars = coverageStars(points, { positionOf, attributionOf, registryNodeOf: registryMatcher(nodes) }) const nameOf = (id) => { const n = byKey.get(id); return (n && n.name) || null } + return { view, stars, points, nameOf, capped: pointsRes.capped, registryOk: Boolean(registry && registry.status === 'ok') } +} +// The band's second line: the window as dates, the hunters, and the cap +// when the window held more receptions than one fetch brings. +function exportSubline(points, hunters, capped, registryOk, notes = []) { + const f = (window.currentFilters && window.currentFilters()) || {} + const oldest = points.reduce((m, p) => (p.rx_at && p.rx_at < m ? p.rx_at : m), new Date().toISOString()) + const sub = [windowText(f.from || oldest, f.to || new Date().toISOString()), huntersText(hunters)] + if (capped) sub.push(`the first ${POINTS_CAP} receptions`) + // Without the registry every star is an estimate and none has a name. + if (!registryOk) sub.push('node list unavailable, every position estimated') + sub.push(...notes) + return sub.filter(Boolean).join(' · ') +} +const toPng = (canvas) => new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('no PNG'))), 'image/png')) + +// "Repeaters heard" (#666): every star in view, drawn as a 1200×1200 PNG. +async function heardExport() { + const { view, stars, points, nameOf, capped, registryOk } = await exportStars() const model = heardModel({ stars, view, nameOf, cachedNameOf: cachedName }) if (!model.drawn.length) return { empty: 'No repeater was heard in this view. Pan to where you drove, or widen the time window.' } const drawnIds = new Set(model.drawn.map((s) => s.id)) const hunters = [...new Set(stars.filter((s) => drawnIds.has(s.id)).flatMap((s) => s.points.map((p) => p.hunter_name)).filter(Boolean))] const theirs = points.filter((p) => hunters.includes(p.hunter_name)) - const f = (window.currentFilters && window.currentFilters()) || {} - const oldest = points.reduce((m, p) => (p.rx_at && p.rx_at < m ? p.rx_at : m), new Date().toISOString()) - const sub = [windowText(f.from || oldest, f.to || new Date().toISOString()), huntersText(hunters)] - if (pointsRes.capped) sub.push(`the first ${POINTS_CAP} receptions`) - // Without the registry every star is an estimate and none has a name. - if (!(registry && registry.status === 'ok')) sub.push('node list unavailable, every position estimated') const off = model.offMap.length ? `Left off, farther than ${FAR_KM} km from their position: ${model.offMap.map((o) => `${o.name} (${o.km} km)`).join(', ')}` : '' const canvas = await renderHeardImage({ - model, routes: routeSegments(theirs), cells: mappedCells(theirs), - text: { title: 'Repeaters heard', sub: sub.filter(Boolean).join(' · '), off }, tokens: lightTokens(), + model, routes: routeSegments(theirs), cells: mappedCells(theirs), exaggeration: exag, + text: { title: 'Repeaters heard', sub: exportSubline(points, hunters, capped, registryOk), off }, tokens: lightTokens(), + }) + return { blob: await toPng(canvas), fileName: exportFileName(Date.now()) } +} + +// "Reach of one repeater" (#720): the selected star, heard and not heard, +// and filled in where the repeater sees the ground (exportreach.js). +const SELECT_ONE = 'Select one repeater on the map: tap its ▲, or pick it as target.' +let lastReach = null +async function reachExport() { + const { stars, points, nameOf, capped, registryOk } = await exportStars() + const { star, picked } = pickReachStar(stars, coverageSelected()) + if (!star) return { empty: picked ? 'More than one repeater is selected. Select one.' : SELECT_ONE } + // The 30 km rule of Repeaters heard (heardModel): a position that far from + // every hearing cannot be right, and a picture fitted to it shows nothing. + const km = nearestKm(star) + if (km > FAR_KM) return { empty: `This repeater's position is ${Math.round(km)} km from its nearest hearing, so it cannot be right, and there is nothing to draw the reach from.` } + const hunters = [...new Set(star.points.map((p) => p.hunter_name).filter(Boolean))] + const theirs = points.filter((p) => hunters.includes(p.hunter_name)) + const elevationAt = await loadTerrain([star.origin, ...star.points]) + const model = reachModel({ star, mapped: theirs, nameOf, cachedNameOf: cachedName, elevationAt }) + lastReach = model + const canvas = await renderReachImage({ + model, exaggeration: exag, + text: { title: model.name, sub: exportSubline(points, hunters, capped, registryOk), off: '' }, tokens: lightTokens(), + }) + return { blob: await toPng(canvas), fileName: exportFileName(Date.now(), 'reach', model.name) } +} +window.__lastReach = () => lastReach && { heard: lastReach.heard.length, filled: lastReach.filled.length } // test hook + +// "Heard with these filters": what the map's filters select in view, +// whatever kind of node was heard, and the cells the same hunters drove +// through without it. Those come from all their receptions in the window: +// the filters with everything that narrows what was heard left out, the id +// sizes included. The nodes drawn are the picked targets and the nodes the +// receptions were placed on, by the rules the map's layers use. +let lastSelection = null +async function selectionExport() { + const f = standFilters() + const view = wm.getBounds() + const everything = { ...f, sender: '', senderPairs: [], types: '', idclass: '', hops: '' } + const [sel, all, registry] = await Promise.all([windowPoints(f), windowPoints(everything), fetchNodeRegistry(view)]) + const inView = (p) => Number.isFinite(p.lat) && Number.isFinite(p.lon) && inside(view, p) + const points = sel.points.filter(inView) + const hunters = new Set(points.map((p) => p.hunter_name).filter(Boolean)) + const mapped = all.points.filter((p) => hunters.has(p.hunter_name) && inView(p)) + const registryOk = Boolean(registry && registry.status === 'ok') + const nodes = registryOk ? registry.nodes : [] + const index = registryIndex(nodes) + const registryNodeOf = registryMatcher(nodes) + // What the stand applies: under the ticker's "all" the sender inputs are + // dropped (tickerFilters), so the title names no target then. + const pairs = f.senderPairs || [] + const picked = pairs.filter(([k]) => k === 'senders').map(([, v]) => v) + const prefix = (pairs.find(([k]) => k === 'sender') || [])[1] || '' + const targets = targetPositions(picked, registryNodeOf) + const heardFrom = heardNodes(points, { attributionOf: (pt) => attributeReception(pt, { index }), registryNodeOf }) + const drawn = [...targets.map((t) => ({ ...t, n: Infinity })), ...heardFrom.filter((h) => !targets.some((t) => t.id === h.id))].filter((n) => inside(view, n)) + // Finer than the cells the map draws, on purpose (selectionCellSize). + const res = selectionRes(leafletZoom(wm.getZoom())) + const model = selectionModel({ points, mapped, nodes: drawn, res }) + lastSelection = model + if (!model.heard.length) return { empty: 'Nothing was heard with these filters in this view. Pan to where it was heard, widen the time window, or pick other targets.' } + const names = picked.map((id) => (targets.find((t) => t.pick === String(id).toLowerCase()) || {}).name || (!isHashId(id) && cachedName(id)) || idLabel(id)) + const title = selectionTitle({ names, prefix, noPath: f.hops === '0', types: f.types, idClasses: f.idclass }) + // The cells driven through come from a query without the selection's + // filters, which reaches the cap first. + const notes = all.capped && !sel.capped ? [`the cells driven from the first ${POINTS_CAP} receptions`] : [] + const canvas = await renderSelectionImage({ + model, exaggeration: exag, + text: { title, sub: exportSubline(points, [...hunters], sel.capped, registryOk, notes), off: '' }, tokens: lightTokens(), }) - const blob = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('no PNG'))), 'image/png')) - return { blob, fileName: exportFileName(Date.now()) } + return { blob: await toPng(canvas), fileName: exportFileName(Date.now(), 'selection', title) } } +window.__lastSelection = () => lastSelection && { ...lastSelection.numbers, nodes: lastSelection.nodes.map((n) => n.name) } // test hook // The receptions the stars are built from. With a target picked the layer's // points are already narrowed to it, and the other stars have to stay up at a @@ -2759,9 +2845,14 @@ window.addEventListener('focus', async () => { // The Export sheet (#666). The picture is made of the same registry and // receptions as the node layer, so it asks the same of the account. +const exportReason = () => (canSeeObserverPoints(currentRole) ? null + : currentRole === 'hunter' ? 'Exports need a verified member account. An admin verifies you.' + : 'Exports need an account. Log in to use them.') initExportSheet({ - heard: heardExport, - reason: () => (canSeeObserverPoints(currentRole) ? null - : currentRole === 'hunter' ? 'Exports need a verified member account. An admin verifies you.' - : 'Exports need an account. Log in to use them.'), + items: [ + { button: 'ex-heard', status: 'ex-heard-status', run: heardExport, reason: exportReason }, + { button: 'ex-reach', status: 'ex-reach-status', run: reachExport, + reason: () => exportReason() || (coverageSelected().size ? null : SELECT_ONE) }, + { button: 'ex-sel', status: 'ex-sel-status', run: selectionExport, reason: exportReason }, + ], }) diff --git a/web/signal.js b/web/signal.js index cdddfbe3..c4330a80 100644 --- a/web/signal.js +++ b/web/signal.js @@ -79,6 +79,11 @@ export function rssiToPct(rssi, offset = 0) { // at all, so dropping it converges the two surfaces (#644) rather than porting // a third rule across. +// The tiers and the dBm each one starts at, strongest first; below the last +// is 'faint'. Data rather than a ladder of ifs so a legend (the exports, +// #720) can spell the same bands the map colours by. +export const TIER_BANDS = [['hot', -80], ['warm', -90], ['mid', -100], ['cool', -110], ['cold', -115]] + // Fixed RSSI dBm bands (iteration 2): hot = strong = close. `offset` is an // optional per-device calibration value (dBm) added before banding. // @@ -90,11 +95,7 @@ export function rssiToPct(rssi, offset = 0) { export function rssiTier(rssi, offset = 0) { if (rssi == null) return 'none' const v = rssi + offset - if (v >= -80) return 'hot' - if (v >= -90) return 'warm' - if (v >= -100) return 'mid' - if (v >= -110) return 'cool' - if (v >= -115) return 'cold' + for (const [tier, floor] of TIER_BANDS) if (v >= floor) return tier return 'faint' }