web: /charts mesh-activity figure, mobile card strip, legend-toggle fix

This commit is contained in:
l5y
2026-07-26 14:30:39 +02:00
parent 33e00a8124
commit 0ea9aaf754
12 changed files with 459 additions and 14 deletions
+31
View File
@@ -4228,3 +4228,34 @@ soft to `null` (non-OK / network error / empty). `sparklinePathsFromSeries` maps
`render(rates)` and `setSeries(series)` each repaint from the last-known other; the
sparkline appears only once a real series arrives (no `data-placeholder`, no fake
curve) and is omitted on failure while the live total/rows still render.
### F2-A3 — `/charts` shows a protocol-aware Mesh activity figure — F2-5
```bash
( cd web && node --test public/assets/js/app/__tests__/mesh-activity-chart.test.js \
public/assets/js/app/__tests__/node-page.test.js \
public/assets/js/app/__tests__/charts-page.test.js )
```
**Expected:** pass. `renderMeshActivityChart` draws a two-line (Meshtastic `#8856a7`,
MeshCore `#3182bd`) packets/hour figure with an **"Activity (pkt/h)"** y-axis, fed by
`fetchActivityChartBuckets` (`/api/stats/activity`, 7 d / 2 h; fails soft to `[]`).
`renderTelemetryCharts` accepts an `insertBefore` map that places the figure
immediately before the `environment` spec — i.e. **between** the channel-utilization
and environmental figures — and `initializeChartsPage` wires it there. The `/charts`
intro no longer names a single protocol (the aggregate is all-protocol).
---
## Bugfix: Neighbor/trace legend toggles highlight when visible
Maps to SPEC decision **NT1**.
### NT-A1 — Neighbor/trace toggles are pressed when their lines are visible — NT1
```bash
git grep -nE "aria-pressed', neighborLinesVisible \? 'true'|aria-pressed', traceLinesVisible \? 'true'" \
-- web/public/assets/js/app/main.js
```
**Expected:** both matches present — the neighbor- and trace-line legend toggles set
`aria-pressed` to `'true'` when their lines are **visible** (`…Visible ? 'true' : 'false'`),
so the highlighted state (`button.legend-item[aria-pressed="true"]`, LC2) marks *shown*
lines, consistent with the role chips and the meta-row protocol toggles. Previously
reversed (pressed when hidden).
+3
View File
@@ -17,6 +17,8 @@ table, and reported-only disclosure rows.
### Features
* Web: `GET /api/stats/activity` — bucketed packets/hour time-series over `ingestor_activity` (snake_case `window_seconds`/`bucket_seconds`), feeding the map-card sparkline and a protocol-aware `/charts` figure (SPEC F2)
* Web: `/charts` gains a protocol-aware "Mesh activity" figure (packets/h per protocol, 7 d) between the channel-utilization and environmental charts; the aggregated-telemetry intro is de-scoped from "Meshtastic" to all protocols (SPEC F2-5)
* Web: mesh-activity map card collapses to a compact caption strip on phones (≤ 640 px) so it no longer covers a small map (design 1d)
* Web: "Mesh activity" map card — bottom-left overlay showing packets/h (total + per-protocol split) from `/api/stats`, toggle-reactive and hidden when idle, with a 24 h sparkline drawn from `/api/stats/activity` (SPEC MA-F1…MA-F6, F2-4)
* Web: join strip renders the radio settings newcomers need; new `MESHTASTIC_PRESET`/`MESHTASTIC_FREQ` + `MESHCORE_PRESET`/`MESHCORE_FREQ` env vars (`CHANNEL`/`FREQUENCY` deprecated but honoured)
* Web: node freshness buckets (live/today/stale) on table rows and map markers, riding the shared relative-time tick
@@ -24,6 +26,7 @@ table, and reported-only disclosure rows.
* Web: nodes table gains grouped headers, curated mobile columns (Battery survives), a per-row disclosure of hidden fields, row hover/click, numeric alignment, captions/scopes
### Fixes
* Web: neighbor/trace line legend toggles highlight when their lines are visible (was reversed — highlighted when hidden), matching the role chips (SPEC NT1)
* Web/data: `GET /api/stats` exposes the mesh-activity packets/hour rate as `<scope>.packets.hour` (folded into the S1 scope→metric→window tree) rather than a top-level `packets_per_hour` map, and `total.packets.hour` now **sums** the per-protocol rates instead of taking a single MAX vantage (distinct protocols never share the air, so they add); the ingestor announcement dogfeed reads the new path (SPEC MA4/MA5, pre-release amendment of the merged-but-unreleased #859)
* Web: WCAG AA text contrast — role-badge text computed by background luminance; chat log entries, error text, links, and focus rings tokenised (`--danger`, single `--accent`)
* Web: federation table's undefined CSS tokens aliased — row borders and the sticky header render again
+15
View File
@@ -1166,3 +1166,18 @@ contradicted.
| **F2-4** | **Card real sparkline.** The MA-F5 placeholder is replaced by the real 24 h series from `/api/stats/activity`, fetched on a slower cadence with its own short cache (the trend moves slowly — not every refresh). On fetch failure the sparkline is omitted but the live total/rows (from `/api/stats`) still render — the card never regresses to a fake curve. | interview |
| **F2-5** | **Charts page — protocol-aware, all-protocol.** A "Mesh activity" figure (packets/h per protocol, 7 d) is added to `/charts` **between** the channel-utilization and environmental figures. The intro no longer names "Meshtastic": the aggregated telemetry (`query_telemetry_buckets`) already carries **all** protocols (MeshCore telemetry included since the telemetry pull shipped) — the prior wording mislabelled all-protocol data. Invariant IV. | interview |
| **F2-6** | **Engineering bar (D9).** 100 % unit tests (rspec for `query_activity_buckets` + the route; node --test for the card sparkline + charts figure), full RDoc/JSDoc, Apache headers, `rufo`/`black` clean; `CONTRACTS.md` documents the new endpoint; all suites stay green. | CLAUDE.md |
---
## Bugfix: Neighbor/trace legend toggles highlight when visible
The neighbor-line and trace-line legend toggles set `aria-pressed="true"` when their
lines were **hidden**, so `button.legend-item[aria-pressed="true"]` (the selected/
highlighted style, LC2) painted them highlighted while the lines were off — reversed
relative to the role chips, which highlight when a role is **visible**. Fixed in
`web/public/assets/js/app/main.js` (`updateNeighborLinesToggleState` /
`updateTraceLinesToggleState`). Presentation-only; no data/API change.
| # | Decision | Source |
| --- | --- | --- |
| **NT1** | The neighbor/trace line toggles set `aria-pressed="true"` when their lines are **visible** (highlighted = shown), consistent with the role chips (LC2) and the meta-row protocol toggles; previously reversed (pressed when hidden). Presentation-only, no data/API change. | review |
@@ -108,6 +108,36 @@ test('initializeChartsPage renders the telemetry charts when snapshots are avail
assert.equal(/^\d{2}$/.test(label), true);
});
test('initializeChartsPage injects the mesh-activity figure before the environmental charts', async () => {
const container = { innerHTML: '' };
const documentStub = {
getElementById(id) {
return id === 'chartsPage' ? container : null;
},
};
const nowSec = 1_700_000_000;
const fetchImpl = async url => {
if (url.includes('/api/stats/activity')) {
return createResponse(200, [
{ bucket_start: nowSec - 7200, meshtastic: 20, meshcore: 10 },
{ bucket_start: nowSec, meshtastic: 30, meshcore: 20 },
]);
}
return createResponse(200, [
{ bucket_start: nowSec, bucket_seconds: 300, aggregates: { temperature: { avg: 22 } } },
]);
};
let receivedOptions = null;
const renderCharts = (node, options) => {
receivedOptions = options;
return '<section class="node-detail__charts">Charts</section>';
};
await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.ok(receivedOptions.insertBefore, 'insertBefore option is passed');
assert.ok(receivedOptions.insertBefore.environment, 'activity figure keyed to environment');
assert.match(receivedOptions.insertBefore.environment, /Mesh activity/);
});
test('initializeChartsPage shows an error message when fetching fails', async () => {
const container = { innerHTML: '' };
const documentStub = {
@@ -0,0 +1,95 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
fetchActivityChartBuckets,
renderMeshActivityChart,
} from '../mesh-activity-chart.js';
const NOW = 1_700_000_000_000; // milliseconds
const TWO_HOURS = 7200;
function bucketsFixture() {
const startSec = Math.floor(NOW / 1000) - 4 * TWO_HOURS;
return [
{ bucket_start: startSec, bucket_end: startSec + TWO_HOURS, total: 30, meshtastic: 20, meshcore: 10 },
{ bucket_start: startSec + TWO_HOURS, bucket_end: startSec + 2 * TWO_HOURS, total: 50, meshtastic: 30, meshcore: 20 },
];
}
test('fetchActivityChartBuckets requests the 7d/2h series and returns the array', async () => {
const calls = [];
const fetchImpl = async url => {
calls.push(url);
return { ok: true, async json() { return bucketsFixture(); } };
};
const buckets = await fetchActivityChartBuckets({ fetchImpl });
assert.equal(buckets.length, 2);
assert.match(calls[0], /\/api\/stats\/activity\?window_seconds=604800&bucket_seconds=7200/);
});
test('fetchActivityChartBuckets fails soft to [] on non-OK, error, and non-array', async () => {
assert.deepEqual(
await fetchActivityChartBuckets({ fetchImpl: async () => ({ ok: false, status: 500 }) }),
[]
);
assert.deepEqual(
await fetchActivityChartBuckets({ fetchImpl: async () => { throw new Error('down'); } }),
[]
);
assert.deepEqual(
await fetchActivityChartBuckets({
fetchImpl: async () => ({ ok: true, async json() { return { not: 'array' }; } }),
}),
[]
);
});
test('renderMeshActivityChart draws a per-protocol figure with the Activity axis label', () => {
const html = renderMeshActivityChart(bucketsFixture(), NOW);
assert.match(html, /<h4>Mesh activity<\/h4>/);
assert.match(html, /Activity \(pkt\/h\)/); // y-axis label (per the note)
assert.match(html, /Meshtastic/);
assert.match(html, /MeshCore/);
assert.match(html, /#8856a7/); // meshtastic colour
assert.match(html, /#3182bd/); // meshcore colour
assert.match(html, /node-detail__chart-trend/); // a trend line path
assert.match(html, /node-detail__chart-legend/);
});
test('renderMeshActivityChart returns empty string when there is no usable data', () => {
assert.equal(renderMeshActivityChart([], NOW), '');
assert.equal(renderMeshActivityChart(null, NOW), '');
// Buckets present but no finite per-protocol values → no lines.
assert.equal(renderMeshActivityChart([{ bucket_start: 'x', meshtastic: 'y' }], NOW), '');
});
test('renderMeshActivityChart still renders when the whole series is zero', () => {
const startSec = Math.floor(NOW / 1000) - TWO_HOURS;
const html = renderMeshActivityChart(
[
{ bucket_start: startSec, meshtastic: 0, meshcore: 0 },
{ bucket_start: startSec + TWO_HOURS, meshtastic: 0, meshcore: 0 },
],
NOW
);
// maxValue 0 → axis.max falls back to 1; the figure and its lines still draw.
assert.match(html, /<h4>Mesh activity<\/h4>/);
assert.match(html, /node-detail__chart-trend/);
});
@@ -472,6 +472,28 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
assert.equal(html.includes('node-detail__chart-point'), true);
});
test('renderTelemetryCharts injects insertBefore figures before their target spec', () => {
const nowMs = CHART_NOW_MS;
const nowSeconds = CHART_NOW_SECONDS;
const node = makeAggregatedNode([
{ rx_time: nowSeconds - 60, telemetry_type: 'device', battery_level: 80, voltage: 4.1 },
{ rx_time: nowSeconds - 3_600, telemetry_type: 'environment', temperature: 18.4, relative_humidity: 52 },
]);
const html = renderTelemetryCharts(node, {
nowMs,
insertBefore: {
environment: '<figure class="injected-activity"></figure>',
'no-such-spec': '<figure class="leftover-figure"></figure>',
},
});
const injectedIdx = html.indexOf('injected-activity');
const envIdx = html.indexOf('Environmental telemetry');
assert.ok(injectedIdx > -1 && envIdx > -1);
assert.ok(injectedIdx < envIdx, 'injected figure precedes the environmental figure');
// A figure whose target spec did not render is appended, never dropped.
assert.ok(html.includes('leftover-figure'));
});
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
const nowMs = CHART_NOW_MS;
const nowSeconds = CHART_NOW_SECONDS;
+10 -1
View File
@@ -15,6 +15,7 @@
*/
import { renderTelemetryCharts } from './node-page.js';
import { fetchActivityChartBuckets, renderMeshActivityChart } from './mesh-activity-chart.js';
const TELEMETRY_BUCKET_SECONDS = 60 * 60;
const HOUR_MS = 60 * 60 * 1000;
@@ -217,9 +218,16 @@ export async function initializeChartsPage(options = {}) {
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
return true;
}
const nowMs = Date.now();
// Mesh activity figure (SPEC F2-5), injected between the channel-utilization
// and environmental telemetry figures; drawn from /api/stats/activity.
const activityFigure = renderMeshActivityChart(
await fetchActivityChartBuckets({ fetchImpl }),
nowMs
);
const node = { rawSources: { telemetry: { snapshots } } };
const chartsHtml = renderCharts(node, {
nowMs: Date.now(),
nowMs,
chartOptions: {
windowMs,
timeRangeLabel: 'Last 7 days',
@@ -227,6 +235,7 @@ export async function initializeChartsPage(options = {}) {
xAxisTickFormatter: formatDayOfMonthLabel,
lineReducer: points => buildMovingAverageSeries(points, HOUR_MS),
},
insertBefore: activityFigure ? { environment: activityFigure } : {},
});
if (!chartsHtml) {
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
+8 -5
View File
@@ -1739,9 +1739,10 @@ export function initializeApp(config) {
// The toggle doubles as the legend key for the solid neighbor-line style
// (SPEC UX7, audit D-014).
neighborLinesToggleButton.innerHTML = `${legendLineSampleSvg('neighbor')} ${label}`;
// aria-pressed reflects whether the user has *activated* the toggle (i.e. lines are
// currently hidden). When lines are visible (default), the button is unpressed.
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'false' : 'true');
// aria-pressed marks the *selected* (highlighted) state, consistent with the
// role chips (button.legend-item[aria-pressed="true"]): the toggle is pressed
// when its lines are visible, unpressed when hidden. (Previously reversed.)
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
neighborLinesToggleButton.setAttribute('aria-label', label);
}
@@ -1775,8 +1776,10 @@ export function initializeApp(config) {
// The toggle doubles as the legend key for the dashed traceroute style
// (SPEC UX7, audit D-014).
traceLinesToggleButton.innerHTML = `${legendLineSampleSvg('trace')} ${label}`;
// aria-pressed reflects whether the user has *activated* the toggle (lines hidden).
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'false' : 'true');
// aria-pressed marks the *selected* (highlighted) state, consistent with the
// role chips: pressed when its lines are visible, unpressed when hidden.
// (Previously reversed.)
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
traceLinesToggleButton.setAttribute('aria-label', label);
}
@@ -0,0 +1,171 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Mesh-activity `/charts` figure (SPEC F2-5): packets/hour **per protocol** over
* the last 7 days. Built with the shared telemetry-chart helpers so it matches
* the surrounding chart chrome; `/charts` injects it between the
* channel-utilization and environmental figures. Data comes from the
* `/api/stats/activity` time-series (SPEC F2-1).
*
* @module mesh-activity-chart
*/
import { escapeHtml } from './utils.js';
import { createChartDimensions } from './node-page-charts/layout.js';
import {
renderYAxis,
renderXAxis,
renderTelemetrySeries,
} from './node-page-charts/svg-renderers.js';
import { buildMidnightTicks } from './node-page-charts/tick-builders.js';
const ACTIVITY_CHART_WINDOW_SECONDS = 7 * 24 * 3600; // 7 days
const ACTIVITY_CHART_BUCKET_SECONDS = 2 * 3600; // 2-hour buckets
const ACTIVITY_CHART_WINDOW_MS = ACTIVITY_CHART_WINDOW_SECONDS * 1000;
/**
* Per-protocol lines, in draw/legend order. Colours match the design (option
* 1c): Meshtastic purple, MeshCore blue.
*
* @type {ReadonlyArray<{protocol: string, label: string, color: string}>}
*/
const ACTIVITY_CHART_LINES = Object.freeze([
Object.freeze({ protocol: 'meshtastic', label: 'Meshtastic', color: '#8856a7' }),
Object.freeze({ protocol: 'meshcore', label: 'MeshCore', color: '#3182bd' }),
]);
/**
* Fetch the per-protocol activity buckets for the `/charts` figure (7 d / 2 h).
* Fails soft to an empty array so the figure is simply omitted on any error.
*
* @param {{fetchImpl?: Function}} [params] Fetch parameters.
* @returns {Promise<Array<Object>>} Ascending bucket objects, or `[]`.
*/
export async function fetchActivityChartBuckets({ fetchImpl = fetch } = {}) {
try {
const url =
`/api/stats/activity?window_seconds=${ACTIVITY_CHART_WINDOW_SECONDS}` +
`&bucket_seconds=${ACTIVITY_CHART_BUCKET_SECONDS}`;
const response = await fetchImpl(url, { cache: 'default' });
if (!response?.ok) {
return [];
}
const payload = await response.json();
return Array.isArray(payload) ? payload : [];
} catch (error) {
console.debug('Failed to fetch /api/stats/activity for /charts', error);
return [];
}
}
/**
* Build a `{timestamp, value}` point list for one protocol from the buckets.
*
* @param {Array<Object>} buckets Bucket objects from the activity endpoint.
* @param {string} protocol Protocol key (`meshtastic` / `meshcore`).
* @returns {Array<{timestamp: number, value: number}>} Chart points (ms).
*/
function pointsForProtocol(buckets, protocol) {
const points = [];
for (const bucket of buckets) {
const start = Number(bucket?.bucket_start);
const value = Number(bucket?.[protocol]);
if (Number.isFinite(start) && Number.isFinite(value)) {
points.push({ timestamp: start * 1000, value: Math.max(0, value) });
}
}
return points;
}
/**
* Render the mesh-activity `/charts` figure (SPEC F2-5).
*
* @param {Array<Object>} buckets Per-protocol activity buckets (7 d / 2 h).
* @param {number} [nowMs] Reference timestamp in milliseconds.
* @returns {string} Figure markup, or an empty string when there is no data.
*/
export function renderMeshActivityChart(buckets, nowMs = Date.now()) {
if (!Array.isArray(buckets) || buckets.length === 0) {
return '';
}
const lines = ACTIVITY_CHART_LINES.map(line => ({
...line,
points: pointsForProtocol(buckets, line.protocol),
})).filter(line => line.points.length > 0);
if (lines.length === 0) {
return '';
}
const domainEnd = nowMs;
const domainStart = nowMs - ACTIVITY_CHART_WINDOW_MS;
const maxValue = lines.reduce(
(peak, line) => line.points.reduce((max, point) => Math.max(max, point.value), peak),
0
);
const dims = createChartDimensions({ axes: [{ position: 'left' }] });
const axis = {
position: 'left',
scale: 'linear',
min: 0,
max: maxValue > 0 ? maxValue : 1,
ticks: 5,
label: 'Activity (pkt/h)',
visible: true,
};
const yAxisMarkup = renderYAxis(axis, dims);
const xAxisMarkup = renderXAxis(
dims,
domainStart,
domainEnd,
buildMidnightTicks(nowMs, ACTIVITY_CHART_WINDOW_MS)
);
const seriesMarkup = lines
.map(line =>
renderTelemetrySeries(
{ color: line.color, valueFormatter: value => `${value} pkt/h` },
line.points,
axis,
dims,
domainStart,
domainEnd
)
)
.join('');
const legendMarkup = lines
.map(line =>
'<span class="node-detail__chart-legend-item">' +
`<span class="node-detail__chart-legend-swatch" style="background:${line.color}"></span>` +
`<span class="node-detail__chart-legend-text">${escapeHtml(line.label)}</span>` +
'</span>'
)
.join('');
return `
<figure class="node-detail__chart">
<figcaption class="node-detail__chart-header">
<h4>Mesh activity</h4>
<span>Last 7 days</span>
</figcaption>
<svg viewBox="0 0 ${dims.width} ${dims.height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Mesh activity packets per hour per protocol over the last seven days">
${yAxisMarkup}
${xAxisMarkup}
${seriesMarkup}
</svg>
<div class="node-detail__chart-legend" aria-hidden="true">${legendMarkup}</div>
</figure>
`;
}
@@ -33,10 +33,12 @@ import { stringOrNull } from '../value-helpers.js';
* exist.
*
* @param {Object} node Normalised node payload.
* @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options.
* @param {{ nowMs?: number, chartOptions?: Object, insertBefore?: Object }} [options]
* Rendering options. ``insertBefore`` maps a chart spec id to HTML injected
* immediately before that spec's figure (SPEC F2-5); default none.
* @returns {string} Chart grid markup or an empty string.
*/
export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {}, insertBefore = {} } = {}) {
const telemetrySource = node?.rawSources?.telemetry;
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
? node.rawSources.telemetrySnapshots
@@ -60,16 +62,39 @@ export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions =
return '';
}
const isAggregated = snapshotHistory == null && aggregatedSnapshots != null;
const charts = TELEMETRY_CHART_SPECS
.map(spec => renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated }))
.filter(chart => stringOrNull(chart));
if (charts.length === 0) {
const rendered = TELEMETRY_CHART_SPECS
.map(spec => ({
specId: spec.id,
html: renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated }),
}))
.filter(chart => stringOrNull(chart.html));
if (rendered.length === 0) {
return '';
}
// Optional caller-supplied figures keyed by the spec id they precede (SPEC
// F2-5: /charts injects the Mesh-activity figure before ``environment``).
const injections = insertBefore && typeof insertBefore === 'object' ? insertBefore : {};
const used = new Set();
const parts = [];
for (const chart of rendered) {
const injected = stringOrNull(injections[chart.specId]);
if (injected && !used.has(chart.specId)) {
parts.push(injected);
used.add(chart.specId);
}
parts.push(chart.html);
}
// Any injection whose target spec did not render is appended rather than
// silently dropped.
for (const [specId, html] of Object.entries(injections)) {
if (!used.has(specId) && stringOrNull(html)) {
parts.push(html);
}
}
return `
<section class="node-detail__charts">
<div class="node-detail__charts-grid">
${charts.join('')}
${parts.join('')}
</div>
</section>
`;
+41
View File
@@ -1969,6 +1969,47 @@ input[type="radio"] {
}
}
/* Mesh activity card compact ticker on phones (design 1d): the full card eats
~a third of a small map, so collapse it to a single-line strip that reads as
a caption (pulse dot · total · per-protocol icons + rates). */
@media (max-width: 640px) {
.map-activity-card {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
padding: 4px 9px;
min-width: 0;
line-height: 1.4;
}
.map-activity-card__header,
.map-activity-card__total {
margin-bottom: 0;
gap: 4px;
}
.map-activity-card__title {
display: none; /* the numbers make the label redundant in a strip */
}
.map-activity-card__total-value {
font-size: 15px;
line-height: 1.4;
}
.map-activity-card__spark {
display: none; /* the trend line needs vertical room the strip lacks */
}
.map-activity-card__rows {
flex-direction: row;
gap: 8px;
}
.map-activity-card__row {
gap: 3px;
}
.map-activity-card__row-name,
.map-activity-card__row-bar {
display: none; /* icon + rate is enough in the strip */
}
}
.legend-header {
display: flex;
align-items: center;
+1 -1
View File
@@ -16,7 +16,7 @@
<section class="charts-page">
<header class="charts-page__intro">
<h2><img src="/assets/img/meshtastic.svg" alt="" aria-hidden="true" width="20" height="20" class="protocol-icon protocol-icon--meshtastic" loading="lazy" decoding="async" /> Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every Meshtastic node in the past week.</p>
<p>Aggregated telemetry snapshots from all nodes in the past week.</p>
</header>
<div id="chartsPage" class="charts-page__content">
<p class="charts-page__status">Loading aggregated telemetry charts…</p>