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.
10 KiB
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
weightersetzen, wie esDetailRowbereits macht. - Mindestbreite des Detailbereichs im
SplitterStateverankern, 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/ scaleFactorentfallen.- Kartenhintergrund
#ede9dfhell,#23282ddunkel 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 ausplan.labelFontSizePx(). - Label mittig in der Zelle, in
#63067aauf halbdurchsichtigem Kästchen. - Linien
#e1e7ecbei 48 % dunkel,#46586cbei 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
warningToMyDirectionwerden 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) undCompactCheckbox(:556) statt roher Material-3-Bedienelemente. Die grünen Pillen entstehen, weilTheme.kt:97primaryauf den Grünton des Abend-Stylesheets legt und MaterialButtongenau 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 Subdomainsa/b/csind 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.pngkommentarlos grau zu bleiben. User-Agentbleibt 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
corewird benutzt, nicht geändert:PathAnalysis*,Terrain*,PathGeometryUtils,MapCallsignRawSnapshotBuilder,MaidenheadGridUtils,MaidenheadGridRenderPlanner. GUIstationMapClusteringEnabledbleibt eine Layout-Einstellung unterhalbguiOptionsmit Vorgabewerttrue.- 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.deriveruftcom.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.