Compare commits
No commits in common. "c16badc275447c10b8dab63421c455b93732edde" and "28cd7705371a23d3224bd8eb478d46edf6190bdc" have entirely different histories.
c16badc275
...
28cd770537
7 changed files with 182 additions and 924 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,4 +17,3 @@ android/app/release/
|
||||||
*.apk
|
*.apk
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
.render/
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
<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,53 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Kanalgraph eines Frequenzbandes — je Netz ein Block über dem belegten
|
* Kanalgraph für ein Frequenzband — jedes Netz als Parabel-Bogen, zentriert
|
||||||
* Frequenzbereich, Höhe = Empfangspegel (ablesbar an der dBm-Achse links).
|
* auf seine Frequenz, mit der Kanalbreite als Bogenbreite und der
|
||||||
*
|
* Signalstärke als Bogenhöhe. Überlappende Bögen (Co-Channel oder
|
||||||
* Komplett neu gebaut, weil die erste Fassung im 5-GHz-Band unbrauchbar war
|
* Adjacent-Channel) sind halbtransparent gefüllt, sodass sich Überlapp
|
||||||
* (gemeldet vom Anwender): 745 MHz in dieselbe Breite gequetscht wie die
|
* direkt als sichtbare Farbverdichtung zeigt — genau das Bild, das die
|
||||||
* 80 MHz des 2,4-GHz-Bandes ergaben 8,6 Einheiten je 20-MHz-Kanal — die
|
* "WiFi Analyzer"-Vorbild-Apps zeigen. Bewusst OHNE externe Chart-Library
|
||||||
* Kanalzahlen überlappten sich zwangsläufig zu einem Zahlenbrei, SSID-Namen
|
* (Inline-SVG, Projektkonvention).
|
||||||
* 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 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 {
|
let {
|
||||||
networks,
|
networks,
|
||||||
|
|
@ -55,218 +16,113 @@
|
||||||
highlightBssid = undefined,
|
highlightBssid = undefined,
|
||||||
}: {
|
}: {
|
||||||
networks: WifiNetwork[];
|
networks: WifiNetwork[];
|
||||||
band: string;
|
band: '2.4 GHz' | '5 GHz' | '6 GHz';
|
||||||
/** eigenes/verbundenes Netz optisch hervorheben */
|
/** eigenes/verbundenes Netz optisch hervorheben */
|
||||||
highlightBssid?: string;
|
highlightBssid?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/** Höhe: Plot + zwei Zeilen Kanalbeschriftung + Blockunterschrift */
|
const W = 320;
|
||||||
const H = BASELINE + 34;
|
const H = 130;
|
||||||
|
const BASELINE = H - 18;
|
||||||
|
const PEAK_MAX = BASELINE - 8;
|
||||||
|
|
||||||
const segmente = $derived.by(() => {
|
// Frequenzbereich + Kanal-Ticks je Band
|
||||||
const segs = BAND_SEGMENTS[band] ?? [];
|
const RANGE: Record<string, { min: number; max: number; ticks: number[] }> = {
|
||||||
const scale = scaleForBand(band);
|
'2.4 GHz': {
|
||||||
const inBand = networks.filter((n) => n.band === band);
|
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],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return segs.map((seg) => {
|
const view = $derived.by(() => {
|
||||||
const spanMhz = seg.maxMhz - seg.minMhz;
|
const r = RANGE[band];
|
||||||
const plotW = spanMhz * scale;
|
const list = networks.filter((n) => n.band === band);
|
||||||
const x = (mhz: number) => PAD_L + (mhz - seg.minMhz) * scale;
|
if (!r) return null;
|
||||||
|
|
||||||
// Halboffenes Intervall [min, max) — ein Netz auf exakt der Grenze
|
const freqSpan = r.max - r.min;
|
||||||
// gehört genau EINEM Segment, sonst würde es in beiden auftauchen.
|
const x = (freq: number) => ((freq - r.min) / freqSpan) * W;
|
||||||
const nets = inBand.filter((n) => {
|
// -90 dBm (schwach) -> flacher Bogen, -30 dBm (stark) -> fast bis zum Rand
|
||||||
const { center } = occupiedRange(n);
|
const peakY = (rssi: number) => {
|
||||||
return center >= seg.minMhz && center < seg.maxMhz;
|
const t = Math.min(1, Math.max(0, (rssi + 90) / 60));
|
||||||
});
|
return BASELINE - t * (PEAK_MAX - 0) - 4;
|
||||||
|
};
|
||||||
|
|
||||||
const bloecke = nets.map((n) => {
|
const bumps = list.map((n) => {
|
||||||
const { from, to } = occupiedRange(n);
|
const widthMhz = n.widthMhz ?? 20;
|
||||||
// Am Segmentrand kappen, aber sichtbar machen — ein stumm
|
const halfWidthPx = Math.max(4, (widthMhz / 2 / freqSpan) * W);
|
||||||
// abgeschnittener 160-MHz-Block sähe sonst wie ein schmalerer Kanal aus.
|
const cx = x(n.frequency);
|
||||||
const xFrom = Math.max(PAD_L, x(from));
|
const py = peakY(n.rssi);
|
||||||
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 {
|
return {
|
||||||
n,
|
n,
|
||||||
xFrom,
|
cx,
|
||||||
xTo: xFrom + breite,
|
halfWidthPx,
|
||||||
mitte: xFrom + breite / 2,
|
py,
|
||||||
y,
|
path: `M ${(cx - halfWidthPx).toFixed(1)},${BASELINE} Q ${cx.toFixed(1)},${py.toFixed(1)} ${(cx + halfWidthPx).toFixed(1)},${BASELINE} Z`,
|
||||||
gekapptLinks,
|
isSelf: highlightBssid != null && n.bssid === highlightBssid,
|
||||||
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`,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const belegteKanaele = new Set<number>();
|
// Kanal-Ticks: nur die tatsächlich im Bereich liegenden, sonst wird die
|
||||||
for (const n of nets) {
|
// Achse bei 5/6 GHz (viele mögliche Kanäle) unleserlich.
|
||||||
const { from, to } = occupiedRange(n);
|
const chFreq2_4 = (ch: number) => (ch === 14 ? 2484 : 2412 + (ch - 1) * 5);
|
||||||
for (const ch of seg.channels) {
|
const chFreq5 = (ch: number) => 5170 + (ch - 34) * 5;
|
||||||
const f = channelToFreq(ch, band);
|
const chFreq6 = (ch: number) => 5950 + ch * 5;
|
||||||
if (f >= from && f <= to) belegteKanaele.add(ch);
|
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 rohTicks = seg.channels
|
return { bumps, ticks };
|
||||||
.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>
|
</script>
|
||||||
|
|
||||||
{#each segmente as s (s.seg.name)}
|
{#if view}
|
||||||
<div class="mt-1.5">
|
{#if view.bumps.length === 0}
|
||||||
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
|
<div class="flex h-[90px] items-center justify-center text-xs text-zinc-600">Keine Netze im {band}-Band</div>
|
||||||
<RssiAxis />
|
{: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" />
|
||||||
|
|
||||||
{#if s.leer}
|
<!-- Kanal-Ticks -->
|
||||||
<text
|
{#each view.ticks as t (t.ch)}
|
||||||
x={PAD_L + s.plotW / 2}
|
<line x1={t.x} y1={BASELINE} x2={t.x} y2={BASELINE + 3} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
|
||||||
y={BASELINE - 12}
|
<text x={t.x} y={H - 4} text-anchor="middle" class="fill-zinc-600" style="font-size: 7px">{t.ch}</text>
|
||||||
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}
|
{/each}
|
||||||
|
|
||||||
<!-- Zuordnungslinien zu weggerückten Beschriftungen -->
|
<!-- Bögen: schwache Netze zuerst zeichnen, damit starke oben liegen -->
|
||||||
{#each s.labels.filter((l) => l.leader != null) as l, i (i)}
|
{#each [...view.bumps].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)}
|
||||||
<line
|
<path
|
||||||
x1={l.x}
|
d={b.path}
|
||||||
y1={l.y + 2}
|
class={b.isSelf ? 'fill-sky-500/40 stroke-sky-400' : 'fill-zinc-400/25 stroke-zinc-400/70'}
|
||||||
x2={l.leader}
|
|
||||||
y2={l.y + 6}
|
|
||||||
stroke="currentColor"
|
|
||||||
class="text-zinc-600"
|
|
||||||
stroke-width="0.5"
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- SSID-Beschriftungen -->
|
|
||||||
{#each s.labels as l, i (i)}
|
|
||||||
<text
|
|
||||||
x={l.x}
|
|
||||||
y={l.y}
|
|
||||||
text-anchor="middle"
|
|
||||||
class={l.farbe.text}
|
|
||||||
style="font-size: {FS_LABEL}px; paint-order: stroke; stroke: #18181b; stroke-width: 1.8px"
|
|
||||||
>
|
|
||||||
{l.text}
|
|
||||||
</text>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Kanal-Ticks: belegte Kanäle hell, freie gedämpft — beantwortet
|
|
||||||
„welcher Kanal ist frei" ohne zusätzliche Beschriftung -->
|
|
||||||
{#each s.ticks as t (t.channel)}
|
|
||||||
<line
|
|
||||||
x1={t.x}
|
|
||||||
y1={BASELINE}
|
|
||||||
x2={t.x}
|
|
||||||
y2={BASELINE + (t.label ? 4 : 2)}
|
|
||||||
stroke="currentColor"
|
|
||||||
class={t.belegt ? 'text-zinc-400' : 'text-zinc-600'}
|
|
||||||
stroke-width="1"
|
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}
|
{/each}
|
||||||
|
|
||||||
<!-- Blockunterschrift -->
|
<!-- Beschriftung am Scheitelpunkt -->
|
||||||
<text x={PAD_L} y={BASELINE + 28} class="fill-zinc-500" style="font-size: {FS_LABEL}px">
|
{#each view.bumps as b (b.n.bssid)}
|
||||||
{s.seg.name}{s.seg.dfs ? ' · DFS' : ''} · {s.anzahl}
|
<text
|
||||||
{s.anzahl === 1 ? 'Netz' : 'Netze'}
|
x={b.cx}
|
||||||
</text>
|
y={Math.max(9, b.py - 3)}
|
||||||
<text x={W - PAD_R} y={BASELINE + 28} text-anchor="end" class="fill-zinc-600" style="font-size: {FS_LABEL}px">
|
text-anchor="middle"
|
||||||
Kanal
|
class={b.isSelf ? 'fill-sky-300 font-medium' : 'fill-zinc-400'}
|
||||||
|
style="font-size: 7px"
|
||||||
|
>
|
||||||
|
{b.n.ssid || b.n.bssid.slice(-5)}
|
||||||
</text>
|
</text>
|
||||||
|
{/each}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
{/if}
|
||||||
{/each}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
<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}
|
|
||||||
|
|
@ -1,212 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,16 +14,14 @@
|
||||||
import { ChevronDown, RefreshCw, Camera, MapPin } from 'lucide-svelte';
|
import { ChevronDown, RefreshCw, Camera, MapPin } from 'lucide-svelte';
|
||||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||||
import WifiChannelChart from '$lib/components/WifiChannelChart.svelte';
|
import WifiChannelChart from '$lib/components/WifiChannelChart.svelte';
|
||||||
import WifiTimelineChart from '$lib/components/WifiTimelineChart.svelte';
|
|
||||||
import { getProtocol, saveProtocol } from '$lib/db';
|
import { getProtocol, saveProtocol } from '$lib/db';
|
||||||
import { addWifiSurvey, addMeasurement, uid } from '$lib/protocols';
|
import { addWifiSurvey, uid } from '$lib/protocols';
|
||||||
import { scanner, type WifiNetwork } from '$lib/scanner';
|
import { scanner, type WifiNetwork } from '$lib/scanner';
|
||||||
import { rssiRating } from '$lib/wifi/rating';
|
import { rssiRating } from '$lib/wifi/rating';
|
||||||
import { recommend24GhzChannels, channelWarnings } from '$lib/wifi/recommend';
|
import { recommend24GhzChannels, channelWarnings } from '$lib/wifi/recommend';
|
||||||
import { netColor } from '$lib/wifi/chart';
|
|
||||||
import { sync } from '$lib/sync.svelte';
|
import { sync } from '$lib/sync.svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import type { MeasureStatus, Protocol, WifiSurvey } from '$lib/types';
|
import type { Protocol, WifiSurvey } from '$lib/types';
|
||||||
|
|
||||||
let protocol = $state<Protocol | null>(null);
|
let protocol = $state<Protocol | null>(null);
|
||||||
let networks = $state<WifiNetwork[]>([]);
|
let networks = $state<WifiNetwork[]>([]);
|
||||||
|
|
@ -150,61 +148,8 @@
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
addWifiSurvey(protocol, survey);
|
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();
|
await persist();
|
||||||
toast.show('Momentaufnahme gespeichert und ins Protokoll übernommen', 'success');
|
toast.show('Momentaufnahme gespeichert', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDateTime(ts: number): string {
|
function fmtDateTime(ts: number): string {
|
||||||
|
|
@ -216,29 +161,66 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* --- Zeitverlauf mehrerer APs aus den gespeicherten Momentaufnahmen --- */
|
||||||
* Klartext-Antwort über dem Diagramm. Vor Ort ist ein Satz schneller
|
const SERIES_COLORS = [
|
||||||
* erfasst als jedes Diagramm — die Zahlen liegen ohnehin vor.
|
'text-sky-400',
|
||||||
*/
|
'text-emerald-400',
|
||||||
const fazit = $derived.by(() => {
|
'text-amber-400',
|
||||||
if (networks.length === 0) return null;
|
'text-fuchsia-400',
|
||||||
const eigenes = networks.filter((n) => n.bssid === connectedBssid);
|
'text-red-400',
|
||||||
const teile: string[] = [];
|
'text-lime-400',
|
||||||
if (eigenes.length > 0) {
|
];
|
||||||
const stk = eigenes.sort((a, b) => b.rssi - a.rssi)[0];
|
const TL_W = 320;
|
||||||
const r = rssiRating(stk.rssi);
|
const TL_H = 100;
|
||||||
teile.push(`Dein Netz: Kanal ${stk.channel} · ${stk.rssi} dBm (${r.label})`);
|
|
||||||
}
|
const timeline = $derived.by(() => {
|
||||||
if (recommendation.length > 0) {
|
const surveys = [...(protocol?.wifiSurveys ?? [])].sort((a, b) => a.createdAt - b.createdAt);
|
||||||
const best = recommendation[0];
|
if (surveys.length < 2) return null;
|
||||||
const eigen24 = eigenes.find((n) => n.band === '2.4 GHz');
|
|
||||||
if (eigen24 && eigen24.channel === best.channel) {
|
// Meistgesehene SSIDs zuerst, auf 6 begrenzt — mehr Linien wären nicht mehr lesbar
|
||||||
teile.push(`2,4 GHz: Kanal ${best.channel} ist bereits der störungsärmste`);
|
const counts = new Map<string, number>();
|
||||||
} else {
|
for (const s of surveys) {
|
||||||
teile.push(`2,4 GHz freister Kanal: ${best.channel}`);
|
for (const n of s.networks) {
|
||||||
|
const key = n.ssid || n.bssid;
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return teile.length > 0 ? teile.join(' · ') : null;
|
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 };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -278,10 +260,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{#if changesSinceLastSurvey.length > 0}
|
||||||
<div class="mt-2 rounded-lg bg-sky-900/30 p-2.5 text-xs text-sky-300">
|
<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>
|
<p class="font-medium">Seit der letzten Momentaufnahme ({fmtDateTime(lastSurvey.createdAt)}):</p>
|
||||||
|
|
@ -323,10 +301,27 @@
|
||||||
<p class="mt-3 text-sm text-zinc-500">Keine Netze gefunden.</p>
|
<p class="mt-3 text-sm text-zinc-500">Keine Netze gefunden.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if (protocol.wifiSurveys ?? []).length >= 2}
|
{#if timeline}
|
||||||
<div class="mt-3 rounded-lg bg-zinc-900 p-2.5">
|
<div class="mt-3 rounded-lg bg-zinc-900 p-2.5">
|
||||||
<p class="mb-1 text-xs font-semibold text-zinc-300">Empfangspegel über die Momentaufnahmen</p>
|
<p class="mb-1 text-xs font-semibold text-zinc-300">
|
||||||
<WifiTimelineChart surveys={protocol.wifiSurveys ?? []} connectedBssid={connectedBssid ?? undefined} />
|
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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -342,18 +337,12 @@
|
||||||
</div>
|
</div>
|
||||||
{#each g.nets as n (n.bssid)}
|
{#each g.nets as n (n.bssid)}
|
||||||
{@const rating = rssiRating(n.rssi)}
|
{@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">
|
<div class="mt-1 flex items-center justify-between text-[11px] text-zinc-500">
|
||||||
<span class="flex min-w-0 items-center gap-1.5">
|
<span>
|
||||||
<!-- 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
|
Kanal {n.channel} · {n.band}{n.widthMhz ? ` · ${n.widthMhz} MHz` : ''}{n.standard
|
||||||
? ` · ${n.standard}`
|
? ` · ${n.standard}`
|
||||||
: ''}{n.security ? ` · ${n.security}` : ''}
|
: ''}{n.security ? ` · ${n.security}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
<span class="shrink-0 {rating.colorClass} tabular-nums">{n.rssi} dBm</span>
|
<span class="shrink-0 {rating.colorClass} tabular-nums">{n.rssi} dBm</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
/**
|
|
||||||
* 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