diff --git a/app/changelog.json b/app/changelog.json index 0f98e1e0..89cbe942 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -83,6 +83,13 @@ "title": "Reach stars hang from the repeater's position", "body": "A repeater heard by Discover draws its reach from its own ▲ and name, no longer from a separate estimate beside it." }, + { + "id": "2026-09-27-export-repeaters-heard", + "date": "2026-09-27", + "where": "map", + "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-dutchmeshcore-by-default", "date": "2026-09-27", diff --git a/changelog.d/2026-09-27-09-export-repeaters-heard.json b/changelog.d/2026-09-27-09-export-repeaters-heard.json new file mode 100644 index 00000000..a503c095 --- /dev/null +++ b/changelog.d/2026-09-27-09-export-repeaters-heard.json @@ -0,0 +1,7 @@ +{ + "id": "2026-09-27-export-repeaters-heard", + "date": "2026-09-27", + "where": "map", + "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." +} diff --git a/docs/2026-09-27-export-repeaters-heard.md b/docs/2026-09-27-export-repeaters-heard.md new file mode 100644 index 00000000..9047ffac --- /dev/null +++ b/docs/2026-09-27-export-repeaters-heard.md @@ -0,0 +1,39 @@ +# Export: Repeaters heard (#666) + +**Date:** 2026-09-27 +**Status:** decided (Kasper, 27 September 2026, on mockups rendered from real data), built in `web/exportheard.js`, `web/exportrender.js`, `web/exportsheet.js` and `web/map.js`. +**Related:** #665 (the exports epic), #720 (export 2, reach of one repeater), #603 (the reach layer), #723 (a Discover-heard star under its own ▲), #561 (the narrow bar) + +## The problem + +A hunter who has just mapped a stretch of road cannot show it. The reach layer draws every repeater's star, but the only thing to pass on is a URL, which the other person has to open with the same filters and an account to see the same thing. + +## What it is + +An **Export** button in #bar, next to Start mapping, opens a sheet with one item: **Repeaters heard**, a 1200×1200 PNG of the map as it stands (filters, time window, hunters), downloaded as `mesh-hunter-repeaters-heard-YYYY-MM-DD.png`. Below 900 px the button moves into the menu; Start mapping and Log in follow below 640 px (#561). Between 641 and 900 px its label wrapped the bar into another row: at 768 px as a guest, in a font as wide as the CI runner's, the bar was 90 px high with it and 64 px without. + +## What the picture shows + +- **The stars the reach layer draws for the view**, built from the same registry slice, window receptions and attribution, whether the layer is on or not. A star is drawn when its origin is in view. +- **Every repeater in its own hue** (`assignHues`), as on the map. +- **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. +- 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. + +## What was tried and not chosen + +- Name labels as pills: busier than the #720 reference's halo text. +- **Colour per signal tier** (the #720 style) for all repeaters: which repeater heard what is lost. +- **#720's hex fill** over every repeater (the strongest per cell wins): a weak repeater disappears under a strong one, and a few far hearings fill a large area. It stays for #720, which has one repeater. + +## How it is drawn + +A second MapLibre instance, offscreen at 1200×1000, draws the base map, the cells, the route, the rays and the dots; it is the only one with `preserveDrawingBuffer`, so the live map is untouched. A canvas takes its pixels and adds the glyphs, the names, the scale bar, the attribution and the band. When the hosted style does not load within half the timeout, the bare background is used, and the lines and the band still say what was heard. + +## Who can use it + +A member, like the node positions the picture is made of. Anyone else sees the item disabled with why: "Exports need an account. Log in to use them." or, for a hunter, "Exports need a verified member account. An admin verifies you." diff --git a/web/barnarrow.js b/web/barnarrow.js index e7e50e29..f4d4f75b 100644 --- a/web/barnarrow.js +++ b/web/barnarrow.js @@ -1,4 +1,5 @@ -// What the map's bar hands off below 768px (#561, #727). +// What the map's bar hands off below 768px (#561, #727), and Export already +// below 900px (#666). // // The bar is one row at every width now, and at 375px a row cannot hold four // filter controls, a brand, a primary action, a login and two icon buttons: @@ -32,6 +33,11 @@ export const NARROW_SLOTS = [ { control: '.tr-wrap', slot: 'bf-slot-time', group: 'bf-group-time', into: 'panel' }, { control: '#hp-toggle', wrap: '.ms-wrap', slot: 'bf-slot-hunters', group: 'bf-group-hunters', into: 'panel' }, + // Export is the first to go (#666). It is the least used action, and between + // 641 and 900px its label is what wraps the bar into another row: measured + // at 768px as a guest in a font as wide as the CI runner's, 90px with it in + // the bar against 64px without. + { control: '#export-btn', slot: 'ss-slot-actions', group: 'ss-slot-actions', into: 'menu', below: 900 }, { control: '#rx-cta', slot: 'ss-slot-actions', group: 'ss-slot-actions', into: 'menu' }, { control: '#auth-btn', slot: 'ss-slot-actions', group: 'ss-slot-actions', into: 'menu' }, ] @@ -39,6 +45,10 @@ export const NARROW_SLOTS = [ // Where each destination lives in index.html, for that guard. export const NARROW_CONTAINERS = { panel: 'bar-filters', menu: 'settings-modal' } +// The width the other controls leave the bar below: map.js's "is this a +// phone" line (NARROW_MEDIA below). +export const NARROW_PX = 767 + // Where each control came from, recorded the first time it leaves. // // The parent's whole original child order, not the control's index in it: two @@ -76,10 +86,13 @@ function nextSiblingAtHome(parent, el) { return null } -export function applyNarrowBar(narrow) { +// applyNarrowBar puts each control in its slot when narrowAt(width) says the +// viewport is below the width it leaves at, and home otherwise. +export function applyNarrowBar(narrowAt) { if (!document.getElementById('bar')) return for (const entry of NARROW_SLOTS) { const { slot, group } = entry + const narrow = narrowAt(entry.below || NARROW_PX) const el = findControl(entry) const target = document.getElementById(slot) const groupEl = document.getElementById(group) @@ -112,12 +125,18 @@ export function applyNarrowBar(narrow) { // #727: between 641 and 729px the wide bar wrapped to two or three rows (76 to // 90px) with CI's wider font, which is a phone held sideways. The stylesheet // repeats the number in its @media rules, since CSS cannot import it. -export const NARROW_MEDIA = '(max-width: 767px)' +export const NARROW_MEDIA = `(max-width: ${NARROW_PX}px)` -// Wires the rule to the viewport and applies it once. The listener is never -// removed: the bar lives as long as the page does. +// Wires the rule to the viewport and applies it once. `mq` is the shared +// NARROW_MEDIA query; a control that leaves earlier gets a query of its own. +// The listeners are never removed: the bar lives as long as the page does. export function wireNarrowBar(mq = window.matchMedia(NARROW_MEDIA)) { - applyNarrowBar(mq.matches) - mq.addEventListener('change', (e) => applyNarrowBar(e.matches)) + const queries = new Map([[NARROW_PX, mq]]) + for (const { below } of NARROW_SLOTS) { + if (below && !queries.has(below)) queries.set(below, window.matchMedia(`(max-width: ${below}px)`)) + } + const apply = () => applyNarrowBar((px) => queries.get(px).matches) + apply() + for (const q of queries.values()) q.addEventListener('change', apply) return mq } diff --git a/web/barnarrow.test.js b/web/barnarrow.test.js index 89079215..92c69d66 100644 --- a/web/barnarrow.test.js +++ b/web/barnarrow.test.js @@ -109,6 +109,7 @@ describe('finding the control a slot moves', () => { node('hunter picker', { classes: ['ms-wrap'] }, [node('hunter toggle', { id: 'hp-toggle' })]), node('time range', { classes: ['tr-wrap'] }), ]), + node('Export', { id: 'export-btn' }), node('Start mapping', { id: 'rx-cta' }), node('Log in', { id: 'auth-btn' }), ]), @@ -120,7 +121,7 @@ describe('finding the control a slot moves', () => { it('finds every control without a selector only some engines parse', () => { const found = NARROW_SLOTS.map((entry) => findControl(entry, doc)?.name) - expect(found).toEqual(['time range', 'hunter picker', 'Start mapping', 'Log in']) + expect(found).toEqual(['time range', 'hunter picker', 'Export', 'Start mapping', 'Log in']) }) }) @@ -134,10 +135,11 @@ describe('the stylesheet\'s widths are NARROW_MEDIA\'s', () => { it('finds the media queries at all', () => { expect(queries.length).toBeGreaterThan(2) }) - it('uses one max-width, the one NARROW_MEDIA names', () => { + it('uses the width NARROW_MEDIA names, or one a control leaves earlier at', () => { const narrow = Number(NARROW_MEDIA.match(/(\d+)px/)[1]) + const earlier = NARROW_SLOTS.map((s) => s.below).filter(Boolean) const widths = queries.flatMap((q) => [...q.matchAll(/(?:max|min)-width:\s*(\d+)px/g)].map((m) => Number(m[1]))) - expect(widths.length).toBeGreaterThan(0) - for (const w of widths) expect(w, `a media query at ${w}px`).toBe(narrow) + expect(widths).toContain(narrow) + for (const w of widths) expect([narrow, ...earlier], `a media query at ${w}px`).toContain(w) }) }) diff --git a/web/changelog.json b/web/changelog.json index 0f98e1e0..89cbe942 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -83,6 +83,13 @@ "title": "Reach stars hang from the repeater's position", "body": "A repeater heard by Discover draws its reach from its own ▲ and name, no longer from a separate estimate beside it." }, + { + "id": "2026-09-27-export-repeaters-heard", + "date": "2026-09-27", + "where": "map", + "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-dutchmeshcore-by-default", "date": "2026-09-27", diff --git a/web/e2e/export.spec.js b/web/e2e/export.spec.js new file mode 100644 index 00000000..6c6e628b --- /dev/null +++ b/web/e2e/export.spec.js @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs' +import { test, expect } from './fixtures.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 +// style here, so this checks the path end to end, not the picture. + +const node = { pubkey: 'db11db11f7808b97' + 'a'.repeat(48), name: 'NL-NIJ-Dikkeboom', lat: 51.84, lon: 5.86 } +const heard = (lat, lon, rssi, min) => ({ lat, lon, rssi, snr: 4, sender_id: 'db11db11f7808b97', sender_kind: 'discover_pubkey', + sender_role: 'Repeater', hops: 0, packet_type: 'Response', hunter_name: 'kas', rx_at: `2026-09-07T12:${String(min).padStart(2, '0')}:00Z` }) +const points = [heard(51.845, 5.865, -80, 1), heard(51.85, 5.87, -95, 2), heard(51.835, 5.85, -105, 3), heard(51.83, 5.87, -112, 4)] + +async function stub(page, role) { + await page.route('**/api/auth/me', (r) => r.fulfill({ json: { role, username: 'k' } })) + await page.route('**/api/points*', (r) => r.fulfill({ json: { points } })) + await page.route('**/api/heatmap*', (r) => r.fulfill({ json: { features: [] } })) + await page.route('**/api/hunters*', (r) => r.fulfill({ json: { hunters: [] } })) + await page.route('**/api/nodes/positions*', (r) => r.fulfill({ json: { nodes: [node] } })) +} + +test('Repeaters heard downloads a 1200×1200 PNG of the view', async ({ page }) => { + await stub(page, 'member') + await page.goto('/?mode=points&lat=51.84&lon=5.86&z=12&from=2026-09-07T00:00&to=2026-09-08T00:00') + 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-heard')).toBeEnabled() + const [download] = await Promise.all([page.waitForEvent('download', { timeout: 60000 }), page.click('#ex-heard')]) + expect(download.suggestedFilename()).toMatch(/^mesh-hunter-repeaters-heard-\d{4}-\d{2}-\d{2}\.png$/) + const png = readFileSync(await download.path()) + expect(png.subarray(1, 4).toString()).toBe('PNG') + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([1200, 1200]) + await expect(page.locator('#ex-heard-status')).toHaveText(`Saved as ${download.suggestedFilename()}`) +}) + +test('a guest is told what the export needs, and cannot start it', async ({ page }) => { + await stub(page, 'guest') + await page.goto('/') + 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-heard')).toBeDisabled() + await expect(page.locator('#ex-heard-status')).toHaveText('Exports need an account. Log in to use them.') +}) + +test('Escape closes the sheet and hands focus back to the button', async ({ page }) => { + await stub(page, 'member') + await page.goto('/') + 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 page.keyboard.press('Escape') + await expect(page.locator('#export-modal')).toBeHidden() + await expect(page.locator('#export-btn')).toBeFocused() +}) + +// Below 900px Export is in the menu, before Start mapping and Log in join it +// below 768px (barnarrow.js): between 768 and 900px its label wrapped the bar +// into another row. +test('Export sits in the menu below 900px and opens the sheet from there', async ({ page }) => { + await stub(page, 'member') + await page.setViewportSize({ width: 768, height: 1024 }) + await page.goto('/') + await expect(async () => { + if (await page.locator('#settings-modal').isHidden()) await page.click('#settings-btn') + await expect(page.locator('#settings-modal #export-btn')).toBeVisible() + }).toPass({ timeout: 15000 }) + await expect(page.locator('#bar #export-btn')).toHaveCount(0) + await page.click('#settings-modal #export-btn') + await expect(page.locator('#export-modal')).toBeVisible() + await expect(page.locator('#settings-modal')).toBeHidden() + await page.keyboard.press('Escape') + // The button it came from is in the closed menu, so the focus goes to the + // menu's button rather than being dropped. + await expect(page.locator('#settings-btn')).toBeFocused() + await page.setViewportSize({ width: 1024, height: 768 }) + await expect(page.locator('#bar #export-btn')).toBeVisible() +}) diff --git a/web/exportheard.js b/web/exportheard.js new file mode 100644 index 00000000..65085a54 --- /dev/null +++ b/web/exportheard.js @@ -0,0 +1,188 @@ +// The "Repeaters heard" export (#666): the reach layer's stars as a +// 1200×1200 PNG, for passing on what a drive heard. This module decides what +// goes on the image; exportrender.js draws it. +// +// Decided with Kasper on 27 September 2026, on mockups from real data: +// every repeater in its own hue, as on the map (assignHues), and each ray's +// strength as a gradient within that hue, strong full and thick, weak lighter +// and thinner, with the width and opacity the map gives it (rayStyle). The +// cells driven through are grey, the route is grey, and a name is dropped +// where it would print over another, never the ▲. +import { assignHues, rayStyle, rayStrength } from './coverage.js' +import { hexCellAt, hexBoundary, hexSizeForRes } from './hexgrid.js' +import { haversineM } from './locate.js' +import { isHashIdKind } from './names.js' + +export const EXPORT_W = 1200 +export const EXPORT_H = 1200 +export const EXPORT_MAP_H = 1000 + +// A star whose nearest hearing is farther than this from where it hangs has a +// registry position that cannot be right: drawing it would stretch the map to +// fit a line to nowhere. It is left off and named in the band instead. +export const FAR_KM = 30 + +// A drive ends where the gap says another one began. +export const ROUTE_GAP_MS = 5 * 60e3 +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). +export const CELL_SIZE = 360 +export const CELL_RES = closestRes(CELL_SIZE) +function closestRes(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 +} + +// The map's own great-circle distance (locate.js), in km. +export const kmBetween = (a, b) => haversineM(a, b) / 1000 + +// idLabel: how an id without a registry name reads on the picture. A relay +// or direct hash is an id, never a name (AGENTS.md §5.4 item 6), so it reads +// as the ticker reads it, '#' and the id; a key reads as its 8-hex prefix. +// A hash is 1 to 3 bytes: a star keyed by a node's full key reads as a key, +// whatever kind of hearing placed it there. Without a kind (a picked +// target), a hash is known by its length alone. +export function isHashId(id, kind = null) { + const s = String(id) + return (kind ? isHashIdKind(kind) : true) && s.length <= 6 +} +export function idLabel(id, kind = null) { + const s = String(id) + return isHashId(s, kind) ? '#' + s : s.slice(0, 8) +} + +// fitText: a line cut to maxW with an ellipsis, for a band row that takes +// one line whatever it holds. measure(text) is the canvas's width in px. +export function fitText(text, maxW, measure) { + if (measure(text) <= maxW) return text + let s = text + while (s.length && measure(s + '…') > maxW) s = s.slice(0, -1) + return s + '…' +} + +const inside = (v, p) => p.lat >= v.south && p.lat <= v.north && p.lon >= v.west && p.lon <= v.east + +// 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. +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) + 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, + rays: s.points.map((p) => ({ lon: p.lon, lat: p.lat, rssi: p.rssi, s: rayStrength(p.rssi), ...rayStyle(p.rssi) })), + }) + } + const hues = assignHues(drawn.map((s) => ({ id: s.id, lat: s.origin.lat, lon: s.origin.lon }))) + for (const s of drawn) s.slot = hues.get(s.id) + const receptions = drawn.reduce((n, s) => n + s.rays.length, 0) + const farthestKm = drawn.reduce((m, s) => Math.max(m, ...s.rays.map((r) => kmBetween(s.origin, r))), 0) + return { drawn, offMap, numbers: { repeaters: drawn.length, receptions, farthestKm } } +} + +// routeSegments: the receptions of the hunters in time order, as [lon, lat] +// lines, broken where more than ROUTE_GAP_MS passes, the position jumps more +// than ROUTE_GAP_KM, or the hunter changes. A one-point segment draws nothing +// and is dropped. +export function routeSegments(points) { + const pts = (points || []).filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon) && p.rx_at) + .slice().sort((a, b) => (a.hunter_name === b.hunter_name ? (a.rx_at < b.rx_at ? -1 : a.rx_at > b.rx_at ? 1 : 0) : String(a.hunter_name) < String(b.hunter_name) ? -1 : 1)) + const out = [] + let cur = [] + let prev = null + for (const p of pts) { + const gap = prev && (Date.parse(p.rx_at) - Date.parse(prev.rx_at) > ROUTE_GAP_MS || kmBetween(prev, p) > ROUTE_GAP_KM || prev.hunter_name !== p.hunter_name) + if (gap) { if (cur.length > 1) out.push(cur); cur = [] } + cur.push([p.lon, p.lat]) + prev = p + } + if (cur.length > 1) out.push(cur) + return out +} + +// mappedCells: one res-8 cell per place a reception was taken, as a closed +// [lon, lat] ring. +export function mappedCells(points, res = CELL_RES) { + const ids = new Set() + for (const p of points || []) if (Number.isFinite(p.lat) && Number.isFinite(p.lon)) ids.add(hexCellAt(p.lat, p.lon, res)) + const out = [] + for (const id of ids) { + const ring = hexBoundary(id) + if (ring) out.push(ring.map(([lat, lon]) => [lon, lat])) + } + return out +} + +// placeLabels: which names fit. Most-heard first, so a busy repeater keeps +// its name over a quiet one; a name is dropped when its box, grown by +// `margin`, meets a name already placed, or when it runs off the image. The +// name starts `dx` right of its glyph and is centred on it. Returns the ids. +export function placeLabels(items, { measure, width, height, dx = 12, lineHeight = 16, margin = 4 }) { + const placed = [] + const kept = new Set() + const order = [...items].sort((a, b) => b.n - a.n || (a.id < b.id ? -1 : 1)) + for (const it of order) { + const w = measure(it.label) + const box = { left: it.x + dx, right: it.x + dx + w, top: it.y - lineHeight / 2, bottom: it.y + lineHeight / 2 } + if (box.right > width || box.top < 0 || box.bottom > height) continue + const grown = { left: box.left - margin, right: box.right + margin, top: box.top - margin, bottom: box.bottom + margin } + if (placed.some((q) => grown.left < q.right && grown.right > q.left && grown.top < q.bottom && grown.bottom > q.top)) continue + placed.push(box) + kept.add(it.id) + } + return kept +} + +// tint: a hue mixed towards white as the strength `s` (0..1) falls, up to 55% +// for the weakest ray, so it stays the repeater's colour. +export function tint(hex, s) { + const n = parseInt(String(hex).replace('#', ''), 16) + const f = 0.55 * (1 - Math.min(1, Math.max(0, s))) + const ch = (v) => Math.round(v + (255 - v) * f) + return `rgb(${ch(n >> 16)},${ch((n >> 8) & 255)},${ch(n & 255)})` +} + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] +// windowText: the time window as dates, the year once when both ends share it. +export function windowText(fromIso, toIso) { + const a = new Date(fromIso), b = new Date(toIso) + const day = (d) => `${d.getDate()} ${MONTHS[d.getMonth()]}` + return a.getFullYear() === b.getFullYear() + ? `${day(a)} to ${day(b)} ${b.getFullYear()}` + : `${day(a)} ${a.getFullYear()} to ${day(b)} ${b.getFullYear()}` +} + +// huntersText: up to three hunters by name, more as a count. +export function huntersText(names) { + const list = [...new Set((names || []).filter(Boolean))] + if (!list.length) return '' + if (list.length > 3) return `${list.length} hunters` + return `${list.length} ${list.length === 1 ? 'hunter' : 'hunters'}: ${list.join(', ')}` +} + +export function exportFileName(nowMs) { + 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` +} + +// scaleBar: the longest round length that fits in `maxPx`, at `metresPerPx`. +const NICE_M = [100, 200, 300, 500, 1000, 2000, 3000, 5000, 10000, 20000, 30000, 50000] +export function scaleBar(metresPerPx, maxPx = 120) { + const m = NICE_M.filter((v) => v / metresPerPx <= maxPx).pop() || NICE_M[0] + return { px: Math.round(m / metresPerPx), label: m >= 1000 ? `${m / 1000} km` : `${m} m` } +} diff --git a/web/exportheard.test.js b/web/exportheard.test.js new file mode 100644 index 00000000..1aeee3db --- /dev/null +++ b/web/exportheard.test.js @@ -0,0 +1,206 @@ +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 { hexSizeForRes } from './hexgrid.js' +import { coverageStars, rayStyle, rayStrength } from './coverage.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. + +// A repeater's hearing, as /api/points returns it: a discover reply names the +// node by an 8-byte prefix and is two-way. +const hearing = (id, lat, lon, rssi, extra = {}) => ({ sender_id: id, sender_kind: 'discover_pubkey', sender_role: 'Repeater', lat, lon, rssi, rx_at: '2026-09-07T12:00:00Z', hunter_name: 'kas', ...extra }) +const view = { west: 5.5, east: 6.2, south: 51.6, north: 52.1 } +const starsOf = (points, positions = {}) => coverageStars(points, { positionOf: (id) => positions[id] || null }) + +describe('heardModel (#666)', () => { + const pts = [ + hearing('aa', 51.80, 5.80, -70), hearing('aa', 51.82, 5.84, -110), + hearing('bb', 51.90, 5.90, -95), + ] + const positions = { aa: { lat: 51.81, lon: 5.82 }, bb: { lat: 51.91, lon: 5.91 } } + + it('draws every star whose origin is in view, with its hue and name', () => { + const m = heardModel({ stars: starsOf(pts, positions), view, nameOf: (id) => id.toUpperCase() }) + expect(m.drawn.map((s) => [s.id, s.name, s.origin.kind])).toEqual([['aa', 'AA', 'advertised'], ['bb', 'BB', 'advertised']]) + expect(m.drawn.every((s) => Number.isInteger(s.slot))).toBe(true) + }) + + it('gives each ray the map\'s width and opacity, and its strength for the tint', () => { + const m = heardModel({ stars: starsOf(pts, positions), view }) + const aa = m.drawn.find((s) => s.id === 'aa') + expect(aa.rays.map((r) => r.rssi)).toEqual([-70, -110]) + expect(aa.rays[0]).toMatchObject({ lon: 5.80, lat: 51.80, ...rayStyle(-70), s: rayStrength(-70) }) + expect(aa.rays[1].s).toBeLessThan(aa.rays[0].s) + }) + + it('leaves out a star whose origin is outside the view', () => { + const far = { aa: { lat: 50.5, lon: 4.0 }, bb: positions.bb } + const m = heardModel({ stars: starsOf([hearing('aa', 50.5, 4.01, -80), pts[2]], far), view }) + expect(m.drawn.map((s) => s.id)).toEqual(['bb']) + }) + + it('names a star whose nearest hearing is farther than 30 km, and does not draw it', () => { + // A registry position 40 km from every hearing cannot be right. + const wrong = { aa: { lat: 51.81, lon: 5.82 }, bb: { lat: 51.55, lon: 5.9 + 0 } } + const stars = starsOf([hearing('bb', 51.91, 5.91, -90), ...pts.slice(0, 2)], wrong) + const bbFar = heardModel({ stars, view: { ...view, south: 51.5 }, nameOf: (id) => id }) + expect(FAR_KM).toBe(30) + expect(bbFar.drawn.map((s) => s.id)).toEqual(['aa']) + expect(bbFar.offMap).toEqual([{ name: 'bb', km: 40 }]) + }) + + it('counts repeaters, receptions and the farthest hearing of what it draws', () => { + const m = heardModel({ stars: starsOf(pts, positions), view }) + expect(m.numbers.repeaters).toBe(2) + expect(m.numbers.receptions).toBe(3) + // aa's hearings are both about 1.77 km from 51.81,5.82; bb's is 1.31. + expect(m.numbers.farthestKm).toBeCloseTo(1.77, 1) + }) + + // AGENTS.md §5.4 item 6: a hash is an id, never a name. A relay hash star + // has no registry name to fall back on, so it reads as the ticker reads it, + // '#' and the id; a key without a name reads as its prefix. + it('names a relay-hash star with a #, and a nameless key by its prefix', () => { + const relay = (lat, lon, rssi) => hearing('db', lat, lon, rssi, { sender_kind: 'path_hash' }) + const stars = starsOf([relay(51.80, 5.80, -70), relay(51.82, 5.84, -90), relay(51.81, 5.81, -100), ...pts.slice(2)], { bb: positions.bb }) + const m = heardModel({ stars, view }) + expect(m.drawn.map((s) => s.name).sort()).toEqual(['#db', 'bb']) + expect(idLabel('db', 'path_hash')).toBe('#db') + expect(idLabel('e0', 'direct_hash')).toBe('#e0') + expect(idLabel('db11db11f7808b97' + 'a'.repeat(48), 'advert_pubkey')).toBe('db11db11') + // A picked id without a kind: a hash by its length, three bytes at most. + expect(idLabel('db')).toBe('#db') + expect(idLabel('ffee00')).toBe('#ffee00') + expect(idLabel('db11db11f7808b97')).toBe('db11db11') + }) + + // AGENTS.md §7 rule 2: a relay id with no candidate in reach is not named + // by a resolver. The cache resolves 2-byte ids for the points layer, so + // only a key reads a cached name. + it('never names a relay-hash star from the resolver cache', () => { + const relay = (lat, lon, rssi) => hearing('db11', lat, lon, rssi, { sender_kind: 'path_hash' }) + const stars = starsOf([relay(51.80, 5.80, -70), relay(51.82, 5.84, -90), relay(51.81, 5.81, -100), ...pts.slice(2)], { bb: positions.bb }) + const m = heardModel({ stars, view, cachedNameOf: () => 'Resolved' }) + expect(m.drawn.map((s) => s.name).sort()).toEqual(['#db11', 'Resolved']) + }) + + it('reads a nameless node\'s key by its prefix, whatever kind its first hearing had', () => { + const key = 'db11db11f7808b97' + 'a'.repeat(48) + const stars = [{ id: key, origin: { lat: 51.81, lon: 5.82, kind: 'advertised' }, points: [hearing('db', 51.80, 5.80, -70, { sender_kind: 'path_hash' })] }] + expect(heardModel({ stars, view }).drawn[0].name).toBe('db11db11') + }) + + it('says nothing is heard when no star is in view', () => { + const m = heardModel({ stars: [], view }) + expect(m.drawn).toEqual([]) + expect(m.numbers).toEqual({ repeaters: 0, receptions: 0, farthestKm: 0 }) + }) +}) + +describe('routeSegments (#666)', () => { + const at = (min, lat, lon, hunter = 'kas') => ({ lat, lon, hunter_name: hunter, rx_at: new Date(Date.UTC(2026, 8, 7, 12, min)).toISOString() }) + it('joins one drive in time order, whatever order the points came in', () => { + expect(routeSegments([at(2, 51.802, 5.8), at(0, 51.80, 5.8), at(1, 51.801, 5.8)])) + .toEqual([[[5.8, 51.80], [5.8, 51.801], [5.8, 51.802]]]) + }) + it('breaks where more than 5 minutes pass, the route jumps more than 3 km, or the hunter changes', () => { + const segs = routeSegments([ + at(0, 51.80, 5.8), at(1, 51.801, 5.8), + at(7, 51.802, 5.8), at(8, 51.803, 5.8), // 6 minutes later + at(9, 51.85, 5.8), at(10, 51.851, 5.8), // 5 km on + at(11, 51.852, 5.8, 'ph'), at(12, 51.853, 5.8, 'ph'), + ]) + expect(segs).toHaveLength(4) + }) + it('keeps no one-point segment, which draws nothing', () => { + expect(routeSegments([at(0, 51.8, 5.8), at(20, 51.9, 5.8)])).toEqual([]) + }) +}) + +// The export's cell is a size, not a row of the grid's table (#734 changes +// what a resolution means): 360 Mercator units centre to corner, about 445 m +// point to point on the ground at 52°N, the cell of #720's reference. +describe('the export cell (#666)', () => { + it('is the resolution whose size is closest to 360 Mercator units', () => { + expect(CELL_SIZE).toBe(360) + const off = (res) => Math.abs(hexSizeForRes(res) - CELL_SIZE) + for (let res = 0; res <= 21; res++) expect(off(CELL_RES), `res ${res}`).toBeLessThanOrEqual(off(res)) + }) + it('stays within a factor of √2 of that size', () => { + const ratio = hexSizeForRes(CELL_RES) / CELL_SIZE + expect(ratio).toBeGreaterThan(Math.SQRT1_2) + expect(ratio).toBeLessThan(Math.SQRT2) + }) +}) + +// The band's lines are one row each: several far stars would run the "left +// off" line past the picture's edge. +describe('fitText (#666)', () => { + const measure = (t) => t.length * 10 + it('leaves a line that fits as it is', () => { + expect(fitText('abc', 30, measure)).toBe('abc') + }) + it('cuts a line that does not fit, with an ellipsis, inside the width', () => { + const out = fitText('a'.repeat(20), 100, measure) + expect(out).toBe('a'.repeat(9) + '…') + expect(measure(out)).toBeLessThanOrEqual(100) + }) +}) + +describe('mappedCells (#666)', () => { + it('gives one res-8 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] + expect(ring).toHaveLength(7) + expect(ring[0]).toEqual(ring[6]) + expect(ring[0][0]).toBeGreaterThan(5) // lon first + expect(ring[0][1]).toBeGreaterThan(51) + }) +}) + +describe('placeLabels (#666)', () => { + const measure = (s) => s.length * 8 + it('keeps the most-heard name where two would print over each other', () => { + const kept = placeLabels([ + { id: 'quiet', x: 100, y: 100, label: 'NL-QUIET', n: 2 }, + { id: 'busy', x: 104, y: 104, label: 'NL-BUSY', n: 40 }, + ], { measure, width: 1200, height: 1000 }) + expect([...kept]).toEqual(['busy']) + }) + it('keeps both when they are clear of each other, with a margin', () => { + const kept = placeLabels([ + { id: 'a', x: 100, y: 100, label: 'AAAA', n: 1 }, + { id: 'b', x: 100, y: 124, label: 'BBBB', n: 1 }, + ], { measure, width: 1200, height: 1000, lineHeight: 16, margin: 4 }) + expect(kept.size).toBe(2) + }) + it('drops a name that would run off the image', () => { + const kept = placeLabels([{ id: 'edge', x: 1180, y: 500, label: 'NL-FAR-EAST', n: 1 }], { measure, width: 1200, height: 1000 }) + expect(kept.size).toBe(0) + }) +}) + +describe('the small rules of the band (#666)', () => { + it('tints a weak ray towards white and leaves a strong one its hue', () => { + expect(tint('#2a63d6', 1)).toBe('rgb(42,99,214)') + expect(tint('#2a63d6', 0)).toBe('rgb(159,185,237)') + }) + it('writes the window as dates', () => { + expect(windowText('2026-08-08T10:00:00Z', '2026-09-07T20:00:00Z')).toBe('8 Aug to 7 Sep 2026') + expect(windowText('2025-12-28T10:00:00Z', '2026-01-03T20:00:00Z')).toBe('28 Dec 2025 to 3 Jan 2026') + }) + it('names up to three hunters and counts more', () => { + expect(huntersText([])).toBe('') + expect(huntersText(['kas'])).toBe('1 hunter: kas') + expect(huntersText(['kas', 'PH', 'Chris'])).toBe('3 hunters: kas, PH, Chris') + expect(huntersText(['a', 'b', 'c', 'd'])).toBe('4 hunters') + }) + 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('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/exportrender.js b/web/exportrender.js new file mode 100644 index 00000000..5550f98e --- /dev/null +++ b/web/exportrender.js @@ -0,0 +1,286 @@ +// Draws the "Repeaters heard" export (#666) from exportheard.js's model: a +// second MapLibre instance, offscreen at the fixed size, for the base map and +// the lines, then a canvas for the glyphs, the names and the band. The live +// map is untouched: only the offscreen one keeps its drawing buffer. +// +// The image is always the light theme, whatever the page shows: it is a +// picture on paper colours, passed on to people who never chose a theme. +// +// 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 { HUE_COUNT } from './coverage.js' +import { STYLES } from './mapcore.js' + +export const EXPORT_STYLE = STYLES.light +const bareStyle = (bg) => ({ version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': bg } }] }) +const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' + +// lightTokens reads the light theme's tokens off a probe that carries +// data-theme="light", so the colours come from style.css and nowhere else. +export function lightTokens(doc = document) { + const probe = doc.createElement('div') + probe.setAttribute('data-theme', 'light') + probe.hidden = true + doc.body.append(probe) + const cs = getComputedStyle(probe) + const get = (n) => cs.getPropertyValue(n).trim() + 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'), + hues: Array.from({ length: HUE_COUNT }, (_, i) => get(`--ch-hue-${i}`)), + } + probe.remove() + return t +} + +// A promise for one map event, or for the time running out: an export must +// finish or fail, never hang on a tile that does not come. +const once = (map, ev, ms) => new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ms) + 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 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) + const map = new maplib.Map({ + container: box, style: EXPORT_STYLE, interactive: false, attributionControl: false, + fadeDuration: 0, preserveDrawingBuffer: true, pixelRatio: 1, + }) + 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] } }) + 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.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. + const rays = [], dots = [] + 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.sort((a, b) => a.properties.w - b.properties.w) + dots.sort((a, b) => a.properties.v - b.properties.v) + map.addSource('rays', { type: 'geojson', data: fc(rays) }) + map.addLayer({ id: 'rays', type: 'line', source: 'rays', paint: { 'line-color': ['get', 'c'], 'line-width': ['get', 'w'], 'line-opacity': ['get', 'op'] } }) + map.addSource('dots', { type: 'geojson', data: fc(dots) }) + map.addLayer({ id: 'dots', type: 'circle', source: 'dots', paint: { 'circle-color': ['get', 'c'], 'circle-radius': 2.6, 'circle-stroke-color': tokens.bg, 'circle-stroke-width': 0.7 } }) + + // Fitted to what is drawn. The right edge keeps room for the names. + if (model.drawn.length) { + const o = model.drawn[0].origin + const b = new maplib.LngLatBounds([o.lon, o.lat], [o.lon, o.lat]) + 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) + 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) + return canvas + } finally { + map.remove() + box.remove() + } +} + +// A halo in the paper's colour (tokens.bg) under text or a glyph, so it +// stays legible over the map. No colour of its own: the picture is on paper. +function halo(ctx, paper, draw) { + ctx.save() + ctx.strokeStyle = paper + ctx.globalAlpha = 0.95 + ctx.lineWidth = 3.5 + ctx.lineJoin = 'round' + draw('stroke') + ctx.restore() + draw('fill') +} + +// The ▲ at an advertised position, the ● at an estimate, in the star's hue; +// then the names that fit (placeLabels), in the same hue on a white halo. +function drawGlyphsAndNames(ctx, map, model, hue, tokens) { + const at = model.drawn.map((s) => ({ s, p: map.project([s.origin.lon, s.origin.lat]) })) + for (const { s, p } of at) { + ctx.beginPath() + if (s.origin.kind === 'advertised') { ctx.moveTo(p.x, p.y - 8); ctx.lineTo(p.x + 8, p.y + 6); ctx.lineTo(p.x - 8, p.y + 6); ctx.closePath() } + else ctx.arc(p.x, p.y, 6, 0, Math.PI * 2) + ctx.fillStyle = hue(s.slot) + ctx.strokeStyle = tokens.bg + ctx.lineWidth = 1.6 + ctx.lineJoin = 'round' + ctx.fill() + ctx.stroke() + } + ctx.font = `600 14px ${FONT}` + ctx.textBaseline = 'middle' + const kept = placeLabels(at.map(({ s, p }) => ({ id: s.id, x: p.x, y: p.y, label: s.name, n: s.n })), + { measure: (t) => ctx.measureText(t).width, width: EXPORT_W, height: EXPORT_MAP_H }) + for (const { s, p } of at) { + if (!kept.has(s.id)) continue + ctx.fillStyle = hue(s.slot) + halo(ctx, tokens.bg, (how) => (how === 'stroke' ? ctx.strokeText(s.name, p.x + 12, p.y) : ctx.fillText(s.name, p.x + 12, p.y))) + } + ctx.textBaseline = 'alphabetic' +} + +function drawMapFurniture(ctx, bar, tokens) { + const y = EXPORT_MAP_H - 16 + ctx.save() + ctx.strokeStyle = tokens.text + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(20, y - 6); ctx.lineTo(20, y); ctx.lineTo(20 + bar.px, y); ctx.lineTo(20 + bar.px, y - 6) + ctx.stroke() + 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' + ctx.font = `11px ${FONT}` + ctx.fillStyle = tokens.muted + ctx.textAlign = 'right' + halo(ctx, tokens.bg, (how) => (how === 'stroke' ? ctx.strokeText(attr, EXPORT_W - 12, EXPORT_MAP_H - 8) : ctx.fillText(attr, EXPORT_W - 12, EXPORT_MAP_H - 8))) + ctx.restore() +} + +// The brand mark (web/icon.svg's shape), drawn at 20 px. +function drawMark(ctx, x, y, tokens) { + ctx.save() + ctx.translate(x, y) + ctx.scale(20 / 512, 20 / 512) + ctx.lineJoin = 'round' + ctx.lineCap = 'round' + ctx.strokeStyle = tokens.accent + ctx.lineWidth = 26 + ctx.beginPath() + ;[[424, 256], [340, 111], [172, 111], [88, 256], [172, 401], [340, 401]].forEach(([px, py], i) => (i ? ctx.lineTo(px, py) : ctx.moveTo(px, py))) + ctx.closePath() + ctx.stroke() + ctx.lineWidth = 30 + const arcs = (r, colour) => { + const a = Math.atan2(88 * r / 112, 70 * r / 112) + ctx.strokeStyle = colour + ctx.beginPath(); ctx.arc(256, 256, r, -a, a); ctx.stroke() + ctx.beginPath(); ctx.arc(256, 256, r, Math.PI - a, Math.PI + a); ctx.stroke() + } + arcs(112, tokens.cool) + arcs(68, tokens.warm) + ctx.fillStyle = tokens.hot + ctx.beginPath(); ctx.arc(256, 256, 52, 0, Math.PI * 2); ctx.fill() + ctx.restore() +} + +// 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) { + const top = EXPORT_MAP_H + const L = 44, R = EXPORT_W - 44 + ctx.save() + ctx.fillStyle = tokens.bg + ctx.fillRect(0, top, EXPORT_W, EXPORT_H - top) + ctx.fillStyle = tokens.border + ctx.fillRect(0, top, EXPORT_W, 1) + drawMark(ctx, L, top + 23, tokens) + ctx.fillStyle = tokens.text + 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.fillStyle = tokens.muted + ctx.font = `16px ${FONT}` + ctx.fillText(text.sub, L, top + 120) + let y = top + 146 + if (text.off) { + ctx.font = `14px ${FONT}` + ctx.fillText(fitText(text.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 + return Math.max(a, b) + }) + let x = R + ctx.textAlign = 'center' + for (let i = cols.length - 1; i >= 0; i--) { + const mid = x - widths[i] / 2 + ctx.fillStyle = tokens.text + ctx.font = `700 34px ${FONT}` + ctx.fillText(cols[i][0], mid, top + 88) + ctx.fillStyle = tokens.muted + ctx.font = `15px ${FONT}` + ctx.fillText(cols[i][1], mid, top + 110) + 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.font = `13px ${FONT}` + ctx.fillStyle = tokens.muted + let lx = L + const hue0 = tokens.hues[0] + ctx.lineCap = 'round' + ctx.strokeStyle = hue0; ctx.lineWidth = 2.2 + ctx.beginPath(); ctx.moveTo(lx + 5, y - 5); ctx.lineTo(lx + 38, y - 9); ctx.stroke() + ctx.strokeStyle = tint(hue0, 0); ctx.lineWidth = 0.9 + ctx.beginPath(); ctx.moveTo(lx + 5, y - 5); ctx.lineTo(lx + 36, y + 2); ctx.stroke() + ctx.fillStyle = hue0 + ctx.beginPath(); ctx.moveTo(lx + 5, y - 11); ctx.lineTo(lx + 10, y - 2); ctx.lineTo(lx, y - 2); ctx.closePath(); ctx.fill() + ctx.fillStyle = tokens.muted + 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 + lx += 20 + ctx.fillStyle = tokens.muted + say('mapped') + ctx.strokeStyle = tokens.none; ctx.lineWidth = 3 + ctx.beginPath(); ctx.moveTo(lx, y - 5); ctx.lineTo(lx + 26, y - 5); ctx.stroke() + lx += 32 + say('route') + ctx.fillStyle = tokens.text + ctx.beginPath(); ctx.arc(lx + 5, y - 5, 5, 0, Math.PI * 2); ctx.fill() + lx += 16 + ctx.fillStyle = tokens.muted + say('estimated position (RSSI)') + ctx.restore() +} diff --git a/web/exportsheet.js b/web/exportsheet.js new file mode 100644 index 00000000..726b7cf3 --- /dev/null +++ b/web/exportsheet.js @@ -0,0 +1,87 @@ +// The Export sheet (#666): a list of the pictures the map can make of itself, +// opened from the Export button in #bar (in the menu below 900px, #561). +// 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. +import { trapFocus } from './focustrap.js' + +export function initExportSheet({ heard, reason = () => null }) { + 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 + 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 || '') + } + + function open() { + // Below 900px the button sits in the settings menu; the export sheet + // takes its place rather than opening on top of it. + const settings = document.getElementById('settings-modal') + if (settings && !settings.hidden) settings.hidden = true + modal.hidden = false + btn.setAttribute('aria-expanded', 'true') + sync() + close.focus() + } + // Focus goes back to the button, or, below 900px where the button sits in + // the settings menu that closed when this sheet opened, to that menu's + // button: a focus on a hidden button is dropped on the body. + function hide() { + modal.hidden = true + btn.setAttribute('aria-expanded', 'false') + const back = btn.offsetParent ? btn : document.getElementById('settings-btn') + ;(back || btn).focus() + } + + btn.addEventListener('click', () => (modal.hidden ? open() : hide())) + close.addEventListener('click', hide) + 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() + } + }) +} + +// A download the browser saves under `name`. The object URL outlives the +// click by a moment: a browser that starts the save asynchronously still +// finds it. +function download(blob, name) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + document.body.append(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 10000) +} diff --git a/web/hexgrid.js b/web/hexgrid.js new file mode 100644 index 00000000..4ddae4fe --- /dev/null +++ b/web/hexgrid.js @@ -0,0 +1,78 @@ +// Pure-JS port of the server's hex grid (server/internal/geo/hexgrid.go) so +// the app's live map draws the same cells as the website. Pointy-top hexes over +// Web Mercator; cell id "res:q:r". +import { resForZoom } from './zoomfade.js' + +const R = 6378137.0; + +function mercator(lat, lon) { + return [R * lon * Math.PI / 180, R * Math.log(Math.tan(Math.PI / 4 + lat * Math.PI / 360))]; +} +function invMercator(x, y) { + return [(2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * 180 / Math.PI, x / R * 180 / Math.PI]; // [lat,lon] +} + +// One size per zoom level (#634), the server's rule: a cell is about +// HEX_TARGET_PX tall on screen at every zoom, point to point. The size is the +// circumradius in Web Mercator units and halves with every resolution. +// MERC_UPP_Z0 is Web Mercator units per pixel at Leaflet zoom 0 (world / 256). +// +// Until #634 the app had ten bands of a fixed size, 1500 down to 3 Mercator +// units, which the website had left behind: a cell ran from 19 to 75 px inside +// one band, and a change of band was a jump of a factor two or more. +export const HEX_TARGET_PX = 28 +const MERC_UPP_Z0 = 156543.03392 +export function hexSizeForRes(res) { + return (HEX_TARGET_PX / 2) * MERC_UPP_Z0 / Math.pow(2, res) +} + +// The finest resolution the app draws. The server stops at 18, which is where +// the website's zoom ends. The app is zoomed in further for the walk-in, and +// 21 keeps a cell at 28 px up to MapLibre zoom 20: 2.1 Mercator units, finer +// than the 3 the old bands ended on (docs/2026-06-29-hex-resolution-zoom.md). +// Below GPS accuracy a single cell is mostly noise; the hotspot the cells pile +// up into is what reads. +export const HEX_MAX_RES = 21 + +// hexResForZoom: the resolution drawn in full at a MapLibre zoom. The map +// blends it with its neighbour around every change (zoomfade.js). +export function hexResForZoom(z) { + return resForZoom(z, HEX_MAX_RES) +} + +function hexRound(q, r) { + let x = q, z = r, y = -x - z; + let rx = Math.round(x), ry = Math.round(y), rz = Math.round(z); + const dx = Math.abs(rx - x), dy = Math.abs(ry - y), dz = Math.abs(rz - z); + if (dx > dy && dx > dz) rx = -ry - rz; + else if (dy > dz) ry = -rx - rz; + else rz = -rx - ry; + return [rx, rz]; +} + +export function hexCellAt(lat, lon, res) { + const size = hexSizeForRes(res); + const [x, y] = mercator(lat, lon); + const q = (Math.sqrt(3) / 3 * x - 1 / 3 * y) / size; + const r = (2 / 3 * y) / size; + const [qi, ri] = hexRound(q, r); + return res + ':' + qi + ':' + ri; +} + +// hexBoundary returns the cell's 6 corners as [lat,lon] pairs (Leaflet order), +// closed ring, or null on a malformed id. +export function hexBoundary(cellId) { + const p = cellId.split(':'); + if (p.length !== 3) return null; + const res = +p[0], q = +p[1], r = +p[2]; + const size = hexSizeForRes(res); + const cx = size * (Math.sqrt(3) * q + Math.sqrt(3) / 2 * r); + const cy = size * (1.5 * r); + const ring = []; + for (let i = 0; i < 6; i++) { + const a = Math.PI / 180 * (60 * i - 30); + ring.push(invMercator(cx + size * Math.cos(a), cy + size * Math.sin(a))); // [lat,lon] + } + ring.push(ring[0]); + return ring; +} diff --git a/web/index.html b/web/index.html index c3426e10..83122721 100644 --- a/web/index.html +++ b/web/index.html @@ -284,6 +284,10 @@
A picture of the map as it stands, with its filters, time window and hunters, to pass on.
+