feat(web): React charts/maps/QR — Phase 3

Replace all window.Chart / window.L / window.QRCode globals and the
charts.js helper script with bundled React components:

- react-chartjs-2: typed config builders in utils/charts.ts
  (buildLineChart, buildActivityChart, buildStackedBar, buildRoutesTrend,
  buildRouteDetailStrip + ChartColors, averageRouteTier, routeQualityToTier)
  and wrappers in components/charts/Charts.tsx (ActivityChart,
  TrendLineChart, StackedBarChart, RoutesTrendChart, RouteDetailStrip).
  utils/charts.ts imports chart.js/auto. Wired into Home, Dashboard, Routes.
- react-leaflet: MapPage rewritten with MapContainer/TileLayer/Marker/Popup
  + a useMap MapController for fit-bounds and memoized markers; NodeDetail
  static hero map with divIcon marker + OffsetCenter. Both import
  leaflet/dist/leaflet.css.
- react-qr-code: replaces window.QRCode in Channels and NodeDetail.

Bundling & shell:
- Chart.js, Leaflet (+CSS), react-qr-code now bundled by Vite; removed the
  leaflet/chart.js/qrcodejs vendor <script>/<link> tags from spa.html and
  their copy steps from build.js (fonts stay vendored). Deleted charts.js.
- Moved the Vite CSS bundle (asset_app_css) into <head> before app.css so
  app.css dark-mode Leaflet overrides win over the bundled leaflet.css.
- Dropped the chart globals from the Window type declaration.

Tests/docs:
- test_caching.py: removed charts.js-specific tests; generic JS-cache tests
  now target spa/app.js.
- Updated charts.js cross-references in collector/routes.py + test_routes.py
  to point at spa-react/utils/charts.ts.

Verified: tsc --noEmit clean, npm run build (153 modules),
pytest tests/test_web (255 passed), pre-commit (passed).
This commit is contained in:
Louis King
2026-07-21 19:03:04 +01:00
parent 8322b5cf9f
commit 715659607a
18 changed files with 1066 additions and 1139 deletions
+41 -20
View File
@@ -8,16 +8,25 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite.
|-------|-------------|--------|
| 1 | Infrastructure (Vite, React shell, router, LitBridge, build pipeline, shared components) | **Complete** |
| 2 | Convert pages one-by-one from LitBridge to native React | **Complete** |
| 3 | Chart & map components (react-chartjs-2, react-leaflet) | Not started |
| 3 | Chart & map components (react-chartjs-2, react-leaflet) | **Complete** |
| 4 | Cleanup (remove lit-html, old spa/, build.js esbuild remnants) | Not started |
| 5 | Optional enhancements (tests, react-query, Storybook) | Not started |
> **Phase 3 status:** All `window.Chart` / `window.L` / `window.QRCode` globals and the
> `charts.js` helper script are gone. Charts now use **react-chartjs-2** (typed builders in
> `spa-react/utils/charts.ts` + components in `spa-react/components/charts/Charts.tsx`),
> maps use **react-leaflet** (`MapPage.tsx`, `NodeDetail.tsx`), and QR codes use
> **react-qr-code** (`Channels.tsx`, `NodeDetail.tsx`). Chart.js, Leaflet (+ its CSS), and
> react-qr-code are bundled by Vite — the vendor `<script>`/`<link>` tags and the
> `build.js` vendor copy for leaflet/chart.js/qrcodejs were removed (fonts stay vendored).
> `spa-react/utils/charts.ts` imports `leaflet/dist/leaflet.css`; that CSS ships in the
> Vite bundle (`asset_app_css`), which is now loaded in `<head>` **before** `app.css` so the
> dark-mode map popup overrides in `app.css` still win.
>
> **Phase 2 status:** All 15 pages are converted to native React and wired into `App.tsx`.
> The old lit-html code in `spa/` is intentionally **kept** as the `spa.html` fallback
> (rendered only when the Vite bundle/manifest is absent) and is still referenced by
> 5 web tests. It will be removed in Phase 4, after those tests are updated.
> Charts/maps still use `window.Chart`, `window.L`, `window.QRCode`, and the `charts.js`
> globals — these move to `react-chartjs-2` / `react-leaflet` in Phase 3.
## Architecture Decisions
@@ -26,7 +35,7 @@ Migration from lit-html (functional templates) to React 19 + TypeScript + Vite.
- **Jinja2 shell preserved** — server renders navbar, SEO meta, config JSON; React owns `<main id="app">`
- **LitBridge** wraps unconverted pages: dynamic import → `render(container, params, router)` → cleanup
- **react-i18next** loads same locale JSONs from `/static/locales/`; exposes `window.t` for legacy scripts
- **Vendor scripts kept** (leaflet, chart.js, qrcodejs as globals) until Phase 3 converts map/charts
- **Vendor scripts removed** (Phase 3): chart.js, leaflet (+ CSS), and react-qr-code are bundled by Vite; only fonts remain vendored
- **DaisyUI + Tailwind v4** unchanged; `@source "../js/"` in input.css scans both spa/ and spa-react/
## File Structure
@@ -91,18 +100,19 @@ src/meshcore_hub/web/static/js/spa/ # OLD lit-html pages (still used via LitB
```bash
npm run build
# 1. npx @tailwindcss/cli build (input.css → tailwind.css)
# 2. Copy vendor files (leaflet, chart.js, qrcodejs, fonts)
# 3. npx vite build (bundles React + legacy lit-html pages → dist/assets/)
# 2. Copy vendor fonts (chart/map/QR libs are bundled by Vite, not vendored)
# 3. npx vite build (bundles React + chart.js + leaflet + react-qr-code → dist/assets/)
# 4. Remove stale dist/src/ artifact
# 5. Generate dist/assets.json (compatible format for Jinja2 template)
```
The Jinja2 template (`spa.html`) reads `assets.json` for the entry JS filename:
The Jinja2 template (`spa.html`) reads `assets.json` for the entry JS/CSS filenames:
```json
{ "app.js": "assets/index-XXXX.js", "vendor": {...}, "locale_version": "..." }
{ "app.js": "assets/index-XXXX.js", "app.css": "assets/index-XXXX.css", "vendor": {}, "locale_version": "..." }
```
Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `asset_app_css` to the template.
`asset_app_css` (which contains the bundled `leaflet.css`) is loaded in `<head>` before `app.css` so theme overrides win.
## Phase 2: Page Conversion
@@ -158,17 +168,28 @@ Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `as
| `renderFilterForm({ fields, basePath, navigate })` | `<FilterForm basePath={...}>...</FilterForm>` |
| `renderStatCard({ icon, color, title, value })` | `<StatCard icon={...} color={...} title={...} value={...} />` |
| `return () => { chart.destroy(); }` (cleanup) | `useEffect` return cleanup |
| `window.createActivityChart(...)` | Keep as-is until Phase 3 (react-chartjs-2) |
| `window.L.map(...)` (Leaflet) | Keep as-is until Phase 3 (react-leaflet) |
| `window.QRCode(...)` | Keep as-is or use `react-qr-code` package |
| `window.createActivityChart(...)` | `<ActivityChart>` / `buildActivityChart` (react-chartjs-2) |
| `window.L.map(...)` (Leaflet) | `<MapContainer>` + `useMap` controller (react-leaflet) |
| `window.QRCode(...)` | `<QRCode>` from `react-qr-code` |
## Phase 3: Charts & Maps
## Phase 3: Charts & Maps — Complete
- Install `react-chartjs-2` (already in package.json) — create typed wrapper components
- Install `react-leaflet` (already in package.json) — create `<MeshMap>` component
- Port `charts.js` global functions into React chart components
- Remove leaflet/chart.js vendor `<script>` tags from `spa.html`
- Remove `charts.js` global script
- **react-chartjs-2**: typed config builders in `utils/charts.ts` (`buildLineChart`,
`buildActivityChart`, `buildStackedBar`, `buildRoutesTrend`, `buildRouteDetailStrip`,
plus `ChartColors`, `averageRouteTier`, `routeQualityToTier`); React wrappers in
`components/charts/Charts.tsx` (`ActivityChart`, `TrendLineChart`, `StackedBarChart`,
`RoutesTrendChart`, `RouteDetailStrip`). `utils/charts.ts` imports `chart.js/auto` (registers
everything) — replaces the old global `charts.js`.
- **react-leaflet**: `MapPage.tsx` rewritten with `<MapContainer>/<TileLayer>/<Marker>/<Popup>`
+ a `MapController` (useMap) for fit-bounds and a memoized marker list; `NodeDetail.tsx`
static hero map with `divIcon` marker + `OffsetCenter` (useMap). Both `import "leaflet/dist/leaflet.css"`.
- **react-qr-code**: replaces `window.QRCode` in `Channels.tsx` and `NodeDetail.tsx`.
- Removed leaflet/chart.js/qrcodejs `@script`/`@link` tags and `charts.js` from `spa.html`;
deleted `charts.js`; removed their copy steps from `build.js` (fonts still vendored).
- Moved the Vite CSS bundle (`asset_app_css`) into `<head>` **before** `app.css` so app.css's
dark-mode Leaflet overrides win over the now-bundled leaflet.css.
- Updated `tests/test_web/test_caching.py` (charts.js-specific tests removed; generic JS-cache
tests point at `spa/app.js`).
## Phase 4: Cleanup
@@ -209,9 +230,9 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core bu
## Important Notes
- The old `spa/app.js` is NO LONGER LOADED. The Jinja2 template now loads the Vite-built React bundle.
- The Jinja2 template still renders the navbar, footer, banners, theme toggle, and vendor scripts.
- The Jinja2 template still renders the navbar, footer, banners, and theme toggle. Vendor chart/map/QR scripts are gone (bundled by Vite); only fonts remain vendored.
- `window.__APP_CONFIG__` is still injected by Jinja2 and read by React on bootstrap.
- The theme toggle in the navbar is still vanilla JS (in spa.html). React doesn't manage it.
- Old lit-html pages loaded via LitBridge still use `window.Chart`, `window.L`, `window.QRCode` globals.
- No more `window.Chart` / `window.L` / `window.QRCode` globals — charts, maps, and QR codes are bundled React components (Phase 3).
- Tailwind scans `static/js/` recursively — both `spa/` and `spa-react/` classes are included.
- The `dist/assets.json` format is unchanged from the esbuild era — Python code didn't need changes.
- The `dist/assets.json` format is unchanged from the esbuild era — Python code didn't need changes (its `vendor` map is now empty).
+1 -16
View File
@@ -35,16 +35,6 @@ execSync(
console.log("Copying vendor files...");
vendor("leaflet", ["dist/leaflet.css", "dist/leaflet.js", "dist/leaflet.js.map"], "leaflet");
mkdirSync(join(VENDOR, "leaflet", "images"), { recursive: true });
cpSync(
join("node_modules", "leaflet", "dist", "images"),
join(VENDOR, "leaflet", "images"),
{ recursive: true },
);
vendor("chart.js", ["dist/chart.umd.min.js"], "chart.js");
vendor("qrcodejs", ["qrcode.min.js"], "qrcodejs");
vendor(
"@fontsource-variable/ibm-plex-sans",
[
@@ -90,12 +80,7 @@ if (existsSync(viteManifestPath)) {
}
}
const vendorFiles = {
"leaflet.css": join(VENDOR, "leaflet", "leaflet.css"),
"leaflet.js": join(VENDOR, "leaflet", "leaflet.js"),
"chart.umd.min.js": join(VENDOR, "chart.js", "chart.umd.min.js"),
"qrcode.min.js": join(VENDOR, "qrcodejs", "qrcode.min.js"),
};
const vendorFiles = {};
const vendorHashes = {};
for (const [name, path] of Object.entries(vendorFiles)) {
+58 -1
View File
@@ -20,6 +20,7 @@
"react-dom": "^19",
"react-i18next": "^15",
"react-leaflet": "^5",
"react-qr-code": "^2.2.0",
"react-router": "^7",
"tailwindcss": "^4"
},
@@ -1911,7 +1912,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -2204,6 +2204,18 @@
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2299,6 +2311,15 @@
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2347,6 +2368,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/qrcodejs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/qrcodejs/-/qrcodejs-1.0.0.tgz",
@@ -2409,6 +2447,12 @@
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
@@ -2423,6 +2467,19 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-qr-code": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz",
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1",
"qrcode-generator": "^2.0.4"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+1
View File
@@ -29,6 +29,7 @@
"react-dom": "^19",
"react-i18next": "^15",
"react-leaflet": "^5",
"react-qr-code": "^2.2.0",
"react-router": "^7",
"tailwindcss": "^4"
},
+2 -2
View File
@@ -786,8 +786,8 @@ def evaluate_route_history(
# Thresholds for ``compute_average_quality`` — kept in sync with the
# ``averageTier`` helper in ``web/static/js/charts.js`` so the server-side
# rolling-average badge matches the chart's per-route line color.
# ``averageRouteTier`` helper in ``web/static/js/spa-react/utils/charts.ts`` so
# the server-side rolling-average badge matches the chart's per-route line color.
AVERAGE_QUALITY_CLEAR_AT = 1.5
AVERAGE_QUALITY_MARGINAL_AT = 0.75
-617
View File
@@ -1,617 +0,0 @@
/**
* MeshCore Hub - Chart.js Helpers
*
* Provides common chart configuration and initialization helpers
* for activity charts used on home and dashboard pages.
*/
// Match app typography (IBM Plex Sans); Chart.js defaults to Helvetica/Arial.
if (typeof Chart !== 'undefined') {
Chart.defaults.font.family = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
}
/**
* Format a number with locale-appropriate grouping separators.
* Uses the visitor's browser locale (no explicit locale argument).
* @param {number} v
* @returns {string}
*/
function formatNumber(v) {
return new Intl.NumberFormat().format(v);
}
/**
* Read page colors from CSS custom properties (defined in app.css :root).
* Falls back to hardcoded values if CSS vars are unavailable.
*/
function getCSSColor(varName, fallback) {
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
}
function withAlpha(color, alpha) {
// oklch(0.65 0.24 265) -> oklch(0.65 0.24 265 / 0.1)
return color.replace(')', ' / ' + alpha + ')');
}
const ChartColors = {
get nodes() { return getCSSColor('--color-nodes', 'oklch(0.65 0.24 265)'); },
get nodesFill() { return withAlpha(this.nodes, 0.1); },
get adverts() { return getCSSColor('--color-adverts', 'oklch(0.7 0.17 330)'); },
get advertsFill() { return withAlpha(this.adverts, 0.1); },
get messages() { return getCSSColor('--color-messages', 'oklch(0.75 0.18 180)'); },
get messagesFill() { return withAlpha(this.messages, 0.1); },
get packets() { return getCSSColor('--color-packets', 'oklch(0.72 0.17 145)'); },
get packetsFill() { return withAlpha(this.packets, 0.1); },
get routes() { return getCSSColor('--color-routes', 'oklch(0.72 0.17 30)'); },
get routesFill() { return withAlpha(this.routes, 0.1); },
// Neutral grays (not page-specific)
grid: 'oklch(0.4 0 0 / 0.2)',
text: 'oklch(0.7 0 0)',
tooltipBg: 'oklch(0.25 0 0)',
tooltipText: 'oklch(0.9 0 0)',
tooltipBorder: 'oklch(0.4 0 0)',
// Qualitative palette for stacked breakdown bars (6 hues + neutral grey
// for "other"). Hardcoded oklch values render consistently across light
// and dark themes without extra CSS tokens.
breakdown: [
'oklch(0.65 0.24 265)', // blue
'oklch(0.7 0.17 330)', // magenta
'oklch(0.75 0.18 180)', // teal
'oklch(0.72 0.17 145)', // green
'oklch(0.7 0.19 80)', // yellow-green
'oklch(0.65 0.22 25)', // orange
'oklch(0.55 0 0)' // neutral grey (for "other")
],
// Semantic quality palette for route health charts. Hardcoded oklch
// values (same approach as `breakdown`) — app.css defines no semantic
// status colors.
quality: {
clear: 'oklch(0.72 0.17 145)',
marginal: 'oklch(0.75 0.18 85)',
failing: 'oklch(0.62 0.24 25)',
no_coverage: 'oklch(0.65 0.15 250)',
disabled: 'oklch(0.55 0 0)'
}
};
/**
* Create common chart options with optional legend
* @param {boolean} showLegend - Whether to show the legend
* @returns {Object} Chart.js options object
*/
function createChartOptions(showLegend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: showLegend,
position: 'top',
align: 'end',
labels: {
color: ChartColors.text,
boxWidth: 12,
padding: 8
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1,
callbacks: {
label: function(ctx) {
const label = ctx.dataset.label || '';
const value = formatNumber(ctx.parsed.y);
return label ? label + ': ' + value : value;
}
}
}
},
scales: {
x: {
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10
}
},
y: {
beginAtZero: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
precision: 0,
callback: function(value) { return formatNumber(value); }
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
};
}
/**
* Format date labels for chart display (e.g., "8 Feb")
* @param {Array} data - Array of objects with 'date' property
* @returns {Array} Formatted date strings
*/
function formatDateLabels(data) {
return data.map(function(d) {
var date = new Date(d.date);
return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
});
}
/**
* Create a single-dataset line chart
* @param {string} canvasId - ID of the canvas element
* @param {Object} data - Data object with 'data' array containing {date, count} objects
* @param {string} label - Dataset label
* @param {string} borderColor - Line color
* @param {string} backgroundColor - Fill color
* @param {boolean} fill - Whether to fill under the line
*/
function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
var ctx = document.getElementById(canvasId);
if (!ctx || !data || !data.data || data.data.length === 0) {
return null;
}
return new Chart(ctx, {
type: 'line',
data: {
labels: formatDateLabels(data.data),
datasets: [{
label: label,
data: data.data.map(function(d) { return d.count; }),
borderColor: borderColor,
backgroundColor: backgroundColor,
fill: fill,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
}]
},
options: createChartOptions(false)
});
}
/**
* Create a multi-dataset activity chart (for home page).
* Pass null for advertData or messageData to omit that series.
* @param {string} canvasId - ID of the canvas element
* @param {Object|null} advertData - Advertisement data with 'data' array, or null to omit
* @param {Object|null} messageData - Message data with 'data' array, or null to omit
*/
function createActivityChart(canvasId, advertData, messageData) {
var ctx = document.getElementById(canvasId);
if (!ctx) return null;
// Build datasets from whichever series are provided
var datasets = [];
var labels = null;
if (advertData && advertData.data && advertData.data.length > 0) {
if (!labels) labels = formatDateLabels(advertData.data);
datasets.push({
label: (window.t && window.t('entities.advertisements')) || 'Advertisements',
data: advertData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.adverts,
backgroundColor: ChartColors.advertsFill,
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
});
}
if (messageData && messageData.data && messageData.data.length > 0) {
if (!labels) labels = formatDateLabels(messageData.data);
datasets.push({
label: (window.t && window.t('entities.messages')) || 'Messages',
data: messageData.data.map(function(d) { return d.count; }),
borderColor: ChartColors.messages,
backgroundColor: ChartColors.messagesFill,
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5
});
}
if (datasets.length === 0 || !labels) return null;
return new Chart(ctx, {
type: 'line',
data: { labels: labels, datasets: datasets },
options: createChartOptions(true)
});
}
/**
* Create a horizontal 100% stacked bar chart from labeled buckets.
*
* Each bucket becomes one dataset sized proportionally to its count. The
* x-axis is fixed at 0-100% and tooltips show the raw count and percentage.
* Returns null when buckets is empty or the total is zero (matching
* createLineChart's empty-data idiom).
*
* @param {string} canvasId - ID of the canvas element
* @param {Array|null} buckets - Array of {label, count} objects
* @param {Array<string>} colors - Ordered color strings (one per bucket)
* @returns {Chart|null}
*/
function createStackedBarChart(canvasId, buckets, colors) {
var ctx = document.getElementById(canvasId);
if (!ctx || !buckets || buckets.length === 0) return null;
var total = buckets.reduce(function(sum, b) { return sum + b.count; }, 0);
if (total === 0) return null;
var datasets = buckets.map(function(bucket, i) {
var pct = (bucket.count / total) * 100;
return {
label: bucket.label,
data: [pct],
backgroundColor: colors[i % colors.length],
borderColor: colors[i % colors.length],
borderWidth: 1,
rawCount: bucket.count
};
});
return new Chart(ctx, {
type: 'bar',
data: {
labels: [''],
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1,
callbacks: {
label: function(ctx) {
var label = ctx.dataset.label || '';
var count = formatNumber(ctx.dataset.rawCount);
var pct = ctx.parsed.x.toFixed(1);
return label + ': ' + count + ' (' + pct + '%)';
}
}
}
},
scales: {
x: {
max: 100,
stacked: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
callback: function(value) { return value + '%'; }
}
},
y: {
stacked: true,
grid: { display: false },
ticks: { display: false }
}
},
interaction: {
mode: 'nearest',
intersect: false
}
}
});
}
/**
* Map a route-quality enum value to the merged 3-tier space used by the
* dashboard trend chart and Route Health widget.
*
* ``clear`` clear
* ``marginal`` marginal
* anything else failing (covers ``failing``, ``unknown``,
* ``no_coverage``, ``disabled``, null)
*/
function routeQualityToTier(q) {
if (q === 'clear') return 'clear';
if (q === 'marginal') return 'marginal';
return 'failing';
}
/**
* Mean tier over the displayed window. Maps the 3-tier space onto a
* 0/1/2 numeric scale (failing < marginal < clear), averages, then
* buckets back: >=1.5 clear, >=0.75 marginal, else failing.
* Empty history falls through to failing (matches routeQualityToTier's
* default for unknown / null quality).
*
* Kept in sync with ``compute_average_quality`` in
* ``src/meshcore_hub/collector/routes.py`` so the server-side rolling
* badge matches the client-side chart line color.
*
* @param {Array<{quality: string}>|null} history
* @returns {string} tier name (``clear`` / ``marginal`` / ``failing``)
*/
function averageRouteTier(history) {
if (!history || history.length === 0) return 'failing';
var sum = 0;
for (var i = 0; i < history.length; i++) {
var tier = routeQualityToTier(history[i].quality);
sum += (tier === 'clear' ? 2 : tier === 'marginal' ? 1 : 0);
}
var mean = sum / history.length;
if (mean >= 1.5) return 'clear';
if (mean >= 0.75) return 'marginal';
return 'failing';
}
/**
* Create a multi-line route-status trend chart for the dashboard.
*
* Each route becomes one line plotted on a 3-tier categorical Y axis
* (``failing`` ``marginal`` ``clear``, bottom to top). The line's
* color reflects the route's CURRENT quality (its latest evaluation),
* so multiple routes in the same health band share a color the chart
* reads as a fleet-health overview rather than per-route identity.
* Hover tooltips still show the route label, tier, and matched_count.
*
* Input is the ``routes`` array from ``GET /dashboard/routes-overview``.
* The top ``maxRoutes`` routes by current ``matched_count`` are drawn;
* the rest are dropped silently (summing quality tiers is meaningless).
*
* Quality tier mapping (per the merged-3-tier design):
* ``clear`` clear
* ``marginal`` marginal
* anything else failing (covers ``failing``, ``unknown``,
* ``no_coverage``, ``disabled``, null)
*
* @param {string} canvasId - ID of the canvas element
* @param {Array|null} routes - Array of RouteOverviewEntry objects
* @param {number} [maxRoutes=6] - Top-N routes drawn distinctly
* @returns {Chart|null}
*/
function createRoutesTrendChart(canvasId, routes, maxRoutes) {
var ctx = document.getElementById(canvasId);
if (!ctx || !routes || routes.length === 0) return null;
maxRoutes = maxRoutes || 6;
// Bottom-to-top tier order on the categorical Y axis.
var tierOrder = ['failing', 'marginal', 'clear'];
function tierColor(tier) {
return ChartColors.quality[tier] || ChartColors.quality.failing;
}
// Sort by current matched_count desc; routes with null matched_count
// (disabled / never evaluated) sort to the end.
var sorted = routes.slice().sort(function(a, b) {
var am = a.matched_count || 0;
var bm = b.matched_count || 0;
return bm - am;
});
var top = sorted.slice(0, maxRoutes);
// Use the longest history as the X-axis label source (all routes
// share the same window in practice, but be defensive).
var labels = [];
for (var i = 0; i < top.length; i++) {
if (top[i].history && top[i].history.length > labels.length) {
labels = formatDateLabels(top[i].history);
}
}
if (labels.length === 0) return null;
var datasets = top.map(function(entry) {
var history = entry.history || [];
var avgTier = averageRouteTier(history);
return {
label: entry.from_label + ' \u2192 ' + entry.to_label,
data: history.map(function(d) { return routeQualityToTier(d.quality); }),
borderColor: tierColor(avgTier),
backgroundColor: 'transparent',
fill: false,
tension: 0.3,
cubicInterpolationMode: 'monotone',
pointRadius: 2,
pointHoverRadius: 5,
spanGaps: true,
_matched: history.map(function(d) { return d.matched_count || 0; })
};
});
var opts = createChartOptions(false);
// Replace the default numeric Y axis with a 3-tier categorical axis.
opts.scales.y = {
type: 'category',
labels: tierOrder,
reverse: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
callback: function(_value, index) {
var tier = tierOrder[index];
return (window.t && window.t('routes.quality_' + tier)) || tier;
}
}
};
// The default tooltip formatter calls formatNumber(ctx.parsed.y),
// which is wrong for categorical string values; emit tier + matched.
opts.plugins.tooltip.callbacks = {
title: function(items) { return items[0].label; },
label: function(ctx) {
var tier = tierOrder[ctx.parsed.y] || 'failing';
var tierLabel = (window.t && window.t('routes.quality_' + tier)) || tier;
var matched = (ctx.dataset._matched && ctx.dataset._matched[ctx.dataIndex]) || 0;
return ctx.dataset.label + ': ' + tierLabel + ' (' + matched + ')';
}
};
return new Chart(ctx, {
type: 'line',
data: { labels: labels, datasets: datasets },
options: opts
});
}
/**
* Initialize dashboard charts (nodes, advertisements, messages, packets,
* plus optional packet-breakdown stacked bars and routes overview).
* Pass null for any data parameter to skip that chart.
* @param {Object|null} nodeData - Node count data, or null to skip
* @param {Object|null} advertData - Advertisement data, or null to skip
* @param {Object|null} messageData - Message data, or null to skip
* @param {Object|null} packetData - Raw-packet trend data, or null to skip
* @param {Array|null} [eventTypeData] - Packet event-type breakdown buckets
* @param {Array|null} [pathWidthData] - Packet path-width breakdown buckets
* @param {Array|null} [routesData] - Routes overview ``routes`` array
*/
function initDashboardCharts(nodeData, advertData, messageData, packetData, eventTypeData, pathWidthData, routesData) {
if (nodeData) {
createLineChart(
'nodeChart',
nodeData,
(window.t && window.t('common.total_entity', { entity: t('entities.nodes') })) || 'Total Nodes',
ChartColors.nodes,
ChartColors.nodesFill,
true
);
}
if (advertData) {
createLineChart(
'advertChart',
advertData,
(window.t && window.t('entities.advertisements')) || 'Advertisements',
ChartColors.adverts,
ChartColors.advertsFill,
true
);
}
if (messageData) {
createLineChart(
'messageChart',
messageData,
(window.t && window.t('entities.messages')) || 'Messages',
ChartColors.messages,
ChartColors.messagesFill,
true
);
}
if (packetData) {
createLineChart(
'packetChart',
packetData,
(window.t && window.t('entities.packets')) || 'Packets',
ChartColors.packets,
ChartColors.packetsFill,
true
);
}
if (eventTypeData && eventTypeData.length > 0) {
createStackedBarChart(
'packetEventTypeChart',
eventTypeData,
ChartColors.breakdown
);
}
if (pathWidthData && pathWidthData.length > 0) {
createStackedBarChart(
'packetPathWidthChart',
pathWidthData,
ChartColors.breakdown.slice(0, 3)
);
}
if (routesData && routesData.length > 0) {
createRoutesTrendChart('routesTrendChart', routesData);
}
}
/**
* Create a per-route health status strip single horizontal bar of N equal
* colored day-segments.
*
* @param {string} canvasId - ID of the canvas element
* @param {Object} routeData - RouteHistory payload with `data` array
* @returns {Chart|null}
*/
function createRouteDetailStrip(canvasId, routeData) {
var ctx = document.getElementById(canvasId);
if (!ctx || !routeData || !routeData.data || routeData.data.length === 0) {
return null;
}
var existing = Chart.getChart(ctx);
if (existing) existing.destroy();
var datasets = routeData.data.map(function(day) {
return {
label: day.date,
data: [1],
backgroundColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
borderColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
borderWidth: 1,
_quality: day.quality,
_matched_count: day.matched_count || 0
};
});
return new Chart(ctx, {
type: 'bar',
data: { labels: [''], datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1,
callbacks: {
title: function(ctx) { return ctx[0].dataset.label; },
label: function(ctx) {
var q = ctx.dataset._quality || 'unknown';
var label = (window.t && window.t('routes.quality_' + q)) || q;
return label + ' (' + ctx.dataset._matched_count + ')';
}
}
}
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { display: false } },
y: { stacked: true, grid: { display: false }, ticks: { display: false } }
},
interaction: { mode: 'nearest', intersect: true }
}
});
}
@@ -0,0 +1,101 @@
import type { ReactNode } from "react";
import { Bar, Line } from "react-chartjs-2";
import { useTranslation } from "react-i18next";
import {
buildActivityChart,
buildLineChart,
buildRouteDetailStrip,
buildRoutesTrend,
buildStackedBar,
type ActivitySeries,
type BreakdownBucket,
type RouteHistory,
type RouteOverviewEntry,
} from "@/utils/charts";
function ChartFrame({
className,
children,
}: {
className: string;
children: ReactNode;
}) {
return <div className={className}>{children}</div>;
}
export function ActivityChart({
advertData,
messageData,
}: {
advertData: ActivitySeries | null;
messageData: ActivitySeries | null;
}) {
const { t } = useTranslation();
const cfg = buildActivityChart(advertData, messageData, t);
return (
<ChartFrame className="h-48">
{cfg && <Line data={cfg.data} options={cfg.options} />}
</ChartFrame>
);
}
export function TrendLineChart({
data,
label,
borderColor,
backgroundColor,
fill = true,
}: {
data: ActivitySeries | null;
label: string;
borderColor: string;
backgroundColor: string;
fill?: boolean;
}) {
const cfg = buildLineChart(data, label, borderColor, backgroundColor, fill);
return (
<ChartFrame className="h-32">
{cfg && <Line data={cfg.data} options={cfg.options} />}
</ChartFrame>
);
}
export function StackedBarChart({
buckets,
colors,
}: {
buckets: BreakdownBucket[] | null;
colors: string[];
}) {
const cfg = buildStackedBar(buckets, colors);
return (
<ChartFrame className="h-32">
{cfg && <Bar data={cfg.data} options={cfg.options} />}
</ChartFrame>
);
}
export function RoutesTrendChart({
routes,
}: {
routes: RouteOverviewEntry[] | null;
}) {
const { t } = useTranslation();
const cfg = buildRoutesTrend(routes, t);
return (
<ChartFrame className="h-32">
{cfg && <Line data={cfg.data} options={cfg.options} />}
</ChartFrame>
);
}
export function RouteDetailStrip({ data }: { data: RouteHistory | undefined }) {
const { t } = useTranslation();
const cfg = buildRouteDetailStrip(data, t);
return (
<div style={{ height: "40px" }}>
{cfg && <Bar data={cfg.data} options={cfg.options} />}
</div>
);
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import QRCode from "react-qr-code";
import { useAppConfig, hasRole } from "@/context/AppConfigContext";
import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api";
@@ -8,24 +9,6 @@ import { usePageTitle } from "@/hooks/usePageTitle";
import { Loading, ErrorAlert } from "@/components/Alerts";
import { IconChannel, IconPlus, IconEdit, IconTrash } from "@/components/icons";
interface QRCodeOptions {
text: string;
width: number;
height: number;
correctLevel: number;
}
interface QRCodeConstructor {
new (el: HTMLElement, options: QRCodeOptions): unknown;
CorrectLevel: { L: number; M: number; Q: number; H: number };
}
declare global {
interface Window {
QRCode: QRCodeConstructor;
}
}
interface Channel {
id: string;
name: string;
@@ -51,21 +34,13 @@ type ModalState =
| { type: "delete"; channel: Channel };
function ChannelQrCode({ channel }: { channel: Channel }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || !channel.key_hex || el.hasChildNodes()) return;
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`;
new window.QRCode(el, {
text: qrUrl,
width: 128,
height: 128,
correctLevel: window.QRCode.CorrectLevel.M,
});
}, [channel]);
return <div ref={ref} className="qr-container" />;
if (!channel.key_hex) return null;
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`;
return (
<div className="qr-container">
<QRCode value={qrUrl} size={128} level="M" />
</div>
);
}
interface ChannelCardProps {
@@ -9,6 +9,11 @@ import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { ErrorAlert, Loading } from "@/components/Alerts";
import {
RoutesTrendChart,
StackedBarChart,
TrendLineChart,
} from "@/components/charts/Charts";
import { ObserverIcons } from "@/components/ObserverBadges";
import { RouteTypeBadge } from "@/components/RouteTypeBadge";
import {
@@ -25,6 +30,12 @@ import {
} from "@/context/AppConfigContext";
import { usePageTitle } from "@/hooks/usePageTitle";
import { apiGet, isAbortError } from "@/utils/api";
import {
averageRouteTier,
ChartColors,
type ActivitySeries,
type BreakdownBucket,
} from "@/utils/charts";
import { formatNumber, useFormatDateTime } from "@/utils/format";
interface DashboardStats {
@@ -35,8 +46,8 @@ interface DashboardStats {
}
interface PacketBreakdown {
by_event_type: { count: number }[];
by_path_width: { count: number }[];
by_event_type: BreakdownBucket[];
by_path_width: BreakdownBucket[];
}
interface RouteHealthEntry {
@@ -92,25 +103,15 @@ interface ChannelsResponse {
interface DashboardData {
stats: DashboardStats;
recentActivity: RecentActivity;
advertActivity: unknown;
messageActivity: unknown;
nodeCount: unknown;
packetActivity: unknown;
advertActivity: ActivitySeries | null;
messageActivity: ActivitySeries | null;
nodeCount: ActivitySeries | null;
packetActivity: ActivitySeries | null;
packetBreakdown: PacketBreakdown;
routesOverview: RoutesOverview | null;
channelsData: ChannelsResponse;
}
const CHART_IDS = [
"nodeChart",
"advertChart",
"messageChart",
"packetChart",
"packetEventTypeChart",
"packetPathWidthChart",
"routesTrendChart",
];
const QUALITY_COLORS: Record<string, string> = {
clear: "oklch(0.72 0.17 145)",
marginal: "oklch(0.75 0.18 85)",
@@ -140,7 +141,6 @@ function ChartCard({
title,
subtitle,
value,
canvasId,
children,
}: {
colorVar: string;
@@ -148,7 +148,6 @@ function ChartCard({
title: string;
subtitle: string;
value?: number;
canvasId?: string;
children?: ReactNode;
}) {
return (
@@ -174,11 +173,6 @@ function ChartCard({
</div>
)}
</div>
{canvasId && (
<div className="h-32">
<canvas id={canvasId}></canvas>
</div>
)}
{children}
</div>
</div>
@@ -204,10 +198,7 @@ function RoutesHealth({ routes }: { routes: RouteOverviewItem[] }) {
{visible.map((route, i) => {
const history = route.history || [];
const averageTier =
history.length > 0
? ((window as any).averageRouteTier?.(history) as string | null) ??
null
: null;
history.length > 0 ? averageRouteTier(history) : null;
const current =
averageTier ||
(route.enabled ? route.quality || "no_coverage" : "disabled");
@@ -289,14 +280,22 @@ export function DashboardPage() {
{},
{ signal },
),
apiGet<unknown>("/api/v1/dashboard/activity", { days: 7 }, { signal }),
apiGet<unknown>(
apiGet<ActivitySeries>(
"/api/v1/dashboard/activity",
{ days: 7 },
{ signal },
),
apiGet<ActivitySeries>(
"/api/v1/dashboard/message-activity",
{ days: 7 },
{ signal },
),
apiGet<unknown>("/api/v1/dashboard/node-count", { days: 7 }, { signal }),
apiGet<unknown>(
apiGet<ActivitySeries>(
"/api/v1/dashboard/node-count",
{ days: 7 },
{ signal },
),
apiGet<ActivitySeries>(
"/api/v1/dashboard/packet-activity",
{ days: 7 },
{ signal },
@@ -341,27 +340,6 @@ export function DashboardPage() {
return () => controller.abort();
}, [showRoutes, t]);
useEffect(() => {
if (!data) return;
window.initDashboardCharts(
showNodes ? data.nodeCount : null,
showAdverts ? data.advertActivity : null,
showMessages ? data.messageActivity : null,
showPackets ? data.packetActivity : null,
showPackets ? data.packetBreakdown.by_event_type : null,
showPackets ? data.packetBreakdown.by_path_width : null,
showRoutes && data.routesOverview?.routes
? data.routesOverview.routes
: null,
);
return () => {
for (const id of CHART_IDS) {
const canvas = document.getElementById(id);
if (canvas) (window as any).Chart?.getChart(canvas)?.destroy();
}
};
}, [data, showNodes, showAdverts, showMessages, showPackets, showRoutes]);
const channelLabels = useMemo(() => {
if (!data) return new Map<number, string>();
return new Map<number, string>([
@@ -438,8 +416,16 @@ export function DashboardPage() {
title={t("entities.nodes")}
subtitle={t("time.over_time_last_7_days")}
value={stats.total_nodes}
canvasId="nodeChart"
/>
>
<TrendLineChart
data={data.nodeCount}
label={t("common.total_entity", {
entity: t("entities.nodes"),
})}
borderColor={ChartColors.nodes}
backgroundColor={ChartColors.nodesFill}
/>
</ChartCard>
)}
{showAdverts && (
<ChartCard
@@ -448,8 +434,14 @@ export function DashboardPage() {
title={t("entities.advertisements")}
subtitle={t("time.per_day_last_7_days")}
value={stats.advertisements_7d}
canvasId="advertChart"
/>
>
<TrendLineChart
data={data.advertActivity}
label={t("entities.advertisements")}
borderColor={ChartColors.adverts}
backgroundColor={ChartColors.advertsFill}
/>
</ChartCard>
)}
{showMessages && (
<ChartCard
@@ -458,8 +450,14 @@ export function DashboardPage() {
title={t("entities.messages")}
subtitle={t("time.per_day_last_7_days")}
value={stats.messages_7d}
canvasId="messageChart"
/>
>
<TrendLineChart
data={data.messageActivity}
label={t("entities.messages")}
borderColor={ChartColors.messages}
backgroundColor={ChartColors.messagesFill}
/>
</ChartCard>
)}
{showPackets && (
<ChartCard
@@ -468,8 +466,14 @@ export function DashboardPage() {
title={t("entities.packets")}
subtitle={t("time.per_day_last_7_days")}
value={stats.packets_7d}
canvasId="packetChart"
/>
>
<TrendLineChart
data={data.packetActivity}
label={t("entities.packets")}
borderColor={ChartColors.packets}
backgroundColor={ChartColors.packetsFill}
/>
</ChartCard>
)}
</div>
@@ -482,8 +486,12 @@ export function DashboardPage() {
title={t("entities.packet_event_types")}
subtitle={t("time.last_7_days")}
value={eventTypeTotal}
canvasId="packetEventTypeChart"
/>
>
<StackedBarChart
buckets={packetBreakdown.by_event_type}
colors={ChartColors.breakdown}
/>
</ChartCard>
)}
{showPackets && (
<ChartCard
@@ -492,8 +500,12 @@ export function DashboardPage() {
title={t("entities.path_hash_width")}
subtitle={t("time.last_7_days")}
value={pathWidthTotal}
canvasId="packetPathWidthChart"
/>
>
<StackedBarChart
buckets={packetBreakdown.by_path_width}
colors={ChartColors.breakdown.slice(0, 3)}
/>
</ChartCard>
)}
{showRoutes && hasRoutes && (
<ChartCard
@@ -511,8 +523,9 @@ export function DashboardPage() {
subtitle={t("time.routes_over_last_n_days", {
n: routesOverview!.days,
})}
canvasId="routesTrendChart"
/>
>
<RoutesTrendChart routes={routesOverview!.routes} />
</ChartCard>
)}
</div>
)}
@@ -10,6 +10,7 @@ import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { ErrorAlert, Loading } from "@/components/Alerts";
import { ActivityChart } from "@/components/charts/Charts";
import { StatCard } from "@/components/StatCard";
import {
IconAdvertisements,
@@ -53,10 +54,6 @@ interface ActivitySeries {
data: { date: string; count: number }[];
}
interface ChartInstance {
destroy: () => void;
}
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
function NavCard({
@@ -153,7 +150,6 @@ export function HomePage() {
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const chartCanvasRef = useRef<HTMLCanvasElement | null>(null);
const hasDataRef = useRef(false);
const networkName = config.network_name || "MeshCore Network";
@@ -217,24 +213,6 @@ export function HomePage() {
useAutoRefresh({ onRefresh: load });
useEffect(() => {
if (!showActivityChart || !chartCanvasRef.current) return;
const chart = window.createActivityChart(
"activityChart",
showAdvertSeries ? advertActivity : null,
showMessageSeries ? messageActivity : null,
) as ChartInstance | null;
return () => {
chart?.destroy();
};
}, [
showActivityChart,
showAdvertSeries,
showMessageSeries,
advertActivity,
messageActivity,
]);
if (loading) return <Loading />;
if (error) return <ErrorAlert message={error} />;
if (!stats) return null;
@@ -470,9 +448,10 @@ export function HomePage() {
<p className="text-sm opacity-70 mb-2">
{t("time.activity_per_day_last_7_days")}
</p>
<div className="h-48">
<canvas ref={chartCanvasRef} id="activityChart"></canvas>
</div>
<ActivityChart
advertData={showAdvertSeries ? advertActivity : null}
messageData={showMessageSeries ? messageActivity : null}
/>
</div>
</div>
)}
@@ -1,6 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
import {
divIcon,
latLngBounds,
type DivIcon,
type Map as LeafletMap,
} from "leaflet";
import "leaflet/dist/leaflet.css";
import { useAppConfig } from "@/context/AppConfigContext";
import { usePageTitle } from "@/hooks/usePageTitle";
@@ -120,7 +128,7 @@ function getTypeDisplay(node: MapNode, t: TFunction): string {
: t("node_types.unknown");
}
function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any {
function createNodeIcon(node: MapNode, oidcEnabled: boolean): DivIcon {
const displayName = node.name || "";
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? " (" + relativeTime + ")" : "";
@@ -130,7 +138,7 @@ function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any {
? '<div style="width: 12px; height: 12px; background: var(--color-marker-infra); border: 2px solid var(--color-marker-infra-border); border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>'
: '<div style="width: 12px; height: 12px; background: var(--color-marker-public); border: 2px solid var(--color-marker-public-border); border-radius: 50%; box-shadow: 0 0 4px rgba(34,197,94,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
return L.divIcon({
return divIcon({
className: "custom-div-icon",
html:
'<div class="map-marker" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">' +
@@ -145,117 +153,92 @@ function createNodeIcon(L: any, node: MapNode, oidcEnabled: boolean): any {
});
}
function createPopupContent(
node: MapNode,
oidcEnabled: boolean,
t: TFunction,
): string {
function NodePopup({
node,
oidcEnabled,
}: {
node: MapNode;
oidcEnabled: boolean;
}) {
const { t } = useTranslation();
const typeDisplay = getTypeDisplay(node, t);
const nodeTypeEmoji = typeEmoji(node.adv_type);
let infraIndicatorHtml = "";
if (oidcEnabled && typeof node.is_adopted !== "undefined") {
const dotColor = node.is_adopted
? "var(--color-marker-infra)"
: "var(--color-marker-public)";
const borderColor = node.is_adopted
? "var(--color-marker-infra-border)"
: "var(--color-marker-public-border)";
const title = node.is_adopted ? t("map.infrastructure") : t("map.public");
infraIndicatorHtml =
' <span style="display: inline-block; width: 10px; height: 10px; background: ' +
dotColor +
"; border: 2px solid " +
borderColor +
'; border-radius: 50%; vertical-align: middle;" title="' +
escapeHtml(title) +
'"></span>';
}
const typeLabel = t("common.type");
const keyLabel = t("common.key");
const locationLabel = t("common.location");
const lastSeenLabel = t("common.last_seen_label");
const unknownLabel = t("node_types.unknown");
const viewDetailsLabel = t("common.view_details");
let rows = "";
rows +=
'<div class="opacity-70">' +
typeLabel +
"</div><div>" +
escapeHtml(typeDisplay) +
"</div>";
if (node.role) {
const roleLabel = t("map.role");
rows +=
'<div class="opacity-70">' +
roleLabel +
'</div><div><span class="badge badge-xs badge-ghost">' +
escapeHtml(node.role) +
"</span></div>";
}
if (node.owner) {
const ownerLabel = t("map.owner");
const ownerDisplay = node.owner.callsign
? escapeHtml(node.owner.name) +
" (" +
escapeHtml(node.owner.callsign) +
")"
: escapeHtml(node.owner.name);
rows +=
'<div class="opacity-70">' + ownerLabel + "</div><div>" + ownerDisplay + "</div>";
}
rows +=
'<div class="opacity-70">' +
keyLabel +
'</div><div><code class="text-xs">' +
escapeHtml(node.public_key.substring(0, 16)) +
"...</code></div>";
rows +=
'<div class="opacity-70">' +
locationLabel +
"</div><div>" +
node.lat.toFixed(4) +
", " +
node.lon.toFixed(4) +
"</div>";
if (node.last_seen) {
rows +=
'<div class="opacity-70">' +
lastSeenLabel +
"</div><div>" +
node.last_seen.substring(0, 19).replace("T", " ") +
"</div>";
}
const showInfra = oidcEnabled && typeof node.is_adopted !== "undefined";
return (
'<div class="p-2">' +
'<h3 class="font-bold text-lg mb-2">' +
nodeTypeEmoji +
" " +
escapeHtml(node.name || unknownLabel) +
infraIndicatorHtml +
"</h3>" +
'<div class="text-sm grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">' +
rows +
"</div>" +
'<a href="/nodes/' +
encodeURIComponent(node.public_key) +
'" class="btn btn-outline btn-xs mt-3">' +
viewDetailsLabel +
"</a>" +
"</div>"
<div className="p-2">
<h3 className="font-bold text-lg mb-2">
{nodeTypeEmoji} {node.name || unknownLabel}
{showInfra && (
<span
style={{
display: "inline-block",
width: "10px",
height: "10px",
background: node.is_adopted
? "var(--color-marker-infra)"
: "var(--color-marker-public)",
border: `2px solid ${
node.is_adopted
? "var(--color-marker-infra-border)"
: "var(--color-marker-public-border)"
}`,
borderRadius: "50%",
verticalAlign: "middle",
}}
title={node.is_adopted ? t("map.infrastructure") : t("map.public")}
/>
)}
</h3>
<div className="text-sm grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
<div className="opacity-70">{t("common.type")}</div>
<div>{typeDisplay}</div>
{node.role && (
<>
<div className="opacity-70">{t("map.role")}</div>
<div>
<span className="badge badge-xs badge-ghost">{node.role}</span>
</div>
</>
)}
{node.owner && (
<>
<div className="opacity-70">{t("map.owner")}</div>
<div>
{node.owner.callsign
? `${node.owner.name} (${node.owner.callsign})`
: node.owner.name}
</div>
</>
)}
<div className="opacity-70">{t("common.key")}</div>
<div>
<code className="text-xs">{node.public_key.substring(0, 16)}...</code>
</div>
<div className="opacity-70">{t("common.location")}</div>
<div>
{node.lat.toFixed(4)}, {node.lon.toFixed(4)}
</div>
{node.last_seen && (
<>
<div className="opacity-70">{t("common.last_seen_label")}</div>
<div>{node.last_seen.substring(0, 19).replace("T", " ")}</div>
</>
)}
</div>
<a
href={`/nodes/${encodeURIComponent(node.public_key)}`}
className="btn btn-outline btn-xs mt-3"
>
{t("common.view_details")}
</a>
</div>
);
}
function fitInitialBounds(
map: any,
L: any,
map: LeafletMap,
data: MapData,
oidcEnabled: boolean,
): void {
@@ -265,7 +248,7 @@ function fitInitialBounds(
const adoptedNodes = allNodes.filter((n) => n.is_adopted);
if (adoptedNodes.length > 0) {
map.fitBounds(
L.latLngBounds(adoptedNodes.map((n) => [n.lat, n.lon])),
latLngBounds(adoptedNodes.map((n) => [n.lat, n.lon])),
{ padding },
);
return;
@@ -284,11 +267,59 @@ function fitInitialBounds(
);
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
map.fitBounds(
L.latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
{ padding },
);
}
function MapController({
mapData,
filteredNodes,
category,
oidcEnabled,
}: {
mapData: MapData;
filteredNodes: MapNode[];
category: string;
oidcEnabled: boolean;
}) {
const map = useMap();
const initialFitRef = useRef(false);
useEffect(() => {
if (!mapData) return;
if (!initialFitRef.current) {
initialFitRef.current = true;
fitInitialBounds(map, mapData, oidcEnabled);
return;
}
if (filteredNodes.length > 0) {
let nodesToFit = filteredNodes;
if (category !== "infra") {
const anchor = getAnchorPoint(filteredNodes, mapData.adopted_center);
const nearbyNodes = getNodesWithinRadius(
filteredNodes,
anchor.lat,
anchor.lon,
MAX_BOUNDS_RADIUS_KM,
);
if (nearbyNodes.length > 0) nodesToFit = nearbyNodes;
}
map.fitBounds(
latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
{ padding: getBoundsPadding() },
);
} else {
const center = mapData.center;
if (center && (center.lat !== 0 || center.lon !== 0)) {
map.setView([center.lat, center.lon], 10);
}
}
}, [map, mapData, filteredNodes, category, oidcEnabled]);
return null;
}
export function MapPage() {
const { t } = useTranslation();
const config = useAppConfig();
@@ -298,11 +329,6 @@ export function MapPage() {
const tz = config.timezone || "";
const operatorRole = config.role_names?.operator || "operator";
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<any>(null);
const markersRef = useRef<any[]>([]);
const initialFitRef = useRef(false);
const [mapData, setMapData] = useState<MapData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -311,8 +337,6 @@ export function MapPage() {
const [typeFilter, setTypeFilter] = useState("");
const [operatorFilter, setOperatorFilter] = useState("");
const [showLabels, setShowLabels] = useState(false);
const [nodeCount, setNodeCount] = useState(0);
const [filteredCount, setFilteredCount] = useState<number | null>(null);
const operatorProfiles = useMemo(
() =>
@@ -343,99 +367,34 @@ export function MapPage() {
return () => ac.abort();
}, [operatorFilter, t]);
useEffect(() => {
if (loading || mapRef.current) return;
const L = (window as any).L;
const el = mapContainerRef.current;
if (!L || !el) return;
const map = L.map(el).setView([0, 0], 2);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
mapRef.current = map;
return () => {
mapRef.current = null;
markersRef.current = [];
map.remove();
};
}, [loading]);
const allNodes = useMemo(() => mapData?.nodes ?? [], [mapData]);
const updateMarkers = useCallback(
(map: any, L: any, nodes: MapNode[]) => {
markersRef.current.forEach((m) => map.removeLayer(m));
markersRef.current = [];
nodes.forEach((node) => {
const marker = L.marker([node.lat, node.lon], {
icon: createNodeIcon(L, node, oidcEnabled),
}).addTo(map);
marker.bindPopup(createPopupContent(node, oidcEnabled, t));
markersRef.current.push(marker);
});
},
[oidcEnabled, t],
const filteredNodes = useMemo(
() =>
allNodes.filter((node) => {
if (category === "infra" && !node.is_adopted) return false;
if (typeFilter && normalizeType(node.adv_type) !== typeFilter)
return false;
return true;
}),
[allNodes, category, typeFilter],
);
const applyFilters = useCallback(() => {
const map = mapRef.current;
const L = (window as any).L;
if (!map || !L || !mapData) return;
const allNodes = mapData.nodes || [];
const filteredNodes = allNodes.filter((node) => {
if (category === "infra" && !node.is_adopted) return false;
if (typeFilter && normalizeType(node.adv_type) !== typeFilter)
return false;
return true;
});
updateMarkers(map, L, filteredNodes);
setNodeCount(allNodes.length);
setFilteredCount(filteredNodes.length);
if (filteredNodes.length > 0) {
let nodesToFit = filteredNodes;
if (category !== "infra") {
const anchor = getAnchorPoint(filteredNodes, mapData.adopted_center);
const nearbyNodes = getNodesWithinRadius(
filteredNodes,
anchor.lat,
anchor.lon,
MAX_BOUNDS_RADIUS_KM,
);
if (nearbyNodes.length > 0) nodesToFit = nearbyNodes;
}
map.fitBounds(
L.latLngBounds(nodesToFit.map((n) => [n.lat, n.lon])),
{ padding: getBoundsPadding() },
);
} else {
const center = mapData.center;
if (center && (center.lat !== 0 || center.lon !== 0)) {
map.setView([center.lat, center.lon], 10);
}
}
}, [mapData, category, typeFilter, updateMarkers]);
useEffect(() => {
const map = mapRef.current;
const L = (window as any).L;
if (!map || !L || !mapData || mapData.debug?.error) return;
if (!initialFitRef.current) {
initialFitRef.current = true;
fitInitialBounds(map, L, mapData, oidcEnabled);
const allNodes = mapData.nodes || [];
updateMarkers(map, L, allNodes);
setNodeCount(allNodes.length);
setFilteredCount(allNodes.length);
return;
}
applyFilters();
}, [mapData, oidcEnabled, applyFilters, updateMarkers]);
useEffect(() => {
const el = mapContainerRef.current;
if (el) el.classList.toggle("show-labels", showLabels);
}, [showLabels]);
const markers = useMemo(
() =>
filteredNodes.map((node) => (
<Marker
key={node.public_key}
position={[node.lat, node.lon]}
icon={createNodeIcon(node, oidcEnabled)}
>
<Popup>
<NodePopup node={node} oidcEnabled={oidcEnabled} />
</Popup>
</Marker>
)),
[filteredNodes, oidcEnabled],
);
const clearFilters = () => {
setCategory("");
@@ -445,6 +404,8 @@ export function MapPage() {
};
const debug = mapData?.debug ?? null;
const nodeCount = allNodes.length;
const filteredCount = filteredNodes.length;
let countBadgeText: string;
if (debug?.error) {
countBadgeText = "Error: " + debug.error;
@@ -456,14 +417,14 @@ export function MapPage() {
countBadgeText = t("map.nodes_none_have_coordinates", {
count: formatNumber(debug.total_nodes),
});
} else if (filteredCount === null || filteredCount === nodeCount) {
} else if (filteredCount === nodeCount) {
countBadgeText = t("map.nodes_on_map", {
count: formatNumber(nodeCount),
});
} else {
countBadgeText = t("common.total", { count: formatNumber(nodeCount) });
}
const showFilteredBadge = filteredCount !== null && filteredCount !== nodeCount;
const showFilteredBadge = filteredCount !== nodeCount;
if (loading) return <Loading />;
if (error) return <ErrorAlert message={error} />;
@@ -560,9 +521,29 @@ export function MapPage() {
<div className="card bg-base-100 shadow-xl">
<div className="card-body p-2">
<div
ref={mapContainerRef}
className={showLabels ? "show-labels" : undefined}
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
/>
>
<MapContainer
center={[0, 0]}
zoom={2}
style={{ height: "100%", width: "100%" }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{markers}
{mapData && (
<MapController
mapData={mapData}
filteredNodes={filteredNodes}
category={category}
oidcEnabled={oidcEnabled}
/>
)}
</MapContainer>
</div>
</div>
</div>
@@ -1,13 +1,17 @@
import {
useCallback,
useEffect,
useRef,
useMemo,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { MapContainer, Marker, TileLayer, useMap } from "react-leaflet";
import { divIcon, point as leafletPoint } from "leaflet";
import "leaflet/dist/leaflet.css";
import QRCode from "react-qr-code";
import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts";
import { IconEdit, IconError, IconPlus, IconTrash } from "@/components/icons";
import { hasRole, useAppConfig } from "@/context/AppConfigContext";
@@ -65,6 +69,33 @@ function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function OffsetCenter({ lat, lon }: { lat: number; lon: number }) {
const map = useMap();
useEffect(() => {
map.setView([lat, lon], 14);
const point = map.latLngToContainerPoint([lat, lon]);
const size = map.getSize();
const newPoint = leafletPoint(point.x + size.x * 0.17, point.y);
const newLatLng = map.containerPointToLatLng(newPoint);
map.setView(newLatLng, 14, { animate: false });
}, [map, lat, lon]);
return null;
}
function NodeQrCode({ url, className }: { url: string; className: string }) {
return (
<div className={className}>
<QRCode
value={url}
size={140}
level="L"
fgColor="#000000"
bgColor="#ffffff"
/>
</div>
);
}
export function NodeDetailPage() {
const { t } = useTranslation();
const config = useAppConfig();
@@ -102,11 +133,6 @@ export function NodeDetailPage() {
const [deleteKey, setDeleteKey] = useState<string | null>(null);
const [deleteSaving, setDeleteSaving] = useState(false);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const qrRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<unknown>(null);
const qrInitRef = useRef(false);
useEffect(() => {
if (!publicKey || publicKey.length === 64) return;
const ac = new AbortController();
@@ -201,48 +227,22 @@ export function NodeDetailPage() {
const displayName = tagName || node?.name || t("common.unnamed_node");
const emoji = typeEmoji(node?.adv_type ?? null);
useEffect(() => {
if (!node || !hasCoords || lat == null || lon == null) return;
const L = (window as any).L;
const mapEl = mapContainerRef.current;
if (!L || !mapEl) return;
const map = L.map(mapEl, {
zoomControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
attributionControl: false,
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(
map,
);
map.setView([lat, lon], 14);
const point = map.latLngToContainerPoint([lat, lon]);
const newPoint = L.point(point.x + map.getSize().x * 0.17, point.y);
const newLatLng = map.containerPointToLatLng(newPoint);
map.setView(newLatLng, 14, { animate: false });
const mapIcon = L.divIcon({
html:
'<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' +
emoji +
"</span>",
className: "",
iconSize: [32, 32],
iconAnchor: [16, 16],
});
L.marker([lat, lon], { icon: mapIcon }).addTo(map);
mapRef.current = map;
return () => {
mapRef.current = null;
map.remove();
};
}, [node, hasCoords, lat, lon, emoji]);
const nodeMapIcon = useMemo(
() =>
divIcon({
html:
'<span style="font-size: 32px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">' +
emoji +
"</span>",
className: "",
iconSize: [32, 32],
iconAnchor: [16, 16],
}),
[emoji],
);
useEffect(() => {
if (!node) return;
qrInitRef.current = false;
const qrUrl = useMemo(() => {
if (!node) return "";
const typeMap: Record<string, number> = {
chat: 1,
repeater: 2,
@@ -251,36 +251,15 @@ export function NodeDetailPage() {
sensor: 4,
};
const typeNum = typeMap[(node.adv_type || "").toLowerCase()] || 1;
const url =
return (
"meshcore://contact/add?name=" +
encodeURIComponent(displayName) +
"&public_key=" +
node.public_key +
"&type=" +
typeNum;
const initQr = (): boolean => {
const QRCode = (window as any).QRCode;
const el = qrRef.current;
if (!QRCode || !el || qrInitRef.current) return false;
el.innerHTML = "";
new QRCode(el, {
text: url,
width: 140,
height: 140,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.L,
});
qrInitRef.current = true;
return true;
};
if (initQr()) return;
let attempts = 0;
const interval = setInterval(() => {
if (initQr() || ++attempts >= 20) clearInterval(interval);
}, 100);
return () => clearInterval(interval);
}, [node, displayName, hasCoords]);
typeNum
);
}, [node, displayName]);
useEffect(() => {
if (!flash) return;
@@ -683,20 +662,40 @@ export function NodeDetailPage() {
<ErrorAlert message={flash.message} />
))}
{hasCoords ? (
{hasCoords && lat != null && lon != null ? (
<div
className="relative rounded-box overflow-hidden mb-6 shadow-xl"
style={{ height: 180 }}
>
<div ref={mapContainerRef} className="absolute inset-0 z-0" />
<div className="absolute inset-0 z-0">
<MapContainer
center={[lat, lon]}
zoom={14}
zoomControl={false}
dragging={false}
scrollWheelZoom={false}
doubleClickZoom={false}
boxZoom={false}
keyboard={false}
attributionControl={false}
style={{ height: "100%", width: "100%" }}
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<Marker position={[lat, lon]} icon={nodeMapIcon} />
<OffsetCenter lat={lat} lon={lon} />
</MapContainer>
</div>
<div className="relative z-20 h-full p-3 flex items-center justify-end">
<div ref={qrRef} className="bg-white p-2 rounded-box shadow-lg" />
<NodeQrCode
url={qrUrl}
className="bg-white p-2 rounded-box shadow-lg"
/>
</div>
</div>
) : (
<div className="card bg-base-100 shadow-xl mb-6">
<div className="card-body flex-row items-center gap-4">
<div ref={qrRef} className="bg-white p-2 rounded-box" />
<NodeQrCode url={qrUrl} className="bg-white p-2 rounded-box" />
<p className="text-sm opacity-70">{t("nodes.scan_to_add")}</p>
</div>
</div>
@@ -13,6 +13,7 @@ import { useAppConfig, hasRole } from "@/context/AppConfigContext";
import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api";
import { usePageTitle } from "@/hooks/usePageTitle";
import { Loading, ErrorAlert } from "@/components/Alerts";
import { RouteDetailStrip } from "@/components/charts/Charts";
import {
IconClock,
IconEdit,
@@ -108,9 +109,6 @@ interface SelectedNode {
name?: string | null;
}
interface ChartInstance {
destroy: () => void;
}
interface ModalState {
type: "add" | "edit" | "delete";
@@ -477,9 +475,7 @@ function DetailContent({
<div className="mt-2 space-y-3 text-sm">
{history && (
<div className="mb-3">
<div style={{ height: "40px" }}>
<canvas id={`routeStripChart-${route.id}`}></canvas>
</div>
<RouteDetailStrip data={history} />
{historyData.length > 0 && (
<div className="flex text-xs opacity-50 mt-0.5">
{historyData.map((d, i) => (
@@ -1169,21 +1165,11 @@ export function RoutesPage() {
const detailCacheRef = useRef<Record<string, RouteDetail>>({});
const historyCacheRef = useRef<Record<string, RouteHistory>>({});
const chartsRef = useRef<ChartInstance[]>([]);
const pathTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const obsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pathSearchIdRef = useRef(0);
const obsSearchIdRef = useRef(0);
const destroyCharts = useCallback(() => {
chartsRef.current.forEach((c) => {
try {
c.destroy();
} catch (_) {}
});
chartsRef.current = [];
}, []);
const loadAllDetails = useCallback(async (routesList: RouteItem[]) => {
const newDetails: Record<string, RouteDetail> = {};
const newHistories: Record<string, RouteHistory> = {};
@@ -1251,27 +1237,12 @@ export function RoutesPage() {
};
}, [fetchRoutes, loadAllDetails]);
useEffect(() => {
destroyCharts();
for (const r of routes) {
const h = historyCache[r.id];
if (detailCache[r.id] && h) {
const chart = window.createRouteDetailStrip(
`routeStripChart-${r.id}`,
h,
) as ChartInstance | null;
if (chart) chartsRef.current.push(chart);
}
}
}, [routes, detailCache, historyCache, destroyCharts]);
useEffect(() => {
return () => {
destroyCharts();
if (pathTimerRef.current) clearTimeout(pathTimerRef.current);
if (obsTimerRef.current) clearTimeout(obsTimerRef.current);
};
}, [destroyCharts]);
}, []);
const openAddModal = () => {
setModal({
@@ -57,13 +57,6 @@ declare global {
interface Window {
__APP_CONFIG__: AppConfig;
t: (key: string, params?: Record<string, unknown>) => string;
formatNumber: (value: number | string | null | undefined) => string;
createActivityChart: (...args: unknown[]) => unknown;
createLineChart: (...args: unknown[]) => unknown;
createStackedBarChart: (...args: unknown[]) => unknown;
createRoutesTrendChart: (...args: unknown[]) => unknown;
createRouteDetailStrip: (...args: unknown[]) => unknown;
initDashboardCharts: (...args: unknown[]) => unknown;
}
}
@@ -0,0 +1,493 @@
import Chart from "chart.js/auto";
import type {
ChartData,
ChartDataset,
ChartOptions,
TooltipItem,
} from "chart.js";
import type { TFunction } from "i18next";
Chart.defaults.font.family =
'"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
export interface ActivityPoint {
date: string;
count: number;
}
export interface ActivitySeries {
data: ActivityPoint[];
}
export interface BreakdownBucket {
label: string;
count: number;
}
export interface RouteHistoryDay {
date: string;
quality?: string | null;
matched_count?: number | null;
}
export interface RouteHistory {
data?: RouteHistoryDay[];
}
export interface RouteOverviewEntry {
from_label: string;
to_label: string;
matched_count?: number | null;
history?: RouteHistoryDay[];
}
export interface ChartConfig<T extends "line" | "bar"> {
data: ChartData<T>;
options: ChartOptions<T>;
}
function formatNumber(v: number): string {
return new Intl.NumberFormat().format(v);
}
function getCSSColor(varName: string, fallback: string): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim() || fallback
);
}
function withAlpha(color: string, alpha: number): string {
return color.replace(")", " / " + alpha + ")");
}
export const ChartColors = {
get nodes() {
return getCSSColor("--color-nodes", "oklch(0.65 0.24 265)");
},
get nodesFill() {
return withAlpha(this.nodes, 0.1);
},
get adverts() {
return getCSSColor("--color-adverts", "oklch(0.7 0.17 330)");
},
get advertsFill() {
return withAlpha(this.adverts, 0.1);
},
get messages() {
return getCSSColor("--color-messages", "oklch(0.75 0.18 180)");
},
get messagesFill() {
return withAlpha(this.messages, 0.1);
},
get packets() {
return getCSSColor("--color-packets", "oklch(0.72 0.17 145)");
},
get packetsFill() {
return withAlpha(this.packets, 0.1);
},
get routes() {
return getCSSColor("--color-routes", "oklch(0.72 0.17 30)");
},
get routesFill() {
return withAlpha(this.routes, 0.1);
},
grid: "oklch(0.4 0 0 / 0.2)",
text: "oklch(0.7 0 0)",
tooltipBg: "oklch(0.25 0 0)",
tooltipText: "oklch(0.9 0 0)",
tooltipBorder: "oklch(0.4 0 0)",
breakdown: [
"oklch(0.65 0.24 265)",
"oklch(0.7 0.17 330)",
"oklch(0.75 0.18 180)",
"oklch(0.72 0.17 145)",
"oklch(0.7 0.19 80)",
"oklch(0.65 0.22 25)",
"oklch(0.55 0 0)",
],
quality: {
clear: "oklch(0.72 0.17 145)",
marginal: "oklch(0.75 0.18 85)",
failing: "oklch(0.62 0.24 25)",
no_coverage: "oklch(0.65 0.15 250)",
disabled: "oklch(0.55 0 0)",
} as Record<string, string>,
};
function createChartOptions(showLegend: boolean): ChartOptions<"line"> {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: showLegend,
position: "top",
align: "end",
labels: {
color: ChartColors.text,
boxWidth: 12,
padding: 8,
},
},
tooltip: {
mode: "index",
intersect: false,
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1,
callbacks: {
label: (ctx: TooltipItem<"line">) => {
const label = ctx.dataset.label || "";
const value = formatNumber(ctx.parsed.y ?? 0);
return label ? label + ": " + value : value;
},
},
},
},
scales: {
x: {
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 10,
},
},
y: {
beginAtZero: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
precision: 0,
callback: (value) => formatNumber(Number(value)),
},
},
},
interaction: {
mode: "nearest",
axis: "x",
intersect: false,
},
};
}
function formatDateLabels(data: { date: string }[]): string[] {
return data.map((d) => {
const date = new Date(d.date);
return date.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
});
});
}
export function routeQualityToTier(q: string | null | undefined): string {
if (q === "clear") return "clear";
if (q === "marginal") return "marginal";
return "failing";
}
export function averageRouteTier(
history: { quality?: string | null }[] | null | undefined,
): string {
if (!history || history.length === 0) return "failing";
let sum = 0;
for (const entry of history) {
const tier = routeQualityToTier(entry.quality);
sum += tier === "clear" ? 2 : tier === "marginal" ? 1 : 0;
}
const mean = sum / history.length;
if (mean >= 1.5) return "clear";
if (mean >= 0.75) return "marginal";
return "failing";
}
export function buildLineChart(
data: ActivitySeries | null | undefined,
label: string,
borderColor: string,
backgroundColor: string,
fill: boolean,
): ChartConfig<"line"> | null {
if (!data || !data.data || data.data.length === 0) return null;
return {
data: {
labels: formatDateLabels(data.data),
datasets: [
{
label,
data: data.data.map((d) => d.count),
borderColor,
backgroundColor,
fill,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5,
},
],
},
options: createChartOptions(false),
};
}
export function buildActivityChart(
advertData: ActivitySeries | null | undefined,
messageData: ActivitySeries | null | undefined,
t: TFunction,
): ChartConfig<"line"> | null {
const datasets: ChartDataset<"line">[] = [];
let labels: string[] | null = null;
if (advertData && advertData.data && advertData.data.length > 0) {
if (!labels) labels = formatDateLabels(advertData.data);
datasets.push({
label: t("entities.advertisements"),
data: advertData.data.map((d) => d.count),
borderColor: ChartColors.adverts,
backgroundColor: ChartColors.advertsFill,
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5,
});
}
if (messageData && messageData.data && messageData.data.length > 0) {
if (!labels) labels = formatDateLabels(messageData.data);
datasets.push({
label: t("entities.messages"),
data: messageData.data.map((d) => d.count),
borderColor: ChartColors.messages,
backgroundColor: ChartColors.messagesFill,
fill: true,
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 5,
});
}
if (datasets.length === 0 || !labels) return null;
return { data: { labels, datasets }, options: createChartOptions(true) };
}
type StackedBarDataset = ChartDataset<"bar"> & { rawCount?: number };
export function buildStackedBar(
buckets: BreakdownBucket[] | null | undefined,
colors: string[],
): ChartConfig<"bar"> | null {
if (!buckets || buckets.length === 0) return null;
const total = buckets.reduce((sum, b) => sum + b.count, 0);
if (total === 0) return null;
const datasets: StackedBarDataset[] = buckets.map((bucket, i) => {
const pct = (bucket.count / total) * 100;
return {
label: bucket.label,
data: [pct],
backgroundColor: colors[i % colors.length],
borderColor: colors[i % colors.length],
borderWidth: 1,
rawCount: bucket.count,
};
});
return {
data: { labels: [""], datasets },
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: "y",
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1,
callbacks: {
label: (ctx: TooltipItem<"bar">) => {
const ds = ctx.dataset as StackedBarDataset;
const label = ds.label || "";
const count = formatNumber(ds.rawCount ?? 0);
const pct = (ctx.parsed.x ?? 0).toFixed(1);
return label + ": " + count + " (" + pct + "%)";
},
},
},
},
scales: {
x: {
max: 100,
stacked: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
callback: (value) => value + "%",
},
},
y: {
stacked: true,
grid: { display: false },
ticks: { display: false },
},
},
interaction: {
mode: "nearest",
intersect: false,
},
},
};
}
type RouteTrendDataset = ChartDataset<"line"> & { _matched?: number[] };
export function buildRoutesTrend(
routes: RouteOverviewEntry[] | null | undefined,
t: TFunction,
maxRoutes = 6,
): ChartConfig<"line"> | null {
if (!routes || routes.length === 0) return null;
const tierOrder = ["failing", "marginal", "clear"];
const tierColor = (tier: string): string =>
ChartColors.quality[tier] || ChartColors.quality.failing;
const sorted = routes
.slice()
.sort((a, b) => (b.matched_count || 0) - (a.matched_count || 0));
const top = sorted.slice(0, maxRoutes);
let labels: string[] = [];
for (const entry of top) {
if (entry.history && entry.history.length > labels.length) {
labels = formatDateLabels(entry.history);
}
}
if (labels.length === 0) return null;
const datasets: RouteTrendDataset[] = top.map((entry) => {
const history = entry.history || [];
const avgTier = averageRouteTier(history);
return {
label: entry.from_label + " \u2192 " + entry.to_label,
data: history.map((d) => routeQualityToTier(d.quality)),
borderColor: tierColor(avgTier),
backgroundColor: "transparent",
fill: false,
tension: 0.3,
cubicInterpolationMode: "monotone",
pointRadius: 2,
pointHoverRadius: 5,
spanGaps: true,
_matched: history.map((d) => d.matched_count || 0),
} as unknown as RouteTrendDataset;
});
const opts = createChartOptions(false);
opts.scales = {
...opts.scales,
y: {
type: "category",
labels: tierOrder,
reverse: true,
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
callback: (_value, index) => {
const tier = tierOrder[index];
return t("routes.quality_" + tier);
},
},
},
};
opts.plugins = {
...opts.plugins,
tooltip: {
...opts.plugins?.tooltip,
callbacks: {
title: (items: TooltipItem<"line">[]) => items[0].label,
label: (ctx: TooltipItem<"line">) => {
const ds = ctx.dataset as RouteTrendDataset;
const tier = tierOrder[ctx.parsed.y as number] || "failing";
const tierLabel = t("routes.quality_" + tier);
const matched = ds._matched?.[ctx.dataIndex] ?? 0;
return (ds.label || "") + ": " + tierLabel + " (" + matched + ")";
},
},
},
};
return {
data: { labels, datasets },
options: opts as ChartOptions<"line">,
};
}
type StripDataset = ChartDataset<"bar"> & {
_quality?: string;
_matched_count?: number;
};
export function buildRouteDetailStrip(
routeData: RouteHistory | null | undefined,
t: TFunction,
): ChartConfig<"bar"> | null {
if (!routeData || !routeData.data || routeData.data.length === 0) return null;
const datasets: StripDataset[] = routeData.data.map((day) => {
const color =
ChartColors.quality[day.quality ?? ""] || ChartColors.quality.no_coverage;
return {
label: day.date,
data: [1],
backgroundColor: color,
borderColor: color,
borderWidth: 1,
_quality: day.quality ?? "unknown",
_matched_count: day.matched_count || 0,
};
});
return {
data: { labels: [""], datasets },
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: "y",
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1,
callbacks: {
title: (ctx: TooltipItem<"bar">[]) => ctx[0].dataset.label || "",
label: (ctx: TooltipItem<"bar">) => {
const ds = ctx.dataset as StripDataset;
const q = ds._quality || "unknown";
const label = t("routes.quality_" + q);
return label + " (" + (ds._matched_count ?? 0) + ")";
},
},
},
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { display: false } },
y: { stacked: true, grid: { display: false }, ticks: { display: false } },
},
interaction: { mode: "nearest", intersect: true },
},
};
}
+5 -17
View File
@@ -43,8 +43,11 @@
<!-- Tailwind CSS with DaisyUI (built from source) -->
<link rel="stylesheet" href="/static/css/tailwind.css?v={{ version }}" />
<!-- Leaflet CSS for maps -->
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v={{ vendor_hashes['leaflet.css'] }}" />
<!-- Vite-bundled CSS (React components + Leaflet). Loaded before app.css
so app.css theme overrides (e.g. dark-mode map popups) still win. -->
{% if asset_app_css %}
<link rel="stylesheet" href="/static/dist/{{ asset_app_css }}">
{% endif %}
<!-- Custom application styles -->
<link rel="stylesheet" href="/static/css/app.css?v={{ version }}">
@@ -183,18 +186,6 @@
</div>
</footer>
<!-- Leaflet JS for maps -->
<script src="/static/vendor/leaflet/leaflet.js?v={{ vendor_hashes['leaflet.js'] }}"></script>
<!-- Chart.js for charts -->
<script src="/static/vendor/chart.js/chart.umd.min.js?v={{ vendor_hashes['chart.umd.min.js'] }}"></script>
<!-- QR Code library -->
<script src="/static/vendor/qrcodejs/qrcode.min.js?v={{ vendor_hashes['qrcode.min.js'] }}"></script>
<!-- Chart helper functions -->
<script src="/static/js/charts.js?v={{ version }}"></script>
<!-- Embedded app configuration -->
<script>
window.__APP_CONFIG__ = {{ config_json|safe }};
@@ -217,9 +208,6 @@
</script>
<!-- SPA Application (ES Module) -->
{% if asset_app_css %}
<link rel="stylesheet" href="/static/dist/{{ asset_app_css }}">
{% endif %}
{% if asset_app_js %}
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
{% else %}
+2 -2
View File
@@ -1125,8 +1125,8 @@ class TestComputeAverageQuality:
"""Rolling-average tier over a history window (server-side badge source).
Mirrors the ``averageRouteTier`` JS helper in
``web/static/js/charts.js`` so the route card badge matches the chart
line color when both render the same window.
``web/static/js/spa-react/utils/charts.ts`` so the route card badge matches
the chart line color when both render the same window.
"""
@staticmethod
+2 -15
View File
@@ -19,7 +19,7 @@ class TestCacheControlHeaders:
def test_static_js_with_version(self, client):
"""Static JS with version parameter should have long-term cache."""
response = client.get(f"/static/js/charts.js?v={__version__}")
response = client.get(f"/static/js/spa/app.js?v={__version__}")
assert response.status_code == 200
assert "cache-control" in response.headers
assert (
@@ -59,7 +59,7 @@ class TestCacheControlHeaders:
def test_static_js_without_version(self, client):
"""Static JS without version should have short fallback cache."""
response = client.get("/static/js/charts.js")
response = client.get("/static/js/spa/app.js")
assert response.status_code == 200
assert "cache-control" in response.headers
assert response.headers["cache-control"] == "public, max-age=3600"
@@ -152,19 +152,6 @@ class TestVersionParameterInHTML:
assert css_link is not None
assert f"?v={__version__}" in css_link["href"]
def test_charts_js_has_version(self, client):
"""Charts.js script should include version parameter."""
response = client.get("/")
assert response.status_code == 200
soup = BeautifulSoup(response.text, "html.parser")
charts_script = soup.find(
"script", {"src": lambda x: x and "/static/js/charts.js" in x}
)
assert charts_script is not None
assert f"?v={__version__}" in charts_script["src"]
def test_app_js_has_version(self, client):
"""SPA app.js script should include version or content hash."""
response = client.get("/")