diff --git a/src/lib/components/RssiAxis.svelte b/src/lib/components/RssiAxis.svelte index 12d3601..5008773 100644 --- a/src/lib/components/RssiAxis.svelte +++ b/src/lib/components/RssiAxis.svelte @@ -15,51 +15,62 @@ W, PAD_L, PAD_R, - BASELINE, PLOT_TOP, - DB_GRID, - DB_LABELED, + RSSI_MIN, + RSSI_MAX, + PLOT_H, RSSI_GOOD, RSSI_WEAK, FS_AXIS, - rssiToY, + gridFor, + rssiToYIn, + type RssiBereich, } from '$lib/wifi/chart'; - let { zonen = true }: { zonen?: boolean } = $props(); + let { + zonen = true, + bereich = { min: RSSI_MIN, max: RSSI_MAX, hoehe: PLOT_H }, + }: { + zonen?: boolean; + /** sichtbarer dBm-Ausschnitt; Vorgabe ist die volle Skala (Zeitverlauf) */ + bereich?: RssiBereich; + } = $props(); - const yGood = rssiToY(RSSI_GOOD); - const yWeak = rssiToY(RSSI_WEAK); + const grund = $derived(PLOT_TOP + bereich.hoehe); + const grid = $derived(gridFor(bereich)); + // Zonen an den Ausschnitt kappen: liegt die Grenze außerhalb, wird die Zone + // ganz oder gar nicht gezeigt statt über den Rand hinauszulaufen. + const yGood = $derived(rssiToYIn(RSSI_GOOD, bereich)); + const yWeak = $derived(rssiToYIn(RSSI_WEAK, bereich)); {#if zonen} - - + + {/if} -{#each DB_GRID as db (db)} - {@const y = rssiToY(db)} +{#each grid as db (db)} + {@const y = rssiToYIn(db, bereich)} - {#if DB_LABELED.has(db)} - - {db} - - {/if} + + {db} + {/each} @@ -74,4 +85,4 @@ - + diff --git a/src/lib/components/WifiChannelChart.svelte b/src/lib/components/WifiChannelChart.svelte index 3edd4c3..28e88c6 100644 --- a/src/lib/components/WifiChannelChart.svelte +++ b/src/lib/components/WifiChannelChart.svelte @@ -35,8 +35,6 @@ W, PAD_L, PAD_R, - PLOT_W, - BASELINE, PLOT_TOP, FS_AXIS, FS_LABEL, @@ -46,7 +44,8 @@ occupiedRange, thinTicks, textWidth, - rssiToY, + rssiBereich, + rssiToYIn, netColor, kuerzeSsid, } from '$lib/wifi/chart'; @@ -62,20 +61,32 @@ highlightBssid?: string; } = $props(); - /** Höhe: Plot + Kanalbeschriftung + Blockunterschrift */ - const H = BASELINE + 34; /** - * Ein Block ohne Netze braucht keine volle 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. + * 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; @@ -98,7 +109,7 @@ 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); + 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); @@ -114,10 +125,10 @@ isSelf, farbe: netColor(n.bssid, isSelf), pfad: - `M ${xFrom.toFixed(1)},${BASELINE} ` + + `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)},${BASELINE} Z`, + `L ${(xFrom + breite).toFixed(1)},${base} Z`, }; }); @@ -143,14 +154,22 @@ * * 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. + * −65 und −85 dBm. 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. + * 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 @@ -174,31 +193,52 @@ const frei = (y: number, x1: number, x2: number) => y >= PLOT_TOP + 2 && - y <= BASELINE - 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 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), - ); - const x1 = cx - breite / 2; - const x2 = cx + breite / 2; + 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; - 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; } + 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++; @@ -257,7 +297,7 @@ {:else} - + {#each [...s.bloecke].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)} @@ -303,9 +343,9 @@ {#each s.ticks as t (t.channel)} - + {s.seg.name}{s.seg.dfs ? ' · DFS' : ''} · {s.anzahl} {s.anzahl === 1 ? 'Netz' : 'Netze'}{s.ohneLabel > 0 ? ` · ${s.ohneLabel} ohne Namen` : ''} - + Kanal diff --git a/src/lib/components/WifiTimelineChart.svelte b/src/lib/components/WifiTimelineChart.svelte index bab1f84..9227803 100644 --- a/src/lib/components/WifiTimelineChart.svelte +++ b/src/lib/components/WifiTimelineChart.svelte @@ -13,7 +13,7 @@ */ 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'; + import { W, PAD_L, PAD_R, PLOT_TOP, FS_LABEL, rssiBereich, rssiToYIn, netColor, kuerzeSsid } from '$lib/wifi/chart'; let { surveys, @@ -23,7 +23,16 @@ connectedBssid?: string; } = $props(); - const H = BASELINE + 16; + /** + * dBm-Ausschnitt über ALLE Momentaufnahmen hinweg — nicht je Aufnahme. Eine + * mitwandernde Achse würde eine Veränderung vortäuschen, wo keine ist: die + * Linie bliebe waagerecht, obwohl der Pegel gefallen wäre. + */ + const bereich = $derived( + rssiBereich(surveys.flatMap((s) => s.networks.map((n) => n.rssi))), + ); + const grund = $derived(PLOT_TOP + bereich.hoehe); + const H = $derived(grund + 16); const view = $derived.by(() => { const sorted = [...surveys].sort((a, b) => a.createdAt - b.createdAt); @@ -65,7 +74,7 @@ continue; } const px = x(p.ts); - const py = rssiToY(p.rssi); + const py = rssiToYIn(p.rssi, bereich); punkte.push({ x: px, y: py }); current.push(`${px.toFixed(1)},${py.toFixed(1)}`); } @@ -96,7 +105,7 @@ {#if view} - + {#each view.series as s (s.bssid)} {#each s.segments as seg, i (i)} diff --git a/src/lib/wifi/chart.ts b/src/lib/wifi/chart.ts index b568f17..c3244df 100644 --- a/src/lib/wifi/chart.ts +++ b/src/lib/wifi/chart.ts @@ -26,21 +26,95 @@ 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 +/** + * Einheiten je dB. Bestimmt, wie hoch das Diagramm wird — und damit, wieviel + * Platz die Netznamen untereinander haben. + * + * Stand vorher: 2 Einheiten je dB, also 130 Einheiten Plothöhe. Bei einer + * realen Nachbarschaft liegen fast alle Netze zwischen −65 und −85 dBm; das + * sind bei diesem Maßstab nur 40 Einheiten, in denen sich zehn und mehr Namen + * drängen mussten. Genau das hat der Anwender gemeldet („kaum lesbar … muss + * weiter auseinander gezogen werden") — und er hatte recht: die Y-Achse gibt + * vor, wo −50 dBm liegt, man kann den Maßstab also einfach dehnen, ohne dass + * ein einziger Wert falsch wird. Die Achsenbeschriftung wandert mit. + * + * 5 Einheiten je dB → 325 Einheiten Plothöhe (2,5-fach). Aus den 40 Einheiten + * im dichten Bereich werden 100 — Platz für neun Namenszeilen statt dreieinhalb. + * Vorbild ist die WiFi-Analyzer-App, die den Bildschirm vertikal ausnutzt + * statt alles zusammenzuquetschen. + */ +export const DB_SCALE = 5; +export const PLOT_H = (RSSI_MAX - RSSI_MIN) * DB_SCALE; // 325 export const PLOT_TOP = 20; -export const BASELINE = PLOT_TOP + PLOT_H; // 150 +export const BASELINE = PLOT_TOP + PLOT_H; // 345 -/** dBm → y-Koordinate */ +/** + * Sichtbarer dBm-Bereich eines Bandes. + * + * Mit dem gespreizten Maßstab (5 Einheiten je dB) wäre der volle Bereich + * −95…−30 immer 325 Einheiten hoch — auch dann, wenn alle Netze zwischen −55 + * und −80 liegen und die obere Hälfte leer bleibt. Bei vier 5-GHz-Blöcken + * untereinander ergäbe das reine Leerfläche zum Scrollen. + * + * Deshalb umfasst die Achse nur den belegten Bereich, auf 10 dB gerastert und + * mit einer Reserve nach oben und unten. Gerastert deshalb, damit sie sich + * nicht bei jedem RSSI-Zucken verschiebt — zwei Momentaufnahmen desselben + * Standorts bleiben so vergleichbar. Das Vorbild ist die einstellbare Y-Achse + * der WiFi-Analyzer-App. + * + * WICHTIG: Alle Blöcke EINES Bandes teilen sich diesen Bereich, sonst wären + * die Diagramme untereinander nicht vergleichbar. + */ +export interface RssiBereich { + min: number; + max: number; + /** Höhe der Zeichenfläche in Einheiten */ + hoehe: number; +} + +export function rssiBereich(rssis: number[]): RssiBereich { + if (rssis.length === 0) { + // ohne Netze ein schmaler Standardbereich statt der vollen Höhe + return { min: -90, max: -50, hoehe: 40 * DB_SCALE }; + } + const stk = Math.max(...rssis); + const schwach = Math.min(...rssis); + // Oben mindestens 5 dB Luft — dort steht der Name des stärksten Netzes, und + // liegt dessen Pegel zufällig genau auf einer Rasterlinie, hätte er sonst + // keinen Platz. Unten genügt das Abrunden (bis zu 9 dB Reserve). + const max = Math.min(RSSI_MAX, Math.ceil((stk + 5) / 10) * 10); + const min = Math.max(RSSI_MIN, Math.floor((schwach - 3) / 10) * 10); + // mindestens 30 dB zeigen, damit ein einzelnes Netz keine Briefmarke wird + const spanne = Math.max(30, max - min); + return { min: max - spanne, max, hoehe: spanne * DB_SCALE }; +} + +/** dBm → y-Koordinate innerhalb eines Bereichs */ +export function rssiToYIn(rssi: number, b: RssiBereich): number { + const clamped = Math.min(b.max, Math.max(b.min, rssi)); + return PLOT_TOP + b.hoehe - (clamped - b.min) * DB_SCALE; +} + +/** dBm → y-Koordinate auf der festen Vollskala (Zeitverlauf) */ 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) */ +/** Rasterlinien-Werte innerhalb eines Bereichs (alle 10 dB) */ +export function gridFor(b: RssiBereich): number[] { + const out: number[] = []; + for (let v = Math.ceil(b.min / 10) * 10; v <= b.max; v += 10) out.push(v); + return out; +} + +/** + * Rasterlinien alle 10 dB. Seit der Achsenspreizung (DB_SCALE 5) liegen sie + * 50 Einheiten auseinander — genug Platz, um jede zu beschriften. Vorher waren + * es 20 Einheiten und nur jede zweite bekam eine Zahl. + */ export const DB_GRID = [-90, -80, -70, -60, -50, -40, -30]; -export const DB_LABELED = new Set([-90, -70, -50, -30]); +export const DB_LABELED = new Set(DB_GRID); /** Praxis-Schwellen für die farbigen Hintergrundzonen */ export const RSSI_GOOD = -67;