Draw the whole interface in Compose

Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.

The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.

The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.

Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.

Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.
This commit is contained in:
Rsclub2_2 committed 2026-10-05 22:33:28 +02:00
1 parent a6fba12f01
commit cc8c90204e
173 files changed
+26500 -4305

No files matched your search

@@ -0,0 +1,200 @@
# Umsetzungsplan Etappe 6 — Karte: Angleichung an die Leaflet-Fassung
Spezifikation: `docs/superpowers/specs/2026-09-25-compose-migration-design.md`, Etappe 6.
## Was dieser Plan ist — und was nicht
Etappe 6 hat zwei Hälften: die Karte nach Compose bringen, und JavaFX aus dem Build
werfen. Die erste Hälfte wurde bereits angefangen (im Übergabeprotokoll „Task 18/19"),
und das Ergebnis trägt nicht. Dieser Plan ist die **Nachbesserung dieser ersten
Hälfte**. Das Entfernen von JavaFX und der dafür nötige Ersatz von
`JavaFxStylesheet.derive` bleiben ausdrücklich außen vor.
Der Anlass ist nicht Geschmack. Die Compose-Karte bildet mehrere Mechanismen der
Leaflet-Fassung **gar nicht** ab, nicht bloß anders. Drei davon liegen fertig und
getestet in `core` und werden nicht aufgerufen.
## Beweislage
Verglichen wurde `github_docs/station_map_compact.png` und
`github_docs/station_map_path_analysis.png` (JavaFX) gegen den Stand vom 30.09.
(`scratch/screenshots/AAA011.png`, `AAA012.png`).
| # | Befund | Stelle |
|---|---|---|
| 1 | `MaidenheadGridRenderPlanner` wird nicht aufgerufen — jede Zelle **und jedes Label** wird gezeichnet | `ComposeStationMap.kt:286` gegen `StationMapView.java:1301` |
| 2 | Gitterlabel in der Ecke statt in der Zellenmitte, weiß statt `#63067a` | `ComposeStationMap.kt:307` gegen `MapHtmlResources.java:302` |
| 3 | Gebrochener Zoom mit `scale()` über die ganze Zeichenfläche; alle Größen werden mit `/ scaleFactor` gegengerechnet | `ComposeStationMap.kt:243` |
| 4 | Clustering: 50 px fest, ab 2 Stationen, immer an — statt 55/70/95/125 px je Zoom, ab 3 Stationen, aus ab Zoom 8 | `ComposeStationMap.kt:68` gegen `MapHtmlResources.java:393-410`, `:758` |
| 5 | Kopfzeile: nur „Selected: X" statt der sechs Felder; rohe Material-3-Knöpfe statt `Fields.button` | `StationMapWindow.kt:159` gegen `StationMapView.java:571`, `:1010` |
| 6 | Zoomknöpfe und OSM-Attribution fehlen | — |
| 7 | Kein Kartenhintergrund; fehlende Kacheln zeigen Systemgrau | gegen `MapHtmlResources.java:57`, `:70` |
| 8 | `TileFetcher`: abgekündigte a/b/c-Subdomains, nur RAM-Cache, Fehler werden still verschluckt | `TileFetcher.java:44` |
| 9 | Absturz „maxWidth must be >= than minWidth", übertüncht durch eine Sperre, die das Fenster unter 500×400 dp leer lässt | `StationMapWindow.kt:151`, `:329` |
**Der dritte Befund ist die Wurzel von mehreren anderen.** Weil die gesamte
Zeichenfläche skaliert wird, hängen Punktradius, Strichstärke und Schriftgröße am
Zoom-Bruchteil. In der Leaflet-Fassung war ein Marker **immer** 12 px und ein Label
**immer** 12 px, weil Leaflet Overlays in Bildschirmkoordinaten zeichnet. Solange der
`scale()`-Block steht, ist jede Farb- und Größenkorrektur nur an einer Zoomstufe
richtig — und genau das erklärt, warum mehrere Anläufe nacheinander „fast richtig"
aussahen.
## Getroffene Entscheidungen
| Frage | Entscheidung |
|---|---|
| Zielbild | Nah genug an Leaflet, eigene Handschrift in Details erlaubt — gleiche Informationsdichte und Bedienlogik sind Pflicht |
| Zoom | Weich zoomen, aber **Overlay in Bildschirmkoordinaten**. Nicht der ganzzahlige Zoom der JavaFX-Fassung |
| Absturz | Zuerst reproduzieren und beheben, dann umbauen |
| Kacheln | `TileFetcher` reparieren und um einen Plattencache erweitern |
**Abweichung von der Spezifikation, bewusst.** Die Spezifikation schreibt „Kacheln
über den bestehenden `TileProxyServer`". Der Proxy existiert, weil die JavaFX-WebView
in AppImage- und Flatpak-Verpackung an TLS scheiterte — ein Compose-Canvas holt die
Kacheln selbst und hat dieses Problem nicht. Der Proxy bleibt für den JavaFX-Pfad
stehen, bis dieser entfällt.
**Der ganzzahlige Zoom war ebenfalls ein WebView-Zugeständnis**, nicht Absicht:
`MapHtmlResources.java:884` begründet `zoomSnap: 1` mit unzuverlässiger
Markerpositionierung in der JavaFX-WebView. Der Grund entfällt mit der WebView.
## Reihenfolge
Erst der Absturz, dann die Zeichenebene, dann die Fachregeln, zuletzt die Hülle.
Umgekehrt müsste jeder Schritt gegen einen Fehler arbeiten, der ihn jederzeit
abbrechen kann.
## Aufgaben
### 1. Absturz beheben, Sperre entfernen
`StationMapWindow.kt:328-330` legt in **eine** `Row` ein `Text` mit fester
`Modifier.width(130.dp)` und daneben eine `Box(Modifier.weight(1f))`. Wird das rechte
Pane schmaler als diese 130 dp samt Innenabstand, bleibt für das gewichtete Kind eine
**negative** Restbreite — und daraus baut Compose die `Constraints`, die mit
„maxWidth must be >= than minWidth, maxHeight must be >= than minHeight, minWidth and
minHeight must be >= 0" abbrechen.
Dazu fehlt dem Detailbereich die Mindestbreite, die JavaFX hatte:
`detailScrollPane.setMinWidth(210)` und `setPrefWidth(350)`, `StationMapView.java:468`.
- Reproduzieren: Teiler ganz nach rechts ziehen.
- Feste Breite durch `weight` ersetzen, wie es `DetailRow` bereits macht.
- Mindestbreite des Detailbereichs im `SplitterState` verankern, nicht im Fenster.
- **Erst danach** die Sperre `BoxWithConstraints { if (maxWidth < 500.dp …) return@… }`
in Zeile 151 entfernen.
**Abnahme:** Der Teiler lässt sich über die gesamte Breite ziehen, ohne dass ein
Dialog erscheint, und das Fenster bleibt bei jeder Größe gezeichnet.
### 2. Zeichenebene: Welt- gegen Bildschirmkoordinaten trennen
Eine Projektionshilfe (Zentrum, Zoom als `Float`, Canvasgröße) rechnet den gebrochenen
Zoom über `2^zoom` direkt ein, statt mit `floor(zoom)` zu rechnen und die Zeichenfläche
hinterher zu skalieren.
- Nur die Kachelschicht wird skaliert gezeichnet: Kacheln der Stufe `floor(zoom)`,
Zielgröße über den Skalierungsfaktor.
- Gitter, Marker, Labels, Antennenkeule, Pfadlinie und Kurzhinweis zeichnen
**unskaliert in Bildschirmpixeln**.
- `scale()` und sämtliche `/ scaleFactor` entfallen.
- Kartenhintergrund `#ede9df` hell, `#23282d` dunkel als erste Zeichenoperation.
- Zoombereich 3–18 wie Leaflet; Compose begrenzt heute auf 4–18.
**Abnahme:** Ein Marker behält seine Größe über den gesamten Zoombereich. Bei
abgeschalteten Kacheln ist die Fläche in der Kartenfarbe, nicht in Systemgrau.
### 3. Gitter über den vorhandenen Planer
`MaidenheadGridRenderPlanner.createPlan(floor(zoom), bbox, canvasW, canvasH)` aufrufen,
Zellen über `buildVisibleCells(…, plan.precision())` holen.
- Label nur bei `plan.shouldShowLabel(cell)`, Schriftgröße aus `plan.labelFontSizePx()`.
- Label **mittig** in der Zelle, in `#63067a` auf halbdurchsichtigem Kästchen.
- Linien `#e1e7ec` bei 48 % dunkel, `#46586c` bei 56 % hell, Strichstärke 1.4.
Der Planer ist vorhanden und wird nicht geändert.
**Abnahme:** Bei weitem Zoom Zweistellen-Felder, bei mittlerem die vierstelligen
Quadrate wie in `station_map_compact.png`; keine Labelteppiche.
### 4. Clustering nach den Leaflet-Regeln
`StationMapClusterer` in `core` vom Nächster-Nachbar-Verfahren auf das Bildschirmraster
der Leaflet-Fassung umstellen:
- Zellgröße 55 px ab Zoom 7, 70 px ab 6, 95 px ab 5, darunter 125 px.
- Bucket über `floor(bildschirmX / zellgröße)`.
- Cluster **erst ab 3** Stationen; darunter werden die Stationen einzeln gezeichnet.
- Clustering **aus ab Zoom ≥ 8**.
- Ausgewählte Stationen und solche mit `warningToMyDirection` werden **nie** geclustert.
- Blasendurchmesser 32 / 36 / 42 px ab 1 / 8 / 20 Stationen.
Das sind Fachregeln, keine Zeichenregeln — sie gehören mit Tests nach `core`. Die
Prüfung der Konstanten gegen `MapHtmlResources.java:393-410` und `:758` ist Teil der
Aufgabe.
**Abnahme:** Tests in `core` decken die vier Zellgrößen, die Schwelle von drei
Stationen, die Abschaltung ab Zoom 8 und beide Ausnahmen ab.
### 5. Kopfzeile und Bedienelemente
- Statuszeile mit dem vollen Text aus `updateStatusLabel()`
(`StationMapView.java:1010`): `Showing N visible stations`, optional
`| filtered view active`, `| Selected: …`, Locator, `km / °`, Bänder, QRG.
- Statuszeile mit `Modifier.weight(1f)`, damit die Knöpfe rechtsbündig stehen.
- `Fields.button` (`FormControls.kt:367`) und `CompactCheckbox` (`:556`) statt roher
Material-3-Bedienelemente. Die grünen Pillen entstehen, weil `Theme.kt:97`
`primary` auf den Grünton des Abend-Stylesheets legt und Material `Button` genau
diese Rolle als Flächenfarbe nimmt.
- Zoomknöpfe oben links als Überlagerung.
- OSM-Attribution unten rechts. Die ist bei OSM-Kacheln lizenzrechtlich nicht
freigestellt.
**Abnahme:** Die Kopfzeile zeigt dieselben Angaben wie `station_map_compact.png` und
sieht aus wie die übrigen Compose-Fenster.
### 6. Kacheln
- Host `tile.openstreetmap.org`; die Subdomains `a`/`b`/`c` sind abgekündigt.
- Plattencache im Profilverzeichnis, nicht im Arbeitsverzeichnis.
- Fehler nach außen melden, damit die Karte „Kacheln nicht erreichbar" anzeigen kann,
statt wie in `AAA012.png` kommentarlos grau zu bleiben.
- `User-Agent` bleibt wie bisher; die OSM-Nutzungsbedingungen verlangen einen
sprechenden Wert.
**Abnahme:** Nach einem Neustart ohne Netz zeigt die Karte die zuletzt geholten
Kacheln aus dem Plattencache und einen Hinweis für die fehlenden.
## Was unverändert bleibt
- Der **gesamte JavaFX-Pfad**: `StationMapView.java`, `MapHtmlResources.java`,
`StationMapBridge.java`, `TileProxyServer.java`. Sie sind bis zum Entfernen von
JavaFX die Referenz.
- Die Fachlogik in `core` wird **benutzt, nicht geändert**: `PathAnalysis*`,
`Terrain*`, `PathGeometryUtils`, `MapCallsignRawSnapshotBuilder`,
`MaidenheadGridUtils`, `MaidenheadGridRenderPlanner`.
- `GUIstationMapClusteringEnabled` bleibt eine Layout-Einstellung unterhalb
`guiOptions` mit Vorgabewert `true`.
- Zurücksetzen der Ansicht lässt den Zoom in Ruhe und löscht nur das Ziel (AGENTS.md).
- Die `/cq`-Vorbelegung bei Stationsauswahl.
- Der Zoom auf die Bounding Box zwischen eigenem Standort und Zielstation.
## Abnahmekriterium der Etappe
Die Karte zeigt dieselben Informationen wie die Leaflet-Fassung. Offline-DEM,
Terrain-Pakete und der Profil-Cache funktionieren unverändert.
Das zweite Kriterium der Spezifikation — keine JavaFX-Abhängigkeit mehr im Projekt —
gehört zur zweiten Hälfte der Etappe und ist hier **nicht** erfüllt.
## Offene Punkte
- Leistungsverhalten des Canvas bei mehreren tausend Markern. Die Spezifikation nennt
als Ausweg eine Vorabaggregation im Kern; Schritt 4 ist genau diese Vorstufe und
sollte vor einer Messung fertig sein.
- `JavaFxStylesheet.derive` ruft `com.sun.javafx.util.Utils.deriveColor` über
Reflexion auf. Das muss **vor** dem Entfernen von JavaFX ersetzt werden, sonst
verschieben sich alle abgeleiteten Farben still. Nicht Teil dieses Plans, aber Teil
dieser Etappe.