Compare commits

..

2 commits

Author SHA1 Message Date
c16badc275 Trigger: APK-Rebuild [apk]
All checks were successful
Build APK / build-apk (push) Successful in 2m11s
2026-08-15 17:38:05 +02:00
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
7 changed files with 920 additions and 178 deletions

1
.gitignore vendored
View file

@ -17,3 +17,4 @@ android/app/release/
*.apk
*.keystore
*.jks
.render/

View file

@ -0,0 +1,77 @@
<script lang="ts">
/**
* dBm-Achse (Empfangspegel) samt Rasterlinien und Praxis-Zonen.
*
* Bewusst eine eigene Komponente statt nur gemeinsamer Konstanten: so
* benutzen Kanalgraph und Zeitverlauf zwangsläufig dieselbe Achse. Vorher
* hatte keines der beiden Diagramme überhaupt eine Achse — die Höhe kodierte
* die Signalstärke, ohne dass man einen Wert ablesen konnte.
*
* Wichtig zur Beschriftung: das ist der EMPFANGSPEGEL am Standort des
* Handys, nicht die Sendeleistung des Routers. Ein und derselbe Router
* liefert je nach Entfernung und Wand 45 oder 85 dBm.
*/
import {
W,
PAD_L,
PAD_R,
BASELINE,
PLOT_TOP,
DB_GRID,
DB_LABELED,
RSSI_GOOD,
RSSI_WEAK,
FS_AXIS,
rssiToY,
} from '$lib/wifi/chart';
let { zonen = true }: { zonen?: boolean } = $props();
const yGood = rssiToY(RSSI_GOOD);
const yWeak = rssiToY(RSSI_WEAK);
</script>
{#if zonen}
<!-- Praxis-Zonen: oberhalb -67 dBm zuverlässig, unterhalb -75 dBm kritisch.
Beantwortet "reicht das hier?" ohne Kopfrechnen. -->
<rect x={PAD_L} y={PLOT_TOP} width={W - PAD_L - PAD_R} height={yGood - PLOT_TOP} class="fill-emerald-500/[0.07]" />
<rect x={PAD_L} y={yWeak} width={W - PAD_L - PAD_R} height={BASELINE - yWeak} class="fill-red-500/[0.07]" />
{/if}
{#each DB_GRID as db (db)}
{@const y = rssiToY(db)}
<line
x1={PAD_L}
y1={y}
x2={W - PAD_R}
y2={y}
stroke="currentColor"
class={DB_LABELED.has(db) ? 'text-zinc-700' : 'text-zinc-800'}
stroke-width="0.5"
/>
{#if DB_LABELED.has(db)}
<text
x={PAD_L - 4}
y={y + 3.5}
text-anchor="end"
class="fill-zinc-300"
style="font-size: {FS_AXIS}px"
>
{db}
</text>
{/if}
{/each}
<!-- Achseneinheit — ohne sie ist nicht klar, was die Zahlen bedeuten -->
<text
x={PAD_L - 4}
y={PLOT_TOP - 5}
text-anchor="end"
class="fill-zinc-500"
style="font-size: {FS_AXIS - 1}px"
>
dBm
</text>
<!-- Grundlinie -->
<line x1={PAD_L} y1={BASELINE} x2={W - PAD_R} y2={BASELINE} stroke="currentColor" class="text-zinc-600" stroke-width="1" />

View file

@ -1,14 +1,53 @@
<script lang="ts">
/**
* Kanalgraph für ein Frequenzband — jedes Netz als Parabel-Bogen, zentriert
* auf seine Frequenz, mit der Kanalbreite als Bogenbreite und der
* Signalstärke als Bogenhöhe. Überlappende Bögen (Co-Channel oder
* Adjacent-Channel) sind halbtransparent gefüllt, sodass sich Überlapp
* direkt als sichtbare Farbverdichtung zeigt — genau das Bild, das die
* "WiFi Analyzer"-Vorbild-Apps zeigen. Bewusst OHNE externe Chart-Library
* (Inline-SVG, Projektkonvention).
* 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/2A,
* UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen fallen
* weg — dadurch wird ein 20-MHz-Kanal ~24 statt 8,6 Einheiten breit.
* 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_W,
BASELINE,
PLOT_TOP,
FS_AXIS,
FS_LABEL,
BAND_SEGMENTS,
channelToFreq,
scaleForBand,
occupiedRange,
thinTicks,
textWidth,
rssiToY,
netColor,
kuerzeSsid,
} from '$lib/wifi/chart';
let {
networks,
@ -16,113 +55,218 @@
highlightBssid = undefined,
}: {
networks: WifiNetwork[];
band: '2.4 GHz' | '5 GHz' | '6 GHz';
band: string;
/** eigenes/verbundenes Netz optisch hervorheben */
highlightBssid?: string;
} = $props();
const W = 320;
const H = 130;
const BASELINE = H - 18;
const PEAK_MAX = BASELINE - 8;
/** Höhe: Plot + zwei Zeilen Kanalbeschriftung + Blockunterschrift */
const H = BASELINE + 34;
// Frequenzbereich + Kanal-Ticks je Band
const RANGE: Record<string, { min: number; max: number; ticks: number[] }> = {
'2.4 GHz': {
min: 2402,
max: 2482,
ticks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
},
'5 GHz': {
min: 5150,
max: 5895,
ticks: [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],
},
'6 GHz': {
min: 5925,
max: 7125,
ticks: [1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65, 69, 73, 77, 81, 85, 89, 93],
},
};
const segmente = $derived.by(() => {
const segs = BAND_SEGMENTS[band] ?? [];
const scale = scaleForBand(band);
const inBand = networks.filter((n) => n.band === band);
const view = $derived.by(() => {
const r = RANGE[band];
const list = networks.filter((n) => n.band === band);
if (!r) return null;
return segs.map((seg) => {
const spanMhz = seg.maxMhz - seg.minMhz;
const plotW = spanMhz * scale;
const x = (mhz: number) => PAD_L + (mhz - seg.minMhz) * scale;
const freqSpan = r.max - r.min;
const x = (freq: number) => ((freq - r.min) / freqSpan) * W;
// -90 dBm (schwach) -> flacher Bogen, -30 dBm (stark) -> fast bis zum Rand
const peakY = (rssi: number) => {
const t = Math.min(1, Math.max(0, (rssi + 90) / 60));
return BASELINE - t * (PEAK_MAX - 0) - 4;
};
// 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 bumps = list.map((n) => {
const widthMhz = n.widthMhz ?? 20;
const halfWidthPx = Math.max(4, (widthMhz / 2 / freqSpan) * W);
const cx = x(n.frequency);
const py = peakY(n.rssi);
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 = rssiToY(n.rssi);
// 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,
cx,
halfWidthPx,
py,
path: `M ${(cx - halfWidthPx).toFixed(1)},${BASELINE} Q ${cx.toFixed(1)},${py.toFixed(1)} ${(cx + halfWidthPx).toFixed(1)},${BASELINE} Z`,
isSelf: highlightBssid != null && n.bssid === highlightBssid,
xFrom,
xTo: xFrom + breite,
mitte: xFrom + breite / 2,
y,
gekapptLinks,
gekapptRechts,
isSelf,
farbe: netColor(n.bssid, isSelf),
pfad:
`M ${xFrom.toFixed(1)},${BASELINE} ` +
`L ${(xFrom + schulter).toFixed(1)},${y.toFixed(1)} ` +
`L ${(xFrom + breite - schulter).toFixed(1)},${y.toFixed(1)} ` +
`L ${(xFrom + breite).toFixed(1)},${BASELINE} Z`,
};
});
// Kanal-Ticks: nur die tatsächlich im Bereich liegenden, sonst wird die
// Achse bei 5/6 GHz (viele mögliche Kanäle) unleserlich.
const chFreq2_4 = (ch: number) => (ch === 14 ? 2484 : 2412 + (ch - 1) * 5);
const chFreq5 = (ch: number) => 5170 + (ch - 34) * 5;
const chFreq6 = (ch: number) => 5950 + ch * 5;
const freqForTick = band === '2.4 GHz' ? chFreq2_4 : band === '5 GHz' ? chFreq5 : chFreq6;
const ticks = r.ticks
.map((ch) => ({ ch, fx: freqForTick(ch) }))
.filter((t) => t.fx >= r.min && t.fx <= r.max)
.map((t) => ({ ...t, x: x(t.fx) }));
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);
}
}
return { bumps, ticks };
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: nach Signalstärke absteigend, Kollision durch
// Ausweichen in eine tiefere Zeile — und wer dann immer noch kollidiert,
// bekommt gar kein Label (statt eines unlesbaren Textstapels).
const belegteLabel: { x1: number; x2: number; y: number }[] = [];
const labels: {
text: string;
x: number;
y: number;
farbe: ReturnType<typeof netColor>;
leader: number | null;
}[] = [];
for (const b of [...bloecke].sort((a, c) => c.n.rssi - a.n.rssi)) {
const text = kuerzeSsid(b.n.ssid || b.n.bssid.slice(-8));
const breite = textWidth(text, FS_LABEL);
// waagerecht im Segment halten, damit nichts aus dem Bild läuft
const cx = Math.min(
PAD_L + plotW - breite / 2 - 1,
Math.max(PAD_L + breite / 2 + 1, b.mitte),
);
let y = b.y - 4;
let versuche = 0;
while (
versuche < 4 &&
belegteLabel.some(
(l) =>
Math.abs(l.y - y) < FS_LABEL + 1 &&
cx - breite / 2 < l.x2 + 2 &&
cx + breite / 2 > l.x1 - 2,
)
) {
y -= FS_LABEL + 2;
versuche++;
}
if (y < PLOT_TOP - 2) continue; // kein Platz mehr lieber weglassen
belegteLabel.push({ x1: cx - breite / 2, x2: cx + breite / 2, y });
labels.push({
text,
x: cx,
y,
farbe: b.farbe,
// Zuordnungslinie, sobald das Label vom eigenen Block weggerückt ist
leader: Math.abs(y - (b.y - 4)) > 2 || Math.abs(cx - b.mitte) > 2 ? b.mitte : null,
});
}
return { seg, plotW, bloecke, ticks, labels, leer: nets.length === 0, anzahl: nets.length };
});
});
</script>
{#if view}
{#if view.bumps.length === 0}
<div class="flex h-[90px] items-center justify-center text-xs text-zinc-600">Keine Netze im {band}-Band</div>
{:else}
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {band}">
<!-- Grundlinie -->
<line x1="0" y1={BASELINE} x2={W} y2={BASELINE} stroke="currentColor" class="text-zinc-800" stroke-width="1" />
{#each segmente as s (s.seg.name)}
<div class="mt-1.5">
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
<RssiAxis />
<!-- Kanal-Ticks -->
{#each view.ticks as t (t.ch)}
<line x1={t.x} y1={BASELINE} x2={t.x} y2={BASELINE + 3} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
<text x={t.x} y={H - 4} text-anchor="middle" class="fill-zinc-600" style="font-size: 7px">{t.ch}</text>
{#if s.leer}
<text
x={PAD_L + s.plotW / 2}
y={BASELINE - 12}
text-anchor="middle"
class="fill-emerald-500/70"
style="font-size: {FS_LABEL}px"
>
frei — keine Netze
</text>
{/if}
<!-- 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}
<!-- Bögen: schwache Netze zuerst zeichnen, damit starke oben liegen -->
{#each [...view.bumps].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)}
<path
d={b.path}
class={b.isSelf ? 'fill-sky-500/40 stroke-sky-400' : 'fill-zinc-400/25 stroke-zinc-400/70'}
stroke-width="1"
<!-- Zuordnungslinien zu weggerückten Beschriftungen -->
{#each s.labels.filter((l) => l.leader != null) as l, i (i)}
<line
x1={l.x}
y1={l.y + 2}
x2={l.leader}
y2={l.y + 6}
stroke="currentColor"
class="text-zinc-600"
stroke-width="0.5"
/>
{/each}
<!-- Beschriftung am Scheitelpunkt -->
{#each view.bumps as b (b.n.bssid)}
<!-- SSID-Beschriftungen -->
{#each s.labels as l, i (i)}
<text
x={b.cx}
y={Math.max(9, b.py - 3)}
x={l.x}
y={l.y}
text-anchor="middle"
class={b.isSelf ? 'fill-sky-300 font-medium' : 'fill-zinc-400'}
style="font-size: 7px"
class={l.farbe.text}
style="font-size: {FS_LABEL}px; paint-order: stroke; stroke: #18181b; stroke-width: 1.8px"
>
{b.n.ssid || b.n.bssid.slice(-5)}
{l.text}
</text>
{/each}
</svg>
<!-- 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={BASELINE}
x2={t.x}
y2={BASELINE + (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={BASELINE + 14}
text-anchor="middle"
class={t.belegt ? 'fill-zinc-200' : 'fill-zinc-500'}
style="font-size: {FS_AXIS}px"
>
{t.channel}
</text>
{/if}
{/if}
{/each}
<!-- Blockunterschrift -->
<text x={PAD_L} y={BASELINE + 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'}
</text>
<text x={W - PAD_R} y={BASELINE + 28} text-anchor="end" class="fill-zinc-600" style="font-size: {FS_LABEL}px">
Kanal
</text>
</svg>
</div>
{/each}

View file

@ -0,0 +1,125 @@
<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, BASELINE, FS_LABEL, rssiToY, netColor, kuerzeSsid } from '$lib/wifi/chart';
let {
surveys,
connectedBssid = undefined,
}: {
surveys: WifiSurvey[];
connectedBssid?: string;
} = $props();
const H = BASELINE + 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 = rssiToY(p.rssi);
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 />
{#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}

212
src/lib/wifi/chart.ts Normal file
View file

@ -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 9030 dBm, der andere 10020, 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 };
});
}

View file

@ -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<Protocol | null>(null);
let networks = $state<WifiNetwork[]>([]);
@ -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<string, number>();
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;
});
</script>
@ -260,6 +278,10 @@
</button>
</div>
{#if fazit}
<p class="mt-2 rounded-lg bg-zinc-800 p-2.5 text-sm font-medium text-zinc-100">{fazit}</p>
{/if}
{#if changesSinceLastSurvey.length > 0}
<div class="mt-2 rounded-lg bg-sky-900/30 p-2.5 text-xs text-sky-300">
<p class="font-medium">Seit der letzten Momentaufnahme ({fmtDateTime(lastSurvey.createdAt)}):</p>
@ -301,27 +323,10 @@
<p class="mt-3 text-sm text-zinc-500">Keine Netze gefunden.</p>
{/if}
{#if timeline}
{#if (protocol.wifiSurveys ?? []).length >= 2}
<div class="mt-3 rounded-lg bg-zinc-900 p-2.5">
<p class="mb-1 text-xs font-semibold text-zinc-300">
Zeitverlauf ({fmtDateTime(timeline.tFirst)} {fmtDateTime(timeline.tLast)})
</p>
<svg viewBox="0 0 {TL_W} {TL_H}" class="w-full">
<line x1="0" y1={TL_H} x2={TL_W} y2={TL_H} stroke="currentColor" class="text-zinc-800" stroke-width="1" />
{#each timeline.series as s (s.ssid)}
{#each s.segments as seg, i (i)}
<polyline points={seg} fill="none" stroke="currentColor" stroke-width="1.5" class={s.color} />
{/each}
{/each}
</svg>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1">
{#each timeline.series as s (s.ssid)}
<span class="flex items-center gap-1 text-[10px] {s.color}">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{s.ssid}
</span>
{/each}
</div>
<p class="mb-1 text-xs font-semibold text-zinc-300">Empfangspegel über die Momentaufnahmen</p>
<WifiTimelineChart surveys={protocol.wifiSurveys ?? []} connectedBssid={connectedBssid ?? undefined} />
</div>
{/if}
@ -337,12 +342,18 @@
</div>
{#each g.nets as n (n.bssid)}
{@const rating = rssiRating(n.rssi)}
{@const farbe = netColor(n.bssid, n.bssid === connectedBssid)}
<div class="mt-1 flex items-center justify-between text-[11px] text-zinc-500">
<span>
<span class="flex min-w-0 items-center gap-1.5">
<!-- gleiche Farbe wie im Kanalgraph — macht die Zuordnung
zwischen Diagramm und Liste ohne Suchen möglich -->
<span class="h-1.5 w-1.5 shrink-0 rounded-full {farbe.dot}"></span>
<span class="truncate">
Kanal {n.channel} · {n.band}{n.widthMhz ? ` · ${n.widthMhz} MHz` : ''}{n.standard
? ` · ${n.standard}`
: ''}{n.security ? ` · ${n.security}` : ''}
</span>
</span>
<span class="shrink-0 {rating.colorClass} tabular-nums">{n.rssi} dBm</span>
</div>
{/each}

172
tools/render-chart.mjs Normal file
View file

@ -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(/<svg[\s\S]*?<\/svg>/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 =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 ${H * svgs.length}" width="960" height="${H * svgs.length * 3}">` +
`<rect width="320" height="${H * svgs.length}" fill="#18181b"/>` +
svgs.map((sv, i) =>
`<g transform="translate(0,${i * H})">` +
farbig(sv).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '') +
`</g>`).join('') +
`</svg>`;
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(/<svg[\s\S]*?<\/svg>/g) ?? [];
if (svgs.length) {
const H = Number((svgs[0].match(/viewBox="0 0 \d+ (\d+)"/) ?? [])[1] ?? 170);
const out =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 ${H}" width="960" height="${H * 3}">` +
`<rect width="320" height="${H}" fill="#18181b"/>` +
farbig(svgs[0]).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '') +
`</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?)');
}
}