From 2099f187a72d14136d672e2a8ad30d6afcaa2fde Mon Sep 17 00:00:00 2001 From: Peter Buchmann Date: Sun, 11 Oct 2026 19:39:33 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Leitstand,=20gesicherter=20Plan,=20Start/Pa?= =?UTF-8?q?use/Stopp=20und=20=C3=BCberarbeitete=20Spielplandaten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Leitstand (vorher Maschinenraum): Inseln, Schaubild mit Verbesserungsmeldungen je grünem Punkt, einheitliche Zählung mit der Kachel, Suchverlauf - Gesicherter Plan: automatische Sicherung beim Neustart/Schließen mit Planvergleich und Rückfrage vor dem Überschreiben - Kopfzeile: Play/Pause und Stopp/Schließen, Verfahren als Segmentknöpfe - Spielplandaten: Kalender der spielfreien Tage mit Grund, Wunschtermine mit Kärtchen, Drag & Drop und Heimspielvorschlag, Setzliste per Ziehen, einheitliche Seitenknöpfe, Änderungspunkte nur auf bearbeiteten Seiten - Deutsche Platzhalter in Zeit- und Datumsauswahl, Rundenfrage nennt den Rückrundenbeginn - MIGRATIONSPLAN: Checkliste für Version 1.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPXzdFCpdtwdYcyQVmsnhH --- .gitignore | 1 + Doku/ANLEITUNG.md | 76 +-- Doku/MIGRATIONSPLAN.md | 65 ++- inselvergleich.cmd | 21 + .../Datenbearbeitung.SpielfreieTage.cs | 8 + .../Optimierungsdienst.cs | 10 +- .../Optimierungsstand.cs | 4 +- src/AndiGenerator.Application/Plansitzung.cs | 23 + src/AndiGenerator.Engine/Inseln/Automodus.cs | 179 ++++++- .../Inseln/Gewichtsanhebung.cs | 14 + src/AndiGenerator.Engine/Inseln/Insel.cs | 5 + .../Inseln/Inseloptimierer.cs | 186 +++++++- src/AndiGenerator.Engine/Inseln/Inselstand.cs | 38 ++ .../Inseln/Kostenverursacher.cs | 14 + .../Inseln/Kriterienwert.cs | 14 + .../Inseln/Lenkungsstand.cs | 4 +- .../Inseln/Maschinenraum.cs | 26 + .../Inseln/Optimierungseinstellungen.cs | 24 +- .../Referenz/Tauschstrategie.cs | 14 + .../Gemeinsam/Staffelordner.cs | 10 + .../DatenSeiteViewModel.cs | 37 +- .../Generierungsanzeige.cs | 116 +++++ .../Gewichtsgruppe.cs | 11 + .../Gewichtssegment.cs | 15 + .../Gewichtszeile.cs | 11 + .../HauptfensterViewModel.cs | 281 +++++++++-- .../HeimkoppelnSeiteViewModel.cs | 8 +- .../HeimrechtZeile.cs | 45 +- .../IOberflaeche.cs | 7 + .../Inselanzeige.cs | 131 +++++ .../Inselzustand.cs | 11 + .../Kalendermonat.cs | 10 + src/AndiGenerator.Presentation/Kalendertag.cs | 20 + src/AndiGenerator.Presentation/Kernanzeige.cs | 83 ++++ .../Kostentreiberzeile.cs | 11 + .../LigadatenSeiteViewModel.cs | 6 + .../MannschaftenSeiteViewModel.cs | 4 + .../MaschinenraumAnsichtViewModel.cs | 278 +++++++++++ .../MehrMannschaftenSeiteViewModel.cs | 2 +- .../PflichtspieltageSeiteViewModel.cs | 4 + src/AndiGenerator.Presentation/PlanQuelle.cs | 11 +- .../PlanQuellenArt.cs | 3 + .../Planaenderung.cs | 10 + .../Planvergleich.cs | 102 ++++ src/AndiGenerator.Presentation/Schaubild.cs | 318 ++++++++++++ .../Schaubildereignis.cs | 21 + .../Schaubildimpuls.cs | 11 + .../SetzlistenSeiteViewModel.cs | 109 +++++ .../Setzlistenzeile.cs | 16 + .../Sicherungswahl.cs | 18 + .../SpielfreieTageSeiteViewModel.cs | 74 ++- .../SpiellokaleSeiteViewModel.cs | 2 +- src/AndiGenerator.Presentation/Suchverlauf.cs | 89 ++++ .../Verbesserungsmeldung.cs | 11 + .../Vergleichszeile.cs | 13 + .../VerlaufAnsichtViewModel.cs | 26 + .../Verlaufspunkt.cs | 16 + .../VorgabespieleSeiteViewModel.cs | 4 + .../WochenendeSeiteViewModel.cs | 2 +- .../WunschterminDetailViewModel.cs | 50 +- .../WunschterminDialogViewModel.cs | 32 ++ .../WunschterminZelle.cs | 116 ++++- .../WunschtermineSeiteViewModel.cs | 9 +- .../ZweitzeitZeile.cs | 3 + .../Ansichten/MaschinenraumAnsicht.axaml | 234 +++++++++ .../Ansichten/MaschinenraumAnsicht.axaml.cs | 14 + .../Ansichten/VerlaufAnsicht.axaml | 13 + .../Ansichten/VerlaufAnsicht.axaml.cs | 14 + src/AndiGenerator.UI/AnsichtenLocator.cs | 4 +- src/AndiGenerator.UI/App.axaml.cs | 1 + src/AndiGenerator.UI/AvaloniaOberflaeche.cs | 4 + src/AndiGenerator.UI/Datendialog.cs | 45 +- src/AndiGenerator.UI/HauptfensterView.axaml | 148 +++--- .../HauptfensterView.axaml.cs | 21 + src/AndiGenerator.UI/Konverter.cs | 8 + src/AndiGenerator.UI/Maschinenschaubild.cs | 451 ++++++++++++++++++ src/AndiGenerator.UI/Platzhalterdeutsch.cs | 38 ++ .../Seiten/AuswaertskoppelDetail.axaml | 50 +- .../Seiten/AuswaertskoppelDetail.axaml.cs | 10 +- .../Seiten/HeimrechtDetail.axaml | 9 +- .../Seiten/LigadatenSeite.axaml | 14 +- .../Seiten/Listenbedienung.cs | 48 ++ .../Seiten/MannschaftenSeite.axaml | 12 +- .../Seiten/MannschaftenSeite.axaml.cs | 10 +- .../Seiten/MehrMannschaftenSeite.axaml | 2 +- .../Seiten/NachbarmannschaftDetail.axaml | 12 +- .../Seiten/NachbarmannschaftDetail.axaml.cs | 10 +- .../Seiten/PflichtspieltageSeite.axaml | 74 +-- .../Seiten/PflichtspieltageSeite.axaml.cs | 10 +- .../Seiten/SetzlistenSeite.axaml | 103 +++- .../Seiten/SetzlistenSeite.axaml.cs | 106 ++-- .../Seiten/SpielfreieTageSeite.axaml | 119 ++++- .../Seiten/SpielfreieTageSeite.axaml.cs | 52 +- .../Seiten/SpiellokalDetail.axaml | 27 +- .../Seiten/VorgabespieleSeite.axaml | 12 +- .../Seiten/VorgabespieleSeite.axaml.cs | 10 +- .../Seiten/WunschterminDetail.axaml | 178 +++++-- .../Seiten/WunschterminDetail.axaml.cs | 126 ++++- src/AndiGenerator.UI/Sicherungsdialog.axaml | 65 +++ .../Sicherungsdialog.axaml.cs | 32 ++ src/AndiGenerator.UI/Stil.axaml | 96 ++-- src/AndiGenerator.UI/Verlaufsdiagramm.cs | 398 ++++++++++++++++ src/AndiGenerator.UI/Wunschtermindialog.axaml | 9 +- .../InseloptimiererTests.cs | 65 +++ .../DatendialogVerhaltenTests.cs | 4 +- .../DialogeTests.cs | 132 +++++ .../GenerierungsanzeigeTests.cs | 56 +++ .../HauptfensterTests.cs | 177 ++++++- .../SchaubildTests.cs | 190 ++++++++ .../TestOberflaeche.cs | 12 + .../AndiGenerator.UI.Tests/Bildschirmfotos.cs | 16 +- tests/AndiGenerator.UI.Tests/DialogTests.cs | 6 +- .../HauptfensterTests.cs | 2 + .../AndiGenerator.Cli/OptimierenArgumente.cs | 6 + tools/AndiGenerator.Cli/OptimierenBefehl.cs | 1 + 115 files changed, 5645 insertions(+), 504 deletions(-) create mode 100644 inselvergleich.cmd create mode 100644 src/AndiGenerator.Engine/Inseln/Gewichtsanhebung.cs create mode 100644 src/AndiGenerator.Engine/Inseln/Inselstand.cs create mode 100644 src/AndiGenerator.Engine/Inseln/Kostenverursacher.cs create mode 100644 src/AndiGenerator.Engine/Inseln/Kriterienwert.cs create mode 100644 src/AndiGenerator.Engine/Inseln/Maschinenraum.cs create mode 100644 src/AndiGenerator.Presentation/Gewichtsgruppe.cs create mode 100644 src/AndiGenerator.Presentation/Gewichtssegment.cs create mode 100644 src/AndiGenerator.Presentation/Gewichtszeile.cs create mode 100644 src/AndiGenerator.Presentation/Inselanzeige.cs create mode 100644 src/AndiGenerator.Presentation/Inselzustand.cs create mode 100644 src/AndiGenerator.Presentation/Kalendermonat.cs create mode 100644 src/AndiGenerator.Presentation/Kalendertag.cs create mode 100644 src/AndiGenerator.Presentation/Kernanzeige.cs create mode 100644 src/AndiGenerator.Presentation/Kostentreiberzeile.cs create mode 100644 src/AndiGenerator.Presentation/MaschinenraumAnsichtViewModel.cs create mode 100644 src/AndiGenerator.Presentation/Planaenderung.cs create mode 100644 src/AndiGenerator.Presentation/Planvergleich.cs create mode 100644 src/AndiGenerator.Presentation/Schaubild.cs create mode 100644 src/AndiGenerator.Presentation/Schaubildereignis.cs create mode 100644 src/AndiGenerator.Presentation/Schaubildimpuls.cs create mode 100644 src/AndiGenerator.Presentation/Setzlistenzeile.cs create mode 100644 src/AndiGenerator.Presentation/Sicherungswahl.cs create mode 100644 src/AndiGenerator.Presentation/Suchverlauf.cs create mode 100644 src/AndiGenerator.Presentation/Verbesserungsmeldung.cs create mode 100644 src/AndiGenerator.Presentation/Vergleichszeile.cs create mode 100644 src/AndiGenerator.Presentation/VerlaufAnsichtViewModel.cs create mode 100644 src/AndiGenerator.Presentation/Verlaufspunkt.cs create mode 100644 src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml create mode 100644 src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml.cs create mode 100644 src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml create mode 100644 src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml.cs create mode 100644 src/AndiGenerator.UI/Maschinenschaubild.cs create mode 100644 src/AndiGenerator.UI/Platzhalterdeutsch.cs create mode 100644 src/AndiGenerator.UI/Seiten/Listenbedienung.cs create mode 100644 src/AndiGenerator.UI/Sicherungsdialog.axaml create mode 100644 src/AndiGenerator.UI/Sicherungsdialog.axaml.cs create mode 100644 src/AndiGenerator.UI/Verlaufsdiagramm.cs create mode 100644 tests/AndiGenerator.Presentation.Tests/SchaubildTests.cs diff --git a/.gitignore b/.gitignore index 8b55239..dca0b57 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ build-messung.txt optimierung-log.txt abdeckung-log.txt messen-log.txt +inselvergleich-*.txt bildschirmfotos-log.txt # Private Originaldaten (Klarnamen) – öffentlich nur die anonymisierte Kopie unter testdaten/ diff --git a/Doku/ANLEITUNG.md b/Doku/ANLEITUNG.md index b3993f8..05c1fef 100644 --- a/Doku/ANLEITUNG.md +++ b/Doku/ANLEITUNG.md @@ -124,7 +124,7 @@ click-TT überträgt nicht alles, was für einen guten Plan nötig ist, und die | **Setzliste** | Wenn Sie mit einer Setzliste arbeiten wollen, geben Sie hier die erwartete Abschlusstabelle vor. | | **Pflichtspieltage** | Die Staffel enthält Pflichtspieltage. Sie sind bei ungerader Mannschaftszahl problematisch (siehe [Pflichtspieltage](#pflichtspieltage)). | -Jeder Punkt lässt sich direkt auf der Seite bearbeiten; mit **Auswahl übernehmen** ist er erledigt, mit **Überspringen** bleibt er, wie er ist. Unten beendet **Abschließen** die Einrichtung; die Generierung startet danach im Hauptfenster mit **Kostenoptimierung starten** bzw. **Automodus starten**, wo auch der Ausgangsplan gewählt wird. Bei Entscheidungen gilt dabei die angeklickte Wahl, auch ohne **Auswahl übernehmen**; Punkte, bei denen Sie nichts gewählt haben, bleiben unverändert. Die Wahlkacheln und Knöpfe stehen immer unten im Blick, auch wenn die Erklärung darüber lang ist. Alles lässt sich später unter **Spielplandaten**, **Einstellungen** und in der Ansicht **Qualität** ändern. +Jeder Punkt lässt sich direkt auf der Seite bearbeiten; mit **Auswahl übernehmen** ist er erledigt, mit **Überspringen** bleibt er, wie er ist. Unten beendet **Abschließen** die Einrichtung; die Generierung startet danach im Hauptfenster mit **▶ Start** (Verfahren daneben wählen), wo auch der Ausgangsplan gewählt wird. Bei Entscheidungen gilt dabei die angeklickte Wahl, auch ohne **Auswahl übernehmen**; Punkte, bei denen Sie nichts gewählt haben, bleiben unverändert. Die Wahlkacheln und Knöpfe stehen immer unten im Blick, auch wenn die Erklärung darüber lang ist. Alles lässt sich später unter **Spielplandaten**, **Einstellungen** und in der Ansicht **Qualität** ändern. ### 3.4 Terminwünsche kontrollieren @@ -132,14 +132,14 @@ Bevor Sie die Generierung starten, lohnt ein Blick in die Ansicht **Terminwünsc ### 3.5 Generierung starten und beobachten -Mit dem blauen Startknopf oben rechts beginnt die Suche. Über den Pfeil rechts daneben wählen Sie das Verfahren: +Oben rechts wählen Sie zuerst unter **Verfahren** eines der beiden Verfahren (zwei Optionsfelder nebeneinander, genau eines ist gewählt und hervorgehoben; der Tooltip sagt, was es tut). Mit dem blauen Knopf **▶** (Start) daneben beginnt die Suche. - **Kostenoptimierung** – wie im Original: Die Suche senkt die Gesamtkosten mit Ihren Gewichtungen. - **Automodus** – die Suche lenkt sich selbst über die Gewichte, so wie man es von Hand an den Reglern tun würde: Erst drängt sie die Verstöße der Stufen A und B heraus (Hallenbelegung, parallele Spiele, Pflichtspieltage, Koppel-, Sperr- und Ausweichtermine), dann glättet sie die übrigen Kriterien, ohne A und B wieder zu verschlechtern. Wie er arbeitet und wann er sich lohnt, steht in Abschnitt [5.1](#51-der-automodus). Beim Start des Automodus (und beim Wechsel zu ihm) springt das Programm in die Ansicht **Qualität**, beim Start der Kostenoptimierung (und beim Wechsel zu ihr) in die Ansicht **Kosten**; fehlt die Ansicht, wird sie geöffnet. -Der Knopf merkt sich die Wahl und heißt danach **Kostenoptimierung starten** bzw. **Automodus starten**. Auch während der Generierung – laufend oder pausiert – lässt sich über den Pfeil zum anderen Verfahren wechseln; die Suche setzt beim bisher besten Plan fort. +Vor dem Start wählt *Verfahren* nur, gestartet wird erst mit **▶**. Die Knöpfe zeigen nur Symbole (der Tooltip nennt sie): Vor dem Start stehen **▶ Start** und rechts daneben **✕ Staffel schließen**; während der Generierung wird ▶ zu **⏸ Pausieren** (in der Pause wieder ▶ *Fortsetzen*) und ✕ zu **■ Stopp**. Auch während der Generierung – laufend oder pausiert – wechselt ein Klick unter *Verfahren* zum anderen Verfahren; die Suche setzt beim bisher besten Plan fort. Das Programm rechnet mit allen Prozessorkernen bis auf einen, damit der Rechner bedienbar bleibt, und zeigt laufend den besten bisher gefundenen Plan. @@ -165,7 +165,7 @@ Passt der Plan, **pausieren** Sie und exportieren ihn mit **Für click-TT export Die Abschnitte *Staffel*, *Ansichten* und *Ausgabe* lassen sich mit einem Klick auf ihre Überschrift zu- und wieder aufklappen; so bleibt die Leiste auch auf kleinen Bildschirmen übersichtlich. -**Oben** stehen der Name der Staffel, die Datei und die Steuerung der Generierung: **Start mit**, **Plan merken**, **Kostenoptimierung starten / Automodus starten / Pausieren / Fortsetzen** (mit dem Pfeil zur Wahl des Verfahrens), **Beenden** und **Schließen**. *Schließen* führt zur Startseite zurück; läuft noch eine Generierung, fragt das Programm vorher nach. Wählen Sie unter **Start mit** einen vorhandenen Plan (gemerkt oder aus click-TT), zeigen alle Ansichten und die Kacheln sofort diesen Plan – mit Kosten und Qualität –, ohne dass eine Optimierung startet und ihn verändert. +**Oben** stehen links der Name der Staffel und die Datei, rechts die Steuerung: **Start mit**, **Plan merken**, **Verfahren** (Kostenoptimierung oder Automodus), **▶ Start** bzw. **⏸ Pausieren** und daneben **✕ Staffel schließen** (führt zur Startseite zurück) bzw. während der Generierung **■ Stopp**. Wählen Sie unter **Start mit** einen vorhandenen Plan (gemerkt oder aus click-TT), zeigen alle Ansichten und die Kacheln sofort diesen Plan – mit Kosten und Qualität –, ohne dass eine Optimierung startet und ihn verändert. Darunter vier **Kacheln** mit dem Stand der Generierung: @@ -173,13 +173,18 @@ Darunter vier **Kacheln** mit dem Stand der Generierung: |---|---| | **Gesamtkosten (bester Plan)** | Die Kosten des besten bisher gefundenen Plans; darunter, wie stark sie seit Beginn gesunken sind. Nach einer Änderung der Gewichtung gilt der Vergleich ab dieser Änderung, weil die Kosten davor anders berechnet wurden. **Details ›** öffnet die Kostenansicht. | | **Pflichtregeln (Stufe A)** | Ob der beste Plan die wichtigsten Regeln einhält (siehe [Qualität](#62-qualität)). **Details ›** öffnet die Qualitätsansicht. | -| **Verbesserungen** | Wie oft ein besserer Plan gefunden wurde, und wann zuletzt. | -| **Berechnete Pläne** | Anzahl der geprüften Pläne und die aktuelle Geschwindigkeit. | +| **Verbesserungen** | Wie oft der beste Plan besser wurde, und wann zuletzt (im Automodus nach der Basisoptimierung zählt nur ein besserer Plan nach A, B und C; reine Kostensenkungen dazwischen nicht). Dieselbe Zahl zeigt der Leitstand: Zu jeder Verbesserung wandert dort ein grüner Punkt, und der Kasten unter dem besten Plan nennt ihre Nummer. Rechts daneben ein kleiner Verlauf (Kosten bzw. im Automodus die Verstöße der wichtigsten Stufe, die noch welche hat). **Details ›** öffnet die Ansicht **Verlauf**: formatfüllend die Kosten bzw. die Verstöße A, B und C seit dem Start, mit den Phasen des Automodus im Hintergrund, kleinen Strichen bei Gewichtsanhebungen und darunter den Plänen je Sekunde; mit der Maus erscheinen die Werte zu einem Zeitpunkt. | +| **Berechnete Pläne** | Anzahl der geprüften Pläne und die aktuelle Geschwindigkeit. Rechts daneben steht je Rechenkern ein kleiner Balken, der mit seiner Arbeit wächst und schrumpft (bei mehr als 16 Kernen je Gruppe von Kernen). **Details ›** öffnet den Leitstand. | Im Automodus entfällt die Kachel *Pflichtregeln*; die Ergebniskachel nimmt ihren Platz mit ein, *Verbesserungen* und *Berechnete Pläne* bleiben an ihrer Stelle. Die breitere Ergebniskachel zeigt oben die Phase (z. B. „Automodus · Optimierung Stufe A“) und unten unter **In Arbeit** alle Kriterien, an denen gerade gearbeitet wird (in Stufe A und B die zuletzt angehobenen, in C das, das geglättet wird) und vor jeder Verstoßzahl klein den Stand am Ende der Basisoptimierung (z. B. „A 14 → 3“). ![Ergebniskachel im Automodus](Anleitung/Bilder/18-automodus.png) +**Leitstand.** Ein Klick auf die Kachel *Berechnete Pläne* zeigt, was beim Rechnen geschieht: oben links je Rechenkern ein Balken mit seiner Last (bei sehr vielen Kernen ein Raster aus Kästchen, je kräftiger, desto mehr Last), oben rechts ein **Schaubild der Maschine**: Inseln → Koordinator → bester Plan. Jede Insel zeigt den Wert ihres besten Plans und grob, was sie gerade tut: *kommt voran* (sie hat in den letzten Sekunden einen besseren Plan gefunden), *sucht*, *findet lange nichts*, *beginnt gleich neu*, *steckt fest, rechnet weiter* oder *beginnt neu*; der schmale Balken daneben zeigt, wie nah sie der Neustart-Grenze ist. Gerechnet wird in den Inseln an vielen **Suchplätzen**, die bei jedem Schritt einen Teil der Spiele neu verteilen; die Rechenkerne rechnen reihum an allen. Das Bild bleibt ruhig und bewegt sich nur bei Ereignissen: Der Koordinator übernimmt den besten Plan aller Inseln – wird dieser besser, wandert ein grüner Punkt von der Insel, die ihn gefunden hat, über den Koordinator zu *Bester Plan*, und der leuchtet kurz auf. Direkt darunter erscheint dann ein Kasten mit der Nummer der Verbesserung (wie in der Kachel *Verbesserungen*, z. B. „Verbesserung 57“) und dem, was sich geändert hat; jede weitere Verbesserung aktualisiert ihn, und 10 Sekunden nach der letzten verschwindet er wieder – oft wird ein Kriterium besser und ein anderes dafür etwas schlechter: bei der Kostenoptimierung die Kriterien mit den größten Kostendifferenzen (grün mit „−“ besser, rot mit „+“ schlechter), im Automodus die Änderungen der Verstöße, wichtigste Stufe zuerst (z. B. „A · Hallenbelegung 2 → 1“). Ändert sich kein Kriterium sichtbar, steht dort *Feinschliff*. Eine Insel, die zu lange nichts findet, startet er neu (sie wird kurz grau). Bei der Kostenoptimierung gibt die Spezial-Insel ihren Plan über den Koordinator an eine Insel weiter; im Automodus hebt das Steuerpult *Automodus* die Gewichte an und wählt den besten Plan nach A, B und C aus. Darunter stehen die **Inseln**. Jede Insel sucht für sich und hat ihren eigenen besten Plan; die Insel mit dem besten Plan ist grün umrandet. Der Balken einer Insel zeigt, wie lange sie schon nichts Besseres gefunden hat, gemessen an der Neustart-Grenze (100 T Pläne). Neu beginnt nur die **schlechteste** Insel (die mit den höchsten Kosten, im Automodus mit den aktuellen Gewichten), und erst wenn ihr Balken voll ist – höchstens eine je Sekunde. Die anderen Inseln rechnen auch mit vollem Balken weiter, denn ihr Plan ist besser als der schlechteste; der Text darunter sagt das dann ausdrücklich („Seit 147 T Plänen nichts Besseres – rechnet weiter, neu beginnt nur die schlechteste Insel“). Darunter stehen die drei größten Kostenverursacher ihres besten Plans mit ihrem Anteil an den Kosten; im Automodus stattdessen die Kriterien mit den meisten Verstößen (wichtigste Stufe zuerst) und deren Anzahl. Bei der Kostenoptimierung kommt die **Spezial-Insel** hinzu: Sie gewichtet eine Kostenart extrem hoch und gibt gute Pläne an die anderen Inseln weiter. Im Automodus zeigen die Inseln statt ihrer Kosten die Verstöße A, B und C ihres besten Plans, und unten steht, welche **Gewichte** der Automodus gerade gegenüber Ihren angehoben hat – je Kriterium und Mannschaft auf einer Skala von „sehr wenig“ bis „extrem hoch“: der Ring ist Ihre Gewichtung, der blaue Punkt die aktuelle des Automodus, die blaue Linie der Weg dazwischen. Diese Gewichte stehen oberhalb der Inseln. + +![Leitstand](Anleitung/Bilder/19-leitstand.png) + + Den **Hauptbereich** füllen die Ansichten. Jede Ansicht öffnet sich als Reiter; Sie können mehrere öffnen, zwischen ihnen wechseln, sie per Maus nebeneinander anordnen und mit dem **×** am Reiter schließen. In vielen Ansichten vergrößert oder verkleinert **Strg + Mausrad** (auf dem Mac auch **⌘ + Mausrad**, oder der Zoomregler) die Darstellung. Ganz unten zeigt die **Statuszeile** den Stand in einer Zeile. @@ -188,20 +193,24 @@ Ganz unten zeigt die **Statuszeile** den Stand in einer Zeile. | Schaltfläche | Wirkung | |---|---| -| **Kostenoptimierung starten** / **Automodus starten** | Beginnt die Suche mit dem Ausgangsplan aus **Start mit** und dem gewählten Verfahren. | -| **Pfeil neben dem Startknopf** | Wählt das Verfahren; während der Generierung wechselt er zum anderen Verfahren, ab dem bisher besten Plan. | -| **Pausieren** | Hält die Suche an. Der beste Plan bleibt erhalten; so können Sie ihn in Ruhe ansehen oder exportieren. | +| **▶ Start** | Beginnt die Suche mit dem Ausgangsplan aus **Start mit** und dem gewählten Verfahren. | +| **Verfahren** | Wählt das Verfahren für den nächsten Start; während der Generierung wechselt ein Klick zum anderen Verfahren, ab dem bisher besten Plan. | +| **⏸ Pausieren** / **▶ Fortsetzen** (statt ▶ Start während der Generierung) | Hält die Suche an. Der beste Plan bleibt erhalten; so können Sie ihn in Ruhe ansehen oder exportieren. | | **Fortsetzen** | Setzt eine pausierte Suche mit ihrem besten Plan fort. | -| **Beenden** | Beendet die Generierung. Danach kann mit einem anderen Ausgangsplan neu gestartet werden. | +| **■ Stopp** (statt ✕ während der Generierung) | Beendet die Generierung. Ihr bester Plan bleibt erhalten (*bester Plan der letzten Generierung*); danach kann mit einem anderen Ausgangsplan neu gestartet werden. | +| **✕ Staffel schließen** (neben ▶ Start, solange keine Generierung läuft) | Schließt die Staffel (eine laufende Generierung wird beendet) und führt zur Startseite. | **Start mit** legt fest, wovon die nächste Generierung ausgeht: - **leerem Plan** – das Programm plant von Grund auf neu (Standard); -- **bestem Plan der letzten Generierung** – nach *Beenden* dort weitermachen; +- **bestem Plan der letzten Generierung** – nach *Stopp* dort weitermachen; +- **gesichertem Plan** – der automatisch gesicherte Plan (siehe unten); - **in click-TT vorhandenem Plan** – ein schon in click-TT eingetragener Plan wird verbessert; - **gemerktem Plan „…“** – ein früher gemerkter Plan (Abschnitt [8](#8-pläne-merken-und-vergleichen)). -Solange eine Generierung läuft oder pausiert ist, ist *Start mit* gesperrt: Eine laufende Generierung setzt immer ihren eigenen besten Plan fort. Für einen anderen Ausgangsplan zuerst **Beenden**. +Solange eine Generierung läuft oder pausiert ist, ist *Start mit* gesperrt: Eine laufende Generierung setzt immer ihren eigenen besten Plan fort. Für einen anderen Ausgangsplan zuerst **Stopp**. + +**Gesicherter Plan.** Je Staffel gibt es einen gesicherten Plan. Würde der aktuelle Plan verworfen – weil eine neue Generierung startet, die Staffel geschlossen, eine andere geöffnet oder das Programm beendet wird –, sichert das Programm ihn vorher: Gibt es noch keinen gesicherten Plan, geschieht das automatisch, und eine Meldung sagt es. Gibt es schon einen, fragt es, ob er durch den aktuellen ersetzt werden soll, und zeigt dazu beide nebeneinander: die Verstöße der Stufen A (mit den harten Fehlern), B und C, die Kosten und die einzelnen Kriterien, bei denen sie sich unterscheiden; der jeweils bessere Wert ist grün, und darunter steht, welcher Plan insgesamt besser ist (erst A, dann B, dann C, zuletzt die Kosten). Sie wählen **Aktuellen Plan sichern**, **Gesicherten Plan behalten** oder **Abbrechen** (dann unterbleibt der Neustart bzw. das Schließen). Der gesicherte Plan steht wie ein gemerkter unter **Start mit** und in der Planauswahl der Ansichten. Das Original kennt keine solche Sicherung; dort geht ein nicht gemerkter Plan beim Schließen verloren. Änderungen an den **Einstellungen** und **Spielplandaten** übernimmt eine laufende Generierung sofort; sie rechnet vom bisher besten Plan aus weiter. @@ -211,19 +220,19 @@ Bei der **Kostenoptimierung** sucht das Programm den Plan mit den niedrigsten Ge Der Automodus arbeitet in **Phasen**; die laufende steht oben in der Ergebniskachel: -1. **Basisoptimierung** – eine gewöhnliche Kostenoptimierung mit Ihren Gewichtungen, bis sie etwa 10 Sekunden lang nichts mehr verbessert. Ihr bester Plan ist der Maßstab für alles Weitere (in der Kachel die kleine Zahl vor dem Pfeil, z. B. „A 14 → 3“). -2. **Optimierung Stufe A** – der Automodus beobachtet die Verstöße der Stufe A. Ändern sie sich 10 Sekunden lang nicht mehr, hebt er das Gewicht genau der Mannschaften und Kriterien um eine Stufe an, die noch Verstöße haben. Das wiederholt er, bis keine Verstöße mehr übrig sind, nichts mehr anzuheben ist oder es dreimal hintereinander nicht besser wird. +1. **Basisoptimierung** – eine gewöhnliche Kostenoptimierung mit Ihren Gewichtungen, bis sie nicht mehr deutlich vorankommt: Gezählt werden nur Verbesserungen um mindestens 1 % der Kosten; bleiben sie mindestens 10 Sekunden und 500.000 berechnete Pläne lang aus, geht es weiter (ein langsamer Rechner sucht dafür entsprechend länger). Spätestens nach 3 Minuten beginnt in jedem Fall Stufe A. Ihr bester Plan ist der Maßstab für alles Weitere (in der Kachel die kleine Zahl vor dem Pfeil, z. B. „A 14 → 3“). +2. **Optimierung Stufe A** – direkt nach der Basisoptimierung hebt der Automodus ein erstes Mal an. Danach beobachtet er die Verstöße der Stufe A. Ändern sie sich mindestens 10 Sekunden und 500.000 berechnete Pläne lang nicht mehr, hebt er das Gewicht genau der Mannschaften und Kriterien um eine Stufe an, die noch Verstöße haben. Das wiederholt er, bis keine Verstöße mehr übrig sind, nichts mehr anzuheben ist oder es dreimal hintereinander nicht besser wird. 3. **Optimierung Stufe B** – ebenso für die Stufe B; die Gewichte der Stufe A bleiben dabei stehen. 4. **Optimierung Stufe C** – nun glättet er die übrigen Kriterien der Reihe nach, wie sie in der Einteilung stehen, ausgehend vom besten Plan. Zuerst nimmt er sich Ausreißer vor: Mannschaften, die bei einem Kriterium mehr als zwei Verstöße mehr haben als nach der Basisoptimierung – ein, zwei mehr sind in Ordnung, viele bei einer Mannschaft wären unfair. Steigen dabei die Verstöße in A oder B wieder, nimmt er die letzte Anhebung zurück und macht beim nächsten Kriterium weiter. Sind alle Kriterien der Stufe C durch, beginnt er wieder mit dem Herausdrängen. -Der Automodus hat keine Zeitgrenze; er läuft, bis Sie **Pausieren** oder **Beenden**. Unter **In Arbeit** zeigt die Ergebniskachel, an welchen Kriterien er gerade arbeitet (Abschnitt [4](#4-das-hauptfenster)). +Der Automodus hat keine Zeitgrenze; er läuft, bis Sie **Pausieren** oder **Stopp** wählen. Unter **In Arbeit** zeigt die Ergebniskachel, an welchen Kriterien er gerade arbeitet (Abschnitt [4](#4-das-hauptfenster)). **Welcher Plan ist der beste?** Der Automodus vergleicht Pläne nicht nach den Gesamtkosten, sondern der Reihe nach: harte Fehler, Verstöße der Stufe A, Verstöße der Stufe B, Ausreißer in C, Verstöße in C und erst zuletzt die Kosten mit Ihren Gewichtungen. Ein Plan mit weniger Verstößen in A gewinnt also immer, auch wenn er teurer ist. **Eingreifen während der Generierung:** -- **Einteilung ändern** – verschieben Sie in der Ansicht **Qualität** ein Kriterium, gilt das sofort, ohne neue Basisoptimierung. Rückt etwa ein Kriterium von B1 nach A2, drängt der Automodus zuerst genau dieses Kriterium heraus und danach wieder die ganze Stufe A. -- **Verfahren wechseln** – über den Pfeil am Startknopf, auch in der Pause. Beim Wechsel von der Kostenoptimierung in den Automodus entfällt die Basisoptimierung: Der bisher beste Plan ist schon optimiert und gilt als Basis, der Automodus beginnt sofort mit Stufe A. Ein bewährter Weg ist deshalb, mit der Kostenoptimierung zu beginnen und, wenn sie kaum noch Fortschritte macht, in den Automodus zu wechseln. +- **Einteilung ändern** – verschieben Sie in der Ansicht **Qualität** ein Kriterium, gilt das sofort, ohne neue Basisoptimierung. Rückt etwa ein Kriterium von B1 nach A2, drängt der Automodus zuerst genau dieses Kriterium heraus und danach wieder die ganze Stufe A. Das verschobene Kriterium steht danach einige Sekunden vorn unter **In Arbeit** – auch wenn der Automodus schon weiter ist, weil es dort gerade nichts zu verbessern gab. +- **Verfahren wechseln** – unter **Verfahren**, auch in der Pause. Beim Wechsel von der Kostenoptimierung in den Automodus entfällt die Basisoptimierung: Der bisher beste Plan ist schon optimiert und gilt als Basis, der Automodus beginnt sofort mit Stufe A. Ein bewährter Weg ist deshalb, mit der Kostenoptimierung zu beginnen und, wenn sie kaum noch Fortschritte macht, in den Automodus zu wechseln. **Wann welches Verfahren?** Der Automodus lohnt sich, wenn Pflichtregeln und Terminwünsche der Vereine Vorrang haben und Sie nicht selbst an den Gewichten drehen wollen. Die Kostenoptimierung ist die richtige Wahl, wenn Sie die Gewichtungen gezielt einsetzen oder Ergebnisse mit dem alten AndiGenerator vergleichen wollen – sie rechnet wie das Original. @@ -401,9 +410,11 @@ Unter **Spielplandaten** lassen sich alle Daten ändern, die click-TT geliefert Weil die Änderungen getrennt von der XML-Datei gespeichert werden, können Sie die XML-Datei jederzeit neu aus click-TT herunterladen: Speichern Sie sie unter demselben Namen, bleiben Ihre Änderungen erhalten. +Die Seiten werden überall gleich bedient: Die Knöpfe **Neu**, **Bearbeiten** und **Löschen** (bei der Setzliste **Nach oben**/**Nach unten**) stehen auf jeder Seite gleich: hellblau hervorgehoben, gleich breit und direkt rechts neben der Liste – auch wenn das Fenster breiter oder schmaler wird; ein **Doppelklick** oder die **Eingabetaste** bearbeitet den gewählten Eintrag, die **Entf**-Taste löscht ihn (mit Rückfrage). In der Seitenliste links steht bei manchen Seiten eine kleine Marke – die Zahl der Einträge (Mannschaften, spielfreie Tage, Pflichtspieltage, festgelegte Begegnungen) bzw. „an“/„aus“ bei der Setzliste –, und ein blauer Punkt zeigt, auf welchen Seiten Sie in diesem Dialog etwas geändert haben. + ### Allgemein -Die Stammdaten der Liga: Name, Liganummer, Art (Herren, Damen …) sowie Start der Vorrunde, Start der Rückrunde und Ende der Rückrunde. Die Art ist wichtig, um die Nachbarmannschaften richtig zu bestimmen. Der Start der Rückrunde wird bei Halbrunden nicht verwendet. +Die Stammdaten der Liga: Name, Liganummer, Art (Herren, Damen …) sowie Start der Vorrunde, Start der Rückrunde und Ende der Rückrunde. Die Art ist wichtig, um die Nachbarmannschaften richtig zu bestimmen. Der Start der Rückrunde wird bei Halbrunden nicht verwendet. Beide Hinweise stehen direkt neben den Feldern; passt eine Eingabe nicht (z. B. leerer Name), steht das sofort rot darunter. ### Mannschaften @@ -413,15 +424,15 @@ Mannschaften anlegen, bearbeiten und löschen. Über die **Vereins-ID** erkennt Mit der Setzliste geben Sie die erwartete Abschlusstabelle vor. Ziel ist, dass die Spiele, in denen es um Auf- und Abstieg geht, am Ende der Saison stattfinden – und nicht Mannschaften beteiligt sind, für die es um nichts mehr geht. So bleibt die Spannung bis zum Schluss. -Ordnen Sie die Mannschaften per Ziehen oder mit **Nach oben** / **Nach unten**. Mit **Setzliste aktivieren** schalten Sie sie ein oder aus. +Ordnen Sie die Mannschaften per Ziehen am Griff **⋮⋮** – wie in der Qualitätsansicht hängt die Zeile am Mauszeiger, und die Liste zeigt schon die neuen Plätze mit einer Lücke an der Ablagestelle (Escape bricht ab) – oder wählen Sie eine Zeile und nutzen **Nach oben** / **Nach unten**. Mit **Setzliste aktivieren** schalten Sie sie ein oder aus; ausgeschaltet ist die Liste ausgegraut, die Reihenfolge bleibt aber erhalten. -### Spiellokale (Kompaktansicht) +### Spiellokale click-TT überträgt keine Spiellokale. Meist ist das egal, weil die Vereine nur eine Halle haben. Stehen mehrere Hallen zur Verfügung, braucht das Programm die Spiellokale, damit die maximale Zahl gleichzeitiger Heimspiele je Halle eingehalten wird. -Je Mannschaft tragen Sie das **Standardspiellokal** ein und darunter, falls nötig, abweichende Spiellokale für einzelne Heimspieltage. Die Tabelle **Spiellokale der Nachbarmannschaften** enthält das Standardspiellokal jeder Nachbarmannschaft; spielt eine Nachbarmannschaft nur bei einzelnen Spielen woanders, öffnet ein Doppelklick auf ihren Namen die Bearbeitung. +Je Mannschaft tragen Sie das **Standardspiellokal** ein („nur eines“, wenn der Verein nur in einer Halle spielt; sonst *Spiellokal 1* bis *5*, die Nummern der Spiellokale des Vereins in click-TT) und darunter, falls nötig, abweichende Spiellokale für einzelne Heimspieltage (*Standard* = das Standardspiellokal). Die Tabelle **Spiellokale der Nachbarmannschaften** enthält das Standardspiellokal jeder Nachbarmannschaft; spielt eine Nachbarmannschaft nur bei einzelnen Spielen woanders, öffnet ein Doppelklick auf ihren Namen die Bearbeitung. -### Heimkoppel/Doppelspieltage (Kompakt) +### Heimkoppel und Doppelspieltage Vor allem in höheren Ligen möchten Mannschaften an einem Wochenende zwei Spiele bestreiten. Die Meldungen dazu sind kompliziert und nicht immer eindeutig; das Programm leitet ab, was vermutlich gemeint ist. **Bitte immer kontrollieren.** @@ -434,19 +445,22 @@ Diese Seite fasst die Koppeloptionen aller Wunschtermine zusammen. Neue Koppelte Hier geben Mannschaften an, bei welchen zwei Gegnern sie an einem Wochenende auswärts spielen möchten – meist, um eine lange Anfahrt nur einmal zu machen. Je Wunsch legen Sie fest, ob beide Spiele an **einem Tag**, an **zwei Tagen** (mit Übernachtung) oder wahlweise stattfinden sollen. Für einen Gegner sind auch mehrere Wünsche möglich. -Sollen beide Spiele an einem Tag stattfinden, braucht es genug Abstand zwischen den Anfangszeiten. Meist spielen die Mannschaften aber zu festen Zeiten; das Programm darf deshalb bei einer der beteiligten Heimmannschaften die Anfangszeit verschieben. Unter **Alternative Anfangszeiten** legen Sie fest, an welchen Terminen welcher Mannschaft das erlaubt ist. Die genaue zweite Uhrzeit steht im jeweiligen Wunschtermin. +Sollen beide Spiele an einem Tag stattfinden, braucht es genug Abstand zwischen den Anfangszeiten. Meist spielen die Mannschaften aber zu festen Zeiten; das Programm darf deshalb bei einer der beteiligten Heimmannschaften die Anfangszeit verschieben. Unter **Alternative Anfangszeiten** legen Sie fest, an welchen Terminen welcher Mannschaft das erlaubt ist; das leere Feld zeigt den Vorschlag (z. B. „z. B. 14:00“), **Vorschläge für alle eintragen** übernimmt ihn überall, **Alle Zeiten leeren** entfernt alle. Die genaue zweite Uhrzeit steht im jeweiligen Wunschtermin. ### Wunschtermine ![Wunschtermin bearbeiten](Anleitung/Bilder/13-wunschtermin.png) -Je Mannschaft alle Tage der Saison, wochenweise: +Je Mannschaft alle Tage der Saison, wochenweise, als Kärtchen (die Legende steht darüber): + +- **blau mit Uhrzeit** (z. B. `18:00`): an diesem Tag ist ein Heimspiel möglich; darunter klein die Zusätze (Koppel, Doppel, max. Heimspiele, Spiellokal); +- **hellblau, Uhrzeit kursiv**: Ausweichtermin, nur im Notfall; +- **rot „gesperrt“**: Sperrtermin, die Mannschaft möchte an diesem Tag nicht spielen; +- **leer (weiß)**: an diesem Tag ist ein Auswärtsspiel möglich. -- eine **Uhrzeit** (z. B. `18:00`): an diesem Tag ist ein Heimspiel möglich; -- **FREI**: Sperrtermin, die Mannschaft möchte an diesem Tag nicht spielen; -- **leer**: an diesem Tag ist ein Auswärtsspiel möglich. +Ein **Klick** auf einen Tag – oder die **rechte Maustaste** und dann **Bearbeiten …** – öffnet den Termin. Oben wählen Sie mit einem Umschalter **Heimspiel möglich**, **kein Heimspiel (Auswärtsspiele möglich)** oder **Sperrtermin**; beim Heimspiel stehen darunter Uhrzeit, Halle, Koppel- und Auswärtskoppeloptionen. Ein **leerer** Tag ist dabei gleich als Heimspiel zur üblichen Uhrzeit der Mannschaft vorgeschlagen (der häufigsten an diesem Wochentag, sonst 18:00) – meist genügt dann **OK**. Einen Termin **ziehen** Sie einfach auf einen anderen Tag: Das Kärtchen hängt am Mauszeiger, der Zieltag ist umrandet. Mit gedrückter **Strg**-Taste wird kopiert statt verschoben; liegt am Zieltag schon ein Termin, tauschen die beiden; Escape bricht ab. Der Tooltip eines Kärtchens nennt Tag und Termin ausgeschrieben. -Ein **Doppelklick** auf einen Heimspieltermin – oder die **rechte Maustaste** und dann **Bearbeiten …** – öffnet seine weiteren Optionen: +Die Optionen eines Termins: | Option | Bedeutung | |---|---| @@ -467,13 +481,13 @@ Die Spiele der Nachbarmannschaften braucht das Programm für die Hallenbelegung dazu die einzelnen **Begegnungen** dieser Mannschaft. -### 60km Regel +### Wochenende (60-km-Regel) Ist die Anfahrt zu einem Gegner zu weit, sollen die Spiele gegen ihn am Wochenende stattfinden. Hier legen Sie fest, für welche Paarungen das gilt. Ob der Freitag zum Wochenende zählt, stellen Sie in den **Einstellungen** ein. ### Spielfreie Tage -An einzelnen Tagen soll gar nicht gespielt werden – etwa weil die Relegationstermine noch nicht feststanden, weil nachträglich ein Spielverbot beschlossen wurde oder weil beim Anlegen der Spieltage ein Fehler passiert ist. Diese Tage tragen Sie hier ein. Die Auswertung der Terminwünsche berücksichtigt sie. +An einzelnen Tagen soll gar nicht gespielt werden – etwa weil die Relegationstermine noch nicht feststanden, weil nachträglich ein Spielverbot beschlossen wurde oder weil beim Anlegen der Spieltage ein Fehler passiert ist. Diese Tage wählen Sie in einem **Monatskalender** der Saison: Ein Klick auf einen Tag macht ihn spielfrei (rot), ein zweiter gibt ihn wieder frei. Wählbar sind die Tage, an denen Mannschaften Heimspielwünsche haben. Grau sind Tage, die **nicht gesperrt** sind, an denen aber keine Mannschaft einen Heimspielwunsch hat – dort wird ohnehin nicht gespielt; ist ein solcher Tag trotzdem als spielfrei eingetragen (etwa aus click-TT), ist er rot unterstrichen. Der Tooltip eines Tages nennt den Grund, die Legende steht über dem Kalender; Wochenenden sind fett. Die Monate brechen um, wenn das Fenster schmaler ist. Die Auswertung der Terminwünsche berücksichtigt die spielfreien Tage. ### Pflichtspieltage @@ -487,7 +501,7 @@ Stehen Spiele schon vor der Planung fest – etwa weil zwei befreundete Vereine ### Heimrecht -Manchmal soll die Zahl der Heimspiele in Vor- und Rückrunde nicht gleich sein, oder das Heimrecht gegen bestimmte Gegner soll in einer bestimmten Runde liegen – etwa weil eine Halle saniert wird oder weil Jugendligen zur Rückrunde neu zusammengestellt werden. Hier geben Sie je Mannschaft die Anzahl der Heimspiele und das Heimrecht gegen einzelne Gegner vor. +Manchmal soll die Zahl der Heimspiele in Vor- und Rückrunde nicht gleich sein, oder das Heimrecht gegen bestimmte Gegner soll in einer bestimmten Runde liegen – etwa weil eine Halle saniert wird oder weil Jugendligen zur Rückrunde neu zusammengestellt werden. Hier geben Sie je Mannschaft die Anzahl der Heimspiele und das Heimrecht gegen einzelne Gegner vor – je Gegner mit dem Umschalter **Vorrunde / egal / Rückrunde**. Zu beachten: diff --git a/Doku/MIGRATIONSPLAN.md b/Doku/MIGRATIONSPLAN.md index 387a09a..34b569c 100644 --- a/Doku/MIGRATIONSPLAN.md +++ b/Doku/MIGRATIONSPLAN.md @@ -520,6 +520,31 @@ Die Portierung soll sich deutlich vom Original unterscheiden, ohne ihre Herkunft - **Bildschirmfotos:** In Bildern, die nur einen Teil des Fensters zeigen, zerfiel die Schrift, wenn nur dieser Teil gezeichnet wurde (ein Versuch mit dem Fensterhintergrund ergab schwarzen Grund). Jetzt zeichnet `Bildschirmfotos.cs` immer das ganze Fenster und schneidet zu; alle Bilder und `Doku/Anleitung.pdf` sind neu erzeugt. - **Ansichten nach dem Start:** Nach „Start mit“ einem vorhandenen Plan zeigten die Ansichten nach dem Start weiter den unveränderten Ausgangsplan (Kostenansicht 104 T, Kachel 85.400). Behoben mit `StartplanAnsichtenZuruecksetzen` und Test. +### Checkliste für Version 1.0 (Stand 11.10.2026) + +Funktional ist der Umfang des Originals portiert und um Automodus, Leitstand, gesicherten Plan und Drag & Drop erweitert. Vor 1.0 fehlen vor allem Absicherung und Abrundung. + +**Muss vor 1.0** + +- [ ] **Stand abschließen:** Branch `feature/automodus-uebergang` committen und zusammenführen; Bildschirmfotos und `Doku/Anleitung.pdf` neu erzeugen (Leitstand, Start/Pause/Stopp, Spielplandaten-Seiten haben sich geändert). +- [ ] **Absturzschutz:** globaler Fehlerfänger (`AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException`, Fehler im UI-Thread): verständliche Meldung, Protokolldatei im Datenordner, vorher den laufenden Plan als gesicherten Plan ablegen. +- [ ] **Klasse-3-Befunde entscheiden (E9):** #3, #4, #5, #6, #9, #10 und #33 – je Befund korrigieren (ggf. als Schalter) oder bewusst wie im Original lassen; Entscheidung in Abschnitt 6 eintragen. +- [ ] **Signierung entscheiden (E12):** Zertifikat für Windows (SmartScreen-Warnung) bzw. Notarisierung für macOS – oder ein klarer Hinweis auf der Download-Seite. + +**Sollte vor 1.0** + +- [ ] **Beta mit echten Staffelleitern (Phase 9):** zwei bis drei Staffeln parallel mit Original und neuem Programm planen, Befunde sammeln. +- [ ] **Sprache fest auf Deutsch:** `CultureInfo.DefaultThreadCurrentCulture`/`UICulture` beim Start auf de-DE setzen und auf einem englisch eingestellten Windows prüfen (Monatsnamen der Datumsauswahl, Zahlenformate, Standardtexte; vgl. Platzhalter „hour“/„minute“). +- [ ] **Änderungsprotokoll für Anwender:** `CHANGELOG.md` bzw. Release Notes je GitHub-Release, mit 1.0 eine Übersicht „Was ist anders als im Original“. +- [ ] **Testabdeckung der Oberfläche** neu messen (zuletzt 01.10.2026: gesamt 92,8 %, UI 76 %) und die neuen Abläufe absichern (Drag & Drop, Kalender, Sicherungsdialog, Einrichtung). + +**Abrundung (auch nach 1.0 möglich)** + +- [ ] **Tablet-Bedienung (E1):** Drag & Drop per Finger, Tooltips ohne Maus, Größe der Bedienelemente. +- [ ] **Barrierefreiheit:** `AutomationProperties.Name` für Bildschirmleser (bisher nirgends gesetzt), Tastaturbedienung systematisch prüfen. +- [ ] **macOS und Linux** im Alltag ausprobieren (Pakete existieren). +- [ ] **Feinschliff-Durchgang** durch alle Dialoge bei kleiner Fenstergröße und 150 % Windows-Skalierung (Umbrüche, abgeschnittene Texte, Ausrichtung). + --- ## 9. Dokumentenverzeichnis (Einzeldokus) @@ -606,10 +631,10 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa **Automodus – Lenkung über die Gewichte** (Vorschlag Peter, Rest von E14; `Optimierungseinstellungen.Automodus`, `Automodus`): Der Automodus macht, was ein Staffelleiter von Hand tut – er ändert während der Rechnung Gewichte, bis die wichtigen Kriterien eingehalten sind. Ziel ist nicht der billigste Plan für eine feste Kostenfunktion, sondern möglichst wenige Verstöße in der Rangfolge, die der Staffelleiter festlegt. - **Einteilung:** Der Staffelleiter teilt die Kriterien (16 Mannschafts-Kostenarten, vereinsinterne Spiele am Anfang, Länge und Überlappung der Spieltage) in die Stufen A, B und C ein und ordnet sie (`Stufeneinteilung`, in der Qualitätsansicht; gespeichert als Attribut `kriterienstufen` am Knoten `plan`, das der alte AndiGenerator übernimmt, ohne es zu beachten). Kriterien ohne Anzahl gehören immer zu C. Standard: A = Hallenbelegung, parallele Spiele, Pflichtspieltage, vereinsinterne Spiele; B = Auswärts-/Heimkoppel, Sperr-, Ausweichtermine; C = die übrigen. - **Bewertung nach Stufen** (`Stufenwert`, immer mit den Gewichtungen des Anwenders): harte Fehler, A-, B-Verstöße, C-Ausreißer (Mannschaften mit mehr als zwei Verstößen mehr als in der Basis je C-Kriterium), C-Verstöße, C-Kosten. -- **Basisoptimierung:** wie das Original, bis die Suche 10 s keine Verbesserung findet (mindestens 10 s); ihr bester Plan ist der Maßstab. Entfällt beim Wechsel von der Kostenoptimierung in den Automodus. +- **Basisoptimierung:** wie das Original, bis die Suche steht: mindestens 10 s, dann 10 s und 500.000 berechnete Pläne ohne Verbesserung um mindestens 1 %; höchstens 180 s. Ihr bester Plan ist der Maßstab. Entfällt beim Wechsel von der Kostenoptimierung in den Automodus. - **Optimierung Stufe A, dann B:** die Gewichte der Mannschaften und Kriterien mit Verstößen der Stufe eine Stufe höher, bis nichts mehr anzuheben ist oder dreimal kein Fortschritt kommt; die A-Gewichte bleiben stehen. - **Optimierung Stufe C:** in der Reihenfolge der C-Kriterien zuerst die Ausreißer, sonst die Mannschaften mit den meisten Verstößen anheben; steigen A oder B (B um mehr als 1), wird zurückgezogen, beim besten Plan neu begonnen und das nächste Kriterium genommen. Sind alle C-Kriterien durch, wieder A und B. -- **Ohne Zeitgrenzen:** Nächster Schritt, wenn sich die Verstöße der Suche 10 s nicht verbessert haben, frühestens 8 s nach der letzten Änderung (reine Rechenzeit ohne Pausen). +- **Ohne Zeitgrenzen:** Nächster Schritt, wenn sich die Verstöße der Suche 10 s und 500.000 berechnete Pläne lang nicht verbessert haben, frühestens 8 s nach der letzten Änderung (reine Rechenzeit ohne Pausen). - **Änderung der Einteilung während des Laufs:** keine neue Basisoptimierung; sofort weiter beim wichtigsten geänderten Kriterium – in A/B zuerst nur dieses Kriterium, dann die ganze Stufe; in C ab diesem Kriterium. - **Ergebnis:** der beste Plan nach Stufen, die angepassten Gewichtungen und die Liste der Änderungen (`Inseloptimierer.Lenkung`). Spezial-Insel im Automodus aus. - *Beispiel (Verbandsoberliga, 3 min):* A 18 → 3, B 13 → 4 bei etwa gleich vielen C-Verstößen; Peter: „arbeitet sehr gut“. @@ -737,3 +762,39 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa | 05.10.2026 | Anleitung: eigener Abschnitt 5.1 „Der Automodus“ (Phasen, Wertung des besten Plans, Eingreifen, Wahl des Verfahrens); Abschnitt 3.5 verweist darauf. Nach dem Start zeigen Ansichten, die „Start mit“ auf den Ausgangsplan gestellt hatte, wieder die laufende Generierung (vorher blieb dort der unveränderte Ausgangsplan stehen). Bildschirmfotos von Teilen des Fensters: ganzes Fenster zeichnen und zuschneiden (beim Zeichnen nur eines Teils zerfiel die Schrift). | | 10.10.2026 | macOS (Weg zu 1.0, Vorgabe Peter): Release-Job `macos` baut für osx-arm64 und osx-x64 ein App-Paket (`src/AndiGenerator.Desktop/macOS`: Info.plist, Symbol .icns, `paket-bauen.sh`), signiert es ad hoc (ohne Apple-Entwicklerkonto keine Notarisierung, erster Start über „Dennoch öffnen“) und hängt je ein .dmg an das Release; ohne Selbst-Update wie unter Linux. CI baut, testet und startet die App zusätzlich auf macOS (Rauchtest mit Bildschirmfoto), da kein Test-Mac vorhanden ist. Oberfläche: Zoom auch mit ⌘ (Tasten und Mausrad), Programmmenü des Macs mit „Über“ und „Anleitung“. Anleitung: Abschnitt Mac; Chromebook-Installation per Terminal (`sudo apt install`), da ChromeOS .deb-Dateien nicht mehr per Doppelklick installiert. | | 10.10.2026 | Windows auf ARM (Snapdragon-Laptops): Release veröffentlicht zusätzlich win-arm64 und packt es als eigenen Velopack-Kanal `win-arm64` (eigenes Setup und Portable-ZIP, Updates nur in diesem Kanal), hochgeladen mit `--merge` ins selbe Release. CI testet zusätzlich auf `windows-11-arm`. Anleitung und README nennen die ARM-Dateien. | +| 10.10.2026 | Automodus, Übergang aus der Basisoptimierung (Befund Peter: hing lange fest, weil alle ~9 s eine winzige Verbesserung die 10-s-Uhr zurücksetzte): Nur Verbesserungen um mindestens `AutoDeutlich` (1 %) gegenüber der letzten deutlichen zählen; Stillstand verlangt neben 10 s auch `AutoStillstandPlaene` (500.000) berechnete Pläne (gleich viel Suche auf langsamen Rechnern, auch beim Drängen und Glätten); Basisoptimierung höchstens `AutoMaxGrundlauf` (180 s). Tests in `InseloptimiererTests`. | +| 10.10.2026 | Automodus-Anzeige (Befund Peter): Nach der Basisoptimierung folgt die erste Anhebung sofort (vorher wartete Stufe A noch einen Stillstand ab und zeigte lange kein Kriterium unter „In Arbeit“). Ein verschobenes Kriterium bleibt 5 s (Rechenzeit) vorn unter „In Arbeit“, auch wenn der Automodus mangels Verbesserungsmöglichkeit schon weiter ist. | +| 10.10.2026 | Maschinenraum (Wunsch Peter): `Inseloptimierer.Maschinenraum` liefert Pläne je Rechen-Thread und den Stand jeder Insel (Kosten, Pläne ohne Verbesserung bis zum Neustart, Neustarts, Suchstrategien) samt Spezial-Insel; `Optimierungsstand.Maschinenraum`. Kachel „Berechnete Pläne“ zeigt je Kern einen Lastbalken (Höhe nach Plänen/s im Verhältnis zum schnellsten Kern, sanft animiert) und öffnet per Klick die neue Ansicht „Maschinenraum“ (Kerne, Inseln, Spezial-Insel, Protokoll des Automodus). Bildschirmfoto 19, Anleitung Abschnitt 4. | +| 10.10.2026 | Maschinenraum im Automodus (Vorgabe Peter): Inseln zeigen die Verstöße A/B/C ihres besten Plans (`Inselstand.Verstoesse`, aus `Automodus.Pruefen`) statt der Kosten; beste Insel nach Stufenwert. Statt des Protokolls die aktuell geänderten Gewichte (`Lenkungsstand.Gewichte`, `Gewichtsanhebung`) je Kriterium in der Reihenfolge der Einteilung, als Skala mit Ring (Gewichtung des Anwenders), Punkt (aktuelle) und Linie dazwischen – eine gefüllte Stufenleiste sah aus wie mehrere gleichzeitig gewählte Werte (Befund Peter). | +| 10.10.2026 | Maschinenraum: Statt der Suchstrategie-Kürzel (für Anwender unverständlich, Befund Peter) zeigt jede Insel die drei größten Kostenverursacher ihres besten Plans mit Anteil und Balken (`Inselstand.Kostentreiber`, bewertet mit den aktuellen Gewichten), im Automodus die Kriterien mit den meisten Verstößen (`Automodus.Verursacher`); die Kürzel bleiben als Hinweis beim Darüberfahren. | +| 11.10.2026 | Maschinenraum für viele Kerne und mit Verlauf (Vorgabe Peter): Kachel höchstens 16 Säulen (Gruppen benachbarter Kerne, Durchschnitt), Ansicht bis 32 Kerne Säulen über die verfügbare Breite, darüber ein Raster aus Kästchen (Deckkraft = Last). Neu `Suchverlauf` (Punkte im Takt der Statusabfrage, Zeit ohne Pausen, ab 800 Punkten auf halbe Auflösung verdichtet) und `Verlaufsdiagramm`: kleine Diagramme mit gemeinsamer Zeitachse (keine zweite y-Achse) – Kosten bzw. Verstöße A/B/C, darunter Pläne/s –, im Automodus Phasenbänder und Striche bei Anhebungen; Fadenkreuz mit Werten unter der Maus. | +| 11.10.2026 | Verlauf als eigene Ansicht „Verlauf“ (formatfüllend, Klick auf die Kachel *Verbesserungen*) und klein in dieser Kachel (`Verlaufsdiagramm.Kompakt`). Im Maschinenraum stattdessen das Schaubild der Maschine (`Maschinenschaubild`, Zustand `Schaubild`): Kerne → Suchplätze → Inseln → Koordinator → bester Plan, Spezial-Insel bzw. Automodus als Steuerpult; fließende Punkte während der Rechnung; Ereignisse (Verbesserung, Neustart, Übergabe, besserer bester Plan, Anhebung) aus dem Vergleich zweier Stände, die kurz aufleuchten (Takt 40 ms). | +| 11.10.2026 | Schaubild richtiggestellt (Befund Peter: sah aus, als fänden die Inseln die Verbesserungen): Suchplätze als Punkte in ihren Inseln, Verbesserung beginnt beim Suchplatz (`Insel.Erfolge`/`ErfolgPlatz`, `Inselstand.Erfolge`/`ErfolgPlatz`), Welle durch die Insel für das Verteilen an alle Suchplätze, im Automodus Linie vom Steuerpult zum besten Plan (er wählt nach A/B/C). | +| 11.10.2026 | Schaubild ohne Rechenkerne (Vorgabe Peter: stehen links daneben, für die Übersicht des Algorithmus unerheblich); die Inseln nehmen die linke Hälfte ein, ihre Suchplätze flackern leicht, solange gerechnet wird. | +| 11.10.2026 | Schaubild: Suchplatz-Punkte zeigen ihre Trefferquote als Farbstärke (`Insel.Treffer`, im `Schaubild` mit 20 s Zeitkonstante verblassend, relativ zum erfolgreichsten Suchplatz) und ihre Suchstrategie als Größe (`Tauschstrategie.Umfang`, logarithmisch). Die Qualität je Suchplatz wird bewusst nicht gezeigt: nach jedem Abgleich (200 ms) stehen alle Suchplätze einer Insel auf ihrem Plan. | +| 11.10.2026 | Hebel im Maschinenraum (Wunsch Peter): Inseln (1–32) und Suchplätze je Insel (14–64; 14 feste Strategien, der Rest anpassbar) einstellbar, gespeichert in den Programmeinstellungen (`inseln`, `suchplaetze-je-insel`). `Optimierungsdienst.MaschineAendern` setzt eine laufende Suche mit dem besten Plan neu auf (Automodus ohne Basisoptimierung). CLI: `andigen optimieren --inseln N`; `inselvergleich.cmd` vergleicht 2/4/8/16 Inseln je 5 Minuten. | +| 11.10.2026 | Hebel im Maschinenraum wieder entfernt (Vorgabe Peter: für Staffelleiter uninteressant); die Zahl der Inseln bleibt nur für Messungen über `andigen optimieren --inseln N` bzw. `inselvergleich.cmd` einstellbar. | +| 11.10.2026 | Schaubild beruhigt (Vorgabe Peter: zu ausführlich und zu unruhig): keine Suchplatz-Punkte, kein Flackern, keine Welle mehr; je Insel nur ihr Wert, grob ihre Tätigkeit (`Inselzustand`: kommt voran / sucht / steckt fest – bald Neustart / beginnt neu) und ein schmaler Balken bis zum Neustart. Ein grüner Punkt wandert nur noch, wenn der beste Plan besser wird (von seiner Insel über den Koordinator); der Takt zeichnet nur, solange ein Ereignis läuft. `Insel.Treffer`/`ErfolgPlatz` und `Tauschstrategie.Umfang` entfallen, `Insel.Erfolge` bleibt. | +| 11.10.2026 | Maschinenraum: Nach einer Verbesserung des besten Plans steht unter *Bester Plan* einige Sekunden, was sich geändert hat (Wunsch Peter). `Maschinenraum.BesterPlan` liefert die Kriterien des besten Plans (`Kriterienwert`: Kosten je Kriterium, im Automodus nach dem Grundlauf Verstöße je Kriterium mit Stufe, `Automodus.BesteKriterien`), neu berechnet nur, wenn sich der beste Plan geändert hat. `Schaubild.Vergleichen` zeigt höchstens vier Änderungen: Kosten nach Betrag der Differenz, Verstöße nach Stufe, dann Betrag; Verbesserungen grün, Verschlechterungen rot. | +| 11.10.2026 | Grüner Punkt und Änderungen passten nicht immer zusammen (Befund Peter): Der Punkt hing an `Optimierungsstand.Verbesserungen` (eigene Abfrage, im Automodus samt Kostenverbesserungen, die den besten Plan nach Stufen nicht ändern), die Änderungen an `Maschinenraum.BesterPlan`. Jetzt beruhen beide auf `Maschinenraum.BesterPlanNummer`, die zusammen mit `BesterPlan` ermittelt wird und nur weiterzählt, wenn sich der geltende beste Plan ändert; kleine Kostenänderungen (ab 0,005) zählen mit, ohne sichtbare Änderung steht „Feinschliff“. Die Änderungen stehen auffälliger in einem eigenen Kasten *Verbesserung* direkt unter dem besten Plan; das Schaubild ist mindestens 300 Pixel hoch. | +| 11.10.2026 | Kasten *Verbesserung* blieb bei häufigen Verbesserungen fast nie sichtbar (Befund Peter): Er erschien erst nach der Wanderzeit des grünen Punkts (1,4 s), und jede neue Verbesserung blendete ihn für diese Zeit wieder aus. Jetzt erscheint er sofort, wird bei jeder Verbesserung aktualisiert und verschwindet 10 s nach der letzten (Vorgabe Peter). | +| 11.10.2026 | Kasten *Verbesserung* zeigt eine Änderung erst, wenn ihr grüner Punkt beim besten Plan ankommt (Vorgabe Peter; `Schaubild.Wanderzeit` 1,4 s gilt für Punkt und Kasten). Solange ein neuer Punkt wandert, bleibt die vorige Änderung stehen (`AngekommeneAenderungen`); kommt währenddessen noch eine Verbesserung, fasst die Änderung beide zusammen. | +| 11.10.2026 | Zusammenfassen wieder entfernt (Befund Peter: angekommene Punkte ohne Anzeige): Jeder grüne Punkt trägt seine eigene `Verbesserungsmeldung` (Änderungen gegenüber dem vorigen besten Plan, Zeitstempel = Start des Punkts). Der Kasten zeigt die Meldung des zuletzt angekommenen Punkts, also bei jeder Ankunft sofort die passende Änderung, und verschwindet `Schaubild.Anzeigedauer` (10 s) nach der letzten Ankunft. | +| 11.10.2026 | Kachel *Verbesserungen*, Verlauf und Maschinenraum zählen dieselben Verbesserungen (Befund Peter: Kachel zählte mehr). Bisher zählte `Inseloptimierer` jede Insel, die im Optimierer-Schritt den besten Plan unterbot (mehrere je Schritt), und im Automodus zusätzlich die Kostensenkungen, die den besten Plan nach Stufen nicht ändern; außerdem holte der Stand Plan und Maschinenraum mit getrennten Sperren. Jetzt: `planVerbesserungen` zählt höchstens eine je Schritt, nur für den geltenden besten Plan (Kosten bzw. nach dem Grundlauf Stufen; der Wechsel zählt nicht); `Optimierungsergebnis.Verbesserungen` und `Maschinenraum.BesterPlanNummer` sind diese Zahl; `Inseloptimierer.Momentaufnahme` liefert Plan, Lenkung und Maschinenraum unter einer Sperre; der Dienst meldet genau bei einer neuen Verbesserung. Der Kasten im Schaubild nennt die Nummer(n) wie die Kachel. Hinweis: Die Zahl der Verbesserungen in `andigen optimieren` ist dadurch kleiner als bisher. | +| 11.10.2026 | Inselkarten: „147 T von 100 T Plänen ohne Verbesserung“ war unverständlich (Befund Peter). Kein Fehler: Wie im Original beginnt nur die schlechteste Insel neu, und nur über der Grenze; die anderen rechnen weiter. `Inselstand.Schlechteste` (wie beim Neustart: höchste Kosten) macht das sichtbar: Der Text sagt „Neustart frühestens nach 100 T“, „beginnt gleich neu (schlechteste Insel)“ oder „rechnet weiter, neu beginnt nur die schlechteste Insel“; im Schaubild entsprechend *findet lange nichts*, *beginnt gleich neu*, *steckt fest, rechnet weiter*. | +| 11.10.2026 | Maschinenraum heißt für den Anwender jetzt **Leitstand** (Wahl Peter; intern bleiben die Namen `Maschinenraum…`). Erster Eingriff im Leitstand: Die Gewichte des Automodus stehen oberhalb der Inseln, und je Gewicht nimmt **−** eine Stufe zurück, bis „sehr wenig“, auch unter die Gewichtung des Anwenders (`Inseloptimierer.GewichtSenken` → `Automodus.Senken`, die Inseln rechnen mit den neuen Gewichten weiter). Das Gewicht ist danach `Automodus.HandGesenkt` = 10 s reine Rechenzeit gesperrt (Pausen zählen nicht; unabhängig vom Tempo des Rechners): `Ziele`/`ZieleC` heben es so lange nicht an; `Gewichtsanhebung.Gesperrt` zeigt die Restzeit („von Hand gesenkt · noch 7 s“). `Niedriger` hebt ein Gewicht unter der Gewichtung des Anwenders beim Zurückziehen nicht mehr an. | +| 11.10.2026 | Eingriff an den Gewichten wieder entfernt (Vorgabe Peter: der Automodus macht das gut, der Standard reicht). Es bleiben der Name **Leitstand** und die Gewichte oberhalb der Inseln; `Automodus.Senken`, die Sperre `HandGesenkt`, `GewichtSenken` in Optimierer, Dienst und Hauptfenster sowie `Gewichtsanhebung.Schluessel`/`Gesperrt` entfallen. | +| 11.10.2026 | Steuerung verständlicher (Befund Peter): **Generierung beenden** und **Staffel schließen** statt „Beenden“/„Schließen“; *Staffel schließen* steht bei der Staffel, getrennt von der Generierung. Das Verfahren wählt ein sichtbarer Umschalter **Kostenoptimierung \| Automodus** mit einer Zeile Erklärung (`VerfahrenErklaerung`) statt des Pfeilmenüs am Startknopf; vor dem Start wählt er nur, während der Generierung wechselt er (`KostenoptimierungText`/`AutomodusText` entfallen). **Gesicherter Plan** (nicht im Original): je Staffel einer (`Gesicherter Plan.sicherung` im Staffelordner, Format eines gemerkten Plans, die Endung hält ihn aus den gemerkten Plänen und dem Original heraus). Bevor ein Plan verworfen würde (neuer Start, Staffel schließen, andere Staffel öffnen, Programm beenden), wird er gesichert: ohne gesicherten Plan automatisch mit Meldung, sonst mit Rückfrage und `Planvergleich` (Verstöße A/B/C, Kosten, Kriterien mit Unterschieden; Urteil wie der Automodus) im `Sicherungsdialog`: ersetzen, behalten oder abbrechen. Über einen Plan wird nur einmal entschieden; während der Frage ruht die Generierung. | +| 11.10.2026 | Kopf entlastet (Befund Peter: überladen, Staffelname nicht lesbar): *Generierung beenden* ist jetzt **Stopp** auf dem Startknopf während der Generierung (`GenerierungCommand` startet bzw. stoppt); Pausieren/Fortsetzen als kleiner Symbolknopf daneben (`PauseCommand`, `PauseText`), nur während der Generierung; *Staffel schließen* als Kreuz links vor dem Namen; *Verfahren* als zwei Optionsfelder untereinander, die Erklärung im Tooltip statt einer eigenen Zeile. | +| 11.10.2026 | Kopf nach Wunsch Peter: nur Symbolknöpfe – vor dem Start **▶** (Start) und **✕** (Staffel schließen), während der Generierung **⏸** bzw. in der Pause **▶** (Pausieren/Fortsetzen, `GenerierungCommand`) und **■** (Stopp, `StoppenCommand`); `GenerierungText` ist nur noch Tooltip. *Verfahren* als echte Optionsfelder (`RadioButton.segment`, gestaltet wie die Umschalter der Ansichten, genau eines gewählt; vorher ließen sich beide Umschalter abwählen). | +| 11.10.2026 | Spielplandaten intuitiver, Pakete 1 und 2 (Wunsch Peter): **Setzliste** wird wie die Qualitätsansicht am Griff gezogen (Zeile am Mauszeiger, Lücke mit den neuen Plätzen, Escape; `SetzlistenSeiteViewModel.ZiehenBeginnen/ZiehenUeber/ZiehenBeenden`, `Setzlistenzeile`); ausgeschaltet ist die Liste ausgegraut. **Wunschtermine** als farbige Kärtchen statt Textfeldern mit Kürzeln (Heimspiel, Ausweichtermin, Sperrtermin, leer; Zusätze klein darunter; Legende; Tooltip ausgeschrieben); Klick öffnet den Dialog, Ziehen verschiebt einen Termin auf einen anderen Tag (Strg kopiert, belegt = tauschen; `WunschterminDetailViewModel.Verschieben`). Die Kurzschreibweise des Originals bleibt intern (`Heimtagtext`). | +| 11.10.2026 | Spielplandaten, Paket 3: einheitliche Bedienung der Listen (`Listenbedienung`: Doppelklick/Eingabetaste bearbeiten, Entf löschen) auf den Seiten Mannschaften, Pflichtspieltage, Manuell festgelegte Begegnungen, Auswärtskoppeltermine und Nachbarmannschaften; bei den Begegnungen stehen die Knöpfe jetzt rechts wie überall. Seitenliste mit Marke (`DatenSeiteViewModel.Marke`: Zahl der Einträge bzw. an/aus) und Punkt für „in diesem Dialog geändert“ (`Bearbeitet`). Seitennamen ohne „(Kompakt…)“: *Spiellokale*, *Heimkoppel und Doppelspieltage*, *Wochenende (60-km-Regel)*. | +| 11.10.2026 | Spielplandaten, Paket 4: **Spielfreie Tage** als Monatskalender statt einer langen Liste von Häkchen (`SpielfreieTageSeiteViewModel.Monate`, `Kalendermonat`, `Kalendertag`); wählbar wie bisher nur die Tage mit Heimspielwünschen (`MoeglicheSpieltage`), ein Klick schaltet spielfrei (rot) an/aus. Ferien und Feiertage kennt das Programm nicht; sie sind deshalb nicht markiert. | +| 11.10.2026 | Spielplandaten, Paket 5: *Allgemein* mit Hinweisen neben den Feldern (statt „*“-Fußnote) und Prüfung schon beim Eingeben (`LigadatenSeiteViewModel.Pruefhinweis`); *Spiellokale* mit „nur eines“/„Standard“/„Spiellokal n“ statt leer/Nummer (`Konverter.Standardlokal`/`Spiellokal`; Namen der Hallen liefert click-TT nicht); *Heimkoppel und Doppelspieltage* mit Erklärung von Koppel- und Doppelspieltag; *Auswärtskoppeltermine*: Vorschlag als Platzhalter (`ZweitzeitZeile.Platzhalter`), Knöpfe „Vorschläge für alle eintragen“/„Alle Zeiten leeren“; *Heimrecht* mit Umschalter Vorrunde/egal/Rückrunde (`HeimrechtZeile.IstVorrunde/IstEgal/IstRueckrunde`, `RadioButton.segment`). Die Art bleibt ein Feld mit Vorschlägen (auch eigene Arten möglich). | +| 11.10.2026 | Spielfreie Tage (Befund Peter): Die Monate brechen um (Breite folgt dem sichtbaren Bereich, weil der Dialog waagrecht scrollt). Graue Tage sind nicht gesperrt, sondern haben keine Heimspielwünsche; Tooltip und Legende sagen das, außerhalb der Saison bzw. als spielfrei eingetragen ohne Heimspielwünsche (`Datenbearbeitung.EingetrageneSpielfreieTage`, rot unterstrichen) wird eigens genannt. | +| 11.10.2026 | Wunschtermine (Befund Peter: Klick auf einen leeren Tag zeigte nur „kein Heimspiel“, Uhrzeit und Halle waren nicht zu sehen): Im Dialog „Wunschtermin“ ist die Art ein Umschalter Heimspiel / kein Heimspiel / Sperrtermin statt einer Auswahlliste (`ArtHeimspiel`, `ArtKeinHeimspiel`, `ArtSperrtermin`); ein leerer Tag wird gleich als Heimspiel zur üblichen Uhrzeit der Mannschaft vorgeschlagen (`WunschterminDetailViewModel.UeblicheUhrzeit`: häufigste Zeit am selben Wochentag, sonst insgesamt, sonst 18:00). Der Hinweis über der Seite beschreibt die Kärtchen statt der Texteingabe des Originals. | +| 11.10.2026 | Knöpfe neben den Listen in „Spielplandaten bearbeiten“ (Befund Peter: unscheinbar, je nach Seite und Fensterbreite verschieden weit rechts, Text teils abgeschnitten): einheitliche Stile `StackPanel.seitenknoepfe` (220 breit) und `Button.seitenknopf` (hellblau, halbfett, 40 hoch) in `Stil.axaml`; Listen haben feste Breiten und stehen mit den Knöpfen in einem linksbündigen `DockPanel`, Erklärtexte darüber sind auf 700 begrenzt (der Dialog scrollt waagrecht, sonst bestimmt die längste Textzeile die Breite). Betrifft Mannschaften, Setzliste, Pflichtspieltage, festgelegte Begegnungen, Auswärtskoppel- und Nachbarmannschaften | +| 11.10.2026 | Wunschtermine (Befund Peter: oberer Text zu lang und doppelt): Der Kopftext der Seite entfällt, Legende und Bedienzeile über dem Kalender sagen dasselbe; der Hinweis auf den Vorschlag für leere Tage steht jetzt in der Bedienzeile. Kopftexte der Mehr-Mannschaften-Seiten sind auf 900 breit begrenzt (der Dialog scrollt waagrecht) | +| 11.10.2026 | Zeit- und Datumsauswahl (Befund Peter): Leere Felder zeigen „Stunde“/„Minute“ bzw. „Tag“/„Monat“/„Jahr“ statt der fest englischen Avalonia-Platzhalter (`Platzhalterdeutsch`, beim Start in `App` angemeldet) | +| 11.10.2026 | Rückfrage „nur Rückrunde“ (Befund Peter: im neuen Programm nicht gesehen): ist seit dem Einrichtungs-Umbau ein Schritt **Rundenplanung** der Einrichtungsseite nach dem Öffnen (gleiche Bedingung wie `OpenFile`: höchstens 60 Tage vor oder nach `mid`, Rundenplanung weder „nur Rückrunde“ noch Corona). Die Frage nennt jetzt den Beginn der Rückrunde, weil das Original schon vor dem Ende der Vorrunde fragt (`RueckrundenFrage`) | +| 11.10.2026 | Checkliste für Version 1.0 in Abschnitt 8 (Muss/Sollte/Abrundung) | diff --git a/inselvergleich.cmd b/inselvergleich.cmd new file mode 100644 index 0000000..4422da9 --- /dev/null +++ b/inselvergleich.cmd @@ -0,0 +1,21 @@ +@echo off +rem Vergleich der Zahl der Inseln (2, 4, 8, 16): je 5 Minuten Kostenoptimierung der Verbandsoberliga aus einem leeren +rem Plan, zusammen gut 20 Minuten. Der Verlauf steht im Fenster; je Variante zusaetzlich in inselvergleich-N.txt +rem (wird von Claude gelesen). Vorher bauen.cmd ausfuehren und den Rechner in der Zeit moeglichst nicht belasten. +chcp 65001 >nul +cd /d "%~dp0" +set "EXE=%LOCALAPPDATA%\AndiGenerator.NET\artifacts\bin\AndiGenerator.Cli\release\andigen.exe" +set "STAFFEL=%~dp0testdaten\referenz\perf\Verbandsoberliga (1).xml" +if not exist "%EXE%" ( + echo andigen.exe nicht gefunden - bitte zuerst bauen.cmd ausfuehren. + pause + exit /b 1 +) +for %%n in (2 4 8 16) do ( + echo. + echo ================================ %%n Inseln ================================ + "%EXE%" optimieren "%STAFFEL%" --leer --sekunden 300 --intervall 15 --inseln %%n --ausgabe "%~dp0inselvergleich-%%n.txt" +) +echo. +echo Fertig. Ergebnisse in inselvergleich-2.txt ... inselvergleich-16.txt - Claude kann sie jetzt lesen. +pause diff --git a/src/AndiGenerator.Application/Datenbearbeitung.SpielfreieTage.cs b/src/AndiGenerator.Application/Datenbearbeitung.SpielfreieTage.cs index 4a6919f..caa23c8 100644 --- a/src/AndiGenerator.Application/Datenbearbeitung.SpielfreieTage.cs +++ b/src/AndiGenerator.Application/Datenbearbeitung.SpielfreieTage.cs @@ -19,6 +19,14 @@ public sealed partial class Datenbearbeitung /// Die Tage, sortiert. public IReadOnlyList SpielfreieTage() => SpielfreieTageAus(Arbeitsstand); + /// + /// Liest alle eingetragenen spielfreien Tage, auch die, an denen keine Mannschaft einen Heimspielwunsch hat (sie + /// bleiben beim Speichern erhalten, sind aber nicht wählbar). + /// + /// Die Tage, sortiert. + public IReadOnlyList EingetrageneSpielfreieTage() => + Arbeitsstand.KinderMitNamen("nogameday").Select(k => Datum(k.Lesen("date"))).Distinct().Order().ToList(); + /// Liest die spielfreien Tage des click-TT-Stands. /// Die Tage oder null ohne Standard. public IReadOnlyList? SpielfreieTageStandard() => standard is null ? null : SpielfreieTageAus(standard); diff --git a/src/AndiGenerator.Application/Optimierungsdienst.cs b/src/AndiGenerator.Application/Optimierungsdienst.cs index 0f39c63..cc9c160 100644 --- a/src/AndiGenerator.Application/Optimierungsdienst.cs +++ b/src/AndiGenerator.Application/Optimierungsdienst.cs @@ -227,7 +227,7 @@ private Optimierungsstand StandIntern() return Optimierungsstand.Leer; } - Optimierungsergebnis bester = optimierer.BesterStand(); + (Optimierungsergebnis bester, Lenkungsstand? lenkung, Maschinenraum raum) = optimierer.Momentaufnahme(); return new Optimierungsstand( true, pausiert, @@ -238,7 +238,8 @@ private Optimierungsstand StandIntern() seitVerbesserung.Elapsed, optimierer.SpezialKostenart, bester.Spiele, - optimierer.Lenkung); + lenkung, + raum); } private void NeuStarten(Staffel staffel, Berechnungsoptionen optionen, bool ausgangsplanMelden, bool ohneGrundlauf = false) @@ -274,10 +275,11 @@ private void Beobachten(CancellationToken token) continue; } - // Im Automodus zählt die Bewertung nach Verstößen; dort kann der beste Plan auch teurer werden. + // Gemeldet wird jede Verbesserung des geltenden besten Plans (im Automodus nach Verstößen, dort kann er auch teurer + // werden) – dieselbe Zahl, die Kachel, Verlauf und Maschinenraum zeigen. Optimierungsergebnis bester = optimierer.BesterStand(); double kosten = bester.Kosten; - bool besser = kosten < letzteKosten || letzteKosten < 0 || (optimierer.Lenkung is not null && bester.Verbesserungen != letzteVerbesserungen); + bool besser = letzteKosten < 0 || bester.Verbesserungen != letzteVerbesserungen; if (kosten >= 0 && besser) { letzteKosten = kosten; diff --git a/src/AndiGenerator.Application/Optimierungsstand.cs b/src/AndiGenerator.Application/Optimierungsstand.cs index 33c8e94..0d4237e 100644 --- a/src/AndiGenerator.Application/Optimierungsstand.cs +++ b/src/AndiGenerator.Application/Optimierungsstand.cs @@ -19,6 +19,7 @@ namespace AndiGenerator.Application; /// Kostenart der Spezial-Insel, wenn sie läuft. /// Der beste Plan (alle Spiele; = null für nicht terminierte). /// Stand des Automodus; null, wenn er nicht läuft. +/// Was Rechenkerne und Inseln gerade tun; null ohne laufende Generierung. public sealed record Optimierungsstand( bool Laeuft, bool Pausiert, @@ -29,7 +30,8 @@ public sealed record Optimierungsstand( TimeSpan SeitLetzterVerbesserung, MannschaftsKostenart? SpezialKostenart, IReadOnlyList Spiele, - Lenkungsstand? Lenkung = null) + Lenkungsstand? Lenkung = null, + Maschinenraum? Maschinenraum = null) { /// Stand ohne laufende Generierung. public static Optimierungsstand Leer { get; } = new(false, false, 0, 0, -1, 0, TimeSpan.Zero, null, []); diff --git a/src/AndiGenerator.Application/Plansitzung.cs b/src/AndiGenerator.Application/Plansitzung.cs index 5a95228..8fe2e91 100644 --- a/src/AndiGenerator.Application/Plansitzung.cs +++ b/src/AndiGenerator.Application/Plansitzung.cs @@ -26,6 +26,9 @@ public sealed class Plansitzung /// Früherer Name des eigenen Ordners (bis zur Umbenennung in AndiGenerator.NET am 29.09.2026). public const string FruehererOrdnername = "AndiGeneratorNeu"; + /// Anzeigename des gesicherten Plans. + public const string GesicherterPlanName = "Gesicherter Plan"; + /// Kosten je hartem Fehler im Original (10 × cMaxKostenNoHardError). private const double KostenJeHartemFehler = 10.0 * 1_000_000_000.0; private const string Uebernahmevermerk = "uebernommen-aus-AndiGeneratorNeu.txt"; @@ -296,6 +299,26 @@ public GemerkterPlanEintrag PlanMerken(string name, IEnumerable spiele) return new GemerkterPlanEintrag(name.Trim(), pfad, File.GetLastWriteTime(pfad)); } + /// Der gesicherte Plan der Staffel, sofern es ihn gibt. + /// Der Eintrag (Name „Gesicherter Plan“) oder null. + public GemerkterPlanEintrag? GesicherterPlan() + { + string pfad = Persistence.Gemeinsam.Staffelordner.GesicherterPlanPfad(Staffelordner); + return File.Exists(pfad) ? new GemerkterPlanEintrag(GesicherterPlanName, pfad, File.GetLastWriteTime(pfad)) : null; + } + + /// Sichert einen Plan als gesicherten Plan der Staffel (ein bisher gesicherter wird ersetzt). + /// Spiele des Plans. + /// Der Eintrag. + public GemerkterPlanEintrag PlanSichern(IEnumerable spiele) + { + ArgumentNullException.ThrowIfNull(spiele); + Directory.CreateDirectory(Staffelordner); + string pfad = Persistence.Gemeinsam.Staffelordner.GesicherterPlanPfad(Staffelordner); + GespeicherterPlanDatei.Speichern(pfad, spiele.Select(s => new GespeichertesSpiel(s.Zeitpunkt, s.Heim, s.Gast))); + return new GemerkterPlanEintrag(GesicherterPlanName, pfad, File.GetLastWriteTime(pfad)); + } + /// Lädt die Spiele eines gemerkten Plans. /// Eintrag aus . /// Die Spiele (ohne Spiellokal). diff --git a/src/AndiGenerator.Engine/Inseln/Automodus.cs b/src/AndiGenerator.Engine/Inseln/Automodus.cs index e02d226..3235df8 100644 --- a/src/AndiGenerator.Engine/Inseln/Automodus.cs +++ b/src/AndiGenerator.Engine/Inseln/Automodus.cs @@ -42,12 +42,18 @@ internal sealed class Automodus /// Mindestdauer des Grundlaufs in Sekunden. private const double MinGrundlauf = 10; - /// Sekunden ohne Verbesserung der Suche, ab denen sie als festgefahren gilt. + /// Sekunden ohne Verbesserung der Suche, ab denen sie als festgefahren gilt (mit mindestens Plänen). private const double Stillstand = 10; /// Mindestabstand zweier Änderungen in Sekunden. private const double MinPhase = 8; + /// + /// Sekunden, die ein verschobenes Kriterium unter „In Arbeit“ stehen bleibt, auch wenn der Automodus schon weiter ist + /// (etwa weil es nichts zu verbessern gab): So sieht der Staffelleiter, dass die Verschiebung angekommen ist. + /// + private const double AnzeigeVerschoben = 5; + /// Änderungen ohne weniger A/B-Verstöße im Plan der Suche, nach denen das Drängen endet. private const int DraengenOhneFortschritt = 3; @@ -61,6 +67,9 @@ internal sealed class Automodus private readonly int spielraum; private readonly double kostenToleranz; private readonly Berechnungsoptionen original; + private readonly double deutlich; + private readonly long stillstandPlaene; + private readonly double maxGrundlauf; private readonly double[] anzahl = new double[Stufeneinteilung.AnzahlKriterien]; private readonly double[] kosten = new double[Stufeneinteilung.AnzahlKriterien]; private readonly double[] grenzeKosten = new double[Stufeneinteilung.AnzahlKriterien]; @@ -75,7 +84,9 @@ internal sealed class Automodus private (int A, int B) bezug; private double letzteAenderung; private double letzteSuche; - private double sucheKosten = -1; + private long plaeneLetzteSuche; + private long plaene; + private double deutlicheKosten = -1; private (int, int, int, int, int)? sucheVerstoesse; private double sekunden; private int zurueckgezogen; @@ -85,6 +96,9 @@ internal sealed class Automodus private int[] reihenfolgeC; private Kostenkriterium[] draengKriterien = []; private Kostenkriterium? fokus; + private Kostenkriterium? verschoben; + private double verschobenBis; + private double zuletzt; private bool sofort; /// Legt den Automodus an. @@ -103,6 +117,9 @@ public Automodus(RefPlan referenz, KernDefinition definition, Optimierungseinste reihenfolgeC = einteilung.C.Select(k => (int)k).ToArray(); spielraum = einstellungen.AutoSpielraum; kostenToleranz = einstellungen.AutoKostenToleranz; + deutlich = einstellungen.AutoDeutlich; + stillstandPlaene = einstellungen.AutoStillstandPlaene; + maxGrundlauf = einstellungen.AutoMaxGrundlauf; original = referenz.Optionen; Optionen = referenz.Optionen; basisMannschaft = new int[ids.Length * KernDefinition.AnzahlArten]; @@ -155,7 +172,8 @@ private enum Phase zurueckgezogen, Optionen, aenderungen.ToArray(), - AktuelleKriterien); + AktuelleKriterien, + Anhebungsliste()); /// Rang der laufenden Phase wie bei : Drängen A = 0, Drängen B = 1, Glätten des i-ten C-Kriteriums = 2 + i. private int Rang => phase switch @@ -165,13 +183,23 @@ private enum Phase _ => -1, }; - /// Die Kriterien, an denen gerade gearbeitet wird: beim Drängen die zuletzt angehobenen, beim Glätten das aktuelle C-Kriterium. - private Kostenkriterium[] AktuelleKriterien => phase switch + /// + /// Die Kriterien, an denen gerade gearbeitet wird: beim Drängen die zuletzt angehobenen, beim Glätten das aktuelle + /// C-Kriterium; ein eben verschobenes Kriterium steht noch Sekunden vorn. + /// + private Kostenkriterium[] AktuelleKriterien { - Phase.Draengen => draengKriterien, - Phase.Glaetten when indexC < reihenfolgeC.Length => [(Kostenkriterium)reihenfolgeC[indexC]], - _ => [], - }; + get + { + Kostenkriterium[] aktuell = phase switch + { + Phase.Draengen => draengKriterien, + Phase.Glaetten when indexC < reihenfolgeC.Length => [(Kostenkriterium)reihenfolgeC[indexC]], + _ => [], + }; + return verschoben is Kostenkriterium v && zuletzt < verschobenBis && !aktuell.Contains(v) ? [v, .. aktuell] : aktuell; + } + } /// Bezeichnung der Phase für die Oberfläche: Basisoptimierung, dann Optimierung der Stufe A, B oder C. private string Bezeichnung => phase switch @@ -188,28 +216,43 @@ private enum Phase _ => indexC < reihenfolgeC.Length ? $"Glätten {(Kostenkriterium)reihenfolgeC[indexC]}" : "Glätten", }; - /// Merkt sich im Grundlauf, wann die Suche (Kostenoptimierung) zuletzt besser geworden ist. + /// Übernimmt die Zahl der bisher berechneten Pläne (für den Stillstand nach Rechenleistung statt nur nach Zeit). + /// Berechnete Pläne seit dem Start. + public void Zaehlen(long berechnet) => plaene = berechnet; + + /// + /// Merkt sich im Grundlauf, wann die Suche (Kostenoptimierung) zuletzt deutlich besser geworden ist: um mindestens + /// gegenüber der letzten deutlichen Verbesserung. Kleine + /// Verbesserungen (etwa alle paar Sekunden ein paar Kosten weniger) halten die Basisoptimierung so nicht fest. + /// /// Beste Kosten der Suche mit den aktuellen Gewichten. /// Sekunden reiner Rechenzeit. public void Beobachten(double besteKosten, double jetzt) { - if (Grundlauf && besteKosten >= 0 && (sucheKosten < 0 || besteKosten < sucheKosten)) + if (Grundlauf && besteKosten >= 0 && (deutlicheKosten < 0 || besteKosten < deutlicheKosten * (1 - deutlich))) { - sucheKosten = besteKosten; - letzteSuche = jetzt; + deutlicheKosten = besteKosten; + UhrNeu(jetzt); } } - /// Der Grundlauf ist vorbei, wenn die Basisoptimierung steht (nach mindestens 10 s, 10 s ohne Verbesserung). + /// + /// Der Grundlauf ist vorbei, wenn die Basisoptimierung steht (nach mindestens 10 s; 10 s und + /// Pläne ohne deutliche Verbesserung), spätestens nach + /// . + /// /// Sekunden reiner Rechenzeit. /// true, wenn die Lenkung beginnen kann. - public bool GrundlaufVorbei(double jetzt) => jetzt >= MinGrundlauf && jetzt - letzteSuche >= Stillstand; + public bool GrundlaufVorbei(double jetzt) => jetzt >= MinGrundlauf && (Steht(jetzt) || jetzt >= maxGrundlauf); - /// Beendet den Grundlauf: der beste Plan wird Maßstab je Mannschaft und C-Kriterium. + /// + /// Beendet den Grundlauf: der beste Plan wird Maßstab je Mannschaft und C-Kriterium. Die erste Änderung der Gewichte + /// folgt sofort – die Suche steht ja schon (Ende der Basisoptimierung) bzw. der Plan ist schon optimiert (Wechsel von + /// der Kostenoptimierung); ein weiteres Abwarten zeigte nur lange „Stufe A“ ohne Kriterium. + /// /// Bester Plan des Grundlaufs. /// Sekunden reiner Rechenzeit. - /// true, um gleich die erste Änderung der Gewichte zu machen (ohne auf Stillstand zu warten). - public void GrundlaufBeenden(Loesung beste, double jetzt, bool sofortLenken = false) + public void GrundlaufBeenden(Loesung beste, double jetzt) { Stufenwert wert = Bewerten(beste, out double gesamt); for (int s = 0; s < basisMannschaft.Length; s++) @@ -231,8 +274,8 @@ public void GrundlaufBeenden(Loesung beste, double jetzt, bool sofortLenken = fa BesterWert = wert; BesteKosten = gesamt; letzteAenderung = jetzt; - letzteSuche = jetzt; - sofort = sofortLenken; + UhrNeu(jetzt); + sofort = true; sucheVerstoesse = null; } @@ -292,8 +335,11 @@ public void EinteilungAendern(Stufeneinteilung neu, double jetzt) fokus = null; } - // Sofort lenken: nicht erst warten, bis die Suche wieder steht. + // Sofort lenken: nicht erst warten, bis die Suche wieder steht. Das verschobene Kriterium bleibt eine Weile sichtbar. sofort = true; + verschoben = geaendert; + verschobenBis = jetzt + AnzeigeVerschoben; + zuletzt = jetzt; aenderungen.Add($"{jetzt:0} s Einteilung geändert, bester Plan {BesterWert}: weiter bei {PhaseText}{(fokus is { } f ? " ab " + f : string.Empty)}"); } @@ -304,19 +350,21 @@ public void Verfolgen(Loesung suche, double jetzt) { Stufenwert wert = Bewerten(suche, out _); Suche = wert; + zuletzt = jetzt; // Beobachtet wird, ob sich die Verstöße noch ändern: Jede Verbesserung der Anzahlen setzt die Uhr des Stillstands zurück. var verstoesse = (wert.Harte, wert.A, wert.B, wert.Ausreisser, wert.CAnzahl); if (!Grundlauf && (sucheVerstoesse is not { } bisher || verstoesse.CompareTo(bisher) < 0)) { sucheVerstoesse = verstoesse; - letzteSuche = jetzt; + UhrNeu(jetzt); } } /// Übernimmt als besten Plan, wenn sie nach Stufen besser ist. /// Lösung einer Insel. - public void Pruefen(Loesung loesung) + /// Ihre Bewertung nach Stufen (für den Maschinenraum). + public Stufenwert Pruefen(Loesung loesung) { Stufenwert wert = Bewerten(loesung, out double gesamt); if (wert.IstBesserAls(BesterWert)) @@ -326,6 +374,37 @@ public void Pruefen(Loesung loesung) BesteKosten = gesamt; Verbesserungen++; } + + return wert; + } + + /// + /// Die Kriterien mit den meisten Verstößen im zuletzt bewerteten Plan (): wichtigste Stufe zuerst, + /// innerhalb der Stufe die meisten Verstöße zuerst. + /// + /// Höchstens so viele. + /// Die Kriterien mit ihren Verstößen. + public Kostenverursacher[] Verursacher(int hoechstens) + { + int[] auswahl = Enumerable.Range(0, stufen.Length) + .Where(k => Stufeneinteilung.HatAnzahl((Kostenkriterium)k) && anzahl[k] > 0) + .OrderBy(k => stufen[k]) + .ThenByDescending(k => anzahl[k]) + .Take(hoechstens) + .ToArray(); + double groesste = auswahl.Select(k => anzahl[k]).DefaultIfEmpty(1).Max(); + return auswahl.Select(k => new Kostenverursacher((Kostenkriterium)k, anzahl[k] / groesste, (int)anzahl[k])).ToArray(); + } + + /// Die Verstöße je Kriterium im besten Plan nach Stufen mit der Stufe des Kriteriums. + /// Alle Kriterien, die Verstöße zählen. + public Kriterienwert[] BesteKriterien() + { + Bewerten(Beste, out _); + return Enumerable.Range(0, stufen.Length) + .Where(k => Stufeneinteilung.HatAnzahl((Kostenkriterium)k)) + .Select(k => new Kriterienwert((Kostenkriterium)k, anzahl[k], stufen[k])) + .ToArray(); } /// Entscheidet über die nächste Änderung der Gewichte, wenn die Suche steht (oder nach der Höchstdauer). @@ -334,7 +413,7 @@ public void Pruefen(Loesung loesung) /// Die neuen Gewichtungen; null = keine Änderung. public Berechnungsoptionen? Lenken(Loesung suche, double jetzt) { - if (Grundlauf || (!sofort && (jetzt - letzteAenderung < MinPhase || jetzt - letzteSuche < Stillstand))) + if (Grundlauf || (!sofort && (jetzt - letzteAenderung < MinPhase || !Steht(jetzt)))) { return null; } @@ -354,7 +433,7 @@ public void Pruefen(Loesung loesung) Optionen = neu; sucheVerstoesse = null; - letzteSuche = jetzt; + UhrNeu(jetzt); return neu; } @@ -400,6 +479,56 @@ private static (int Rang, Kostenkriterium? Kriterium) ErsteAenderung(Stufeneinte return (i < neu.A.Count + neu.B.Count ? 1 : 2 + i - neu.A.Count - neu.B.Count, kriterium); } + /// + /// Die Gewichte, die gerade von denen des Anwenders abweichen: je Kriterium das Gewicht des ganzen Kriteriums und bei + /// Kriterien mit Anzahl das Gewicht je Mannschaft. + /// + /// Die geänderten Gewichte. + private Gewichtsanhebung[] Anhebungsliste() + { + var liste = new List(); + foreach (Kostenkriterium k in Enum.GetValues()) + { + Vergleichen(liste, k, null, Gesamtschluessel((int)k)); + if (Stufeneinteilung.HatAnzahl(k) && (int)k < KernDefinition.AnzahlArten) + { + for (int m = 0; m < ids.Length; m++) + { + Vergleichen(liste, k, namen[m], (m * KernDefinition.AnzahlArten) + (int)k); + } + } + } + + return liste.ToArray(); + } + + private void Vergleichen(List liste, Kostenkriterium k, string? mannschaft, int schluessel) + { + Gewichtung vorher = Gewicht(original, schluessel); + Gewichtung jetzt = Gewicht(Optionen, schluessel); + if (vorher != jetzt) + { + liste.Add(new Gewichtsanhebung(k, mannschaft, vorher, jetzt)); + } + } + + /// Startet die Uhr des Stillstands neu: Zeit und Zahl der berechneten Pläne der letzten Verbesserung. + /// Sekunden reiner Rechenzeit. + private void UhrNeu(double jetzt) + { + letzteSuche = jetzt; + plaeneLetzteSuche = plaene; + } + + /// + /// Die Suche steht: seit der letzten Verbesserung mindestens 10 s und + /// berechnete Pläne. So sucht ein langsamer Rechner ebenso + /// viel wie ein schneller, bevor der Automodus entscheidet. + /// + /// Sekunden reiner Rechenzeit. + /// true, wenn die Suche als festgefahren gilt. + private bool Steht(double jetzt) => jetzt - letzteSuche >= Stillstand && plaene - plaeneLetzteSuche >= stillstandPlaene; + /// /// Drängen, erst Stufe A, dann Stufe B (die A-Gewichte bleiben stehen): Verstöße der Stufe im Plan der Suche herausdrängen, /// bis bei ihr nichts mehr geht (nichts mehr anzuheben oder dreimal kein Fortschritt); dann beginnt diff --git a/src/AndiGenerator.Engine/Inseln/Gewichtsanhebung.cs b/src/AndiGenerator.Engine/Inseln/Gewichtsanhebung.cs new file mode 100644 index 0000000..40ad633 --- /dev/null +++ b/src/AndiGenerator.Engine/Inseln/Gewichtsanhebung.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Optionen; + +namespace AndiGenerator.Engine.Inseln; + +/// Ein Gewicht, das der Automodus gegenüber den Gewichtungen des Anwenders geändert hat. +/// Das Kriterium. +/// Die Mannschaft; null = das Gewicht des ganzen Kriteriums. +/// Gewichtung des Anwenders. +/// Aktuelle Gewichtung des Automodus. +public sealed record Gewichtsanhebung(Kostenkriterium Kriterium, string? Mannschaft, Gewichtung Vorher, Gewichtung Jetzt); diff --git a/src/AndiGenerator.Engine/Inseln/Insel.cs b/src/AndiGenerator.Engine/Inseln/Insel.cs index 53ba8e3..7882914 100644 --- a/src/AndiGenerator.Engine/Inseln/Insel.cs +++ b/src/AndiGenerator.Engine/Inseln/Insel.cs @@ -52,6 +52,9 @@ public Insel(int nummer, Suchplatz[] plaetze, Loesung start, double startKosten) /// Holt einen Zähler, der sich bei jeder neuen Lösung der Insel (Verbesserung oder Neustart) erhöht (Automodus). public int Stand { get; private set; } + /// Holt die Zahl der Abgleiche, bei denen ein Suchplatz der Insel einen besseren Plan geliefert hat. + public int Erfolge { get; private set; } + /// Anzahl der Neustarts dieser Insel. public int Neustarts { get; private set; } @@ -83,6 +86,8 @@ public void Abgleichen() return; } + Erfolge++; + verbesserungBei = durchlaeufe; Verteilen(new Auftrag(Loesung.Kopie(), Kosten)); DynamischeAnpassen(besteStrategie); diff --git a/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs b/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs index f65c138..4c0340b 100644 --- a/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs +++ b/src/AndiGenerator.Engine/Inseln/Inseloptimierer.cs @@ -22,6 +22,9 @@ public sealed class Inseloptimierer : IDisposable private const int AbgleicheJeOptimiererSchritt = 5; private const int SchritteJeAbgleich = 2_000; + /// So viele größte Verursacher zeigt der Maschinenraum je Insel. + private const int Verursacher = 3; + private readonly Lock sperre = new(); private readonly Optimierungseinstellungen einstellungen; private readonly Staffel staffel; @@ -33,6 +36,8 @@ public sealed class Inseloptimierer : IDisposable private readonly Automodus? auto; private readonly double autoBudget; private readonly int[] autoStand; + private readonly Stufenwert?[] autoWerte; + private readonly Kostenverursacher[][] autoVerursacher; private readonly Random koordinatorZufall; private readonly ManualResetEventSlim laufen = new(true); private readonly Stopwatch uhr = new(); @@ -40,6 +45,7 @@ public sealed class Inseloptimierer : IDisposable /// Reine Rechenzeit ohne Pausen (für den Automodus, der nach Sekunden lenkt). private readonly Stopwatch laufzeit = new(); private readonly List threads = []; + private Suchplatz[][] kernPlaetze = []; private CancellationTokenSource? abbruch; private KernDefinition definition; private Berechnungsoptionen optionen; @@ -47,6 +53,10 @@ public sealed class Inseloptimierer : IDisposable private long rechnenZiel; private double besteKosten; private int verbesserungen; + private (int Verbesserungen, bool Gelenkt) zaehlStand; + private int planVerbesserungen; + private (int Verbesserungen, bool Gelenkt)? besteKriterienStand; + private Kriterienwert[] besteKriterien = []; private int abgleiche; private bool spezialLaeuft; private int spezialVerwendet; @@ -88,6 +98,8 @@ public Inseloptimierer(Staffel staffel, Berechnungsoptionen optionen, Optimierun koordinatorZufall = new Random(samen.Next()); inseln = new Insel[this.einstellungen.Inseln]; autoStand = new int[inseln.Length]; + autoWerte = new Stufenwert?[inseln.Length]; + autoVerursacher = new Kostenverursacher[inseln.Length][]; Array.Fill(autoStand, -1); for (int i = 0; i < inseln.Length; i++) { @@ -164,6 +176,39 @@ public MannschaftsKostenart? SpezialKostenart } } + /// Blick in den Maschinenraum: Pläne je Rechenkern und der Stand jeder Insel. + public Maschinenraum Maschinenraum + { + get + { + long[] kerne = kernPlaetze.Select(eigene => eigene.Sum(p => p.Durchlaeufe)).ToArray(); + lock (sperre) + { + // Die schlechteste Insel wie beim Neustart im Optimierer-Schritt: die mit den höchsten Kosten. + int schlechteste = -1; + for (int i = 0; i < inseln.Length; i++) + { + if (inseln[i].Kosten >= 0 && (schlechteste < 0 || inseln[i].Kosten > inseln[schlechteste].Kosten)) + { + schlechteste = i; + } + } + + IEnumerable stand = inseln.Select((insel, i) => + StandVon(insel, spezial: false, laeuft: true, autoWerte[i], autoWerte[i] is null ? Kostentreiber(insel) : autoVerursacher[i]) with + { + Schlechteste = i == schlechteste && inseln.Length > 1, + }); + if (spezial is not null) + { + stand = stand.Append(StandVon(spezial, spezial: true, laeuft: spezialLaeuft)); + } + + return new Maschinenraum(kerne, stand.ToArray(), einstellungen.DurchlaeufeVorNeustart, spezialKostenart, spezialVerwendet, BesteKriterien(), planVerbesserungen); + } + } + } + /// Stand des Automodus; null, wenn er nicht eingeschaltet ist. public Lenkungsstand? Lenkung { @@ -236,12 +281,16 @@ public void Starten() abbruch = new CancellationTokenSource(); CancellationToken token = abbruch.Token; int kerne = einstellungen.Kerne; + var plaetze = new Suchplatz[kerne][]; for (int k = 0; k < kerne; k++) { Suchplatz[] eigene = alle.Where((_, index) => index % kerne == k).ToArray(); + plaetze[k] = eigene; threads.Add(ThreadStarten(() => Arbeiten(eigene, token), $"Suche {k + 1}", ThreadPriority.BelowNormal)); } + kernPlaetze = plaetze; + threads.Add(ThreadStarten(() => Koordinieren(token), "Koordinator", ThreadPriority.Normal)); uhr.Restart(); if (!Pausiert) @@ -266,6 +315,7 @@ public void Stoppen() } threads.Clear(); + kernPlaetze = []; abbruch.Dispose(); abbruch = null; uhr.Stop(); @@ -317,6 +367,19 @@ public void Rechnen(long durchlaeufe) } } + /// + /// Bester Plan, Stand des Automodus und Maschinenraum aus demselben Augenblick (unter einer Sperre), damit Kacheln, + /// Verlauf und Maschinenraum dieselben Verbesserungen zeigen. + /// + /// Die drei Angaben. + public (Optimierungsergebnis Bester, Lenkungsstand? Lenkung, Maschinenraum Raum) Momentaufnahme() + { + lock (sperre) + { + return (BesterStand(), Lenkung, Maschinenraum); + } + } + /// Bester bisher gefundener Plan (Original copyPlanDates). /// Kosten, Anzahl der Verbesserungen des Gesamtergebnisses und alle Spiele. public Optimierungsergebnis BesterStand() @@ -333,7 +396,7 @@ public Optimierungsergebnis BesterStand() spiele.Add(new Spiel(zeitpunkt, definition.Name[definition.SpielHeim[s]], definition.Name[definition.SpielGast[s]], string.Empty)); } - return new Optimierungsergebnis(gelenkt ? auto!.BesteKosten : besteKosten, verbesserungen + (auto?.Verbesserungen ?? 0), spiele); + return new Optimierungsergebnis(gelenkt ? auto!.BesteKosten : besteKosten, planVerbesserungen, spiele); } } @@ -344,6 +407,26 @@ public void Dispose() laufen.Dispose(); } + /// Stand einer Insel für den Maschinenraum (unter der Sperre des Koordinators). + /// Die Insel. + /// Ist die Spezial-Insel. + /// Rechnet gerade. + /// Im Automodus: ihre Verstöße nach Stufen. + /// Ihre größten Kostenverursacher. + /// Der Stand. + private static Inselstand StandVon(Insel insel, bool spezial, bool laeuft, Stufenwert? verstoesse = null, IReadOnlyList? kostentreiber = null) => new( + insel.Nummer, + spezial, + laeuft, + insel.Kosten, + insel.Plaetze.Sum(p => p.Durchlaeufe), + insel.OhneVerbesserung, + insel.Neustarts, + insel.Plaetze.Select(p => p.Strategie.Kurz()).ToArray(), + verstoesse, + insel.Erfolge, + kostentreiber); + private static Thread ThreadStarten(ThreadStart arbeit, string name, ThreadPriority prioritaet) { var thread = new Thread(arbeit) { IsBackground = true, Name = name, Priority = prioritaet }; @@ -369,6 +452,89 @@ private static double Bewerten(KernPlan plan, Loesung loesung) return plan.Kosten(); } + /// + /// Die drei größten Kostenverursacher des besten Plans einer Insel mit den aktuellen Gewichten (unter der Sperre des + /// Koordinators; bewertet mit dem gemeinsamen Bewerter). + /// + /// Die Insel. + /// Kriterium und Anteil an den Kosten aller Kriterien, größter zuerst. + private Kostenverursacher[] Kostentreiber(Insel insel) + { + if (insel.Kosten < 0) + { + return []; + } + + List kosten = Kriterienkosten(insel.Loesung); + double summe = kosten.Sum(k => k.Wert); + return summe <= 0 + ? [] + : kosten.Where(k => k.Wert > 0).OrderByDescending(k => k.Wert).Take(Verursacher).Select(k => new Kostenverursacher(k.Kriterium, k.Wert / summe)).ToArray(); + } + + /// + /// Die Kosten je Kriterium einer Lösung mit den aktuellen Gewichten (unter der Sperre des Koordinators; bewertet mit dem + /// gemeinsamen Bewerter). + /// + private List Kriterienkosten(Loesung loesung) + { + Bewerten(bewerter, loesung); + var kosten = new List(); + foreach (MannschaftsKostenart art in Enum.GetValues()) + { + if (optionen.Fuer(art) != Gewichtung.NichtBeruecksichtigen) + { + kosten.Add(new Kriterienwert((Kostenkriterium)(int)art, bewerter.MannschaftsKosten(art))); + } + } + + if (optionen.Spieltag != Gewichtung.NichtBeruecksichtigen || optionen.SpieltagUeberlappung != Gewichtung.NichtBeruecksichtigen) + { + RefPlan.Spieltagskosten s = bewerter.SpieltagsKosten(); + kosten.Add(new Kriterienwert(Kostenkriterium.Spieltaglaenge, s.Breite + s.LetzteBreite)); + kosten.Add(new Kriterienwert(Kostenkriterium.Spieltagueberlappung, s.Ueberlappung + s.LetzteUeberlappung)); + } + + return kosten; + } + + /// + /// Die Kriterien des besten Plans (unter der Sperre des Koordinators): bei der Kostenoptimierung und im Grundlauf seine + /// Kosten je Kriterium, im Automodus danach die Verstöße je Kriterium. Neu berechnet nur, wenn sich der beste Plan geändert + /// hat, der gerade gilt (im Automodus nach dem Grundlauf der beste nach Stufen), oder die Bewertung wechselt. + /// + private Kriterienwert[] BesteKriterien() + { + bool gelenkt = auto is { Grundlauf: false }; + (int, bool) stand = (planVerbesserungen, gelenkt); + if (besteKriterienStand != stand) + { + besteKriterienStand = stand; + besteKriterien = gelenkt ? auto!.BesteKriterien() : KostenDesBesten(); + } + + return besteKriterien; + } + + /// + /// Zählt die Verbesserungen des geltenden besten Plans: höchstens eine je Optimierer-Schritt, bei der Kostenoptimierung und + /// im Grundlauf nach Kosten, im Automodus danach nach Stufen. Der Wechsel vom Grundlauf zur Lenkung zählt nicht. Diese Zahl + /// zeigen die Kachel „Verbesserungen“, der Verlauf und der Maschinenraum (ein grüner Punkt je Verbesserung). + /// + private void PlanVerbesserungenZaehlen() + { + bool gelenkt = auto is { Grundlauf: false }; + (int Verbesserungen, bool Gelenkt) stand = gelenkt ? (auto!.Verbesserungen, true) : (verbesserungen, false); + if (stand.Gelenkt == zaehlStand.Gelenkt && stand.Verbesserungen > zaehlStand.Verbesserungen) + { + planVerbesserungen++; + } + + zaehlStand = stand; + } + + private Kriterienwert[] KostenDesBesten() => besteKosten >= 0 ? Kriterienkosten(beste).ToArray() : []; + private Insel InselAnlegen(int nummer, RefPlan referenz, Random samen, bool aktiv) { double startKosten = aktiv ? besteKosten : -1; @@ -505,6 +671,7 @@ private void Abgleichen(bool optimiererSchritt) } Insel? schlechteste = null; + bool besser = false; foreach (Insel insel in inseln) { if (insel.Kosten < 0) @@ -516,7 +683,7 @@ private void Abgleichen(bool optimiererSchritt) { insel.Loesung.KopierenNach(beste); besteKosten = insel.Kosten; - verbesserungen++; + besser = true; } if (schlechteste is null || insel.Kosten > schlechteste.Kosten) @@ -525,11 +692,18 @@ private void Abgleichen(bool optimiererSchritt) } } + if (besser) + { + verbesserungen++; + } + if (auto is not null) { AutoSchritt(); } + PlanVerbesserungenZaehlen(); + if (spezial is not null && !spezialLaeuft && Durchlaeufe > einstellungen.DurchlaeufeVorSpezialInsel) { SpezialNeuAufsetzen(spezial); @@ -565,13 +739,14 @@ private void AutoSchritt() // Der Automodus beobachtet die Verstöße über die reine Rechenzeit (ohne Pausen); er kennt keine Zeitgrenzen. Bei einer // festen Zahl von Durchläufen (reproduzierbar) entspricht der ganze Lauf dem Zeitbudget bzw. 300 s. double f = AutoSekunden(); - auto!.Beobachten(besteKosten, f); + auto!.Zaehlen(Durchlaeufe); + auto.Beobachten(besteKosten, f); if (auto.Grundlauf) { // Nach einem Wechsel von der Kostenoptimierung ist der Plan schon optimiert: gleich mit Stufe A beginnen. if (besteKosten >= 0 && (einstellungen.AutoOhneGrundlauf || auto.GrundlaufVorbei(f))) { - auto.GrundlaufBeenden(beste, f, sofortLenken: einstellungen.AutoOhneGrundlauf); + auto.GrundlaufBeenden(beste, f); } return; @@ -583,7 +758,8 @@ private void AutoSchritt() if (insel.Kosten >= 0 && insel.Stand != autoStand[i]) { autoStand[i] = insel.Stand; - auto.Pruefen(insel.Loesung); + autoWerte[i] = auto.Pruefen(insel.Loesung); + autoVerursacher[i] = auto.Verursacher(Verursacher); } } diff --git a/src/AndiGenerator.Engine/Inseln/Inselstand.cs b/src/AndiGenerator.Engine/Inseln/Inselstand.cs new file mode 100644 index 0000000..d048370 --- /dev/null +++ b/src/AndiGenerator.Engine/Inseln/Inselstand.cs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Inseln; + +/// Stand einer Insel: eine eigenständige Suche mit mehreren Suchplätzen und eigenem besten Plan. +/// Nummer der Insel (0 = Spezial-Insel). +/// Spezial-Insel: rechnet mit einer extrem gewichteten Kostenart und gibt gute Pläne an die Inseln ab. +/// Die Insel rechnet (die Spezial-Insel erst nach einer Anlaufzeit). +/// Kosten ihres besten Plans (Spezial-Insel: mit ihren eigenen Gewichten); negativ = noch keiner. +/// Berechnete Pläne der Insel seit dem Start. +/// Berechnete Pläne seit ihrer letzten Verbesserung. +/// Wie oft die Insel neu gestartet wurde. +/// Suchstrategien ihrer Suchplätze in Kurzform (R, 15, S1,25, M2,10 …). +/// Im Automodus (nach der Basisoptimierung): Verstöße ihres besten Plans nach Stufen. +/// Wie oft ein Suchplatz der Insel einen besseren Plan geliefert hat. +/// +/// Die größten Verursacher in ihrem besten Plan: bei der Kostenoptimierung nach Kosten (mit den aktuellen Gewichten), im +/// Automodus nach Verstößen (wichtigste Stufe zuerst). +/// +/// +/// Sie ist die schlechteste Insel (höchste Kosten, im Automodus mit den aktuellen Gewichten): Nur sie beginnt neu, wenn sie +/// länger als die Grenze nichts Besseres gefunden hat. +/// +public sealed record Inselstand( + int Nummer, + bool Spezial, + bool Laeuft, + double Kosten, + long Durchlaeufe, + long OhneVerbesserung, + int Neustarts, + IReadOnlyList Strategien, + Stufenwert? Verstoesse = null, + int Erfolge = 0, + IReadOnlyList? Kostentreiber = null, + bool Schlechteste = false); diff --git a/src/AndiGenerator.Engine/Inseln/Kostenverursacher.cs b/src/AndiGenerator.Engine/Inseln/Kostenverursacher.cs new file mode 100644 index 0000000..33f7144 --- /dev/null +++ b/src/AndiGenerator.Engine/Inseln/Kostenverursacher.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Inseln; + +/// +/// Ein großer Verursacher im besten Plan einer Insel: bei der Kostenoptimierung ein Kriterium mit seinem Anteil an den +/// Kosten, im Automodus ein Kriterium mit seinen Verstößen. +/// +/// Das Kriterium. +/// Kostenoptimierung: Anteil an den Kosten aller Kriterien; Automodus: Verstöße im Verhältnis zum größten (0 bis 1). +/// Im Automodus die Zahl der Verstöße; sonst null. +public sealed record Kostenverursacher(Kostenkriterium Kriterium, double Anteil, int? Verstoesse = null); diff --git a/src/AndiGenerator.Engine/Inseln/Kriterienwert.cs b/src/AndiGenerator.Engine/Inseln/Kriterienwert.cs new file mode 100644 index 0000000..c05febc --- /dev/null +++ b/src/AndiGenerator.Engine/Inseln/Kriterienwert.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Inseln; + +/// +/// Ein Kriterium im besten Plan: bei der Kostenoptimierung mit seinen Kosten, im Automodus mit seinen Verstößen und seiner +/// Stufe. Aus zwei aufeinanderfolgenden besten Plänen zeigt der Maschinenraum, was eine Verbesserung geändert hat. +/// +/// Das Kriterium. +/// Kostenoptimierung: seine Kosten; Automodus: die Zahl seiner Verstöße. +/// Im Automodus seine Stufe (A, B oder C); sonst null. +public sealed record Kriterienwert(Kostenkriterium Kriterium, double Wert, Stufe? Stufe = null); diff --git a/src/AndiGenerator.Engine/Inseln/Lenkungsstand.cs b/src/AndiGenerator.Engine/Inseln/Lenkungsstand.cs index 869af59..e004447 100644 --- a/src/AndiGenerator.Engine/Inseln/Lenkungsstand.cs +++ b/src/AndiGenerator.Engine/Inseln/Lenkungsstand.cs @@ -20,6 +20,7 @@ namespace AndiGenerator.Engine.Inseln; /// Die Kriterien, an denen gerade gearbeitet wird, in der Reihenfolge der Einteilung: in Stufe A und B die zuletzt /// angehobenen, in Stufe C das, das geglättet wird; leer in der Basisoptimierung. /// +/// Die Gewichte, die der Automodus gegenüber den Gewichtungen des Anwenders geändert hat. public sealed record Lenkungsstand( Stufenwert? Basis, Stufenwert Bester, @@ -30,4 +31,5 @@ public sealed record Lenkungsstand( int Zurueckgezogen, Berechnungsoptionen Gewichtungen, IReadOnlyList Aenderungen, - IReadOnlyList? Kriterien = null); + IReadOnlyList? Kriterien = null, + IReadOnlyList? Gewichte = null); diff --git a/src/AndiGenerator.Engine/Inseln/Maschinenraum.cs b/src/AndiGenerator.Engine/Inseln/Maschinenraum.cs new file mode 100644 index 0000000..96f5e8e --- /dev/null +++ b/src/AndiGenerator.Engine/Inseln/Maschinenraum.cs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Optionen; + +namespace AndiGenerator.Engine.Inseln; + +/// +/// Blick in den Maschinenraum der Optimierung: was die Rechenkerne und Inseln gerade tun (Anzeige „Maschinenraum“). +/// +/// Berechnete Pläne je Rechen-Thread seit dem Start (ohne Rechen-Threads leer). +/// Die Inseln, zuletzt die Spezial-Insel, wenn es sie gibt. +/// Pläne ohne Verbesserung, nach denen die schlechteste Insel neu startet. +/// Kostenart, die die Spezial-Insel gerade extrem gewichtet; null, solange sie nicht läuft. +/// Wie oft die Lösung der Spezial-Insel eine Insel neu gestartet hat. +/// Die Kriterien des besten Plans (Kosten bzw. im Automodus Verstöße je Kriterium). +/// Zählt weiter, sobald sich der geltende beste Plan ändert (zusammen mit ermittelt). +public sealed record Maschinenraum( + IReadOnlyList Kerne, + IReadOnlyList Inseln, + long DurchlaeufeVorNeustart, + MannschaftsKostenart? SpezialKostenart, + int SpezialVerwendet, + IReadOnlyList? BesterPlan = null, + int BesterPlanNummer = 0); diff --git a/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs b/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs index e87f27d..6bef818 100644 --- a/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs +++ b/src/AndiGenerator.Engine/Inseln/Optimierungseinstellungen.cs @@ -20,10 +20,12 @@ namespace AndiGenerator.Engine.Inseln; /// Sekunden reiner Rechenzeit; reproduzierbar werden sie aus den Durchläufen abgeleitet); 0 = 300 s. /// /// -/// Automodus: Nach der Basisoptimierung (Grundlauf mit den Gewichtungen des Anwenders, bis die Suche 10 s keine -/// Verbesserung findet, mindestens 10 s) lenkt die Suche über die Gewichte: erst A-, dann B-Verstöße herausdrängen, dann -/// die C-Kriterien nach Wichtigkeit je Mannschaft glätten, bis A oder B wieder steigen. Ohne Zeitgrenzen: Der Automodus -/// beobachtet, ob sich die Verstöße nach einer Gewichtsänderung noch ändern (10 s ohne Verbesserung, frühestens 8 s nach der letzten Änderung = nächster Schritt). +/// Automodus: Nach der Basisoptimierung (Grundlauf mit den Gewichtungen des Anwenders, bis die Suche steht – siehe +/// , und ) lenkt die +/// Suche über die Gewichte: erst A-, dann B-Verstöße herausdrängen, dann die C-Kriterien nach Wichtigkeit je Mannschaft +/// glätten, bis A oder B wieder steigen. Ohne Zeitgrenzen: Der Automodus beobachtet, ob sich die Verstöße nach einer +/// Gewichtsänderung noch ändern (Stillstand: mindestens 10 s und berechnete Pläne ohne +/// Verbesserung, frühestens 8 s nach der letzten Änderung = nächster Schritt). /// Die Spezial-Insel ist dabei aus. /// /// Verstöße, die eine Mannschaft je C-Kriterium mehr haben darf als im Grundlauf, bevor sie als Ausreißer gilt. @@ -33,6 +35,15 @@ namespace AndiGenerator.Engine.Inseln; /// Automodus ohne Basisoptimierung: Der Ausgangsplan ist schon optimiert (Wechsel von der Kostenoptimierung), sein bester /// Plan wird sofort Maßstab, und die Optimierung der Stufe A beginnt gleich. /// +/// +/// Basisoptimierung: Nur Verbesserungen um mindestens diesen Anteil der Kosten (0,01 = 1 %) gegenüber der letzten deutlichen +/// Verbesserung gelten als Fortschritt; kleinere halten die Basisoptimierung nicht fest. +/// +/// +/// Berechnete Pläne ohne (deutliche) Verbesserung, ab denen die Suche als festgefahren gilt – zusätzlich zu mindestens 10 s, +/// damit langsame Rechner ebenso viel suchen wie schnelle, bevor der Automodus entscheidet. +/// +/// Höchstdauer der Basisoptimierung in Sekunden reiner Rechenzeit; danach beginnt in jedem Fall Stufe A. public sealed record Optimierungseinstellungen( int Kerne, int Inseln, @@ -49,7 +60,10 @@ public sealed record Optimierungseinstellungen( int AutoSpielraum = 2, double AutoKostenToleranz = 0.5, Stufeneinteilung? AutoStufen = null, - bool AutoOhneGrundlauf = false) + bool AutoOhneGrundlauf = false, + double AutoDeutlich = 0.01, + long AutoStillstandPlaene = 500_000, + double AutoMaxGrundlauf = 180) { /// Standard: Topologie des Originals (4 Inseln × 14 feste + 10 dynamische Suchplätze, Spezial-Insel), alle Kerne bis auf einen. public static Optimierungseinstellungen Standard { get; } = new( diff --git a/src/AndiGenerator.Engine/Referenz/Tauschstrategie.cs b/src/AndiGenerator.Engine/Referenz/Tauschstrategie.cs index 3f6a335..5c063df 100644 --- a/src/AndiGenerator.Engine/Referenz/Tauschstrategie.cs +++ b/src/AndiGenerator.Engine/Referenz/Tauschstrategie.cs @@ -20,4 +20,18 @@ internal sealed record Tauschstrategie(Tauschtyp Typ, int Anzahl, double Prozent 'M' => new Tauschstrategie(Tauschtyp.Mannschaft, text[1] - '0', double.Parse(text[3..], CultureInfo.InvariantCulture)), _ => new Tauschstrategie(Tauschtyp.Normal, 0, double.Parse(text, CultureInfo.InvariantCulture)), }; + + /// Kurzform wie beim Lesen (R, 15, S1,25, M2,10) für die Anzeige. + /// Die Kurzform. + public string Kurz() + { + string prozent = Prozent.ToString("0.##", CultureInfo.InvariantCulture); + return Typ switch + { + Tauschtyp.Raster => "R", + Tauschtyp.Spieltag => $"S{Anzahl},{prozent}", + Tauschtyp.Mannschaft => $"M{Anzahl},{prozent}", + _ => prozent, + }; + } } diff --git a/src/AndiGenerator.Persistence/Gemeinsam/Staffelordner.cs b/src/AndiGenerator.Persistence/Gemeinsam/Staffelordner.cs index bd05932..1686ddf 100644 --- a/src/AndiGenerator.Persistence/Gemeinsam/Staffelordner.cs +++ b/src/AndiGenerator.Persistence/Gemeinsam/Staffelordner.cs @@ -96,6 +96,16 @@ public static string DateinameDekodieren(string wert) public static string GemerkterPlanPfad(string staffelordner, string planName) => Path.Combine(staffelordner, DateinameKodieren(planName) + ".xml"); + /// + /// Dateipfad des gesicherten Plans einer Staffel: genau einer je Staffel, gesichert, bevor ein Plan durch einen Neustart + /// oder das Schließen verloren ginge. Die Endung .sicherung hält ihn aus den gemerkten Plänen heraus (auch im + /// Original, das nur *.xml liest); das Format ist das eines gemerkten Plans. + /// + /// Ordner der Staffel. + /// Der Pfad. + public static string GesicherterPlanPfad(string staffelordner) => + Path.Combine(staffelordner, "Gesicherter Plan.sicherung"); + /// Gemerkte Pläne im Staffelordner, neueste zuerst (Original TSavedPlanComparer). /// Ordner der Staffel. /// Die gemerkten Pläne; leer, wenn der Ordner fehlt. diff --git a/src/AndiGenerator.Presentation/DatenSeiteViewModel.cs b/src/AndiGenerator.Presentation/DatenSeiteViewModel.cs index f4136dc..9d7eef5 100644 --- a/src/AndiGenerator.Presentation/DatenSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/DatenSeiteViewModel.cs @@ -14,6 +14,8 @@ namespace AndiGenerator.Presentation; /// public abstract class DatenSeiteViewModel : ObservableObject { + private bool bearbeitet; + /// Initialisiert die Seite. /// Arbeitskopie. /// Seitentitel in der Navigation. @@ -22,6 +24,15 @@ protected DatenSeiteViewModel(Datenbearbeitung bearbeitung, string titel) ArgumentNullException.ThrowIfNull(bearbeitung); Bearbeitung = bearbeitung; Titel = titel; + + // Die Marke in der Seitenliste (z. B. die Zahl der Einträge) folgt jeder Änderung der Seite. + PropertyChanged += (_, e) => + { + if (e.PropertyName is not (nameof(Marke) or nameof(Bearbeitet))) + { + OnPropertyChanged(nameof(Marke)); + } + }; } /// Tritt ein, wenn der Benutzer etwas geändert hat (Original FireOnChange). @@ -30,6 +41,16 @@ protected DatenSeiteViewModel(Datenbearbeitung bearbeitung, string titel) /// Holt den Seitentitel. public string Titel { get; } + /// Holt die Marke in der Seitenliste, z. B. die Zahl der Einträge; leer = keine. + public virtual string Marke => string.Empty; + + /// Holt einen Wert, der angibt, ob auf der Seite in diesem Dialog etwas geändert wurde (Punkt in der Seitenliste). + public bool Bearbeitet + { + get => bearbeitet; + private set => SetProperty(ref bearbeitet, value); + } + /// Holt einen Wert, der angibt, ob die Seite dem click-TT-Stand entspricht; ohne Standard immer true. public abstract bool IstStandard { get; } @@ -50,5 +71,19 @@ protected DatenSeiteViewModel(Datenbearbeitung bearbeitung, string titel) public virtual string? Pruefen() => null; /// Meldet eine Änderung des Benutzers. - protected void AenderungMelden() => Geaendert?.Invoke(this, EventArgs.Empty); + protected void AenderungMelden() + { + Bearbeitet = true; + ZustandMelden(); + } + + /// + /// Meldet einen neuen Zustand der Seite, ohne dass der Benutzer etwas geändert hat (z. B. eine andere Mannschaft + /// gewählt): Der Dialog aktualisiert „Standard wiederherstellen“, die Seite gilt aber nicht als geändert. + /// + protected void ZustandMelden() + { + OnPropertyChanged(nameof(Marke)); + Geaendert?.Invoke(this, EventArgs.Empty); + } } diff --git a/src/AndiGenerator.Presentation/Generierungsanzeige.cs b/src/AndiGenerator.Presentation/Generierungsanzeige.cs index 1b0e252..c8a8b3a 100644 --- a/src/AndiGenerator.Presentation/Generierungsanzeige.cs +++ b/src/AndiGenerator.Presentation/Generierungsanzeige.cs @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Collections.ObjectModel; +using System.Diagnostics; using System.Globalization; using AndiGenerator.Application; using AndiGenerator.Engine.Inseln; @@ -24,8 +26,17 @@ public sealed class Generierungsanzeige : ObservableObject /// Ab diesen Kosten hat ein Plan mindestens einen harten Fehler (Original: 10 × cMaxKostenNoHardError). private const double HarteFehler = 10.0 * 1_000_000_000.0; + /// Höchstens so viele Säulen in der Kachel. + private const int KachelSaeulen = 16; + + /// Bis zu so vielen Kernen zeigt der Maschinenraum Säulen, darüber ein Raster. + private const int EinzelneSaeulen = 32; + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + private readonly ObservableCollection kerne = []; + private readonly ObservableCollection kachelKerne = []; + private double? startkosten; private string bezug = SeitStart; private string kosten = Leer; @@ -47,6 +58,9 @@ public sealed class Generierungsanzeige : ObservableObject private string basisC = string.Empty; private string phase = string.Empty; private Stufenwert? verstoesse; + private long[] kernVorher = []; + private long zeitVorher; + private bool rechnet; /// Wird ausgelöst, wenn zwischen Kosten und Verstößen umgeschaltet wird (Ziel des Klicks auf die Kachel). public event EventHandler? ZeigtVerstoesseGeaendert; @@ -186,6 +200,25 @@ public string Phase private set => SetProperty(ref phase, value); } + /// Holt die Rechenkerne mit ihrer Last (Blick in den Maschinenraum). + public IReadOnlyList Kerne => kerne; + + /// + /// Holt die Kerne für die Kachel: höchstens Säulen; bei mehr Kernen steht eine Säule für + /// eine Gruppe benachbarter Kerne (ihre durchschnittliche Last). + /// + public IReadOnlyList KachelKerne => kachelKerne; + + /// Holt einen Wert, der angibt, ob es so viele Kerne sind, dass der Maschinenraum sie als Raster zeigt. + public bool VieleKerne => kerne.Count > EinzelneSaeulen; + + /// Holt einen Wert, der angibt, ob gerade gerechnet wird (die Kerne bewegen sich). + public bool Rechnet + { + get => rechnet; + private set => SetProperty(ref rechnet, value); + } + /// /// Merkt sich, dass sich die Kostenfunktion geändert hat (Gewichte, Optionen oder Spielplandaten): Die alten Kosten /// sind nicht mehr vergleichbar, Bezug der prozentualen Verbesserung ist ab jetzt der erste gültige Plan danach. @@ -201,6 +234,7 @@ public void KostenAngepasst() public void Uebernehmen(Optimierungsstand stand) { ArgumentNullException.ThrowIfNull(stand); + KerneZeigen(stand); Plaene = Kostenanzeige.Kurz(stand.Durchlaeufe); Tempo = stand.PlaeneProSekunde.ToString("N0", Deutsch) + " Pläne/s"; ZeigtVerstoesse = stand.Lenkung is not null; @@ -253,6 +287,20 @@ internal void Zuruecksetzen(string hinweis) VerstossB = Leer; VerstossC = Leer; LenkungZeigen(null); + kerne.Clear(); + kachelKerne.Clear(); + kernVorher = []; + Rechnet = false; + } + + /// Die Generierung ist beendet: Die Kerne ruhen. + internal void KerneAnhalten() + { + Rechnet = false; + foreach (Kernanzeige kern in kerne.Concat(kachelKerne)) + { + kern.Setzen(0, 0); + } } /// Zeigt vor dem Start einen gewählten Ausgangsplan: seine Kosten und Pflichtregeln, noch ohne Generierung. @@ -289,6 +337,13 @@ internal void Uebernehmen(Planbewertung bewertung, Stufeneinteilung einteilung) private static string Basiswert(int? wert) => wert is int w ? string.Create(Deutsch, $"{w} →") : string.Empty; + /// Kerne g einer Gruppeneinteilung: [von, bis) der Kerne. + /// Nummer der Gruppe (ab 0). + /// Anzahl der Gruppen. + /// Anzahl der Kerne. + /// Erster und hinter dem letzten Kern der Gruppe. + private static (int Von, int Bis) Gruppe(int g, int gruppen, int anzahl) => (g * anzahl / gruppen, (g + 1) * anzahl / gruppen); + /// Automodus: Verstöße am Ende der Basisoptimierung und die Kriterien, an denen gerade gearbeitet wird. private void LenkungZeigen(Lenkungsstand? lenkung) { @@ -325,4 +380,65 @@ private string Veraenderung(double jetzt) ? "−< 1 % " + bezug : string.Create(Deutsch, $"−{prozent:0} % {bezug}"); } + + /// + /// Last je Rechenkern: Pläne seit der letzten Abfrage je Sekunde, im Verhältnis zum schnellsten Kern. Im Takt der + /// Statusabfrage wachsen und schrumpfen die Balken so mit der Arbeit der Kerne. + /// + /// Der Stand. + private void KerneZeigen(Optimierungsstand stand) + { + IReadOnlyList jetzt = stand.Maschinenraum?.Kerne ?? []; + Rechnet = stand.Laeuft && !stand.Pausiert && jetzt.Count > 0; + if (kerne.Count != jetzt.Count) + { + KerneAnlegen(jetzt.Count); + } + + long zeit = Stopwatch.GetTimestamp(); + double sekunden = kernVorher.Length == jetzt.Count && zeitVorher > 0 ? Stopwatch.GetElapsedTime(zeitVorher, zeit).TotalSeconds : 0; + double[] jeSekunde = new double[jetzt.Count]; + for (int k = 0; k < jetzt.Count; k++) + { + jeSekunde[k] = sekunden > 0 && Rechnet ? Math.Max(0, jetzt[k] - kernVorher[k]) / sekunden : 0; + } + + double schnellster = jeSekunde.DefaultIfEmpty(0).Max(); + double[] anteil = jeSekunde.Select(t => schnellster > 0 ? t / schnellster : 0).ToArray(); + for (int k = 0; k < jetzt.Count; k++) + { + kerne[k].Setzen(anteil[k], jeSekunde[k]); + } + + for (int g = 0; g < kachelKerne.Count; g++) + { + (int von, int bis) = Gruppe(g, kachelKerne.Count, jetzt.Count); + kachelKerne[g].Setzen(anteil[von..bis].Average(), jeSekunde[von..bis].Sum()); + } + + kernVorher = [.. jetzt]; + zeitVorher = zeit; + } + + /// Legt die Kerne und die Säulen der Kachel neu an. + /// Anzahl der Kerne. + private void KerneAnlegen(int anzahl) + { + kerne.Clear(); + for (int k = 0; k < anzahl; k++) + { + kerne.Add(new Kernanzeige(string.Create(Deutsch, $"Kern {k + 1}"))); + } + + kachelKerne.Clear(); + int gruppen = Math.Min(anzahl, KachelSaeulen); + for (int g = 0; g < gruppen; g++) + { + (int von, int bis) = Gruppe(g, gruppen, anzahl); + kachelKerne.Add(bis - von == 1 ? kerne[von] : new Kernanzeige(string.Create(Deutsch, $"Kerne {von + 1}–{bis}"))); + } + + kernVorher = []; + OnPropertyChanged(nameof(VieleKerne)); + } } diff --git a/src/AndiGenerator.Presentation/Gewichtsgruppe.cs b/src/AndiGenerator.Presentation/Gewichtsgruppe.cs new file mode 100644 index 0000000..404fd26 --- /dev/null +++ b/src/AndiGenerator.Presentation/Gewichtsgruppe.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein Kriterium mit den Gewichten, die der Automodus dafür geändert hat (Maschinenraum). +/// Stufe mit Nummer, z. B. A2. +/// Name des Kriteriums. +/// Je geändertes Gewicht eine Zeile (ganzes Kriterium bzw. je Mannschaft). +public sealed record Gewichtsgruppe(string Stufe, string Kriterium, IReadOnlyList Zeilen); diff --git a/src/AndiGenerator.Presentation/Gewichtssegment.cs b/src/AndiGenerator.Presentation/Gewichtssegment.cs new file mode 100644 index 0000000..dde7dd1 --- /dev/null +++ b/src/AndiGenerator.Presentation/Gewichtssegment.cs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// +/// Eine Stufe der Gewichtsskala: Ring = Gewichtung des Anwenders, Punkt = aktuelle Gewichtung des Automodus, dazwischen +/// eine Linie (je halbe Stufe links und rechts der Mitte). +/// +/// Hier steht die Gewichtung des Anwenders. +/// Hier steht die aktuelle Gewichtung des Automodus. +/// Die Linie von „vorher“ nach „jetzt“ läuft durch die linke Hälfte. +/// Die Linie von „vorher“ nach „jetzt“ läuft durch die rechte Hälfte. +public sealed record Gewichtssegment(bool Vorher, bool Jetzt, bool LinksWeg, bool RechtsWeg); diff --git a/src/AndiGenerator.Presentation/Gewichtszeile.cs b/src/AndiGenerator.Presentation/Gewichtszeile.cs new file mode 100644 index 0000000..25d74f2 --- /dev/null +++ b/src/AndiGenerator.Presentation/Gewichtszeile.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein vom Automodus geändertes Gewicht als Stufenleiste: bis zur Gewichtung des Anwenders grau, angehoben farbig. +/// „alle Mannschaften“ oder der Name der Mannschaft. +/// Die Stufen „sehr wenig“ bis „extrem hoch“. +/// Vorher und jetzt, z. B. „normal → sehr hoch“. +public sealed record Gewichtszeile(string Wer, IReadOnlyList Segmente, string Text); diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs index d3c632a..7f5eb01 100644 --- a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs +++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs @@ -48,6 +48,9 @@ public sealed class HauptfensterViewModel : ObservableObject, IDisposable private readonly string? uebernahmeAus; private Plansitzung? sitzung; private Planstand? laufenderStand; + + /// Spiele des Plans, über dessen Sicherung schon entschieden ist (gesichert oder bewusst verworfen). + private IReadOnlyList? sicherungEntschieden; private Planstand? clickTtStand; private IReadOnlyList planquellen = [PlanQuelle.Laufend]; private IReadOnlyList startplaene = [Startplan.Leer]; @@ -57,6 +60,8 @@ public sealed class HauptfensterViewModel : ObservableObject, IDisposable private OptionenAnsichtViewModel? optionenAnsicht; private TerminwunschAnsichtViewModel? terminwunschAnsicht; private NachbarterminAnsichtViewModel? nachbarterminAnsicht; + private MaschinenraumAnsichtViewModel? maschinenraumAnsicht; + private VerlaufAnsichtViewModel? verlaufAnsicht; private string titel = UeberViewModel.Programm; private string statuszeile = "Bitte eine click-TT-Datei oder Plandatei öffnen."; private bool laeuft; @@ -122,6 +127,16 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster { nachbarterminAnsicht = null; } + + if (ReferenceEquals(e.Dockable, maschinenraumAnsicht)) + { + maschinenraumAnsicht = null; + } + + if (ReferenceEquals(e.Dockable, verlaufAnsicht)) + { + verlaufAnsicht = null; + } }; IRootDock layout = fabrik.CreateLayout(); fabrik.InitLayout(layout); @@ -131,15 +146,15 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster OeffnenCommand = new AsyncRelayCommand(OeffnenAsync); NeuCommand = new AsyncRelayCommand(NeueDateiAsync); SchliessenCommand = new AsyncRelayCommand(SchliessenAsync, () => IstGeoeffnet); - GenerierungCommand = new RelayCommand(GenerierungUmschalten, () => IstGeoeffnet); - KostenoptimierungCommand = new RelayCommand(() => VerfahrenWaehlen(false), () => IstGeoeffnet && !(Laeuft && !Automodus)); - AutomodusCommand = new RelayCommand(() => VerfahrenWaehlen(true), () => IstGeoeffnet && !(Laeuft && Automodus)); + GenerierungCommand = new AsyncRelayCommand(GenerierungUmschaltenAsync, () => IstGeoeffnet); + KostenoptimierungCommand = new RelayCommand(() => VerfahrenWaehlen(false), () => IstGeoeffnet); + AutomodusCommand = new RelayCommand(() => VerfahrenWaehlen(true), () => IstGeoeffnet); ZuletztOeffnenCommand = new AsyncRelayCommand(ZuletztOeffnenAsync); ZuletztEntfernenCommand = new RelayCommand(ZuletztEntfernen); ZoomGroesserCommand = new RelayCommand(() => ZoombareAnsicht()?.Zoom.Groesser()); ZoomKleinerCommand = new RelayCommand(() => ZoombareAnsicht()?.Zoom.Kleiner()); ZoomZuruecksetzenCommand = new RelayCommand(() => ZoombareAnsicht()?.Zoom.Zuruecksetzen()); - StartenCommand = new RelayCommand(Starten, () => IstGeoeffnet); + StartenCommand = new AsyncRelayCommand(StartenAsync, () => IstGeoeffnet); PauseCommand = new RelayCommand(PauseUmschalten, () => Laeuft); StoppenCommand = new RelayCommand(Stoppen, () => Laeuft); PlanMerkenCommand = new AsyncRelayCommand(PlanMerkenAsync, () => laufenderStand is not null); @@ -162,6 +177,8 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster DatenCommand = new AsyncRelayCommand(DatenBearbeitenAsync, () => ZeigtArbeitsbereich); TerminwunschansichtCommand = new RelayCommand(TerminwuenscheOeffnen, () => ZeigtArbeitsbereich); NachbarterminansichtCommand = new RelayCommand(NachbartermineOeffnen, () => ZeigtArbeitsbereich); + MaschinenraumCommand = new RelayCommand(MaschinenraumOeffnen, () => ZeigtArbeitsbereich); + VerlaufCommand = new RelayCommand(VerlaufOeffnen, () => ZeigtArbeitsbereich); Zuletzt = Zeilen(Zuletztlesen()); statusTakt = new Timer(_ => StatusAbfragen(), null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(500)); @@ -194,6 +211,9 @@ public string Staffelinfo /// Holt die Kacheln mit dem Stand der Generierung. public Generierungsanzeige Anzeige { get; } = new(); + /// Holt den Verlauf der laufenden Suche (Maschinenraum). + public Suchverlauf Verlauf { get; } = new(); + /// Holt die zuletzt geöffneten Staffeln für die Startseite. public IReadOnlyList Zuletzt { @@ -215,7 +235,7 @@ private set /// gemerkter Plan). Ohne Plan sind die Planansichten links deaktiviert. /// public bool HatPlan => - sitzung is Plansitzung s && (laufenderStand is not null || s.Staffel.BestehenderSpielplan.Count > 0 || Planquellen.Any(q => q.Art == PlanQuellenArt.GemerkterPlan)); + sitzung is Plansitzung s && (laufenderStand is not null || s.Staffel.BestehenderSpielplan.Count > 0 || Planquellen.Any(q => q.Eintrag is not null)); /// /// Holt die Art der vorn liegenden Ansicht (z. B. „Kosten“), für die Hervorhebung links; leer, solange keine Staffel @@ -256,19 +276,25 @@ private set /// Holt die Vergrößerung, mit der Ansichten geöffnet werden (siehe ). public int Startzoom => startzoom; - /// Holt die Beschriftung des Knopfs, der die Generierung startet, anhält und fortsetzt. + /// + /// Holt, was der Hauptknopf tut (sein Tooltip): „Start“, während der Generierung „Pausieren“, in der Pause + /// „Fortsetzen“. Der Knopf rechts daneben schließt vor dem Start die Staffel und stoppt während der Generierung. + /// public string GenerierungText => (Laeuft, Pausiert) switch { - (false, _) => Automodus ? "Automodus starten" : "Kostenoptimierung starten", + (false, _) => "Start", (true, true) => "Fortsetzen", _ => "Pausieren", }; - /// Holt einen Wert, der angibt, ob der Knopf das Pause-Symbol zeigt (Generierung läuft). + /// Holt einen Wert, der angibt, ob der Hauptknopf das Play-Symbol zeigt (vor dem Start und in der Pause). + public bool ZeigtStart => !ZeigtPause; + + /// Holt einen Wert, der angibt, ob der Hauptknopf das Pause-Symbol zeigt (Generierung läuft). public bool ZeigtPause => Laeuft && !Pausiert; - /// Holt einen Wert, der angibt, ob der Knopf das Start-Symbol zeigt. - public bool ZeigtStart => !ZeigtPause; + /// Holt einen Wert, der angibt, ob rechts daneben „Stopp“ steht (Generierung läuft oder ruht; sonst „Schließen“). + public bool ZeigtStopp => Laeuft; /// /// Holt einen Wert, der angibt, ob „Start mit“ gewählt werden kann: nur vor dem Start. Eine laufende oder angehaltene @@ -315,15 +341,11 @@ private set { if (SetProperty(ref pausiert, value)) { - OnPropertyChanged(nameof(PauseText)); GenerierungGeaendert(); } } } - /// Holt die Beschriftung der Pause-Schaltfläche. - public string PauseText => Pausiert ? "Weiter" : "Pause"; - /// Holt die auswählbaren Planquellen aller Ansichten. public IReadOnlyList Planquellen { @@ -340,8 +362,8 @@ public IReadOnlyList Planquellen /// Holt den Befehl „Schließen“: Staffel schließen und zur Startseite zurückkehren. public IAsyncRelayCommand SchliessenCommand { get; } - /// Holt den Befehl, der die Generierung startet, anhält oder fortsetzt (ein Knopf, der umschaltet). - public IRelayCommand GenerierungCommand { get; } + /// Holt den Befehl des Hauptknopfs: startet die Generierung, hält sie an oder setzt sie fort. + public IAsyncRelayCommand GenerierungCommand { get; } /// Holt den Befehl, der eine zuletzt geöffnete Staffel öffnet (Parameter: Pfad). public IAsyncRelayCommand ZuletztOeffnenCommand { get; } @@ -359,7 +381,7 @@ public IReadOnlyList Planquellen public IRelayCommand ZoomZuruecksetzenCommand { get; } /// Holt den Befehl „Generierung starten“ (mit leerem Plan wie im Original). - public IRelayCommand StartenCommand { get; } + public IAsyncRelayCommand StartenCommand { get; } /// Holt den Befehl „Pause/Weiter“. public IRelayCommand PauseCommand { get; } @@ -411,6 +433,12 @@ public IReadOnlyList Planquellen /// Holt den Befehl „Nachbarmannschaften“ (gemeldete Spiele der Nachbarmannschaften, als andockbares Dokument). public IRelayCommand NachbarterminansichtCommand { get; } + /// Holt den Befehl „Maschinenraum“ (Rechenkerne und Inseln der laufenden Generierung, als andockbares Dokument). + public IRelayCommand MaschinenraumCommand { get; } + + /// Holt den Befehl „Verlauf“ (Verlauf der Suche seit dem Start, als andockbares Dokument). + public IRelayCommand VerlaufCommand { get; } + /// Holt den Befehl „Einstellungen“ (Optionen der Staffel, als andockbares Dokument). public IRelayCommand EinstellungenCommand { get; } @@ -478,21 +506,40 @@ private set } } - /// Holt den Eintrag „Kostenoptimierung“ im Auswahlmenü des Startknopfs. - public string KostenoptimierungText => Menueeintrag("Kostenoptimierung", !Automodus); - - /// Holt den Eintrag „Automodus“ im Auswahlmenü des Startknopfs. - public string AutomodusText => Menueeintrag("Automodus", Automodus); + /// Holt den Hinweis zur Wahl des Verfahrens: was das gewählte tut und wie gewechselt wird. + public string VerfahrenErklaerung => (Automodus, Laeuft) switch + { + (true, false) => "Lenkt sich selbst: erst Verstöße der Stufen A und B herausdrängen, dann die übrigen Kriterien glätten.", + (false, false) => "Wie im Original: senkt die Gesamtkosten mit Ihren Gewichtungen.", + _ => "Wechsel jederzeit möglich – die Suche macht mit dem bisher besten Plan weiter.", + }; /// Holt den Hinweis zum Startknopf: welches Verfahren läuft bzw. startet. public string VerfahrenHinweis => Automodus - ? "Automodus: Die Suche lenkt sich selbst über die Gewichte. Über den Pfeil rechts lässt sich zur Kostenoptimierung wechseln." - : "Kostenoptimierung wie im Original. Über den Pfeil rechts lässt sich zum Automodus wechseln."; + ? "Automodus: Die Suche lenkt sich selbst über die Gewichte. Mit „Verfahren“ links daneben lässt sich zur Kostenoptimierung wechseln, auch während der Generierung. Während der Generierung hält der Knopf sie an; „Stopp“ rechts daneben beendet sie." + : "Kostenoptimierung wie im Original. Mit „Verfahren“ links daneben lässt sich zum Automodus wechseln, auch während der Generierung. Während der Generierung hält der Knopf sie an; „Stopp“ rechts daneben beendet sie."; + + /// + /// Holt oder setzt, ob unter „Verfahren“ die Kostenoptimierung gewählt ist (Umschalter im Kopf). Setzen wählt das + /// Verfahren bzw. wechselt während der Generierung; ein Klick auf das schon gewählte lässt es gewählt. + /// + public bool KostenoptimierungGewaehlt + { + get => !Automodus; + set => VerfahrenUmschalten(value, auto: false); + } + + /// Holt oder setzt, ob unter „Verfahren“ der Automodus gewählt ist (siehe ). + public bool AutomodusGewaehlt + { + get => Automodus; + set => VerfahrenUmschalten(value, auto: true); + } - /// Holt den Befehl „Kostenoptimierung starten“ bzw. während der Generierung „zur Kostenoptimierung wechseln“. + /// Holt den Befehl „Kostenoptimierung“ der Verfahrenswahl: wählen bzw. während der Generierung dorthin wechseln. public IRelayCommand KostenoptimierungCommand { get; } - /// Holt den Befehl „Automodus starten“ bzw. während der Generierung „zum Automodus wechseln“. + /// Holt den Befehl „Automodus“ der Verfahrenswahl: wählen bzw. während der Generierung dorthin wechseln. public IRelayCommand AutomodusCommand { get; } /// Holt die Einteilung der Kriterien in die Stufen A, B und C der geöffneten Staffel (sonst den Standard). @@ -515,6 +562,10 @@ public void Dispose() dienst.Dispose(); } + /// Vor dem Beenden des Programms: Ein noch nicht gesicherter Plan wird gesichert (oder es wird gefragt). + /// false, wenn der Anwender abbricht und das Programm offen bleiben soll. + public Task BeendenErlaubtAsync() => PlanSichernAsync("Das Programm wird beendet."); + /// /// Legt die Vergrößerung fest, mit der die Ansichten starten (von der Oberfläche einmal nach der Größe des /// Bildschirms ermittelt, siehe ); gilt für die offenen und alle neuen Ansichten. @@ -915,6 +966,7 @@ private static string Zeitangabe(DateTime zeitpunkt, DateTime heute) { PlanQuellenArt.LaufendeGenerierung => "bestem Plan der letzten Generierung", PlanQuellenArt.ClickTtPlan => "in click-TT vorhandenem Plan", + PlanQuellenArt.GesicherterPlan => "gesichertem Plan", _ => "gemerktem Plan „" + quelle.Eintrag!.Name + "“", }; @@ -942,6 +994,14 @@ private static string Statustext(Optimierungsstand stand) return stand.Pausiert ? "Angehalten – " + text : text; } + /// + /// Frage zum Vorschlag „nur Rückrunde“ (Original: „Die Vorrunde liegt in der Vergangenheit. Soll nur die Rückrunde + /// generiert werden?“). Das Original fragt schon ab 60 Tagen vor Beginn der Rückrunde; deshalb nennt die Frage das Datum. + /// + private static string RueckrundenFrage(DateOnly? rueckrunde) => rueckrunde is DateOnly beginn + ? $"Die Rückrunde beginnt am {beginn.ToString("dd.MM.yyyy", Deutsch)}, die Vorrunde ist also (fast) vorbei. Soll nur die Rückrunde generiert werden?" + : "Die Vorrunde liegt in der Vergangenheit. Soll nur die Rückrunde generiert werden?"; + /// Die Ansicht mit dem Fokus, sonst die aktive Ansicht des Hauptbereichs, sofern sie vergrößert werden kann. private IZoombar? ZoombareAnsicht() => Layout.FocusedDockable as IZoombar ?? (Layout.ActiveDockable as IDock)?.ActiveDockable as IZoombar; @@ -1011,8 +1071,7 @@ private async Task SchliessenAsync() return; } - const string Frage = "Die Generierung wird beendet; nicht gemerkte Pläne gehen verloren. Staffel trotzdem schließen?"; - if (laufenderStand is not null && !await oberflaeche.FragenAsync("Staffel schließen", Frage)) + if (!await PlanSichernAsync("Die Staffel wird geschlossen.")) { return; } @@ -1024,7 +1083,7 @@ private async Task SchliessenAsync() gemerkteStaende.Clear(); Einrichtung = null; var offen = new List(ansichten); - offen.AddRange(new IDockable?[] { optionenAnsicht, terminwunschAnsicht, nachbarterminAnsicht }.OfType()); + offen.AddRange(new IDockable?[] { optionenAnsicht, terminwunschAnsicht, nachbarterminAnsicht, maschinenraumAnsicht, verlaufAnsicht }.OfType()); foreach (IDockable ansicht in offen) { fabrik.CloseDockable(ansicht); @@ -1034,6 +1093,8 @@ private async Task SchliessenAsync() optionenAnsicht = null; terminwunschAnsicht = null; nachbarterminAnsicht = null; + maschinenraumAnsicht = null; + verlaufAnsicht = null; AnsichtOeffnen(new KostenAnsichtViewModel(this, NaechsteId())); Titel = UeberViewModel.Programm; @@ -1054,6 +1115,8 @@ private async Task SchliessenAsync() DruckenCommand.NotifyCanExecuteChanged(); TerminwunschansichtCommand.NotifyCanExecuteChanged(); NachbarterminansichtCommand.NotifyCanExecuteChanged(); + MaschinenraumCommand.NotifyCanExecuteChanged(); + VerlaufCommand.NotifyCanExecuteChanged(); SchliessenCommand.NotifyCanExecuteChanged(); BefehleAktualisieren(); QuellenNeuAufbauen(); @@ -1062,6 +1125,11 @@ private async Task SchliessenAsync() private async Task StaffelOeffnenAsync(string pfad) { + if (!await PlanSichernAsync("Eine andere Staffel wird geöffnet.")) + { + return false; + } + Plansitzung neu; try { @@ -1158,7 +1226,7 @@ private async Task StaffelOeffnenAsync(string pfad) rundenschritt = new Einrichtungsschritt( schritte.Count + 1, "Rundenplanung", - halb ? "Die Runde ist kürzer als 6 Monate. Welche Spiele sollen generiert werden?" : "Die Vorrunde liegt in der Vergangenheit. Welche Spiele sollen generiert werden?", + halb ? "Die Runde ist kürzer als 6 Monate. Welche Spiele sollen generiert werden?" : RueckrundenFrage(neu.Staffel.Rueckrundenbeginn), [], [ halb ? new Einrichtungsoption("Nur eine Halbrunde", "Vorschlag für kurze Runden") : new Einrichtungsoption("Nur die Rückrunde", "Vorschlag – die Vorrunde bleibt, wie sie ist"), @@ -1333,6 +1401,7 @@ private void Starten() dienst.Pausiert = false; dienst.Automodus = Automodus; Anzeige.Zuruecksetzen("erster Plan wird gesucht …"); + Verlauf.Zuruecksetzen(); dienst.Starten(s.Staffel, s.Optionen, ausgangsplan); StartplaeneAufbauen(); Laeuft = true; @@ -1343,28 +1412,51 @@ private void Starten() VerfahrensansichtZeigen(); } - private void GenerierungUmschalten() + /// Hauptknopf: startet eine neue Generierung bzw. hält die laufende an und setzt sie fort. + private async Task GenerierungUmschaltenAsync() { if (Laeuft) { PauseUmschalten(); } else + { + await StartenAsync(); + } + } + + /// Startet eine neue Generierung; ein noch nicht gesicherter Plan wird vorher gesichert (oder es wird gefragt). + private async Task StartenAsync() + { + if (await PlanSichernAsync("Eine neue Generierung startet.")) { Starten(); } } + /// Umschalter: Wählen eines Verfahrens; das Abwählen des gewählten stellt nur die Anzeige wieder her. + private void VerfahrenUmschalten(bool gewaehlt, bool auto) + { + if (gewaehlt) + { + VerfahrenWaehlen(auto); + } + else + { + OnPropertyChanged(auto ? nameof(AutomodusGewaehlt) : nameof(KostenoptimierungGewaehlt)); + } + } + /// - /// Auswahl im Menü des Startknopfs: vor dem Start wird mit diesem Verfahren gestartet; während der Generierung wird zu - /// ihm gewechselt, und die Suche setzt beim bisher besten Plan fort (auch aus der Pause heraus). + /// Wahl des Verfahrens: vor dem Start wird das Verfahren nur gewählt (gestartet wird mit dem Startknopf); während + /// der Generierung wird zu ihm gewechselt, und die Suche setzt beim bisher besten Plan fort (auch aus der Pause heraus). /// private void VerfahrenWaehlen(bool auto) { + bool wechsel = Automodus != auto; Automodus = auto; - if (!Laeuft) + if (!Laeuft || !wechsel) { - Starten(); return; } @@ -1400,19 +1492,13 @@ private void VerfahrensansichtZeigen() } } - private string Menueeintrag(string verfahren, bool gewaehlt) => (Laeuft, gewaehlt) switch - { - (false, _) => verfahren + " starten", - (true, true) => verfahren + " (läuft)", - _ => (verfahren == "Automodus" ? "Zum " : "Zur ") + verfahren + " wechseln", - }; - private void VerfahrenGeaendert() { OnPropertyChanged(nameof(GenerierungText)); - OnPropertyChanged(nameof(KostenoptimierungText)); - OnPropertyChanged(nameof(AutomodusText)); + OnPropertyChanged(nameof(VerfahrenErklaerung)); OnPropertyChanged(nameof(VerfahrenHinweis)); + OnPropertyChanged(nameof(KostenoptimierungGewaehlt)); + OnPropertyChanged(nameof(AutomodusGewaehlt)); KostenoptimierungCommand.NotifyCanExecuteChanged(); AutomodusCommand.NotifyCanExecuteChanged(); } @@ -1423,6 +1509,7 @@ private void GenerierungGeaendert() OnPropertyChanged(nameof(GenerierungText)); OnPropertyChanged(nameof(ZeigtPause)); OnPropertyChanged(nameof(ZeigtStart)); + OnPropertyChanged(nameof(ZeigtStopp)); OnPropertyChanged(nameof(StartplanWaehlbar)); PlanLoeschenAktualisieren(); } @@ -1485,6 +1572,74 @@ private async Task ZuletztOeffnenAsync(string? pfad) } } + /// + /// Sichert den aktuellen Plan, bevor er durch einen Neustart oder das Schließen verloren ginge: Gibt es noch keinen + /// gesicherten Plan, wird er gesichert und eine Meldung gezeigt; sonst wird gefragt, ob der gesicherte ersetzt werden + /// soll, mit beiden Plänen im Vergleich. Über einen Plan wird nur einmal entschieden; ist er schon gesichert, geschieht + /// nichts. Während der Frage ruht die Generierung. + /// + /// Was geschieht, z. B. „Die Staffel wird geschlossen.“. + /// false, wenn der Anwender abbricht (der Neustart bzw. das Schließen unterbleibt). + private async Task PlanSichernAsync(string anlass) + { + if (sitzung is not Plansitzung s || laufenderStand is not Planstand aktuell || ReferenceEquals(aktuell.Spiele, sicherungEntschieden)) + { + return true; + } + + using IDisposable? pause = dienst.Laeuft ? dienst.Anhalten() : null; + GemerkterPlanEintrag? eintrag = s.GesicherterPlan(); + Planstand? gesichert = eintrag is null ? null : GemerktenStandLaden(s, eintrag); + if (eintrag is null || gesichert is null) + { + if (!await SichernAsync(s, aktuell)) + { + return false; + } + + await oberflaeche.MeldenAsync( + "Plan gesichert", + $"{anlass} Der aktuelle Plan wurde deshalb als „{Plansitzung.GesicherterPlanName}“ gesichert. Sie finden ihn unter „Start mit“ und in der Planauswahl der Ansichten."); + return true; + } + + if (gesichert.Spiele.SequenceEqual(aktuell.Spiele)) + { + sicherungEntschieden = aktuell.Spiele; + return true; + } + + var vergleich = new Planvergleich(anlass, aktuell.Bewertung, gesichert.Bewertung, Einteilung, eintrag.Geaendert); + switch (await oberflaeche.SicherungAbfragenAsync(vergleich)) + { + case Sicherungswahl.Ueberschreiben: + return await SichernAsync(s, aktuell); + case Sicherungswahl.Behalten: + sicherungEntschieden = aktuell.Spiele; + return true; + default: + return false; + } + } + + /// Schreibt den gesicherten Plan; bei einem Fehler wird gefragt, ob trotzdem fortgefahren werden soll. + private async Task SichernAsync(Plansitzung s, Planstand aktuell) + { + try + { + GemerkterPlanEintrag eintrag = s.PlanSichern(aktuell.Spiele); + gemerkteStaende.Remove(eintrag.Pfad); + } + catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) + { + return await oberflaeche.FragenAsync("Plan sichern", $"Der Plan konnte nicht gesichert werden:{Environment.NewLine}{fehler.Message}{Environment.NewLine}Trotzdem fortfahren? Der Plan geht dann verloren."); + } + + sicherungEntschieden = aktuell.Spiele; + QuellenNeuAufbauen(); + return true; + } + private void PauseUmschalten() { dienst.Pausiert = !dienst.Pausiert; @@ -1494,6 +1649,8 @@ private void PauseUmschalten() private void Stoppen() { dienst.Stoppen(); + Anzeige.KerneAnhalten(); + maschinenraumAnsicht?.Uebernehmen(Optimierungsstand.Leer); Laeuft = false; Pausiert = false; StartplaeneAufbauen(); @@ -1608,6 +1765,33 @@ private void TerminwuenscheOeffnen() } } + private void VerlaufOeffnen() + { + if (verlaufAnsicht is null) + { + verlaufAnsicht = new VerlaufAnsichtViewModel(this); + fabrik.AnsichtHinzufuegen(verlaufAnsicht); + } + else + { + fabrik.SetActiveDockable(verlaufAnsicht); + } + } + + private void MaschinenraumOeffnen() + { + if (maschinenraumAnsicht is null) + { + maschinenraumAnsicht = new MaschinenraumAnsichtViewModel(this); + maschinenraumAnsicht.Uebernehmen(Laeuft ? dienst.Stand() : Optimierungsstand.Leer); + fabrik.AnsichtHinzufuegen(maschinenraumAnsicht); + } + else + { + fabrik.SetActiveDockable(maschinenraumAnsicht); + } + } + private void NachbartermineOeffnen() { if (nachbarterminAnsicht is null) @@ -1729,6 +1913,11 @@ private void QuellenNeuAufbauen() neu.Add(PlanQuelle.ClickTt); } + if (s.GesicherterPlan() is GemerkterPlanEintrag gesichert) + { + neu.Add(PlanQuelle.Gesichert(gesichert)); + } + neu.AddRange(s.GemerktePlaene().Select(PlanQuelle.Gemerkt)); } @@ -1849,6 +2038,8 @@ private void AnsichtsbefehleAktualisieren() DiagrammansichtCommand.NotifyCanExecuteChanged(); TerminwunschansichtCommand.NotifyCanExecuteChanged(); NachbarterminansichtCommand.NotifyCanExecuteChanged(); + MaschinenraumCommand.NotifyCanExecuteChanged(); + VerlaufCommand.NotifyCanExecuteChanged(); } /// Zeigt die Ansicht dieser Art: holt eine offene nach vorn oder öffnet sie neu. @@ -1916,6 +2107,8 @@ private void StatusAbfragen() { Statuszeile = text; Anzeige.Uebernehmen(stand); + Verlauf.Hinzufuegen(stand); + maschinenraumAnsicht?.Uebernehmen(stand); } }); } diff --git a/src/AndiGenerator.Presentation/HeimkoppelnSeiteViewModel.cs b/src/AndiGenerator.Presentation/HeimkoppelnSeiteViewModel.cs index 2357213..485e2ac 100644 --- a/src/AndiGenerator.Presentation/HeimkoppelnSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/HeimkoppelnSeiteViewModel.cs @@ -10,13 +10,15 @@ namespace AndiGenerator.Presentation; public sealed class HeimkoppelnSeiteViewModel : MehrMannschaftenSeiteViewModel { private const string Hinweis = - "Neue Heimkoppeltermine können nur in den Wunschterminen angelegt werden.\n" - + "Die zweite Anfangszeit kann ebenfalls nur in den Wunschterminen angegeben werden"; + "Koppelspieltag: zwei Heimspiele am selben Tag (mit zweiter Anfangszeit). Doppelspieltag: zwei Heimspiele an zwei " + + "aufeinanderfolgenden Tagen, meist Samstag und Sonntag. Je Termin bzw. Terminpaar wählen Sie, ob das möglich, " + + "gewünscht oder mit hoher Priorität gewünscht ist.\n" + + "Neue Koppeltermine und die zweite Anfangszeit legen Sie in den Wunschterminen an."; /// Initialisiert die Seite. /// Arbeitskopie. public HeimkoppelnSeiteViewModel(Datenbearbeitung bearbeitung) - : base(bearbeitung, "Heimkoppel/Doppelspieltage (Kompakt)", Hinweis) + : base(bearbeitung, "Heimkoppel und Doppelspieltage", Hinweis) { Laden(); } diff --git a/src/AndiGenerator.Presentation/HeimrechtZeile.cs b/src/AndiGenerator.Presentation/HeimrechtZeile.cs index 5a1b5d8..64f61fa 100644 --- a/src/AndiGenerator.Presentation/HeimrechtZeile.cs +++ b/src/AndiGenerator.Presentation/HeimrechtZeile.cs @@ -39,17 +39,60 @@ public int Index { if (value >= 0 && SetProperty(ref index, value)) { + WahlGeaendert(); geaendert(); } } } + /// Holt oder setzt, ob die gegnerische Mannschaft in der Vorrunde Heimrecht hat (Umschalter). + public bool IstVorrunde + { + get => index == 0; + set => Waehlen(value, 0); + } + + /// Holt oder setzt, ob das Heimrecht egal ist (Umschalter). + public bool IstEgal + { + get => index == 1; + set => Waehlen(value, 1); + } + + /// Holt oder setzt, ob die gegnerische Mannschaft in der Rückrunde Heimrecht hat (Umschalter). + public bool IstRueckrunde + { + get => index == 2; + set => Waehlen(value, 2); + } + /// Holt das Heimrecht (0 = egal, 1 = Vorrunde, 2 = Rückrunde). public int Wert => Werte[index]; /// Setzt das Heimrecht ohne Änderungsmeldung (Standard). /// Neues Heimrecht. - internal void Setzen(int wert) => SetProperty(ref index, IndexVon(wert), nameof(Index)); + internal void Setzen(int wert) + { + if (SetProperty(ref index, IndexVon(wert), nameof(Index))) + { + WahlGeaendert(); + } + } private static int IndexVon(int wert) => Math.Max(0, Array.IndexOf(Werte, wert)); + + private void Waehlen(bool gewaehlt, int wahl) + { + if (gewaehlt) + { + Index = wahl; + } + } + + private void WahlGeaendert() + { + OnPropertyChanged(nameof(IstVorrunde)); + OnPropertyChanged(nameof(IstEgal)); + OnPropertyChanged(nameof(IstRueckrunde)); + } } diff --git a/src/AndiGenerator.Presentation/IOberflaeche.cs b/src/AndiGenerator.Presentation/IOberflaeche.cs index 9989786..3de2f54 100644 --- a/src/AndiGenerator.Presentation/IOberflaeche.cs +++ b/src/AndiGenerator.Presentation/IOberflaeche.cs @@ -36,6 +36,13 @@ public interface IOberflaeche /// true bei „Ja“. Task FragenAsync(string titel, string frage); + /// + /// Fragt, ob der gesicherte Plan durch den aktuellen ersetzt werden soll, und zeigt dazu beide im Vergleich. + /// + /// Der Vergleich mit Frage und Urteil. + /// Die Antwort. + Task SicherungAbfragenAsync(Planvergleich vergleich); + /// Stellt eine Entscheidungsfrage mit Detailtext und eigenen Schaltflächen. /// Titel des Dialogs. /// Einleitender Text. diff --git a/src/AndiGenerator.Presentation/Inselanzeige.cs b/src/AndiGenerator.Presentation/Inselanzeige.cs new file mode 100644 index 0000000..8dd267c --- /dev/null +++ b/src/AndiGenerator.Presentation/Inselanzeige.cs @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace AndiGenerator.Presentation; + +/// Eine Insel im Maschinenraum: eine eigenständige Suche mit eigenem besten Plan. +public sealed class Inselanzeige : ObservableObject +{ + private string kosten = string.Empty; + private bool istBeste; + private bool laeuft; + private double fortschritt; + private string fortschrittText = string.Empty; + private string neustarts = string.Empty; + private string strategien = string.Empty; + private IReadOnlyList kostentreiber = []; + private string hinweis = string.Empty; + private bool zeigtVerstoesse; + private string verstossA = string.Empty; + private string verstossB = string.Empty; + private string verstossC = string.Empty; + + /// Initialisiert die Insel. + /// Überschrift, z. B. „Insel 2“ oder „Spezial-Insel“. + /// Ist die Spezial-Insel. + public Inselanzeige(string titel, bool spezial) + { + Titel = titel; + IstSpezial = spezial; + } + + /// Holt die Überschrift. + public string Titel { get; } + + /// Holt einen Wert, der angibt, ob es die Spezial-Insel ist. + public bool IstSpezial { get; } + + /// Holt die Kosten ihres besten Plans. + public string Kosten + { + get => kosten; + internal set => SetProperty(ref kosten, value); + } + + /// Holt einen Wert, der angibt, ob sie gerade den besten Plan aller Inseln hat. + public bool IstBeste + { + get => istBeste; + internal set => SetProperty(ref istBeste, value); + } + + /// Holt einen Wert, der angibt, ob sie rechnet. + public bool Laeuft + { + get => laeuft; + internal set => SetProperty(ref laeuft, value); + } + + /// Holt den Weg bis zum Neustart in Prozent: Pläne ohne Verbesserung im Verhältnis zur Grenze. + public double Fortschritt + { + get => fortschritt; + internal set => SetProperty(ref fortschritt, value); + } + + /// Holt den Text zum Fortschritt. + public string FortschrittText + { + get => fortschrittText; + internal set => SetProperty(ref fortschrittText, value); + } + + /// Holt die Zahl der Neustarts als Text. + public string Neustarts + { + get => neustarts; + internal set => SetProperty(ref neustarts, value); + } + + /// Holt die größten Kostenverursacher ihres besten Plans. + public IReadOnlyList Kostentreiber + { + get => kostentreiber; + internal set => SetProperty(ref kostentreiber, value); + } + + /// Holt die Suchstrategien ihrer Suchplätze (als Hinweis für Neugierige). + public string Strategien + { + get => strategien; + internal set => SetProperty(ref strategien, value); + } + + /// Holt einen Wert, der angibt, ob statt der Kosten die Verstöße A, B und C gezeigt werden (Automodus). + public bool ZeigtVerstoesse + { + get => zeigtVerstoesse; + internal set => SetProperty(ref zeigtVerstoesse, value); + } + + /// Holt die Verstöße der Stufe A ihres besten Plans. + public string VerstossA + { + get => verstossA; + internal set => SetProperty(ref verstossA, value); + } + + /// Holt die Verstöße der Stufe B ihres besten Plans. + public string VerstossB + { + get => verstossB; + internal set => SetProperty(ref verstossB, value); + } + + /// Holt die Verstöße der Stufe C ihres besten Plans. + public string VerstossC + { + get => verstossC; + internal set => SetProperty(ref verstossC, value); + } + + /// Holt einen erklärenden Hinweis (Spezial-Insel). + public string Hinweis + { + get => hinweis; + internal set => SetProperty(ref hinweis, value); + } +} diff --git a/src/AndiGenerator.Presentation/Inselzustand.cs b/src/AndiGenerator.Presentation/Inselzustand.cs new file mode 100644 index 0000000..a87e667 --- /dev/null +++ b/src/AndiGenerator.Presentation/Inselzustand.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Was eine Insel im Schaubild grob zeigt. +/// Wert ihres besten Plans (Kosten bzw. Verstöße A · B · C). +/// Was sie gerade grob tut, z. B. „kommt voran“ oder „steckt fest“. +/// Weg bis zum Neustart (0 = hat gerade verbessert, 1 = beginnt bald neu). +public sealed record Inselzustand(string Wert, string Taetigkeit, double Stillstand); diff --git a/src/AndiGenerator.Presentation/Kalendermonat.cs b/src/AndiGenerator.Presentation/Kalendermonat.cs new file mode 100644 index 0000000..20dad96 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kalendermonat.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein Monat im Kalender der spielfreien Tage. +/// Monat und Jahr, z. B. „September 2026“. +/// Die Felder ab Montag der ersten Woche: leere Felder vor dem Ersten, dann jeder Tag des Monats. +public sealed record Kalendermonat(string Titel, IReadOnlyList Tage); diff --git a/src/AndiGenerator.Presentation/Kalendertag.cs b/src/AndiGenerator.Presentation/Kalendertag.cs new file mode 100644 index 0000000..b6e89d1 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kalendertag.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein Feld im Kalender der spielfreien Tage. +/// Tag im Monat; leer bei einem Feld vor dem Ersten. +/// Der wählbare Tag (er hat Heimspielwünsche); null, wenn der Tag nicht wählbar ist. +/// Samstag oder Sonntag. +/// Tooltip: Datum und, bei nicht wählbaren Tagen, der Grund, z. B. „keine Mannschaft hat hier einen Heimspielwunsch“. +/// Nicht wählbar, aber als spielfreier Tag eingetragen (z. B. aus click-TT). +public sealed record Kalendertag(string Nummer, AuswahlZeile? Zeile, bool Wochenende, string Hinweis, bool Eingetragen = false) +{ + /// Holt einen Wert, der angibt, ob der Tag gewählt werden kann. + public bool Waehlbar => Zeile is not null; + + /// Holt einen Wert, der angibt, ob es ein Tag des Monats ist (kein leeres Feld). + public bool IstTag => Nummer.Length > 0; +} diff --git a/src/AndiGenerator.Presentation/Kernanzeige.cs b/src/AndiGenerator.Presentation/Kernanzeige.cs new file mode 100644 index 0000000..956afa7 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kernanzeige.cs @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace AndiGenerator.Presentation; + +/// Ein Rechenkern im Maschinenraum: wie viele Pläne er gerade schafft, im Vergleich zum schnellsten Kern. +public sealed class Kernanzeige : ObservableObject +{ + /// Höhe eines ruhenden Kerns in der Kachel (Pixel). + private const double Ruhe = 3; + + /// Zusätzliche Höhe bei voller Last in der Kachel (Pixel). + private const double Vollast = 19; + + /// Faktor für die große Darstellung in der Ansicht „Maschinenraum“. + private const double Gross = 6; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private double hoehe = Ruhe; + private double last; + private string tempo = string.Empty; + + /// Initialisiert den Kern bzw. eine Gruppe von Kernen. + /// Name für den Hinweis, z. B. „Kern 3“ oder „Kerne 1–12“. + public Kernanzeige(string name) => Name = name; + + /// Holt den Namen für den Hinweis. + public string Name { get; } + + /// Holt die Last im Verhältnis zum schnellsten Kern (0 bis 1). + public double Last + { + get => last; + private set + { + if (SetProperty(ref last, value)) + { + OnPropertyChanged(nameof(Deckkraft)); + } + } + } + + /// Holt die Deckkraft des Kästchens in der Rasterdarstellung vieler Kerne (kräftig = volle Last). + public double Deckkraft => 0.12 + (0.88 * Last); + + /// Holt die Höhe des Balkens in der Kachel. + public double Hoehe + { + get => hoehe; + private set + { + if (SetProperty(ref hoehe, value)) + { + OnPropertyChanged(nameof(HoeheGross)); + } + } + } + + /// Holt die Höhe des Balkens in der Ansicht „Maschinenraum“. + public double HoeheGross => Hoehe * Gross; + + /// Holt den Hinweis: Kern und Pläne je Sekunde. + public string Tempo + { + get => tempo; + private set => SetProperty(ref tempo, value); + } + + /// Übernimmt die Last des Kerns. + /// Pläne je Sekunde im Verhältnis zum schnellsten Kern (0 bis 1; 0 = ruht). + /// Pläne je Sekunde des Kerns. + internal void Setzen(double anteil, double plaeneProSekunde) + { + Last = Math.Clamp(anteil, 0, 1); + Hoehe = Ruhe + (Vollast * Last); + Tempo = string.Create(Deutsch, $"{Name}: {plaeneProSekunde:N0} Pläne/s"); + } +} diff --git a/src/AndiGenerator.Presentation/Kostentreiberzeile.cs b/src/AndiGenerator.Presentation/Kostentreiberzeile.cs new file mode 100644 index 0000000..79401e8 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kostentreiberzeile.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein großer Verursacher einer Insel: Name, Balken und Anteil an den Kosten bzw. Zahl der Verstöße (Automodus). +/// Name des Kriteriums. +/// Breite des Balkens in Pixeln (voller Anteil = volle Breite). +/// Anteil an den Kosten, z. B. „43 %“, im Automodus die Zahl der Verstöße. +public sealed record Kostentreiberzeile(string Name, double Breite, string Prozent); diff --git a/src/AndiGenerator.Presentation/LigadatenSeiteViewModel.cs b/src/AndiGenerator.Presentation/LigadatenSeiteViewModel.cs index 0a5bb66..8daa78f 100644 --- a/src/AndiGenerator.Presentation/LigadatenSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/LigadatenSeiteViewModel.cs @@ -69,6 +69,12 @@ public DateTime? Ende set => Setzen(ref ende, value); } + /// Holt den Hinweis der Prüfung, sobald eine Eingabe nicht passt (z. B. Rückrunde vor Vorrunde); sonst leer. + public string Pruefhinweis => Pruefen() ?? string.Empty; + + /// Holt einen Wert, der angibt, ob eine Eingabe nicht passt. + public bool HatPruefhinweis => Pruefhinweis.Length > 0; + /// public override bool IstStandard => Bearbeitung.StandardLigadaten is not Ligadaten standard || Werte() == standard; diff --git a/src/AndiGenerator.Presentation/MannschaftenSeiteViewModel.cs b/src/AndiGenerator.Presentation/MannschaftenSeiteViewModel.cs index e251415..bc1f1a7 100644 --- a/src/AndiGenerator.Presentation/MannschaftenSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/MannschaftenSeiteViewModel.cs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; using AndiGenerator.Application; using CommunityToolkit.Mvvm.Input; @@ -58,6 +59,9 @@ public string? Auswahl /// Holt den Befehl „Löschen“. public IAsyncRelayCommand LoeschenCommand { get; } + /// + public override string Marke => Namen.Count.ToString(CultureInfo.InvariantCulture); + /// public override bool IstStandard => Bearbeitung.MannschaftenSindStandard; diff --git a/src/AndiGenerator.Presentation/MaschinenraumAnsichtViewModel.cs b/src/AndiGenerator.Presentation/MaschinenraumAnsichtViewModel.cs new file mode 100644 index 0000000..9d596c4 --- /dev/null +++ b/src/AndiGenerator.Presentation/MaschinenraumAnsichtViewModel.cs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Collections.ObjectModel; +using System.Globalization; +using AndiGenerator.Application; +using AndiGenerator.Domain.Optionen; +using AndiGenerator.Engine.Inseln; +using AndiGenerator.Engine.Referenz; +using Dock.Model.Mvvm.Controls; + +namespace AndiGenerator.Presentation; + +/// +/// Blick in den Maschinenraum der Optimierung: was die Rechenkerne gerade schaffen, wie die Inseln suchen (beste Kosten, +/// Weg bis zum Neustart, Suchstrategien; im Automodus statt der Kosten die Verstöße A, B und C), was die Spezial-Insel +/// tut und – im Automodus – welche Gewichte er gerade gegenüber den eigenen angehoben hat. Hängt nicht vom Plan ab, +/// sondern nur von der laufenden Generierung. +/// +public sealed class MaschinenraumAnsichtViewModel : Document +{ + /// Breite eines Kostenbalkens bei 100 % (Pixel). + private const double Balkenbreite = 90; + + /// Höchste Gewichtsstufe der Stufenleiste („extrem hoch“; „nicht berücksichtigen“ = keine Stufe). + private const int Stufen = (int)Gewichtung.ExtremHoch; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private readonly HauptfensterViewModel hauptfenster; + private readonly ObservableCollection inseln = []; + private string ueberschrift = "Keine Generierung aktiv"; + private bool rechnet; + private bool automodus; + private IReadOnlyList gewichte = []; + private string gewichteSchluessel = string.Empty; + + /// Initialisiert die Ansicht. + /// Hauptfenster mit der Anzeige der Rechenkerne. + public MaschinenraumAnsichtViewModel(HauptfensterViewModel hauptfenster) + { + ArgumentNullException.ThrowIfNull(hauptfenster); + this.hauptfenster = hauptfenster; + Id = "Maschinenraum"; + Title = "Leitstand"; + CanClose = true; + CanFloat = true; + Anzeige = hauptfenster.Anzeige; + } + + /// Holt die Erklärung des Inselmodells. + public static string Erklaerung => + "Jeder Rechenkern rechnet abwechselnd an mehreren Suchplätzen. Die Suchplätze gehören zu Inseln: Jede Insel sucht für sich " + + "und hat ihren eigenen besten Plan; die Balken zeigen, welche Kriterien in ihm die meisten Kosten verursachen. Im Schaubild " + + "steht je Insel grob, was sie gerade tut; ein grüner Punkt wandert nur, wenn der beste Plan besser wird. Findet die " + + "schlechteste Insel lange nichts Besseres, beginnt sie neu – mit einem leeren Plan oder mit dem Plan der Spezial-Insel, " + + "die eine Kostenart extrem gewichtet."; + + /// Holt die Anzeige mit den Rechenkernen. + public Generierungsanzeige Anzeige { get; } + + /// Holt das Schaubild der Maschine mit seinen Ereignissen. + public Schaubild Schaubild { get; } = new(); + + /// Holt die Inseln (zuletzt die Spezial-Insel). + public IReadOnlyList Inseln => inseln; + + /// Holt die Überschrift: Kerne, Inseln und Tempo. + public string Ueberschrift + { + get => ueberschrift; + private set => SetProperty(ref ueberschrift, value); + } + + /// Holt einen Wert, der angibt, ob gerade gerechnet wird (nicht pausiert). + public bool Rechnet + { + get => rechnet; + private set => SetProperty(ref rechnet, value); + } + + /// Holt einen Wert, der angibt, ob der Automodus lenkt (dann gibt es den Abschnitt der Gewichte). + public bool Automodus + { + get => automodus; + private set => SetProperty(ref automodus, value); + } + + /// Holt die Gewichte, die der Automodus gerade angehoben hat, je Kriterium in der Reihenfolge der Einteilung. + public IReadOnlyList Gewichte + { + get => gewichte; + private set + { + if (SetProperty(ref gewichte, value)) + { + OnPropertyChanged(nameof(KeineGewichte)); + } + } + } + + /// Holt einen Wert, der angibt, ob der Automodus noch kein Gewicht geändert hat. + public bool KeineGewichte => Gewichte.Count == 0; + + /// Übernimmt den Stand der Generierung (im Takt der Statusabfrage). + /// Der Stand. + public void Uebernehmen(Optimierungsstand stand) + { + ArgumentNullException.ThrowIfNull(stand); + Rechnet = stand.Laeuft && !stand.Pausiert; + if (stand.Maschinenraum is not Maschinenraum raum) + { + Ueberschrift = "Keine Generierung aktiv"; + inseln.Clear(); + Automodus = false; + GewichteZeigen([]); + Schaubild.Uebernehmen(stand, inseln); + return; + } + + int normale = raum.Inseln.Count(i => !i.Spezial); + string zustand = stand.Pausiert ? " · angehalten" : string.Empty; + Ueberschrift = string.Create( + Deutsch, + $"{raum.Kerne.Count} Rechenkerne · {normale} Inseln{(normale < raum.Inseln.Count ? " und Spezial-Insel" : string.Empty)} · {stand.PlaeneProSekunde:N0} Pläne/s{zustand}"); + InselnUebernehmen(raum); + Automodus = stand.Lenkung is not null; + GewichteZeigen(stand.Lenkung?.Gewichte ?? []); + Schaubild.Uebernehmen(stand, inseln); + } + + /// + /// Text unter dem Balken einer Insel: wie viele Pläne sie schon nichts Besseres gefunden hat und was das bedeutet. Neu + /// beginnt nur die schlechteste Insel, und erst über der Grenze; die anderen rechnen auch darüber hinaus weiter. + /// + /// Die Insel. + /// Pläne ohne Verbesserung, ab denen die schlechteste Insel neu beginnt. + /// Der Text. + private static string Fortschrittstext(Inselstand insel, long grenze) + { + string plaene = string.Create(Deutsch, $"Seit {Kostenanzeige.Kurz(insel.OhneVerbesserung)} Plänen nichts Besseres"); + if (insel.OhneVerbesserung <= grenze) + { + return string.Create(Deutsch, $"{plaene} – Neustart frühestens nach {Kostenanzeige.Kurz(grenze)}"); + } + + return insel.Schlechteste + ? plaene + " – beginnt gleich neu (schlechteste Insel)" + : plaene + " – rechnet weiter, neu beginnt nur die schlechteste Insel"; + } + + /// Skala eines Gewichts: Ring bei der Gewichtung des Anwenders, Punkt bei der aktuellen, Linie dazwischen. + /// Gewichtung des Anwenders. + /// Gewichtung des Automodus. + /// Die Stufen „sehr wenig“ bis „extrem hoch“. + private static Gewichtssegment[] Segmente(Gewichtung vorher, Gewichtung jetzt) + { + int von = Math.Min((int)vorher, (int)jetzt); + int bis = Math.Max((int)vorher, (int)jetzt); + return Enumerable.Range(1, Stufen) + .Select(stufe => new Gewichtssegment( + stufe == (int)vorher && vorher != jetzt, + stufe == (int)jetzt, + stufe > von && stufe <= bis, + stufe >= von && stufe < bis)) + .ToArray(); + } + + /// Stufe mit Nummer wie in der Qualitätsansicht: A2 … (A1 sind die harten Fehler), B1 …, C1 …. + /// Die vollständige Einteilung. + /// Das Kriterium. + /// Z. B. A2. + private static string StufeMitNummer(Stufeneinteilung einteilung, Kostenkriterium k) + { + Stufe stufe = einteilung.StufeVon(k); + int nummer = einteilung.Position(k) + (stufe == Stufe.A ? 2 : 1); + return stufe.ToString() + nummer.ToString(Deutsch); + } + + private static string Spezialhinweis(Maschinenraum raum, Inselstand insel) + { + if (!insel.Laeuft || raum.SpezialKostenart is not { } art) + { + return "Wartet noch: Sie beginnt, wenn die Inseln eine Weile gerechnet haben."; + } + + string verwendet = raum.SpezialVerwendet == 1 ? "einmal" : raum.SpezialVerwendet.ToString(Deutsch) + "-mal"; + return $"Gewichtet „{Gewichtungsanzeige.Name(art)}“ extrem hoch. Ihr Plan hat bisher {verwendet} eine Insel neu gestartet."; + } + + /// Baut die Gewichte nur neu auf, wenn sie sich geändert haben (sonst flackerte die Ansicht im Takt). + /// Die geänderten Gewichte. + private void GewichteZeigen(IReadOnlyList anhebungen) + { + string schluessel = string.Join(';', anhebungen.Select(a => $"{a.Kriterium},{a.Mannschaft},{a.Vorher},{a.Jetzt}")); + if (schluessel == gewichteSchluessel) + { + return; + } + + gewichteSchluessel = schluessel; + Stufeneinteilung einteilung = hauptfenster.Einteilung.Vervollstaendigt(); + Kostenkriterium[] reihenfolge = [.. einteilung.A, .. einteilung.B, .. einteilung.C]; + Gewichte = anhebungen + .GroupBy(a => a.Kriterium) + .OrderBy(g => Array.IndexOf(reihenfolge, g.Key)) + .Select(g => new Gewichtsgruppe( + StufeMitNummer(einteilung, g.Key), + Planqualitaet.Name(g.Key), + g.OrderBy(a => a.Mannschaft is not null) + .ThenBy(a => a.Mannschaft, StringComparer.CurrentCulture) + .Select(a => new Gewichtszeile( + a.Mannschaft ?? "alle Mannschaften", + Segmente(a.Vorher, a.Jetzt), + $"{Gewichtungsanzeige.Name(a.Vorher)} → {Gewichtungsanzeige.Name(a.Jetzt)}")) + .ToArray())) + .ToArray(); + } + + private void InselnUebernehmen(Maschinenraum raum) + { + if (inseln.Count != raum.Inseln.Count) + { + inseln.Clear(); + foreach (Inselstand insel in raum.Inseln) + { + inseln.Add(new Inselanzeige(insel.Spezial ? "Spezial-Insel" : "Insel " + insel.Nummer.ToString(Deutsch), insel.Spezial)); + } + } + + double beste = raum.Inseln.Where(i => !i.Spezial && i.Kosten >= 0).Select(i => i.Kosten).DefaultIfEmpty(-1).Min(); + Stufenwert? besterWert = null; + foreach (Stufenwert wert in raum.Inseln.Select(i => i.Verstoesse).OfType()) + { + if (besterWert is not { } bisher || wert.IstBesserAls(bisher)) + { + besterWert = wert; + } + } + + for (int i = 0; i < raum.Inseln.Count; i++) + { + Inselstand insel = raum.Inseln[i]; + Inselanzeige anzeige = inseln[i]; + anzeige.Laeuft = insel.Laeuft; + anzeige.Kosten = insel.Kosten < 0 ? "–" : Kostenanzeige.Kurz(insel.Kosten); + anzeige.IstBeste = insel.Verstoesse is { } eigener + ? besterWert is { } b && !b.IstBesserAls(eigener) + : besterWert is null && !insel.Spezial && insel.Kosten >= 0 && insel.Kosten <= beste; + anzeige.ZeigtVerstoesse = insel.Verstoesse is not null; + if (insel.Verstoesse is { } verstoesse) + { + anzeige.VerstossA = verstoesse.A.ToString(Deutsch); + anzeige.VerstossB = verstoesse.B.ToString(Deutsch); + anzeige.VerstossC = verstoesse.CAnzahl.ToString(Deutsch); + } + + anzeige.Strategien = "Suchstrategien der Suchplätze: " + string.Join(" · ", insel.Strategien); + anzeige.Kostentreiber = (insel.Kostentreiber ?? []) + .Select(k => new Kostentreiberzeile( + Planqualitaet.Name(k.Kriterium), + Balkenbreite * k.Anteil, + k.Verstoesse is int verstoesse ? verstoesse.ToString(Deutsch) : k.Anteil.ToString("P0", Deutsch))) + .ToArray(); + anzeige.Neustarts = insel.Neustarts == 1 ? "1 Neustart" : insel.Neustarts.ToString(Deutsch) + " Neustarts"; + if (insel.Spezial) + { + anzeige.Hinweis = Spezialhinweis(raum, insel); + continue; + } + + long grenze = Math.Max(1, raum.DurchlaeufeVorNeustart); + anzeige.Fortschritt = Math.Clamp(100.0 * insel.OhneVerbesserung / grenze, 0, 100); + anzeige.FortschrittText = Fortschrittstext(insel, grenze); + } + } +} diff --git a/src/AndiGenerator.Presentation/MehrMannschaftenSeiteViewModel.cs b/src/AndiGenerator.Presentation/MehrMannschaftenSeiteViewModel.cs index 08ef834..ad63119 100644 --- a/src/AndiGenerator.Presentation/MehrMannschaftenSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/MehrMannschaftenSeiteViewModel.cs @@ -131,7 +131,7 @@ private void Anzeigen(string name) Detail = neu; OnPropertyChanged(nameof(Mannschaft)); OnPropertyChanged(nameof(Ueberschrift)); - AenderungMelden(); + ZustandMelden(); } private void DetailGeaendert(object? sender, EventArgs e) => AenderungMelden(); diff --git a/src/AndiGenerator.Presentation/PflichtspieltageSeiteViewModel.cs b/src/AndiGenerator.Presentation/PflichtspieltageSeiteViewModel.cs index 8309a70..369169e 100644 --- a/src/AndiGenerator.Presentation/PflichtspieltageSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/PflichtspieltageSeiteViewModel.cs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; using AndiGenerator.Application; using CommunityToolkit.Mvvm.Input; @@ -74,6 +75,9 @@ public PflichtspielzeitZeile? Auswahl /// Holt den Befehl „Löschen“. public IRelayCommand LoeschenCommand { get; } + /// + public override string Marke => Zeilen.Count > 0 ? Zeilen.Count.ToString(CultureInfo.InvariantCulture) : string.Empty; + /// public override bool IstStandard => Bearbeitung.PflichtspielzeitenStandard() is not IReadOnlyList standard diff --git a/src/AndiGenerator.Presentation/PlanQuelle.cs b/src/AndiGenerator.Presentation/PlanQuelle.cs index d77f3f5..80ae4bb 100644 --- a/src/AndiGenerator.Presentation/PlanQuelle.cs +++ b/src/AndiGenerator.Presentation/PlanQuelle.cs @@ -9,7 +9,7 @@ namespace AndiGenerator.Presentation; /// Auswählbare Planquelle einer Ansicht. /// Art der Quelle. /// Anzeigename. -/// Gemerkter Plan bei , sonst null. +/// Gemerkter bzw. gesicherter Plan bei und , sonst null. public sealed record PlanQuelle(PlanQuellenArt Art, string Name, GemerkterPlanEintrag? Eintrag) { /// Holt die Quelle „laufende Generierung“. @@ -18,6 +18,15 @@ public sealed record PlanQuelle(PlanQuellenArt Art, string Name, GemerkterPlanEi /// Holt die Quelle „in click-TT vorhandener Plan“. public static PlanQuelle ClickTt { get; } = new(PlanQuellenArt.ClickTtPlan, "In click-TT vorhandener Plan", null); + /// Erzeugt die Quelle zum gesicherten Plan. + /// Der gesicherte Plan. + /// Die Quelle. + public static PlanQuelle Gesichert(GemerkterPlanEintrag eintrag) + { + ArgumentNullException.ThrowIfNull(eintrag); + return new PlanQuelle(PlanQuellenArt.GesicherterPlan, "Gesicherter Plan", eintrag); + } + /// Erzeugt die Quelle zu einem gemerkten Plan. /// Der gemerkte Plan. /// Die Quelle. diff --git a/src/AndiGenerator.Presentation/PlanQuellenArt.cs b/src/AndiGenerator.Presentation/PlanQuellenArt.cs index e12f1ad..bc486b1 100644 --- a/src/AndiGenerator.Presentation/PlanQuellenArt.cs +++ b/src/AndiGenerator.Presentation/PlanQuellenArt.cs @@ -13,6 +13,9 @@ public enum PlanQuellenArt /// In click-TT vorhandener Plan (bestehender Spielplan der Datei). ClickTtPlan, + /// Gesicherter Plan der Staffel (gesichert, bevor ein Plan durch Neustart oder Schließen verloren ginge). + GesicherterPlan, + /// Gemerkter Plan aus dem Staffelordner. GemerkterPlan, } diff --git a/src/AndiGenerator.Presentation/Planaenderung.cs b/src/AndiGenerator.Presentation/Planaenderung.cs new file mode 100644 index 0000000..070e04e --- /dev/null +++ b/src/AndiGenerator.Presentation/Planaenderung.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Was eine Verbesserung des besten Plans bei einem Kriterium geändert hat. +/// Kriterium und Änderung, z. B. „Sperrtermine −1.234“ oder „A · Hallenbelegung 3 → 1“. +/// Das Kriterium ist besser geworden (sonst schlechter). +public sealed record Planaenderung(string Text, bool Besser); diff --git a/src/AndiGenerator.Presentation/Planvergleich.cs b/src/AndiGenerator.Presentation/Planvergleich.cs new file mode 100644 index 0000000..599e692 --- /dev/null +++ b/src/AndiGenerator.Presentation/Planvergleich.cs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Application; +using AndiGenerator.Engine.Inseln; +using AndiGenerator.Engine.Referenz; + +namespace AndiGenerator.Presentation; + +/// +/// Vergleich des aktuellen Plans mit dem gesicherten, bevor der aktuelle durch einen Neustart oder das Schließen verloren +/// ginge: Verstöße je Stufe (A mit den harten Fehlern), die Kosten und die Kriterien, bei denen sich die beiden +/// unterscheiden. Die Güte ordnet wie der Automodus: erst A, dann B, dann C, zuletzt die Kosten. +/// +public sealed class Planvergleich +{ + /// So viele Kriterien mit Unterschieden zeigt der Vergleich höchstens. + private const int UnterschiedeHoechstens = 8; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + /// Initialisiert den Vergleich. + /// Was geschieht, z. B. „Die Staffel wird geschlossen.“. + /// Bewertung des aktuellen Plans. + /// Bewertung des gesicherten Plans. + /// Einteilung der Kriterien in A, B und C. + /// Wann der gesicherte Plan gesichert wurde. + public Planvergleich(string anlass, Planbewertung aktuell, Planbewertung gesichert, Stufeneinteilung einteilung, DateTime gesichertAm) + { + ArgumentNullException.ThrowIfNull(aktuell); + ArgumentNullException.ThrowIfNull(gesichert); + IReadOnlyList neu = Planqualitaet.Kriterien(aktuell, einteilung); + IReadOnlyList alt = Planqualitaet.Kriterien(gesichert, einteilung); + (int A, int B, int C) summeNeu = Summen(neu); + (int A, int B, int C) summeAlt = Summen(alt); + Zeilen = + [ + Zahlzeile("Stufe A (Pflicht, mit harten Fehlern)", summeNeu.A, summeAlt.A), + Zahlzeile("Stufe B", summeNeu.B, summeAlt.B), + Zahlzeile("Stufe C", summeNeu.C, summeAlt.C), + new Vergleichszeile( + "Kosten", + Kostenanzeige.Kurz(aktuell.Gesamtkosten), + Kostenanzeige.Kurz(gesichert.Gesamtkosten), + aktuell.Gesamtkosten < gesichert.Gesamtkosten, + aktuell.Gesamtkosten > gesichert.Gesamtkosten), + ]; + Dictionary vorige = alt.Where(k => k.Anzahl is not null).GroupBy(Schluessel).ToDictionary(g => g.Key, g => g.First()); + Unterschiede = neu + .Where(k => k.Anzahl is int && vorige.TryGetValue(Schluessel(k), out Qualitaetskriterium? v) && v.Anzahl != k.Anzahl) + .Take(UnterschiedeHoechstens) + .Select(k => Zahlzeile($"{k.Stufe} · {k.Name}", k.Anzahl.GetValueOrDefault(), vorige[Schluessel(k)].Anzahl.GetValueOrDefault())) + .ToArray(); + int vergleich = (summeNeu.A, summeNeu.B, summeNeu.C, aktuell.Gesamtkosten).CompareTo((summeAlt.A, summeAlt.B, summeAlt.C, gesichert.Gesamtkosten)); + AktuellBesser = vergleich < 0; + GesichertBesser = vergleich > 0; + Empfehlung = vergleich switch + { + < 0 => "Der aktuelle Plan ist besser.", + > 0 => "Der gesicherte Plan ist besser.", + _ => "Beide Pläne sind gleich gut.", + }; + Frage = string.Create( + Deutsch, + $"{anlass} Dabei ginge der aktuelle Plan verloren. Es gibt schon einen gesicherten Plan (gesichert am {gesichertAm:dd.MM.yyyy} um {gesichertAm:HH:mm} Uhr). Soll er durch den aktuellen Plan ersetzt werden?"); + } + + /// Holt die Frage mit dem Anlass. + public string Frage { get; } + + /// Holt die Verstöße je Stufe und die Kosten beider Pläne. + public IReadOnlyList Zeilen { get; } + + /// Holt die Kriterien, bei denen sich die Zahl der Verstöße unterscheidet (wichtigste Stufe zuerst). + public IReadOnlyList Unterschiede { get; } + + /// Holt einen Wert, der angibt, ob sich die beiden bei einzelnen Kriterien unterscheiden. + public bool HatUnterschiede => Unterschiede.Count > 0; + + /// Holt einen Wert, der angibt, ob der aktuelle Plan insgesamt besser ist. + public bool AktuellBesser { get; } + + /// Holt einen Wert, der angibt, ob der gesicherte Plan insgesamt besser ist. + public bool GesichertBesser { get; } + + /// Holt das Urteil, z. B. „Der aktuelle Plan ist besser.“. + public string Empfehlung { get; } + + private static string Schluessel(Qualitaetskriterium k) => k.Stufe + "|" + k.Name; + + /// Verstöße je Stufe (nur Kriterien mit Anzahl; die harten Fehler zählen zu A). + private static (int A, int B, int C) Summen(IReadOnlyList kriterien) + { + int Summe(char stufe) => kriterien.Where(k => k.Stufe.StartsWith(stufe)).Sum(k => k.Anzahl ?? 0); + return (Summe('A'), Summe('B'), Summe('C')); + } + + private static Vergleichszeile Zahlzeile(string bezeichnung, int aktuell, int gesichert) => + new(bezeichnung, aktuell.ToString(Deutsch), gesichert.ToString(Deutsch), aktuell < gesichert, aktuell > gesichert); +} diff --git a/src/AndiGenerator.Presentation/Schaubild.cs b/src/AndiGenerator.Presentation/Schaubild.cs new file mode 100644 index 0000000..3fb8b40 --- /dev/null +++ b/src/AndiGenerator.Presentation/Schaubild.cs @@ -0,0 +1,318 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using AndiGenerator.Application; +using AndiGenerator.Engine.Inseln; +using AndiGenerator.Engine.Referenz; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace AndiGenerator.Presentation; + +/// +/// Zustand des Schaubilds im Maschinenraum: je Insel ihr Wert und grob, was sie gerade tut (kommt voran, sucht, steckt +/// fest, beginnt neu), dazu die wenigen Ereignisse, die kurz aufleuchten: ein besserer bester Plan (von seiner Insel über +/// den Koordinator), ein Neustart, die Übergabe der Spezial-Insel und eine Anhebung des Automodus. Die Ereignisse ergeben +/// sich aus dem Vergleich zweier aufeinanderfolgender Stände. Bei einem besseren besten Plan steht kurz, was sich geändert +/// hat: bei der Kostenoptimierung die Kriterien mit den größten Kostendifferenzen, im Automodus die wichtigsten Änderungen +/// der Verstöße (Stufe A zuerst) – Verbesserungen wie Verschlechterungen. +/// +public sealed class Schaubild : ObservableObject +{ + /// + /// So lange (Sekunden) wandert der grüne Punkt von der Insel zum besten Plan; erst wenn er ankommt, zeigt das Schaubild, + /// was sich geändert hat. + /// + public const double Wanderzeit = 1.4; + + /// So lange (Sekunden) nach der Ankunft des letzten grünen Punkts steht, was sich geändert hat. + public const double Anzeigedauer = 10; + + /// So lange (Sekunden) bleibt ein Ereignis in der Liste; länger dauert keine Animation. + private const double Lebensdauer = 3; + + /// So lange (Sekunden) nach einer Verbesserung gilt eine Insel als „kommt voran“. + private const double Voran = 5; + + /// Ab diesem Anteil des Wegs bis zum Neustart gilt eine Insel als festgefahren. + private const double Fest = 0.6; + + /// So viele Kriterien zeigt die Änderung eines besseren besten Plans höchstens. + private const int AenderungenHoechstens = 4; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private readonly List impulse = []; + private readonly List meldungen = []; + private Maschinenraum? vorher; + private IReadOnlyList? kriterienVorher; + private long[] zuletztVoran = []; + private int besterPlanVorher = -1; + private int anhebungenVorher = -1; + private int kerne; + private bool spezial; + private bool automodus; + private bool rechnet; + private string bester = "–"; + private IReadOnlyList inseln = []; + private int besteInsel = -1; + private int version; + + /// Holt die Zahl der Rechenkerne (0 = keine Generierung). + public int Kerne + { + get => kerne; + private set => SetProperty(ref kerne, value); + } + + /// Holt einen Wert, der angibt, ob es eine Spezial-Insel gibt (Kostenoptimierung). + public bool Spezial + { + get => spezial; + private set => SetProperty(ref spezial, value); + } + + /// Holt einen Wert, der angibt, ob der Automodus lenkt. + public bool Automodus + { + get => automodus; + private set => SetProperty(ref automodus, value); + } + + /// Holt einen Wert, der angibt, ob gerade gerechnet wird. + public bool Rechnet + { + get => rechnet; + private set => SetProperty(ref rechnet, value); + } + + /// Holt den Wert des besten Plans (Kosten bzw. Verstöße A, B und C). + public string Bester + { + get => bester; + private set => SetProperty(ref bester, value); + } + + /// Holt je Insel (ohne Spezial-Insel) ihren Zustand. + public IReadOnlyList Inseln + { + get => inseln; + private set => SetProperty(ref inseln, value); + } + + /// Holt den Index der Insel mit dem besten Plan; −1, wenn keine. + public int BesteInsel + { + get => besteInsel; + private set => SetProperty(ref besteInsel, value); + } + + /// + /// Holt je grünem Punkt der letzten Sekunden, was seine Verbesserung geändert hat (ältester zuerst). Zu sehen ist die + /// Meldung des zuletzt angekommenen Punkts ( nach ihrem Zeitpunkt). + /// + public IReadOnlyList Meldungen => meldungen; + + /// Holt die Ereignisse der letzten Sekunden. + public IReadOnlyList Impulse => impulse; + + /// Holt eine Zahl, die sich mit jedem Stand ändert (zum Neuzeichnen). + public int Version + { + get => version; + private set => SetProperty(ref version, value); + } + + /// + /// Was sich zwischen zwei besten Plänen geändert hat: bei der Kostenoptimierung die größten Kostendifferenzen, im + /// Automodus die Änderungen der Verstöße, wichtigste Stufe zuerst. Leer, wenn die beiden nicht vergleichbar sind. + /// + /// Die Kriterien des vorigen besten Plans. + /// Die Kriterien des jetzigen besten Plans. + /// Höchstens vier Änderungen. + public static IReadOnlyList Vergleichen(IReadOnlyList? alt, IReadOnlyList? neu) + { + if (!Vergleichbar(alt, neu)) + { + return []; + } + + bool automodus = neu[0].Stufe is not null; + Dictionary vorige = alt.ToDictionary(k => k.Kriterium, k => k.Wert); + var unterschiede = neu + .Select(k => (Neu: k, Alt: vorige.GetValueOrDefault(k.Kriterium))) + .Where(u => Math.Abs(u.Neu.Wert - u.Alt) >= (automodus ? 0.5 : 0.005)); + unterschiede = automodus + ? unterschiede.OrderBy(u => u.Neu.Stufe).ThenByDescending(u => Math.Abs(u.Neu.Wert - u.Alt)) + : unterschiede.OrderByDescending(u => Math.Abs(u.Neu.Wert - u.Alt)); + return unterschiede.Take(AenderungenHoechstens).Select(u => Aenderung(u.Neu, u.Alt, automodus)).ToArray(); + } + + /// Gibt an, ob sich zwei beste Pläne vergleichen lassen (beide nach Kosten oder beide nach Verstößen). + /// Die Kriterien des vorigen besten Plans. + /// Die Kriterien des jetzigen besten Plans. + /// true, wenn sie vergleichbar sind. + public static bool Vergleichbar([NotNullWhen(true)] IReadOnlyList? alt, [NotNullWhen(true)] IReadOnlyList? neu) => + alt is { Count: > 0 } && neu is { Count: > 0 } && (alt[0].Stufe is null) == (neu[0].Stufe is null); + + /// Übernimmt den Stand und leitet die Ereignisse seit dem letzten Stand ab. + /// Der Stand. + /// Die Inseln, wie sie die Karten zeigen (Werte und beste Insel). + public void Uebernehmen(Optimierungsstand stand, IReadOnlyList karten) + { + ArgumentNullException.ThrowIfNull(stand); + ArgumentNullException.ThrowIfNull(karten); + long jetzt = Stopwatch.GetTimestamp(); + impulse.RemoveAll(i => Stopwatch.GetElapsedTime(i.Zeitpunkt, jetzt).TotalSeconds > Lebensdauer); + meldungen.RemoveAll(m => Stopwatch.GetElapsedTime(m.Zeitpunkt, jetzt).TotalSeconds > Wanderzeit + Anzeigedauer + 1); + Rechnet = stand.Laeuft && !stand.Pausiert; + Automodus = stand.Lenkung is not null; + if (stand.Maschinenraum is not Maschinenraum raum) + { + vorher = null; + kriterienVorher = null; + meldungen.Clear(); + besterPlanVorher = -1; + anhebungenVorher = -1; + Kerne = 0; + Version++; + return; + } + + Inselstand[] normale = raum.Inseln.Where(i => !i.Spezial).ToArray(); + if (zuletztVoran.Length != normale.Length) + { + zuletztVoran = new long[normale.Length]; + } + + Kerne = raum.Kerne.Count; + Spezial = raum.Inseln.Any(i => i.Spezial); + BesteInsel = Enumerable.Range(0, karten.Count).FirstOrDefault(i => karten[i].IstBeste && !karten[i].IstSpezial, -1); + Bester = BesterText(stand); + if (Rechnet) + { + EreignisseAbleiten(stand, raum, jetzt); + } + + Inseln = normale.Select((insel, i) => Zustand(insel, i < karten.Count ? karten[i] : null, raum.DurchlaeufeVorNeustart, i, jetzt)).ToArray(); + vorher = raum; + kriterienVorher = raum.BesterPlan; + besterPlanVorher = raum.BesterPlanNummer; + anhebungenVorher = stand.Lenkung?.Anhebungen ?? -1; + Version++; + } + + private static string BesterText(Optimierungsstand stand) + { + if (stand.Lenkung is { Basis: not null } lenkung) + { + return string.Create(Deutsch, $"A {lenkung.Bester.A} · B {lenkung.Bester.B} · C {lenkung.Bester.CAnzahl}"); + } + + return stand.Kosten >= 0 ? Kostenanzeige.Kurz(stand.Kosten) : "–"; + } + + private static Planaenderung Aenderung(Kriterienwert neu, double alt, bool automodus) + { + double differenz = neu.Wert - alt; + string name = Planqualitaet.Name(neu.Kriterium); + string text = automodus + ? string.Create(Deutsch, $"{neu.Stufe} · {name} {alt:0} → {neu.Wert:0}") + : $"{name} {Vorzeichen(differenz)}{Kostenanzeige.Kurz(Math.Abs(differenz))}"; + return new Planaenderung(text, differenz < 0); + } + + private static string Vorzeichen(double differenz) => differenz < 0 ? "−" : "+"; + + private static string Wert(Inselanzeige? karte) => karte switch + { + null => string.Empty, + { ZeigtVerstoesse: true } => $"{karte.VerstossA} · {karte.VerstossB} · {karte.VerstossC}", + _ => karte.Kosten, + }; + + /// Grob, was eine Insel gerade tut. + private Inselzustand Zustand(Inselstand insel, Inselanzeige? karte, long grenze, int index, long jetzt) + { + double stillstand = Math.Clamp(insel.OhneVerbesserung / (double)Math.Max(1, grenze), 0, 1); + bool neu = impulse.Exists(i => i.Art == Schaubildereignis.Neustart && i.Insel == index); + bool voran = zuletztVoran[index] != 0 && Stopwatch.GetElapsedTime(zuletztVoran[index], jetzt).TotalSeconds < Voran; + string taetigkeit = (neu, voran, stillstand) switch + { + (true, _, _) => "beginnt neu", + (_, true, _) => "kommt voran", + (_, _, >= 1) when insel.Schlechteste => "beginnt gleich neu", + (_, _, >= 1) => "steckt fest, rechnet weiter", + (_, _, >= Fest) => "findet lange nichts", + _ => "sucht", + }; + return new Inselzustand(Wert(karte), taetigkeit, stillstand); + } + + /// + /// Merkt sich zum grünen Punkt einer Verbesserung, was sie gegenüber dem vorigen besten Plan geändert hat; angezeigt + /// wird es, wenn dieser Punkt ankommt. + /// + /// Die Kriterien des jetzigen besten Plans. + /// So viele Verbesserungen seit dem vorigen Stand (meist eine). + /// Nummer der letzten, wie sie die Kachel „Verbesserungen“ zeigt. + /// Zeitstempel, zugleich Start des grünen Punkts. + private void AenderungMerken(IReadOnlyList? neu, int anzahl, int letzte, long jetzt) + { + IReadOnlyList liste = Vergleichen(kriterienVorher, neu); + string titel = anzahl == 1 + ? string.Create(Deutsch, $"Verbesserung {letzte}") + : string.Create(Deutsch, $"Verbesserungen {letzte - anzahl + 1}–{letzte}"); + meldungen.Add(new Verbesserungsmeldung(titel, liste.Count > 0 ? liste : [new Planaenderung("Feinschliff, Kriterien fast unverändert", true)], jetzt)); + } + + private void EreignisseAbleiten(Optimierungsstand stand, Maschinenraum raum, long jetzt) + { + if (vorher is { } alt && alt.Inseln.Count == raum.Inseln.Count) + { + int normal = 0; + for (int i = 0; i < raum.Inseln.Count; i++) + { + Inselstand neu = raum.Inseln[i]; + if (neu.Spezial) + { + continue; + } + + if (neu.Neustarts > alt.Inseln[i].Neustarts) + { + impulse.Add(new Schaubildimpuls(Schaubildereignis.Neustart, normal, jetzt)); + } + else if (neu.Erfolge > alt.Inseln[i].Erfolge) + { + zuletztVoran[normal] = jetzt; + } + + normal++; + } + + if (raum.SpezialVerwendet > alt.SpezialVerwendet) + { + int ziel = impulse.LastOrDefault(i => i.Art == Schaubildereignis.Neustart && i.Zeitpunkt == jetzt)?.Insel ?? 0; + impulse.Add(new Schaubildimpuls(Schaubildereignis.Uebergabe, ziel, jetzt)); + } + } + + // Nur ein besserer bester Plan wandert: von der Insel, die ihn hat, über den Koordinator zum besten Plan. Die Nummer + // des besten Plans ist dieselbe Zahl wie in der Kachel „Verbesserungen“ (ohne die Läufe vor einer Datenänderung); + // zu jedem Anstieg wandert ein Punkt, und seine Meldung nennt die Nummern, die Kachel und Verlauf zeigen. + if (besterPlanVorher >= 0 && raum.BesterPlanNummer > besterPlanVorher) + { + impulse.Add(new Schaubildimpuls(Schaubildereignis.BesterPlan, BesteInsel, jetzt)); + AenderungMerken(raum.BesterPlan, raum.BesterPlanNummer - besterPlanVorher, stand.Verbesserungen, jetzt); + } + + if (anhebungenVorher >= 0 && stand.Lenkung is { } lenkung && lenkung.Anhebungen > anhebungenVorher) + { + impulse.Add(new Schaubildimpuls(Schaubildereignis.Anhebung, -1, jetzt)); + } + } +} diff --git a/src/AndiGenerator.Presentation/Schaubildereignis.cs b/src/AndiGenerator.Presentation/Schaubildereignis.cs new file mode 100644 index 0000000..2b4c9b9 --- /dev/null +++ b/src/AndiGenerator.Presentation/Schaubildereignis.cs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Was im Schaubild des Maschinenraums kurz aufleuchtet. +public enum Schaubildereignis +{ + /// Der beste Plan ist besser geworden (der Plan kommt von einer Insel über den Koordinator). + BesterPlan, + + /// Eine Insel beginnt neu. + Neustart, + + /// Die Spezial-Insel gibt ihren Plan an eine Insel weiter. + Uebergabe, + + /// Der Automodus hat Gewichte angehoben. + Anhebung, +} diff --git a/src/AndiGenerator.Presentation/Schaubildimpuls.cs b/src/AndiGenerator.Presentation/Schaubildimpuls.cs new file mode 100644 index 0000000..4b2edfd --- /dev/null +++ b/src/AndiGenerator.Presentation/Schaubildimpuls.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein Ereignis im Schaubild mit dem Zeitpunkt, zu dem es aufleuchtet. +/// Was geschah. +/// Index der Insel (0 …); −1, wenn keine bestimmte. +/// Zeitstempel (). +public sealed record Schaubildimpuls(Schaubildereignis Art, int Insel, long Zeitpunkt); diff --git a/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs b/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs index 80087b7..aa868f7 100644 --- a/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs @@ -16,6 +16,9 @@ public sealed class SetzlistenSeiteViewModel : DatenSeiteViewModel { private bool aktiv; private int auswahl = -1; + private int gezogen = -1; + private int ablage = -1; + private Setzlistenzeile? schwebend; /// Initialisiert die Seite. /// Arbeitskopie. @@ -24,6 +27,7 @@ public SetzlistenSeiteViewModel(Datenbearbeitung bearbeitung) { HochCommand = new RelayCommand(() => Verschieben(-1), () => Auswahl > 0); RunterCommand = new RelayCommand(() => Verschieben(1), () => Auswahl >= 0 && Auswahl < Reihenfolge.Count - 1); + Reihenfolge.CollectionChanged += (_, _) => ZeilenAufbauen(); Laden(); } @@ -52,6 +56,19 @@ public bool Aktiv /// Holt die erwartete Reihenfolge (Platz 1 zuerst). public ObservableCollection Reihenfolge { get; } = []; + /// + /// Holt die Zeilen der Anzeige mit Platznummer; beim Ziehen schon in der Reihenfolge, die beim Ablegen entstünde, mit + /// einer Lücke an der Ablagestelle. + /// + public ObservableCollection Zeilen { get; } = []; + + /// Holt beim Ziehen die Zeile, die am Mauszeiger hängt; sonst null. + public Setzlistenzeile? Schwebend + { + get => schwebend; + private set => SetProperty(ref schwebend, value); + } + /// Holt oder setzt den Index der gewählten Mannschaft. public int Auswahl { @@ -62,6 +79,7 @@ public int Auswahl { HochCommand.NotifyCanExecuteChanged(); RunterCommand.NotifyCanExecuteChanged(); + ZeilenAufbauen(); } } } @@ -72,6 +90,9 @@ public int Auswahl /// Holt den Befehl „Nach unten“. public IRelayCommand RunterCommand { get; } + /// + public override string Marke => Aktiv ? "an" : "aus"; + /// public override bool IstStandard => !Aktiv; @@ -113,6 +134,72 @@ public void Ziehen(int von, int ziel) Auswahl = nach; } + /// Beginnt das Ziehen einer Mannschaft (am Griff ihrer Zeile). + /// Index der Mannschaft. + /// true, wenn gezogen wird. + public bool ZiehenBeginnen(int index) + { + if (index < 0 || index >= Reihenfolge.Count) + { + return false; + } + + gezogen = index; + ablage = index; + ZeilenAufbauen(); + return true; + } + + /// + /// Die gezogene Mannschaft ist über der Zeile (Index in der angezeigten Vorschau): über deren + /// oberer Hälfte kommt sie davor, sonst dahinter. Die Anzeige zeigt die neue Reihenfolge schon mit der Lücke. + /// + /// Index der Zeile unter dem Zeiger in . + /// true = obere Hälfte. + public void ZiehenUeber(int ziel, bool davor) + { + if (gezogen < 0 || ziel < 0 || ziel >= Zeilen.Count || ziel == ablage) + { + return; + } + + // In der Vorschau ohne die gezogene Mannschaft: vor bzw. hinter die Zeile unter dem Zeiger. + int ohne = ziel > ablage ? ziel - 1 : ziel; + int neu = davor ? ohne : ohne + 1; + if (neu != ablage) + { + ablage = neu; + ZeilenAufbauen(); + } + } + + /// Beendet das Ziehen: legt die Mannschaft an der Ablagestelle ab oder bricht ab (Escape). + /// true = ablegen. + public void ZiehenBeenden(bool uebernehmen) + { + int von = gezogen; + int nach = ablage; + gezogen = -1; + ablage = -1; + if (uebernehmen && von >= 0 && nach >= 0 && von != nach) + { + Reihenfolge.Move(von, nach); + Auswahl = nach; + } + + ZeilenAufbauen(); + } + + /// Wählt eine Mannschaft (Klick auf ihre Zeile). + /// Index in . + public void Waehlen(int index) + { + if (gezogen < 0 && index >= 0 && index < Reihenfolge.Count) + { + Auswahl = index; + } + } + /// Verschiebt die gewählte Mannschaft (Original ButtonUpClick/ButtonDownClick). /// -1 nach oben, 1 nach unten. internal void Verschieben(int richtung) @@ -127,4 +214,26 @@ internal void Verschieben(int richtung) Reihenfolge.Move(von, nach); Auswahl = nach; } + + /// Baut die angezeigten Zeilen auf; beim Ziehen in der Reihenfolge nach dem Ablegen, mit Lücke. + private void ZeilenAufbauen() + { + List namen = [.. Reihenfolge]; + string? gezogenerName = null; + if (gezogen >= 0 && gezogen < namen.Count) + { + gezogenerName = namen[gezogen]; + namen.RemoveAt(gezogen); + namen.Insert(Math.Clamp(ablage, 0, namen.Count), gezogenerName); + } + + Zeilen.Clear(); + for (int i = 0; i < namen.Count; i++) + { + bool luecke = gezogenerName is not null && i == ablage; + Zeilen.Add(new Setzlistenzeile(i + 1, namen[i], luecke, gezogenerName is null && i == Auswahl)); + } + + Schwebend = gezogenerName is null ? null : new Setzlistenzeile(ablage + 1, gezogenerName, false, false); + } } diff --git a/src/AndiGenerator.Presentation/Setzlistenzeile.cs b/src/AndiGenerator.Presentation/Setzlistenzeile.cs new file mode 100644 index 0000000..ed7782c --- /dev/null +++ b/src/AndiGenerator.Presentation/Setzlistenzeile.cs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Eine Zeile der Setzliste: erwarteter Platz und Mannschaft. +/// Erwarteter Platz am Ende der Saison (1 = Erster). +/// Name der Mannschaft. +/// Beim Ziehen die Lücke an der Ablagestelle (die Zeile selbst hängt am Mauszeiger). +/// Die gewählte Zeile (für „Nach oben“/„Nach unten“). +public sealed record Setzlistenzeile(int Platz, string Name, bool IstGezogen, bool IstGewaehlt) +{ + /// Holt den Platz als Text, z. B. „3.“. + public string PlatzText => Platz.ToString(System.Globalization.CultureInfo.InvariantCulture) + "."; +} diff --git a/src/AndiGenerator.Presentation/Sicherungswahl.cs b/src/AndiGenerator.Presentation/Sicherungswahl.cs new file mode 100644 index 0000000..474fc01 --- /dev/null +++ b/src/AndiGenerator.Presentation/Sicherungswahl.cs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Antwort auf die Frage, ob der gesicherte Plan durch den aktuellen ersetzt werden soll. +public enum Sicherungswahl +{ + /// Abbrechen: Der Neustart bzw. das Schließen unterbleibt, der aktuelle Plan bleibt erhalten. + Abbrechen, + + /// Den aktuellen Plan sichern und damit den bisher gesicherten ersetzen. + Ueberschreiben, + + /// Den bisher gesicherten Plan behalten; der aktuelle Plan wird verworfen. + Behalten, +} diff --git a/src/AndiGenerator.Presentation/SpielfreieTageSeiteViewModel.cs b/src/AndiGenerator.Presentation/SpielfreieTageSeiteViewModel.cs index c6703a7..2d6c19a 100644 --- a/src/AndiGenerator.Presentation/SpielfreieTageSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/SpielfreieTageSeiteViewModel.cs @@ -2,18 +2,20 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; using AndiGenerator.Application; namespace AndiGenerator.Presentation; /// -/// Seite „Spielfreie Tage“ (Original TDialogPanelFreeDays): alle Tage mit Heimspielwünschen mit Häkchen; an den -/// gewählten Tagen sollen keine Spiele stattfinden. +/// Seite „Spielfreie Tage“ (Original TDialogPanelFreeDays): alle Tage mit Heimspielwünschen, wählbar in einem +/// Monatskalender der Saison; an den gewählten Tagen sollen keine Spiele stattfinden. /// public sealed class SpielfreieTageSeiteViewModel : DatenSeiteViewModel { private IReadOnlyList tage = []; private IReadOnlyList zeilen = []; + private IReadOnlyList monate = []; /// Initialisiert die Seite. /// Arbeitskopie. @@ -23,6 +25,9 @@ public SpielfreieTageSeiteViewModel(Datenbearbeitung bearbeitung) Laden(); } + /// Holt die Wochentage für die Kopfzeile eines Monats. + public static IReadOnlyList Wochentage => WunschterminDetailViewModel.Wochentage; + /// Holt die möglichen Tage mit Häkchen. public IReadOnlyList Zeilen { @@ -30,6 +35,25 @@ public IReadOnlyList Zeilen private set => SetProperty(ref zeilen, value); } + /// Holt die Monate der Saison als Kalender; wählbar sind die Tage mit Heimspielwünschen. + public IReadOnlyList Monate + { + get => monate; + private set + { + if (SetProperty(ref monate, value)) + { + OnPropertyChanged(nameof(KeineTage)); + } + } + } + + /// Holt einen Wert, der angibt, ob es keine wählbaren Tage gibt. + public bool KeineTage => Monate.Count == 0; + + /// + public override string Marke => Zeilen.Count(z => z.Gewaehlt) is int n and > 0 ? n.ToString(CultureInfo.InvariantCulture) : string.Empty; + /// public override bool IstStandard => Bearbeitung.SpielfreieTageStandard() is not IReadOnlyList standard @@ -41,6 +65,7 @@ public override void Laden() // Die möglichen Tage hängen von den Wunschterminen ab und werden bei jedem Betreten neu bestimmt. tage = Bearbeitung.MoeglicheSpieltage(); Zeilen = tage.Select(t => new AuswahlZeile(Datumsanzeige.Tag(t), false, AenderungMelden)).ToList(); + Monate = Kalender(); Anzeigen(Bearbeitung.SpielfreieTage()); } @@ -55,6 +80,51 @@ public override void Standard() AenderungMelden(); } + /// Warum ein Tag nicht wählbar ist (für den Tooltip). + private static string Grund(DateOnly tag, Ligadaten liga, bool eingetragen) + { + if (tag < liga.Beginn || tag > liga.Ende) + { + return "außerhalb der Saison"; + } + + return eingetragen + ? "als spielfreier Tag eingetragen (z. B. aus click-TT); keine Mannschaft hat hier einen Heimspielwunsch" + : "nicht gesperrt: keine Mannschaft hat an diesem Tag einen Heimspielwunsch, deshalb wird hier ohnehin nicht gespielt"; + } + + /// Die Monate vom ersten bis zum letzten möglichen Tag, je Monat ab dem Montag der ersten Woche. + private List Kalender() + { + if (tage.Count == 0) + { + return []; + } + + var deutsch = CultureInfo.GetCultureInfo("de-DE"); + HashSet eingetragen = [.. Bearbeitung.EingetrageneSpielfreieTage()]; + Ligadaten liga = Bearbeitung.Ligadaten; + Dictionary waehlbar = tage.Select((t, i) => (t, i)).ToDictionary(x => x.t, x => Zeilen[x.i]); + var liste = new List(); + for (var erster = new DateOnly(tage.Min().Year, tage.Min().Month, 1); erster <= tage.Max(); erster = erster.AddMonths(1)) + { + int leer = ((int)erster.DayOfWeek + 6) % 7; + var felder = Enumerable.Range(0, leer).Select(_ => new Kalendertag(string.Empty, null, false, string.Empty)).ToList(); + for (DateOnly tag = erster; tag.Month == erster.Month; tag = tag.AddDays(1)) + { + AuswahlZeile? zeile = waehlbar.GetValueOrDefault(tag); + string datum = tag.ToString("ddd, dd.MM.yyyy", deutsch); + string hinweis = datum + " – " + (zeile is null ? Grund(tag, liga, eingetragen.Contains(tag)) : "Klick: spielfrei an/aus"); + bool wochenende = tag.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; + felder.Add(new Kalendertag(tag.Day.ToString(CultureInfo.InvariantCulture), zeile, wochenende, hinweis, zeile is null && eingetragen.Contains(tag))); + } + + liste.Add(new Kalendermonat(erster.ToString("MMMM yyyy", deutsch), felder)); + } + + return liste; + } + private List Gewaehlt() => tage.Where((_, i) => Zeilen[i].Gewaehlt).ToList(); private void Anzeigen(IReadOnlyList gewaehlt) diff --git a/src/AndiGenerator.Presentation/SpiellokaleSeiteViewModel.cs b/src/AndiGenerator.Presentation/SpiellokaleSeiteViewModel.cs index 4025ee4..bc6fc17 100644 --- a/src/AndiGenerator.Presentation/SpiellokaleSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/SpiellokaleSeiteViewModel.cs @@ -15,7 +15,7 @@ public sealed class SpiellokaleSeiteViewModel : MehrMannschaftenSeiteViewModel /// Arbeitskopie. /// Dienste der Oberfläche für den Dialog „Nachbarmannschaft“. public SpiellokaleSeiteViewModel(Datenbearbeitung bearbeitung, IOberflaeche oberflaeche) - : base(bearbeitung, "Spiellokale (Kompaktansicht)", string.Empty) + : base(bearbeitung, "Spiellokale", string.Empty) { this.oberflaeche = oberflaeche; Laden(); diff --git a/src/AndiGenerator.Presentation/Suchverlauf.cs b/src/AndiGenerator.Presentation/Suchverlauf.cs new file mode 100644 index 0000000..d8afb63 --- /dev/null +++ b/src/AndiGenerator.Presentation/Suchverlauf.cs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Diagnostics; +using AndiGenerator.Application; +using AndiGenerator.Engine.Inseln; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace AndiGenerator.Presentation; + +/// +/// Verlauf der Suche seit dem Start, im Takt der Statusabfrage: Kosten bzw. Verstöße A, B und C, Pläne je Sekunde, Phase +/// des Automodus. Damit auch eine stundenlange Suche in das Diagramm passt, wird der Verlauf ab +/// Punkten auf die halbe Auflösung verdichtet. +/// +public sealed class Suchverlauf : ObservableObject +{ + /// Höchstens so viele Punkte; darüber wird jeder zweite verworfen. + public const int HoechstePunkte = 800; + + private readonly List punkte = []; + private long zuletzt; + private double sekunden; + private int version; + + /// Holt die Punkte in zeitlicher Reihenfolge. + public IReadOnlyList Punkte => punkte; + + /// Holt eine Zahl, die sich mit jedem neuen Punkt ändert (zum Neuzeichnen). + public int Version + { + get => version; + private set => SetProperty(ref version, value); + } + + /// Beginnt einen neuen Verlauf (Start einer Generierung). + public void Zuruecksetzen() + { + punkte.Clear(); + zuletzt = 0; + sekunden = 0; + Version++; + } + + /// Übernimmt den Stand der Generierung; in der Pause läuft die Zeit nicht weiter. + /// Der Stand. + public void Hinzufuegen(Optimierungsstand stand) + { + ArgumentNullException.ThrowIfNull(stand); + long jetzt = Stopwatch.GetTimestamp(); + if (zuletzt != 0 && !stand.Pausiert) + { + sekunden += Stopwatch.GetElapsedTime(zuletzt, jetzt).TotalSeconds; + } + + zuletzt = jetzt; + if (!stand.Laeuft || stand.Pausiert) + { + return; + } + + Stufenwert? bester = stand.Lenkung is { Basis: not null } lenkung ? lenkung.Bester : null; + punkte.Add(new Verlaufspunkt( + sekunden, + stand.Kosten, + bester?.A, + bester?.B, + bester?.CAnzahl, + stand.PlaeneProSekunde, + stand.Lenkung?.Bezeichnung ?? string.Empty, + stand.Lenkung?.Anhebungen ?? 0)); + if (punkte.Count > HoechstePunkte) + { + Verdichten(); + } + + Version++; + } + + /// Verwirft jeden zweiten Punkt; der erste und der letzte bleiben. + private void Verdichten() + { + for (int i = punkte.Count - 2; i > 0; i -= 2) + { + punkte.RemoveAt(i); + } + } +} diff --git a/src/AndiGenerator.Presentation/Verbesserungsmeldung.cs b/src/AndiGenerator.Presentation/Verbesserungsmeldung.cs new file mode 100644 index 0000000..741cd24 --- /dev/null +++ b/src/AndiGenerator.Presentation/Verbesserungsmeldung.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Was eine Verbesserung des besten Plans geändert hat, zum grünen Punkt, der sie zum besten Plan trägt. +/// Überschrift mit der Nummer wie in der Kachel „Verbesserungen“, z. B. „Verbesserung 57“. +/// Die Änderungen, größte bzw. wichtigste zuerst. +/// Zeitstempel der Verbesserung (), zugleich Start des Punkts. +public sealed record Verbesserungsmeldung(string Titel, IReadOnlyList Aenderungen, long Zeitpunkt); diff --git a/src/AndiGenerator.Presentation/Vergleichszeile.cs b/src/AndiGenerator.Presentation/Vergleichszeile.cs new file mode 100644 index 0000000..b97ec99 --- /dev/null +++ b/src/AndiGenerator.Presentation/Vergleichszeile.cs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Eine Zeile im Vergleich des aktuellen mit dem gesicherten Plan. +/// Was verglichen wird, z. B. „Stufe A“ oder „A2 · Hallenbelegung“. +/// Wert des aktuellen Plans. +/// Wert des gesicherten Plans. +/// Der aktuelle Plan ist hier besser. +/// Der gesicherte Plan ist hier besser. +public sealed record Vergleichszeile(string Bezeichnung, string Aktuell, string Gesichert, bool AktuellBesser, bool GesichertBesser); diff --git a/src/AndiGenerator.Presentation/VerlaufAnsichtViewModel.cs b/src/AndiGenerator.Presentation/VerlaufAnsichtViewModel.cs new file mode 100644 index 0000000..f275ccb --- /dev/null +++ b/src/AndiGenerator.Presentation/VerlaufAnsichtViewModel.cs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using Dock.Model.Mvvm.Controls; + +namespace AndiGenerator.Presentation; + +/// Verlauf der Suche seit dem Start als eigene, formatfüllende Ansicht. +public sealed class VerlaufAnsichtViewModel : Document +{ + /// Initialisiert die Ansicht. + /// Hauptfenster mit dem Verlauf. + public VerlaufAnsichtViewModel(HauptfensterViewModel hauptfenster) + { + ArgumentNullException.ThrowIfNull(hauptfenster); + Id = "Verlauf"; + Title = "Verlauf"; + CanClose = true; + CanFloat = true; + Verlauf = hauptfenster.Verlauf; + } + + /// Holt den Verlauf der Suche. + public Suchverlauf Verlauf { get; } +} diff --git a/src/AndiGenerator.Presentation/Verlaufspunkt.cs b/src/AndiGenerator.Presentation/Verlaufspunkt.cs new file mode 100644 index 0000000..d60c815 --- /dev/null +++ b/src/AndiGenerator.Presentation/Verlaufspunkt.cs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Ein Punkt im Verlauf der Suche (Maschinenraum). +/// Rechenzeit seit dem Start (ohne Pausen). +/// Kosten des besten Plans; negativ = noch keiner. +/// Im Automodus nach der Basisoptimierung: Verstöße der Stufe A des besten Plans; sonst null. +/// Ebenso Stufe B. +/// Ebenso Stufe C. +/// Berechnete Pläne je Sekunde. +/// Phase des Automodus („Basisoptimierung“, „Optimierung Stufe A“ …); leer bei der Kostenoptimierung. +/// Wie oft der Automodus bis hierher Gewichte angehoben hat. +public sealed record Verlaufspunkt(double Sekunden, double Kosten, int? A, int? B, int? C, double PlaeneProSekunde, string Phase, int Anhebungen); diff --git a/src/AndiGenerator.Presentation/VorgabespieleSeiteViewModel.cs b/src/AndiGenerator.Presentation/VorgabespieleSeiteViewModel.cs index 537342f..d619981 100644 --- a/src/AndiGenerator.Presentation/VorgabespieleSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/VorgabespieleSeiteViewModel.cs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; using AndiGenerator.Application; using CommunityToolkit.Mvvm.Input; @@ -61,6 +62,9 @@ public VorgabespielZeile? Auswahl /// Holt den Befehl „Begegnung löschen…“. public IAsyncRelayCommand LoeschenCommand { get; } + /// + public override string Marke => Zeilen.Count > 0 ? Zeilen.Count.ToString(CultureInfo.InvariantCulture) : string.Empty; + /// public override bool IstStandard => Bearbeitung.VorgegebeneSpieleSindStandard; diff --git a/src/AndiGenerator.Presentation/WochenendeSeiteViewModel.cs b/src/AndiGenerator.Presentation/WochenendeSeiteViewModel.cs index 4c2fdd7..8fea52b 100644 --- a/src/AndiGenerator.Presentation/WochenendeSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/WochenendeSeiteViewModel.cs @@ -12,7 +12,7 @@ public sealed class WochenendeSeiteViewModel : MehrMannschaftenSeiteViewModel /// Initialisiert die Seite. /// Arbeitskopie. public WochenendeSeiteViewModel(Datenbearbeitung bearbeitung) - : base(bearbeitung, "60km Regel", "Wegen des langen Anfahrtsweges dürfen Spiele gegen diese Mannschaften nur am Wochenende stattfinden") + : base(bearbeitung, "Wochenende (60-km-Regel)", "Wegen des langen Anfahrtsweges dürfen Spiele gegen diese Mannschaften nur am Wochenende stattfinden") { Laden(); } diff --git a/src/AndiGenerator.Presentation/WunschterminDetailViewModel.cs b/src/AndiGenerator.Presentation/WunschterminDetailViewModel.cs index d023b5a..3476292 100644 --- a/src/AndiGenerator.Presentation/WunschterminDetailViewModel.cs +++ b/src/AndiGenerator.Presentation/WunschterminDetailViewModel.cs @@ -8,8 +8,9 @@ namespace AndiGenerator.Presentation; /// -/// Wunschtermine einer Mannschaft (Original TDialogPanelHomeDaysDetail): Wochenübersicht mit einem Kurztext je Tag, -/// Bearbeiten per Doppelklick oder Kontextmenü im Dialog „Wunschtermin“. +/// Wunschtermine einer Mannschaft (Original TDialogPanelHomeDaysDetail): Wochenübersicht mit einem Kärtchen je Tag +/// (Heimspiel, Ausweichtermin, Sperrtermin); Klick oder Kontextmenü öffnet den Dialog „Wunschtermin“, Ziehen verschiebt +/// einen Termin auf einen anderen Tag. /// public sealed class WunschterminDetailViewModel : DatenSeiteViewModel { @@ -51,6 +52,30 @@ public IReadOnlyList Wochen /// public override bool IstStandard => Bearbeitung.WunschtermineSindStandard(mannschaft, Werte()); + /// + /// Verschiebt den Termin eines Tages auf einen anderen (Ziehen im Wochenraster). Steht dort schon ein Termin, tauschen + /// die beiden; mit bleibt der Termin am alten Tag stehen und ersetzt den am neuen. + /// + /// Der Tag, dessen Termin gezogen wird. + /// Der Tag, auf den er gelegt wird. + /// true = kopieren statt verschieben (Strg). + public static void Verschieben(WunschterminZelle von, WunschterminZelle nach, bool kopieren) + { + ArgumentNullException.ThrowIfNull(von); + ArgumentNullException.ThrowIfNull(nach); + if (ReferenceEquals(von, nach) || !von.HatTermin) + { + return; + } + + string bisher = nach.Text; + nach.Text = von.Text; + if (!kopieren) + { + von.Text = bisher; + } + } + /// public override void Laden() => Anzeigen(Bearbeitung.Wunschtermine(mannschaft)); @@ -79,6 +104,20 @@ private static void Loeschen(WunschterminZelle? zelle) } } + /// + /// Die übliche Uhrzeit der Mannschaft: die häufigste ihrer Heimspielzeiten am selben Wochentag, sonst an allen Tagen, + /// sonst 18:00. + /// + private TimeSpan UeblicheUhrzeit(DayOfWeek wochentag) + { + List<(DayOfWeek Tag, TimeSpan Zeit)> zeiten = [.. Wochen.SelectMany(w => w.Tage) + .Where(z => z.IstHeimspiel || z.IstAusweich) + .Select(z => (z.Tag.DayOfWeek, TimeSpan.Parse(z.Hauptzeile, System.Globalization.CultureInfo.InvariantCulture)))]; + List<(DayOfWeek Tag, TimeSpan Zeit)> amTag = [.. zeiten.Where(z => z.Tag == wochentag)]; + List<(DayOfWeek Tag, TimeSpan Zeit)> basis = amTag.Count > 0 ? amTag : zeiten; + return basis.Count == 0 ? new TimeSpan(18, 0, 0) : basis.GroupBy(z => z.Zeit).OrderByDescending(g => g.Count()).First().Key; + } + private List Werte() => Wochen.Select(w => w.Wert()).ToList(); private void Anzeigen(IReadOnlyList daten) => @@ -98,6 +137,13 @@ private async Task BearbeitenAsync(WunschterminZelle? zelle) } var dialog = new WunschterminDialogViewModel(zelle.Tag, zelle.Text); + if (!zelle.HatTermin) + { + // Ein leerer Tag wird angeklickt, um einen Termin einzutragen: gleich als Heimspiel zur üblichen Uhrzeit vorschlagen. + dialog.Art = 1; + dialog.Uhrzeit = UeblicheUhrzeit(zelle.Tag.DayOfWeek); + } + if (await oberflaeche.WunschterminBearbeitenAsync(dialog)) { zelle.Text = dialog.Ergebnis; diff --git a/src/AndiGenerator.Presentation/WunschterminDialogViewModel.cs b/src/AndiGenerator.Presentation/WunschterminDialogViewModel.cs index a44c73a..11e5c1c 100644 --- a/src/AndiGenerator.Presentation/WunschterminDialogViewModel.cs +++ b/src/AndiGenerator.Presentation/WunschterminDialogViewModel.cs @@ -98,6 +98,9 @@ public int Art if (SetProperty(ref art, value)) { OnPropertyChanged(nameof(IstHeimspiel)); + OnPropertyChanged(nameof(ArtHeimspiel)); + OnPropertyChanged(nameof(ArtKeinHeimspiel)); + OnPropertyChanged(nameof(ArtSperrtermin)); } } } @@ -105,6 +108,27 @@ public int Art /// Holt einen Wert, der angibt, ob die Heimspielangaben sichtbar sind. public bool IstHeimspiel => Art == 1; + /// Holt oder setzt, ob „Heimspiel möglich“ gewählt ist (Umschalter). + public bool ArtHeimspiel + { + get => Art == 1; + set => ArtWaehlen(value, 1); + } + + /// Holt oder setzt, ob „kein Heimspiel, Auswärtsspiele möglich“ gewählt ist (Umschalter). + public bool ArtKeinHeimspiel + { + get => Art == 0; + set => ArtWaehlen(value, 0); + } + + /// Holt oder setzt, ob „Sperrtermin“ gewählt ist (Umschalter). + public bool ArtSperrtermin + { + get => Art == 2; + set => ArtWaehlen(value, 2); + } + /// Holt oder setzt die Uhrzeit. public TimeSpan? Uhrzeit { @@ -269,4 +293,12 @@ public bool Bestaetigen() } private static TimeOnly Zeit(TimeSpan? wert) => wert is TimeSpan t ? new TimeOnly(t.Hours, t.Minutes) : TimeOnly.MinValue; + + private void ArtWaehlen(bool gewaehlt, int wahl) + { + if (gewaehlt) + { + Art = wahl; + } + } } diff --git a/src/AndiGenerator.Presentation/WunschterminZelle.cs b/src/AndiGenerator.Presentation/WunschterminZelle.cs index 620e16a..96701ff 100644 --- a/src/AndiGenerator.Presentation/WunschterminZelle.cs +++ b/src/AndiGenerator.Presentation/WunschterminZelle.cs @@ -2,16 +2,22 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using System.Globalization; using AndiGenerator.Application; using CommunityToolkit.Mvvm.ComponentModel; namespace AndiGenerator.Presentation; -/// Ein Tag der Terminübersicht; die Eingabe wird wie im Original beim Verlassen der Zelle vereinheitlicht. +/// +/// Ein Tag der Terminübersicht. Der Termin steht als Kurztext wie im Original (z. B. 18:00 A K1:14:00); für die +/// Anzeige als Kärtchen liefert die Zelle seine Art (Heimspiel, Ausweichtermin, Sperrtermin), die Uhrzeit und die Zusätze. +/// public sealed class WunschterminZelle : ObservableObject { private readonly Action geaendert; private string text; + private bool istZiel; + private bool istQuelle; /// Initialisiert die Zelle. /// Der Tag. @@ -37,6 +43,13 @@ public string Text if (SetProperty(ref text, bereinigt)) { OnPropertyChanged(nameof(HatTermin)); + OnPropertyChanged(nameof(IstHeimspiel)); + OnPropertyChanged(nameof(IstAusweich)); + OnPropertyChanged(nameof(IstSperre)); + OnPropertyChanged(nameof(IstLeer)); + OnPropertyChanged(nameof(Hauptzeile)); + OnPropertyChanged(nameof(Zusatz)); + OnPropertyChanged(nameof(Hinweis)); geaendert(); } else if (bereinigt != value) @@ -49,4 +62,105 @@ public string Text /// Holt einen Wert, der angibt, ob ein Termin eingetragen ist. public bool HatTermin => text.Length > 0; + + /// Holt einen Wert, der angibt, ob kein Termin eingetragen ist (Auswärtsspiele möglich). + public bool IstLeer => !HatTermin; + + /// Holt einen Wert, der angibt, ob ein Heimspiel möglich ist (kein Ausweichtermin). + public bool IstHeimspiel => Termin is { Sperrtermin: false, Ausweichtermin: false }; + + /// Holt einen Wert, der angibt, ob es ein Ausweichtermin ist (nur im Notfall einplanen). + public bool IstAusweich => Termin is { Sperrtermin: false, Ausweichtermin: true }; + + /// Holt einen Wert, der angibt, ob es ein Sperrtermin ist (gar kein Spiel). + public bool IstSperre => Termin is { Sperrtermin: true }; + + /// Holt die erste Zeile des Kärtchens: die Uhrzeit bzw. „gesperrt“. + public string Hauptzeile => Termin switch + { + null => string.Empty, + { Sperrtermin: true } => "gesperrt", + { } h => h.Datum.ToString("HH:mm", CultureInfo.InvariantCulture), + }; + + /// Holt die zweite Zeile: Ausweich, Koppel, Doppelspieltag, höchstens n Heimspiele, Spiellokal (kurz). + public string Zusatz + { + get + { + if (Termin is not { Sperrtermin: false } h) + { + return string.Empty; + } + + var teile = new List(); + if (h.Ausweichtermin) + { + teile.Add("Ausweich"); + } + + if (h.Koppeltermin) + { + teile.Add("Koppel " + h.KoppelZweitzeit.ToString("HH:mm", CultureInfo.InvariantCulture)); + } + else if (h.AuswaertsKoppelZweitzeit) + { + teile.Add("2. Zeit " + h.KoppelZweitzeit.ToString("HH:mm", CultureInfo.InvariantCulture)); + } + + if (h.Doppeltermin) + { + teile.Add("Doppel"); + } + + if (h.MaxParallel > 0) + { + teile.Add("max " + h.MaxParallel.ToString(CultureInfo.InvariantCulture)); + } + + if (h.Spiellokal.Length > 0) + { + teile.Add("Lokal " + h.Spiellokal); + } + + return string.Join(" · ", teile); + } + } + + /// Holt den Tooltip: Tag, Termin ausgeschrieben und was ein Klick bzw. Ziehen tut. + public string Hinweis + { + get + { + string tag = Tag.ToString("dddd, dd.MM.yyyy", Deutsch); + string termin = Termin switch + { + null => "kein Heimspiel (Auswärtsspiele möglich)", + { Sperrtermin: true } => "Sperrtermin: an diesem Tag kein Spiel", + _ => (IstAusweich ? "Ausweichtermin " : "Heimspiel möglich ") + Hauptzeile + (Zusatz.Length > 0 ? " (" + Zusatz + ")" : string.Empty), + }; + string bedienung = HatTermin + ? "Klick: bearbeiten · Ziehen: auf einen anderen Tag verschieben (mit Strg kopieren)" + : "Klick: Termin eintragen"; + return $"{tag}: {termin}{Environment.NewLine}{bedienung}"; + } + } + + /// Holt oder setzt, ob beim Ziehen ein Termin über diesem Tag schwebt (Ablagestelle hervorheben). + public bool IstZiel + { + get => istZiel; + set => SetProperty(ref istZiel, value); + } + + /// Holt oder setzt, ob der Termin dieses Tages gerade gezogen wird. + public bool IstQuelle + { + get => istQuelle; + set => SetProperty(ref istQuelle, value); + } + + private static CultureInfo Deutsch { get; } = CultureInfo.GetCultureInfo("de-DE"); + + private Heimtag? Termin => Heimtagtext.Lesen(Tag, text); } diff --git a/src/AndiGenerator.Presentation/WunschtermineSeiteViewModel.cs b/src/AndiGenerator.Presentation/WunschtermineSeiteViewModel.cs index 02849e2..b32c092 100644 --- a/src/AndiGenerator.Presentation/WunschtermineSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/WunschtermineSeiteViewModel.cs @@ -9,20 +9,13 @@ namespace AndiGenerator.Presentation; /// Seite „Wunschtermine“ (Original TDialogPanelHomeDays). public sealed class WunschtermineSeiteViewModel : MehrMannschaftenSeiteViewModel { - private const string Hinweis = - "Beispiele für die Termineingabe:\n" - + "18:00: Mannschaft kann um 18:00 Uhr ein Heimspiel austragen.\n" - + "FREI: Eingabe des Textes 'FREI' bedeutet, dass die Mannschaft an diesem Tag kein Spiel machen will.\n" - + "Keine Eingabe: Die Mannschaft kann an diesem Tag ein Auswärtsspiel wahrnehmen.\n\n" - + "Weitere Optionen (maximale Heimspiele, Koppeloptionen, Ausweichtermin etc.) mit Doppelklick oder Maus rechts."; - private readonly IOberflaeche oberflaeche; /// Initialisiert die Seite. /// Arbeitskopie. /// Dienste der Oberfläche für den Dialog. public WunschtermineSeiteViewModel(Datenbearbeitung bearbeitung, IOberflaeche oberflaeche) - : base(bearbeitung, "Wunschtermine", Hinweis) + : base(bearbeitung, "Wunschtermine", string.Empty) { this.oberflaeche = oberflaeche; Laden(); diff --git a/src/AndiGenerator.Presentation/ZweitzeitZeile.cs b/src/AndiGenerator.Presentation/ZweitzeitZeile.cs index 2fa43d7..990f857 100644 --- a/src/AndiGenerator.Presentation/ZweitzeitZeile.cs +++ b/src/AndiGenerator.Presentation/ZweitzeitZeile.cs @@ -42,6 +42,9 @@ public string Text } } + /// Holt den Platzhalter des leeren Feldes: der Vorschlag, z. B. „z. B. 14:00“. + public string Platzhalter => "z. B. " + Format(Vorschlag); + /// Holt den Termin mit der eingegebenen Zeit (ungültige Eingaben gelten wie im Original als keine Zeit). public Zweitzeit Wert => new(Termin, Zweitzeit.Lesen(text)); diff --git a/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml new file mode 100644 index 0000000..3992c10 --- /dev/null +++ b/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml.cs b/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml.cs new file mode 100644 index 0000000..d69e8e7 --- /dev/null +++ b/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using Avalonia.Controls; + +namespace AndiGenerator.UI.Ansichten; + +/// Ansicht „Maschinenraum“: Rechenkerne, Inseln und Protokoll des Automodus. +public partial class MaschinenraumAnsicht : UserControl +{ + /// Initialisiert die Ansicht. + public MaschinenraumAnsicht() => InitializeComponent(); +} diff --git a/src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml new file mode 100644 index 0000000..8389d76 --- /dev/null +++ b/src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml.cs b/src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml.cs new file mode 100644 index 0000000..fe623be --- /dev/null +++ b/src/AndiGenerator.UI/Ansichten/VerlaufAnsicht.axaml.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using Avalonia.Controls; + +namespace AndiGenerator.UI.Ansichten; + +/// Ansicht „Verlauf“: der Verlauf der Suche formatfüllend. +public partial class VerlaufAnsicht : UserControl +{ + /// Initialisiert die Ansicht. + public VerlaufAnsicht() => InitializeComponent(); +} diff --git a/src/AndiGenerator.UI/AnsichtenLocator.cs b/src/AndiGenerator.UI/AnsichtenLocator.cs index e5b8372..6ed90ac 100644 --- a/src/AndiGenerator.UI/AnsichtenLocator.cs +++ b/src/AndiGenerator.UI/AnsichtenLocator.cs @@ -23,10 +23,12 @@ public sealed class AnsichtenLocator : IDataTemplate NachbarterminAnsichtViewModel => new NachbarterminAnsicht(), DiagrammAnsichtViewModel => new DiagrammAnsicht(), MeldungenAnsichtViewModel => new MeldungenAnsicht(), + MaschinenraumAnsichtViewModel => new MaschinenraumAnsicht(), + VerlaufAnsichtViewModel => new VerlaufAnsicht(), _ => null, }; /// public bool Match(object? data) => data is KostenAnsichtViewModel or TerminplanAnsichtViewModel or QualitaetAnsichtViewModel or OptionenAnsichtViewModel - or TerminwunschAnsichtViewModel or NachbarterminAnsichtViewModel or DiagrammAnsichtViewModel or MeldungenAnsichtViewModel; + or TerminwunschAnsichtViewModel or NachbarterminAnsichtViewModel or DiagrammAnsichtViewModel or MeldungenAnsichtViewModel or MaschinenraumAnsichtViewModel or VerlaufAnsichtViewModel; } diff --git a/src/AndiGenerator.UI/App.axaml.cs b/src/AndiGenerator.UI/App.axaml.cs index 3558921..fff366a 100644 --- a/src/AndiGenerator.UI/App.axaml.cs +++ b/src/AndiGenerator.UI/App.axaml.cs @@ -22,6 +22,7 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { Bildschirmfoto.Anmelden(); + Platzhalterdeutsch.Anmelden(); var fenster = new HauptfensterView(); var oberflaeche = new AvaloniaOberflaeche(fenster); var modell = new HauptfensterViewModel(oberflaeche, () => new HostWindow()); diff --git a/src/AndiGenerator.UI/AvaloniaOberflaeche.cs b/src/AndiGenerator.UI/AvaloniaOberflaeche.cs index a0116fe..1796017 100644 --- a/src/AndiGenerator.UI/AvaloniaOberflaeche.cs +++ b/src/AndiGenerator.UI/AvaloniaOberflaeche.cs @@ -50,6 +50,10 @@ public sealed class AvaloniaOberflaeche(Window fenster) : IOberflaeche public Task FragenAsync(string titel, string frage) => Zeigen(new Dialogfenster(titel, frage, eingabe: null, mitAbbrechen: true)); + /// + public Task SicherungAbfragenAsync(Planvergleich vergleich) => + Zeigen(new Sicherungsdialog(vergleich)); + /// public Task EntscheidenAsync(string titel, string text, IReadOnlyList details, string ja, string nein) => Zeigen(new Dialogfenster(titel, text, details, ja, nein)); diff --git a/src/AndiGenerator.UI/Datendialog.cs b/src/AndiGenerator.UI/Datendialog.cs index 2718b3f..39e11f8 100644 --- a/src/AndiGenerator.UI/Datendialog.cs +++ b/src/AndiGenerator.UI/Datendialog.cs @@ -6,6 +6,7 @@ using AndiGenerator.Presentation; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; using Avalonia.Layout; using Avalonia.Media; @@ -48,7 +49,7 @@ public Datendialog(DatenDialogViewModel dialog) { ItemsSource = dialog.Seiten, SelectedItem = dialog.Seite, - ItemTemplate = new FuncDataTemplate((s, _) => new TextBlock { Text = s?.Titel, TextWrapping = TextWrapping.Wrap }), + ItemTemplate = new FuncDataTemplate((s, _) => Seiteneintrag(s)), Width = 230, }; liste.Classes.Add("seitenliste"); @@ -108,6 +109,48 @@ protected override void OnClosed(EventArgs e) base.OnClosed(e); } + /// + /// Eintrag der Seitenliste: Titel, rechts eine Marke (z. B. die Zahl der Einträge) und ein Punkt, wenn auf der Seite in + /// diesem Dialog etwas geändert wurde. + /// + private static Grid Seiteneintrag(DatenSeiteViewModel? seite) + { + var titel = new TextBlock { Text = seite?.Titel, TextWrapping = TextWrapping.Wrap, VerticalAlignment = VerticalAlignment.Center }; + var marke = new TextBlock { FontSize = 11, VerticalAlignment = VerticalAlignment.Center }; + var markenrahmen = new Border + { + Child = marke, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(7, 1), + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Background = Farbe("Schwebe"), + }; + var punkt = new Ellipse { Width = 8, Height = 8, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, Fill = Farbe("Akzent") }; + ToolTip.SetTip(punkt, "In diesem Dialog geändert"); + var zeile = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto") }; + Grid.SetColumn(markenrahmen, 1); + Grid.SetColumn(punkt, 2); + zeile.Children.AddRange([titel, markenrahmen, punkt]); + if (seite is not null) + { + void Aktualisieren() + { + marke.Text = seite.Marke; + markenrahmen.IsVisible = seite.Marke.Length > 0; + punkt.IsVisible = seite.Bearbeitet; + } + + Aktualisieren(); + seite.PropertyChanged += (_, _) => Aktualisieren(); + } + + return zeile; + } + + private static IBrush Farbe(string name) => + global::Avalonia.Application.Current?.TryFindResource(name, out object? wert) == true && wert is IBrush b ? b : Brushes.Gray; + /// Verkleinert den Dialog, falls er größer als der Arbeitsbereich des Bildschirms ist, und zentriert ihn neu. private void AnBildschirmAnpassen() { diff --git a/src/AndiGenerator.UI/HauptfensterView.axaml b/src/AndiGenerator.UI/HauptfensterView.axaml index 1ed393f..7467e6f 100644 --- a/src/AndiGenerator.UI/HauptfensterView.axaml +++ b/src/AndiGenerator.UI/HauptfensterView.axaml @@ -77,10 +77,10 @@ - - - - + + + @@ -282,7 +297,7 @@ @@ -300,52 +315,35 @@ - - - - + - - + + + + @@ -403,20 +401,46 @@ - - - - - - - - - - - - - - + + diff --git a/src/AndiGenerator.UI/HauptfensterView.axaml.cs b/src/AndiGenerator.UI/HauptfensterView.axaml.cs index e6053d2..c366aa0 100644 --- a/src/AndiGenerator.UI/HauptfensterView.axaml.cs +++ b/src/AndiGenerator.UI/HauptfensterView.axaml.cs @@ -27,11 +27,13 @@ public partial class HauptfensterView : Window private const double Ansichtskopf = 140; private bool startzoomGesetzt; + private bool beendenBestaetigt; /// Initialisiert das Fenster. public HauptfensterView() { InitializeComponent(); + Closing += BeimSchliessen; SizeChanged += (_, e) => { Navigation.Classes.Set("schmal", e.NewSize.Width < SchmaleBreite); @@ -48,4 +50,23 @@ public HauptfensterView() } }; } + + /// + /// Vor dem Beenden des Programms: Ein noch nicht gesicherter Plan wird gesichert bzw. es wird gefragt, ob er den + /// gesicherten ersetzen soll. Dafür wird das Schließen zunächst abgebrochen und nach der Antwort wiederholt. + /// + private async void BeimSchliessen(object? sender, WindowClosingEventArgs e) + { + if (beendenBestaetigt || DataContext is not HauptfensterViewModel modell) + { + return; + } + + e.Cancel = true; + if (await modell.BeendenErlaubtAsync()) + { + beendenBestaetigt = true; + Close(); + } + } } diff --git a/src/AndiGenerator.UI/Konverter.cs b/src/AndiGenerator.UI/Konverter.cs index f1c4f32..f27f629 100644 --- a/src/AndiGenerator.UI/Konverter.cs +++ b/src/AndiGenerator.UI/Konverter.cs @@ -74,6 +74,14 @@ public static class Konverter public static IValueConverter Gleich { get; } = new FuncValueConverter((wert, vergleich) => string.Equals(wert, vergleich, StringComparison.Ordinal)); + /// Holt den Namen eines Spiellokals an einem Termin: leer = das Standardspiellokal der Mannschaft. + public static IValueConverter Spiellokal { get; } = + new FuncValueConverter(nummer => string.IsNullOrEmpty(nummer) ? "Standard" : "Spiellokal " + nummer); + + /// Holt den Namen des Standardspiellokals: leer = der Verein spielt nur in einem Spiellokal. + public static IValueConverter Standardlokal { get; } = + new FuncValueConverter(nummer => string.IsNullOrEmpty(nummer) ? "nur eines" : "Spiellokal " + nummer); + /// Holt die Skalierung einer vergrößerbaren Ansicht zu ihrem Zoomfaktor (für LayoutTransformControl). public static IValueConverter Skalierung { get; } = new FuncValueConverter(faktor => new ScaleTransform(faktor, faktor)); diff --git a/src/AndiGenerator.UI/Maschinenschaubild.cs b/src/AndiGenerator.UI/Maschinenschaubild.cs new file mode 100644 index 0000000..5365b93 --- /dev/null +++ b/src/AndiGenerator.UI/Maschinenschaubild.cs @@ -0,0 +1,451 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Diagnostics; +using System.Globalization; +using AndiGenerator.Presentation; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; + +namespace AndiGenerator.UI; + +/// +/// Schaubild der Maschine im Maschinenraum: Inseln → Koordinator → bester Plan, im Automodus mit dem Automodus als +/// Steuerpult, bei der Kostenoptimierung mit der Spezial-Insel. Je Insel steht grob, was sie gerade tut, und ein schmaler +/// Balken, wie nah sie einem Neustart ist. Bewegung gibt es nur bei Ereignissen: Wird der beste Plan besser, wandert ein +/// grüner Punkt von seiner Insel über den Koordinator zum besten Plan; eine Insel, die neu beginnt, wird kurz grau; die +/// Spezial-Insel gibt ihren Plan weiter; der Automodus hebt Gewichte an. Unter dem besten Plan steht nach einer +/// Verbesserung einige Sekunden in einem Kasten, was sich geändert hat (grün besser, rot schlechter). +/// +public sealed class Maschinenschaubild : Control +{ + /// Definiert die Eigenschaft . + public static readonly StyledProperty SchaubildProperty = + AvaloniaProperty.Register(nameof(Schaubild)); + + /// Definiert die Eigenschaft . + public static readonly StyledProperty VersionProperty = + AvaloniaProperty.Register(nameof(Version)); + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private readonly DispatcherTimer takt = new() { Interval = TimeSpan.FromMilliseconds(40) }; + + static Maschinenschaubild() + { + AffectsRender(SchaubildProperty, VersionProperty); + AffectsMeasure(SchaubildProperty, VersionProperty); + } + + /// Initialisiert das Schaubild; ein Takt bewegt die Ereignisse, sonst steht das Bild still. + public Maschinenschaubild() + { + takt.Tick += (_, _) => + { + if (Schaubild is { } bild && (bild.Impulse.Count > 0 || Bild.ZeigtAenderungen(bild))) + { + InvalidateVisual(); + } + }; + } + + /// Holt oder setzt den Zustand des Schaubilds. + public Schaubild? Schaubild + { + get => GetValue(SchaubildProperty); + set => SetValue(SchaubildProperty, value); + } + + /// Holt oder setzt die Version des Zustands (löst das Neuzeichnen aus). + public int Version + { + get => GetValue(VersionProperty); + set => SetValue(VersionProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + base.Render(context); + if (Schaubild is not { Kerne: > 0 } bild || Bounds.Width < 300 || Bounds.Height < 120) + { + context.DrawText(Bild.Text("Das Schaubild erscheint, sobald gerechnet wird.", Bild.Farbe("Grau"), 11, false, 0), new Point(4, 4)); + return; + } + + new Bild(context, bild, Bounds.Size).Zeichnen(); + } + + /// + /// + /// Die Höhe wächst mit der Zahl der Inseln (je Insel eine Zeile), statt die Inseln zu stauchen; mindestens 300 Pixel (Platz für die Änderungen unter dem besten Plan). + /// + protected override Size MeasureOverride(Size availableSize) + { + Schaubild? bild = Schaubild; + int zeilen = Math.Max(1, bild?.Inseln.Count ?? 0) + (bild?.Spezial == true ? 1 : 0); + double hoehe = (2 * Bild.Rand) + (bild?.Automodus == true ? 44 : 0) + (zeilen * Bild.Zeilenhoehe) + ((zeilen - 1) * Bild.Zeilenabstand); + double breite = double.IsFinite(availableSize.Width) ? availableSize.Width : 600; + return new Size(breite, Math.Max(300, hoehe)); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + takt.Start(); + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + takt.Stop(); + } + + /// Zeichnet das Schaubild einmal (Lage der Kästen, Linien, Inseln, Ereignisse). + private sealed class Bild + { + /// Abstand zum Rand. + public const double Rand = 6; + + /// Höhe einer Inselzeile (Name und Wert, darunter Tätigkeit und Balken). + public const double Zeilenhoehe = 38; + + /// Abstand zwischen zwei Inselzeilen. + public const double Zeilenabstand = 6; + + private const double KastenHoehe = 48; + + /// So lange (Sekunden) wandert der grüne Punkt von der Insel bis zum besten Plan. + private const double Wanderzeit = Presentation.Schaubild.Wanderzeit; + + /// So lange (Sekunden) nach der Ankunft des letzten Punkts steht, was sich geändert hat. + private const double Aenderungsdauer = Presentation.Schaubild.Anzeigedauer; + + private readonly DrawingContext context; + private readonly Schaubild bild; + private readonly long jetzt; + private readonly Rect[] inseln; + private readonly Rect? spezial; + private readonly Rect koordinator; + private readonly Rect bester; + private readonly Rect? steuerpult; + + public Bild(DrawingContext context, Schaubild bild, Size groesse) + { + this.context = context; + this.bild = bild; + Groesse = groesse; + jetzt = Stopwatch.GetTimestamp(); + double w = groesse.Width; + double h = groesse.Height; + double mitte = bild.Automodus ? h * 0.6 : h / 2; + koordinator = Kasten(w * 0.72, mitte, w * 0.15, KastenHoehe); + bester = Kasten(w * 0.905, mitte, w * 0.17, KastenHoehe + 8); + steuerpult = bild.Automodus ? Kasten(w * 0.72, Rand + 20, w * 0.17, 36) : null; + (inseln, spezial) = Inselkaesten(w, h, mitte); + } + + private Size Groesse { get; } + + public static IBrush Farbe(string name) => + global::Avalonia.Application.Current?.TryFindResource(name, out object? wert) == true && wert is IBrush b ? b : Brushes.Gray; + + public static FormattedText Text(string text, IBrush farbe, double groesse, bool fett, double breite) + { + var formatiert = new FormattedText(text, Deutsch, FlowDirection.LeftToRight, new Typeface(Schriftart(), FontStyle.Normal, fett ? FontWeight.SemiBold : FontWeight.Normal), groesse, farbe); + if (breite > 0) + { + formatiert.MaxTextWidth = breite; + formatiert.Trimming = TextTrimming.CharacterEllipsis; + formatiert.MaxLineCount = 1; + } + + return formatiert; + } + + /// Gibt an, ob gerade steht, was die letzte Verbesserung geändert hat (der Takt muss es ausblenden). + public static bool ZeigtAenderungen(Schaubild bild) => + bild.Meldungen.Any(m => Stopwatch.GetElapsedTime(m.Zeitpunkt).TotalSeconds < Wanderzeit + Aenderungsdauer + 0.2); + + public void Zeichnen() + { + Linien(); + for (int i = 0; i < inseln.Length; i++) + { + Insel(i); + } + + if (spezial is Rect s) + { + Kastenzeichnen(s, "Spezial-Insel", "eine Kostenart extrem", null, gestrichelt: true); + } + + Kastenzeichnen(koordinator, "Koordinator", "gleicht ab, startet neu", null); + if (steuerpult is Rect pult) + { + Kastenzeichnen(pult, "Automodus", "Gewichte · bester Plan", Leuchten(Schaubildereignis.Anhebung, 0, 1.5) is double l and > 0 ? (Farbe("Akzent"), l) : null); + } + + // Der beste Plan leuchtet auf, wenn der grüne Punkt angekommen ist. + double angekommen = Leuchten(Schaubildereignis.BesterPlan, Wanderzeit, 1.2); + Kastenzeichnen(bester, "Bester Plan", bild.Bester, angekommen > 0 ? (Farbe("Gut"), angekommen) : null, gross: true); + Aenderungen(); + Ereignisse(); + } + + private static FontFamily Schriftart() => + global::Avalonia.Application.Current?.TryFindResource("Grundschrift", out object? schrift) == true && schrift is FontFamily f ? f : FontFamily.Default; + + private static Rect Kasten(double mitteX, double mitteY, double breite, double hoehe) => + new(mitteX - (breite / 2), mitteY - (hoehe / 2), breite, hoehe); + + private static Point Rechts(Rect r) => new(r.Right, r.Center.Y); + + private static Point Links(Rect r) => new(r.Left, r.Center.Y); + + private static IBrush Mit(IBrush farbe, double deckkraft) => + farbe is ISolidColorBrush s ? new SolidColorBrush(s.Color, Math.Clamp(deckkraft, 0, 1)) : farbe; + + /// Punkt auf einem Weg aus Strecken beim Anteil (0 … 1) seiner Länge. + private static Point Auf(Point[] weg, double t) + { + double[] laengen = weg.Zip(weg.Skip(1), (a, b) => Point.Distance(a, b)).ToArray(); + double rest = Math.Clamp(t, 0, 1) * laengen.Sum(); + for (int i = 0; i < laengen.Length; i++) + { + if (rest <= laengen[i] || i == laengen.Length - 1) + { + double f = laengen[i] > 0 ? Math.Min(1, rest / laengen[i]) : 0; + return new Point(weg[i].X + ((weg[i + 1].X - weg[i].X) * f), weg[i].Y + ((weg[i + 1].Y - weg[i].Y) * f)); + } + + rest -= laengen[i]; + } + + return weg[^1]; + } + + /// Weicher Verlauf von 0 nach 1 (langsam los, langsam an). + private static double Sanft(double t) + { + double x = Math.Clamp(t, 0, 1); + return x * x * (3 - (2 * x)); + } + + private (Rect[] Inseln, Rect? Spezial) Inselkaesten(double w, double h, double mitte) + { + int anzahl = Math.Max(1, bild.Inseln.Count); + int zeilen = anzahl + (bild.Spezial ? 1 : 0); + double oben = bild.Automodus ? Rand + 44 : Rand; + double platz = h - oben - Rand; + double hoehe = Math.Min(Zeilenhoehe, (platz - (Zeilenabstand * (zeilen - 1))) / zeilen); + double gesamt = (zeilen * hoehe) + (Zeilenabstand * (zeilen - 1)); + double y = Math.Max(oben, mitte - (gesamt / 2)); + var kaesten = new Rect[anzahl]; + for (int i = 0; i < anzahl; i++) + { + kaesten[i] = new Rect(Rand, y, w * 0.5, hoehe); + y += hoehe + Zeilenabstand; + } + + Rect? sonder = bild.Spezial ? new Rect(Rand, y, w * 0.5, hoehe) : null; + return (kaesten, sonder); + } + + private void Linien() + { + var stift = new Pen(Farbe("Rand"), 1.5); + foreach (Rect insel in inseln) + { + context.DrawLine(stift, Rechts(insel), Links(koordinator)); + } + + if (spezial is Rect s) + { + context.DrawLine(new Pen(Farbe("Rand"), 1.5, new DashStyle([3, 3], 0)), Rechts(s), Links(koordinator)); + } + + context.DrawLine(stift, Rechts(koordinator), Links(bester)); + if (steuerpult is Rect pult) + { + // Der Automodus hebt Gewichte an (zum Koordinator) und wählt den besten Plan nach A, B und C aus. + context.DrawLine(stift, new Point(pult.Center.X, pult.Bottom), new Point(koordinator.Center.X, koordinator.Top)); + context.DrawLine(stift, Rechts(pult), new Point(bester.Center.X, pult.Center.Y)); + context.DrawLine(stift, new Point(bester.Center.X, pult.Center.Y), new Point(bester.Center.X, bester.Top)); + } + } + + /// + /// Eine Insel: Name und Wert in der ersten Zeile, darunter grob, was sie tut, und ein schmaler Balken, wie nah sie + /// einem Neustart ist. Die Insel mit dem besten Plan hat einen grünen Rand. + /// + private void Insel(int i) + { + Rect kasten = inseln[i]; + Inselzustand? zustand = i < bild.Inseln.Count ? bild.Inseln[i] : null; + bool beste = i == bild.BesteInsel; + context.DrawRectangle(Farbe("Flaeche"), new Pen(beste ? Farbe("Gut") : Farbe("Linie"), beste ? 2 : 1), kasten, 8, 8); + double breite = kasten.Width - 12; + FormattedText titel = Text("Insel " + (i + 1).ToString(Deutsch), Farbe("Tinte"), 11, true, breite / 2); + FormattedText wert = Text(zustand?.Wert ?? string.Empty, Farbe("Tinte"), 10, false, breite / 2); + context.DrawText(titel, new Point(kasten.Left + 6, kasten.Top + 3)); + context.DrawText(wert, new Point(kasten.Right - 6 - wert.Width, kasten.Top + 4)); + if (zustand is not null && kasten.Height >= 30) + { + double balkenBreite = breite * 0.3; + IBrush tintenfarbe = zustand.Taetigkeit == "kommt voran" ? Farbe("Gut") : Farbe("Grau"); + FormattedText taetigkeit = Text(zustand.Taetigkeit, tintenfarbe, 10, false, breite - balkenBreite - 8); + double y = kasten.Top + 4 + titel.Height; + context.DrawText(taetigkeit, new Point(kasten.Left + 6, y)); + var spur = new Rect(kasten.Right - 6 - balkenBreite, y + (taetigkeit.Height / 2) - 2, balkenBreite, 4); + context.DrawRectangle(Farbe("Linie"), null, spur, 2, 2); + context.DrawRectangle(Farbe("Grau"), null, spur.WithWidth(balkenBreite * zustand.Stillstand), 2, 2); + } + + double neu = Leuchten(Schaubildereignis.Neustart, 0, 1.4, i); + if (neu > 0) + { + context.DrawRectangle(Mit(Farbe("Grau"), 0.3 * neu), null, kasten, 8, 8); + } + } + + /// + /// Direkt unter dem besten Plan ein Kasten mit dem, was die letzte Verbesserung geändert hat: grün, was besser, rot, + /// was schlechter wurde. Er erscheint, wenn der grüne Punkt beim besten Plan ankommt, wird mit jedem weiteren + /// angekommenen Punkt aktualisiert (mit einem kurzen Aufleuchten) und blendet 10 Sekunden nach der letzten Ankunft aus + /// (in der letzten Sekunde verblassend). Jeder Punkt trägt seine eigene Änderung; solange ein neuer Punkt wandert, bleibt + /// die des zuletzt angekommenen stehen. + /// + private void Aenderungen() + { + // Die Meldung des zuletzt angekommenen Punkts; Punkte, die noch wandern, zählen noch nicht. + Verbesserungsmeldung? meldung = bild.Meldungen.LastOrDefault(m => Stopwatch.GetElapsedTime(m.Zeitpunkt, jetzt).TotalSeconds >= Wanderzeit); + if (meldung is null) + { + return; + } + + IReadOnlyList liste = meldung.Aenderungen; + double alter = Stopwatch.GetElapsedTime(meldung.Zeitpunkt, jetzt).TotalSeconds - Wanderzeit; + if (liste.Count == 0 || alter > Aenderungsdauer) + { + return; + } + + double deckkraft = Math.Min(1, Aenderungsdauer - alter); + double aufleuchten = Math.Max(0, 1 - (alter / 0.8)); + double rechts = bester.Right; + double breite = Math.Min(Math.Max(bester.Width, Groesse.Width * 0.34), rechts - koordinator.Left); + const double Innen = 6; + FormattedText kopf = Text(meldung.Titel, Mit(Farbe("Gut"), deckkraft), 11, true, breite - (2 * Innen)); + var zeilen = new List(); + double hoehe = (2 * Innen) + kopf.Height + 2; + double platz = Groesse.Height - Rand - (bester.Bottom + 4); + foreach (Planaenderung aenderung in liste) + { + FormattedText zeile = Text(aenderung.Text, Mit(Farbe(aenderung.Besser ? "Gut" : "Schlecht"), deckkraft), 11, true, breite - (2 * Innen)); + if (hoehe + zeile.Height > platz) + { + break; + } + + zeilen.Add(zeile); + hoehe += zeile.Height; + } + + var kasten = new Rect(rechts - breite, bester.Bottom + 4, breite, hoehe); + context.DrawRectangle(Mit(Farbe("Flaeche"), deckkraft), new Pen(Mit(Farbe("Gut"), deckkraft), 1.5 + (1.5 * aufleuchten)), kasten, 8, 8); + context.DrawRectangle(Mit(Farbe("Gut"), (0.08 + (0.2 * aufleuchten)) * deckkraft), null, kasten, 8, 8); + double y = kasten.Top + Innen; + context.DrawText(kopf, new Point(kasten.Left + Innen, y)); + y += kopf.Height + 2; + foreach (FormattedText zeile in zeilen) + { + context.DrawText(zeile, new Point(kasten.Left + Innen, y)); + y += zeile.Height; + } + } + + /// + /// Wie stark ein Ereignis noch leuchtet: 0 bis Sekunden danach, dann 1, nach weiteren + /// Sekunden wieder 0. + /// + private double Leuchten(Schaubildereignis art, double verzug, double dauer, int insel = -1) + { + double staerke = 0; + foreach (Schaubildimpuls impuls in bild.Impulse.Where(x => x.Art == art && (insel < 0 || x.Insel == insel))) + { + double alter = Stopwatch.GetElapsedTime(impuls.Zeitpunkt, jetzt).TotalSeconds - verzug; + if (alter >= 0) + { + staerke = Math.Max(staerke, 1 - (alter / dauer)); + } + } + + return staerke; + } + + /// Punkte auf den Linien: der bessere beste Plan (grün), die Übergabe der Spezial-Insel, die Anhebung. + private void Ereignisse() + { + foreach (Schaubildimpuls impuls in bild.Impulse) + { + double alter = Stopwatch.GetElapsedTime(impuls.Zeitpunkt, jetzt).TotalSeconds; + Point[]? weg = Weg(impuls); + double dauer = impuls.Art switch + { + Schaubildereignis.BesterPlan => Wanderzeit, + Schaubildereignis.Uebergabe => 1.4, + _ => 0.9, + }; + if (weg is null || alter > dauer) + { + continue; + } + + IBrush farbe = impuls.Art == Schaubildereignis.BesterPlan ? Farbe("Gut") : Farbe("Akzent"); + Point ort = Auf(weg, Sanft(alter / dauer)); + context.DrawEllipse(Mit(farbe, 0.2), null, ort, 8, 8); + context.DrawEllipse(farbe, null, ort, 4, 4); + } + } + + private Point[]? Weg(Schaubildimpuls impuls) + { + bool inselGueltig = impuls.Insel >= 0 && impuls.Insel < inseln.Length; + return impuls.Art switch + { + Schaubildereignis.BesterPlan when inselGueltig => + [Rechts(inseln[impuls.Insel]), Links(koordinator), Rechts(koordinator), Links(bester)], + Schaubildereignis.BesterPlan => [Links(koordinator), Rechts(koordinator), Links(bester)], + Schaubildereignis.Uebergabe when inselGueltig && spezial is Rect s => + [Rechts(s), Links(koordinator), new Point(koordinator.Left - 12, inseln[impuls.Insel].Center.Y), Rechts(inseln[impuls.Insel])], + Schaubildereignis.Anhebung when steuerpult is Rect pult => [new Point(pult.Center.X, pult.Bottom), new Point(koordinator.Center.X, koordinator.Top)], + _ => null, + }; + } + + private void Kastenzeichnen(Rect kasten, string titel, string zeile, (IBrush Farbe, double Staerke)? glanz, bool gestrichelt = false, bool gross = false) + { + var stift = new Pen(Farbe("Linie"), 1, gestrichelt ? new DashStyle([3, 3], 0) : null); + context.DrawRectangle(Farbe("Flaeche"), stift, kasten, 8, 8); + if (glanz is ({ } farbe, double staerke)) + { + context.DrawRectangle(Mit(farbe, 0.12 * staerke), new Pen(Mit(farbe, staerke), 2), kasten, 8, 8); + } + + double breite = kasten.Width - 12; + FormattedText oben = Text(titel, Farbe("Tinte"), 11, true, breite); + FormattedText unten = Text(zeile, gross ? Farbe("Tinte") : Farbe("Grau"), gross ? 13 : 10, gross, breite); + double hoehe = oben.Height + unten.Height; + double y = kasten.Center.Y - (hoehe / 2); + context.DrawText(oben, new Point(kasten.Left + 6, y)); + context.DrawText(unten, new Point(kasten.Left + 6, y + oben.Height)); + } + } +} diff --git a/src/AndiGenerator.UI/Platzhalterdeutsch.cs b/src/AndiGenerator.UI/Platzhalterdeutsch.cs new file mode 100644 index 0000000..c90404b --- /dev/null +++ b/src/AndiGenerator.UI/Platzhalterdeutsch.cs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using Avalonia; +using Avalonia.Controls; + +namespace AndiGenerator.UI; + +/// +/// Deutsche Platzhalter für Zeit- und Datumsauswahl: Avalonia schreibt in leere Felder fest „hour“/„minute“ bzw. +/// „day“/„month“/„year“ (im Code, nicht über die Sprache); diese Texte werden beim Setzen ersetzt. +/// +internal static class Platzhalterdeutsch +{ + private static readonly Dictionary Uebersetzungen = new(StringComparer.Ordinal) + { + ["hour"] = "Stunde", + ["minute"] = "Minute", + ["second"] = "Sekunde", + ["day"] = "Tag", + ["month"] = "Monat", + ["year"] = "Jahr", + }; + + /// Meldet die Ersetzung für alle Zeit- und Datumsauswahlen der Anwendung an. + public static void Anmelden() => TextBlock.TextProperty.Changed.AddClassHandler(Ersetzen); + + private static void Ersetzen(TextBlock feld, AvaloniaPropertyChangedEventArgs e) + { + if (feld.TemplatedParent is TimePicker or DatePicker + && e.NewValue is string text + && Uebersetzungen.TryGetValue(text, out string? deutsch)) + { + feld.Text = deutsch; + } + } +} diff --git a/src/AndiGenerator.UI/Seiten/AuswaertskoppelDetail.axaml b/src/AndiGenerator.UI/Seiten/AuswaertskoppelDetail.axaml index 612df3c..b61f7da 100644 --- a/src/AndiGenerator.UI/Seiten/AuswaertskoppelDetail.axaml +++ b/src/AndiGenerator.UI/Seiten/AuswaertskoppelDetail.axaml @@ -7,28 +7,28 @@ x:DataType="p:AuswaertskoppelDetailViewModel"> - - - - - + + + + + - + @@ -37,10 +37,10 @@ - + - - - - + diff --git a/src/AndiGenerator.UI/Seiten/MannschaftenSeite.axaml.cs b/src/AndiGenerator.UI/Seiten/MannschaftenSeite.axaml.cs index ef60752..a4c2da0 100644 --- a/src/AndiGenerator.UI/Seiten/MannschaftenSeite.axaml.cs +++ b/src/AndiGenerator.UI/Seiten/MannschaftenSeite.axaml.cs @@ -7,19 +7,13 @@ namespace AndiGenerator.UI.Seiten; -/// Seite „Mannschaften“ des Dialogs „Spielplandaten bearbeiten“; Doppelklick bearbeitet wie im Original. +/// Seite „Mannschaften“ des Dialogs „Spielplandaten bearbeiten“; Doppelklick oder Eingabetaste bearbeitet, Entf löscht. public partial class MannschaftenSeite : UserControl { /// Initialisiert die Seite. public MannschaftenSeite() { InitializeComponent(); - Liste.DoubleTapped += (_, _) => - { - if (DataContext is MannschaftenSeiteViewModel seite && seite.BearbeitenCommand.CanExecute(null)) - { - seite.BearbeitenCommand.Execute(null); - } - }; + Listenbedienung.Anmelden(Liste, () => DataContext is MannschaftenSeiteViewModel seite ? (seite.BearbeitenCommand, seite.LoeschenCommand) : default); } } diff --git a/src/AndiGenerator.UI/Seiten/MehrMannschaftenSeite.axaml b/src/AndiGenerator.UI/Seiten/MehrMannschaftenSeite.axaml index 2d04a3e..13fad86 100644 --- a/src/AndiGenerator.UI/Seiten/MehrMannschaftenSeite.axaml +++ b/src/AndiGenerator.UI/Seiten/MehrMannschaftenSeite.axaml @@ -5,7 +5,7 @@ x:Class="AndiGenerator.UI.Seiten.MehrMannschaftenSeite" x:DataType="p:MehrMannschaftenSeiteViewModel"> - + diff --git a/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml b/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml index b9b5e9d..9462164 100644 --- a/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml +++ b/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml @@ -5,29 +5,29 @@ xmlns:a="using:AndiGenerator.Application" x:Class="AndiGenerator.UI.Seiten.NachbarmannschaftDetail" x:DataType="p:NachbarmannschaftDetailViewModel"> - + - - - - - + diff --git a/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml.cs b/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml.cs index 9e87866..c0ea398 100644 --- a/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml.cs +++ b/src/AndiGenerator.UI/Seiten/NachbarmannschaftDetail.axaml.cs @@ -7,19 +7,13 @@ namespace AndiGenerator.UI.Seiten; -/// Nachbarmannschaften einer Mannschaft; Doppelklick bearbeitet wie im Original. +/// Nachbarmannschaften einer Mannschaft; Doppelklick oder Eingabetaste bearbeitet, Entf löscht. public partial class NachbarmannschaftDetail : UserControl { /// Initialisiert die Ansicht. public NachbarmannschaftDetail() { InitializeComponent(); - Liste.DoubleTapped += (_, _) => - { - if (DataContext is NachbarmannschaftDetailViewModel detail && detail.BearbeitenCommand.CanExecute(null)) - { - detail.BearbeitenCommand.Execute(null); - } - }; + Listenbedienung.Anmelden(Liste, () => DataContext is NachbarmannschaftDetailViewModel detail ? (detail.BearbeitenCommand, detail.LoeschenCommand) : default); } } diff --git a/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml b/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml index 89493be..350c717 100644 --- a/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml +++ b/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml @@ -5,41 +5,43 @@ x:Class="AndiGenerator.UI.Seiten.PflichtspieltageSeite" x:DataType="p:PflichtspieltageSeiteViewModel"> - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml.cs b/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml.cs index b9eb670..7990e61 100644 --- a/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml.cs +++ b/src/AndiGenerator.UI/Seiten/PflichtspieltageSeite.axaml.cs @@ -7,19 +7,13 @@ namespace AndiGenerator.UI.Seiten; -/// Seite „Pflichtspieltage“ des Dialogs „Spielplandaten bearbeiten“; Doppelklick bearbeitet wie im Original. +/// Seite „Pflichtspieltage“ des Dialogs „Spielplandaten bearbeiten“; Doppelklick oder Eingabetaste bearbeitet, Entf löscht. public partial class PflichtspieltageSeite : UserControl { /// Initialisiert die Seite. public PflichtspieltageSeite() { InitializeComponent(); - Liste.DoubleTapped += (_, _) => - { - if (DataContext is PflichtspieltageSeiteViewModel seite && seite.BearbeitenCommand.CanExecute(null)) - { - seite.BearbeitenCommand.Execute(null); - } - }; + Listenbedienung.Anmelden(Liste, () => DataContext is PflichtspieltageSeiteViewModel seite ? (seite.BearbeitenCommand, seite.LoeschenCommand) : default); } } diff --git a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml index 093543c..d726a62 100644 --- a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml +++ b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml @@ -4,25 +4,96 @@ xmlns:ui="using:AndiGenerator.UI" x:Class="AndiGenerator.UI.Seiten.SetzlistenSeite" x:DataType="p:SetzlistenSeiteViewModel"> + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs index 0b0905d..694229b 100644 --- a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs +++ b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs @@ -6,60 +6,110 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Interactivity; using Avalonia.VisualTree; namespace AndiGenerator.UI.Seiten; /// -/// Seite „Setzliste“ des Dialogs „Spielplandaten bearbeiten“. Wie im Original lässt sich eine Mannschaft mit der Maus -/// auf eine andere ziehen; sie landet dann vor dieser. +/// Seite „Setzliste“ des Dialogs „Spielplandaten bearbeiten“. Wie in der Qualitätsansicht wird eine Mannschaft am Griff +/// gezogen: Die Zeile hängt am Mauszeiger, und die Liste zeigt an der Ablagestelle eine Lücke mit den Plätzen, die beim +/// Ablegen entstünden. Escape bricht ab. Ein Klick auf eine Zeile wählt sie für „Nach oben“/„Nach unten“. /// public partial class SetzlistenSeite : UserControl { - private static readonly Cursor Ziehcursor = new(StandardCursorType.DragMove); - - private int gezogen = -1; - private Point start; + private bool ziehen; + private double griffhoehe; /// Initialisiert die Seite. public SetzlistenSeite() { InitializeComponent(); - Liste.AddHandler(PointerPressedEvent, Gedrueckt, RoutingStrategies.Tunnel); - Liste.AddHandler(PointerMovedEvent, Bewegt, RoutingStrategies.Tunnel); - Liste.AddHandler(PointerReleasedEvent, Losgelassen, RoutingStrategies.Tunnel); + Focusable = true; + KeyDown += (_, e) => + { + if (ziehen && e.Key == Key.Escape) + { + Beenden(uebernehmen: false, null); + e.Handled = true; + } + }; } - private void Gedrueckt(object? sender, PointerPressedEventArgs e) + private SetzlistenSeiteViewModel? Modell => DataContext as SetzlistenSeiteViewModel; + + private void ListeGedrueckt(object? sender, PointerPressedEventArgs e) { - gezogen = e.GetCurrentPoint(Liste).Properties.IsLeftButtonPressed ? Index(e.GetPosition(Liste)) : -1; - start = e.GetPosition(Liste); + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || Modell is not { } modell) + { + return; + } + + Border? zeile = (e.Source as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(b => b.Classes.Contains("setzzeile")); + int index = zeile?.DataContext is Setzlistenzeile z ? modell.Zeilen.IndexOf(z) : -1; + if (zeile is null || index < 0) + { + return; + } + + bool amGriff = (e.Source as Visual)?.GetSelfAndVisualAncestors().OfType().Any(b => b.Classes.Contains("griff")) == true; + if (amGriff && modell.ZiehenBeginnen(index)) + { + ziehen = true; + griffhoehe = e.GetPosition(zeile).Y; + Schwebezeile.Width = Liste.Bounds.Width; + Mitfuehren(e); + e.Pointer.Capture(Liste); + Focus(); + } + else + { + modell.Waehlen(index); + } + + e.Handled = true; } - private void Bewegt(object? sender, PointerEventArgs e) + private void ListeBewegt(object? sender, PointerEventArgs e) { - if (gezogen >= 0 && Math.Abs(e.GetPosition(Liste).Y - start.Y) > 4) + if (!ziehen || Modell is not { } modell) + { + return; + } + + Mitfuehren(e); + + // Zeile unter dem Zeiger: obere Hälfte = davor, untere Hälfte = dahinter. + foreach (Control behaelter in Liste.GetRealizedContainers()) { - Liste.Cursor = Ziehcursor; + Point p = e.GetPosition(behaelter); + if (p.Y >= 0 && p.Y < behaelter.Bounds.Height && behaelter.DataContext is Setzlistenzeile ziel) + { + modell.ZiehenUeber(modell.Zeilen.IndexOf(ziel), p.Y < behaelter.Bounds.Height / 2); + return; + } } } - private void Losgelassen(object? sender, PointerReleasedEventArgs e) + /// Hängt die schwebende Zeile an den Mauszeiger (senkrecht beweglich, bündig mit der Liste). + private void Mitfuehren(PointerEventArgs e) { - int ziel = Index(e.GetPosition(Liste)); - if (gezogen >= 0 && ziel >= 0 && ReferenceEquals(Liste.Cursor, Ziehcursor) && DataContext is SetzlistenSeiteViewModel seite) + Canvas.SetLeft(Schwebezeile, Liste.TranslatePoint(default, Schwebeebene)?.X ?? 0); + Canvas.SetTop(Schwebezeile, e.GetPosition(Schwebeebene).Y - griffhoehe); + } + + private void ListeLosgelassen(object? sender, PointerReleasedEventArgs e) => Beenden(uebernehmen: true, e.Pointer); + + private void ListeVerloren(object? sender, PointerCaptureLostEventArgs e) => Beenden(uebernehmen: false, null); + + private void Beenden(bool uebernehmen, IPointer? zeiger) + { + if (!ziehen) { - seite.Ziehen(gezogen, ziel); + return; } - gezogen = -1; - Liste.Cursor = Cursor.Default; + ziehen = false; + zeiger?.Capture(null); + Modell?.ZiehenBeenden(uebernehmen); } - - /// Index der Mannschaft unter dem Mauszeiger oder -1 (Original ItemAtPos). - private int Index(Point punkt) => - Liste.InputHitTest(punkt) is Visual treffer && treffer.FindAncestorOfType(includeSelf: true) is ListBoxItem eintrag - ? Liste.IndexFromContainer(eintrag) - : -1; } diff --git a/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml b/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml index fb8350f..8dc1fa4 100644 --- a/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml +++ b/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml @@ -3,12 +3,121 @@ xmlns:p="using:AndiGenerator.Presentation" x:Class="AndiGenerator.UI.Seiten.SpielfreieTageSeite" x:DataType="p:SpielfreieTageSeiteViewModel"> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml.cs b/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml.cs index 2040990..9a864f1 100644 --- a/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml.cs +++ b/src/AndiGenerator.UI/Seiten/SpielfreieTageSeite.axaml.cs @@ -2,16 +2,66 @@ // SPDX-FileCopyrightText: 2026 Peter Buchmann // SPDX-License-Identifier: GPL-3.0-only +using Avalonia; using Avalonia.Controls; +using Avalonia.VisualTree; namespace AndiGenerator.UI.Seiten; -/// Seite „Spielfreie Tage“ des Dialogs „Spielplandaten bearbeiten“. +/// +/// Seite „Spielfreie Tage“ des Dialogs „Spielplandaten bearbeiten“. Der Dialog scrollt auch waagrecht; damit die Monate +/// trotzdem umbrechen statt nach rechts zu wachsen (Legende und Monate), folgt die Breite der Seite dem sichtbaren Bereich. +/// public partial class SpielfreieTageSeite : UserControl { + /// Rand links und rechts der Seite innerhalb des sichtbaren Bereichs. + private const double Rand = 40; + + private ScrollViewer? rahmen; + /// Initialisiert die Seite. public SpielfreieTageSeite() { InitializeComponent(); } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + rahmen = this.FindAncestorOfType(); + if (rahmen is not null) + { + rahmen.PropertyChanged += RahmenGeaendert; + BreiteSetzen(); + } + } + + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (rahmen is not null) + { + rahmen.PropertyChanged -= RahmenGeaendert; + rahmen = null; + } + + base.OnDetachedFromVisualTree(e); + } + + private void RahmenGeaendert(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == ScrollViewer.ViewportProperty) + { + BreiteSetzen(); + } + } + + private void BreiteSetzen() + { + if (rahmen is { Viewport.Width: > Rand }) + { + Seite.MaxWidth = rahmen.Viewport.Width - Rand; + } + } } diff --git a/src/AndiGenerator.UI/Seiten/SpiellokalDetail.axaml b/src/AndiGenerator.UI/Seiten/SpiellokalDetail.axaml index 46a016f..3a07a1d 100644 --- a/src/AndiGenerator.UI/Seiten/SpiellokalDetail.axaml +++ b/src/AndiGenerator.UI/Seiten/SpiellokalDetail.axaml @@ -1,13 +1,20 @@ - - + + + + + + + + @@ -17,7 +24,13 @@ - + + + + + + + @@ -32,7 +45,13 @@ - + + + + + + + diff --git a/src/AndiGenerator.UI/Seiten/VorgabespieleSeite.axaml b/src/AndiGenerator.UI/Seiten/VorgabespieleSeite.axaml index 2cb2ae5..b5b854c 100644 --- a/src/AndiGenerator.UI/Seiten/VorgabespieleSeite.axaml +++ b/src/AndiGenerator.UI/Seiten/VorgabespieleSeite.axaml @@ -4,21 +4,21 @@ xmlns:ui="using:AndiGenerator.UI" x:Class="AndiGenerator.UI.Seiten.VorgabespieleSeite" x:DataType="p:VorgabespieleSeiteViewModel"> - - - - -