diff --git a/app/changelog.json b/app/changelog.json index 89cbe942..cfee3e01 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -41,6 +41,13 @@ "title": "Space under the HUD on Android", "body": "The HUD's buttons no longer sit on Android's navigation bar." }, + { + "id": "2026-09-27-noise-values", + "date": "2026-09-27", + "where": "app", + "title": "The noise floor as a number", + "body": "Noise cells show their median in dBm from zoom 16, and the HUD shows the latest reading. The sender moved beside the number." + }, { "id": "2026-09-27-float-no-pause", "date": "2026-09-27", diff --git a/app/index.html b/app/index.html index ee82ec3f..74394150 100644 --- a/app/index.html +++ b/app/index.html @@ -103,11 +103,11 @@
- +
+
+
+ +
+
+
diff --git a/app/src/__tests__/hudlayout.test.js b/app/src/__tests__/hudlayout.test.js index 255979b0..bd273460 100644 --- a/app/src/__tests__/hudlayout.test.js +++ b/app/src/__tests__/hudlayout.test.js @@ -26,28 +26,45 @@ const subject = (selector) => selector.split(/\s*[>+~]\s*|\s+/).filter(Boolean). const rulesStyling = (id) => rules.filter((r) => r.selectors.some((s) => namesId(id, subject(s)))) const where = (r) => r.selectors.join(', ') -describe('the HUD readout rows (#637, #618)', () => { +describe('the HUD readout rows (#637, #618, #708)', () => { // #637: SNR shared the row's deficit with the sender (min-width:0 and an - // ellipsis), so a long relay name cut it to "SNR -1…". The sender has its - // own row now, and the SNR keeps its digits. No rule may take that back. - it('never shrinks the SNR (#637)', () => { - const snr = rulesStyling('hud-snr') - expect(snr.length, 'app.css declares #hud-snr').toBeGreaterThan(0) - expect(snr.some((r) => /(^|[;\s])flex:\s*none\s*(;|$)/.test(r.body))).toBe(true) - for (const r of snr) { - expect(r.body, where(r)).not.toMatch(/text-overflow/) - expect(r.body, where(r)).not.toMatch(/(^|[;\s])min-width:\s*0/) - expect(r.body, where(r)).not.toMatch(/(^|[;\s])max-width:/) - expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex:(?!\s*none\s*(;|$))/) - expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex-shrink:(?!\s*0\s*(;|$))/) + // ellipsis), so a long relay name cut it to "SNR -1…". Since #708 the + // measurements have a row of their own, numbers of a known widest form, and + // none of them gives up digits. No rule may take that back. + for (const id of ['hud-snr', 'hud-noise']) { + it(`never shrinks #${id} (#637)`, () => { + const own = rulesStyling(id) + expect(own.length, `app.css declares #${id}`).toBeGreaterThan(0) + expect(own.some((r) => /(^|[;\s])flex:\s*none\s*(;|$)/.test(r.body))).toBe(true) + for (const r of own) { + expect(r.body, where(r)).not.toMatch(/text-overflow/) + expect(r.body, where(r)).not.toMatch(/(^|[;\s])min-width:\s*0/) + expect(r.body, where(r)).not.toMatch(/(^|[;\s])max-width:/) + expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex:(?!\s*none\s*(;|$))/) + expect(r.body, where(r)).not.toMatch(/(^|[;\s])flex-shrink:(?!\s*0\s*(;|$))/) + } + }) + } + + // Kasper, 27 September (#708): who beside the number, the measurements + // under it. The sender is the only thing in row 1 that can give up width, + // and row 2 holds only numbers. + it('puts the number and who in row 1, the measurements in row 2', () => { + const html = readFileSync(new URL('../../index.html', import.meta.url), 'utf8') + const ids = (rowId) => { + const start = html.indexOf(`id="${rowId}"`) + const body = html.slice(start, html.indexOf('\n ', start)) + return [...body.matchAll(/\bid="(hud-[\w-]+)"/g)].map((m) => m[1]).filter((id) => id !== rowId) } + expect(ids('hud-readout-row')).toEqual(['hud-dir', 'hud-rssi', 'hud-sender', 'hud-backlog']) + expect(ids('hud-measure-row')).toEqual(['hud-snr', 'hud-noise', 'hud-since']) }) // #hud is anchored to the bottom and the FAB rail sits a fixed distance above // it (#264), so a row that grows moves every button. The direction arrow // (#660) goes into row 1 and must not be able to do that. it('fixes the height of both readout rows, so an arrow cannot move the FAB rail', () => { - for (const id of ['hud-readout-row', 'hud-sender-row']) { + for (const id of ['hud-readout-row', 'hud-measure-row']) { const row = rulesStyling(id) expect(row.some((r) => /(^|[;\s])height:\s*\d+(\.\d+)?px\s*(;|$)/.test(r.body)), `#${id} declares a px height`).toBe(true) for (const r of row) { @@ -126,7 +143,7 @@ describe('the HUD before app.js runs (#618)', () => { expect(sender, 'index.html has #hud-sender holding text only').toBeTruthy() expect(sender[1]).toMatch(/\bclass="([^"]*\s)?empty(\s[^"]*)?"/) expect(sender[2]).toBe(senderReadout(null).text) - for (const id of ['hud-rssi', 'hud-snr', 'hud-since']) { + for (const id of ['hud-rssi', 'hud-snr', 'hud-noise', 'hud-since']) { expect(slot(id), `index.html has #${id} holding text only`).toBeTruthy() expect(slot(id)[2], `#${id}`).toBe('') } diff --git a/app/src/__tests__/noise.test.js b/app/src/__tests__/noise.test.js index fe792804..10bc2923 100644 --- a/app/src/__tests__/noise.test.js +++ b/app/src/__tests__/noise.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample, noiseCells, noiseFill, withNoise, noiseHexFC, NOISE_BANDS, CMD_GET_STATS, RESP_CODE_STATS, STATS_TYPE_RADIO } from '../noise.js' +import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample, noiseCells, noiseFill, withNoise, noiseSpotFC, noiseRadius, noiseLabelItems, noiseReadout, NOISE_LIVE_MS, NOISE_SPOT_SPREAD, PX_PER_MERCATOR_M_Z0, NOISE_BANDS, CMD_GET_STATS, RESP_CODE_STATS, STATS_TYPE_RADIO } from '../noise.js' import { INTERVAL_MS, MOVE_THRESHOLD_M } from '../autoping.js' // STATS_TYPE_RADIO (docs/stats_binary_frames.md in meshcore-dev/MeshCore), 14 @@ -125,22 +125,75 @@ describe('the noise layer on the map (#410)', () => { expect(withNoise(both, false)).toEqual({ ...both, noise: false }) }) - it('draws one hex per cell, carrying its median', () => { + // Kasper, 28 September: the same cells and medians, drawn as soft spots + // rather than hexes. A hex read as one of the signal cells, while a noise + // floor is a property of the place around the sample. One blurred circle + // per cell at its centre, wider than the cell so neighbours flow together. + it('draws one spot per cell at its centre, carrying its median and the cell radius', () => { const samples = [ { lat: 51.84, lon: 5.85, noise_floor: -100, stationary: false, session: 's' }, { lat: 51.84, lon: 5.85, noise_floor: -110, stationary: false, session: 's' }, { lat: 52.0, lon: 5.0, noise_floor: -120, stationary: false, session: 's' }, ] const cellAt = (lat) => (lat > 51.9 ? 'far' : 'near') - const boundary = (id) => (id === 'near' ? [[0, 0], [0, 1], [1, 1], [0, 0]] : [[2, 2], [2, 3], [3, 3], [2, 2]]) - const fc = noiseHexFC(samples, cellAt, boundary) - expect(fc.features.map((f) => f.properties)).toEqual([{ nf: -105, count: 2 }, { nf: -120, count: 1 }]) - // hexBoundary answers [lat, lon]; GeoJSON wants [lon, lat]. - expect(fc.features[0].geometry).toEqual({ type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }) + const centreOf = (id) => (id === 'near' ? [51.8401, 5.8499] : [52.0002, 5.0001]) + const fc = noiseSpotFC(samples, cellAt, centreOf, 360) + expect(fc.features.map((f) => f.properties)).toEqual([{ id: 'near', nf: -105, count: 2, r: 360 }, { id: 'far', nf: -120, count: 1, r: 360 }]) + // hexgrid answers [lat, lon]; GeoJSON wants [lon, lat]. + expect(fc.features[0].geometry).toEqual({ type: 'Point', coordinates: [5.8499, 51.8401] }) }) - it('skips a cell whose boundary does not parse', () => { - const fc = noiseHexFC([{ lat: 1, lon: 1, noise_floor: -100 }], () => 'x', () => null) + it('skips a cell whose centre does not parse', () => { + const fc = noiseSpotFC([{ lat: 1, lon: 1, noise_floor: -100 }], () => 'x', () => null, 360) expect(fc.features).toEqual([]) }) + + // The radius follows the zoom so a spot stays the cell's size on the + // ground: r is in Web Mercator metres, and at zoom z a mercator metre is + // 512 * 2^z / (2 * pi * 6378137) px, so the expression doubles per zoom. + it('sizes a spot to its cell on the ground, wider by the spread, at every zoom', () => { + expect(NOISE_SPOT_SPREAD).toBe(1.3) + expect(PX_PER_MERCATOR_M_Z0).toBeCloseTo(512 / (2 * Math.PI * 6378137), 12) + expect(noiseRadius()).toEqual(['interpolate', ['exponential', 2], ['zoom'], + 0, ['*', ['get', 'r'], NOISE_SPOT_SPREAD * PX_PER_MERCATOR_M_Z0], + 24, ['*', ['get', 'r'], NOISE_SPOT_SPREAD * PX_PER_MERCATOR_M_Z0 * 2 ** 24]]) + }) +}) + +describe('the measured value, on the map and in the HUD (#708)', () => { + // A spot is a Point at the cell's centre ([lon, lat]), so the label sits + // where the spot is. + const cell = (id, nf, lon, lat) => ({ type: 'Feature', properties: { id, nf, count: 1, r: 360 }, geometry: { type: 'Point', coordinates: [lon, lat] } }) + + it('labels each cell with its median in whole dBm, at the centre of the cell', () => { + const fc = { type: 'FeatureCollection', features: [cell('a', -104.5, 1, 1), cell('b', -97.4, 11, 11)] } + expect(noiseLabelItems(fc, () => true)).toEqual([ + { id: 'a', label: '-104', lat: 1, lon: 1 }, + { id: 'b', label: '-97', lat: 11, lon: 11 }, + ]) + }) + + it('labels only the cells whose centre is in view', () => { + const fc = { type: 'FeatureCollection', features: [cell('a', -104, 1, 1), cell('b', -97, 11, 11)] } + expect(noiseLabelItems(fc, (lat) => lat < 5).map((it) => it.id)).toEqual(['a']) + }) + + const T = 1_000_000 + it('reads the latest value in the HUD while the layer is on and the companion answers', () => { + expect(noiseReadout({ on: true, connected: true, value: -104, at: T, now: T + 5000 })).toBe('Noise -104 dBm') + }) + + it('says nothing with the layer off, without a companion, or without a reading', () => { + expect(noiseReadout({ on: false, connected: true, value: -104, at: T, now: T })).toBe('') + expect(noiseReadout({ on: true, connected: false, value: -104, at: T, now: T })).toBe('') + expect(noiseReadout({ on: true, connected: true, value: null, at: T, now: T })).toBe('') + }) + + it('drops a reading older than three rounds, so a lost fix does not leave an old value standing', () => { + // The companion is only asked with a fix (noiseTick): without one the + // last value would stay on screen, looking live, for as long as it lasts. + expect(noiseReadout({ on: true, connected: true, value: -104, at: T, now: T + NOISE_LIVE_MS })).toBe('Noise -104 dBm') + expect(noiseReadout({ on: true, connected: true, value: -104, at: T, now: T + NOISE_LIVE_MS + 1 })).toBe('') + expect(NOISE_LIVE_MS).toBe(3 * INTERVAL_MS) + }) }) diff --git a/app/src/app.js b/app/src/app.js index 0daa3157..2d8e90da 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -27,7 +27,7 @@ import { backlogState } from './backlog.js' import { mqttShouldRun, mqttAction } from './mqttlifecycle.js' import { Publisher } from './publisher.js' import { Gps, shouldNoticePoorFix, accuracyLabel, GPS_MAX_ACC_M, isUsableFix } from './gps.js' -import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample } from './noise.js' +import { buildStatsRadioRequest, parseStatsRadio, shouldSampleNoise, noiseSample, noiseReadout } from './noise.js' import { requestSelfInfo, radioSummary } from './selfinfo.js' import { requestStatsCore, mvToPercent, isLowBattery } from './battery.js' import { senderReadout } from './hudsender.js' @@ -325,7 +325,7 @@ const state = { battery: { mv: null, timer: null, failures: 0 }, // The noise-floor sampler (#410): the connection the samples belong to, the // last sample's time and place for the rhythm, and the misses in a row. - noise: { session: null, last: null, failures: 0, busy: false }, + noise: { session: null, last: null, failures: 0, busy: false, value: null, valueAt: null }, // The noise layer on the map (#410): a setting, kept across launches, and // while it is on the samples it draws, which noiseTick appends to. noiseLayer: { on: loadNoiseLayer(), samples: null }, @@ -1541,6 +1541,7 @@ async function renderTick() { await drawOnce() await trackTick() noiseTick() + renderHudNoise() setTimeout(renderTick, 1000) } @@ -1578,6 +1579,14 @@ async function applyNoiseLayer() { if (state.map) state.map.setNoise(rows) } +// The HUD's noise line (#708), every tick, so the layer's switch, a connect +// and a reading that goes stale all reach it within a second. +function renderHudNoise() { + const text = noiseReadout({ on: state.noiseLayer.on, connected: state.connected, value: state.noise.value, at: state.noise.valueAt, now: Date.now() }) + const out = el('hud-noise') + if (out.textContent !== text) out.textContent = text +} + function noiseTick() { const n = state.noise if (!state.connected || !state.transport || n.busy || !n.session || n.failures >= NOISE_FAILURES_BEFORE_GIVING_UP) return @@ -1588,6 +1597,10 @@ function noiseTick() { n.busy = true sendAndWait(buildStatsRadioRequest(), parseStatsRadio, NOISE_REPLY_TIMEOUT_MS) .then(async (r) => { + // The HUD shows the latest answer (#708): a miss or the firmware's 0 + // (not measured yet) clears it rather than leaving an old value up. + n.value = r ? r.noiseFloor : null + n.valueAt = now if (!r) { n.failures++; return } n.failures = 0 const sample = noiseSample({ noiseFloor: r.noiseFloor, fix, session: n.session, rxPubkey: state.rxPubkey, nowMs: now, last: n.last }) @@ -2312,7 +2325,7 @@ async function connectAll() { const info = await requestSelfInfo(state.transport, 'core-hunter') state.rxPubkey = info.pubkey.toLowerCase() // A noise session is one connection (#410, Kasper 2026-09-25). - state.noise = { session: new Date().toISOString(), last: null, failures: 0, busy: false } + state.noise = { session: new Date().toISOString(), last: null, failures: 0, busy: false, value: null, valueAt: null } state.name = info.name || '' state.sf = info.sf ?? null // The rest of the radio (#650): what the companion reports, for the Status diff --git a/app/src/filtersheet.js b/app/src/filtersheet.js index 12823275..4f603ad9 100644 --- a/app/src/filtersheet.js +++ b/app/src/filtersheet.js @@ -80,7 +80,7 @@ export function filterSheetMarkup({ types, idClasses }) { Noise floor -

Colours the cells by the noise your companion measured there, instead of by signal. Loud cells explain a stretch with nothing heard.

+

Shows the noise your companion measured, as soft spots in place of the signal cells. A loud spot explains a stretch with nothing heard.

Changes apply immediately diff --git a/app/src/huntmap.js b/app/src/huntmap.js index 2fc79d48..c863bfaf 100644 --- a/app/src/huntmap.js +++ b/app/src/huntmap.js @@ -1,4 +1,4 @@ -import { hexCellAt, hexBoundary, hexResForZoom, HEX_MAX_RES } from './hexgrid.js' +import { hexCellAt, hexBoundary, hexResForZoom, hexSizeForRes, 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' @@ -20,7 +20,7 @@ import { skyForHour, currentHour } from './sky.js' import { DEM_TILES, DEM_ENCODING, DEM_MAX_ZOOM, DEM_ATTRIBUTION, DEFAULT_EXAGGERATION, hillshadeFor, terrainPlan, reportMapError } from './terrain.js' import { followAfter, paddingAction } from './rotation.js' import { STYLE_RETRY_MS, nextStyleAttempt } from './basemapswap.js' -import { noiseFill, withNoise, noiseHexFC } from './noise.js' +import { noiseFill, withNoise, noiseSpotFC, noiseRadius, noiseLabelItems, NOISE_SPOT_BLUR, NOISE_SPOT_OPACITY } from './noise.js' // Map layer — MapLibre GL (#147). Migrated from Leaflet + leaflet-rotate: native // rotation/pitch replaces the plugin (and its zoom-drift patch, #167/#168), and @@ -671,11 +671,15 @@ export function createHuntMap(containerId) { const hexPaint = { 'fill-color': ['get', 'color'], 'fill-opacity': 0 } if (!map.getLayer('hex')) map.addLayer({ id: 'hex', type: 'fill', source: 'hex', layout: { visibility: shown('hex') }, paint: hexPaint }) if (!map.getLayer('hex-b')) map.addLayer({ id: 'hex-b', type: 'fill', source: 'hex-b', layout: { visibility: shown('hex-b') }, paint: hexPaint }) - // Flat in 3D too: a noise floor has no height to give a pillar. The band - // colours are baked in like every overlay's; a theme swap re-adds them. - if (!map.getLayer('noise')) map.addLayer({ id: 'noise', type: 'fill', source: 'noise', + // Soft spots, one per cell (noise.js): a blurred circle the cell's size + // on the ground, lying on the map so it stays flat in 3D too, where a + // noise floor has no height to give a pillar. The band colours are baked + // in like every overlay's; a theme swap re-adds them. + if (!map.getLayer('noise')) map.addLayer({ id: 'noise', type: 'circle', source: 'noise', layout: { visibility: shown('noise') }, - paint: { 'fill-color': noiseFill([1, 2, 3, 4].map((i) => cssVar(`--ch-noise-${i}`))) } }) + paint: { 'circle-color': noiseFill([1, 2, 3, 4].map((i) => cssVar(`--ch-noise-${i}`))), + 'circle-radius': noiseRadius(), 'circle-blur': NOISE_SPOT_BLUR, 'circle-opacity': NOISE_SPOT_OPACITY, + 'circle-pitch-alignment': 'map', 'circle-pitch-scale': 'map' } }) // 3D twin of 'hex': same source, extruded to 'height' (RSSI/SNR tier, #147). // fill-extrusion-opacity is not data-driven, and one opacity for every // tier is what made a faint bar a solid purple on a 19% tint (#412). The @@ -956,7 +960,7 @@ export function createHuntMap(containerId) { } applyFades(slots) put('noise', vis.noise ? buildNoiseFC() : EMPTY) - drawHexLabels(records, vis['hex-labels']) + drawHexLabels(records, vis['hex-labels'], vis.noise) zoomDrawn.cells = zoomKeysNow().cells } @@ -985,11 +989,18 @@ export function createHuntMap(containerId) { map.setPaintProperty('points-3d', 'fill-extrusion-opacity', pillarSwitch(view)) } - // The noise cells at this zoom's resolution, rebuilt when a sample arrives - // or the resolution changes. + // The noise spots at this zoom's resolution, rebuilt when a sample arrives + // or the resolution changes. A cell's centre is the mean of its corners. function buildNoiseFC() { const res = hexResForZoom(map.getZoom()) - return noiseCache.get(`${noiseGen}|${noiseSamples.length}|${res}`, () => noiseHexFC(noiseSamples, (lat, lon) => hexCellAt(lat, lon, res), hexBoundary)) + const centreOf = (id) => { + const ring = hexBoundary(id) + if (!ring) return null + let lat = 0, lon = 0 + for (let i = 0; i < 6; i++) { lat += ring[i][0]; lon += ring[i][1] } + return [lat / 6, lon / 6] + } + return noiseCache.get(`${noiseGen}|${noiseSamples.length}|${res}`, () => noiseSpotFC(noiseSamples, (lat, lon) => hexCellAt(lat, lon, res), centreOf, hexSizeForRes(res))) } // ---- hex labels (#556) ---- @@ -1012,14 +1023,12 @@ export function createHuntMap(containerId) { const share = String(labelShare(map.getZoom())) hexLabelMarkers.forEach((m) => { m.getElement().style.opacity = share }) } - function drawHexLabels(records, on) { - if (!on || !(labelShare(map.getZoom()) > 0)) { if (hexLabelMarkers.size) clearHexLabels(); return } + // Who was heard in each cell in view, for its label (hexCellLabel). + function receptionLabelItems(records, inView) { const res = hexResForZoom(map.getZoom()) - const b = map.getBounds() const cells = new Map() for (const r of records) { - if (r.lat == null || r.lon == null) continue - if (r.lat < b.getSouth() || r.lat > b.getNorth() || r.lon < b.getWest() || r.lon > b.getEast()) continue + if (r.lat == null || r.lon == null || !inView(r.lat, r.lon)) continue const id = hexCellAt(r.lat, r.lon, res) const cur = cells.get(id) || [] cur.push(r); cells.set(id, cur) @@ -1034,6 +1043,16 @@ export function createHuntMap(containerId) { for (let i = 0; i < n; i++) { lat += ring[i][0]; lon += ring[i][1] } items.push({ id, label, lat: lat / n, lon: lon / n }) } + return items + } + // With the noise layer on, the same markers carry each noise cell's median + // in dBm (#708): the noise spots take the signal cells' place, so their + // labels take the hex labels' place, from the same zoom. + function drawHexLabels(records, on, noiseOn) { + if (!(on || noiseOn) || !(labelShare(map.getZoom()) > 0)) { if (hexLabelMarkers.size) clearHexLabels(); return } + const b = map.getBounds() + const inView = (lat, lon) => lat >= b.getSouth() && lat <= b.getNorth() && lon >= b.getWest() && lon <= b.getEast() + const items = noiseOn ? noiseLabelItems(buildNoiseFC(), inView) : receptionLabelItems(records, inView) const drawn = new Map([...hexLabelMarkers].map(([id, m]) => [id, m.getElement().textContent])) const { add, relabel, remove } = planHexLabels(drawn, items) for (const id of remove) { hexLabelMarkers.get(id).remove(); hexLabelMarkers.delete(id) } diff --git a/app/src/noise.js b/app/src/noise.js index 1726a3a7..e58b7391 100644 --- a/app/src/noise.js +++ b/app/src/noise.js @@ -117,16 +117,70 @@ export function withNoise(vis, on) { return out } -// noiseHexFC: one polygon per cell, with its median as `nf`. `cellAt(lat, lon)` -// names a cell and `boundary(id)` gives its ring as [lat, lon] pairs, the -// hexgrid.js pair, passed in so the zoom's resolution stays the caller's. -export function noiseHexFC(samples, cellAt, boundary) { +// The layer draws the cells as soft spots, not hexes (Kasper, 28 September): +// a hex read as one of the signal cells, while a noise floor is a property +// of the place around the sample. One blurred circle per cell at its centre, +// NOISE_SPOT_SPREAD times the cell's radius, so neighbours flow into each +// other and a lone reading has no hard edge. +export const NOISE_SPOT_SPREAD = 1.3 +export const NOISE_SPOT_BLUR = 0.8 +export const NOISE_SPOT_OPACITY = 0.6 +// Pixels per Web Mercator metre at zoom 0, with MapLibre's 512px tiles: the +// cell radius the grid works in (hexSizeForRes) is in those metres. +export const PX_PER_MERCATOR_M_Z0 = 512 / (2 * Math.PI * 6378137) + +// noiseSpotFC: one point per cell at its centre, with its median as `nf` and +// the cell's radius in mercator metres as `r`. `cellAt(lat, lon)` names a +// cell and `centreOf(id)` answers its centre as [lat, lon], the hexgrid.js +// pair, passed in so the zoom's resolution stays the caller's. +export function noiseSpotFC(samples, cellAt, centreOf, radiusM) { const features = [] for (const [id, c] of noiseCells(samples, (s) => cellAt(s.lat, s.lon))) { - const ring = boundary(id) - if (!ring) continue - features.push({ type: 'Feature', properties: { nf: c.median, count: c.n }, - geometry: { type: 'Polygon', coordinates: [ring.map(([lat, lon]) => [lon, lat])] } }) + const centre = centreOf(id) + if (!centre) continue + features.push({ type: 'Feature', properties: { id, nf: c.median, count: c.n, r: radiusM }, + geometry: { type: 'Point', coordinates: [centre[1], centre[0]] } }) } return { type: 'FeatureCollection', features } } + +// noiseRadius: the spot's radius in px, from `r` and the zoom. A mercator +// metre is PX_PER_MERCATOR_M_Z0 * 2^zoom px, so the radius doubles per zoom +// and the spot stays the cell's size on the ground. A camera expression has +// to be the top-level interpolate, so the zoom range is spelled out. +export function noiseRadius() { + const k = NOISE_SPOT_SPREAD * PX_PER_MERCATOR_M_Z0 + return ['interpolate', ['exponential', 2], ['zoom'], 0, ['*', ['get', 'r'], k], 24, ['*', ['get', 'r'], k * 2 ** 24]] +} + +// ---- the measured value (#708) ---- +// The colour says loud or quiet; these say how loud, in whole dBm. + +// noiseLabelItems: a label per noise cell whose centre is in view, for the +// hex-label markers (#556), which the noise cells replace while the layer is +// on. Read from the spots the layer draws (noiseSpotFC), so a label and its +// colour are always the same median, at the same place. `inView(lat, lon)` +// is the map's bounds. +export function noiseLabelItems(fc, inView) { + const items = [] + for (const f of (fc && fc.features) || []) { + const [lon, lat] = f.geometry.coordinates + if (!inView(lat, lon)) continue + items.push({ id: f.properties.id, label: String(Math.round(f.properties.nf)), lat, lon }) + } + return items +} + +// How long a reading counts as the noise floor of now: three rounds of the +// rhythm. The companion is only asked with a fix, so without one the value +// stops being renewed, and an old one must not stand on screen looking live. +export const NOISE_LIVE_MS = 3 * INTERVAL_MS + +// noiseReadout: the HUD's line for the latest reading, taken at `at`, or '' +// when there is nothing to say: the layer is off, no companion is connected, +// the last ask brought no reading (none yet, a miss, or the firmware's 0), or +// the reading is older than NOISE_LIVE_MS. +export function noiseReadout({ on, connected, value, at, now }) { + if (!on || !connected || value == null || at == null || now - at > NOISE_LIVE_MS) return '' + return `Noise ${value} dBm` +} diff --git a/app/src/styles/app.css b/app/src/styles/app.css index 1b46fc85..08c020dd 100644 --- a/app/src/styles/app.css +++ b/app/src/styles/app.css @@ -133,12 +133,11 @@ html, body { margin: 0; height: 100%; background: var(--ch-bg); color: var(--ch- background: var(--ch-surface); backdrop-filter: blur(10px); display: flex; flex-direction: column; gap: 8px; z-index: 500; } -/* Row 1, the reading: hero RSSI, SNR, and the age pushed right (#618). The - height is fixed, so the direction arrow (#660) that goes before the number - cannot grow the HUD: #hud is bottom-anchored and the FAB rail sits a fixed - distance above it (#264). The gap is 8px, not 12: the worst row with the - 30px arrow ('-120', 'SNR -20.0 dB', '59m 59s') needs 329px of the 328px a - 360px screen leaves at 12px, and fits with 8. */ +/* Row 1, the number and who (#708, after #618): the arrow, the RSSI, the + sender and the backlog pill. The height is fixed, so the direction arrow + (#660) that goes before the number cannot grow the HUD: #hud is + bottom-anchored and the FAB rail sits a fixed distance above it (#264). + The sender is the one thing in the row that gives up width. */ #hud-readout-row { display: flex; align-items: baseline; gap: 8px; height: 38px; } /* The direction arrow (#660), before the number: a fixed 30px box centred in the 38px row, so it never grows the row. The colour is the reading's tier, @@ -155,24 +154,25 @@ html, body { margin: 0; height: 100%; background: var(--ch-bg); color: var(--ch- font-variant-numeric: tabular-nums; line-height: 1; white-space: nowrap; flex: none; } -/* The SNR never shrinks (#637). It used to share the row's deficit with the - sender (min-width:0 and an ellipsis), so a long relay name cut it to - "SNR -1..." at 360 and at 412px. The sender has a row of its own now, - and row 1 holds only numbers of a known widest form. */ -#hud-snr { font-family: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace; +/* Row 2, the measurements (#708): the SNR, the noise floor while its layer + is on, and the age pushed right. Numbers of a known widest form only + ('SNR -12.5 dB', 'Noise -104 dBm', '59m 59s' fit 328px at 360 wide with + the 12px gap), so none of them ever shrinks (#637: a long relay name once + cut the SNR to "SNR -1..." when the two shared a row). Fixed height, like + row 1. */ +#hud-measure-row { display: flex; align-items: baseline; gap: 12px; height: 20px; } +#hud-snr, #hud-noise { font-family: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace; font-size: 15px; color: var(--ch-muted); font-variant-numeric: tabular-nums; flex: none; white-space: nowrap; } -/* Age of the reception on the HUD, at the right edge of row 1. Empty before +#hud-noise:empty { display: none; } + +/* Age of the reception on the HUD, at the right edge of row 2. Empty before the first reception, like the RSSI and the SNR. */ #hud-since { margin-left: auto; font-family: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace; font-size: 15px; color: var(--ch-muted); font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; } -/* Row 2, the sender line (#618), with the backlog pill at its right end. Fixed - height, like row 1. */ -#hud-sender-row { display: flex; align-items: center; gap: 8px; height: 20px; min-width: 0; } - /* Who the reception came from (#281): the origin, or "via " when it reached us through a relay. Only "via " and the guess mark are muted; the name is in the text colour and is cut from its end. #hud-sender stays a @@ -180,8 +180,8 @@ html, body { margin: 0; height: 100%; background: var(--ch-bg); color: var(--ch- on anonymous flex items (AGENTS.md 5.4 item 2), and the leading muted span survives the cut. A reception without a sender, and the line before the first reception, read as one muted note. */ -#hud-sender { flex: 1; min-width: 0; font-family: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace; - font-size: 15px; color: var(--ch-text); +#hud-sender { flex: 1; min-width: 0; align-self: center; font-family: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace; + font-size: 17px; color: var(--ch-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #hud-sender .hud-via, #hud-sender.empty { color: var(--ch-muted); } @@ -609,7 +609,7 @@ html, body { margin: 0; height: 100%; background: var(--ch-bg); color: var(--ch- map is not. The amber is the UI's alert token, not a signal tier: the tiers are for readings only (docs/design-system.md), and a backlog is the app's state. The alarm level keeps only its weight. */ -#hud-backlog { flex: none; white-space: nowrap; +#hud-backlog { flex: none; white-space: nowrap; align-self: center; font-family: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace; font-size: 11px; line-height: 1; padding: 3px 7px; border: 1px solid var(--ch-accent-2); border-radius: 999px; diff --git a/changelog.d/2026-09-27-02-noise-values.json b/changelog.d/2026-09-27-02-noise-values.json new file mode 100644 index 00000000..98fc5e21 --- /dev/null +++ b/changelog.d/2026-09-27-02-noise-values.json @@ -0,0 +1,7 @@ +{ + "id": "2026-09-27-noise-values", + "date": "2026-09-27", + "where": "app", + "title": "The noise floor as a number", + "body": "Noise cells show their median in dBm from zoom 16, and the HUD shows the latest reading. The sender moved beside the number." +} diff --git a/docs/2026-09-15-reading-layer.md b/docs/2026-09-15-reading-layer.md index b61996a3..311b4da1 100644 --- a/docs/2026-09-15-reading-layer.md +++ b/docs/2026-09-15-reading-layer.md @@ -10,7 +10,8 @@ The HUD's readout was one row: RSSI with its unit, SNR, the sender, the age and 360 px a long relay name took the space the SNR needed, so the SNR was cut to `SNR -1...` (#637), and a visible backlog wrapped inside the row and grew the HUD over the FAB rail. -It is two rows now, above the unchanged tools row: +It is two rows now, above the unchanged tools row. (Amended 27 September, #708: the rows were +regrouped as the number and who, then the measurements; see the last section.) 1. **The reading.** The RSSI as a number in its tier colour, without a unit (the float keeps `dBm`), the SNR, and the age at the right edge. The SNR never shrinks. @@ -180,3 +181,18 @@ HUD stays 114 px throughout. Not verified: a real compass, a real phone in pictu (where the page is hidden and GPS and orientation events may stop, which the age gates turn into no arrow rather than a frozen one), and the bearing swinging close to the target. +## The rows regrouped (#708) + +Amended 27 September 2026 (Kasper, on the artboard of that day). The noise floor needed a place in +the HUD, and beside the SNR it wrapped to two lines at 360 px. The rows are regrouped instead: + +1. **The number and who.** The arrow, the RSSI, the sender at 17 px, and the backlog pill at the + right end. The sender is the one thing in the row that gives up width: about 20 characters + at 360 px and 24 at 390 px before the cut, where the line of its own held about 36. +2. **The measurements.** The SNR, `Noise -104 dBm` while the noise layer is on, and the age at the + right edge. Numbers of a known widest form only: `SNR -12.5 dB`, `Noise -104 dBm` and `59m 59s` + fit 328 px at 360 px wide with a 12 px gap, so none of them shrinks and #637 cannot come back. + +The heights stay 38 px and 20 px, so the HUD stays 114 px and the FAB rail does not move. The +float readout keeps its own layout. + diff --git a/docs/2026-09-25-noise-floor-layer.md b/docs/2026-09-25-noise-floor-layer.md index 087e17f4..f630c7aa 100644 --- a/docs/2026-09-25-noise-floor-layer.md +++ b/docs/2026-09-25-noise-floor-layer.md @@ -25,12 +25,18 @@ A stretch with no receptions reads the same whether it was out of coverage or dr ## The layer -- **Always hex cells**, whatever the view (points, hex + points, hex, 2D or 3D). A noise floor is a property of a place, not of a reception. +- **Always cells**, whatever the view (points, hex + points, hex, 2D or 3D). A noise floor is a property of a place, not of a reception. +- **Drawn as soft spots, not hexes** (amended 28 September 2026, Kasper, on the review of #708). Hexes read as the signal cells, while a noise floor is a property of the place around the sample. Each cell is one blurred circle at its centre (`circle-blur` 0.8, opacity 0.6), 1.3 times the cell's radius so neighbours flow into each other, sized on the ground by the zoom (`noiseRadius`, `PX_PER_MERCATOR_M_Z0`). The cells, the medians and the labels are unchanged. - **It replaces the signal cells** while it is on: `hex`, `hex-3d` and the hex labels step aside, the points stay on top. Laid over the signal cells, blue on orange mixed into grey and olive and neither read (artboard of 25 September, variants C over and D stripes). - **The value per cell is the median.** The stationary samples of one connection in one cell count once, as their own median: ten minutes parked is one place, not sixty readings of it. - **Colour:** clear below -119 dBm, then four bands at -119, -113, -107 and -101, blue that gets stronger as it gets louder (`--ch-noise-1..4`). Blue-to-black was tried first: black on the dark basemap hid exactly the loud cells. -- **Flat in 3D.** A noise floor has no height to give a pillar. +- **Flat in 3D.** A noise floor has no height to give a pillar; the spots lie on the map (`circle-pitch-alignment: map`). - **No legend.** The hint under the switch says what the colour means. +- **The value itself** (#708, Kasper, 27 September). Each cell carries its median in whole dBm, + fading in with the cell names (zoom 15.5 to 16.5, #634), on the hex-label markers the noise cells take over while the layer is on, read from + the same cells the fill draws. The HUD shows the latest reading, `Noise -104 dBm`, while the layer + is on and the companion answers; a miss, the firmware's 0 or a reading older than three rounds + (30 s, since without a fix nothing is asked) leaves the line empty. ## Where the switch is diff --git a/web/changelog.json b/web/changelog.json index 89cbe942..cfee3e01 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -41,6 +41,13 @@ "title": "Space under the HUD on Android", "body": "The HUD's buttons no longer sit on Android's navigation bar." }, + { + "id": "2026-09-27-noise-values", + "date": "2026-09-27", + "where": "app", + "title": "The noise floor as a number", + "body": "Noise cells show their median in dBm from zoom 16, and the HUD shows the latest reading. The sender moved beside the number." + }, { "id": "2026-09-27-float-no-pause", "date": "2026-09-27",