netdiag-app/src/lib/wifi/chart.ts
Eduard Wisch d74a89b2d5 WLAN-Diagramme lesbar gemacht: dBm-Achse, 5-GHz-Aufteilung, Farben je Netz
Eddys Rückmeldung nach dem ersten Einsatz auf dem Handy: das zweite Diagramm
ergibt keinen Sinn, Zahlen überlappen, und die Signalstärke ist in den
Diagrammen nirgends ablesbar. Alle drei Punkte bestätigt und behoben.

Ursache (nachgerechnet, nicht geraten): die 745 MHz des 5-GHz-Bandes wurden
in dieselbe Breite gequetscht wie die 80 MHz des 2,4-GHz-Bandes — 9,3-fach
schlechtere Auflösung, 8,6 statt 80 Einheiten je 20-MHz-Kanal. Bei 8,6
Einheiten Tick-Abstand und 12,6 Einheiten Breite einer dreistelligen Zahl ist
Überlappung unvermeidbar ("3640444852566064"). Der vorhandene Tick-Filter
filterte nachweislich 0 von 25 Ticks weg, weil er nur gegen den
Frequenzbereich prüfte, in dem ohnehin alle Kanäle liegen.

Kanalgraph:
- 5 GHz in die regulatorischen Blöcke geteilt (UNII-1/2A, UNII-2C, UNII-3),
  ein gemeinsamer Maßstab je Band -> 20-MHz-Kanal jetzt ~24 statt 8,6
  Einheiten breit, alle Kanalzahlen einzeln lesbar.
- Leere Blöcke werden gezeichnet ("frei — keine Netze") statt weggelassen.
  Weglassen ließe das Band voller aussehen als es ist und versteckt genau
  die gesuchte Antwort: wo ist noch Platz.
- Trapez statt Parabel — die Fläche zeigt den tatsächlich belegten
  Frequenzbereich. Bei der Parabel lag nur die Spitze auf dem Messwert; mit
  dBm-Achse wäre alles außer der Bogenmitte falsch abgelesen worden.
- Kanalbreite über centerFreq0 statt Primärfrequenz: ein 80-MHz-Netz auf
  Kanal 36 wurde sonst um 5180 statt 5210 gezeichnet, Nachbarkanäle sahen
  fälschlich frei aus.
- Farbe je Netz (stabil über BSSID-Hash), gleiche Farbe in Graph, Zeitverlauf
  und Netzliste. Vorher verschmolzen überlappende graue Blöcke zu einer Fläche.
- Belegte Kanalnummern hell, freie gedämpft; SSID von hinten gekürzt (sonst
  heißen alle FRITZ!Box-Netze gleich); gekappte Blöcke werden markiert.

dBm-Achse (neue gemeinsame Komponente RssiAxis, für Kanalgraph UND
Zeitverlauf — damit sie konstruktiv nicht auseinanderlaufen können):
- feste Skala -95..-30 dBm, 2,0 Einheiten je dB (glatte Rasterlinien),
  beschriftet -30/-50/-70/-90, Einheit "dBm", Praxiszonen grün/rot.
- Bewusst "Empfangspegel", nicht "Sendestärke": die Achse zeigt den Pegel am
  Standort des Handys, nicht die Leistung des Routers.
- Schrift 7 -> 9/10 (war ~7,6 CSS-px, unter der Lesegrenze), Kontrast der
  Achsenbeschriftung von zinc-600 (2,29:1, unter WCAG-Minimum) auf zinc-200/300.

Momentaufnahme wird jetzt synchronisiert: sie lag bisher nur lokal und kam nie
im Protokoll auf dem Server an, obwohl sie genau dafür gemacht wird. Landet
jetzt zusätzlich als Messung (tool='wifikanal') in Sync und PDF.

Klartext-Fazit über dem Diagramm ("Dein Netz: Kanal 6 · -48 dBm (sehr gut) ·
2,4 GHz freister Kanal: 1") — unter Zeitdruck schneller als jedes Diagramm.

Neu: tools/render-chart.mjs rendert die echten Svelte-Komponenten
serverseitig zu SVG/PNG, inklusive Randfällen (1 Netz, 25 Netze, 5 Netze auf
einem Kanal, Bandränder, leeres Band, Zeitverlauf mit Aussetzer). Das ist die
Lehre aus dem Fehler: in Phase 4 wurde die Chart-Mathematik nur durchgerechnet
(keine NaN, nichts außerhalb des Zeichenbereichs) und daraus geschlossen, das
genüge. Rechnerisch korrekt heißt nicht lesbar — ein Diagramm muss man ansehen.

Weiterhin offen: der Emulator hat keine WLAN-Hardware und liefert immer 0
Netze. Alle Diagramme sind mit realistischen Testdaten gerendert und geprüft,
aber nicht mit echtem Empfang auf dem Handy.

Details: ROADMAP_UMSETZUNG.md Phase 4 / Nachbesserung
2026-08-15 17:24:59 +02:00

212 lines
9.1 KiB
TypeScript
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.

/**
* Gemeinsame Geometrie der WLAN-Diagramme (Kanalgraph + Zeitverlauf).
*
* Hier liegen ALLE Maße und Umrechnungen, damit Kanalgraph und Zeitverlauf
* dieselbe dBm-Achse benutzen und nicht auseinanderlaufen können — vorher
* benutzte der eine 90…30 dBm, der andere 100…20, und beide hatten
* überhaupt keine ablesbare Achse.
*
* Warum die Zahlen so gewählt sind (nachgerechnet, nicht geraten):
* - Die SVG-Fläche ist auf einem 393-px-Handy real ~349 CSS-px breit. Bei
* viewBox-Breite 320 ist der Maßstab 1,09 — eine Schriftgröße 10 im
* viewBox rendert also als ~10,9 CSS-px und ist damit lesbar. (Die alte
* Fassung nutzte 7 → ~7,6 CSS-px, unter der Lesegrenze.)
* - 2,0 Einheiten je dB ergibt Rasterlinien auf glatten y-Werten
* (90 dBm → y=140, 80 → 120, …, 30 → 20) statt krummer Zwischenwerte.
*/
/* --- Zeichenfläche --- */
export const W = 320;
/** Platz links für die dBm-Beschriftung */
export const PAD_L = 30;
/** Luft rechts, damit der letzte Kanal-Tick nicht am Rand klebt */
export const PAD_R = 6;
export const PLOT_W = W - PAD_L - PAD_R;
/* --- dBm-Skala (Empfangspegel) --- */
export const RSSI_MIN = -95;
export const RSSI_MAX = -30;
/** Einheiten je dB — bewusst glatt, siehe Kopfkommentar */
export const DB_SCALE = 2;
export const PLOT_H = (RSSI_MAX - RSSI_MIN) * DB_SCALE; // 130
export const PLOT_TOP = 20;
export const BASELINE = PLOT_TOP + PLOT_H; // 150
/** dBm → y-Koordinate */
export function rssiToY(rssi: number): number {
const clamped = Math.min(RSSI_MAX, Math.max(RSSI_MIN, rssi));
return BASELINE - (clamped - RSSI_MIN) * DB_SCALE;
}
/** Rasterlinien alle 10 dB; nur jede zweite wird beschriftet (größer = schneller erfassbar) */
export const DB_GRID = [-90, -80, -70, -60, -50, -40, -30];
export const DB_LABELED = new Set([-90, -70, -50, -30]);
/** Praxis-Schwellen für die farbigen Hintergrundzonen */
export const RSSI_GOOD = -67;
export const RSSI_WEAK = -75;
/* --- Schrift --- */
export const FS_AXIS = 10;
export const FS_LABEL = 9;
/** Mittlere Glyphenbreite als Anteil der Schriftgröße — Ziffern ~0,58, Text ~0,55 */
const EM_DIGIT = 0.58;
const EM_TEXT = 0.55;
export function digitWidth(text: string, fontSize: number): number {
return text.length * EM_DIGIT * fontSize;
}
export function textWidth(text: string, fontSize: number): number {
return text.length * EM_TEXT * fontSize;
}
/* --- Frequenzband-Segmente --- */
export interface BandSegment {
/** Kurzname, erscheint als Unterschrift unter der Achse */
name: string;
minMhz: number;
/** obere Grenze, HALBOFFEN — ein Netz auf exakt maxMhz gehört ins nächste Segment */
maxMhz: number;
/** true = Kanäle unterliegen der Radar-Erkennung (DFS), Zwangswechsel möglich */
dfs?: boolean;
/** Kanalnummern, die als Ticks in Frage kommen */
channels: number[];
}
/**
* Warum das 5-GHz-Band aufgeteilt wird: es umfasst 745 MHz, das 2,4-GHz-Band
* nur 80 MHz. In dieselbe Pixelbreite gequetscht ist die Auflösung 9,3-fach
* schlechter — ein 20-MHz-Kanal war dort 8,6 Einheiten breit, die Kanalzahlen
* überlappten sich zwangsläufig zu einem unlesbaren Zahlenbrei. Aufgeteilt in
* die regulatorischen Blöcke bleiben die dazwischenliegenden, in Europa gar
* nicht nutzbaren Bereiche (53305490, 58355895) außen vor, ohne dass echte
* Kanäle verschwinden.
*/
export const BAND_SEGMENTS: Record<string, BandSegment[]> = {
'2.4 GHz': [
{
name: '2,4 GHz',
minMhz: 2400,
maxMhz: 2495,
channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
},
],
'5 GHz': [
{ name: 'UNII-1/2A', minMhz: 5150, maxMhz: 5340, channels: [36, 40, 44, 48, 52, 56, 60, 64] },
{ name: 'UNII-2C', minMhz: 5470, maxMhz: 5734, dfs: true, channels: [100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144] },
{ name: 'UNII-3', minMhz: 5734, maxMhz: 5850, channels: [149, 153, 157, 161, 165] },
],
'6 GHz': [
{ name: '6 GHz unten', minMhz: 5925, maxMhz: 6425, channels: [1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65, 69, 73, 77, 81, 85, 89, 93] },
{ name: '6 GHz oben', minMhz: 6425, maxMhz: 7125, channels: [97, 101, 105, 109, 113, 117, 121, 125, 129, 133, 137, 141, 145, 149, 153, 157, 161, 165, 169, 173, 177, 181, 185, 189, 193, 197, 201, 205, 209, 213, 217, 221, 225, 229, 233] },
],
};
/** Kanalnummer → Mittenfrequenz (Umkehrung von freqToChannel im nativen Plugin) */
export function channelToFreq(channel: number, band: string): number {
if (band === '2.4 GHz') return channel === 14 ? 2484 : 2412 + (channel - 1) * 5;
if (band === '6 GHz') return 5950 + channel * 5;
return 5000 + channel * 5;
}
/**
* Gemeinsamer Maßstab für ALLE Segmente eines Bandes: das breiteste Segment
* füllt die Zeichenfläche, die schmaleren werden proportional kürzer
* gezeichnet. Ohne das wäre ein 20-MHz-Kanal in UNII-3 mehr als doppelt so
* breit wie in UNII-2C — untereinander gestapelt liest man Balkenbreite dann
* als Kanalbreite und schätzt sie falsch ein.
*/
export function scaleForBand(band: string): number {
const segs = BAND_SEGMENTS[band] ?? [];
const widest = Math.max(1, ...segs.map((s) => s.maxMhz - s.minMhz));
return PLOT_W / widest;
}
/**
* Belegter Frequenzbereich eines Netzes. Bei breiten Kanälen (40/80/160 MHz)
* liegt die Mitte des belegten Blocks NICHT auf der Primärfrequenz, sondern
* auf `centerFreq0` — ohne diese Unterscheidung würde ein 80-MHz-Netz auf
* Kanal 36 symmetrisch um 5180 gezeichnet (51405220) statt korrekt um 5210
* (51705250), und benachbarte Kanäle sähen fälschlich frei aus.
*/
export function occupiedRange(n: {
frequency: number;
widthMhz?: number;
centerFreq0?: number;
}): { from: number; to: number; center: number } {
const width = n.widthMhz && n.widthMhz > 0 ? n.widthMhz : 20;
const center = width > 20 && n.centerFreq0 && n.centerFreq0 > 0 ? n.centerFreq0 : n.frequency;
return { from: center - width / 2, to: center + width / 2, center };
}
/**
* Feste Farbpalette je Netz. Ohne sie verschmelzen mehrere überlappende
* Blöcke derselben Graustufe zu EINER Fläche — man sieht dann nicht mehr, wo
* ein Netz aufhört und das nächste anfängt, und kann Diagramm und Netzliste
* nicht einander zuordnen. Die Zuordnung ist über den BSSID-Hash stabil:
* dasselbe Netz bekommt in jedem Scan dieselbe Farbe, auch wenn sich die
* Reihenfolge ändert.
*
* Tailwind-Klassen müssen vollständig im Quelltext stehen (sonst entfernt sie
* der Purge-Schritt) — deshalb eine feste Liste statt zusammengesetzter Namen.
*/
export const NET_COLORS = [
{ fill: 'fill-violet-400/25', stroke: 'stroke-violet-400', text: 'fill-violet-300', dot: 'bg-violet-400' },
{ fill: 'fill-emerald-400/25', stroke: 'stroke-emerald-400', text: 'fill-emerald-300', dot: 'bg-emerald-400' },
{ fill: 'fill-amber-400/25', stroke: 'stroke-amber-400', text: 'fill-amber-300', dot: 'bg-amber-400' },
{ fill: 'fill-rose-400/25', stroke: 'stroke-rose-400', text: 'fill-rose-300', dot: 'bg-rose-400' },
{ fill: 'fill-lime-400/25', stroke: 'stroke-lime-400', text: 'fill-lime-300', dot: 'bg-lime-400' },
{ fill: 'fill-orange-400/25', stroke: 'stroke-orange-400', text: 'fill-orange-300', dot: 'bg-orange-400' },
{ fill: 'fill-teal-400/25', stroke: 'stroke-teal-400', text: 'fill-teal-300', dot: 'bg-teal-400' },
{ fill: 'fill-fuchsia-400/25', stroke: 'stroke-fuchsia-400', text: 'fill-fuchsia-300', dot: 'bg-fuchsia-400' },
];
/** Das eigene/verbundene Netz bekommt immer dieselbe Signalfarbe, egal welcher Hash */
export const SELF_COLOR = {
fill: 'fill-sky-500/40',
stroke: 'stroke-sky-300',
text: 'fill-sky-200',
dot: 'bg-sky-400',
};
export function netColor(bssid: string, isSelf = false): (typeof NET_COLORS)[number] {
if (isSelf) return SELF_COLOR;
let h = 0;
for (let i = 0; i < bssid.length; i++) h = (h * 31 + bssid.charCodeAt(i)) >>> 0;
return NET_COLORS[h % NET_COLORS.length];
}
/**
* SSID auf eine im Diagramm darstellbare Länge bringen. Gekürzt wird VORNE,
* weil sich reale Netznamen hinten unterscheiden („FRITZ!Box 7590 AX" vs.
* „FRITZ!Box Gastzugang") — vorne gekürzt hießen beide „FRITZ!Box…".
*/
export function kuerzeSsid(s: string, max = 18): string {
if (s.length <= max) return s;
return '…' + s.slice(-(max - 1));
}
/**
* Kanal-Ticks eines Segments so ausdünnen, dass sich die Beschriftungen
* garantiert nicht berühren. Die alte Fassung filterte nur nach
* Frequenzbereich — dabei lagen ALLE Kanäle im Bereich, es wurde also
* faktisch nichts ausgedünnt und die Zahlen überlappten sich um bis zu 29 %
* ihrer Breite.
*
* Ticks, die kein Label bekommen, bleiben als kurzer Strich stehen (ein
* 1 Einheit breiter Strich kollidiert auch bei engem Raster nicht).
*/
export function thinTicks(
ticks: { channel: number; x: number }[],
fontSize: number,
): { channel: number; x: number; label: boolean }[] {
let lastLabelX = -Infinity;
return ticks.map((t) => {
const need = digitWidth(String(t.channel), fontSize) + 4;
const halfNeed = need / 2;
const fits = t.x - lastLabelX >= halfNeed + 2;
if (fits) lastLabelX = t.x;
return { ...t, label: fits };
});
}