Compare commits

..

No commits in common. "767861046c81038f6135df8c3435acbf6b8b9cf9" and "f671f2605c3150fac2ae042173cb1c281ca87473" have entirely different histories.

5 changed files with 106 additions and 314 deletions

View file

@ -15,62 +15,51 @@
W,
PAD_L,
PAD_R,
BASELINE,
PLOT_TOP,
RSSI_MIN,
RSSI_MAX,
PLOT_H,
DB_GRID,
DB_LABELED,
RSSI_GOOD,
RSSI_WEAK,
FS_AXIS,
gridFor,
rssiToYIn,
type RssiBereich,
rssiToY,
} from '$lib/wifi/chart';
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();
let { zonen = true }: { zonen?: boolean } = $props();
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));
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={Math.max(0, yGood - PLOT_TOP)} class="fill-emerald-500/[0.07]" />
<rect x={PAD_L} y={yWeak} width={W - PAD_L - PAD_R} height={Math.max(0, grund - yWeak)} class="fill-red-500/[0.07]" />
<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 grid as db (db)}
{@const y = rssiToYIn(db, bereich)}
{#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="text-zinc-700"
class={DB_LABELED.has(db) ? 'text-zinc-700' : 'text-zinc-800'}
stroke-width="0.5"
/>
<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 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 -->
@ -85,4 +74,4 @@
</text>
<!-- Grundlinie -->
<line x1={PAD_L} y1={grund} x2={W - PAD_R} y2={grund} stroke="currentColor" class="text-zinc-600" stroke-width="1" />
<line x1={PAD_L} y1={BASELINE} x2={W - PAD_R} y2={BASELINE} stroke="currentColor" class="text-zinc-600" stroke-width="1" />

View file

@ -35,6 +35,8 @@
W,
PAD_L,
PAD_R,
PLOT_W,
BASELINE,
PLOT_TOP,
FS_AXIS,
FS_LABEL,
@ -44,8 +46,7 @@
occupiedRange,
thinTicks,
textWidth,
rssiBereich,
rssiToYIn,
rssiToY,
netColor,
kuerzeSsid,
} from '$lib/wifi/chart';
@ -61,32 +62,20 @@
highlightBssid?: string;
} = $props();
/** Höhe: Plot + Kanalbeschriftung + Blockunterschrift */
const H = BASELINE + 34;
/**
* 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.
* 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.
*/
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;
@ -109,7 +98,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 = rssiToYIn(n.rssi, ber);
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);
@ -125,10 +114,10 @@
isSelf,
farbe: netColor(n.bssid, isSelf),
pfad:
`M ${xFrom.toFixed(1)},${base} ` +
`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)},${base} Z`,
`L ${(xFrom + breite).toFixed(1)},${BASELINE} Z`,
};
});
@ -154,22 +143,14 @@
*
* 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.
* 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.
*
* 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.
* 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
@ -193,52 +174,31 @@
const frei = (y: number, x1: number, x2: number) =>
y >= PLOT_TOP + 2 &&
y <= base - 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 name = kuerzeSsid(b.n.ssid || b.n.bssid.slice(-8));
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 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;
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 === null) {
ohneLabel++;
@ -297,7 +257,7 @@
</svg>
{:else}
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
<RssiAxis {bereich} />
<RssiAxis />
<!-- 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)}
@ -343,9 +303,9 @@
{#each s.ticks as t (t.channel)}
<line
x1={t.x}
y1={grund}
y1={BASELINE}
x2={t.x}
y2={grund + (t.label ? 4 : 2)}
y2={BASELINE + (t.label ? 4 : 2)}
stroke="currentColor"
class={t.belegt ? 'text-zinc-400' : 'text-zinc-600'}
stroke-width="1"
@ -353,7 +313,7 @@
{#if t.label}
<text
x={t.x}
y={grund + 14}
y={BASELINE + 14}
text-anchor="middle"
class={t.belegt ? 'fill-zinc-200' : 'fill-zinc-500'}
style="font-size: {FS_AXIS}px"
@ -365,11 +325,11 @@
<!-- 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">
<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.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">
<text x={W - PAD_R} y={BASELINE + 28} text-anchor="end" class="fill-zinc-600" style="font-size: {FS_LABEL}px">
Kanal
</text>
</svg>

View file

@ -13,7 +13,7 @@
*/
import type { WifiSurvey } from '$lib/types';
import RssiAxis from './RssiAxis.svelte';
import { W, PAD_L, PAD_R, PLOT_TOP, FS_LABEL, rssiBereich, rssiToYIn, netColor, kuerzeSsid } from '$lib/wifi/chart';
import { W, PAD_L, PAD_R, BASELINE, FS_LABEL, rssiToY, netColor, kuerzeSsid } from '$lib/wifi/chart';
let {
surveys,
@ -23,16 +23,7 @@
connectedBssid?: string;
} = $props();
/**
* 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 H = BASELINE + 16;
const view = $derived.by(() => {
const sorted = [...surveys].sort((a, b) => a.createdAt - b.createdAt);
@ -74,7 +65,7 @@
continue;
}
const px = x(p.ts);
const py = rssiToYIn(p.rssi, bereich);
const py = rssiToY(p.rssi);
punkte.push({ x: px, y: py });
current.push(`${px.toFixed(1)},${py.toFixed(1)}`);
}
@ -105,7 +96,7 @@
{#if view}
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Empfangspegel über die Momentaufnahmen">
<RssiAxis {bereich} />
<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} />

View file

@ -26,95 +26,21 @@ 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. 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
/** 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; // 345
export const BASELINE = PLOT_TOP + PLOT_H; // 150
/**
* Sichtbarer dBm-Bereich eines Bandes.
*
* Mit dem gespreizten Maßstab (5 Einheiten je dB) wäre der volle Bereich
* 9530 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) */
/** 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-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.
*/
/** 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(DB_GRID);
export const DB_LABELED = new Set([-90, -70, -50, -30]);
/** Praxis-Schwellen für die farbigen Hintergrundzonen */
export const RSSI_GOOD = -67;

View file

@ -20,7 +20,6 @@
saveScan,
deleteScan,
} from '$lib/protocols';
import { scanner } from '$lib/scanner';
import { resumeFinishedStressSessions } from '$lib/stresstest';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
@ -40,20 +39,6 @@
let saveScanOpen = $state(false);
let expandedScan = $state<string | null>(null);
let deleteScanId = $state<string | null>(null);
/** Kopfdaten sind zugeklappt, solange nichts eingetragen ist (siehe Kommentar im Markup) */
let kopfdatenOffen = $state(false);
/** vom Adapter erkanntes Subnetz — als antippbarer Vorschlag im Eingabefeld */
let erkanntesSubnetz = $state('');
/** Menü hinter den drei Punkten (selten gebrauchte, gefährliche Aktionen) */
let ueberlaufOffen = $state(false);
/** Kompakte Zusammenfassung für die zugeklappte Kopfzeile */
const kopfZusammenfassung = $derived(
[protocol?.location, protocol?.subnet, protocol?.note?.split('\n')[0]]
.map((t) => (t ?? '').trim())
.filter(Boolean)
.join(' · '),
);
let appStateListener: PluginListenerHandle | null = null;
@ -117,19 +102,6 @@
await persist();
}
// Kopfdaten offen lassen, solange noch nichts eingetragen ist — dann ist
// das Ausfüllen der nächste sinnvolle Schritt. Sind sie gefüllt, bleibt
// der Platz für die Werkzeuge frei.
kopfdatenOffen = !(p.location || p.subnet || p.note);
// Erkanntes Subnetz als Eingabevorschlag bereitstellen (best effort)
try {
const local = await scanner.getLocalSubnet();
erkanntesSubnetz = String(local.subnet ?? '').trim();
} catch {
/* kein Adapter aktiv — dann gibt es eben keinen Vorschlag */
}
// App wechselt in den Hintergrund (anderer App-Wechsel, Display aus) →
// sofort sichern, bevor Android den Prozess evtl. beendet.
appStateListener = await App.addListener('appStateChange', ({ isActive }) => {
@ -312,81 +284,35 @@
/>
<div class="flex-1 overflow-y-auto pb-24">
<!--
Kopfdaten eingeklappt, Werkzeuge zuerst.
Vor Ort wird zuerst gemessen und erst danach (wenn überhaupt) beschriftet.
Vorher füllten Standort/Netzbereich/Notiz den ganzen ersten Bildschirm und
man musste an ihnen vorbeiscrollen, um überhaupt an ein Werkzeug zu
kommen. Sind bereits Daten eingetragen, zeigt die Kopfzeile sie kompakt.
-->
<section class="border-b border-zinc-800">
<button
class="flex w-full items-center gap-2 px-3 py-2.5 text-left active:bg-zinc-900"
onclick={() => (kopfdatenOffen = !kopfdatenOffen)}
>
<Icons.FileText size={15} class="shrink-0 text-zinc-500" />
<span class="min-w-0 flex-1 text-xs text-zinc-400">
{#if kopfZusammenfassung}
<span class="text-zinc-300">{kopfZusammenfassung}</span>
{:else}
Standort, Netzbereich, Notiz
{/if}
</span>
<Icons.ChevronDown
size={16}
class="shrink-0 text-zinc-500 {kopfdatenOffen ? 'rotate-180' : ''}"
<!-- Stammdaten -->
<section class="flex flex-col gap-2 border-b border-zinc-800 p-3">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Standort
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.location}
onblur={persist}
placeholder="Gebäude / Raum"
/>
</button>
{#if kopfdatenOffen}
<div class="flex flex-col gap-2 px-3 pb-3">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Standort
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.location}
onblur={persist}
placeholder="Gebäude / Raum"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Netzbereich
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.subnet}
onblur={persist}
placeholder="192.168.1.0/24"
inputmode="decimal"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
<!-- Vorschlag aus dem tatsächlich erkannten Netz: spart das
fehleranfällige Eintippen einer CIDR-Angabe auf dem Handy -->
{#if erkanntesSubnetz && erkanntesSubnetz !== protocol.subnet}
<button
class="mt-0.5 w-fit rounded bg-zinc-800 px-2 py-1 text-[11px] text-sky-300 active:bg-zinc-700"
onclick={() => {
if (protocol) protocol.subnet = erkanntesSubnetz;
void persist();
}}
>
{erkanntesSubnetz} übernehmen
</button>
{/if}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Notiz
<textarea
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
rows="2"
bind:value={protocol.note}
onblur={persist}
></textarea>
</label>
</div>
{/if}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Netzbereich
<input
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={protocol.subnet}
onblur={persist}
placeholder="192.168.1.0/24"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
Notiz
<textarea
class="rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
rows="2"
bind:value={protocol.note}
onblur={persist}
></textarea>
</label>
</section>
<!-- Werkzeuge -->