Compare commits
2 commits
28cd770537
...
c16badc275
| Author | SHA1 | Date | |
|---|---|---|---|
| c16badc275 | |||
| d74a89b2d5 |
7 changed files with 920 additions and 178 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,3 +17,4 @@ android/app/release/
|
|||
*.apk
|
||||
*.keystore
|
||||
*.jks
|
||||
.render/
|
||||
|
|
|
|||
77
src/lib/components/RssiAxis.svelte
Normal file
77
src/lib/components/RssiAxis.svelte
Normal 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" />
|
||||
|
|
@ -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}
|
||||
|
||||
<!-- 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}
|
||||
{/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>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
125
src/lib/components/WifiTimelineChart.svelte
Normal file
125
src/lib/components/WifiTimelineChart.svelte
Normal 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
212
src/lib/wifi/chart.ts
Normal 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 −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<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 (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 };
|
||||
});
|
||||
}
|
||||
|
|
@ -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 freister 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
172
tools/render-chart.mjs
Normal 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?)');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue