Kanalgraph bei vielen Netzen lesbar: Namen entzerrt, Netzauswahl

Eddys Meldung: bei zu vielen Netzen überschreiben sich die Namen; die Netze
müssten "von der Signalstärke nach oben weiter auseinander gezogen werden".
Mit 15 Netzen (Mehrfamilienhaus) nachgestellt und bestätigt.

Ursache: die Wunschhöhe eines Labels hängt am Messwert, und reale Nachbarnetze
liegen fast alle zwischen -65 und -85 dBm — in einem Band von nur ~40 Einheiten
drängten sich zehn und mehr Namen.

Die BLÖCKE lassen sich dafür aber nicht verschieben: ihre Höhe IST der
Messwert. Ein Netz mit -70 dBm auf Höhe -50 zu zeichnen hätte die dBm-Achse
zur Falschaussage gemacht — genau der Fehler, der beim Umbau von Parabel auf
Trapez behoben wurde. Verschoben wird deshalb nur der NAME:

- Vom Wunschplatz aus wird abwechselnd nach oben und unten die nächste freie
  Zeile gesucht (bis 12 Stufen). Zwei Namen stören sich nur, wenn sie sich
  auch waagerecht überlappen — nebeneinanderliegende Netze behalten ihre Höhe.
  Vorher: nur 4 Versuche nach oben, danach fiel das Label ersatzlos weg.
- Eine dünne Linie in der Netzfarbe führt vom versetzten Namen zum Scheitel
  seines Blocks, damit die Zuordnung eindeutig bleibt.
- Findet ein Name gar keinen Platz, wird das ausgewiesen ("15 Netze · 3 ohne
  Namen") statt stillschweigend wegzufallen.

Netzauswahl (Eddys Vorschlag): Kästchen je Netz in der Liste, dazu die
Schnellwahl "nur starke" (eigenes Netz + alles über -70 dBm) und "alle".
Ein Hinweisband nennt die Zahl der ausgeblendeten Netze und bietet
"Alle zeigen" an.

WICHTIG dabei: Das Ausblenden betrifft ausschließlich die Zeichnung.
Kanalempfehlung und Warnregeln rechnen weiter mit ALLEN sichtbaren Netzen —
sonst würde das Ausblenden eines starken Störers dessen Kanal plötzlich als
"frei" empfehlen. Im Emulator gegengeprüft: nach "nur starke" (11 von 18
ausgeblendet) blieben die Empfehlungswerte 0,83 / 1,33 / 1,38 unverändert.

Demodaten auf 18 Netze erweitert und ein Render-Szenario "dicht24" (15 Netze)
ergänzt, damit sich diese Dichte auch ohne echten Empfang prüfen lässt.
Gerendert, angesehen und zusätzlich im Emulator auf dem Gerät geprüft.
This commit is contained in:
Eduard Wisch 2026-08-16 17:29:18 +02:00
parent 295ccb2e62
commit d44c002078
4 changed files with 232 additions and 37 deletions

View file

@ -138,17 +138,47 @@
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).
/*
* 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 — also in einem Band von nur ~40 Einheiten. Dort
* drängeln sich bei einer Wohnhaus-Nachbarschaft zehn und mehr Namen
* übereinander und werden unlesbar.
*
* Die Blöcke dürfen dabei NICHT verschoben werden — ihre Höhe IST der
* Messwert, sie zu spreizen würde die dBm-Achse zur Falschaussage
* machen. Verschoben wird ausschließlich die Beschriftung; eine dünne
* Zuordnungslinie führt 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: number | null;
leader: { x: number; y: number } | null;
}[] = [];
let ohneLabel = 0;
const frei = (y: number, x1: number, x2: number) =>
y >= PLOT_TOP + 2 &&
y <= BASELINE - 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 text = kuerzeSsid(b.n.ssid || b.n.bssid.slice(-8));
const breite = textWidth(text, FS_LABEL);
@ -157,33 +187,48 @@
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++;
const x1 = cx - breite / 2;
const x2 = cx + breite / 2;
const wunschY = b.y - 4;
let y: number | null = frei(wunschY, x1, x2) ? wunschY : null;
// abwechselnd nach oben und unten suchen — nach oben zuerst, weil
// dort der Platz über den Blöcken frei ist
for (let stufe = 1; y === null && 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 < PLOT_TOP - 2) continue; // kein Platz mehr lieber weglassen
belegteLabel.push({ x1: cx - breite / 2, x2: cx + breite / 2, y });
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, 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,
// 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, leer: nets.length === 0, anzahl: nets.length };
return {
seg,
plotW,
bloecke,
ticks,
labels,
ohneLabel,
leer: nets.length === 0,
anzahl: nets.length,
};
});
});
@ -225,16 +270,18 @@
{/if}
{/each}
<!-- Zuordnungslinien zu weggerückten Beschriftungen -->
<!-- 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}
y2={l.y + 6}
x2={l.leader?.x}
y2={l.leader?.y}
stroke="currentColor"
class="text-zinc-600"
stroke-width="0.5"
class="{l.farbe.text} opacity-40"
stroke-width="0.6"
/>
{/each}
@ -276,10 +323,11 @@
{/if}
{/each}
<!-- Blockunterschrift -->
<!-- Blockunterschrift. Unbeschriftete Netze werden ausgewiesen — sonst
entstünde der Eindruck, es wären weniger Netze als tatsächlich da. -->
<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'}
{s.anzahl === 1 ? 'Netz' : 'Netze'}{s.ohneLabel > 0 ? ` · ${s.ohneLabel} ohne Namen` : ''}
</text>
<text x={W - PAD_R} y={BASELINE + 28} text-anchor="end" class="fill-zinc-600" style="font-size: {FS_LABEL}px">
Kanal

View file

@ -89,6 +89,33 @@ export const DEMO_NETWORKS: WifiNetwork[] = [
rssi: -68, band: '5 GHz', widthMhz: 160, centerFreq0: 5775,
standard: 'Wi-Fi 6 (802.11ax)', security: 'WPA2', ageMs: 1800,
},
// Weitere Nachbarn — erst mit dieser Dichte zeigt sich, ob Beschriftung und
// Netzauswahl im Diagramm wirklich tragen. In einem Mehrfamilienhaus oder
// Gewerbegebiet sind 15 sichtbare Netze der Normalfall, nicht die Ausnahme.
{
ssid: 'Telekom_FMK_5EA1', bssid: 'DD:EE:FF:30:40:08', channel: 1, frequency: 2412,
rssi: -71, band: '2.4 GHz', widthMhz: 20, standard: 'Wi-Fi 5 (802.11ac)', security: 'WPA2', ageMs: 4800,
},
{
ssid: 'WLAN-Gast-Praxis', bssid: 'DD:EE:FF:30:40:09', channel: 1, frequency: 2412,
rssi: -74, band: '2.4 GHz', widthMhz: 20, security: 'offen', ageMs: 5300,
},
{
ssid: 'EasyBox-99AF', bssid: 'DD:EE:FF:30:40:0A', channel: 6, frequency: 2437,
rssi: -76, band: '2.4 GHz', widthMhz: 20, standard: 'Wi-Fi 4 (802.11n)', security: 'WPA2', ageMs: 6100,
},
{
ssid: 'devolo-1122', bssid: 'DD:EE:FF:30:40:0B', channel: 7, frequency: 2442,
rssi: -72, band: '2.4 GHz', widthMhz: 20, standard: 'Wi-Fi 4 (802.11n)', security: 'WPA2', ageMs: 3700,
},
{
ssid: 'Kabel-Box-2231', bssid: 'DD:EE:FF:30:40:0C', channel: 11, frequency: 2462,
rssi: -66, band: '2.4 GHz', widthMhz: 20, standard: 'Wi-Fi 5 (802.11ac)', security: 'WPA2', ageMs: 2400,
},
{
ssid: 'HUAWEI-B535-A1', bssid: 'DD:EE:FF:30:40:0D', channel: 11, frequency: 2462,
rssi: -75, band: '2.4 GHz', widthMhz: 20, standard: 'Wi-Fi 5 (802.11ac)', security: 'WPA2', ageMs: 5600,
},
];
/**

View file

@ -11,7 +11,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { ChevronDown, RefreshCw, Camera, MapPin } from 'lucide-svelte';
import { ChevronDown, RefreshCw, Camera, MapPin, EyeOff } from 'lucide-svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import WifiChannelChart from '$lib/components/WifiChannelChart.svelte';
import WifiTimelineChart from '$lib/components/WifiTimelineChart.svelte';
@ -61,6 +61,47 @@
return list;
});
/*
* Netzauswahl fürs Diagramm.
*
* In einer Wohnhaus-Nachbarschaft sind 15 und mehr Netze sichtbar; das
* Diagramm wird dann selbst mit entzerrten Beschriftungen voll. Hier kann
* man einzelne Netze ausblenden, um sich auf die interessanten zu
* beschränken.
*
* WICHTIG: Das Ausblenden betrifft NUR die Zeichnung. Kanalempfehlung und
* Warnregeln rechnen weiterhin mit ALLEN sichtbaren Netzen — sonst würde
* das Ausblenden eines starken Störers plötzlich dessen Kanal als „frei"
* empfehlen. Genau diese Falle wäre der teuerste Fehler an dieser Stelle.
*
* `versteckt` enthält die abgewählten BSSIDs; Vorgabe ist „alles sichtbar".
*/
let versteckt = $state<Set<string>>(new Set());
const sichtbareNetze = $derived(networks.filter((n) => !versteckt.has(n.bssid)));
const anzahlVersteckt = $derived(networks.length - sichtbareNetze.length);
function toggleNetz(bssid: string) {
const s = new Set(versteckt);
if (s.has(bssid)) s.delete(bssid);
else s.add(bssid);
versteckt = s;
}
/** Alle einblenden */
function alleZeigen() {
versteckt = new Set();
}
/** Nur das eigene Netz und die stärksten Störer zeigen */
function nurStarke() {
const grenze = -70;
versteckt = new Set(
networks.filter((n) => n.bssid !== connectedBssid && n.rssi < grenze).map((n) => n.bssid),
);
}
// Empfehlung und Warnungen bewusst auf ALLEN Netzen, nicht auf der Auswahl
const foreignNetworks = $derived(networks.filter((n) => n.bssid !== connectedBssid));
const recommendation = $derived(recommend24GhzChannels(foreignNetworks));
const warnings = $derived(channelWarnings(networks));
@ -338,11 +379,31 @@
</div>
{/if}
{#if anzahlVersteckt > 0}
<div class="mt-2 flex items-center gap-2 rounded-lg bg-sky-900/30 p-2 text-xs text-sky-300">
<EyeOff size={14} class="shrink-0" />
<span class="flex-1">
{anzahlVersteckt} von {networks.length} Netzen ausgeblendet — Kanalempfehlung und
Hinweise rechnen weiterhin mit allen.
</span>
<button
class="shrink-0 rounded bg-sky-800/60 px-2 py-1 font-medium active:bg-sky-800"
onclick={alleZeigen}
>
Alle zeigen
</button>
</div>
{/if}
{#if bandsPresent.length > 0}
{#each bandsPresent as b (b)}
<div class="mt-3 rounded-lg bg-zinc-900 p-2.5">
<p class="mb-1 text-xs font-semibold text-zinc-300">{b}</p>
<WifiChannelChart networks={networks.filter((n) => n.band === b)} band={b} highlightBssid={connectedBssid ?? undefined} />
<WifiChannelChart
networks={sichtbareNetze.filter((n) => n.band === b)}
band={b}
highlightBssid={connectedBssid ?? undefined}
/>
</div>
{/each}
{:else if !scanning && !errorMsg}
@ -356,7 +417,23 @@
</div>
{/if}
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Netze ({groups.length})</h2>
<div class="mb-1 mt-4 flex items-center gap-2">
<h2 class="flex-1 text-sm font-semibold text-zinc-300">Netze ({groups.length})</h2>
<!-- Schnellauswahl: in einer dichten Nachbarschaft will man meist nur
das eigene Netz und die wirklich störenden Nachbarn im Diagramm -->
<button
class="rounded bg-zinc-800 px-2 py-1 text-[11px] text-zinc-300 active:bg-zinc-700"
onclick={nurStarke}
>
nur starke
</button>
<button
class="rounded bg-zinc-800 px-2 py-1 text-[11px] text-zinc-300 active:bg-zinc-700"
onclick={alleZeigen}
>
alle
</button>
</div>
<div class="flex flex-col gap-1.5">
{#each groups as g (g.ssid)}
<div class="rounded-lg bg-zinc-900 p-2.5">
@ -369,19 +446,29 @@
{#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">
{@const sichtbar = !versteckt.has(n.bssid)}
<label class="mt-1 flex items-center justify-between gap-2 text-[11px] text-zinc-400">
<span class="flex min-w-0 items-center gap-1.5">
<!-- Kästchen steuert ausschließlich die Zeichnung im Diagramm -->
<input
type="checkbox"
class="h-3.5 w-3.5 shrink-0 accent-sky-500"
checked={sichtbar}
onchange={() => toggleNetz(n.bssid)}
/>
<!-- 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">
<span
class="h-1.5 w-1.5 shrink-0 rounded-full {farbe.dot} {sichtbar ? '' : 'opacity-30'}"
></span>
<span class="{sichtbar ? '' : 'opacity-50'} min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
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>
</label>
{/each}
</div>
{/each}

View file

@ -90,6 +90,26 @@ const SZENARIEN = [
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 })) },
{ name: 'dicht24', band: '2.4 GHz', self: 'FF:00:01',
nets: [
['FRITZ!Box 7590 AX', 1, 2412, -48, 20],
['Telekom_FMK_5EA1', 1, 2412, -71, 20],
['WLAN-Gast-Praxis', 1, 2412, -74, 20],
['o2-WLAN-8812', 2, 2417, -69, 20],
['Vodafone-4C2A', 5, 2432, -73, 20],
['FRITZ!Box Gastzugang', 6, 2437, -52, 20],
['Nachbar-oben', 6, 2437, -70, 20],
['EasyBox-99AF', 6, 2437, -76, 20],
['devolo-1122', 7, 2442, -72, 20],
['TP-LINK_5G_ext', 9, 2452, -78, 20],
['Kabel-Box-2231', 11, 2462, -66, 20],
['Praxis-Intern', 11, 2462, -72, 20],
['HUAWEI-B535-A1', 11, 2462, -75, 20],
['Repeater-Flur', 11, 2462, -79, 20],
['Gaeste-WLAN-Cafe', 13, 2472, -81, 20],
].map(([ssid, ch, f, rssi, w], i) => ({
ssid, bssid: `FF:00:${String(i + 1).padStart(2, '0')}`, channel: ch,
frequency: f, rssi, band: '2.4 GHz', widthMhz: w })) },
];
const FARBEN = [
@ -112,8 +132,21 @@ const FARBEN = [
[/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'],
// Zuordnungslinien nutzen die TEXT-Farbe des Netzes als stroke (currentColor)
[/opacity-40/, 'opacity:.4'],
];
// Fuer <line class="fill-xxx-300 opacity-40"> muss die Textfarbe als stroke wirken
function lineStroke(svg) {
return svg.replace(/<line([^>]*?)style="([^"]*)"([^>]*)>/g, (m, a, st, b) => {
const f = st.match(/fill:(#[0-9a-f]{6})/i);
if (f && /stroke="currentColor"/.test(m)) {
return `<line${a}style="${st};stroke:${f[1]}"${b}>`;
}
return m;
});
}
function farbig(svg) {
return svg.replace(/class="([^"]*)"/g, (_m, cls) => {
const styles = FARBEN.filter(([re]) => re.test(cls)).map(([, v]) => v).join(';');
@ -135,7 +168,7 @@ for (const s of SZENARIEN) {
let yOff = 0;
const teile = svgs.map((sv, i) => {
const g = `<g transform="translate(0,${yOff})">` +
farbig(sv).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '') + `</g>`;
lineStroke(farbig(sv)).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '') + `</g>`;
yOff += hoehen[i];
return g;
});