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: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,10 @@ reception came from are different acts. The second is one rule on both surfaces,
marker. Should a hub get a name surface again, a 1-byte id reads `#` plus the id, never a bare
name (§5.4 item 6).
- Relay ids longer than 3 bytes, advert and discover keys and channel names keep their own rules.
On the node-position layer both surfaces pair an advert by its whole key, and a discover prefix
from 2 bytes only when it starts exactly one key among the nodes compared
(`groupSenderPointsForNodes`); the other kinds never pair. This replaces the website's refusal
On the node-position layer and for the reach stars (#723), both surfaces pair an advert by its
whole key, and a discover prefix from 2 bytes only when it starts exactly one key among the
nodes compared (`registryMatcher`, used by `groupSenderPointsForNodes` and `coverageStars`); the
other kinds never pair. This replaces the website's refusal
to resolve a prefix to a node on its node-position layer (#296). The decision and its limits:
`docs/2026-09-15-attribution-by-reach.md`.

Expand Down
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@
"title": "Type straight away in the target picker",
"body": "Opening the target picker puts the cursor in its search. In the app it opens on an empty search every time."
},
{
"id": "2026-09-27-discover-star-on-node",
"date": "2026-09-27",
"where": "both",
"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-dutchmeshcore-by-default",
"date": "2026-09-27",
Expand Down
46 changes: 44 additions & 2 deletions app/src/__tests__/coverage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'
import {
isRepeaterHearing, isTwoWay, hueSlot, assignHues, HUE_COUNT, NEAR_M,
rayStrength, rayStyle, ONE_WAY_OPACITY, DIM_OPACITY,
starOrigin, coverageStars, coverageFeatures, RAY_ALT_M, selectionDim, starKey, starSelected,
starOrigin, coverageStars, coverageFeatures, RAY_ALT_M, selectionDim, starKey, starKeyOf, starSelected,
} from '../coverage.js'
import { estimateFor } from '../nodelayer.js'
import { estimateFor, registryMatcher } from '../nodelayer.js'

const A = 'aa'.repeat(32), B = 'bb'.repeat(32)

Expand Down Expand Up @@ -400,3 +400,45 @@ describe('selectionDim', () => {
expect(selectionDim(new Set([A]), A.toUpperCase())).toBe(1)
})
})

// #723: a Discover reply names its responder by an 8-byte prefix. The node
// layer paired it with the one registry key it starts, the stars did not, so
// the same repeater drew a ▲ and, beside it, a star from its estimate.
describe('a discover prefix joins its node\'s star (#723)', () => {
const node = { pubkey: 'db11db11f7808b97' + 'a'.repeat(48), lat: 51.84, lon: 5.84 }
const other = { pubkey: 'db11aa' + 'b'.repeat(58), lat: 51.9, lon: 5.9 }
const heard = (id, kind, lat, lon, rssi) => ({ sender_id: id, sender_kind: kind, sender_role: 'Repeater', lat, lon, rssi })
const pts = [
heard('db11db11f7808b97', 'discover_pubkey', 51.85, 5.85, -90),
heard('db11db11f7808b97', 'discover_pubkey', 51.86, 5.83, -100),
heard(node.pubkey, 'advert_pubkey', 51.83, 5.86, -80),
]
it('hangs the prefix\'s hearings from the node, one star with its advert', () => {
const stars = coverageStars(pts, { registryNodeOf: registryMatcher([node, other]) })
expect(stars.map((s) => [s.id, s.origin.kind, s.points.length])).toEqual([[node.pubkey, 'advertised', 3]])
expect(stars[0].origin).toMatchObject({ lat: node.lat, lon: node.lon })
})
it('keeps a star of its own for a prefix that starts two keys', () => {
const twin = { pubkey: 'db11db11f7808b97' + 'c'.repeat(48), lat: 52, lon: 6 }
const stars = coverageStars(pts.slice(0, 2), { registryNodeOf: registryMatcher([node, twin]) })
expect(stars.map((s) => s.id)).toEqual([]) // two hearings: no estimate, so no origin
const three = [...pts.slice(0, 2), heard('db11db11f7808b97', 'discover_pubkey', 51.87, 5.84, -95)]
expect(coverageStars(three, { registryNodeOf: registryMatcher([node, twin]) }).map((s) => [s.id, s.origin.kind]))
.toEqual([['db11db11f7808b97', 'estimate']])
})
it('leaves a relay hash to the attribution by reach', () => {
const attributionOf = () => ({ rule: 'collision', count: 2 })
const relay = [heard('db', 'path_hash', 51.85, 5.85, -90)]
expect(coverageStars(relay, { registryNodeOf: registryMatcher([node]), attributionOf })).toEqual([])
}) // The dots take their star's hue and dim with its selection by this key,
// so it has to name the star the hearing hangs from.
it('keys each hearing by the id of the star it hangs from', () => {
const keys = { registryNodeOf: registryMatcher([node, other]) }
const [star] = coverageStars(pts, keys)
for (const pt of pts) expect(starKeyOf(pt, keys)).toBe(star.id)
})
it('keys no star for a hearing that belongs to none', () => {
expect(starKeyOf(heard('db', 'path_hash', 51.85, 5.85, -90), { attributionOf: () => ({ rule: 'collision', count: 2 }) })).toBe(null)
expect(starKeyOf({ sender_id: 'aa11', sender_kind: 'advert_pubkey', sender_role: 'Companion' })).toBe(null)
})
})
25 changes: 24 additions & 1 deletion app/src/__tests__/nodelayer.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { inBounds, nodesInView, driftPresentation, senderIdMatches, groupSenderPointsForNodes, estimateFor, circleRing, TIGHT_DRIFT_M, TRUSTED_ENCIRCLEMENT, drawableNodes } from '../nodelayer.js'
import { inBounds, nodesInView, driftPresentation, senderIdMatches, groupSenderPointsForNodes, registryMatcher, estimateFor, circleRing, TIGHT_DRIFT_M, TRUSTED_ENCIRCLEMENT, drawableNodes } from '../nodelayer.js'
import { haversineM } from '../geometry.js'

const node = (o) => ({ pubkey: 'aa'.repeat(32), name: 'Node', lat: 51.2, lon: 4.4, ...o })
Expand Down Expand Up @@ -341,3 +341,26 @@ describe('drawableNodes — registry rows that can actually be plotted', () => {
}
})
})

// #723: the node a reception's own id names, as one function the node layer
// and the reach stars both pair by, so a repeater heard by Discover gets one
// ▲ and one star under it.
describe('registryMatcher (#723)', () => {
const A = { pubkey: 'db11db11f7808b97' + 'a'.repeat(48), lat: 51.84, lon: 5.84, name: 'NL-NIJ-Dikkeboom' }
const B = { pubkey: 'db11aa' + 'b'.repeat(58), lat: 51.9, lon: 5.9, name: 'Other' }
const match = registryMatcher([A, B])
it('names the node a discover prefix alone starts', () => {
expect(match({ sender_id: 'DB11DB11F7808B97', sender_kind: 'discover_pubkey' })).toBe(A)
})
it('names the node an advert key is', () => {
expect(match({ sender_id: A.pubkey, sender_kind: 'advert_pubkey' })).toBe(A)
expect(match({ sender_id: A.pubkey.slice(0, 16), sender_kind: 'advert_pubkey' })).toBeNull()
})
it('names none when the prefix starts two keys, or none', () => {
expect(match({ sender_id: 'db11', sender_kind: 'discover_pubkey' })).toBeNull()
expect(match({ sender_id: 'ffee0011', sender_kind: 'discover_pubkey' })).toBeNull()
})
it('leaves a relay hash to the attribution by reach', () => {
expect(match({ sender_id: 'db', sender_kind: 'path_hash' })).toBeNull()
})
})
26 changes: 23 additions & 3 deletions app/src/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,22 @@ export function starKey(pt, attr) {
return String(pt.sender_id).toLowerCase()
}

// starKeyOf: the star a reception hangs from, by the rules coverageStars
// uses: reach first (attributionOf), then the one registry node a Discover
// prefix or an advert key names (registryNodeOf, #723). null for a reception
// that belongs to no repeater or to no star (a collision). The dots' hue and
// the selection's dimming ask with the same two functions the stars were
// built with, so a dot always names its own star.
function starAttribution(pt, attributionOf, registryNodeOf) {
const reach = attributionOf(pt)
const named = reach ? null : registryNodeOf(pt)
return named ? { rule: 'node', node: named } : reach
}
export function starKeyOf(pt, { attributionOf = () => null, registryNodeOf = () => null } = {}) {
if (!isRepeaterHearing(pt) || pt.sender_id == null) return null
return starKey(pt, starAttribution(pt, attributionOf, registryNodeOf))
}

// starSelected: a selection holds raw ids (the app's selected senders, the
// map's picker), while a star is keyed by its node once a hearing is
// attributed. A star is selected by its own id or by the id of any hearing in it.
Expand All @@ -176,7 +192,11 @@ export function starSelected(star, selected) {

// coverageStars groups the repeater hearings by starKey and hangs each star
// from its origin. attributionOf(pt) answers a hearing's attribution, or null
// where none was worked out, which groups by id as before. A star keyed by an
// where none was worked out, which groups by id as before. registryNodeOf(pt)
// (nodelayer.js registryMatcher) answers the registry node a hearing's own id
// names, an advert key or a discover prefix that starts one key only (#723):
// such a hearing joins that node's star, as it joins the node on the node
// layer, rather than hanging a star of its own from an estimate. A star keyed by an
// attributed node hangs from that node's advertised position; otherwise
// positionOf(id) answers the registry's advertised position or null.
// estimate(points) is the node layer's estimateFor unless a test says
Expand All @@ -189,13 +209,13 @@ export function starSelected(star, selected) {
// hear nothing new. So a star's estimate is reused while its hearings are the
// same positions and RSSIs in the same order, and the cache keeps only the
// stars of this call. The registry position is read every call.
export function coverageStars(points, { positionOf = () => null, estimate = estimateFor, cache = null, attributionOf = () => null } = {}) {
export function coverageStars(points, { positionOf = () => null, estimate = estimateFor, cache = null, attributionOf = () => null, registryNodeOf = () => null } = {}) {
const byId = new Map()
const nodeOf = new Map()
for (const pt of points || []) {
if (!isRepeaterHearing(pt) || pt.sender_id == null) continue
if (!Number.isFinite(pt.lat) || !Number.isFinite(pt.lon)) continue
const attr = attributionOf(pt)
const attr = starAttribution(pt, attributionOf, registryNodeOf)
const id = starKey(pt, attr)
if (id == null) continue
if (attr && attr.rule === 'node') nodeOf.set(id, attr.node)
Expand Down
26 changes: 16 additions & 10 deletions app/src/huntmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { hexCellAt, hexBoundary, hexResForZoom, HEX_MAX_RES } from './hexgrid.js
import { pointShare, backlogShare, labelShare, hexSlots, resForZoom, hexFillOpacity, hexBarHeight, hexBarSwitch, pointFillOpacity, pointStrokeOpacity, pillarSwitch } from './zoomfade.js'
import { rssiTier, tierColorVar, fillOpacity, effectivePlotOffset, extrusionHeight, tintOver, pillarAlpha, EXTRUSION_LIGHT_INTENSITY } from './signal.js'
import { getConfig } from './config.js'
import { nodesInView, driftPresentation, groupSenderPointsForNodes, estimateFor, circleRing } from './nodelayer.js'
import { nodesInView, driftPresentation, groupSenderPointsForNodes, estimateFor, circleRing, registryMatcher } from './nodelayer.js'
import { unclutteredLabels, createLabelMeasurer, screenObstacles } from './nodelabels.js'
import { advertFeature, dotFeature, nodeGlyphLayers, nearestGlyph, hitBox, drawTriangle, fc as glyphFc, EMPTY_FC as GLYPH_EMPTY, NODE_GLYPH_SOURCE, NODE_DOT_SOURCE, NODE_ADVERT_LAYER, NODE_DOT_LAYER, NODE_GLYPH_LAYERS, TRI_IMAGE, TRI_W, TRI_H } from './nodeglyphs.js'
import { appendTrailPoint } from './trail.js'
import { packetTypeLabel } from './filters.js'
import { layerVisibility, pitchTransition } from './maplayers.js'
import { coverageStars, coverageFeatures, assignHues, isRepeaterHearing, selectionDim, starKey, starSelected } from './coverage.js'
import { coverageStars, coverageFeatures, assignHues, selectionDim, starKeyOf, starSelected } from './coverage.js'
import { createRayLayer } from './raylayer.js'
import { octagonRing, pillarRadiusM, collapsePillars, PILLAR_MERGE_M } from './pointmarker.js'
import { recordsKey, lastValueCache, hueKey, selectionKey, ownersKey } from './rendercache.js'
Expand Down Expand Up @@ -204,21 +204,24 @@ export function createHuntMap(containerId) {
if (!coverageSel.size) return
coverageSel.clear(); nodePosSig = null; draw()
}
// What the stars of the last draw were keyed with (starKeyOf), so a dot and
// a selection name the star the hearing hangs from, a Discover prefix
// included (#723).
let starKeys = { attributionOf }
// The dots of the points layer take the repeater's hue while the reach is
// on (#603), the tier colour otherwise; the pillars keep the tier. A hearing
// placed on a node by reach takes that node's star's hue, a collided one
// none (starKey, #661).
// takes the hue of the star it hangs from, a collided one none (starKeyOf,
// #661).
function pointHue(r) {
if (!coverageHue.size || !isRepeaterHearing(r) || r.sender_id == null) return null
const key = starKey(r, attributionOf(r))
if (!coverageHue.size) return null
const key = starKeyOf(r, starKeys)
return key == null ? null : coverageHue.get(key) || null
}
// The star a reception belongs to, by the same attribution the stars use
// (starKey, #661): the node's pubkey when reach placed it, its own id
// otherwise, and null for a collision or a reception that belongs to no
// (starKeyOf): null for a collision or a reception that belongs to no
// repeater (#624). What selectionDim is asked about for a dot, a cell or a
// pillar.
const ownerOf = (r) => (isRepeaterHearing(r) && r.sender_id != null ? starKey(r, attributionOf(r)) : null)
const ownerOf = (r) => starKeyOf(r, starKeys)
// Builds the stars for this tick, puts the rays up (one setData feeds the
// 2D line source and the 3D ray layer), a ● hub for a star with no registry
// position, and remembers the hues. Returns the selection for the markers:
Expand All @@ -233,7 +236,10 @@ export function createHuntMap(containerId) {
}
const byKey = new Map(nodePositions.map((n) => [String(n.pubkey).toLowerCase(), n]))
const positionOf = (id) => { const n = byKey.get(id); return n ? { lat: n.lat, lon: n.lon } : null }
const stars = coverageStars(lastReachRows || records, { positionOf, cache: starCache, attributionOf })
// registryNodeOf (#723): a Discover prefix joins its node's star, as it
// joins the node on the node layer.
starKeys = { attributionOf, registryNodeOf: registryMatcher(nodePositions) }
const stars = coverageStars(lastReachRows || records, { positionOf, cache: starCache, ...starKeys })
const hues = assignHues(stars.map((st) => ({ id: st.id, lat: st.origin.lat, lon: st.origin.lon })))
const colorOf = (slot) => cssVar(`--ch-hue-${slot}`)
const selected = coverageSelected()
Expand Down
39 changes: 29 additions & 10 deletions app/src/nodelayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,12 @@ export function senderIdMatches(senderId, senderKind, nodePubkey) {
const HEAD_HEX = 4
export function groupSenderPointsForNodes(records, nodes, { attributionOf = () => null } = {}) {
const out = new Map()
const byHead = new Map()
for (const n of nodes || []) {
const k = n && n.pubkey ? String(n.pubkey).toLowerCase() : null
if (!k) continue
out.set(k, [])
const head = k.slice(0, HEAD_HEX)
if (!byHead.has(head)) byHead.set(head, [])
byHead.get(head).push(k)
if (k) out.set(k, [])
}
if (!Array.isArray(records) || out.size === 0) return out
const nodeOf = registryMatcher(nodes)

for (const r of records) {
if (!r || r.sender_id == null) continue
Expand All @@ -169,16 +165,39 @@ export function groupSenderPointsForNodes(records, nodes, { attributionOf = () =
if (bucket) bucket.push({ lat: r.lat, lon: r.lon, rssi: r.rssi })
continue
}
if (!isRegistryIdKind(r.sender_kind)) continue
const n = nodeOf(r)
if (n) out.get(String(n.pubkey).toLowerCase()).push({ lat: r.lat, lon: r.lon, rssi: r.rssi })
}
return out
}

// registryMatcher: the node a reception's own id names among `nodes`, by
// senderIdMatches, or null when it names none or more than one (ambiguous ids
// contribute to nothing, as above). An advert names its whole key, a discover
// reply a prefix of it; a relay hash is left to the attribution by reach.
// groupSenderPointsForNodes and the reach stars (coverage.js, #723) both pair
// by it, so a repeater heard by Discover has one ▲ and its star under it.
export function registryMatcher(nodes) {
const byHead = new Map()
const byKey = new Map()
for (const n of nodes || []) {
const k = n && n.pubkey ? String(n.pubkey).toLowerCase() : null
if (!k) continue
byKey.set(k, n)
const head = k.slice(0, HEAD_HEX)
if (!byHead.has(head)) byHead.set(head, [])
byHead.get(head).push(k)
}
return (r) => {
if (!r || r.sender_id == null || !isRegistryIdKind(r.sender_kind)) return null
let matched = null
for (const k of byHead.get(String(r.sender_id).toLowerCase().slice(0, HEAD_HEX)) || []) {
if (!senderIdMatches(r.sender_id, r.sender_kind, k)) continue
if (matched !== null) { matched = null; break } // ambiguous -> drop it
if (matched !== null) return null // ambiguous -> nothing
matched = k
}
if (matched) out.get(matched).push({ lat: r.lat, lon: r.lon, rssi: r.rssi })
return matched ? byKey.get(matched) : null
}
return out
}

// estimateFor is web/locate.js's locate() without the density grid and the
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/2026-09-27-08-discover-star-on-node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"id": "2026-09-27-discover-star-on-node",
"date": "2026-09-27",
"where": "both",
"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."
}
Loading
Loading