From d74a89b2d54c1138632143c23d2ae1a6c96a9c6e Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Sat, 15 Aug 2026 17:24:59 +0200 Subject: [PATCH] WLAN-Diagramme lesbar gemacht: dBm-Achse, 5-GHz-Aufteilung, Farben je Netz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + src/lib/components/RssiAxis.svelte | 77 ++++ src/lib/components/WifiChannelChart.svelte | 330 +++++++++++++----- src/lib/components/WifiTimelineChart.svelte | 125 +++++++ src/lib/wifi/chart.ts | 212 +++++++++++ .../protokoll/[id]/wifikanal/+page.svelte | 181 +++++----- tools/render-chart.mjs | 172 +++++++++ 7 files changed, 920 insertions(+), 178 deletions(-) create mode 100644 src/lib/components/RssiAxis.svelte create mode 100644 src/lib/components/WifiTimelineChart.svelte create mode 100644 src/lib/wifi/chart.ts create mode 100644 tools/render-chart.mjs diff --git a/.gitignore b/.gitignore index b216105..2480b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ android/app/release/ *.apk *.keystore *.jks +.render/ diff --git a/src/lib/components/RssiAxis.svelte b/src/lib/components/RssiAxis.svelte new file mode 100644 index 0000000..12d3601 --- /dev/null +++ b/src/lib/components/RssiAxis.svelte @@ -0,0 +1,77 @@ + + +{#if zonen} + + + +{/if} + +{#each DB_GRID as db (db)} + {@const y = rssiToY(db)} + + {#if DB_LABELED.has(db)} + + {db} + + {/if} +{/each} + + + + dBm + + + + diff --git a/src/lib/components/WifiChannelChart.svelte b/src/lib/components/WifiChannelChart.svelte index bef4d7c..d5a9c58 100644 --- a/src/lib/components/WifiChannelChart.svelte +++ b/src/lib/components/WifiChannelChart.svelte @@ -1,14 +1,53 @@ -{#if view} - {#if view.bumps.length === 0} -
Keine Netze im {band}-Band
- {:else} - - - +{#each segmente as s (s.seg.name)} +
+ + - - {#each view.ticks as t (t.ch)} - - {t.ch} + {#if s.leer} + + frei — keine Netze + + {/if} + + + {#each [...s.bloecke].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)} + + {#if b.gekapptLinks} + + {/if} + {#if b.gekapptRechts} + + {/if} {/each} - - {#each [...view.bumps].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)} - + {#each s.labels.filter((l) => l.leader != null) as l, i (i)} + {/each} - - {#each view.bumps as b (b.n.bssid)} + + {#each s.labels as l, i (i)} - {b.n.ssid || b.n.bssid.slice(-5)} + {l.text} {/each} + + + {#each s.ticks as t (t.channel)} + + {#if t.label} + + {t.channel} + + {/if} + {/each} + + + + {s.seg.name}{s.seg.dfs ? ' · DFS' : ''} · {s.anzahl} + {s.anzahl === 1 ? 'Netz' : 'Netze'} + + + Kanal + - {/if} -{/if} +
+{/each} diff --git a/src/lib/components/WifiTimelineChart.svelte b/src/lib/components/WifiTimelineChart.svelte new file mode 100644 index 0000000..bab1f84 --- /dev/null +++ b/src/lib/components/WifiTimelineChart.svelte @@ -0,0 +1,125 @@ + + +{#if view} + + + {#each view.series as s (s.bssid)} + {#each s.segments as seg, i (i)} + + {/each} + + {#each s.punkte as p, i (i)} + + {/each} + {/each} + + {fmtDateTime(view.tFirst)} + + + {fmtDateTime(view.tLast)} + + +
+ {#each view.series as s (s.bssid)} + + + {s.name}{s.letzterWert != null ? ` ${s.letzterWert} dBm` : ''} + + {/each} +
+{/if} diff --git a/src/lib/wifi/chart.ts b/src/lib/wifi/chart.ts new file mode 100644 index 0000000..f789175 --- /dev/null +++ b/src/lib/wifi/chart.ts @@ -0,0 +1,212 @@ +/** + * 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 (5330–5490, 5835–5895) außen vor, ohne dass echte + * Kanäle verschwinden. + */ +export const BAND_SEGMENTS: Record = { + '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 (5140–5220) statt korrekt um 5210 + * (5170–5250), 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 }; + }); +} diff --git a/src/routes/protokoll/[id]/wifikanal/+page.svelte b/src/routes/protokoll/[id]/wifikanal/+page.svelte index f4108d8..68fcccc 100644 --- a/src/routes/protokoll/[id]/wifikanal/+page.svelte +++ b/src/routes/protokoll/[id]/wifikanal/+page.svelte @@ -14,14 +14,16 @@ import { ChevronDown, RefreshCw, Camera, MapPin } from 'lucide-svelte'; import AppHeader from '$lib/components/AppHeader.svelte'; import WifiChannelChart from '$lib/components/WifiChannelChart.svelte'; + import WifiTimelineChart from '$lib/components/WifiTimelineChart.svelte'; import { getProtocol, saveProtocol } from '$lib/db'; - import { addWifiSurvey, uid } from '$lib/protocols'; + import { addWifiSurvey, addMeasurement, uid } from '$lib/protocols'; import { scanner, type WifiNetwork } from '$lib/scanner'; import { rssiRating } from '$lib/wifi/rating'; import { recommend24GhzChannels, channelWarnings } from '$lib/wifi/recommend'; + import { netColor } from '$lib/wifi/chart'; import { sync } from '$lib/sync.svelte'; import { toast } from '$lib/toast.svelte'; - import type { Protocol, WifiSurvey } from '$lib/types'; + import type { MeasureStatus, Protocol, WifiSurvey } from '$lib/types'; let protocol = $state(null); let networks = $state([]); @@ -148,8 +150,61 @@ })), }; addWifiSurvey(protocol, survey); + + // Zusätzlich als echte Messung ablegen. `wifiSurveys` allein bleibt nur + // lokal auf dem Gerät — eine beim Kunden aufgenommene Momentaufnahme kam + // damit nie im Protokoll auf dem Server an, obwohl sie genau dafür + // gemacht wird. Als Messung landet sie in der Synchronisation und im PDF. + const eigen = survey.networks.filter((n) => n.bssid === connectedBssid); + const eigenStk = eigen.sort((a, b) => b.rssi - a.rssi)[0]; + const warnungen = channelWarnings(networks); + const status: MeasureStatus = eigenStk + ? rssiRating(eigenStk.rssi).status + : warnungen.length > 0 + ? 1 + : 0; + addMeasurement(protocol, { + deviceClientId: null, + tool: 'wifikanal', + category: 'netzwerk', + label: + `${survey.networks.length} WLAN-Netze` + + (eigenStk ? ` · eigenes Netz Kanal ${eigenStk.channel}, ${eigenStk.rssi} dBm` : '') + + (recommendation.length > 0 ? ` · 2,4 GHz frei: Kanal ${recommendation[0].channel}` : ''), + params: {}, + result: { + anzahlNetze: survey.networks.length, + eigenesNetz: eigenStk + ? { + ssid: eigenStk.ssid, + kanal: eigenStk.channel, + band: eigenStk.band, + rssi: eigenStk.rssi, + breiteMhz: eigenStk.widthMhz ?? null, + bewertung: rssiRating(eigenStk.rssi).label, + } + : null, + empfehlung24GHz: recommendation.map((r) => ({ kanal: r.channel, stoerlast: r.load })), + warnungen, + netze: survey.networks + .slice() + .sort((a, b) => b.rssi - a.rssi) + .map((n) => ({ + ssid: n.ssid, + kanal: n.channel, + band: n.band, + rssi: n.rssi, + breiteMhz: n.widthMhz ?? null, + standard: n.standard ?? null, + sicherheit: n.security ?? null, + })), + }, + measureStatus: status, + dateMeasure: survey.createdAt, + }); + await persist(); - toast.show('Momentaufnahme gespeichert', 'success'); + toast.show('Momentaufnahme gespeichert und ins Protokoll übernommen', 'success'); } function fmtDateTime(ts: number): string { @@ -161,66 +216,29 @@ }); } - /* --- Zeitverlauf mehrerer APs aus den gespeicherten Momentaufnahmen --- */ - const SERIES_COLORS = [ - 'text-sky-400', - 'text-emerald-400', - 'text-amber-400', - 'text-fuchsia-400', - 'text-red-400', - 'text-lime-400', - ]; - const TL_W = 320; - const TL_H = 100; - - const timeline = $derived.by(() => { - const surveys = [...(protocol?.wifiSurveys ?? [])].sort((a, b) => a.createdAt - b.createdAt); - if (surveys.length < 2) return null; - - // Meistgesehene SSIDs zuerst, auf 6 begrenzt — mehr Linien wären nicht mehr lesbar - const counts = new Map(); - for (const s of surveys) { - for (const n of s.networks) { - const key = n.ssid || n.bssid; - counts.set(key, (counts.get(key) ?? 0) + 1); + /** + * Klartext-Antwort über dem Diagramm. Vor Ort ist ein Satz schneller + * erfasst als jedes Diagramm — die Zahlen liegen ohnehin vor. + */ + const fazit = $derived.by(() => { + if (networks.length === 0) return null; + const eigenes = networks.filter((n) => n.bssid === connectedBssid); + const teile: string[] = []; + if (eigenes.length > 0) { + const stk = eigenes.sort((a, b) => b.rssi - a.rssi)[0]; + const r = rssiRating(stk.rssi); + teile.push(`Dein Netz: Kanal ${stk.channel} · ${stk.rssi} dBm (${r.label})`); + } + if (recommendation.length > 0) { + const best = recommendation[0]; + const eigen24 = eigenes.find((n) => n.band === '2.4 GHz'); + if (eigen24 && eigen24.channel === best.channel) { + teile.push(`2,4 GHz: Kanal ${best.channel} ist bereits der störungsärmste`); + } else { + teile.push(`2,4 GHz frei­ster Kanal: ${best.channel}`); } } - const topSsids = [...counts.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 6) - .map(([k]) => k); - - const t0 = surveys[0].createdAt; - const t1 = surveys[surveys.length - 1].createdAt; - const span = Math.max(1, t1 - t0); - const x = (ts: number) => ((ts - t0) / span) * TL_W; - // Feste Skala -100..-20 dBm statt automatisch — sonst verschiebt sich die - // Achse zwischen zwei Momentaufnahmen und Werte sind nicht vergleichbar. - const y = (rssi: number) => TL_H - ((rssi + 100) / 80) * TL_H; - - const series = topSsids.map((ssid, i) => { - const points = surveys.map((s) => { - const match = s.networks.find((n) => (n.ssid || n.bssid) === ssid); - return { ts: s.createdAt, rssi: match ? match.rssi : null }; - }); - // In Segmente teilen — eine Lücke (Netz in dieser Aufnahme nicht - // gesehen) wird NICHT durchgezogen, sonst täuscht das ein stabiles - // Signal vor, wo tatsächlich ein Aussetzer war. - const segments: string[] = []; - let current: string[] = []; - for (const p of points) { - if (p.rssi == null) { - if (current.length > 1) segments.push(current.join(' ')); - current = []; - continue; - } - current.push(`${x(p.ts).toFixed(1)},${y(p.rssi).toFixed(1)}`); - } - if (current.length > 1) segments.push(current.join(' ')); - return { ssid, segments, color: SERIES_COLORS[i % SERIES_COLORS.length] }; - }); - - return { series, tFirst: t0, tLast: t1 }; + return teile.length > 0 ? teile.join(' · ') : null; }); @@ -260,6 +278,10 @@ + {#if fazit} +

{fazit}

+ {/if} + {#if changesSinceLastSurvey.length > 0}

Seit der letzten Momentaufnahme ({fmtDateTime(lastSurvey.createdAt)}):

@@ -301,27 +323,10 @@

Keine Netze gefunden.

{/if} - {#if timeline} + {#if (protocol.wifiSurveys ?? []).length >= 2}
-

- Zeitverlauf ({fmtDateTime(timeline.tFirst)} – {fmtDateTime(timeline.tLast)}) -

- - - {#each timeline.series as s (s.ssid)} - {#each s.segments as seg, i (i)} - - {/each} - {/each} - -
- {#each timeline.series as s (s.ssid)} - - - {s.ssid} - - {/each} -
+

Empfangspegel über die Momentaufnahmen

+
{/if} @@ -337,11 +342,17 @@
{#each g.nets as n (n.bssid)} {@const rating = rssiRating(n.rssi)} + {@const farbe = netColor(n.bssid, n.bssid === connectedBssid)}
- - Kanal {n.channel} · {n.band}{n.widthMhz ? ` · ${n.widthMhz} MHz` : ''}{n.standard - ? ` · ${n.standard}` - : ''}{n.security ? ` · ${n.security}` : ''} + + + + + Kanal {n.channel} · {n.band}{n.widthMhz ? ` · ${n.widthMhz} MHz` : ''}{n.standard + ? ` · ${n.standard}` + : ''}{n.security ? ` · ${n.security}` : ''} + {n.rssi} dBm
diff --git a/tools/render-chart.mjs b/tools/render-chart.mjs new file mode 100644 index 0000000..59348b0 --- /dev/null +++ b/tools/render-chart.mjs @@ -0,0 +1,172 @@ +/** + * Rendert die ECHTE WifiChannelChart.svelte (inkl. Kindkomponenten) server- + * seitig zu SVG/PNG. Damit lässt sich eine Diagramm-Änderung ansehen, ohne + * aufs Handy zu müssen — genau die Prüfung, deren Fehlen in Phase 4 dazu + * geführt hat, dass ein rechnerisch korrektes, aber unlesbares Diagramm + * ausgeliefert wurde. + * node tools/render-chart.mjs + */ +import { compile } from 'svelte/compiler'; +import { transformSync } from 'esbuild'; +import { render } from 'svelte/server'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { pathToFileURL, fileURLToPath } from 'url'; +import { dirname, resolve as pathResolve, basename } from 'path'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const OUT = pathResolve(ROOT, '.render'); +mkdirSync(OUT, { recursive: true }); + +/** Svelte-/TS-Quelle rekursiv nach ESM kompilieren, $lib-Alias auflösen */ +const cache = new Map(); +function build(srcPath) { + if (cache.has(srcPath)) return cache.get(srcPath); + const code = readFileSync(srcPath, 'utf8'); + const isSvelte = srcPath.endsWith('.svelte'); + const outName = basename(srcPath).replace(/\.(svelte|ts)$/, '') + (isSvelte ? '.svelte.js' : '.js'); + const outPath = pathResolve(OUT, outName); + cache.set(srcPath, outPath); + + let js; + if (isSvelte) { + js = compile(code, { generate: 'server', filename: basename(srcPath), runes: true }).js.code; + } else { + js = transformSync(code, { loader: 'ts', format: 'esm', target: 'node20' }).code; + } + + // Importe umschreiben und Abhängigkeiten mitkompilieren + js = js.replace(/from\s+['"]([^'"]+)['"]/g, (m, spec) => { + if (spec.startsWith('svelte')) return m; + let dep = null; + if (spec.startsWith('$lib/')) dep = pathResolve(ROOT, 'src/lib', spec.slice(5)); + else if (spec.startsWith('.')) dep = pathResolve(dirname(srcPath), spec); + if (!dep) return m; + for (const ext of ['', '.ts', '.svelte', '.js']) { + if (existsSync(dep + ext)) { + const built = build(dep + ext); + return `from './${basename(built)}'`; + } + } + return m; + }); + + writeFileSync(outPath, js); + return outPath; +} + +const entry = build(pathResolve(ROOT, 'src/lib/components/WifiChannelChart.svelte')); +const { default: Chart } = await import(pathToFileURL(entry).href); +const tlEntry = build(pathResolve(ROOT, 'src/lib/components/WifiTimelineChart.svelte')); +const { default: Timeline } = await import(pathToFileURL(tlEntry).href); + +const networks = [ + { ssid: 'FRITZ!Box 7590 SM', bssid: 'AA:00:01', channel: 6, frequency: 2437, rssi: -48, band: '2.4 GHz', widthMhz: 20 }, + { ssid: 'FRITZ!Box 7590 SM', bssid: 'AA:00:02', channel: 44, frequency: 5220, rssi: -55, band: '5 GHz', widthMhz: 80, centerFreq0: 5210 }, + { ssid: 'Sandmann-Gast', bssid: 'AA:00:03', channel: 6, frequency: 2437, rssi: -52, band: '2.4 GHz', widthMhz: 20 }, + { ssid: 'Repeater-Halle', bssid: 'AA:00:04', channel: 11, frequency: 2462, rssi: -61, band: '2.4 GHz', widthMhz: 20 }, + { ssid: 'Repeater-Halle', bssid: 'AA:00:05', channel: 100, frequency: 5500, rssi: -70, band: '5 GHz', widthMhz: 80, centerFreq0: 5530 }, + { ssid: 'Nachbar-WLAN-1', bssid: 'BB:00:01', channel: 1, frequency: 2412, rssi: -74, band: '2.4 GHz', widthMhz: 40, centerFreq0: 2422 }, + { ssid: 'Telekom-2AB3F', bssid: 'BB:00:02', channel: 11, frequency: 2462, rssi: -79, band: '2.4 GHz', widthMhz: 20 }, + { ssid: 'Vodafone-Hotspot', bssid: 'BB:00:03', channel: 13, frequency: 2472, rssi: -85, band: '2.4 GHz', widthMhz: 20 }, + { ssid: 'o2-WLAN-9912', bssid: 'BB:00:04', channel: 36, frequency: 5180, rssi: -76, band: '5 GHz', widthMhz: 20 }, + { ssid: 'DIRECT-hp-Drucker', bssid: 'BB:00:05', channel: 48, frequency: 5240, rssi: -81, band: '5 GHz', widthMhz: 20 }, + { ssid: 'Kamera-Hof-5G', bssid: 'BB:00:06', channel: 149, frequency: 5745, rssi: -72, band: '5 GHz', widthMhz: 40, centerFreq0: 5755 }, + { ssid: 'Buero-AP-2', bssid: 'BB:00:07', channel: 161, frequency: 5805, rssi: -68, band: '5 GHz', widthMhz: 80, centerFreq0: 5775 }, +]; + +const SZENARIEN = [ + { name: 'kunde-24', band: '2.4 GHz', nets: networks, self: 'AA:00:01' }, + { name: 'kunde-5', band: '5 GHz', nets: networks, self: 'AA:00:02' }, + { name: 'einzeln', band: '2.4 GHz', nets: [networks[0]], self: 'AA:00:01' }, + { name: 'leer5', band: '5 GHz', nets: [], self: undefined }, + { name: 'stapel', band: '2.4 GHz', self: 'CC:00:01', + nets: [1,2,3,4,5].map((i) => ({ ssid: `Netz-auf-Kanal-6-Nr${i}`, bssid: `CC:00:0${i}`, channel: 6, frequency: 2437, rssi: -45 - i*8, band: '2.4 GHz', widthMhz: 20 })) }, + { name: 'randlage', band: '2.4 GHz', self: 'DD:00:01', + nets: [ + { ssid: 'GanzLinksAmRand', bssid: 'DD:00:01', channel: 1, frequency: 2412, rssi: -50, band: '2.4 GHz', widthMhz: 20 }, + { ssid: 'GanzRechtsAmRand', bssid: 'DD:00:02', channel: 13, frequency: 2472, rssi: -55, band: '2.4 GHz', widthMhz: 20 }, + ] }, + { name: 'voll5', band: '5 GHz', self: 'EE:00:00', + nets: [36,40,44,48,52,56,60,64,100,104,108,112,116,120,124,128,132,136,140,144,149,153,157,161,165].map((ch, i) => ({ + ssid: `AP-${ch}`, bssid: `EE:00:${String(i).padStart(2,'0')}`, channel: ch, + frequency: 5000 + ch*5, rssi: -50 - (i % 8) * 5, band: '5 GHz', widthMhz: 20 })) }, +]; + +const FARBEN = [ + [/text-zinc-800/, 'stroke:#27272a'], [/text-zinc-700/, 'stroke:#3f3f46'], + [/text-zinc-600/, 'stroke:#52525b'], [/text-zinc-400/, 'stroke:#a1a1aa'], + [/fill-zinc-600/, 'fill:#52525b'], [/fill-zinc-500/, 'fill:#71717a'], + [/fill-zinc-400/, 'fill:#a1a1aa'], [/fill-zinc-300/, 'fill:#d4d4d8'], + [/fill-zinc-200/, 'fill:#e4e4e7'], + [/fill-emerald-500\/\[0\.07\]/, 'fill:rgba(16,185,129,.07)'], + [/fill-red-500\/\[0\.07\]/, 'fill:rgba(239,68,68,.07)'], + [/fill-emerald-500\/70/, 'fill:rgba(16,185,129,.7)'], + [/stroke-amber-400/, 'stroke:#fbbf24'], + // Netzfarben (fill /25 + stroke + text) + [/fill-violet-400\/25/, 'fill:rgba(167,139,250,.25)'], [/stroke-violet-400/, 'stroke:#a78bfa'], [/fill-violet-300/, 'fill:#c4b5fd'], + [/fill-emerald-400\/25/, 'fill:rgba(52,211,153,.25)'], [/stroke-emerald-400/, 'stroke:#34d399'], [/fill-emerald-300/, 'fill:#6ee7b7'], + [/fill-amber-400\/25/, 'fill:rgba(251,191,36,.25)'], [/fill-amber-300/, 'fill:#fcd34d'], + [/fill-rose-400\/25/, 'fill:rgba(251,113,133,.25)'], [/stroke-rose-400/, 'stroke:#fb7185'], [/fill-rose-300/, 'fill:#fda4af'], + [/fill-lime-400\/25/, 'fill:rgba(163,230,53,.25)'], [/stroke-lime-400/, 'stroke:#a3e635'], [/fill-lime-300/, 'fill:#bef264'], + [/fill-orange-400\/25/, 'fill:rgba(251,146,60,.25)'], [/stroke-orange-400/, 'stroke:#fb923c'], [/fill-orange-300/, 'fill:#fdba74'], + [/fill-teal-400\/25/, 'fill:rgba(45,212,191,.25)'], [/stroke-teal-400/, 'stroke:#2dd4bf'], [/fill-teal-300/, 'fill:#5eead4'], + [/fill-fuchsia-400\/25/, 'fill:rgba(232,121,249,.25)'], [/stroke-fuchsia-400/, 'stroke:#e879f9'], [/fill-fuchsia-300/, 'fill:#f0abfc'], + [/fill-sky-500\/40/, 'fill:rgba(14,165,233,.4)'], [/stroke-sky-300/, 'stroke:#7dd3fc'], [/fill-sky-200/, 'fill:#bae6fd'], +]; + +function farbig(svg) { + return svg.replace(/class="([^"]*)"/g, (_m, cls) => { + const styles = FARBEN.filter(([re]) => re.test(cls)).map(([, v]) => v).join(';'); + return styles ? `data-style="${styles}"` : ''; + }).replace(/data-style="([^"]*)"\s*style="([^"]*)"/g, 'style="$1;$2"') + .replace(/style="([^"]*)"\s*data-style="([^"]*)"/g, 'style="$1;$2"') + .replace(/data-style="([^"]*)"/g, 'style="$1"'); +} + +for (const s of SZENARIEN) { + const { body } = render(Chart, { props: { networks: s.nets, band: s.band, highlightBssid: s.self } }); + const svgs = body.match(//g) ?? []; + if (svgs.length === 0) { console.log(`${s.name}: kein SVG`); continue; } + const H = Number((svgs[0].match(/viewBox="0 0 \d+ (\d+)"/) ?? [])[1] ?? 190); + const kombiniert = + `` + + `` + + svgs.map((sv, i) => + `` + + farbig(sv).replace(/^]*>/, '').replace(/<\/svg>$/, '') + + ``).join('') + + ``; + writeFileSync(pathResolve(OUT, `${s.name}.svg`), kombiniert); + console.log(`gerendert: .render/${s.name}.svg (${s.band}, ${s.nets.length} Netze, ${svgs.length} Segment(e))`); +} + + +/* --- Zeitverlauf: mehrere Momentaufnahmen, mit Lücke (Netz zeitweise weg) --- */ +const T0 = 1786800000000; +const surveys = [0, 1, 2, 3, 4].map((i) => ({ + id: 's' + i, + createdAt: T0 + i * 15 * 60 * 1000, + networks: networks + // Nur 4 Netze, damit alle in die Top-6 der Legende passen; das VIERTE + // fehlt in Aufnahme 2 und 3 -> seine Linie MUSS reißen. Wird sie + // durchgezogen, verschweigt das Diagramm einen Aussetzer. + .slice(0, 4) + .filter((_n, idx) => !(idx === 3 && (i === 2 || i === 3))) + .map((n) => ({ ...n, rssi: n.rssi + [0, -4, -9, -3, 2][i] })), +})); +{ + const { body } = render(Timeline, { props: { surveys, connectedBssid: 'AA:00:01' } }); + const svgs = body.match(//g) ?? []; + if (svgs.length) { + const H = Number((svgs[0].match(/viewBox="0 0 \d+ (\d+)"/) ?? [])[1] ?? 170); + const out = + `` + + `` + + farbig(svgs[0]).replace(/^]*>/, '').replace(/<\/svg>$/, '') + + ``; + writeFileSync(pathResolve(OUT, 'zeitverlauf.svg'), out); + console.log('gerendert: .render/zeitverlauf.svg (5 Momentaufnahmen, eine Lücke)'); + } else { + console.log('zeitverlauf: kein SVG (zu wenige Aufnahmen?)'); + } +}