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
14 changes: 14 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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."
}
7 changes: 7 additions & 0 deletions changelog.d/2026-09-27-11-export-selection.json
Original file line number Diff line number Diff line change
@@ -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."
}
65 changes: 65 additions & 0 deletions docs/2026-09-27-export-reach.md
Original file line number Diff line number Diff line change
@@ -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-<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 ●.

## 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
45 changes: 45 additions & 0 deletions docs/2026-09-27-export-selection.md
Original file line number Diff line number Diff line change
@@ -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-<title>-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.
14 changes: 14 additions & 0 deletions web/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading