Eddys Rückmeldung nach dem ersten Einsatz auf dem Handy: das zweite Diagramm
ergibt keinen Sinn, Zahlen überlappen, und die Signalstärke ist in den
Diagrammen nirgends ablesbar. Alle drei Punkte bestätigt und behoben.
Ursache (nachgerechnet, nicht geraten): die 745 MHz des 5-GHz-Bandes wurden
in dieselbe Breite gequetscht wie die 80 MHz des 2,4-GHz-Bandes — 9,3-fach
schlechtere Auflösung, 8,6 statt 80 Einheiten je 20-MHz-Kanal. Bei 8,6
Einheiten Tick-Abstand und 12,6 Einheiten Breite einer dreistelligen Zahl ist
Überlappung unvermeidbar ("3640444852566064"). Der vorhandene Tick-Filter
filterte nachweislich 0 von 25 Ticks weg, weil er nur gegen den
Frequenzbereich prüfte, in dem ohnehin alle Kanäle liegen.
Kanalgraph:
- 5 GHz in die regulatorischen Blöcke geteilt (UNII-1/2A, UNII-2C, UNII-3),
ein gemeinsamer Maßstab je Band -> 20-MHz-Kanal jetzt ~24 statt 8,6
Einheiten breit, alle Kanalzahlen einzeln lesbar.
- Leere Blöcke werden gezeichnet ("frei — keine Netze") statt weggelassen.
Weglassen ließe das Band voller aussehen als es ist und versteckt genau
die gesuchte Antwort: wo ist noch Platz.
- Trapez statt Parabel — die Fläche zeigt den tatsächlich belegten
Frequenzbereich. Bei der Parabel lag nur die Spitze auf dem Messwert; mit
dBm-Achse wäre alles außer der Bogenmitte falsch abgelesen worden.
- Kanalbreite über centerFreq0 statt Primärfrequenz: ein 80-MHz-Netz auf
Kanal 36 wurde sonst um 5180 statt 5210 gezeichnet, Nachbarkanäle sahen
fälschlich frei aus.
- Farbe je Netz (stabil über BSSID-Hash), gleiche Farbe in Graph, Zeitverlauf
und Netzliste. Vorher verschmolzen überlappende graue Blöcke zu einer Fläche.
- Belegte Kanalnummern hell, freie gedämpft; SSID von hinten gekürzt (sonst
heißen alle FRITZ!Box-Netze gleich); gekappte Blöcke werden markiert.
dBm-Achse (neue gemeinsame Komponente RssiAxis, für Kanalgraph UND
Zeitverlauf — damit sie konstruktiv nicht auseinanderlaufen können):
- feste Skala -95..-30 dBm, 2,0 Einheiten je dB (glatte Rasterlinien),
beschriftet -30/-50/-70/-90, Einheit "dBm", Praxiszonen grün/rot.
- Bewusst "Empfangspegel", nicht "Sendestärke": die Achse zeigt den Pegel am
Standort des Handys, nicht die Leistung des Routers.
- Schrift 7 -> 9/10 (war ~7,6 CSS-px, unter der Lesegrenze), Kontrast der
Achsenbeschriftung von zinc-600 (2,29:1, unter WCAG-Minimum) auf zinc-200/300.
Momentaufnahme wird jetzt synchronisiert: sie lag bisher nur lokal und kam nie
im Protokoll auf dem Server an, obwohl sie genau dafür gemacht wird. Landet
jetzt zusätzlich als Messung (tool='wifikanal') in Sync und PDF.
Klartext-Fazit über dem Diagramm ("Dein Netz: Kanal 6 · -48 dBm (sehr gut) ·
2,4 GHz freister Kanal: 1") — unter Zeitdruck schneller als jedes Diagramm.
Neu: tools/render-chart.mjs rendert die echten Svelte-Komponenten
serverseitig zu SVG/PNG, inklusive Randfällen (1 Netz, 25 Netze, 5 Netze auf
einem Kanal, Bandränder, leeres Band, Zeitverlauf mit Aussetzer). Das ist die
Lehre aus dem Fehler: in Phase 4 wurde die Chart-Mathematik nur durchgerechnet
(keine NaN, nichts außerhalb des Zeichenbereichs) und daraus geschlossen, das
genüge. Rechnerisch korrekt heißt nicht lesbar — ein Diagramm muss man ansehen.
Weiterhin offen: der Emulator hat keine WLAN-Hardware und liefert immer 0
Netze. Alle Diagramme sind mit realistischen Testdaten gerendert und geprüft,
aber nicht mit echtem Empfang auf dem Handy.
Details: ROADMAP_UMSETZUNG.md Phase 4 / Nachbesserung
172 lines
9.5 KiB
JavaScript
172 lines
9.5 KiB
JavaScript
/**
|
|
* 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?)');
|
|
}
|
|
}
|