Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/2026-09-27-09-export-repeaters-heard.json
Original file line number Diff line number Diff line change
@@ -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."
}
39 changes: 39 additions & 0 deletions docs/2026-09-27-export-repeaters-heard.md
Original file line number Diff line number Diff line change
@@ -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."
33 changes: 26 additions & 7 deletions web/barnarrow.js
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -32,13 +33,22 @@
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' },
]

// 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
10 changes: 6 additions & 4 deletions web/barnarrow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
]),
Expand All @@ -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'])
})
})

Expand All @@ -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)
})
})
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions web/e2e/export.spec.js
Original file line number Diff line number Diff line change
@@ -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()
})
Loading
Loading