netdiag-app/src/lib/components/WifiTimelineChart.svelte
Eduard Wisch 9eef6177ba WLAN-Diagramme: Y-Achse gespreizt statt Namen zusammenzuquetschen
Eddy hatte vorgeschlagen, die Netze "weiter auseinander zu ziehen". Das wurde
mit dem Argument abgelehnt, die Blockhoehe SEI der Messwert und duerfe nicht
verschoben werden. Das war ein Denkfehler: ein Netz von seinem Messwert
wegzuschieben waere falsch, den Massstab der Achse zu dehnen dagegen voellig
unbedenklich - die Achsenbeschriftung wandert mit, jeder Wert bleibt exakt
ablesbar. Genau so loest es auch WiFiAnalyzer (Einstellung "Maximum Y-axis
Value" plus Pinch-Zoom).

- DB_SCALE 2 -> 5 Einheiten je dB. Der dichte Bereich (-65...-85 dBm) waechst
  von 40 auf 100 Einheiten: Platz fuer neun Namenszeilen statt dreieinhalb.
- Achse zeigt nur den belegten Bereich (rssiBereich), auf 10 dB gerastert mit
  5 dB Luft nach oben. Ohne das waere 5 GHz mit vier Bloecken ueberwiegend
  leere Flaeche zum Scrollen. Gerastert, damit die Achse nicht bei jedem
  RSSI-Zucken springt und zwei Momentaufnahmen vergleichbar bleiben.
  Ergebnis: trotz 2,5-facher Spreizung 30 % kuerzer als vorher.
- Alle Bloecke EINES Bandes teilen sich den Bereich - sonst laese man zwei
  gleich hohe Balken als "gleich stark", obwohl 20 dB dazwischen liegen.
  Gleiches gilt im Zeitverlauf ueber alle Momentaufnahmen.
- Jede 10-dB-Linie beschriftet (vorher nur jede zweite, da war kein Platz).
- dBm-Wert am Netznamen ("FRITZ!Box 7590 AX -48"). Beantwortet Eddys erste
  Meldung ("ich sehe nicht die Sendestaerke in dB") ohne Blickwechsel zur
  Achse - und bleibt richtig, wenn ein Name verschoben werden musste. Passt
  der Text nicht, wird der Wert geopfert, nicht der Name: den Wert kann man
  an der Achse ablesen, den Namen nirgends.

Geprueft mit tools/render-chart.mjs (dicht24: 15/15 Netze beschriftet,
voll5: 25/25) und im Emulator auf der echten Kanalanalyse-Seite.
2026-08-16 17:48:43 +02:00

134 lines
5.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
/**
* Empfangspegel mehrerer Netze über die gespeicherten Momentaufnahmen.
*
* Bewusst eine eigene Komponente (und nicht in der Seite eingebettet): so
* lässt sie sich mit `tools/render-chart.mjs` ohne Gerät rendern und
* ansehen. Genau dieser fehlende Blick aufs fertige Bild war die Ursache
* dafür, dass die erste Fassung der Diagramme unlesbar ausgeliefert wurde.
*
* Nutzt dieselbe dBm-Achse wie der Kanalgraph (RssiAxis) — vorher hatte
* dieses Diagramm eine eigene Skala (100…20) ganz ohne Beschriftung, die
* Werte waren also weder ablesbar noch mit dem Kanalgraph vergleichbar.
*/
import type { WifiSurvey } from '$lib/types';
import RssiAxis from './RssiAxis.svelte';
import { W, PAD_L, PAD_R, PLOT_TOP, FS_LABEL, rssiBereich, rssiToYIn, netColor, kuerzeSsid } from '$lib/wifi/chart';
let {
surveys,
connectedBssid = undefined,
}: {
surveys: WifiSurvey[];
connectedBssid?: string;
} = $props();
/**
* dBm-Ausschnitt über ALLE Momentaufnahmen hinweg — nicht je Aufnahme. Eine
* mitwandernde Achse würde eine Veränderung vortäuschen, wo keine ist: die
* Linie bliebe waagerecht, obwohl der Pegel gefallen wäre.
*/
const bereich = $derived(
rssiBereich(surveys.flatMap((s) => s.networks.map((n) => n.rssi))),
);
const grund = $derived(PLOT_TOP + bereich.hoehe);
const H = $derived(grund + 16);
const view = $derived.by(() => {
const sorted = [...surveys].sort((a, b) => a.createdAt - b.createdAt);
if (sorted.length < 2) return null;
// Meistgesehene Netze zuerst, auf 6 begrenzt — mehr Linien wären auf einem
// Handy nicht mehr auseinanderzuhalten. Schlüssel ist das BSSID, damit die
// Farbe dieselbe ist wie im Kanalgraph und in der Netzliste.
const counts = new Map<string, { bssid: string; name: string; n: number }>();
for (const s of sorted) {
for (const net of s.networks) {
const e = counts.get(net.bssid);
if (e) e.n++;
else counts.set(net.bssid, { bssid: net.bssid, name: net.ssid || net.bssid, n: 1 });
}
}
const top = [...counts.values()].sort((a, b) => b.n - a.n).slice(0, 6);
const t0 = sorted[0].createdAt;
const t1 = sorted[sorted.length - 1].createdAt;
const span = Math.max(1, t1 - t0);
const x = (ts: number) => PAD_L + ((ts - t0) / span) * (W - PAD_L - PAD_R);
const series = top.map((netz) => {
const points = sorted.map((s) => {
const match = s.networks.find((n) => n.bssid === netz.bssid);
return { ts: s.createdAt, rssi: match ? match.rssi : null };
});
// In Segmente teilen — war ein Netz in einer Aufnahme nicht sichtbar,
// wird die Linie NICHT durchgezogen: das würde ein stabiles Signal
// vortäuschen, wo tatsächlich ein Aussetzer war.
const segments: string[] = [];
const punkte: { x: number; y: number }[] = [];
let current: string[] = [];
for (const p of points) {
if (p.rssi == null) {
if (current.length > 1) segments.push(current.join(' '));
current = [];
continue;
}
const px = x(p.ts);
const py = rssiToYIn(p.rssi, bereich);
punkte.push({ x: px, y: py });
current.push(`${px.toFixed(1)},${py.toFixed(1)}`);
}
if (current.length > 1) segments.push(current.join(' '));
const letzter = points.filter((p) => p.rssi != null).at(-1);
return {
bssid: netz.bssid,
name: kuerzeSsid(netz.name),
segments,
punkte,
letzterWert: letzter?.rssi ?? null,
farbe: netColor(netz.bssid, netz.bssid === connectedBssid),
};
});
return { series, tFirst: t0, tLast: t1 };
});
function fmtDateTime(ts: number): string {
return new Date(ts).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
{#if view}
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Empfangspegel über die Momentaufnahmen">
<RssiAxis {bereich} />
{#each view.series as s (s.bssid)}
{#each s.segments as seg, i (i)}
<polyline points={seg} fill="none" stroke="currentColor" stroke-width="1.5" class={s.farbe.stroke} />
{/each}
<!-- Messpunkte sichtbar machen: eine Serie mit nur EINEM Wert hat kein
Liniensegment und wäre sonst komplett unsichtbar. -->
{#each s.punkte as p, i (i)}
<circle cx={p.x} cy={p.y} r="1.8" class={s.farbe.text} />
{/each}
{/each}
<text x={PAD_L} y={H - 3} class="fill-zinc-500" style="font-size: {FS_LABEL}px">
{fmtDateTime(view.tFirst)}
</text>
<text x={W - PAD_R} y={H - 3} text-anchor="end" class="fill-zinc-500" style="font-size: {FS_LABEL}px">
{fmtDateTime(view.tLast)}
</text>
</svg>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1">
{#each view.series as s (s.bssid)}
<span class="flex items-center gap-1 text-[10px] text-zinc-400">
<span class="h-1.5 w-1.5 rounded-full {s.farbe.dot}"></span>
{s.name}{s.letzterWert != null ? ` ${s.letzterWert} dBm` : ''}
</span>
{/each}
</div>
{/if}