Skip to content
Open
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 @@ -90,6 +90,13 @@
"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-dutchmeshcore-by-default",
"date": "2026-09-27",
Expand Down
11 changes: 6 additions & 5 deletions app/src/signal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand All @@ -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'
}

Expand Down
7 changes: 7 additions & 0 deletions changelog.d/2026-09-27-10-export-reach.json
Original file line number Diff line number Diff line change
@@ -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."
}
66 changes: 66 additions & 0 deletions docs/2026-09-27-export-reach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 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-<name>-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 ●.
- **The cap the band reports is that of the fetch the picture uses.** With a target picked, the stars and the cells driven come from a fetch without the sender filter, which reaches the 25,000 cap sooner. The band reports that fetch's cap, and when it fails it says the picture holds only the target's hearings (`exportPoints`). Repeaters heard reads the same fetch.

## 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.
3 changes: 2 additions & 1 deletion docs/2026-09-27-export-repeaters-heard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@
"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-dutchmeshcore-by-default",
"date": "2026-09-27",
Expand Down
72 changes: 72 additions & 0 deletions web/e2e/export.spec.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -79,3 +80,74 @@ 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)
})
Loading
Loading