From 82588a09fb3553ecbc405e870137d0e6cb3844cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Sun, 27 Sep 2026 15:29:52 +0200 Subject: [PATCH] fix(app,web): hang a Discover-heard repeater's star from its own position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Discover reply carries a prefix of the responder's key. The node layer paired it with the registry node whose key it alone starts (#661); the reach stars did not, and looked the prefix up as a whole key, which it never is. So the repeater drew its ▲ and, beside it, a star hung from its RSSI estimate with the prefix as its name. The pairing is now one function, registryMatcher in nodelayer.js, which groupSenderPointsForNodes and coverageStars both use; AGENTS.md §7 names both. On the data of 8 Aug to 7 Sep around Nijmegen, 63 of 71 stars now hang from their ▲, where 5 of 66 did. Closes #723 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +-- app/changelog.json | 7 +++ app/src/__tests__/coverage.test.js | 46 ++++++++++++++++++- app/src/__tests__/nodelayer.test.js | 25 +++++++++- app/src/coverage.js | 26 +++++++++-- app/src/huntmap.js | 26 +++++++---- app/src/nodelayer.js | 39 ++++++++++++---- .../2026-09-27-08-discover-star-on-node.json | 7 +++ docs/2026-09-08-coverage-overview.md | 2 +- web/changelog.json | 7 +++ web/coverage.js | 26 +++++++++-- web/map.js | 31 +++++++------ web/nodelayer.js | 39 ++++++++++++---- web/parity.test.js | 18 +++++++- 14 files changed, 249 insertions(+), 57 deletions(-) create mode 100644 changelog.d/2026-09-27-08-discover-star-on-node.json diff --git a/AGENTS.md b/AGENTS.md index e0c77960..bb8c09eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -611,9 +611,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`. diff --git a/app/changelog.json b/app/changelog.json index b2ca7c2a..ee4a1d89 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -20,6 +20,13 @@ "title": "A larger audio buffer", "body": "Sound uses a larger output buffer, against ticking while the app is busy right after start." }, + { + "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-26-broker-presets-shown", "date": "2026-09-26", diff --git a/app/src/__tests__/coverage.test.js b/app/src/__tests__/coverage.test.js index 3e68dac3..c8f8a85b 100644 --- a/app/src/__tests__/coverage.test.js +++ b/app/src/__tests__/coverage.test.js @@ -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) @@ -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) + }) +}) diff --git a/app/src/__tests__/nodelayer.test.js b/app/src/__tests__/nodelayer.test.js index 3a17f326..a9d9e851 100644 --- a/app/src/__tests__/nodelayer.test.js +++ b/app/src/__tests__/nodelayer.test.js @@ -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 }) @@ -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() + }) +}) diff --git a/app/src/coverage.js b/app/src/coverage.js index e8805741..406459b1 100644 --- a/app/src/coverage.js +++ b/app/src/coverage.js @@ -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. @@ -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 @@ -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) diff --git a/app/src/huntmap.js b/app/src/huntmap.js index 22a67a1e..7c24ea5a 100644 --- a/app/src/huntmap.js +++ b/app/src/huntmap.js @@ -1,13 +1,13 @@ import { hexCellAt, hexBoundary, hexResForZoom } from './hexgrid.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' @@ -187,21 +187,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: @@ -216,7 +219,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() diff --git a/app/src/nodelayer.js b/app/src/nodelayer.js index c94e7ced..9bb47f05 100644 --- a/app/src/nodelayer.js +++ b/app/src/nodelayer.js @@ -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 @@ -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 diff --git a/changelog.d/2026-09-27-08-discover-star-on-node.json b/changelog.d/2026-09-27-08-discover-star-on-node.json new file mode 100644 index 00000000..28ee225a --- /dev/null +++ b/changelog.d/2026-09-27-08-discover-star-on-node.json @@ -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." +} diff --git a/docs/2026-09-08-coverage-overview.md b/docs/2026-09-08-coverage-overview.md index c9ae72d5..5ef0e41b 100644 --- a/docs/2026-09-08-coverage-overview.md +++ b/docs/2026-09-08-coverage-overview.md @@ -23,7 +23,7 @@ Node positions now has a third stop, **Positions + reach**: every repeater in vi 5. **No cap** (2026-09-08). Every repeater with hearings in the view draws; the zoom decides what is legible. A render of the real data (Nijmegen to Arnhem, all time) drew 41 repeaters with 10 or more hearings; a top-N dropped Arnhem. 6. **One selected** (2026-09-08). A tap on a repeater's ▲ or ● selects its star, and so does picking it as a target; both drive one selection. The selected star draws as it is, its name in a pill on the marker; every other star drops to 25% in its own hue, so the hand-over to the neighbours stays readable. A second tap, or a tap on bare map, clears it. With a target picked the plotted set is narrowed to it, so the stars come from the same window without the sender filter. 7. **3D from the mast** (2026-09-08). Each ray starts 30 m above the ground at the repeater and lands at ground level at the hearing. A MapLibre line layer lies on the ground, so this is a custom WebGL layer (`raylayer.js`): each ray a screen-space quad between two mercator points with altitude, the terrain's height under both ends when the mesh is on. No deck.gl: it would have been the first dependency either map carries for one layer, and the layer is under 200 lines. The quads take 32-bit indices, so on a WebGL1 context without `OES_element_index_uint` the layer is not mounted and the console says so once; the flat rays still draw in 2D. -8. **Attribution** is `classifyReception`'s rule: the originator at zero hops, or the last relay of a flood. On a record: `sender_role` Repeater, or `sender_kind` relay, discover_pubkey or trace_reply. A relay hash has no registry position, so that star hangs from the RSSI estimate (●); a full pubkey the registry places hangs from its ▲. +8. **Attribution** is `classifyReception`'s rule: the originator at zero hops, or the last relay of a flood. On a record: `sender_role` Repeater, or `sender_kind` relay, discover_pubkey or trace_reply. A relay hash has no registry position, so that star hangs from the RSSI estimate (●); a full pubkey the registry places hangs from its ▲. (Amended 27 September 2026, #723: a Discover reply carries a prefix of the key, not the whole key, so a repeater heard by Discover hung its star from an estimate beside its own ▲. It now joins the node its prefix alone starts, by `registryMatcher` in `nodelayer.js`, the rule the node layer pairs by since #661. On the data of 8 Aug to 7 Sep around Nijmegen that moved 61 of 66 stars from an estimate to 63 of 71 under their ▲. The dots' hue and the dimming of a selection ask the same question through `starKeyOf` in `coverage.js`, so a Discover dot takes its node's hue and stays lit with its ▲.) > **Amended 2026-09-15 by `docs/2026-09-15-attribution-by-reach.md` (#661).** A relay id of 1, 2 or 3 bytes (`relay`, and `path_hash` at 1 byte) is placed per hearing by reach: `starKey` in `coverage.js` holds the rule, and both maps take it in the same bundle. A hearing with exactly one positioned registry node of that prefix within reach joins that node's star, keyed by its pubkey and hung from its ▲. A hearing with two or more within reach joins no star. Every other hearing of such an id keys a star of that id, hung from the RSSI estimate (●) over those hearings only. diff --git a/web/changelog.json b/web/changelog.json index b2ca7c2a..ee4a1d89 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -20,6 +20,13 @@ "title": "A larger audio buffer", "body": "Sound uses a larger output buffer, against ticking while the app is busy right after start." }, + { + "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-26-broker-presets-shown", "date": "2026-09-26", diff --git a/web/coverage.js b/web/coverage.js index e8805741..406459b1 100644 --- a/web/coverage.js +++ b/web/coverage.js @@ -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. @@ -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 @@ -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) diff --git a/web/map.js b/web/map.js index 9cb6a87b..8395509e 100644 --- a/web/map.js +++ b/web/map.js @@ -1,7 +1,7 @@ import { tierColorVar } from './signal.js' import { createWebMap } from './mapcore.js' import { leafletZoom, mapZoomFromLeaflet, zoomParam, pointFeatures, hexFeatures, pillarFeatures, observerFeatures, locateFeatures, heatImageData, imageCoordinates, latLonBounds, cameraFor, angleParam } from './mapmodel.js' -import { coverageStars, coverageFeatures, assignHues, isRepeaterHearing, selectionDim, starKey, starSelected } from './coverage.js' +import { coverageStars, coverageFeatures, assignHues, selectionDim, starKeyOf, starSelected } from './coverage.js' import { advertFeature, dotFeature, fc as glyphFc, NODE_GLYPH_SOURCE, NODE_DOT_SOURCE } from './nodeglyphs.js' import { registryIndex, attributeReception, REACH_CAP_KM } from './attribution.js' import { EXAGGERATION_STEPS, DEFAULT_EXAGGERATION } from './terrain.js' @@ -9,7 +9,7 @@ import { API_BASE } from './config.js' import { resolveName, cachedName, isFullPubkey, isResolvableId, senderName, resolvableKey } from './names.js' import { loadSeenRole, saveSeenRole, roleRose, roleNotice } from './rolechange.js' import { locate, toLocatePoints } from './locate.js' -import { groupSenderPointsForNodes, nodesInView, padBounds, circleRing, nodeRows } from './nodelayer.js' +import { groupSenderPointsForNodes, nodesInView, padBounds, circleRing, nodeRows, registryMatcher } from './nodelayer.js' import { nodePosPresentation, registryStatusFor, nextNotice } from './nodeposnotice.js' import { unclutteredLabels, createLabelMeasurer, screenObstacles } from './nodelabels.js' import { fetchPointsPaged } from './pagedpoints.js' @@ -1054,10 +1054,12 @@ const coverageSel = new Set() let coverageHue = new Map() // Each star's estimate, kept from one draw to the next (coverage.js). const starCache = new Map() -// Each hearing's attribution by reach in the last draw (#661, attribution.js), -// for the dots' hues between draws: a relay id placed on a node takes that -// node's hue, a collided one none. -let coverageAttributionOf = () => null +// What the stars of the last draw were keyed with (starKeyOf): the +// attribution by reach (#661, attribution.js) and the registry match (#723), +// for the dots' hues and the dimming between draws. A relay id placed on a +// node and a Discover prefix of one node take that node's hue, a collided +// hearing none. +let coverageStarKeys = { attributionOf: () => null } // The stars of the last draw, so a repaint between draws can tell which star a // selection picks (starSelected) without building them again. let coverageStarList = [] @@ -1110,12 +1112,15 @@ function buildCoverage(points, registryNodes, attributionOf) { if (!coverageOn()) return null const byKey = new Map((registryNodes || []).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(points, { positionOf, cache: starCache, attributionOf }) + // registryNodeOf (#723): a Discover prefix joins its node's star, as it + // joins the node on the node layer. + const starKeys = { attributionOf, registryNodeOf: registryMatcher(registryNodes) } + const stars = coverageStars(points, { 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() const selectedStars = new Set(selected.size ? stars.filter((st) => starSelected(st, selected)).map((st) => st.id) : []) - return { stars, hues, colorOf, selected, selectedStars, attributionOf, fc: coverageFeatures(stars, { slotOf: (id) => hues.get(id), colorOf, selected }) } + return { stars, hues, colorOf, selected, selectedStars, starKeys, fc: coverageFeatures(stars, { slotOf: (id) => hues.get(id), colorOf, selected }) } } window.__coverageSel = () => [...coverageSel] // test hook window.__rayCount = () => wm.rayCount() // test hook @@ -1593,7 +1598,7 @@ wm.onGlyphTap(({ kind, key }, at) => { function drawCoverage(cov) { if (!cov) { clearCoverageLayer(); repaintSelection(); return } coverageHue = new Map([...cov.hues].map(([id, slot]) => [id, cov.colorOf(slot)])) - coverageAttributionOf = cov.attributionOf + coverageStarKeys = cov.starKeys coverageStarList = cov.stars wm.setData('reach', cov.fc) // The ● hub of a star with no registry position, in the star's hue. A @@ -1627,7 +1632,7 @@ function repaintSelection() { // point (coverageSelected builds a fresh Set each call). Only in the reach // stop, where a selection means something, so a target picked with the reach // off leaves the map at full strength. dimFor asks about a reception's -// repeater by the stars' own attribution: the star starKey puts it in (#661), +// repeater by the stars' own attribution: the star starKeyOf puts it in (#661), // none for a collision, lit when that star is picked by its own id or by the // raw id of a hearing in it (starSelected), so a hearing placed on the node of // a picked relay id stays lit with its star. cellDim is the single factor @@ -1637,12 +1642,12 @@ function selectionDimmer() { const lit = sel && sel.size ? new Set([...sel, ...coverageStarList.filter((st) => starSelected(st, sel)).map((st) => st.id)]) : sel - const owner = (pt) => (isRepeaterHearing(pt) && pt.sender_id != null ? starKey(pt, coverageAttributionOf(pt)) : null) + const owner = (pt) => starKeyOf(pt, coverageStarKeys) return { dimFor: (pt) => selectionDim(lit, owner(pt)), cellDim: selectionDim(sel) } } function pointHue(pt) { - if (!coverageHue.size || !isRepeaterHearing(pt) || pt.sender_id == null) return null - const key = starKey(pt, coverageAttributionOf(pt)) + if (!coverageHue.size) return null + const key = starKeyOf(pt, coverageStarKeys) return key == null ? null : coverageHue.get(key) || null } diff --git a/web/nodelayer.js b/web/nodelayer.js index c01a39f5..60bc717f 100644 --- a/web/nodelayer.js +++ b/web/nodelayer.js @@ -174,16 +174,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 @@ -194,16 +190,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 locate() without the density grid: the layer needs a centroid diff --git a/web/parity.test.js b/web/parity.test.js index b74f6bb6..f3f7a357 100644 --- a/web/parity.test.js +++ b/web/parity.test.js @@ -872,7 +872,7 @@ describe('calloutPosition — parity between the app and web copies', () => { describe('nodelayer — parity of the shared core', () => { const SHARED = ['TIGHT_DRIFT_M', 'TRUSTED_ENCIRCLEMENT', 'circleRing', 'drawableNodes', 'driftPresentation', 'estimateFor', 'groupSenderPointsForNodes', 'inBounds', - 'isRegistryIdKind', 'nodesInView', 'senderIdMatches'] + 'isRegistryIdKind', 'nodesInView', 'registryMatcher', 'senderIdMatches'] it('keeps the shared core present on both sides, and the divergence deliberate', () => { const names = (m) => Object.keys(m).sort() @@ -890,6 +890,22 @@ describe('nodelayer — parity of the shared core', () => { // reception with a registry node the same way. An advert by its whole key, a // discover prefix from 2 bytes when it starts one key only, and a relay, path // or direct hash through its attribution by reach, never by comparing keys. + // #723: the reach stars pair by the same matcher, so one surface cannot + // hang a Discover star from its node while the other hangs it from an + // estimate. + it('names the same registry node for a reception on both surfaces', () => { + const key = (head, fill) => head + fill.repeat((64 - head.length) / 2) + const A = { pubkey: key('db11db', '11'), lat: 51.84, lon: 5.84 } + const B = { pubkey: key('db11aa', '22'), lat: 51.9, lon: 5.9 } + const cases = [['db11db', 'discover_pubkey'], ['db11', 'discover_pubkey'], [A.pubkey, 'advert_pubkey'], ['db', 'path_hash']] + const web = webLayer.registryMatcher([A, B]), app = appLayer.registryMatcher([A, B]) + for (const [id, kind] of cases) { + const r = { sender_id: id, sender_kind: kind } + expect(web(r), `${kind} ${id}`).toBe(app(r)) + } + expect(web({ sender_id: 'db11db', sender_kind: 'discover_pubkey' })).toBe(A) + }) + it('attributes relays through attribution and refuses an ambiguous discover prefix the same way on both surfaces', () => { const key = (head, fill) => head + fill.repeat((64 - head.length) / 2) const P = { pubkey: key('cc', 'cc'), name: 'Advertiser', lat: 51.01, lon: 4 }