Eddy hatte vorgeschlagen, die Netze "weiter auseinander zu ziehen". Das wurde
mit dem Argument abgelehnt, die Blockhoehe SEI der Messwert und duerfe nicht
verschoben werden. Das war ein Denkfehler: ein Netz von seinem Messwert
wegzuschieben waere falsch, den Massstab der Achse zu dehnen dagegen voellig
unbedenklich - die Achsenbeschriftung wandert mit, jeder Wert bleibt exakt
ablesbar. Genau so loest es auch WiFiAnalyzer (Einstellung "Maximum Y-axis
Value" plus Pinch-Zoom).
- DB_SCALE 2 -> 5 Einheiten je dB. Der dichte Bereich (-65...-85 dBm) waechst
von 40 auf 100 Einheiten: Platz fuer neun Namenszeilen statt dreieinhalb.
- Achse zeigt nur den belegten Bereich (rssiBereich), auf 10 dB gerastert mit
5 dB Luft nach oben. Ohne das waere 5 GHz mit vier Bloecken ueberwiegend
leere Flaeche zum Scrollen. Gerastert, damit die Achse nicht bei jedem
RSSI-Zucken springt und zwei Momentaufnahmen vergleichbar bleiben.
Ergebnis: trotz 2,5-facher Spreizung 30 % kuerzer als vorher.
- Alle Bloecke EINES Bandes teilen sich den Bereich - sonst laese man zwei
gleich hohe Balken als "gleich stark", obwohl 20 dB dazwischen liegen.
Gleiches gilt im Zeitverlauf ueber alle Momentaufnahmen.
- Jede 10-dB-Linie beschriftet (vorher nur jede zweite, da war kein Platz).
- dBm-Wert am Netznamen ("FRITZ!Box 7590 AX -48"). Beantwortet Eddys erste
Meldung ("ich sehe nicht die Sendestaerke in dB") ohne Blickwechsel zur
Achse - und bleibt richtig, wenn ein Name verschoben werden musste. Passt
der Text nicht, wird der Wert geopfert, nicht der Name: den Wert kann man
an der Achse ablesen, den Namen nirgends.
Geprueft mit tools/render-chart.mjs (dicht24: 15/15 Netze beschriftet,
voll5: 25/25) und im Emulator auf der echten Kanalanalyse-Seite.
378 lines
15 KiB
Svelte
378 lines
15 KiB
Svelte
<script lang="ts">
|
||
/**
|
||
* 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,
|
||
* UNII-2A, UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen
|
||
* fallen weg — dadurch wird ein 20-MHz-Kanal deutlich breiter als die
|
||
* 8,6 Einheiten von vorher. Die Trennung UNII-1/UNII-2A ist zusätzlich
|
||
* fachlich nötig: ab Kanal 52 gilt DFS, und das muss am Block stehen.
|
||
* 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_TOP,
|
||
FS_AXIS,
|
||
FS_LABEL,
|
||
BAND_SEGMENTS,
|
||
channelToFreq,
|
||
scaleForBand,
|
||
occupiedRange,
|
||
thinTicks,
|
||
textWidth,
|
||
rssiBereich,
|
||
rssiToYIn,
|
||
netColor,
|
||
kuerzeSsid,
|
||
} from '$lib/wifi/chart';
|
||
|
||
let {
|
||
networks,
|
||
band,
|
||
highlightBssid = undefined,
|
||
}: {
|
||
networks: WifiNetwork[];
|
||
band: string;
|
||
/** eigenes/verbundenes Netz optisch hervorheben */
|
||
highlightBssid?: string;
|
||
} = $props();
|
||
|
||
/**
|
||
* Ein Block ohne Netze braucht keine dBm-Skala — er wird flach gezeichnet.
|
||
* Sonst kostet jedes leere Teilband so viel Platz wie ein belegtes, und bei
|
||
* vier 5-GHz-Blöcken müsste man an leeren Flächen vorbeiscrollen, um die
|
||
* belegten zu finden.
|
||
*/
|
||
const H_LEER = 52;
|
||
|
||
/**
|
||
* dBm-Ausschnitt EINMAL für das gesamte Band — nicht je Block. Zwei Blöcke
|
||
* mit unterschiedlicher Achse würde man unweigerlich als „gleich stark"
|
||
* fehllesen, weil beide Balken gleich hoch aussähen.
|
||
*/
|
||
const bereich = $derived(
|
||
rssiBereich(networks.filter((n) => n.band === band).map((n) => n.rssi)),
|
||
);
|
||
const grund = $derived(PLOT_TOP + bereich.hoehe);
|
||
/** Höhe: Plot + Kanalbeschriftung + Blockunterschrift */
|
||
const H = $derived(grund + 34);
|
||
|
||
const segmente = $derived.by(() => {
|
||
const segs = BAND_SEGMENTS[band] ?? [];
|
||
const scale = scaleForBand(band);
|
||
const inBand = networks.filter((n) => n.band === band);
|
||
const ber = bereich;
|
||
const base = PLOT_TOP + ber.hoehe;
|
||
|
||
return segs.map((seg) => {
|
||
const spanMhz = seg.maxMhz - seg.minMhz;
|
||
const plotW = spanMhz * scale;
|
||
const x = (mhz: number) => PAD_L + (mhz - seg.minMhz) * scale;
|
||
|
||
// 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 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 = rssiToYIn(n.rssi, ber);
|
||
// 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,
|
||
xFrom,
|
||
xTo: xFrom + breite,
|
||
mitte: xFrom + breite / 2,
|
||
y,
|
||
gekapptLinks,
|
||
gekapptRechts,
|
||
isSelf,
|
||
farbe: netColor(n.bssid, isSelf),
|
||
pfad:
|
||
`M ${xFrom.toFixed(1)},${base} ` +
|
||
`L ${(xFrom + schulter).toFixed(1)},${y.toFixed(1)} ` +
|
||
`L ${(xFrom + breite - schulter).toFixed(1)},${y.toFixed(1)} ` +
|
||
`L ${(xFrom + breite).toFixed(1)},${base} Z`,
|
||
};
|
||
});
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 entzerren.
|
||
*
|
||
* Das Kernproblem, gemeldet vom Anwender: die Wunsch-Höhe eines Labels
|
||
* hängt am Messwert, und reale Nachbarnetze liegen fast alle zwischen
|
||
* −65 und −85 dBm. Dort drängeln sich bei einer Wohnhaus-Nachbarschaft
|
||
* zehn und mehr Namen übereinander und werden unlesbar.
|
||
*
|
||
* Der eigentliche Hebel dagegen ist die gespreizte Y-Achse (DB_SCALE in
|
||
* chart.ts): aus 40 Einheiten für den dichten Bereich werden 100, damit
|
||
* lösen sich die meisten Überschneidungen von selbst. Anmerkung zur
|
||
* Entstehung, weil der Irrtum hier festgehalten gehört: die Spreizung
|
||
* wurde zunächst mit dem Argument abgelehnt, die Blockhöhe SEI der
|
||
* Messwert und dürfe nicht verschoben werden. Das verwechselte zwei
|
||
* Dinge — ein Netz von seinem Messwert wegzuschieben wäre falsch, den
|
||
* Maßstab der Achse zu dehnen dagegen völlig unbedenklich, weil die
|
||
* Achsenbeschriftung mitwandert und jeder Wert ablesbar bleibt.
|
||
*
|
||
* Was danach noch übereinander liegt, wird als Beschriftung verschoben —
|
||
* nur der Text, nie der Block; eine dünne Zuordnungslinie in der Farbe
|
||
* des Netzes führt zurück zum zugehörigen Block.
|
||
*
|
||
* Verfahren: stärkstes Netz zuerst (das interessiert am meisten und
|
||
* bekommt deshalb die beste Position), dann für jedes weitere Label vom
|
||
* Wunschplatz aus abwechselnd nach oben und unten die nächste freie
|
||
* Zeile suchen. Zwei Labels stören sich nur, wenn sie sich AUCH
|
||
* waagerecht überlappen — nebeneinander liegende Netze dürfen dieselbe
|
||
* Höhe behalten. Findet ein Label gar keinen Platz, bleibt sein Block
|
||
* farbig stehen und wird über die Netzliste zugeordnet; die Anzahl der
|
||
* unbeschrifteten Netze wird ausgewiesen statt sie zu verschweigen.
|
||
*/
|
||
const ZEILE = FS_LABEL + 2;
|
||
const belegteLabel: { x1: number; x2: number; y: number }[] = [];
|
||
const labels: {
|
||
text: string;
|
||
x: number;
|
||
y: number;
|
||
farbe: ReturnType<typeof netColor>;
|
||
leader: { x: number; y: number } | null;
|
||
}[] = [];
|
||
let ohneLabel = 0;
|
||
|
||
const frei = (y: number, x1: number, x2: number) =>
|
||
y >= PLOT_TOP + 2 &&
|
||
y <= base - 2 &&
|
||
!belegteLabel.some(
|
||
(l) => Math.abs(l.y - y) < ZEILE && x1 < l.x2 + 2 && x2 > l.x1 - 2,
|
||
);
|
||
|
||
for (const b of [...bloecke].sort((a, c) => c.n.rssi - a.n.rssi)) {
|
||
const name = kuerzeSsid(b.n.ssid || b.n.bssid.slice(-8));
|
||
const wunschY = b.y - 4;
|
||
/*
|
||
* Zwei Textfassungen, lange zuerst: „FRITZ!Box 7590 −48" statt nur
|
||
* „FRITZ!Box 7590". Der angehängte Messwert war die ursprüngliche
|
||
* Meldung des Anwenders („ich sehe nicht die Sendestärke in dB in den
|
||
* Diagrammen"). Die dBm-Achse beantwortet das inzwischen, aber am Namen
|
||
* steht der Wert ohne Blickwechsel zur Achse — und vor allem auch dann
|
||
* noch richtig, wenn das Label verschoben werden musste und damit nicht
|
||
* mehr auf der Höhe seines Blocks steht.
|
||
*
|
||
* Reicht der Platz nicht, wird der Wert geopfert, nicht der Name: den
|
||
* Wert kann man an der Achse ablesen, den Namen nirgends.
|
||
*/
|
||
let text = `${name} ${b.n.rssi}`;
|
||
let breite = textWidth(text, FS_LABEL);
|
||
let cx = 0, x1 = 0, x2 = 0;
|
||
let y: number | null = null;
|
||
|
||
for (const fassung of [text, name]) {
|
||
text = fassung;
|
||
breite = textWidth(text, FS_LABEL);
|
||
// waagerecht im Segment halten, damit nichts aus dem Bild läuft
|
||
cx = Math.min(
|
||
PAD_L + plotW - breite / 2 - 1,
|
||
Math.max(PAD_L + breite / 2 + 1, b.mitte),
|
||
);
|
||
x1 = cx - breite / 2;
|
||
x2 = cx + breite / 2;
|
||
|
||
if (frei(wunschY, x1, x2)) { y = wunschY; break; }
|
||
// abwechselnd nach oben und unten suchen — nach oben zuerst, weil
|
||
// dort der Platz über den Blöcken frei ist
|
||
for (let stufe = 1; stufe <= 12; stufe++) {
|
||
const hoch = wunschY - stufe * ZEILE;
|
||
if (frei(hoch, x1, x2)) { y = hoch; break; }
|
||
const runter = wunschY + stufe * ZEILE;
|
||
if (frei(runter, x1, x2)) { y = runter; break; }
|
||
}
|
||
if (y !== null) break;
|
||
}
|
||
if (y === null) {
|
||
ohneLabel++;
|
||
continue;
|
||
}
|
||
|
||
belegteLabel.push({ x1, x2, y });
|
||
const versetzt = Math.abs(y - wunschY) > 2 || Math.abs(cx - b.mitte) > 2;
|
||
labels.push({
|
||
text,
|
||
x: cx,
|
||
y,
|
||
farbe: b.farbe,
|
||
// Zuordnungslinie zum Scheitel des eigenen Blocks, sobald das Label
|
||
// weggerückt werden musste — sonst rät man, welcher Name zu welchem
|
||
// Block gehört.
|
||
leader: versetzt ? { x: b.mitte, y: b.y } : null,
|
||
});
|
||
}
|
||
|
||
return {
|
||
seg,
|
||
plotW,
|
||
bloecke,
|
||
ticks,
|
||
labels,
|
||
ohneLabel,
|
||
leer: nets.length === 0,
|
||
anzahl: nets.length,
|
||
};
|
||
});
|
||
});
|
||
|
||
</script>
|
||
|
||
{#each segmente as s (s.seg.name)}
|
||
<div class="mt-1.5">
|
||
{#if s.leer}
|
||
<!-- Kompaktzeile: kein Netz im Block, also keine dBm-Skala nötig.
|
||
Der Block wird trotzdem gezeigt — er ist die Antwort auf „wo ist
|
||
noch Platz". -->
|
||
<svg viewBox="0 0 {W} {H_LEER}" class="w-full" role="img" aria-label="{s.seg.name}: keine Netze">
|
||
<line x1={PAD_L} y1={26} x2={PAD_L + s.plotW} y2={26} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
|
||
<text x={PAD_L + s.plotW / 2} y={18} text-anchor="middle" class="fill-emerald-400/80" style="font-size: {FS_LABEL}px">
|
||
frei — keine Netze
|
||
</text>
|
||
{#each s.ticks as t (t.channel)}
|
||
<line x1={t.x} y1={26} x2={t.x} y2={26 + (t.label ? 3 : 2)} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
|
||
{#if t.label}
|
||
<text x={t.x} y={38} text-anchor="middle" class="fill-zinc-500" style="font-size: {FS_AXIS - 1}px">{t.channel}</text>
|
||
{/if}
|
||
{/each}
|
||
<text x={PAD_L} y={H_LEER - 2} class="fill-zinc-500" style="font-size: {FS_LABEL}px">
|
||
{s.seg.name}{s.seg.dfs ? ' · DFS' : ''}
|
||
</text>
|
||
</svg>
|
||
{:else}
|
||
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
|
||
<RssiAxis {bereich} />
|
||
|
||
<!-- 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}
|
||
|
||
<!-- Zuordnungslinien: vom weggerückten Namen zum Scheitel seines Blocks.
|
||
In der Farbe des Netzes, damit die Zuordnung auch dann eindeutig
|
||
bleibt, wenn mehrere Linien nebeneinander verlaufen. -->
|
||
{#each s.labels.filter((l) => l.leader != null) as l, i (i)}
|
||
<line
|
||
x1={l.x}
|
||
y1={l.y + 2}
|
||
x2={l.leader?.x}
|
||
y2={l.leader?.y}
|
||
stroke="currentColor"
|
||
class="{l.farbe.text} opacity-40"
|
||
stroke-width="0.6"
|
||
/>
|
||
{/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={grund}
|
||
x2={t.x}
|
||
y2={grund + (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={grund + 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. Unbeschriftete Netze werden ausgewiesen — sonst
|
||
entstünde der Eindruck, es wären weniger Netze als tatsächlich da. -->
|
||
<text x={PAD_L} y={grund + 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'}{s.ohneLabel > 0 ? ` · ${s.ohneLabel} ohne Namen` : ''}
|
||
</text>
|
||
<text x={W - PAD_R} y={grund + 28} text-anchor="end" class="fill-zinc-600" style="font-size: {FS_LABEL}px">
|
||
Kanal
|
||
</text>
|
||
</svg>
|
||
{/if}
|
||
</div>
|
||
{/each}
|