Files
kst4contest/docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md
T
Rsclub2_2 cc8c90204e 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.
2026-10-05 22:33:28 +02:00

10 KiB
Raw Blame History

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.