netdiag-app/src/lib/components/WifiChannelChart.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

378 lines
15 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">
/**
* Kanalgraph eines Frequenzbandes — je Netz ein Block über dem belegten
* Frequenzbereich, Höhe = Empfangspegel (ablesbar an der dBm-Achse links).
*
* Komplett neu gebaut, weil die erste Fassung im 5-GHz-Band unbrauchbar war
* (gemeldet vom Anwender): 745 MHz in dieselbe Breite gequetscht wie die
* 80 MHz des 2,4-GHz-Bandes ergaben 8,6 Einheiten je 20-MHz-Kanal — die
* Kanalzahlen überlappten sich zwangsläufig zu einem Zahlenbrei, SSID-Namen
* liefen aus dem Bild, und ohne Achse war die Signalstärke überhaupt nicht
* ablesbar.
*
* Die drei tragenden Entscheidungen:
* 1. Das Band wird in seine regulatorischen Blöcke geteilt (UNII-1,
* UNII-2A, UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen
* fallen weg — dadurch wird ein 20-MHz-Kanal deutlich breiter als die
* 8,6 Einheiten von vorher. Die Trennung UNII-1/UNII-2A ist zusätzlich
* fachlich nötig: ab Kanal 52 gilt DFS, und das muss am Block stehen.
* 2. ALLE Blöcke eines Bandes teilen sich EINEN Maßstab. Sonst wäre
* derselbe 20-MHz-Kanal in UNII-3 doppelt so breit wie in UNII-2C und
* man würde Balkenbreite als Kanalbreite fehllesen.
* 3. Ein Block ohne Netze wird trotzdem gezeichnet (flach, als „frei"
* beschriftet). Ihn wegzulassen würde das Band voller aussehen lassen
* als es ist — und genau die Antwort verstecken, die gesucht wird:
* wo ist noch Platz.
*
* Blockform bewusst ein Trapez, keine Parabel: die Fläche zeigt den
* tatsächlich belegten Frequenzbereich. Bei einer Parabel liegt nur die
* Spitze auf dem gemessenen Pegel, die Flanken suggerieren einen niedrigeren
* Wert — die dBm-Achse wäre für alles außer der Bogenmitte falsch.
*/
import type { WifiNetwork } from '$lib/scanner';
import RssiAxis from './RssiAxis.svelte';
import {
W,
PAD_L,
PAD_R,
PLOT_TOP,
FS_AXIS,
FS_LABEL,
BAND_SEGMENTS,
channelToFreq,
scaleForBand,
occupiedRange,
thinTicks,
textWidth,
rssiBereich,
rssiToYIn,
netColor,
kuerzeSsid,
} from '$lib/wifi/chart';
let {
networks,
band,
highlightBssid = undefined,
}: {
networks: WifiNetwork[];
band: string;
/** eigenes/verbundenes Netz optisch hervorheben */
highlightBssid?: string;
} = $props();
/**
* Ein Block ohne Netze braucht keine dBm-Skala — er wird flach gezeichnet.
* Sonst kostet jedes leere Teilband so viel Platz wie ein belegtes, und bei
* vier 5-GHz-Blöcken müsste man an leeren Flächen vorbeiscrollen, um die
* belegten zu finden.
*/
const H_LEER = 52;
/**
* dBm-Ausschnitt EINMAL für das gesamte Band — nicht je Block. Zwei Blöcke
* mit unterschiedlicher Achse würde man unweigerlich als „gleich stark"
* fehllesen, weil beide Balken gleich hoch aussähen.
*/
const bereich = $derived(
rssiBereich(networks.filter((n) => n.band === band).map((n) => n.rssi)),
);
const grund = $derived(PLOT_TOP + bereich.hoehe);
/** Höhe: Plot + Kanalbeschriftung + Blockunterschrift */
const H = $derived(grund + 34);
const segmente = $derived.by(() => {
const segs = BAND_SEGMENTS[band] ?? [];
const scale = scaleForBand(band);
const inBand = networks.filter((n) => n.band === band);
const ber = bereich;
const base = PLOT_TOP + ber.hoehe;
return segs.map((seg) => {
const spanMhz = seg.maxMhz - seg.minMhz;
const plotW = spanMhz * scale;
const x = (mhz: number) => PAD_L + (mhz - seg.minMhz) * scale;
// Halboffenes Intervall [min, max) — ein Netz auf exakt der Grenze
// gehört genau EINEM Segment, sonst würde es in beiden auftauchen.
const nets = inBand.filter((n) => {
const { center } = occupiedRange(n);
return center >= seg.minMhz && center < seg.maxMhz;
});
const bloecke = nets.map((n) => {
const { from, to } = occupiedRange(n);
// Am Segmentrand kappen, aber sichtbar machen — ein stumm
// abgeschnittener 160-MHz-Block sähe sonst wie ein schmalerer Kanal aus.
const xFrom = Math.max(PAD_L, x(from));
const xTo = Math.min(PAD_L + plotW, x(to));
const gekapptLinks = x(from) < PAD_L - 0.5;
const gekapptRechts = x(to) > PAD_L + plotW + 0.5;
const breite = Math.max(3, xTo - xFrom);
const y = rssiToYIn(n.rssi, ber);
// Schulter als feste Frequenzmenge (2 MHz), damit die Flankenneigung
// bei 20 und 160 MHz gleich aussieht statt mit der Breite zu skalieren.
const schulter = Math.min(breite / 3, 2 * scale);
const isSelf = highlightBssid != null && n.bssid === highlightBssid;
return {
n,
xFrom,
xTo: xFrom + breite,
mitte: xFrom + breite / 2,
y,
gekapptLinks,
gekapptRechts,
isSelf,
farbe: netColor(n.bssid, isSelf),
pfad:
`M ${xFrom.toFixed(1)},${base} ` +
`L ${(xFrom + schulter).toFixed(1)},${y.toFixed(1)} ` +
`L ${(xFrom + breite - schulter).toFixed(1)},${y.toFixed(1)} ` +
`L ${(xFrom + breite).toFixed(1)},${base} Z`,
};
});
const belegteKanaele = new Set<number>();
for (const n of nets) {
const { from, to } = occupiedRange(n);
for (const ch of seg.channels) {
const f = channelToFreq(ch, band);
if (f >= from && f <= to) belegteKanaele.add(ch);
}
}
const rohTicks = seg.channels
.map((ch) => ({ channel: ch, x: x(channelToFreq(ch, band)) }))
.filter((t) => t.x >= PAD_L - 0.5 && t.x <= PAD_L + plotW + 0.5);
const ticks = thinTicks(rohTicks, FS_AXIS).map((t) => ({
...t,
belegt: belegteKanaele.has(t.channel),
}));
/*
* SSID-Beschriftung entzerren.
*
* Das Kernproblem, gemeldet vom Anwender: die Wunsch-Höhe eines Labels
* hängt am Messwert, und reale Nachbarnetze liegen fast alle zwischen
* 65 und 85 dBm. Dort drängeln sich bei einer Wohnhaus-Nachbarschaft
* zehn und mehr Namen übereinander und werden unlesbar.
*
* Der eigentliche Hebel dagegen ist die gespreizte Y-Achse (DB_SCALE in
* chart.ts): aus 40 Einheiten für den dichten Bereich werden 100, damit
* lösen sich die meisten Überschneidungen von selbst. Anmerkung zur
* Entstehung, weil der Irrtum hier festgehalten gehört: die Spreizung
* wurde zunächst mit dem Argument abgelehnt, die Blockhöhe SEI der
* Messwert und dürfe nicht verschoben werden. Das verwechselte zwei
* Dinge — ein Netz von seinem Messwert wegzuschieben wäre falsch, den
* Maßstab der Achse zu dehnen dagegen völlig unbedenklich, weil die
* Achsenbeschriftung mitwandert und jeder Wert ablesbar bleibt.
*
* Was danach noch übereinander liegt, wird als Beschriftung verschoben —
* nur der Text, nie der Block; eine dünne Zuordnungslinie in der Farbe
* des Netzes führt zurück zum zugehörigen Block.
*
* Verfahren: stärkstes Netz zuerst (das interessiert am meisten und
* bekommt deshalb die beste Position), dann für jedes weitere Label vom
* Wunschplatz aus abwechselnd nach oben und unten die nächste freie
* Zeile suchen. Zwei Labels stören sich nur, wenn sie sich AUCH
* waagerecht überlappen — nebeneinander liegende Netze dürfen dieselbe
* Höhe behalten. Findet ein Label gar keinen Platz, bleibt sein Block
* farbig stehen und wird über die Netzliste zugeordnet; die Anzahl der
* unbeschrifteten Netze wird ausgewiesen statt sie zu verschweigen.
*/
const ZEILE = FS_LABEL + 2;
const belegteLabel: { x1: number; x2: number; y: number }[] = [];
const labels: {
text: string;
x: number;
y: number;
farbe: ReturnType<typeof netColor>;
leader: { x: number; y: number } | null;
}[] = [];
let ohneLabel = 0;
const frei = (y: number, x1: number, x2: number) =>
y >= PLOT_TOP + 2 &&
y <= base - 2 &&
!belegteLabel.some(
(l) => Math.abs(l.y - y) < ZEILE && x1 < l.x2 + 2 && x2 > l.x1 - 2,
);
for (const b of [...bloecke].sort((a, c) => c.n.rssi - a.n.rssi)) {
const name = kuerzeSsid(b.n.ssid || b.n.bssid.slice(-8));
const wunschY = b.y - 4;
/*
* Zwei Textfassungen, lange zuerst: „FRITZ!Box 7590 48" statt nur
* „FRITZ!Box 7590". Der angehängte Messwert war die ursprüngliche
* Meldung des Anwenders („ich sehe nicht die Sendestärke in dB in den
* Diagrammen"). Die dBm-Achse beantwortet das inzwischen, aber am Namen
* steht der Wert ohne Blickwechsel zur Achse — und vor allem auch dann
* noch richtig, wenn das Label verschoben werden musste und damit nicht
* mehr auf der Höhe seines Blocks steht.
*
* Reicht der Platz nicht, wird der Wert geopfert, nicht der Name: den
* Wert kann man an der Achse ablesen, den Namen nirgends.
*/
let text = `${name} ${b.n.rssi}`;
let breite = textWidth(text, FS_LABEL);
let cx = 0, x1 = 0, x2 = 0;
let y: number | null = null;
for (const fassung of [text, name]) {
text = fassung;
breite = textWidth(text, FS_LABEL);
// waagerecht im Segment halten, damit nichts aus dem Bild läuft
cx = Math.min(
PAD_L + plotW - breite / 2 - 1,
Math.max(PAD_L + breite / 2 + 1, b.mitte),
);
x1 = cx - breite / 2;
x2 = cx + breite / 2;
if (frei(wunschY, x1, x2)) { y = wunschY; break; }
// abwechselnd nach oben und unten suchen — nach oben zuerst, weil
// dort der Platz über den Blöcken frei ist
for (let stufe = 1; stufe <= 12; stufe++) {
const hoch = wunschY - stufe * ZEILE;
if (frei(hoch, x1, x2)) { y = hoch; break; }
const runter = wunschY + stufe * ZEILE;
if (frei(runter, x1, x2)) { y = runter; break; }
}
if (y !== null) break;
}
if (y === null) {
ohneLabel++;
continue;
}
belegteLabel.push({ x1, x2, y });
const versetzt = Math.abs(y - wunschY) > 2 || Math.abs(cx - b.mitte) > 2;
labels.push({
text,
x: cx,
y,
farbe: b.farbe,
// Zuordnungslinie zum Scheitel des eigenen Blocks, sobald das Label
// weggerückt werden musste — sonst rät man, welcher Name zu welchem
// Block gehört.
leader: versetzt ? { x: b.mitte, y: b.y } : null,
});
}
return {
seg,
plotW,
bloecke,
ticks,
labels,
ohneLabel,
leer: nets.length === 0,
anzahl: nets.length,
};
});
});
</script>
{#each segmente as s (s.seg.name)}
<div class="mt-1.5">
{#if s.leer}
<!-- Kompaktzeile: kein Netz im Block, also keine dBm-Skala nötig.
Der Block wird trotzdem gezeigt — er ist die Antwort auf „wo ist
noch Platz". -->
<svg viewBox="0 0 {W} {H_LEER}" class="w-full" role="img" aria-label="{s.seg.name}: keine Netze">
<line x1={PAD_L} y1={26} x2={PAD_L + s.plotW} y2={26} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
<text x={PAD_L + s.plotW / 2} y={18} text-anchor="middle" class="fill-emerald-400/80" style="font-size: {FS_LABEL}px">
frei — keine Netze
</text>
{#each s.ticks as t (t.channel)}
<line x1={t.x} y1={26} x2={t.x} y2={26 + (t.label ? 3 : 2)} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
{#if t.label}
<text x={t.x} y={38} text-anchor="middle" class="fill-zinc-500" style="font-size: {FS_AXIS - 1}px">{t.channel}</text>
{/if}
{/each}
<text x={PAD_L} y={H_LEER - 2} class="fill-zinc-500" style="font-size: {FS_LABEL}px">
{s.seg.name}{s.seg.dfs ? ' · DFS' : ''}
</text>
</svg>
{:else}
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
<RssiAxis {bereich} />
<!-- Blöcke: schwächste zuerst, damit starke oben liegen -->
{#each [...s.bloecke].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)}
<path d={b.pfad} class="{b.farbe.fill} {b.farbe.stroke}" stroke-width={b.isSelf ? 1.6 : 1} />
{#if b.gekapptLinks}
<path d="M {PAD_L + 1},{b.y + 4} l 4,-4 l -4,-4" fill="none" class="stroke-amber-400" stroke-width="1" />
{/if}
{#if b.gekapptRechts}
<path d="M {PAD_L + s.plotW - 1},{b.y + 4} l -4,-4 l 4,-4" fill="none" class="stroke-amber-400" stroke-width="1" />
{/if}
{/each}
<!-- Zuordnungslinien: vom weggerückten Namen zum Scheitel seines Blocks.
In der Farbe des Netzes, damit die Zuordnung auch dann eindeutig
bleibt, wenn mehrere Linien nebeneinander verlaufen. -->
{#each s.labels.filter((l) => l.leader != null) as l, i (i)}
<line
x1={l.x}
y1={l.y + 2}
x2={l.leader?.x}
y2={l.leader?.y}
stroke="currentColor"
class="{l.farbe.text} opacity-40"
stroke-width="0.6"
/>
{/each}
<!-- SSID-Beschriftungen -->
{#each s.labels as l, i (i)}
<text
x={l.x}
y={l.y}
text-anchor="middle"
class={l.farbe.text}
style="font-size: {FS_LABEL}px; paint-order: stroke; stroke: #18181b; stroke-width: 1.8px"
>
{l.text}
</text>
{/each}
<!-- Kanal-Ticks: belegte Kanäle hell, freie gedämpft — beantwortet
„welcher Kanal ist frei" ohne zusätzliche Beschriftung -->
{#each s.ticks as t (t.channel)}
<line
x1={t.x}
y1={grund}
x2={t.x}
y2={grund + (t.label ? 4 : 2)}
stroke="currentColor"
class={t.belegt ? 'text-zinc-400' : 'text-zinc-600'}
stroke-width="1"
/>
{#if t.label}
<text
x={t.x}
y={grund + 14}
text-anchor="middle"
class={t.belegt ? 'fill-zinc-200' : 'fill-zinc-500'}
style="font-size: {FS_AXIS}px"
>
{t.channel}
</text>
{/if}
{/each}
<!-- Blockunterschrift. Unbeschriftete Netze werden ausgewiesen — sonst
entstünde der Eindruck, es wären weniger Netze als tatsächlich da. -->
<text x={PAD_L} y={grund + 28} class="fill-zinc-500" style="font-size: {FS_LABEL}px">
{s.seg.name}{s.seg.dfs ? ' · DFS' : ''} · {s.anzahl}
{s.anzahl === 1 ? 'Netz' : 'Netze'}{s.ohneLabel > 0 ? ` · ${s.ohneLabel} ohne Namen` : ''}
</text>
<text x={W - PAD_R} y={grund + 28} text-anchor="end" class="fill-zinc-600" style="font-size: {FS_LABEL}px">
Kanal
</text>
</svg>
{/if}
</div>
{/each}