diff --git a/.gitignore b/.gitignore index 53b3f77..0ceba63 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ korrekturvergleich/ hallenvergleich/ pdftext-log.txt bildschirmfotos-log.txt +anleitung-log.txt # Private Originaldaten (Klarnamen) – öffentlich nur die anonymisierte Kopie unter testdaten/ /Referenz/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d303f..8719600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ Was sich von Version zu Version für Anwender ändert. Die Einzelheiten stehen in der [Anleitung](Doku/ANLEITUNG.md), die technische Geschichte im [Migrationsplan](Doku/MIGRATIONSPLAN.md). +## 0.9.8 (14.10.2026) + +### Neu + +- **Alle drei click-TT-Dateien auf einmal öffnen:** Im Dialog „Öffnen“ lassen sich zusammen mit der XML-Datei die Mannschaftsmeldung (CSV) und der Bericht „Terminwünsche“ (PDF) markieren. Jede wird gleich übernommen, wenn alle Mannschaften der Staffel darin vorkommen (wie in der Einrichtung, die Mannschaftsmeldung auch als Setzliste); mit beiden ist „Daten aus click-TT ergänzen“ erledigt. Eine Datei, die nicht passt, bleibt unberücksichtigt, und die Einrichtung nennt die fehlenden Mannschaften. +- **Kompaktspieltage** (z. B. Jugend-Verbandsoberliga): Bei einer Halbrunde mit überwiegend Spieltagen am Wochenende fragt die Einrichtung, ob die Staffel in 3er- und 4er-Spieltagen geplant wird. Die neue Ansicht prüft den Plan aus click-TT gegen die Meldungen der Vereine aus den Terminwünschen: Raster aus Meldungen und Plan, Karte der Hallen mit den Fahrten (Luftlinie), km je Mannschaft, Spieltage mit den in click-TT zu verlegenden Hallen und Hinweise. +- **Kompaktspieltage planen:** Im Kompaktmodus berechnet ▶ Varianten aus 4er-Spieltagen (3er nur, wenn es anders nicht aufgeht; ausgewogen, kürzeste Fahrten in Luftlinie, gleichmäßiger Stand, gemeinsamer Spieltag); die gewählte gilt für Terminplan, Druck und Export. Die Kennzahlen zeigen dann Fahrten, Meldungen, Heimspieltage und Spieltage; Kosten-, Qualitäts- und Meldungsansicht sowie die Wahl des Verfahrens entfallen. Beim 4er-Spieltag lautet das vierte Spiel Gast 1 – Partner, so ist der Partner nur einmal formal Heimmannschaft. Der Dialog *Plan sichern* vergleicht im Kompaktmodus die Kennzahlen der Kompaktspieltage statt Stufen und Kosten. +- **Bestes Gesamtpaket:** Der Kompaktplaner wählt unter den kürzesten reinen 4er-Plänen den, der Fahrten, gleich weite Fahrten für alle Mannschaften, Stand, gemeinsamen Spieltag sowie Spitzen- und Abstiegsspieltag zusammen am besten trifft. Kompaktspieltage auch samstags (mit Heimtermin), Einzelbegegnungen auch unter der Woche bis 60 km; Einzel-Heimspiele zählen bei den Heimspieltagen mit. Die Planung lässt sich anhalten (zeigt die bisher besten Pläne), fortsetzen und beenden. Haben alle Pläne Verstöße, sucht der Planer vollständig nach einem Plan ganz ohne Verstoß – notfalls mit einer Mannschaft ohne Heimspieltag oder mit Einzelspielen. Varianten mit 3er-Spieltagen (höchstens drei) nur, wenn sie ein Ziel besser treffen als jeder reine 4er-Plan; die Erläuterung nennt die Punkte jedes Plans mit Begründung. Was es schon mindestens ebenso gut erfüllt, steht auf seiner Karte („✓ zugleich“) statt als eigene Variante. Neue Variante *Gleich weite Fahrten*; Fortschrittsbalken über die ganze Breite während der Berechnung. +- **Hinweis für Windows-Laptops mit ARM-Prozessor:** Für die ARM-Fassung gibt es HiGHS noch nicht; sie plant Kompaktspieltage mit der vollständigen Suche ohne Löser. Für Kompaktspieltage dort besser die normale Fassung (x64) installieren, die auch auf ARM läuft. +- **Kompaktspieltage gezielt konstruieren statt zufällig suchen:** Der Planer stellt die Pläne mit dem freien Löser HiGHS (MIT-Lizenz) als ganzzahlige Optimierung auf – jeder mögliche Spieltag an jedem möglichen Tag ist eine Ja/Nein-Entscheidung – und findet so den kürzesten Plan und den mit den wenigsten Punkten nachweisbar, auch bei knappen Meldungen (Testklasse 10er eng: rund 17 % kürzer als bisher). Dazu ein Plan mit Spitzen- und Abstiegsspieltag (wenn ihn das Gesamtpaket nicht schon hat) und zwei weitere Pläne mit anderen Spieltagen zur Auswahl. Unabhängige Schritte rechnen gleichzeitig, und jeder Schritt lässt mehrere Läufe des Lösers mit verschiedenen Startwerten parallel rechnen; der erste, der den besten Plan beweist, beendet die anderen. Die Zufallssuche liefert die Startpunkte und läuft danach nur noch kurz als Ergänzung. +- **Gespeicherte Pläne im Kompaktmodus:** Der gesicherte und die gemerkten Pläne stehen in der Ansicht „Kompaktspieltage“ unter „Pläne“ neben den Varianten, mit Punkten und Kennzahlen. Gewählt gelten sie mit genau den gespeicherten Spielen (Uhrzeiten, Hallen, Heimrecht) für Terminplan, Druck und den Export für click-TT. Den gewählten berechneten Plan merkt man direkt auf seiner Karte („Merken …“). Vor dem Export für click-TT prüft der Kompaktmodus den Plan mit den Hinweisen der Kompaktauswertung und listet sie auf, statt mit der Prüfung für normale Pläne (die jedes Spiel außerhalb eines Heimtermins der Heimmannschaft als ungültig zählt). Die CSV enthält dabei das Spiellokal (Nummer der Halle) für die Spiele des Ausrichters – das seines Heimtermins an diesem Tag, sonst sein Standardlokal; bisher blieb es leer, weil nur ein Heimtermin mit genau der Uhrzeit des Spiels zählte. Spiele anderer Heimmannschaften bleiben ohne Spiellokal und werden in click-TT in die Halle des Ausrichters verlegt. Gesicherte und gemerkte Pläne speichern das Spiellokal mit (nur wenn es eins gibt; Pläne ohne bleiben wie im Original), so bleibt beim 4er-Spieltag der Ausrichter beim Wiederöffnen derselbe wie berechnet. +- **Übersicht der Kompaktspieltage für die Vereine (Drucken):** Im Kompaktmodus bietet die Druckauswahl statt der Kosten „Kompaktspieltage (Übersicht für die Vereine)“. Jeder Teil beginnt auf einer neuen Seite: Klassenname mit dem Raster „Meldungen und Plan“ samt Legende, Karte aller Fahrten mit „km je Mannschaft“, Spieltage und je Mannschaft genau eine Seite mit der Karte ihrer Fahrten; Hinweise für die Staffelleitung (zu verlegende Hallen, Verstöße) stehen nicht darin. Ansicht und Ausdruck zeichnen die Karte mit demselben Kartenzeichner. Die Namen der Hallen und Städte werden dabei mit ihrer tatsächlichen Breite gesetzt: ganz im Bild und ohne sich zu überdecken (bisher wurden Namen am Rand abgeschnitten, z. B. „Klein-Winternheim“); Städte entfallen, wo kein Platz ist. +- **Meldungen im Raster erfassen:** Mit dem Schalter *Bearbeiten* über dem Raster „Meldungen und Plan“ steht ein Rahmen auf einem Feld (Klick oder Pfeiltasten); **A** auswärts, **H** nur Heim, **B** beides, **Entf** bzw. **Leertaste** kann nicht. Für Heimtermine lassen sich Beginn (leer = Standard: 3er-Spieltag 10:00, 4er-Spieltag 11:00; eine eingetragene Uhrzeit gilt so, wie sie ist), Halle und zwei Tische (4er-Spieltag) einstellen, die Halle auf Wunsch für alle Heimtermine der Mannschaft; der Tooltip eines Felds zeigt Halle und Beginn; weitere Tage werden als Zeilen angelegt (alle Felder zunächst leer). Gespeichert wird sofort wie in den Spielplandaten: Heimtermin, Sperrtermin bzw. die Zeile „Nur Heim: …“ in der Bemerkung. Samstage gelten dabei wie jeder andere Tag (auswärts, wenn nicht gesperrt), nicht mehr wie der Sonntag desselben Wochenendes. +- **Spitzen- und Abstiegsspieltag:** Mit bekanntem QTTR-Schnitt sucht der Kompaktplaner zusätzlich den kürzesten Plan, in dem die vier stärksten und die vier schwächsten Mannschaften je einen 4er-Spieltag bilden, so spät wie möglich; in Plänen aus 3er-Spieltagen zählt ein 3er-Spieltag der drei Stärksten bzw. Schwächsten. +- **Bericht „Terminwünsche“ (PDF) ist bei Klassen für Kompaktspieltage Pflicht:** Ohne ihn lässt sich die Einrichtung nicht abschließen (Hallenadressen für Karte und Entfernungen). +- **Klassen mit „(HR)“ bzw. „(FR)“ im Namen** spielen nur eine Halbrunde: Geplant wird ohne Rückfrage nur die Hinrunde („(HR)“) bzw. nur die Rückrunde („(FR)“); in den Einstellungen ist die Rundenplanung dann fest. +- **Setzliste mit TTR-Werten:** Die Schnitte aus der Mannschaftsmeldung werden mit der Staffel gespeichert und in der Setzliste neben jeder Mannschaft gezeigt; **Nach TTR ordnen** stellt die Reihenfolge danach auf. + +### Geändert + +- **Kompaktspieltage, Berechnung bei fehlenden Meldungen:** Lässt sich kein Plan ohne Verstöße finden (z. B. weil Mannschaften nichts gemeldet haben), wird das Modell sehr groß; dessen Vorverarbeitung in HiGHS dauerte bis zu 20 Minuten – ohne Fortschritt, und Stopp wirkte nicht. Große Modelle rechnet HiGHS jetzt ohne diese Vorverarbeitung, und jeder Schritt hat eine harte Zeitgrenze: Der Fortschritt läuft, Stopp greift nach wenigen Sekunden, die Pläne liegen nach wenigen Minuten vor. +- **Einrichtung einer Kompaktklasse:** „Mit Kompaktspieltagen planen“ ist vorgewählt und gilt auch beim Abschließen ohne weiteren Klick (bisher war die Kachel zwar markiert, übernommen wurde die Wahl aber nur nach einem Klick). +- **Kompaktspieltage, volle Mannschaftsnamen:** Raster, Karte, km-Liste, Spieltage, Erläuterung und Druck zeigen den vollen Namen wie in click-TT (ab der zweiten Mannschaft mit Nummer) statt eines abgeleiteten Kurznamens, der weder Ort noch eindeutig war (z. B. „Grün-Weiss Kirn“, „Horner“, zwei Mannschaften eines Vereins gleich benannt). Die Spaltenköpfe des Rasters wachsen mit dem längsten Namen; Namen über 15 Zeichen stehen dort in zwei Zeilen (geteilt nach „/“, „-“ oder an einem Leerzeichen, z. B. „TTG Mündersbach/“ – „Höchstenbach“), und im Ausdruck stehen Raster und Karte je Mannschaft mittig auf der Seite; die Spieltagskarten werden so breit wie die längste Begegnung (bis 560 Bildpunkte; Längeres bricht um, statt mit „…“ abgeschnitten zu werden). Auf der Karte steht ein Name, der neben dem Punkt keinen Platz hat, weiter weg mit Hinweislinie; reicht auch das nicht, steht am Punkt eine Nummer und der Name in einer Legende. Die Karte zoomt auch auf eine Klasse innerhalb einer Stadt (bisher mindestens rund 80 × 90 km, nun rund 25 × 30 km). +- **Übersicht für die Vereine, Seite je Mannschaft:** luftiger gesetzt – mehr Abstand zur Karte, je Spieltag eine Überschrift (Datum, Form, Heim bzw. bei wem), darunter die Halle (auch beim Heimspieltag) und je Spiel eine Zeile mit Beginn und Gegner, mit Abstand zwischen den Spieltagen. Die Karte ist höchstens 340 Punkte hoch, damit auch bei Stadtklassen alles auf eine Seite passt. +- **Drucken im Kompaktmodus:** Der Dialog bietet nur noch die Übersicht für die Vereine an – im Hochformat, mit dem Plan, der in der Ansicht „Kompaktspieltage“ gewählt ist (der Dialog nennt ihn; bisher stand dort missverständlich „Laufende Generierung“). Terminwünsche, Spielplan und Mannschaftspläne waren doppelt, Nachbarmannschaften und Diagramme spielen bei Kompaktspieltagen keine Rolle, und die Übersicht ist für das Hochformat gestaltet (je Mannschaft genau eine Seite). Ohne Plan sagt der Dialog das, und PDF erzeugen ist gesperrt. Bei Klassen mit vielen Tagen wird das Raster samt Klassenname auf die erste Seite verkleinert; bisher rutschte es auf die zweite, und die erste Seite zeigte nur den Klassennamen. +- **Kompaktspieltage, Raster und Karte:** Die Begegnungen des Spitzenspieltags haben im Raster einen schwarzen, die des Abstiegsspieltags einen blauen Ring. In der Karte ist bei einer Auswahl der Ausgangspunkt grün und jedes Ziel in der Farbe seiner Linie (Einzelbegegnung gelb), bei zwei verschiedenfarbigen Linien geteilt in Halbkreise; Pfeilspitzen zeigen die Richtung, mehrere Fahrten zwischen denselben Hallen liegen nebeneinander, und sehr kurze Fahrten (z. B. Konz – Trier) liegen als Pfeil mit hellem Rand über den Hallenpunkten. Ohne Plan entfallen „km je Mannschaft“ und die Hinweise auf fehlende Paarungen. +- **Kompaktspieltage, Raster:** Ausrichter (★) und Gäste (●) waren nach der Umstellung auf zentrierte Formen unsichtbar (Füllfarbe griff nicht) – behoben. +- **Anleitung:** Kompaktspieltage neu beschrieben (Berechnung mit HiGHS, Pläne und Punkte, Raster und Karte, Meldungen bearbeiten, Druck und Export), dazu fünf neue Bildschirmfotos aus einer erfundenen Testklasse. Außerdem neu gegliedert nach den zwei Arten der Planung: Nach den gemeinsamen ersten Schritten (mit einer Gegenüberstellung in 3.5) beschreibt Kapitel 5 die Optimierung normaler Runden und Kapitel 6 die Kompaktspieltage, jeweils mit eigenem „Weg zum Plan“. Danach folgen die gemeinsamen Ansichten, Export und Druck sowie die Spielplandaten. +- Der Knopf **Fortsetzen** in der Pause hat ein eigenes Symbol (Strich und Play) und unterscheidet sich so von **Start**. +- **Leitstand:** Die Säulen der Rechenkerne füllen die ganze Höhe; die helle Spur zeigt 100 % (so schnell wie der schnellste Kern). Bisher sah jeder Kern nur halb ausgelastet aus. + ## 0.9.7 (12.10.2026) ### Neu diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0bc6e75 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# Arbeitsregeln für Claude in diesem Repository + +AndiGenerator.NET – Portierung des AndiGenerator (Delphi) nach C#/.NET 10 mit Avalonia 12. Ansprechpartner: Peter +(spricht Deutsch; Antworten, Code-Bezeichner, Kommentare und Doku auf Deutsch). + +Zu Beginn jeder Sitzung auch `Doku/PROJEKTWISSEN.md` lesen (Arbeitsweise, Vorlieben, Kompaktspieltage, offene Punkte). + +## Bauen und Testen +- Vor dem Bauen prüfen, dass `AndiGenerator.NET.exe` nicht läuft (sonst schlägt der Build fehl) – ggf. Peter bitten, sie zu schließen. +- Bauen wie `bauen.cmd`: `dotnet build AndiGenerator.slnx -c Release -nologo -v:minimal` (immer die ganze Solution). +- Testen gezielt: Bei lokalen Änderungen nur die Tests, die betroffen sein können – betroffenes Testprojekt und + `--filter "FullyQualifiedName~Klasse1|FullyQualifiedName~Klasse2"` (z. B. Karte: `Kartenbau|Kompaktdruck`). Den vollen + Lauf (`dotnet test AndiGenerator.slnx -c Release --no-build -nologo --filter "Kategorie!=Messung"`, gut 2 Minuten, davon + fast alles Persistence.Tests) vor jedem Commit und wenn Peter danach fragt. Im Bericht sagen, was gelaufen ist. +- StyleCop- und Sonar-Befunde sind Build-Fehler. Häufig: SA1515 (Leerzeile vor Kommentar), SA1513 (Leerzeile nach `}`), + SA1636 (Dateikopf, siehe unten), SA1201/SA1202/SA1204 (Reihenfolge der Member), S3358 (keine verschachtelten + Bedingungsausdrücke), S3267 (Schleifen mit Bedingung → LINQ), S1117/CS0136 (Namen verdecken), S2234 (Argumentreihenfolge), + S3604 (überflüssiger Initialisierer). Erst melden, wenn der Build grün ist. +- Zeitabhängige Tests (HiGHS, Kompaktplaner) nicht durch lange Zeitgrenzen „reparieren“, wenn die Suche dann nicht von selbst + früher endet – sonst dauert der Testlauf Minuten. +- Keine automatischen Leistungsmessungen mit dem Original-AndiGenerator. + +## Dateikopf (jede .cs-Datei) +``` +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only +``` + +## Architektur +- Schichten: Domain, Engine (ohne Paketabhängigkeiten – Architekturtest), Import, Persistence, Application, Rendering, + Presentation (ViewModels ohne Avalonia), UI (Avalonia, Skia, Velopack). +- HiGHS: Schnittstelle `IGanzzahlloeser` in der Engine, `HighsLoeser` in Import, `Highsbruecke` in Application. +- Karte: `Kartenzeichner` (Presentation, auf `IZeichenflaeche`) zeichnet für Ansicht und PDF. + +## Daten und Datenschutz +- `Referenz/` ist privat (gitignored), echte Daten mit Klarnamen. Nie ins Repository, nie in Commits. +- `testdaten/` ist anonymisiert; neue Testdaten nur anonymisiert oder erfunden (z. B. `testdaten/kompakt`). +- PDFs/CSVs mit personenbezogenen Daten bleiben in `Referenz/`. +- Die Originale in `%LOCALAPPDATA%\Andi-Generator` nie verändern (nur lesen/kopieren). +- Keine Windows-Sicherheitseinstellungen ändern. + +## Git +- Gearbeitet wird auf Feature-Branches; Peter führt sie per Pull Request nach `main` zusammen. +- Commits nur nach grünem Build, möglichst klein und thematisch. +- Autor: `Peter Buchmann `. +- Commit-Nachricht auf Deutsch, am Ende: + ``` + Co-Authored-By: Claude + ``` +- Nicht pushen und keine Zugangsdaten eingeben – Peter pusht selbst (Visual Studio). + +## Doku und Versionen +- Jede sichtbare Änderung in `CHANGELOG.md` unter „Unveröffentlicht“ (aktuell Arbeit an 0.9.9; 0.9.8 ist veröffentlicht – + den Abschnitt „Unveröffentlicht“ bei der ersten Änderung wieder anlegen). Release: Abschnitt zu `## x.y.z (Datum)`, + PDF der Anleitung neu, Pull Request; nach dem Merge setzt Peter den Tag `vx.y.z` auf `main` (startet den Release-Workflow). +- Anleitung: `Doku/ANLEITUNG.md`; Bilder mit `bildschirmfotos.cmd` (Test `Bildschirmfotos`), PDF siehe `tools/anleitung/README.md`. + `Doku/Anleitung.pdf` nur vor einem Pull Request neu erzeugen (`anleitung.cmd`), nicht bei jeder Änderung der Anleitung – + bis dahin darf das PDF hinter `ANLEITUNG.md` zurückliegen. +- Entscheidungen und offene Punkte: `Doku/MIGRATIONSPLAN.md` (Kompaktspieltage: Abschnitt 12). +- Signierung darf nichts kosten. + +## Oberfläche +- Explorer (Windows) nur per Klick bedienen lassen, keine Skripte für Dateiverwaltung an Peter. diff --git a/Directory.Packages.props b/Directory.Packages.props index 54e1f88..7e71c5f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,10 @@ + + + + diff --git a/Doku/ANLEITUNG.md b/Doku/ANLEITUNG.md index 6efc68b..87eb2be 100644 --- a/Doku/ANLEITUNG.md +++ b/Doku/ANLEITUNG.md @@ -1,24 +1,27 @@ # AndiGenerator.NET – Anleitung -AndiGenerator.NET erstellt Spielpläne für Tischtennis-Staffeln. Grundlage sind die Terminwünsche, die die Vereine in click-TT gemeldet haben. Das Programm sucht so lange nach besseren Plänen, bis Sie zufrieden sind, und exportiert das Ergebnis als CSV-Datei für den Import in click-TT. +AndiGenerator.NET erstellt Spielpläne für Tischtennis-Staffeln. Grundlage sind die Terminwünsche, die die Vereine in click-TT gemeldet haben. Das Programm plant auf zwei Arten: **normale Runden** optimiert es so lange, bis Sie zufrieden sind (Kapitel [5](#5-normale-runden-optimieren)); für Klassen, die eine Halbrunde in **Kompaktspieltagen** spielen, baut es Pläne aus 3er- und 4er-Spieltagen in einer Halle (Kapitel [6](#6-kompaktspieltage-planen)). In beiden Fällen exportiert es das Ergebnis als CSV-Datei für den Import in click-TT. -AndiGenerator.NET ist die Weiterentwicklung des **AndiGenerators** von Andreas Hofmann. Die Optimierung, die Bewertung der Pläne und die Dateiformate stammen aus seinem Programm und rechnen genauso. Neu sind die Oberfläche und die Bedienung. Diese Anleitung beschreibt die neue Oberfläche; die fachlichen Kapitel folgen inhaltlich der Anleitung des Originals. +AndiGenerator.NET ist die Weiterentwicklung des **AndiGenerators** von Andreas Hofmann. Die Optimierung normaler Runden, die Bewertung der Pläne und die Dateiformate stammen aus seinem Programm und rechnen genauso. Neu sind die Oberfläche, die Bedienung und die Planung von Kompaktspieltagen. Diese Anleitung beschreibt die neue Oberfläche; die fachlichen Abschnitte zu normalen Runden folgen inhaltlich der Anleitung des Originals. **Inhalt** 1. [Allgemeines](#1-allgemeines) 2. [Installation](#2-installation) -3. [Erste Schritte](#3-erste-schritte) -4. [Das Hauptfenster](#4-das-hauptfenster) – mit [Leitstand](#41-der-leitstand) und [Verlauf](#42-der-verlauf) -5. [Die Generierung steuern](#5-die-generierung-steuern) – mit [Der Automodus](#51-der-automodus) -6. [Pläne beurteilen: die Ansichten](#6-pläne-beurteilen-die-ansichten) -7. [Gewichtungen anpassen](#7-gewichtungen-anpassen) -8. [Pläne merken und vergleichen](#8-pläne-merken-und-vergleichen) -9. [Export und Druck](#9-export-und-druck) -10. [Spielplandaten bearbeiten](#10-spielplandaten-bearbeiten) -11. [Runden: Doppelrunde, Halbrunde, Vor- und Rückrunde einzeln](#11-runden) -12. [Bewertung der Pläne (Kosten)](#12-bewertung-der-pläne-kosten) -13. [Tipps und häufige Fragen](#13-tipps-und-häufige-fragen) +3. [Erste Schritte](#3-erste-schritte) – Staffel öffnen und einrichten, [Zwei Arten zu planen](#35-zwei-arten-zu-planen) +4. [Das Hauptfenster](#4-das-hauptfenster) +5. [Normale Runden optimieren](#5-normale-runden-optimieren) + - [Der Weg zum Plan](#51-der-weg-zum-plan) · [Die Kacheln](#52-die-kacheln) · [Die Generierung steuern](#53-die-generierung-steuern) · [Der Automodus](#54-der-automodus) · [Der Leitstand](#55-der-leitstand) · [Der Verlauf](#56-der-verlauf) + - [Kosten](#57-kosten) · [Qualität](#58-qualität) · [Meldungen](#59-meldungen) · [Gewichtungen anpassen](#510-gewichtungen-anpassen) · [Pläne merken und vergleichen](#511-pläne-merken-und-vergleichen) + - [Runden](#512-runden) · [Bewertung der Pläne (Kosten)](#513-bewertung-der-pläne-kosten) +6. [Kompaktspieltage planen](#6-kompaktspieltage-planen) + - [Was sind Kompaktspieltage?](#61-was-sind-kompaktspieltage) · [Der Weg zum Plan](#62-der-weg-zum-plan) · [Der Kompaktmodus](#63-der-kompaktmodus) · [Meldungen und Daten aus click-TT](#64-meldungen-und-daten-aus-click-tt) + - [Pläne berechnen](#65-pläne-berechnen) · [Pläne und Punkte](#66-pläne-und-punkte) · [Meldungen und Plan, Karte](#67-meldungen-und-plan-karte) · [Spieltage und Hinweise](#68-spieltage-und-hinweise) · [Meldungen im Raster bearbeiten](#69-meldungen-im-raster-bearbeiten) + - [Pläne sichern und merken](#610-pläne-sichern-und-merken) · [Drucken und Export für click-TT](#611-drucken-und-export-für-click-tt) · [Grenzen](#612-grenzen) +7. [Ansichten für beide Arten der Planung](#7-ansichten-für-beide-arten-der-planung) – Terminplan, Diagramme, Terminwünsche, Nachbarmannschaften +8. [Export und Druck](#8-export-und-druck) +9. [Spielplandaten bearbeiten](#9-spielplandaten-bearbeiten) +10. [Tipps und häufige Fragen](#10-tipps-und-häufige-fragen) --- @@ -98,7 +101,7 @@ Ohne Installation geht es mit `AndiGeneratorNET-linux-x64.tar.gz` bzw. `AndiGene ## 3. Erste Schritte -Der Weg zu einem fertigen Plan in sechs Schritten: +Ob normale Runde oder Kompaktspieltage – die ersten Schritte sind dieselben: Terminwünsche aus click-TT herunterladen, die Staffel öffnen und einrichten. Danach trennen sich die Wege (Abschnitt [3.5](#35-zwei-arten-zu-planen)); gemeinsam bleiben die Ansichten Terminplan, Diagramme, Terminwünsche und Nachbarmannschaften, Export und Druck sowie die Spielplandaten (Kapitel [7](#7-ansichten-für-beide-arten-der-planung) bis [9](#9-spielplandaten-bearbeiten)). ### 3.1 Terminwünsche aus click-TT herunterladen @@ -116,7 +119,7 @@ Die Datei enthält die Terminwünsche aller Mannschaften und die schon terminier ![Startseite](Anleitung/Bilder/01-startseite.png) -Auf der Startseite **click-TT-Datei öffnen** wählen, oder links **Öffnen**. Zuletzt geöffnete Staffeln stehen in der Liste darunter und öffnen sich mit einem Klick; mit dem **×** am Ende einer Zeile entfernen Sie einen Eintrag aus der Liste (die Datei selbst bleibt erhalten). +Auf der Startseite **click-TT-Datei öffnen** wählen, oder links **Öffnen**. Im Dateidialog können Sie zusammen mit der XML-Datei gleich die beiden weiteren Downloads der Staffel markieren (mit gedrückter **Strg**-Taste): die **Mannschaftsmeldung (CSV)** und den Bericht **Terminwünsche (PDF)**. Das Programm übernimmt jede dieser Dateien, wenn alle Mannschaften der Staffel darin vorkommen – genau so, als hätten Sie sie in der Einrichtung unter *Daten aus click-TT ergänzen* geladen und übernommen (die Mannschaftsmeldung auch als Setzliste); mit beiden Dateien ist dieser Punkt dann schon erledigt. Passt eine Datei nicht zur Staffel (z. B. aus einer anderen Klasse), bleibt sie unberücksichtigt, und die Einrichtung sagt, welche Mannschaften fehlen. Zuletzt geöffnete Staffeln stehen in der Liste darunter und öffnen sich mit einem Klick; mit dem **×** am Ende einer Zeile entfernen Sie einen Eintrag aus der Liste (die Datei selbst bleibt erhalten). Ohne click-TT können Sie mit **Neu** eine leere Plandatei anlegen und Liga, Mannschaften und Termine von Hand eingeben. Die Eingabemasken sind dafür aber eher zum Korrigieren gedacht als zum Erfassen einer ganzen Staffel. @@ -128,19 +131,20 @@ click-TT überträgt nicht alles, was für einen guten Plan nötig ist, und die | Punkt | Worum es geht | |---|---| -| **Daten aus click-TT ergänzen** | Immer als erster Punkt: weitere Exporte aus click-TT, die nicht in der XML-Datei stehen (siehe unten). Jeder ist freiwillig. Erledigt ist der Punkt erst, wenn Mannschaftsmeldung **und** Terminwünsche übernommen sind – das darf auch in verschiedenen Sitzungen geschehen; die Seite nennt bei jeder Datei, wann sie übernommen wurde. Ist beides erledigt und sonst nichts zu klären, erscheint die Einrichtung nicht. | +| **Daten aus click-TT ergänzen** | Immer als erster Punkt: weitere Exporte aus click-TT, die nicht in der XML-Datei stehen (siehe unten). Jeder ist freiwillig – außer bei Klassen mit Kompaktspieltagen, für die der Bericht *Terminwünsche* Pflicht ist. Erledigt ist der Punkt erst, wenn Mannschaftsmeldung **und** Terminwünsche übernommen sind – das darf auch in verschiedenen Sitzungen geschehen; die Seite nennt bei jeder Datei, wann sie übernommen wurde. Ist beides erledigt und sonst nichts zu klären, erscheint die Einrichtung nicht. | | **Gespeicherte Einstellungen** | Sie haben die Staffel schon einmal bearbeitet und dabei Gewichtungen geändert. Wählen Sie, ob Sie diese weiterverwenden oder für diese Sitzung mit den Standardeinstellungen rechnen wollen (die gespeicherten bleiben erhalten). | | **Gespeicherte Kriterienreihenfolge** | Sie haben für diese Staffel in der Ansicht **Qualität** eine eigene Einteilung der Kriterien in die Stufen A, B und C festgelegt (danach richtet sich der Automodus). Die Seite zeigt sie; wählen Sie, ob Sie sie weiterverwenden oder für diese Sitzung die Standardreihenfolge nehmen (die gespeicherte bleibt erhalten, bis Sie die Einteilung wieder ändern). | -| **Rundenplanung** | Ist die Runde kürzer als sechs Monate, schlägt das Programm eine Halbrunde vor. Beginnt die Rückrunde in weniger als 60 Tagen (oder hat sie vor weniger als 60 Tagen begonnen), schlägt es vor, nur die Rückrunde zu planen; die Frage nennt dazu den Beginn der Rückrunde. | +| **Rundenplanung** | Ist die Runde kürzer als sechs Monate, schlägt das Programm eine Halbrunde vor. Beginnt die Rückrunde in weniger als 60 Tagen (oder hat sie vor weniger als 60 Tagen begonnen), schlägt es vor, nur die Rückrunde zu planen; die Frage nennt dazu den Beginn der Rückrunde. Enthält der Klassenname „(HR)“ (Hinrunde bzw. Herbstrunde) oder „(FR)“ (Finalrunde bzw. Frühlingsrunde), spielt die Klasse nur eine Halbrunde: Dann wird ohne Frage nur die Hinrunde bzw. nur die Rückrunde geplant; in den Einstellungen ist die Rundenplanung dann fest. | +| **Klasse mit Kompaktspieltagen** | Nur bei einer Halbrunde, deren Spieltage überwiegend am Wochenende liegen: Erklärung der Kompaktspieltage und die Wahl, ob die Staffel so geplant wird. Siehe Abschnitt [6.3](#63-der-kompaktmodus). | | **Spiellokale** | click-TT überträgt keine Nummern der Spiellokale, wohl aber die Namen der Hallen. Das Programm vergleicht die Hallenbelegung deshalb nach dem Hallennamen: Ein Heimspiel einer Nachbarmannschaft in einer anderen Halle belegt die eigene Halle nicht. Die Nummern (1, 2, 3) brauchen Sie für den Export nach click-TT und für von Hand angelegte Termine ohne Hallennamen. Haben alle Vereine nur eine Halle, können Sie den Punkt überspringen. | | **Koppeltermine** | Die Staffel enthält Koppelwünsche oder mögliche Doppelspieltage. Das Programm leitet sie aus den Meldungen ab; bitte kontrollieren. | | **Auswärtskoppelwünsche** | Mannschaften möchten zwei Auswärtsspiele an einem Wochenende zusammenlegen. Bitte kontrollieren. | | **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; **Abbrechen** schließt die Staffel wieder (schon übernommene Punkte bleiben gespeichert). Solange die Einrichtung offen ist, sind auch **Öffnen** und **Neu** gesperrt – erst abschließen oder abbrechen; 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. +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; **Abbrechen** schließt die Staffel wieder (schon übernommene Punkte bleiben gespeichert). Solange die Einrichtung offen ist, sind auch **Öffnen** und **Neu** gesperrt – erst abschließen oder abbrechen. 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. -**Daten aus click-TT ergänzen.** Hier gibt es zwei Dateien. Die **Mannschaftsmeldung**: In click-TT stehen bei den Downloads der Staffel die Dateien **Mannschaftsmeldung Vorrunde (csv)** bzw. **Rückrunde (csv)**. Nach **Mannschaftsmeldung laden…** zeigt das Programm je Mannschaft den Schnitt der TTR-Werte ihrer besten Stammspieler – bei Vierer-Mannschaften der besten vier, bei Sechser-Mannschaften der besten sechs; Reservespieler zählen nicht – und ordnet die Mannschaften danach, die stärkste zuerst. Mit dem Haken **Als Setzliste übernehmen** wird diese Reihenfolge die Setzliste (und die Setzliste eingeschaltet); im Punkt **Setzliste** können Sie sie danach wie gewohnt verschieben. Gelesen werden nur Mannschaft, Rang, Status und TTR-Wert – keine Namen oder Geburtsdaten. +**Daten aus click-TT ergänzen.** Hier gibt es zwei Dateien. Die **Mannschaftsmeldung**: In click-TT stehen bei den Downloads der Staffel die Dateien **Mannschaftsmeldung Vorrunde (csv)** bzw. **Rückrunde (csv)**. Nach **Mannschaftsmeldung laden…** zeigt das Programm je Mannschaft den Schnitt der TTR-Werte ihrer besten Stammspieler – bei Vierer-Mannschaften der besten vier, bei Sechser-Mannschaften der besten sechs; Reservespieler zählen nicht – und ordnet die Mannschaften danach, die stärkste zuerst. Mit dem Haken **Als Setzliste übernehmen** wird diese Reihenfolge die Setzliste (und die Setzliste eingeschaltet); im Punkt **Setzliste** können Sie sie danach wie gewohnt verschieben. Bei Kompaktspieltagen bestimmen die Schnitte den Spitzen- und den Abstiegsspieltag. Gelesen werden nur Mannschaft, Rang, Status und TTR-Wert – keine Namen oder Geburtsdaten. ![Daten aus click-TT: Mannschaftsmeldung als Vorschlag für die Setzliste](Anleitung/Bilder/26-daten-aus-clicktt.png) @@ -156,28 +160,20 @@ Nach dem Laden erscheint direkt unter dem jeweiligen Knopf eine hervorgehobene M ### 3.4 Terminwünsche kontrollieren -Bevor Sie die Generierung starten, lohnt ein Blick in die Ansicht **Terminwünsche** (links unter *Ansichten*). Sie zeigt, was die Vereine gemeldet haben, und markiert Probleme – zum Beispiel eine Mannschaft, die zu wenige Heimtermine gemeldet hat. Ein Plan lässt sich trotzdem erzeugen; es bleiben dann aber Spiele ohne Termin. Einzelheiten in Abschnitt [6.6](#66-terminwünsche). +Bevor Sie planen, lohnt ein Blick in die Ansicht **Terminwünsche** (links unter *Ansichten*). Sie zeigt, was die Vereine gemeldet haben, und markiert Probleme – zum Beispiel eine Mannschaft, die zu wenige Heimtermine gemeldet hat. Ein Plan lässt sich trotzdem erzeugen; es bleiben dann aber Spiele ohne Termin. Einzelheiten in Abschnitt [7.3](#73-terminwünsche). Bei Kompaktspieltagen zeigt das Raster der Ansicht **Kompaktspieltage** die Meldungen noch übersichtlicher (Abschnitt [6.7](#67-meldungen-und-plan-karte)). -### 3.5 Generierung starten und beobachten +### 3.5 Zwei Arten zu planen -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. +Nach der Einrichtung plant das Programm die Staffel auf eine von zwei Arten. Welche gilt, ergibt sich aus der Einrichtung: Wer bei einer passenden Klasse die Kompaktspieltage wählt, arbeitet im **Kompaktmodus**, alle anderen Staffeln planen **normale Runden**. -- **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. - -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. - -Schon nach wenigen Sekunden steht ein erster Plan. Nach einigen hunderttausend berechneten Plänen ist meist etwas Brauchbares dabei; nach einigen Millionen findet das Programm oft nur noch kleine Verbesserungen. Wie schnell das geht, hängt stark vom Prozessor ab – die Anzeige **Berechnete Pläne** nennt die aktuelle Geschwindigkeit. - -### 3.6 Plan beurteilen, nachsteuern und exportieren - -Während die Generierung läuft, beurteilen Sie den Plan in den Ansichten **Kosten**, **Qualität** und **Diagramme** und steuern über die Gewichtungen nach (Abschnitt [7](#7-gewichtungen-anpassen)). Die Generierung muss dafür nicht angehalten werden. - -Passt der Plan, **pausieren** Sie und exportieren ihn mit **Für click-TT exportieren** als CSV-Datei (Abschnitt [9](#9-export-und-druck)). Die CSV-Datei importieren Sie in click-TT als Spielplan. +| | Normale Runden | Kompaktspieltage | +|---|---|---| +| **Für** | jede Staffel: Vor- und Rückrunde, Halbrunde, Doppelrunde | Halbrunden mit Spieltagen überwiegend am Wochenende, z. B. Jugend-Verbandsoberliga | +| **Spiele** | jedes Spiel an einem eigenen Termin der Heimmannschaft | drei oder vier Mannschaften treffen sich an einem Tag in einer Halle | +| **Rechnen** | lange Suche, die den Plan immer weiter verbessert (Kostenoptimierung oder Automodus) | gezielte Berechnung mit dem Löser HiGHS; nach kurzer Zeit liegen mehrere fertige Varianten vor | +| **Bewertung** | Kosten mit Ihren Gewichtungen, Verstöße nach Stufen A, B und C | Punkte für Verstöße, fehlende Heimspieltage, Fahrten, Stand und weitere Ziele | +| **Hauptansichten** | Kosten, Qualität, Meldungen, Leitstand, Verlauf | Kompaktspieltage: Pläne, Raster, Karte, Spieltage | +| **Beschrieben in** | Kapitel [5](#5-normale-runden-optimieren) | Kapitel [6](#6-kompaktspieltage-planen) | ## 4. Das Hauptfenster @@ -186,7 +182,7 @@ Passt der Plan, **pausieren** Sie und exportieren ihn mit **Für click-TT export **Links** stehen alle Befehle: - **Staffel:** Öffnen, Neu, Spielplandaten, Einstellungen -- **Ansichten:** Kosten, Qualität, Meldungen, Terminplan, Diagramme, Terminwünsche, Nachbarmannschaften +- **Ansichten:** Kosten, Qualität, Meldungen, Terminplan, Diagramme, Terminwünsche, Nachbarmannschaften, bei Halbrunden Kompaktspieltage - **Ausgabe:** Für click-TT exportieren (farblich hervorgehoben), Drucken (PDF) - **Anleitung:** öffnet diese Anleitung als PDF - **Über:** Version, Urheber und Lizenzen @@ -195,69 +191,61 @@ Die Abschnitte *Staffel*, *Ansichten* und *Ausgabe* lassen sich mit einem Klick **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: +Darunter stehen **Kacheln** mit dem Stand der Generierung (Abschnitt [5.2](#52-die-kacheln)). -| Kachel | Bedeutung | -|---|---| -| **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 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** (Abschnitt [4.2](#42-der-verlauf)). | -| **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** (Abschnitt [4.1](#41-der-leitstand)). | +Im **Kompaktmodus** sieht das Hauptfenster etwas anders aus: Die Ansicht *Kompaktspieltage* ist die Hauptansicht, *Kosten*, *Qualität* und *Meldungen* sowie die Wahl des Verfahrens entfallen, und ▶ berechnet die Pläne der Kompaktspieltage (Abschnitt [6.3](#63-der-kompaktmodus)). -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“). +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. -![Ergebniskachel im Automodus](Anleitung/Bilder/18-automodus.png) +Ganz unten zeigt die **Statuszeile** den Stand in einer Zeile. -Was die Kacheln *Berechnete Pläne* und *Verbesserungen* hinter **Details ›** zeigen, beschreiben die Abschnitte [4.1 Der Leitstand](#41-der-leitstand) und [4.2 Der Verlauf](#42-der-verlauf). +## 5. Normale Runden optimieren +Für normale Runden sucht das Programm den Plan, der die Wünsche der Vereine am besten erfüllt. Es bewertet jeden Plan mit **Kosten** und verbessert ihn durch Millionen kleiner Tauschaktionen – so lange, bis Sie zufrieden sind. Dieses Kapitel beschreibt den Ablauf, die Steuerung der Suche, die Ansichten zur Beurteilung und die Gewichtungen. -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. +### 5.1 Der Weg zum Plan -Ganz unten zeigt die **Statuszeile** den Stand in einer Zeile. +Ist die Staffel eingerichtet und sind die Terminwünsche kontrolliert (Kapitel [3](#3-erste-schritte)), geht es so weiter: -### 4.1 Der Leitstand +**Generierung starten und beobachten.** 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. -Der Leitstand zeigt, was beim Rechnen geschieht. Sie öffnen ihn mit **Details ›** in der Kachel *Berechnete Pläne*. Für die Planung selbst brauchen Sie ihn nicht; er hilft aber zu verstehen, warum es gerade vorangeht oder nicht. Das Bild bleibt ruhig und bewegt sich nur, wenn etwas geschieht. +- **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.4](#54-der-automodus). -![Leitstand bei der Kostenoptimierung](Anleitung/Bilder/20-leitstand-kosten.png) +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. -Von oben nach unten: +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 **▮▶ Fortsetzen** – ein Strich wie bei Pause und das Play-Dreieck, damit man es vom Start unterscheidet) 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. -- **Rechenkerne** (oben links): je Kern ein Balken mit seiner Last. Bei sehr vielen Kernen ist es ein Raster aus Kästchen – je kräftiger, desto mehr Last. -- **Schaubild der Maschine** (oben rechts): *Inseln → Koordinator → Bester Plan*. Gerechnet wird in den Inseln, jeweils an vielen **Suchplätzen**, die bei jedem Schritt einen Teil der Spiele neu verteilen; die Rechenkerne rechnen reihum an allen. 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. -- **Kasten unter dem besten Plan:** Bei jedem grünen Punkt erscheint bzw. aktualisiert sich ein Kasten mit der Nummer der Verbesserung – dieselbe Zahl wie in der Kachel *Verbesserungen*, z. B. „Verbesserung 57“ – und dem, was sich geändert hat. Oft wird ein Kriterium besser und ein anderes dafür etwas schlechter. Bei der Kostenoptimierung stehen dort 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*. 10 Sekunden nach der letzten Verbesserung verschwindet der Kasten. -- **Inseln:** Jede Insel sucht für sich und hat ihren eigenen besten Plan. Ihre Karte zeigt den Wert dieses Plans (Kosten, im Automodus die Verstöße A · B · C), was sie gerade tut (Tabelle unten) und einen Balken: Er zeigt, wie lange sie schon nichts Besseres gefunden hat, gemessen an der Neustart-Grenze (100 T Pläne). Darunter stehen die drei größten Kostenverursacher ihres Plans mit ihrem Anteil an den Kosten, im Automodus die Kriterien mit den meisten Verstößen (wichtigste Stufe zuerst). Die Insel mit dem besten Plan ist grün umrandet. +Das Programm rechnet mit allen Prozessorkernen bis auf einen, damit der Rechner bedienbar bleibt, und zeigt laufend den besten bisher gefundenen Plan. -| Die Insel … | Bedeutung | -|---|---| -| *kommt voran* | Sie hat in den letzten 5 Sekunden einen besseren Plan gefunden. | -| *sucht* | Sie rechnet; ihr Balken ist noch nicht weit gefüllt. | -| *findet lange nichts* | Ihr Balken ist über 60 % gefüllt. | -| *steckt fest, rechnet weiter* | Ihr Balken ist voll, ihr Plan ist aber besser als der schlechteste – sie rechnet weiter. | -| *beginnt gleich neu* | Ihr Balken ist voll, und sie hat den schlechtesten Plan aller Inseln – sie wird als nächste neu begonnen, auch wenn sie eben noch verbessert hat. Bei vielen Plänen je Sekunde ist die Grenze schnell wieder erreicht. | -| *beginnt neu* | Sie wird gerade mit einem neuen Plan begonnen (kurz grau). | +Schon nach wenigen Sekunden steht ein erster Plan. Nach einigen hunderttausend berechneten Plänen ist meist etwas Brauchbares dabei; nach einigen Millionen findet das Programm oft nur noch kleine Verbesserungen. Wie schnell das geht, hängt stark vom Prozessor ab – die Anzeige **Berechnete Pläne** nennt die aktuelle Geschwindigkeit. -Neu beginnt also nur die **schlechteste** Insel, und erst wenn ihr Balken voll ist – höchstens eine je Sekunde. Bei der Kostenoptimierung kommt die **Spezial-Insel** hinzu: Sie gewichtet eine Kostenart extrem hoch und gibt gute Pläne über den Koordinator an die anderen Inseln weiter. +**Plan beurteilen, nachsteuern und exportieren.** Während die Generierung läuft, beurteilen Sie den Plan in den Ansichten **Kosten**, **Qualität** und **Diagramme** und steuern über die Gewichtungen nach (Abschnitt [5.10](#510-gewichtungen-anpassen)). Die Generierung muss dafür nicht angehalten werden. In der Regel reichen Kosten, Qualität und Diagramme, um einen Plan zu beurteilen; die einzelnen Termine müssen Sie meist nicht durchsehen. -![Leitstand im Automodus](Anleitung/Bilder/19-leitstand.png) +Passt der Plan, **pausieren** Sie und exportieren ihn mit **Für click-TT exportieren** als CSV-Datei (Abschnitt [8.1](#81-für-click-tt-exportieren)). Die CSV-Datei importieren Sie in click-TT als Spielplan. -Im **Automodus** steuert das Pult *Automodus* im Schaubild die Suche: Es hebt die Gewichte an und wählt den besten Plan nach A, B und C aus (Abschnitt [5.1](#51-der-automodus)). Oberhalb der Inseln steht dann, welche **Gewichte** der Automodus 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. +### 5.2 Die Kacheln -### 4.2 Der Verlauf +Unter der Steuerung zeigen vier **Kacheln** den Stand der Generierung: -Die Ansicht **Verlauf** öffnen Sie mit **Details ›** in der Kachel *Verbesserungen*. Sie zeigt formatfüllend, wie sich der beste Plan seit dem Start entwickelt hat: bei der Kostenoptimierung die Kosten, im Automodus die Verstöße der Stufen A, B und C. Im Hintergrund sind die Phasen des Automodus farbig hinterlegt, kleine Striche markieren die Gewichtsanhebungen, und darunter stehen die berechneten Pläne je Sekunde. Fahren Sie mit der Maus über das Diagramm, erscheinen die Werte zu diesem Zeitpunkt. Haben Sie mit der Kostenoptimierung begonnen und dann in den Automodus gewechselt, steht unter den Verstößen zusätzlich die Kurve der Kosten: Verstöße zeichnet das Programm erst ab dem Automodus auf, die Kosten dagegen von Anfang an. Dass die Kosten im Automodus wieder steigen, ist kein Rückschritt: Er hebt Gewichte an, und mit höheren Gewichten kostet derselbe Plan mehr. +| Kachel | Bedeutung | +|---|---| +| **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](#58-qualität)). **Details ›** öffnet die Qualitätsansicht. | +| **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** (Abschnitt [5.6](#56-der-verlauf)). | +| **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** (Abschnitt [5.5](#55-der-leitstand)). | -![Verlauf](Anleitung/Bilder/21-verlauf.png) +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“). -Am Verlauf sehen Sie, ob sich weiteres Rechnen noch lohnt: Wird die Kurve flach, findet das Programm nur noch selten etwas Besseres. +![Ergebniskachel im Automodus](Anleitung/Bilder/18-automodus.png) -## 5. Die Generierung steuern +### 5.3 Die Generierung steuern | Schaltfläche | Wirkung | |---|---| | **▶ 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. | +| **⏸ 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. | | **■ 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. | @@ -268,19 +256,19 @@ Am Verlauf sehen Sie, ob sich weiteres Rechnen noch lohnt: Wird die Kurve flach, - **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)). +- **gemerktem Plan „…“** – ein früher gemerkter Plan (Abschnitt [5.11](#511-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 **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. +**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 und hat er dieselben Begegnungen an denselben Tagen, meldet das Programm nur, dass beide identisch sind. Sonst 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). Im Kompaktmodus vergleicht der Dialog stattdessen die Kennzahlen der Kompaktspieltage (Abschnitt [6.10](#610-pläne-sichern-und-merken)). 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. ![Rückfrage beim Sichern: aktueller und gesicherter Plan im Vergleich](Anleitung/Bilder/22-sicherung.png) Änderungen an den **Einstellungen** und **Spielplandaten** übernimmt eine laufende Generierung sofort; sie rechnet vom bisher besten Plan aus weiter. -### 5.1 Der Automodus +### 5.4 Der Automodus -Bei der **Kostenoptimierung** sucht das Programm den Plan mit den niedrigsten Gesamtkosten; was dabei wie viel zählt, bestimmen Sie über die Gewichtungen (Abschnitt [7](#7-gewichtungen-anpassen)). Oft dreht man dann von Hand nach: Ein Kriterium hat noch Verstöße, also Gewicht hoch, abwarten, nächstes Kriterium. Genau das übernimmt der **Automodus**. Er verändert die Gewichte selbst und richtet sich dabei nach der Einteilung der Kriterien in die Stufen A, B und C, die Sie in der Ansicht **Qualität** festlegen (Abschnitt [6.2](#62-qualität)). Ihre eigenen Gewichtungen bleiben dabei unverändert. +Bei der **Kostenoptimierung** sucht das Programm den Plan mit den niedrigsten Gesamtkosten; was dabei wie viel zählt, bestimmen Sie über die Gewichtungen (Abschnitt [5.10](#510-gewichtungen-anpassen)). Oft dreht man dann von Hand nach: Ein Kriterium hat noch Verstöße, also Gewicht hoch, abwarten, nächstes Kriterium. Genau das übernimmt der **Automodus**. Er verändert die Gewichte selbst und richtet sich dabei nach der Einteilung der Kriterien in die Stufen A, B und C, die Sie in der Ansicht **Qualität** festlegen (Abschnitt [5.8](#58-qualität)). Ihre eigenen Gewichtungen bleiben dabei unverändert. Der Automodus arbeitet in **Phasen**; die laufende steht oben in der Ergebniskachel: @@ -289,7 +277,7 @@ Der Automodus arbeitet in **Phasen**; die laufende steht oben in der Ergebniskac 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 **Stopp** wählen. 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 [5.2](#52-die-kacheln)). **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. @@ -300,19 +288,49 @@ Der Automodus hat keine Zeitgrenze; er läuft, bis Sie **Pausieren** oder **Stop **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. -## 6. Pläne beurteilen: die Ansichten +### 5.5 Der Leitstand -Die Ansichten **Kosten, Qualität, Meldungen, Terminplan** und **Diagramme** zeigen einen Plan. Welchen, wählen Sie oben in der Ansicht unter **Plan**: den besten Plan der laufenden Generierung, den in click-TT vorhandenen Plan oder einen gemerkten Plan. So lassen sich Pläne nebeneinander vergleichen – etwa zwei Kostenansichten mit verschiedenen Plänen. +Der Leitstand zeigt, was beim Rechnen geschieht. Sie öffnen ihn mit **Details ›** in der Kachel *Berechnete Pläne*. Für die Planung selbst brauchen Sie ihn nicht; er hilft aber zu verstehen, warum es gerade vorangeht oder nicht. Das Bild bleibt ruhig und bewegt sich nur, wenn etwas geschieht. -Die Ansichten **Terminwünsche** und **Nachbarmannschaften** hängen nicht vom Plan ab, sondern nur von den Daten der Staffel. +![Leitstand bei der Kostenoptimierung](Anleitung/Bilder/20-leitstand-kosten.png) + +Von oben nach unten: + +- **Rechenkerne** (oben links): je Kern eine Säule mit seiner Last im Verhältnis zum schnellsten Kern; die volle Höhe der hellen Spur entspricht 100 %. Bei sehr vielen Kernen ist es ein Raster aus Kästchen – je kräftiger, desto mehr Last. +- **Schaubild der Maschine** (oben rechts): *Inseln → Koordinator → Bester Plan*. Gerechnet wird in den Inseln, jeweils an vielen **Suchplätzen**, die bei jedem Schritt einen Teil der Spiele neu verteilen; die Rechenkerne rechnen reihum an allen. 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. +- **Kasten unter dem besten Plan:** Bei jedem grünen Punkt erscheint bzw. aktualisiert sich ein Kasten mit der Nummer der Verbesserung – dieselbe Zahl wie in der Kachel *Verbesserungen*, z. B. „Verbesserung 57“ – und dem, was sich geändert hat. Oft wird ein Kriterium besser und ein anderes dafür etwas schlechter. Bei der Kostenoptimierung stehen dort 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*. 10 Sekunden nach der letzten Verbesserung verschwindet der Kasten. +- **Inseln:** Jede Insel sucht für sich und hat ihren eigenen besten Plan. Ihre Karte zeigt den Wert dieses Plans (Kosten, im Automodus die Verstöße A · B · C), was sie gerade tut (Tabelle unten) und einen Balken: Er zeigt, wie lange sie schon nichts Besseres gefunden hat, gemessen an der Neustart-Grenze (100 T Pläne). Darunter stehen die drei größten Kostenverursacher ihres Plans mit ihrem Anteil an den Kosten, im Automodus die Kriterien mit den meisten Verstößen (wichtigste Stufe zuerst). Die Insel mit dem besten Plan ist grün umrandet. + +| Die Insel … | Bedeutung | +|---|---| +| *kommt voran* | Sie hat in den letzten 5 Sekunden einen besseren Plan gefunden. | +| *sucht* | Sie rechnet; ihr Balken ist noch nicht weit gefüllt. | +| *findet lange nichts* | Ihr Balken ist über 60 % gefüllt. | +| *steckt fest, rechnet weiter* | Ihr Balken ist voll, ihr Plan ist aber besser als der schlechteste – sie rechnet weiter. | +| *beginnt gleich neu* | Ihr Balken ist voll, und sie hat den schlechtesten Plan aller Inseln – sie wird als nächste neu begonnen, auch wenn sie eben noch verbessert hat. Bei vielen Plänen je Sekunde ist die Grenze schnell wieder erreicht. | +| *beginnt neu* | Sie wird gerade mit einem neuen Plan begonnen (kurz grau). | -In der Regel reichen Kosten, Qualität und Diagramme, um einen Plan zu beurteilen. Die einzelnen Termine müssen Sie meist nicht durchsehen. +Neu beginnt also nur die **schlechteste** Insel, und erst wenn ihr Balken voll ist – höchstens eine je Sekunde. Bei der Kostenoptimierung kommt die **Spezial-Insel** hinzu: Sie gewichtet eine Kostenart extrem hoch und gibt gute Pläne über den Koordinator an die anderen Inseln weiter. -### 6.1 Kosten +![Leitstand im Automodus](Anleitung/Bilder/19-leitstand.png) + +Im **Automodus** steuert das Pult *Automodus* im Schaubild die Suche: Es hebt die Gewichte an und wählt den besten Plan nach A, B und C aus (Abschnitt [5.4](#54-der-automodus)). Oberhalb der Inseln steht dann, welche **Gewichte** der Automodus 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. + +### 5.6 Der Verlauf + +Die Ansicht **Verlauf** öffnen Sie mit **Details ›** in der Kachel *Verbesserungen*. Sie zeigt formatfüllend, wie sich der beste Plan seit dem Start entwickelt hat: bei der Kostenoptimierung die Kosten, im Automodus die Verstöße der Stufen A, B und C. Im Hintergrund sind die Phasen des Automodus farbig hinterlegt, kleine Striche markieren die Gewichtsanhebungen, und darunter stehen die berechneten Pläne je Sekunde. Fahren Sie mit der Maus über das Diagramm, erscheinen die Werte zu diesem Zeitpunkt. Haben Sie mit der Kostenoptimierung begonnen und dann in den Automodus gewechselt, steht unter den Verstößen zusätzlich die Kurve der Kosten: Verstöße zeichnet das Programm erst ab dem Automodus auf, die Kosten dagegen von Anfang an. Dass die Kosten im Automodus wieder steigen, ist kein Rückschritt: Er hebt Gewichte an, und mit höheren Gewichten kostet derselbe Plan mehr. + +![Verlauf](Anleitung/Bilder/21-verlauf.png) + +Am Verlauf sehen Sie, ob sich weiteres Rechnen noch lohnt: Wird die Kurve flach, findet das Programm nur noch selten etwas Besseres. + +### 5.7 Kosten ![Kostenansicht](Anleitung/Bilder/04-kostenansicht.png) -Die Kostenansicht ist das wichtigste Werkzeug während der Generierung. Das Programm bewertet jeden Plan mit **Kosten** (Strafpunkten): Jeder verletzte Wunsch, jede ungünstige Konstellation kostet etwas. Gesucht wird der Plan mit den geringsten Gesamtkosten. Was die einzelnen Kostenarten bedeuten, steht in Abschnitt [12](#12-bewertung-der-pläne-kosten). +Die Kostenansicht ist das wichtigste Werkzeug während der Generierung. Das Programm bewertet jeden Plan mit **Kosten** (Strafpunkten): Jeder verletzte Wunsch, jede ungünstige Konstellation kostet etwas. Gesucht wird der Plan mit den geringsten Gesamtkosten. Was die einzelnen Kostenarten bedeuten, steht in Abschnitt [5.13](#513-bewertung-der-pläne-kosten). + +Wie alle Ansichten, die einen Plan zeigen, wählt die Kostenansicht oben unter **Plan**, welchen: den besten Plan der laufenden Generierung, den in click-TT vorhandenen Plan oder einen gemerkten Plan. So lassen sich Pläne nebeneinander vergleichen – etwa zwei Kostenansichten mit verschiedenen Plänen. - **Kennzahlen:** Unter der Leiste stehen die Kosten, die den ganzen Plan betreffen (Überlappung und Länge der Spieltage, Überlappung und Länge des letzten Spieltags, vereinsinterne Spiele am Anfang). Die Gesamtkosten erscheinen hier nur, wenn Sie einen anderen Plan als die laufende Generierung gewählt haben – die laufende steht ja schon oben in der Kachel. - **Kostenmatrix:** Zeilen sind die Mannschaften, Spalten die Kostenarten. Beide sind nach Kosten sortiert; die teuersten stehen links oben. Je röter eine Zelle, desto größer ihr Anteil an den Gesamtkosten – dort lohnt es sich zuerst hinzuschauen. Rechts steht die Summe je Mannschaft. @@ -321,11 +339,11 @@ Die Kostenansicht ist das wichtigste Werkzeug während der Generierung. Das Prog - **Wo stecken die Kosten?** Die Karte neben der Matrix zeigt den Anteil jeder Kostenart an den Mannschaftskosten als Balken und Liste. - **Marken:** Blaue Marken wie **+1** bis **+3** (wichtiger), **−1**, **−2** (weniger wichtig) oder **X** (nicht berücksichtigt) zeigen eine geänderte Gewichtung. Ein blauer Punkt in einer Zelle bedeutet, dass genau diese Zelle eine eigene Gewichtung hat. -**Klicken zeigt Details:** Ein Klick auf eine Zelle, eine Kostenart (Spaltenkopf), eine Mannschaft (Name) oder eine Kennzahl öffnet rechts ein Detailfeld. Es zeigt Verstöße, Kosten und Anteil, die Meldungen dazu (was genau verletzt ist) und die **Gewichtung**, die Sie dort direkt ändern können (Abschnitt [7](#7-gewichtungen-anpassen)). Mit **×** schließen Sie das Detailfeld wieder. +**Klicken zeigt Details:** Ein Klick auf eine Zelle, eine Kostenart (Spaltenkopf), eine Mannschaft (Name) oder eine Kennzahl öffnet rechts ein Detailfeld. Es zeigt Verstöße, Kosten und Anteil, die Meldungen dazu (was genau verletzt ist) und die **Gewichtung**, die Sie dort direkt ändern können (Abschnitt [5.10](#510-gewichtungen-anpassen)). Mit **×** schließen Sie das Detailfeld wieder. Die aufklappbare Hilfe **So funktioniert die Tabelle** unter der Matrix fasst das alles kurz zusammen. -### 6.2 Qualität +### 5.8 Qualität ![Qualitätsansicht](Anleitung/Bilder/05-qualitaet.png) @@ -343,13 +361,237 @@ Die Kosten sagen, wie gut ein Plan *insgesamt* ist. Die Qualitätsansicht beantw Die Spalte **Kosten** erscheint nur bei der Kostenoptimierung. Rot markiert sind verletzte Pflichten der Stufe A. Die Kachel **Pflichtregeln (Stufe A)** im Kopf zeigt denselben Stand für die laufende Generierung. -### 6.3 Meldungen +### 5.9 Meldungen Die Meldungen nennen für jede Mannschaft im Klartext, was im Plan nicht passt – zum Beispiel welcher Sperrtermin belegt ist oder zwischen welchen Spielen weniger als drei Tage liegen. Jede Mannschaft hat eine eigene Karte; die Zahl oben rechts nennt die Anzahl ihrer Meldungen. Die Texte lassen sich markieren und kopieren, etwa für eine Rückfrage beim Verein. ![Meldungen](Anleitung/Bilder/16-meldungen.png) -### 6.4 Terminplan +### 5.10 Gewichtungen anpassen + +Die Gewichtungen legen fest, wie wichtig Ihnen die einzelnen Kostenarten sind. Es kommt nicht auf die Höhe der Kosten an, sondern auf ihr **Verhältnis** zueinander: Ist ein belegter Sperrtermin schlimmer als ein paralleles Spiel? Sind überlappende Spieltage wichtiger als ein Wechsel von Heim- und Auswärtsspielen? Die Standardgewichtung ist ein erprobter Mittelweg; jede Staffel ist aber anders. + +Es gibt sieben Stufen: **Nicht berücksichtigen, sehr wenig, wenig, normal, hoch, sehr hoch, extrem hoch.** + +Gewichten lässt sich auf drei Ebenen, die sich addieren: + +1. **eine Kostenart für alle Mannschaften** – z. B. Sperrtermine *hoch*; +2. **alle Kosten einer Mannschaft** – z. B. eine Mannschaft, deren Wünsche besonders wichtig sind; +3. **eine Kostenart bei einer einzelnen Mannschaft** – z. B. die Sperrtermine nur dieser einen Mannschaft. + +*Nicht berücksichtigen* auf irgendeiner Ebene schaltet die Kosten ganz ab. + +**In der Kostenansicht:** Klicken Sie auf einen Spaltenkopf (Ebene 1), einen Mannschaftsnamen (Ebene 2) oder eine Zelle (Ebene 3). Im Detailfeld rechts wählen Sie die Stufe (*Nicht berücksichtigen* heißt dort kurz **aus**); die Änderung gilt sofort und wird gespeichert. Aus einer Zelle heraus springen **Kostenart für alle …** und **Ganze Mannschaft …** zur übergeordneten Ebene. + +![Gewichtung im Detailfeld](Anleitung/Bilder/09-gewichtung.png) + +**In den Einstellungen** (links unter *Staffel*) finden Sie alle Gewichtungen auf einer Seite: die allgemeinen Plankosten, die Mannschaftskosten und die Gewichtung je Mannschaft. Unter den Mannschaftskosten stehen außerdem die Freitagsregel für die 60-km-Regel und die Regel für den Pflichtspieltag bei ungerader Mannschaftszahl (siehe [Pflichtspieltage](#pflichtspieltage)). Auch hier gilt jede Änderung sofort. **Standard wiederherstellen …** setzt alles auf die Ausgangswerte zurück. + +![Einstellungen](Anleitung/Bilder/10-einstellungen.png) + +**So gehen Sie am besten vor** + +- Ändern Sie immer nur **eine** Gewichtung und geben Sie dem Programm dann etwas Zeit, einen Plan für die neue Gewichtung zu finden. +- Jede Erhöhung hat ihren Preis: Was wichtiger wird, drängt alles andere zurück. +- Koppeltermine verschlechtern fast immer die übrigen Kriterien. Sollen möglichst viele erfüllt werden, müssen **Heimkoppel** und **Auswärtskoppel** meist höher gewichtet werden. +- Nach einer Änderung vergleicht die Kachel *Gesamtkosten* die Verbesserung ab dieser Änderung – die Kosten davor sind mit anderen Gewichten gerechnet und nicht vergleichbar. + +### 5.11 Pläne merken und vergleichen + +Um Pläne aus verschiedenen Einstellungen zu vergleichen, merken Sie sich Zwischenstände: + +1. **Plan merken** (oben) anklicken – gemerkt wird der beste Plan der laufenden Generierung. +2. Einen Namen eingeben, z. B. „Sperrtermine hoch“. + +Der Plan erscheint danach in jeder Ansicht in der Auswahl **Plan** und unter **Start mit**. Sie können ihn so ansehen, mit dem aktuellen vergleichen, als Ausgangsplan für eine neue Generierung nehmen oder entfernen: Ist er vor dem Start unter **Start mit** gewählt, steht oben an der Stelle von *Plan merken* der Knopf **Plan löschen**. + +Gemerkte Pläne liegen im Datenordner der Staffel und stehen auch nach einem Neustart zur Verfügung. + +### 5.12 Runden + +Die Art der Runde stellen Sie in den **Einstellungen** unter **Runden** ein. Beim Öffnen schlägt das Programm eine passende Einstellung vor (siehe [Staffel einrichten](#33-die-staffel-einrichten)). + +**Vor- und Rückrunde planen** ist der Normalfall und liefert die besten Ergebnisse. + +**Halbrunde planen:** Manche Jugendligen spielen zwei Halbrunden statt Vor- und Rückrunde. In diesem Fall wird zwischen zwei Mannschaften nur ein Spiel geplant. Spielt die Halbrunde in Kompaktspieltagen, gilt Kapitel [6](#6-kompaktspieltage-planen). + +**Nur Vorrunde / Nur Rückrunde planen:** In manchen Verbänden werden die Runden einzeln geplant, weil Vereine ihre Hallenzeiten für die Rückrunde noch nicht kennen. Nutzen Sie das nur aus diesem Grund – gemeinsam geplant werden die Pläne besser. Wird nur die Rückrunde geplant, berücksichtigt das Programm die in click-TT vorhandenen Spiele der Vorrunde. Auch der Export enthält dann nur die geplante Runde. + +**Doppelrunde planen:** Jede Mannschaft spielt viermal gegen jede andere, in zwei Vor- und zwei Rückrunden (Viertelrunden). Das Programm ermittelt aus den Wunschterminen, wann die erste und die dritte Viertelrunde enden sollten. Passt das nicht, schalten Sie **Enddatum der Viertelrunden automatisch ermitteln** aus und tragen die Daten selbst ein. + +**Coronarunde planen:** Plant nur die noch nicht gespielten Spiele der Rückrunde. Die Einstellung stammt aus der Saison 2020/21 und ist für Sonderfälle erhalten geblieben. + +### 5.13 Bewertung der Pläne (Kosten) + +Das Programm vergibt für ungünstige Konstellationen **Kosten**, etwa für einen belegten Sperrtermin. Gesucht wird der Plan mit den geringsten Gesamtkosten. Die absolute Höhe spielt keine Rolle, nur das Verhältnis der Kostenarten zueinander – und das bestimmen Sie über die Gewichtungen (Abschnitt [5.10](#510-gewichtungen-anpassen)). + +Damit die Nachteile halbwegs gerecht verteilt werden, steigen die Kosten je Mannschaft in der Regel **quadratisch** mit der Zahl der Verstöße: Kostet ein Verstoß 100 Punkte, kosten zwei bei derselben Mannschaft 400. Zwei Verstöße bei einer Mannschaft wiegen also schwerer als je einer bei zwei Mannschaften. + +In der Standardgewichtung haben die Kostenarten schon ein sinnvolles Verhältnis; eine belegte Halle zählt zum Beispiel mehr als ein paralleles Spiel. + +#### Kosten des ganzen Plans + +**Überlappung Spieltage** – Eine Mannschaft hat zu einem Zeitpunkt schon mehr als ein Spiel mehr absolviert als eine andere. Im Diagramm *Spieltage* sind diese Überlappungen grau hinterlegt. + +**Länge Spieltage** – Ein Spieltag soll sich über möglichst wenige Tage erstrecken. + +**Überlappung letzter Spieltag** und **Länge letzter Spieltag** – dasselbe, nur für den letzten Spieltag. Er soll möglichst kurz sein und so liegen, dass alle Mannschaften zuletzt nur noch ein Spiel haben. Anders als im Original zählen beim letzten Spieltag alle Überlappungen mit früheren Spieltagen, nicht nur die mit dem ältesten; die Kosten können deshalb etwas höher sein als im alten AndiGenerator. + +**Vereinsinterne Spiele am Anfang** – Mannschaften desselben Vereins sollen zu Beginn jeder Halbrunde gegeneinander spielen. + +#### Kosten je Mannschaft + +**Hallenbelegung** – Die maximale Zahl gleichzeitiger Heimspiele in einer Halle ist überschritten. + +**parallele Spiele** – Eine Nachbarmannschaft spielt am selben Tag. Nachbarmannschaft ist nur die direkt höhere oder niedrigere Mannschaft des Vereins: Für die 2. Herren zählen also die 1. und die 3. Herren. Die 4. zählt nur, wenn auch die 3. spielt. Sollen weitere Mannschaften nicht parallel spielen – etwa Bambini und 3. Jugend –, tragen Sie das bei den *Spielen der Nachbarmannschaften* ein. + +**synchrone Heimspiele** – Ist bei einer Nachbarmannschaft eingestellt, dass gemeinsame Heimspiele erwünscht sind, versucht das Programm, die Heimspiele beider Mannschaften auf dieselben Tage zu legen. Die Kosten entstehen, wenn das nicht gelingt. + +**Sperrtermine** – An einem Sperrtermin wird gespielt. Das Programm behandelt Sperrtermine als Wunsch, nicht als absolutes Verbot: Je mehr Sperrtermine eine Mannschaft meldet, desto weniger zählt der einzelne. Wer einen ganzen Monat sperrt, muss damit rechnen, dass nicht alle eingehalten werden; wer nur die drei Tage des örtlichen Altstadtfests sperrt, kann sich ziemlich sicher sein. + +**Ausweichtermine** – Ein Ausweichtermin wurde verplant. Besser ist ein Plan, der ohne auskommt. + +**60km Regel** – Ein Spiel, das wegen der weiten Anfahrt am Wochenende stattfinden soll, liegt unter der Woche. Ob der Freitag zum Wochenende zählt, legen Sie in den Einstellungen fest. + +**3-Tage-Abstand** – Zwischen zwei Spielen einer Mannschaft liegen weniger als drei Tage; bei nur einem Tag entsprechend teurer. + +**2-Spiele-die-Woche** – Eine Mannschaft hat zwei oder mehr Spiele in einer Woche. Koppeltermine sind ausgenommen. + +**Spielverteilung** – Die Abstände zwischen den Spielen einer Mannschaft sollen ähnlich sein. Dabei zählen die gemeldeten Wunschtermine im Zeitraum: Bei vielen möglichen Terminen soll der Abstand kürzer sein, bei wenigen länger. Ferien gelten so nicht als Spielpause. Bei großen Staffeln ergibt sich eine gute Verteilung meist von allein; wichtig ist die Kostenart vor allem bei Staffeln mit wenigen Mannschaften. + +**Heimkoppel** – Ein gewünschter Heimkoppeltermin oder Doppelspieltag wurde nicht eingeplant. Je Halbrunde wird höchstens ein Viertel der Staffelgröße als Koppeltermin vergeben: In einer Zehnerstaffel hat eine Mannschaft vier bis fünf Heimspiele je Halbrunde, möglich sind also zwei Koppeltermine. + +**Auswärtskoppel** – Ein Auswärtskoppelwunsch wurde nicht erfüllt. Je mehr solcher Wünsche eine Mannschaft meldet, desto weniger zählt der einzelne. + +**Ungleich H/A** – Die Zahl der Heimspiele in Vor- und Rückrunde ist verschieden bzw. weicht von den Vorgaben beim *Heimrecht* ab. + +**Wechsel H/A** – Auf ein Heimspiel soll ein Auswärtsspiel folgen und umgekehrt. Siehe Diagramm *Wechsel Heim/Auswärts*. + +**Abstand H/A** – Die Rückrunde soll die Gegner in ähnlicher Reihenfolge bringen wie die Vorrunde: Wer das letzte Vorrundenspiel gegen B hat, soll die Rückrunde nicht wieder mit B beginnen. Siehe Diagramm *Abstand Hin-/Rückspiel*. + +**Setzliste** – Nah beieinander gesetzte Mannschaften treffen zu früh aufeinander. + +**Pflichtspieltage** – Eine Mannschaft erreicht in einem vorgegebenen Zeitraum nicht die geforderte Zahl von Spielen. + +#### Harte Fehler + +Spiele ohne Termin, Spiele mit ungültigem Termin und Spiele an spielfreien Tagen gelten als harte Fehler und kosten je 10 Milliarden. Ein Plan mit harten Fehlern ist deshalb immer schlechter als jeder Plan ohne. In den ersten Sekunden einer Generierung sind sie normal; bleiben sie bestehen, fehlen meist Termine (siehe [Terminwünsche](#73-terminwünsche)). + +## 6. Kompaktspieltage planen + +Manche Staffeln – etwa die Jugend-Verbandsoberliga – spielen eine Halbrunde in **Kompaktspieltagen**: Drei oder vier Mannschaften treffen sich an einem Tag in einer Halle. Für solche Klassen hat AndiGenerator.NET einen eigenen Modus mit eigener Berechnung. Statt einen Plan lange zu verbessern, stellt er aus den Meldungen der Vereine gezielt Spieltage zusammen und bietet nach kurzer Zeit mehrere fertige Varianten an – mit Punkten, einer Karte der Fahrten und einem Ausdruck für die Vereine. + +### 6.1 Was sind Kompaktspieltage? + +| Form | Wer spielt | Spiele | +|---|---|---| +| **4er-Spieltag** | Der Ausrichter und eine zweite Mannschaft spielen je gegen die beiden anderen. | vier Spiele in zwei Runden, zwei Spiele parallel | +| **3er-Spieltag** | Jede gegen jede. | drei Spiele | +| **Einzelbegegnung** | Bei gerader Mannschaftszahl bleiben einzelne Begegnungen. | ein Spiel | + +Kompaktspieltage finden an allen Sonntagen der Halbrunde statt und an Samstagen, für die eine Mannschaft einen Heimtermin gemeldet hat; ein Samstag gilt dabei wie jeder andere Tag (auswärts möglich, wenn die Mannschaft dort nicht gesperrt ist). Einzelbegegnungen gehen auch unter der Woche – an einem gemeldeten Heimtermin des Gastgebers, wenn die Hallen höchstens 60 km Luftlinie auseinanderliegen (60-km-Regel). + +Jede Mannschaft soll mindestens einen 3er- oder 4er-Spieltag ausrichten; ein Einzel-Heimspiel zählt bei den Heimspieltagen mit, erfüllt diese Pflicht aber nicht. Einen 4er-Spieltag kann nur ausrichten, wer in seiner Halle zwei Spiele parallel austragen kann. + +### 6.2 Der Weg zum Plan + +1. **Meldungen in click-TT erfassen.** Die Vereine melden in den Terminwünschen von click-TT, wann sie ausrichten, auswärts spielen oder nicht können (Abschnitt [6.4](#64-meldungen-und-daten-aus-click-tt)). +2. **Staffel öffnen und einrichten.** XML-Datei, Bericht **Terminwünsche** (Pflicht, für Hallen und Entfernungen) und **Mannschaftsmeldung** (für Spitzen- und Abstiegsspieltag) herunterladen und alle drei zusammen öffnen (Abschnitte [3.1](#31-terminwünsche-aus-click-tt-herunterladen) und [3.2](#32-staffel-öffnen)) – oder die beiden letzten in der Einrichtung unter *Daten aus click-TT ergänzen* laden. Dann die Kompaktspieltage wählen (Abschnitt [6.3](#63-der-kompaktmodus)). +3. **Meldungen prüfen.** Im Raster der Ansicht **Kompaktspieltage** sehen Sie alle Meldungen auf einen Blick und ergänzen oder korrigieren sie dort direkt (Abschnitte [6.7](#67-meldungen-und-plan-karte) und [6.9](#69-meldungen-im-raster-bearbeiten)). +4. **Pläne berechnen** mit **▶** (Abschnitt [6.5](#65-pläne-berechnen)). +5. **Plan wählen.** Die Varianten vergleichen, einen Plan wählen und merken (Abschnitte [6.6](#66-pläne-und-punkte) und [6.10](#610-pläne-sichern-und-merken)). +6. **Drucken und exportieren.** Die Übersicht für die Vereine drucken, den Plan für click-TT exportieren und dort beim 4er-Spieltag die Spiele der zweiten „Heimmannschaft“ in die Halle des Ausrichters verlegen (Abschnitt [6.11](#611-drucken-und-export-für-click-tt)). + +### 6.3 Der Kompaktmodus + +Ob eine Klasse in Kompaktspieltagen geplant werden kann, erkennt das Programm beim Öffnen: Es muss eine Halbrunde sein („(HR)“ bzw. „(FR)“ im Namen oder Runde kürzer als sechs Monate), deren Spieltage überwiegend am Wochenende liegen – zwei oder drei Termine unter der Woche für einzelne Spiele stören nicht. Dann erscheint in der Einrichtung der Punkt **Klasse mit Kompaktspieltagen** mit einer Erklärung und der Wahl, ob die Staffel so geplant wird. Vorgewählt ist **Mit Kompaktspieltagen planen**; das gilt auch, wenn Sie ohne weiteren Klick **Abschließen** – für die normale Planung klicken Sie **Normal planen** an. Wer es eingeschaltet hat, wird beim nächsten Öffnen noch einmal gefragt und kann es zurücknehmen; danach lässt es sich in der Ansicht **Kompaktspieltage** aus- und einschalten. + +Die Ansicht **Kompaktspieltage** steht links oben unter *Ansichten* – nur bei Klassen, die dafür in Frage kommen. Sind die Kompaktspieltage eingeschaltet, arbeitet das Hauptfenster im **Kompaktmodus**: + +- Die Ansicht *Kompaktspieltage* ist die Hauptansicht. Sie zeigt die Pläne, das Raster „Meldungen und Plan“, die Karte, die Spieltage und die Hinweise. +- **▶** berechnet die Pläne der Kompaktspieltage statt einer Kostenoptimierung. +- *Kosten*, *Qualität* und *Meldungen* sowie die Wahl des Verfahrens entfallen; Kosten und Gewichtungen spielen keine Rolle. +- Terminplan, Diagramme, Terminwünsche, Nachbarmannschaften, Einstellungen, Druck und Export bleiben. + +### 6.4 Meldungen und Daten aus click-TT + +**Die Meldungen** kommen aus den Terminwünschen in click-TT: Ein Tag mit Uhrzeit ist ein möglicher Heimspieltag, ein Sonntag ohne Eintrag ein möglicher Auswärtsspieltag, `frei` heißt „kann nicht“. **Max. Heimspiele 2** bei einem Heimtermin heißt, dass in der Halle zwei Spiele parallel gehen – nur dann kann die Mannschaft einen 4er-Spieltag ausrichten. „An diesem Tag nur Heimspiel“ steht als Zeile `Nur Heim: 18.10., 01.11.` in den Bemerkungen des Vereins. Wie man das in click-TT erfasst, beschreibt [Kompaktspieltage erfassen](KOMPAKTSPIELTAGE.md). + +**Entfernungen** sind Luftlinie zwischen den Hallen, aus den Adressen des Berichts *Terminwünsche* (Postleitzahl und Ort bzw. GPS-Angabe). Bei Klassen für Kompaktspieltage ist dieser Bericht deshalb Pflicht: Die Einrichtung lässt sich erst abschließen, wenn er im ersten Punkt *Daten aus click-TT ergänzen* geladen ist. Die QTTR-Schnitte für Spitzen- und Abstiegsspieltag kommen aus der Mannschaftsaufstellung, die dort ebenfalls geladen wird. + +**Uhrzeiten:** Ohne Uhrzeit am Heimtermin des Ausrichters beginnt der 4er-Spieltag um 11 Uhr, der 3er-Spieltag um 10 Uhr; sonst zur eingetragenen Uhrzeit. Die Runden folgen im Abstand von drei Stunden. + +### 6.5 Pläne berechnen + +Der Knopf **▶** berechnet die Pläne. Jede mögliche Kombination aus Spieltag und Tag ist dabei eine Ja/Nein-Entscheidung, die der freie Löser **HiGHS** als ganzzahlige Optimierung löst: jede Paarung genau einmal, jede Mannschaft höchstens einmal am Tag, jede richtet mindestens einen Spieltag aus, möglichst ohne Verstöße gegen die Meldungen. So findet der Planer den kürzesten Plan und den Plan mit den wenigsten Punkten nachweisbar – auch bei knappen Meldungen. Unabhängige Schritte rechnen gleichzeitig, und jeder Schritt lässt mehrere Läufe des Lösers mit verschiedenen Startwerten parallel laufen; der erste, der den besten Plan beweist, beendet die anderen. Eine Zufallssuche liefert die Startpunkte und ergänzt am Ende weitere Varianten. Bei 9 Mannschaften (oder 10 mit je einer Einzelbegegnung) geht der Plan genau mit 4er-Spieltagen auf; Pläne mit 3er-Spieltagen erscheinen dann nur, wenn sie ein Ziel besser treffen als jeder reine 4er-Plan (*Mit 3er-Spieltagen: kürzer*, *… gleich weite Fahrten* usw., höchstens drei). + +> **Windows-Laptops mit ARM-Prozessor:** Für die ARM-Fassung des Programms (`…-win-arm64-Setup.exe`) gibt es HiGHS noch nicht. Sie plant Kompaktspieltage mit der vollständigen Suche ohne Löser – das dauert länger und findet bei knappen Meldungen nicht immer den kürzesten Plan. Wer dort Kompaktspieltage plant, installiert besser die normale Fassung (`AndiGeneratorNET-win-Setup.exe`); sie läuft auch auf ARM-Laptops und bringt HiGHS mit. + +Während der Berechnung zeigt ein Fortschrittsbalken den laufenden Schritt. Der Hauptknopf (Pause) hält sie an: Die Ansicht zeigt dann die bisher besten Pläne; Fortsetzen rechnet weiter und behält dabei nur den gewählten. **Stopp** (■) beendet die Berechnung mit den bis dahin besten Plänen. Solange die Meldungen bearbeitet werden (Abschnitt [6.9](#69-meldungen-im-raster-bearbeiten)), lässt sich die Berechnung nicht starten. + +### 6.6 Pläne und Punkte + +![Kompaktspieltage: Pläne mit Punkten und Erläuterung des gewählten Plans](Anleitung/Bilder/30-kompakt-plaene.png) + +Unter **Pläne** stehen der Plan aus click-TT, der gesicherte und die gemerkten Pläne der Staffel sowie die Varianten der letzten Berechnung – aufsteigend nach **Punkten** (weniger ist besser, die Zahl steht auf jeder Karte): + +- **Bestes Gesamtpaket** – trifft alle Ziele zusammen am besten; nach der Berechnung ist es gewählt. +- **Kürzeste Fahrten**, **Gleich weite Fahrten** (kleinster Unterschied zwischen der Mannschaft mit den meisten und der mit den wenigsten Kilometern), **Gleichmäßiger Stand**, **Gemeinsamer Spieltag** (möglichst viele Mannschaften am selben Sonntag), **Spitzen- und Abstiegsspieltag** (die vier stärksten und die vier schwächsten Mannschaften nach QTTR-Schnitt bilden je einen 4er-Spieltag, so spät wie möglich) und ein **Weiterer Plan mit anderen Spieltagen** zur Auswahl. +- **Ohne Verstöße** bzw. **… mit Einzelspielen**, wenn alle anderen Pläne gegen Meldungen verstoßen. + +Trifft das Gesamtpaket ein Ziel einer anderen Variante schon mindestens ebenso gut, entfällt diese und die Karte des Gesamtpakets nennt es unter **✓ zugleich**. + +**Die Punkte:** 10 je Verstoß gegen die Meldungen, 15 je Mannschaft ohne 3er- oder 4er-Heimspieltag (2 weitere, wenn sie gar kein Heimspiel hat), 3 je fehlendem Spitzen- oder Abstiegsspieltag (liegt er früh in der Halbrunde, ein halber Punkt), 3 je 3er-Spieltag, wenn ein reiner 4er-Plan möglich wäre, und je ein Punkt für 10 % mehr Kilometer als der kürzeste Plan (Fahrten zu Einzelbegegnungen zählen doppelt), einen Spieltag Rückstand im Stand, keinen Sonntag mit zwei 4er-Spieltagen und ungleich weite Fahrten. + +Ein Klick zeigt einen Plan und erläutert ihn darunter: wofür er steht, Fahrten (gesamt, weiteste und kürzeste Mannschaft), Stand, gemeinsamer Sonntag, Heimspieltage, Verstöße, Spitzen- und Abstiegsspieltag und seine Punkte mit den Gründen. Der gewählte Plan gilt für Terminplan, Diagramme, Druck und den Export für click-TT – ein gesicherter oder gemerkter Plan genau so, wie er gespeichert wurde (Uhrzeiten, Hallen, Heimrecht). + +### 6.7 Meldungen und Plan, Karte + +![Meldungen und Plan mit der Karte der Fahrten](Anleitung/Bilder/31-kompakt-raster-karte.png) + +**Meldungen und Plan:** je Tag eine Zeile, je Mannschaft eine Spalte (Namen senkrecht). Die Farbe zeigt die Meldung: grün Heim möglich, blau auswärts möglich, halb und halb beides, ? keine Meldung; leer heißt „kann nicht“. Darauf ★ für den Ausrichter und ● für die Gäste; ein zweiter Spieltag am selben Tag hat orange Zeichen (ein dritter violette), Einzelbegegnungen gelbe. Ein schwarzer Ring um die Zeichen markiert den Spitzenspieltag, ein blauer den Abstiegsspieltag. Rot umrandet ist, was der Meldung widerspricht. Der Tooltip eines Felds nennt Meldung, Rolle, Halle und Beginn. + +**Karte:** die Hallen auf einer Karte mit den Bundesländern und einigen Städten, gezoomt auf die Spielorte, dazu die Fahrten als Pfeile von der Halle des Gasts (grün umrandet) zur Halle des Ausrichters in der Farbe des Spieltags. Fahren zwei Mannschaften dieselbe Strecke in dieselbe Richtung, zeigt ein Pfeil aus zwei Hälften in beiden Farben beide Fahrten. Kurze Fahrten liegen hell umrandet im Vordergrund. An jedem Spielort steht der volle Mannschaftsname wie in click-TT (ab der zweiten Mannschaft mit Nummer) – keine Ortsnamen, denn in einer Stadt wie Hamburg hieße sonst jeder Ort gleich. Ist neben dem Punkt kein Platz, steht der Name etwas weiter weg und eine dünne Linie führt zum Punkt; reicht auch das nicht (etwa bei vielen Hallen dicht beieinander), steht am Punkt eine Nummer und der Name in einer Legende in einer freien Ecke der Karte. Die Karte ist so gezoomt, dass auch eine Klasse innerhalb einer Stadt groß erscheint. Ein Klick auf einen Tag, eine Mannschaft (im Raster oder in der Liste *km je Mannschaft*) oder einen Spieltag zeigt nur dessen Fahrten und hebt die passenden Zeilen, Spalten und Spieltage hervor; ein zweiter Klick zeigt wieder alle. Über einem Spielort nennt ein Hinweis Mannschaft und Halle. + +### 6.8 Spieltage und Hinweise + +![Die Spieltage mit Spielfolge und den in click-TT zu verlegenden Hallen](Anleitung/Bilder/32-kompakt-spieltage.png) + +**Spieltage:** je Spieltag Ausrichter, Spiele mit Beginn und km. Beim 4er-Spieltag steht dabei, bei welchen Spielen Sie in click-TT die Halle des Ausrichters eintragen, mit deren Adresse aus dem Bericht *Terminwünsche* zum Markieren und Kopieren (die zweite „Heimmannschaft“ ist in Wahrheit Gast). Die Spielfolge ist Ausrichter – Gast 1 und Partner – Gast 2, danach Ausrichter – Gast 2 und Gast 1 – Partner; so ist der Partner nur einmal formal Heimmannschaft. + +**Hinweise:** Verstöße, Widersprüche, Mannschaften ohne Meldung oder ohne bekannte Halle und die Bemerkungen der Vereine. + +### 6.9 Meldungen im Raster bearbeiten + +![Meldungen bearbeiten: Rahmen auf einem Feld, darunter Beginn und Halle des Heimtermins](Anleitung/Bilder/33-kompakt-bearbeiten.png) + +Mit dem Schalter **Bearbeiten** über dem Raster steht ein Rahmen auf einem Feld; Sie bewegen ihn per Klick oder mit den Pfeiltasten. **A** heißt nur auswärts, **H** nur Heim, **B** beides, **Entf** oder **Leertaste** kann nicht. Für einen Heimtermin stellen Sie darunter Beginn, Halle und „Zwei Spiele parallel“ (nötig für einen 4er-Spieltag) ein. Ein leerer Beginn bedeutet den Standard – 3er-Spieltag 10 Uhr, 4er-Spieltag 11 Uhr –, eine eingetragene Uhrzeit gilt so, wie sie ist; **Zeit löschen** setzt wieder den Standard. **Halle für alle Heimtermine der Mannschaft** macht die gewählte Halle zur Standardhalle der Mannschaft. Weitere Tage (etwa ein Samstag) legen Sie mit **Tag als Zeile anlegen** an; ihre Felder sind zunächst leer. Gespeichert wird sofort, wie in den Spielplandaten: als Heimtermin, Sperrtermin bzw. Zeile „Nur Heim: …“ in der Bemerkung. + +### 6.10 Pläne sichern und merken + +Den gewählten berechneten Plan merken Sie direkt auf seiner Karte mit **Merken …**. Gemerkte Pläne, der gesicherte Plan und der Plan aus click-TT stehen danach unter **Pläne** neben den Varianten, mit Punkten und Kennzahlen; gewählt gelten sie genau so, wie sie gespeichert wurden (Uhrzeiten, Hallen, Heimrecht). + +Wie bei normalen Runden sichert das Programm den aktuellen Plan, bevor er verworfen würde (Abschnitt [5.3](#53-die-generierung-steuern)). Im Kompaktmodus vergleicht die Rückfrage dabei die Kennzahlen der Kompaktspieltage: Verstöße gegen die Meldungen, Mannschaften ohne Heimspieltag, die Spieltage (4er, 3er, Einzel), Fahrten gesamt, den Unterschied zwischen weitester und kürzester Mannschaft, den Stand, wie viele Mannschaften höchstens am selben Sonntag spielen und – wenn die QTTR-Werte bekannt sind – Spitzen- und Abstiegsspieltag; insgesamt besser ist der Plan mit weniger Punkten, gezählt wie beim besten Gesamtpaket. Sie wählen **Aktuellen Plan sichern**, **Gesicherten Plan behalten** oder **Abbrechen**. + +### 6.11 Drucken und Export für click-TT + +Im Kompaktmodus druckt **Drucken** nur die **Übersicht für die Vereine**, immer im Hochformat und mit dem Plan, der in der Ansicht *Kompaktspieltage* gewählt ist; der Dialog nennt ihn. Die übrigen Drucke für normale Runden entfallen hier: Terminwünsche, Spielplan und Mannschaftspläne stehen schon in der Übersicht, Nachbarmannschaften spielen bei Kompaktspieltagen keine Rolle, und die Diagramme bewerten normale Runden. Gibt es noch keinen Plan, sagt der Dialog das, und es lässt sich nichts drucken. Jeder Teil beginnt auf einer neuen Seite: der Klassenname mit dem Raster „Meldungen und Plan“ samt Legende, die Karte aller Fahrten mit „km je Mannschaft“ darunter, die Spieltage (Datum, Form, Ausrichter, Halle und Begegnungen mit Beginn) und für jede Mannschaft genau eine Seite mit der **Karte ihrer Fahrten** und ihren Spieltagen (Ort, Gegner, Uhrzeiten, Halle). Hinweise für die Staffelleitung (zu verlegende Hallen, Verstöße) stehen nicht darin. Die Karten sind dieselbe Zeichnung wie in der Ansicht. + +![Druckauswahl im Kompaktmodus](Anleitung/Bilder/34-kompakt-druckauswahl.png) + +Der **Export für click-TT** prüft den Plan vorher mit den Hinweisen der Kompaktspieltage (nicht mit der Prüfung für normale Pläne) und listet sie, falls es welche gibt. Die Spiele des Ausrichters erhalten das Spiellokal seines Heimtermins an diesem Tag (sonst seine Standardhalle); Spiele der zweiten „Heimmannschaft“ bleiben ohne Spiellokal, weil click-TT nur Hallen des Heimvereins kennt – sie verlegen Sie in click-TT in die Halle des Ausrichters. Welche Spiele das sind, steht bei den Spieltagen (Abschnitt [6.8](#68-spieltage-und-hinweise)). + +### 6.12 Grenzen + +Bei wenigen Mannschaften (5 oder 6) gibt es zu wenige Spieltage, als dass jede Mannschaft einen ausrichten könnte; der Planer sagt das dann. Lässt sich kein Plan ohne Verstöße gegen die Meldungen finden, erlaubt er welche und nennt sie. + +## 7. Ansichten für beide Arten der Planung + +Die Ansichten **Terminplan** und **Diagramme** zeigen einen Plan. Bei normalen Runden wählen Sie ihn oben in der Ansicht unter **Plan**: den besten Plan der laufenden Generierung, den in click-TT vorhandenen Plan oder einen gemerkten Plan. Im Kompaktmodus gilt der Plan, der in der Ansicht *Kompaktspieltage* gewählt ist (Abschnitt [6.6](#66-pläne-und-punkte)). + +Die Ansichten **Terminwünsche** und **Nachbarmannschaften** hängen nicht vom Plan ab, sondern nur von den Daten der Staffel. + +### 7.1 Terminplan ![Terminplan](Anleitung/Bilder/06-terminplan.png) @@ -363,7 +605,7 @@ Der Terminplan zeigt die Spiele in drei Darstellungen (Umschalter oben): **Spiele ohne Termin** stehen immer am Anfang. -### 6.5 Diagramme +### 7.2 Diagramme Die Diagramme zeigen auf einen Blick, wie sich der Plan für die Mannschaften anfühlt. Oben wählen Sie das Diagramm; jedes hat eine kurze Beschreibung. @@ -383,7 +625,7 @@ Die Diagramme zeigen auf einen Blick, wie sich der Plan für die Mannschaften an **Setzliste** (nur mit aktiver Setzliste) – für jedes Spiel als Balken, wie weit die beiden Gegner in der Setzliste auseinanderliegen. Mannschaften, die nah beieinander gesetzt sind, sollen erst gegen Ende gegeneinander spielen – Erster gegen Zweiten möglichst am letzten Spieltag. Ideal liegen alle Balken unter der grauen Linie. Überbewerten sollte man das nicht: Die Setzliste ist nur eine Vermutung, und Sperrtermine oder enge Spielfolgen sind den Mannschaften meist wichtiger. -### 6.6 Terminwünsche +### 7.3 Terminwünsche ![Terminwünsche – Übersicht](Anleitung/Bilder/08-terminwuensche.png) @@ -396,55 +638,15 @@ Die Ansicht zeigt, was die Vereine gemeldet haben, auf zwei Seiten (Umschalter o Hat eine Mannschaft zu wenige Termine gemeldet, bleiben einzelne Spiele ohne Termin. Beim Export werden sie auf den ersten Tag der Runde um 00:00 Uhr gesetzt und müssen in click-TT von Hand terminiert werden. -### 6.7 Nachbarmannschaften +### 7.4 Nachbarmannschaften Die Ansicht zeigt die Spiele aus anderen Staffeln, die mit der XML-Datei eingelesen wurden: je Tag, welche Nachbar- und Vereinsmannschaften spielen. Sie dient zur Kontrolle, was click-TT übertragen hat und welche Staffeln dort schon terminiert sind. Direkte Nachbarmannschaften sind anders markiert als weitere Vereinsmannschaften. ![Termine der Nachbarmannschaften](Anleitung/Bilder/15-nachbarmannschaften.png) -## 7. Gewichtungen anpassen +## 8. Export und Druck -Die Gewichtungen legen fest, wie wichtig Ihnen die einzelnen Kostenarten sind. Es kommt nicht auf die Höhe der Kosten an, sondern auf ihr **Verhältnis** zueinander: Ist ein belegter Sperrtermin schlimmer als ein paralleles Spiel? Sind überlappende Spieltage wichtiger als ein Wechsel von Heim- und Auswärtsspielen? Die Standardgewichtung ist ein erprobter Mittelweg; jede Staffel ist aber anders. - -Es gibt sieben Stufen: **Nicht berücksichtigen, sehr wenig, wenig, normal, hoch, sehr hoch, extrem hoch.** - -Gewichten lässt sich auf drei Ebenen, die sich addieren: - -1. **eine Kostenart für alle Mannschaften** – z. B. Sperrtermine *hoch*; -2. **alle Kosten einer Mannschaft** – z. B. eine Mannschaft, deren Wünsche besonders wichtig sind; -3. **eine Kostenart bei einer einzelnen Mannschaft** – z. B. die Sperrtermine nur dieser einen Mannschaft. - -*Nicht berücksichtigen* auf irgendeiner Ebene schaltet die Kosten ganz ab. - -**In der Kostenansicht:** Klicken Sie auf einen Spaltenkopf (Ebene 1), einen Mannschaftsnamen (Ebene 2) oder eine Zelle (Ebene 3). Im Detailfeld rechts wählen Sie die Stufe (*Nicht berücksichtigen* heißt dort kurz **aus**); die Änderung gilt sofort und wird gespeichert. Aus einer Zelle heraus springen **Kostenart für alle …** und **Ganze Mannschaft …** zur übergeordneten Ebene. - -![Gewichtung im Detailfeld](Anleitung/Bilder/09-gewichtung.png) - -**In den Einstellungen** (links unter *Staffel*) finden Sie alle Gewichtungen auf einer Seite: die allgemeinen Plankosten, die Mannschaftskosten und die Gewichtung je Mannschaft. Unter den Mannschaftskosten stehen außerdem die Freitagsregel für die 60-km-Regel und die Regel für den Pflichtspieltag bei ungerader Mannschaftszahl (siehe [Pflichtspieltage](#pflichtspieltage)). Auch hier gilt jede Änderung sofort. **Standard wiederherstellen …** setzt alles auf die Ausgangswerte zurück. - -![Einstellungen](Anleitung/Bilder/10-einstellungen.png) - -**So gehen Sie am besten vor** - -- Ändern Sie immer nur **eine** Gewichtung und geben Sie dem Programm dann etwas Zeit, einen Plan für die neue Gewichtung zu finden. -- Jede Erhöhung hat ihren Preis: Was wichtiger wird, drängt alles andere zurück. -- Koppeltermine verschlechtern fast immer die übrigen Kriterien. Sollen möglichst viele erfüllt werden, müssen **Heimkoppel** und **Auswärtskoppel** meist höher gewichtet werden. -- Nach einer Änderung vergleicht die Kachel *Gesamtkosten* die Verbesserung ab dieser Änderung – die Kosten davor sind mit anderen Gewichten gerechnet und nicht vergleichbar. - -## 8. Pläne merken und vergleichen - -Um Pläne aus verschiedenen Einstellungen zu vergleichen, merken Sie sich Zwischenstände: - -1. **Plan merken** (oben) anklicken – gemerkt wird der beste Plan der laufenden Generierung. -2. Einen Namen eingeben, z. B. „Sperrtermine hoch“. - -Der Plan erscheint danach in jeder Ansicht in der Auswahl **Plan** und unter **Start mit**. Sie können ihn so ansehen, mit dem aktuellen vergleichen, als Ausgangsplan für eine neue Generierung nehmen oder entfernen: Ist er vor dem Start unter **Start mit** gewählt, steht oben an der Stelle von *Plan merken* der Knopf **Plan löschen**. - -Gemerkte Pläne liegen im Datenordner der Staffel und stehen auch nach einem Neustart zur Verfügung. - -## 9. Export und Druck - -### 9.1 Für click-TT exportieren +### 8.1 Für click-TT exportieren **Für click-TT exportieren** (linke Leiste, *Ausgabe*, farblich hervorgehoben) speichert den **gerade angezeigten Plan** als CSV-Datei, die click-TT als Spielplan importiert: den Plan, den die aktive Ansicht zeigt (laufende Generierung, click-TT-Plan oder ein gemerkter Plan), sonst den Stand der Generierung bzw. den unter *Start mit* gewählten Plan. Welcher es ist, verrät der Tooltip. Gibt es keinen Plan, ist der Eintrag grau und nicht anklickbar. Exportieren Sie den laufenden Plan, pausieren Sie die Generierung vorher, damit er sich nicht mehr ändert. @@ -454,7 +656,9 @@ Die Spiellokale werden mit exportiert. Sie müssen dafür Nummern von 1 bis 5 se Wurde nur die Vor- oder nur die Rückrunde geplant, enthält die CSV-Datei auch nur deren Spiele. -### 9.2 Drucken (PDF) +Im Kompaktmodus exportiert das Programm den in der Ansicht *Kompaktspieltage* gewählten Plan und prüft ihn mit den Hinweisen der Kompaktspieltage; wie die Spiellokale dabei gesetzt werden, steht in Abschnitt [6.11](#611-drucken-und-export-für-click-tt). + +### 8.2 Drucken (PDF) ![Druckauswahl](Anleitung/Bilder/11-druckauswahl.png) @@ -466,7 +670,9 @@ Wurde nur die Vor- oder nur die Rückrunde geplant, enthält die CSV-Datei auch Jeder Abschnitt beginnt auf einer neuen Seite; zu breite Tabellen und Diagramme werden passend verkleinert. Solange es noch keinen Plan gibt, lassen sich nur Terminwünsche und Termine der Nachbarmannschaften drucken. -## 10. Spielplandaten bearbeiten +Im Kompaktmodus gibt es nichts zu wählen: Gedruckt wird die **Übersicht für die Vereine** mit dem in der Ansicht gewählten Plan, im Hochformat (Abschnitt [6.11](#611-drucken-und-export-für-click-tt)). + +## 9. Spielplandaten bearbeiten ![Spielplandaten](Anleitung/Bilder/12-spielplandaten.png) @@ -476,6 +682,8 @@ Weil die Änderungen getrennt von der XML-Datei gespeichert werden, können Sie 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. +Bei Kompaktspieltagen erfassen Sie die Meldungen bequemer direkt im Raster der Ansicht *Kompaktspieltage* (Abschnitt [6.9](#69-meldungen-im-raster-bearbeiten)); gespeichert wird dort genauso wie hier. + ### 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. Beide Hinweise stehen direkt neben den Feldern; passt eine Eingabe nicht (z. B. leerer Name), steht das sofort rot darunter. @@ -490,6 +698,8 @@ Mit der Setzliste geben Sie die erwartete Abschlusstabelle vor. Ziel ist, dass d 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. +Ist eine **Mannschaftsmeldung** übernommen (Einrichtung, *Daten aus click-TT ergänzen*), steht neben jeder Mannschaft ihr TTR-Schnitt (**Ø**, Schnitt der besten Stammspieler). Die Werte werden mit der Staffel gespeichert – auch wenn Sie den Vorschlag nicht als Setzliste übernommen haben. **Nach TTR ordnen** stellt die Reihenfolge danach auf (stärkste Mannschaft zuerst, Mannschaften ohne Wert am Ende); danach können Sie wie gewohnt verschieben. + ![Setzliste](Anleitung/Bilder/24-setzliste.png) ### Spiellokale @@ -582,77 +792,7 @@ Zu beachten: - Soll eine Mannschaft in einer Runde nur Heim- oder nur Auswärtsspiele haben, steigen ihre Kosten für den Wechsel von Heim- und Auswärtsspielen stark an. Stellen Sie *Wechsel H/A* für diese Mannschaft dann auf **Nicht berücksichtigen**. - Ob die Vorgaben überhaupt erfüllbar sind, prüft das Programm nicht. Es ließe sich zum Beispiel eingeben, dass sowohl A gegen B als auch B gegen A das Heimrecht in der Vorrunde hat. -## 11. Runden - -Die Art der Runde stellen Sie in den **Einstellungen** unter **Runden** ein. Beim Öffnen schlägt das Programm eine passende Einstellung vor (siehe [Staffel einrichten](#33-die-staffel-einrichten)). - -**Vor- und Rückrunde planen** ist der Normalfall und liefert die besten Ergebnisse. - -**Halbrunde planen:** Manche Jugendligen spielen zwei Halbrunden statt Vor- und Rückrunde. In diesem Fall wird zwischen zwei Mannschaften nur ein Spiel geplant. - -**Nur Vorrunde / Nur Rückrunde planen:** In manchen Verbänden werden die Runden einzeln geplant, weil Vereine ihre Hallenzeiten für die Rückrunde noch nicht kennen. Nutzen Sie das nur aus diesem Grund – gemeinsam geplant werden die Pläne besser. Wird nur die Rückrunde geplant, berücksichtigt das Programm die in click-TT vorhandenen Spiele der Vorrunde. Auch der Export enthält dann nur die geplante Runde. - -**Doppelrunde planen:** Jede Mannschaft spielt viermal gegen jede andere, in zwei Vor- und zwei Rückrunden (Viertelrunden). Das Programm ermittelt aus den Wunschterminen, wann die erste und die dritte Viertelrunde enden sollten. Passt das nicht, schalten Sie **Enddatum der Viertelrunden automatisch ermitteln** aus und tragen die Daten selbst ein. - -**Coronarunde planen:** Plant nur die noch nicht gespielten Spiele der Rückrunde. Die Einstellung stammt aus der Saison 2020/21 und ist für Sonderfälle erhalten geblieben. - -## 12. Bewertung der Pläne (Kosten) - -Das Programm vergibt für ungünstige Konstellationen **Kosten**, etwa für einen belegten Sperrtermin. Gesucht wird der Plan mit den geringsten Gesamtkosten. Die absolute Höhe spielt keine Rolle, nur das Verhältnis der Kostenarten zueinander – und das bestimmen Sie über die Gewichtungen (Abschnitt [7](#7-gewichtungen-anpassen)). - -Damit die Nachteile halbwegs gerecht verteilt werden, steigen die Kosten je Mannschaft in der Regel **quadratisch** mit der Zahl der Verstöße: Kostet ein Verstoß 100 Punkte, kosten zwei bei derselben Mannschaft 400. Zwei Verstöße bei einer Mannschaft wiegen also schwerer als je einer bei zwei Mannschaften. - -In der Standardgewichtung haben die Kostenarten schon ein sinnvolles Verhältnis; eine belegte Halle zählt zum Beispiel mehr als ein paralleles Spiel. - -### Kosten des ganzen Plans - -**Überlappung Spieltage** – Eine Mannschaft hat zu einem Zeitpunkt schon mehr als ein Spiel mehr absolviert als eine andere. Im Diagramm *Spieltage* sind diese Überlappungen grau hinterlegt. - -**Länge Spieltage** – Ein Spieltag soll sich über möglichst wenige Tage erstrecken. - -**Überlappung letzter Spieltag** und **Länge letzter Spieltag** – dasselbe, nur für den letzten Spieltag. Er soll möglichst kurz sein und so liegen, dass alle Mannschaften zuletzt nur noch ein Spiel haben. Anders als im Original zählen beim letzten Spieltag alle Überlappungen mit früheren Spieltagen, nicht nur die mit dem ältesten; die Kosten können deshalb etwas höher sein als im alten AndiGenerator. - -**Vereinsinterne Spiele am Anfang** – Mannschaften desselben Vereins sollen zu Beginn jeder Halbrunde gegeneinander spielen. - -### Kosten je Mannschaft - -**Hallenbelegung** – Die maximale Zahl gleichzeitiger Heimspiele in einer Halle ist überschritten. - -**parallele Spiele** – Eine Nachbarmannschaft spielt am selben Tag. Nachbarmannschaft ist nur die direkt höhere oder niedrigere Mannschaft des Vereins: Für die 2. Herren zählen also die 1. und die 3. Herren. Die 4. zählt nur, wenn auch die 3. spielt. Sollen weitere Mannschaften nicht parallel spielen – etwa Bambini und 3. Jugend –, tragen Sie das bei den *Spielen der Nachbarmannschaften* ein. - -**synchrone Heimspiele** – Ist bei einer Nachbarmannschaft eingestellt, dass gemeinsame Heimspiele erwünscht sind, versucht das Programm, die Heimspiele beider Mannschaften auf dieselben Tage zu legen. Die Kosten entstehen, wenn das nicht gelingt. - -**Sperrtermine** – An einem Sperrtermin wird gespielt. Das Programm behandelt Sperrtermine als Wunsch, nicht als absolutes Verbot: Je mehr Sperrtermine eine Mannschaft meldet, desto weniger zählt der einzelne. Wer einen ganzen Monat sperrt, muss damit rechnen, dass nicht alle eingehalten werden; wer nur die drei Tage des örtlichen Altstadtfests sperrt, kann sich ziemlich sicher sein. - -**Ausweichtermine** – Ein Ausweichtermin wurde verplant. Besser ist ein Plan, der ohne auskommt. - -**60km Regel** – Ein Spiel, das wegen der weiten Anfahrt am Wochenende stattfinden soll, liegt unter der Woche. Ob der Freitag zum Wochenende zählt, legen Sie in den Einstellungen fest. - -**3-Tage-Abstand** – Zwischen zwei Spielen einer Mannschaft liegen weniger als drei Tage; bei nur einem Tag entsprechend teurer. - -**2-Spiele-die-Woche** – Eine Mannschaft hat zwei oder mehr Spiele in einer Woche. Koppeltermine sind ausgenommen. - -**Spielverteilung** – Die Abstände zwischen den Spielen einer Mannschaft sollen ähnlich sein. Dabei zählen die gemeldeten Wunschtermine im Zeitraum: Bei vielen möglichen Terminen soll der Abstand kürzer sein, bei wenigen länger. Ferien gelten so nicht als Spielpause. Bei großen Staffeln ergibt sich eine gute Verteilung meist von allein; wichtig ist die Kostenart vor allem bei Staffeln mit wenigen Mannschaften. - -**Heimkoppel** – Ein gewünschter Heimkoppeltermin oder Doppelspieltag wurde nicht eingeplant. Je Halbrunde wird höchstens ein Viertel der Staffelgröße als Koppeltermin vergeben: In einer Zehnerstaffel hat eine Mannschaft vier bis fünf Heimspiele je Halbrunde, möglich sind also zwei Koppeltermine. - -**Auswärtskoppel** – Ein Auswärtskoppelwunsch wurde nicht erfüllt. Je mehr solcher Wünsche eine Mannschaft meldet, desto weniger zählt der einzelne. - -**Ungleich H/A** – Die Zahl der Heimspiele in Vor- und Rückrunde ist verschieden bzw. weicht von den Vorgaben beim *Heimrecht* ab. - -**Wechsel H/A** – Auf ein Heimspiel soll ein Auswärtsspiel folgen und umgekehrt. Siehe Diagramm *Wechsel Heim/Auswärts*. - -**Abstand H/A** – Die Rückrunde soll die Gegner in ähnlicher Reihenfolge bringen wie die Vorrunde: Wer das letzte Vorrundenspiel gegen B hat, soll die Rückrunde nicht wieder mit B beginnen. Siehe Diagramm *Abstand Hin-/Rückspiel*. - -**Setzliste** – Nah beieinander gesetzte Mannschaften treffen zu früh aufeinander. - -**Pflichtspieltage** – Eine Mannschaft erreicht in einem vorgegebenen Zeitraum nicht die geforderte Zahl von Spielen. - -### Harte Fehler - -Spiele ohne Termin, Spiele mit ungültigem Termin und Spiele an spielfreien Tagen gelten als harte Fehler und kosten je 10 Milliarden. Ein Plan mit harten Fehlern ist deshalb immer schlechter als jeder Plan ohne. In den ersten Sekunden einer Generierung sind sie normal; bleiben sie bestehen, fehlen meist Termine (siehe [Terminwünsche](#66-terminwünsche)). - -## 13. Tipps und häufige Fragen +## 10. Tipps und häufige Fragen **Wie erkenne ich Spiele ohne Termin?** In der Qualitätsansicht unter A1, an sehr hohen Gesamtkosten und im Terminplan: Spiele ohne Termin stehen dort immer ganz oben. diff --git a/Doku/Anleitung.pdf b/Doku/Anleitung.pdf index c450da7..215b0cc 100644 Binary files a/Doku/Anleitung.pdf and b/Doku/Anleitung.pdf differ diff --git a/Doku/Anleitung/Bilder/03-hauptfenster.png b/Doku/Anleitung/Bilder/03-hauptfenster.png index 27266f5..ba81856 100644 Binary files a/Doku/Anleitung/Bilder/03-hauptfenster.png and b/Doku/Anleitung/Bilder/03-hauptfenster.png differ diff --git a/Doku/Anleitung/Bilder/04-kostenansicht.png b/Doku/Anleitung/Bilder/04-kostenansicht.png index d619e80..8e9ac8a 100644 Binary files a/Doku/Anleitung/Bilder/04-kostenansicht.png and b/Doku/Anleitung/Bilder/04-kostenansicht.png differ diff --git a/Doku/Anleitung/Bilder/05-qualitaet.png b/Doku/Anleitung/Bilder/05-qualitaet.png index 33e4461..ea66074 100644 Binary files a/Doku/Anleitung/Bilder/05-qualitaet.png and b/Doku/Anleitung/Bilder/05-qualitaet.png differ diff --git a/Doku/Anleitung/Bilder/06-terminplan.png b/Doku/Anleitung/Bilder/06-terminplan.png index 23245ff..3f6f078 100644 Binary files a/Doku/Anleitung/Bilder/06-terminplan.png and b/Doku/Anleitung/Bilder/06-terminplan.png differ diff --git a/Doku/Anleitung/Bilder/07-diagramm-spieltage.png b/Doku/Anleitung/Bilder/07-diagramm-spieltage.png index 6890db7..40c0189 100644 Binary files a/Doku/Anleitung/Bilder/07-diagramm-spieltage.png and b/Doku/Anleitung/Bilder/07-diagramm-spieltage.png differ diff --git a/Doku/Anleitung/Bilder/10-einstellungen.png b/Doku/Anleitung/Bilder/10-einstellungen.png index 4baf2c4..76dc75e 100644 Binary files a/Doku/Anleitung/Bilder/10-einstellungen.png and b/Doku/Anleitung/Bilder/10-einstellungen.png differ diff --git a/Doku/Anleitung/Bilder/12-spielplandaten.png b/Doku/Anleitung/Bilder/12-spielplandaten.png index d8a9947..b602ca3 100644 Binary files a/Doku/Anleitung/Bilder/12-spielplandaten.png and b/Doku/Anleitung/Bilder/12-spielplandaten.png differ diff --git a/Doku/Anleitung/Bilder/13-wunschtermin.png b/Doku/Anleitung/Bilder/13-wunschtermin.png index d2f855e..9b78eeb 100644 Binary files a/Doku/Anleitung/Bilder/13-wunschtermin.png and b/Doku/Anleitung/Bilder/13-wunschtermin.png differ diff --git a/Doku/Anleitung/Bilder/16-meldungen.png b/Doku/Anleitung/Bilder/16-meldungen.png index ac1e4b6..6026f19 100644 Binary files a/Doku/Anleitung/Bilder/16-meldungen.png and b/Doku/Anleitung/Bilder/16-meldungen.png differ diff --git a/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png b/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png index f9610e5..e3fbf26 100644 Binary files a/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png and b/Doku/Anleitung/Bilder/17-diagramm-spielverteilung.png differ diff --git a/Doku/Anleitung/Bilder/18-automodus.png b/Doku/Anleitung/Bilder/18-automodus.png index ca9b9ad..ce553c5 100644 Binary files a/Doku/Anleitung/Bilder/18-automodus.png and b/Doku/Anleitung/Bilder/18-automodus.png differ diff --git a/Doku/Anleitung/Bilder/19-leitstand.png b/Doku/Anleitung/Bilder/19-leitstand.png index 8f98c68..03f4673 100644 Binary files a/Doku/Anleitung/Bilder/19-leitstand.png and b/Doku/Anleitung/Bilder/19-leitstand.png differ diff --git a/Doku/Anleitung/Bilder/20-leitstand-kosten.png b/Doku/Anleitung/Bilder/20-leitstand-kosten.png index fb2a416..ef87087 100644 Binary files a/Doku/Anleitung/Bilder/20-leitstand-kosten.png and b/Doku/Anleitung/Bilder/20-leitstand-kosten.png differ diff --git a/Doku/Anleitung/Bilder/21-verlauf.png b/Doku/Anleitung/Bilder/21-verlauf.png index 79e3892..cf65b6f 100644 Binary files a/Doku/Anleitung/Bilder/21-verlauf.png and b/Doku/Anleitung/Bilder/21-verlauf.png differ diff --git a/Doku/Anleitung/Bilder/22-sicherung.png b/Doku/Anleitung/Bilder/22-sicherung.png index 496bf8b..c54b071 100644 Binary files a/Doku/Anleitung/Bilder/22-sicherung.png and b/Doku/Anleitung/Bilder/22-sicherung.png differ diff --git a/Doku/Anleitung/Bilder/24-setzliste.png b/Doku/Anleitung/Bilder/24-setzliste.png index 0450e6a..c2e96ff 100644 Binary files a/Doku/Anleitung/Bilder/24-setzliste.png and b/Doku/Anleitung/Bilder/24-setzliste.png differ diff --git a/Doku/Anleitung/Bilder/30-kompakt-plaene.png b/Doku/Anleitung/Bilder/30-kompakt-plaene.png new file mode 100644 index 0000000..ba0d4ad Binary files /dev/null and b/Doku/Anleitung/Bilder/30-kompakt-plaene.png differ diff --git a/Doku/Anleitung/Bilder/31-kompakt-raster-karte.png b/Doku/Anleitung/Bilder/31-kompakt-raster-karte.png new file mode 100644 index 0000000..f620ac4 Binary files /dev/null and b/Doku/Anleitung/Bilder/31-kompakt-raster-karte.png differ diff --git a/Doku/Anleitung/Bilder/32-kompakt-spieltage.png b/Doku/Anleitung/Bilder/32-kompakt-spieltage.png new file mode 100644 index 0000000..1472ad6 Binary files /dev/null and b/Doku/Anleitung/Bilder/32-kompakt-spieltage.png differ diff --git a/Doku/Anleitung/Bilder/33-kompakt-bearbeiten.png b/Doku/Anleitung/Bilder/33-kompakt-bearbeiten.png new file mode 100644 index 0000000..f83f37c Binary files /dev/null and b/Doku/Anleitung/Bilder/33-kompakt-bearbeiten.png differ diff --git a/Doku/Anleitung/Bilder/34-kompakt-druckauswahl.png b/Doku/Anleitung/Bilder/34-kompakt-druckauswahl.png new file mode 100644 index 0000000..ec13511 Binary files /dev/null and b/Doku/Anleitung/Bilder/34-kompakt-druckauswahl.png differ diff --git a/Doku/KOMPAKTSPIELTAGE.md b/Doku/KOMPAKTSPIELTAGE.md new file mode 100644 index 0000000..b70d1f3 --- /dev/null +++ b/Doku/KOMPAKTSPIELTAGE.md @@ -0,0 +1,71 @@ +# Kompaktspieltage: Meldungen in click-TT erfassen + +Anleitung für die Staffelleitung einer Staffel, die in **Kompaktspieltagen** spielt (bisher „Turniersystem“, z. B. Jugend 19 Verbandsoberliga): 3er-Spieltage (drei Mannschaften, drei Spiele) und 4er-Spieltage (vier Mannschaften in zwei Runden, zwei Spiele parallel). Statt einer eigenen Umfrage stehen die Meldungen der Vereine in den **Terminwünschen** von click-TT. Von dort kommen sie über die XML-Datei und den Bericht „Terminwünsche“ (PDF) in den AndiGenerator. + +> Stand 12.10.2026. Die Planung von Kompaktspieltagen im Programm ist in Arbeit; diese Anleitung beschreibt nur die Erfassung, damit die Daten schon richtig in click-TT stehen. + +## Auf einen Blick + +Je Mannschaft und Sonntag in der Spalte **So** der Terminwünsche: + +| Meldung des Vereins | Eintrag in click-TT | Bedeutung für die Planung | +|---|---|---| +| Heimspieltag möglich | Uhrzeit, z. B. `11:00` | Die Mannschaft kann an diesem Tag ausrichten; die Uhrzeit ist der **früheste Beginn**. Auswärts geht auch. | +| Heimspieltag nur notfalls | Uhrzeit mit Stern, z. B. `11:00*` | Ausweichtermin: wird nur genutzt, wenn es anders nicht geht. Höchstens ein Stern je Spieltag (Woche). | +| nur Heimspiel, nicht auswärts | Uhrzeit **und** Zeile `Nur Heim:` in den Bemerkungen (siehe unten) | Die Mannschaft richtet aus oder spielt an diesem Tag gar nicht. | +| Auswärtsspieltag möglich | leer lassen | Die Mannschaft kann an diesem Tag als Gast zu einem Kompaktspieltag fahren. | +| kann nicht | `frei` | Sperrtermin: an diesem Tag kein Spiel. | +| zwei Spiele parallel möglich (4er-Spieltag) | **max. Heimspiele** `2` beim Heimtermin (Schritt 4.1) | Ohne die 2 kann die Mannschaft nur einen 3er-Spieltag ausrichten. | +| Halle | Spiellokal (Schritt 1.2, je Termin in 4.1) | Für den Spielplan und die Entfernungen. | + +Die übrigen Felder der Maske (Koppelspiele, gesperrte Auswärtsspiele unter der Woche, Doppelspieltage) bleiben leer. An anderen Tagen als Sonntag wird nichts eingetragen. + +## Schritt für Schritt + +Für jede Mannschaft der Staffel die Terminwünsche öffnen und die Schritte der Maske durchgehen. + +**1. Terminwünsche Vorrunde** (bzw. **2. Rückrunde**) + +- Jede Zeile ist ein Spieltag (eine Woche). Eingetragen wird nur in der Spalte **So**. +- Format der Uhrzeit: `hh:mm`, für einen Ausweichtermin direkt dahinter ein `*` (z. B. `10:30*`). +- `frei` für Sonntage, an denen die Mannschaft nicht kann. +- Felder mit „-“ sind vom Verband gesperrt (Ranglisten, Meisterschaften, Feiertage) und lassen sich nicht füllen. +- Unter **1.2 Spiellokal** das übliche Spiellokal wählen. +- Achtung: Was ein Verein vorher selbst eingetragen hat, bleibt stehen. Alte Einträge an Werktagen (z. B. „Mo 17:45“) löschen, sonst gelten sie als Heimtermine. + +**3. Koppelspiele** – nichts eintragen. + +**4. Spiellokal und Zusatzinformationen** + +- **4.1 / 4.2:** Für jeden Heimtermin das Spiellokal prüfen und bei **max. Heimspiele** `2` eintragen, wenn in der Halle zwei Spiele gleichzeitig gehen (dann ist ein 4er-Spieltag möglich). Sonst leer lassen. +- **4.3 Gesperrte Auswärtsspiele unter der Woche:** nichts ankreuzen. +- **4.4 Doppelspieltage:** nicht ankreuzen. +- **4.4 Bemerkungen:** besondere Wünsche des Vereins als Text, dazu bei Bedarf die Zeile `Nur Heim:` (nächster Abschnitt). + +**5. Kontrolle** – Heimtermine, Sperrtermine und Bemerkungen prüfen, dann **Speichern**. + +**Danach:** Wenn alle Mannschaften erfasst sind, wie gewohnt die Terminmeldung (XML) und den Bericht **Terminmeldungen (pdf)** herunterladen und im AndiGenerator öffnen bzw. in der Einrichtung unter *Daten aus click-TT ergänzen* laden. + +## „Nur Heim“ in den Bemerkungen + +Die Maske kennt keinen Eintrag für „an diesem Tag nur Heimspiel, nicht auswärts“. Dafür gibt es eine feste Zeile in den **Bemerkungen** (Schritt 4.4), die der AndiGenerator aus dem PDF liest: + +``` +Nur Heim: 18.10., 01.11., 07.03. +``` + +- Die Zeile beginnt mit `Nur Heim:` (Groß- und Kleinschreibung egal, auch `Nur Heimspiel:` geht). +- Danach die Sonntage, durch Komma getrennt, als `TT.MM.` oder `TT.MM.JJJJ`. Ohne Jahr gilt das Jahr, in dem der Tag in der Runde liegt. +- An diesen Tagen muss **auch** eine Uhrzeit in der Spalte So stehen. Fehlt sie, meldet das Programm einen Widerspruch. +- Weitere Bemerkungen dürfen davor oder danach stehen, am besten in eigenen Zeilen. Bemerkungen gelten für die ganze Saison, Vorrunde und Rückrunde stehen also in derselben Zeile. + +Beispiel für ein vollständiges Bemerkungsfeld: + +``` +Nur Heim: 18.10., 01.11. +Bitte nicht vor 11 Uhr, wir haben morgens Training der Jüngsten. +``` + +## Woran das Programm die Kompaktspieltage erkennt + +Die XML-Datei sagt nicht, dass eine Staffel in Kompaktspieltagen spielt (auch das Bemerkungsfeld der Gruppe kommt nicht mit). Das Programm fragt deshalb beim ersten Öffnen in der Einrichtung danach und merkt sich die Antwort mit der Staffel. Es schlägt „ja“ vor, wenn alle Heimtermine an Sonntagen liegen. diff --git a/Doku/MIGRATIONSPLAN.md b/Doku/MIGRATIONSPLAN.md index 4911849..3476d19 100644 --- a/Doku/MIGRATIONSPLAN.md +++ b/Doku/MIGRATIONSPLAN.md @@ -659,7 +659,7 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa - *1. Fahrtstrecken* möglichst kurz (Hallen brauchen Postleitzahl bzw. Adresse; zuerst Luftlinie, später Routenplaner). - *2. Gleichmäßiger Stand:* zu jedem Zeitpunkt haben alle Mannschaften etwa gleich viele Spiele; keine ist fast fertig, während eine andere kaum angefangen hat. - *3. Gemeinsamer Spieltag:* mindestens ein Tag, an dem möglichst alle Mannschaften spielen. - - *Abwägung im Programm statt fester Gewichte* (Peter, 12.10.2026): Der Planer legt mehrere gute Varianten vor (kürzeste Strecke, gleichmäßigster Stand, mit gemeinsamem Spieltag und Kompromisse dazwischen); Karte mit Hallen und Fahrten je Variante, km je Mannschaft. Fahrtwünsche vorgeben („Sinzig spielt dieses Jahr in Trier“), einzelne Turniere festhalten und den Rest neu rechnen. + - *Abwägung im Programm statt fester Gewichte* (Peter, 12.10.2026): Der Planer legt mehrere gute Varianten vor (kürzeste Strecke, gleichmäßigster Stand, mit gemeinsamem Spieltag und Kompromisse dazwischen); Karte mit Hallen und Fahrten je Variante, km je Mannschaft. Einzelne Turniere festhalten und den Rest neu rechnen (13.10.2026 gestrichen: es wird das Optimum gesucht, nicht iteriert). - *Versuch 12.10.2026* (Luftlinie × 1,3, Plan 2026): Peters Plan ca. 5.570 km, kürzester gefundener Plan ca. 4.160 km – aber dauerhaft ein Turnier Abstand im Stand und kein gemeinsamer Spieltag. Die Kriterien ziehen gegeneinander. - *Vergleich Bericht „Terminwünsche“ (PDF) mit der click-TT-XML* (Verbandsoberliga, 12.10.2026): Heimtermine mit Uhrzeit, Halle, max. Heimspiele (`maxParallelGames`), Heimkoppel (unbedingt/möglich = `coupleGame` hard/soft), Sperrtermine, Auswärtskoppeln mit Übernachtung (`onSameDay`), 60-km-Liste (`noWeekGame`) und Doppelspieltage (`noDoubleDays`) stehen auch in der XML. **Nur im PDF:** Adressen der Spiellokale 1–3, Bemerkung des Vereins (Freitext-Wünsche, bei 4 von 12 Mannschaften), Ball. Eine Abweichung: Bei SV Windhagen stehen Sperrtermine der Rückrunde nur in der (älteren) XML. - *Region und Name* (Peter, 12.10.2026): Das Programm wird deutschlandweit eingesetzt – Karte und Entfernungen ohne Annahmen über eine Region, nur aus den Planungsdateien (Adressen der Spiellokale); Einschränkung höchstens auf Deutschland. Entfernungen sind für die Planung nur im Turniermodus nötig (der noch einen besseren Namen braucht); für normale Staffeln dienen sie der **Prüfung der 60-km-Regel** (nächster Schritt). @@ -668,8 +668,20 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa - *Änderungsprotokoll von click-TT* (geprüft 12.10.2026, **verworfen**): nur per Kopieren/Bildschirmfoto; Verlegungen auf Sperrterminen sind ohnehin klar, die übrigen haben kurzfristige Gründe (Hallenbelegung, Spieler verhindert, Nominierungen) – keine Information für die nächste Saison (Peter). - *Hallenadressen:* aus dem click-TT-Bericht „Terminwünsche“ (PDF, nu.Dokument 019; Zuordnung über die Vereinsnummer, Halle aus der Umfrage). Nebennutzen für normale Staffeln: Entfernungen könnten die 60-km-Regel vorschlagen. - *Prüfung der 60-km-Regel* (umgesetzt 12.10.2026): Die Wettspielordnung des RTTVR kennt **kein** Maß; die Liste „gesperrte Auswärtsspiele unter der Woche“ (`noWeekGame`) ist ein Vereinswunsch (§ 5.2: Sa/So verbindlich, je nach Ebene auch Freitag bzw. Mo–Fr 19:30–20:30). In der Verbandsoberliga sperren 5 von 12 Mannschaften pauschal alle Gegner. Deshalb nur **Hinweise**: Luftlinie zwischen den Hallen (Postleitzahl und Ort → Koordinaten aus GeoNames, CC BY 4.0, eingebettet als `plz-de.csv.gz`, erzeugt mit `tools/geonames/plz_tabelle.py`; deutschlandweit, ohne Netz), Grenze einstellbar (Vorgabe 60 km), geprüft nur bei Gastgebern mit Heimterminen unter der Woche **und** am Wochenende (Peter, 12.10.2026: nur dann entscheidet die Sperre; unter der Woche = Mo–Do, Freitag nur, wenn er laut Einstellung `friday-is-part-of-weekend` nicht zum Wochenende zählt; `Datenbearbeitung.FreitagZaehltZumWochenende` aus den Optionen der Sitzung). Ohne solche Gastgeber (z. B. Verbandsoberliga: nur Sa/So) sagt die Seite ausdrücklich, dass es nichts zu prüfen gibt. **Fehlende Sperren setzen…** (Peter, 12.10.2026): eine Rückfrage für alle Treffer, setzt nur fehlende (`WochenspielSperrenSetzen`), Protokoll auf der Seite (Zeitpunkt, Grenze, je Sperre Gast/Gastgeber/km); „Standard“ nimmt sie zurück (`WochenspielSperrenEntfernen`). Unnötige Sperren werden nicht automatisch entfernt. Adresszeilen mit Anhang nach „PLZ Ort“ (SV Windhagen: „…, 53578 Windhagen, Fax: GPS:50.63599°N 7.35227 °E“) wurden zunächst nicht erkannt; jetzt zählt der letzte Teil „PLZ Ort“, eine GPS-Angabe wird als genauere Lage übernommen (`Spiellokaladresse.Lage`). „Sperre fehlt vermutlich“ (weiter, keine Sperre) und „Sperre vermutlich unnötig“ (Sperre, höchstens so weit); wer alle Gegner sperrt, wird nicht bewertet. Anzeige auf der Einrichtungsseite „Daten aus click-TT“ und je Gegner auf der Seite „Wochenende (60-km-Regel)“ (Entfernung und Marke „weit“/„nah“). Spiellokal der Mannschaft: Nummer an der Mannschaft, sonst die häufigste der Heimtermine, sonst 1. + - *Erfassung in click-TT statt Umfrage* (Peter, 12.10.2026): Peter erfasst die Meldungen selbst in den Terminwünschen der Mannschaften (Anleitung `Doku/KOMPAKTSPIELTAGE.md`): Sonntag mit Uhrzeit = Heim möglich (früheste Beginnzeit), mit `*` = Ausweichtermin, leer = auswärts möglich, `frei` = Sperrtermin, max. Heimspiele 2 = 4er-Spieltag möglich. „Nur Heim“ über eine Zeile `Nur Heim: TT.MM., …` in den Bemerkungen (aus dem PDF). Das Bemerkungsfeld der Gruppe kommt nicht in die XML; erkannt wird der Modus deshalb über eine Frage in der Einrichtung (Vorschlag „ja“, wenn alle Heimtermine sonntags liegen). Der CSV-Import der Umfrage entfällt damit (bleibt nur für die Prüfung mit `andigen turnier pruefen`). + - *Entscheidungen zur Oberfläche* (Peter, 12.10.2026): + - **Einschalten:** Nur bei einer Halbrunde (Voraussetzung) bekommt die Einrichtung eine zusätzliche Seite **„Klasse mit Kompaktspieltagen“**: Erklärung, was Kompaktspieltage sind, und ein Schalter. Die Seite steht nach der Rundenplanung (dort wird die Halbrunde gewählt) bzw. vorn, wenn die Halbrunde schon eingestellt ist. Wer sie irrtümlich eingeschaltet hat, bekommt sie beim nächsten Öffnen noch einmal zum Zurücknehmen; danach liegt der Schalter in den Einstellungen. + - **Erkennung** (Peter, 12.10.2026): An den Stammdaten der XML ist eine Halbrunde nicht zu erkennen (die Jugend-VOL läuft von August bis Mai). Der Verband kennzeichnet solche Klassen im Namen mit „(HR)“ (Hinrunde/Herbstrunde) bzw. „(FR)“ (Finalrunde/Frühlingsrunde) – `Halbrundenname.Kennzeichen`. Für sie ist die Rundenplanung ohne Rückfrage fest: „(HR)“ → `NurVorrunde`, „(FR)“ → `NurRueckrunde` (`Plansitzung.VorgegebeneRundenplanung`, gilt auch für gespeicherte und Standardoptionen; keine Meldung als abweichende Einstellung). Gefragt wird nach Kompaktspieltagen, wenn die Termine (Terminwünsche und Plan) überwiegend am Wochenende liegen: mindestens doppelt so viele Wochenendtage wie Werktage, bis zu drei Werktage für einzelne Spiele stören nicht (`Kompaktplanung.InFrage`, `WochenendeUeberwiegt`). Offen: Planen einer „(FR)“-Klasse mit `NurRueckrunde` ohne Spiele der Vorrunde ist noch nicht erprobt. + - **Entfernungen:** Luftlinie genügt; in Oberfläche und Anleitung deutlich sagen, dass nach Luftlinie optimiert wird. + - **Karte:** Umriss von Deutschland und den Bundesländern, wenige wichtige Städte, die Spielorte eingezeichnet; der Ausschnitt zoomt auf den Bereich, in dem alle Spielorte liegen. Ohne Internet (Umrisse und Städte eingebettet). + - **click-TT beim 4er-Spieltag:** Peter trägt jedes Spiel normal ein (linke Mannschaft = Heimmannschaft) und verlegt beim Spiel der zweiten „Heimmannschaft“ die Halle, mit dem Text der Halle des Ausrichters. Das Programm exportiert entsprechend und listet die Spiele, deren Halle zu verlegen ist. + - **Einzelbegegnungen** (Peter, 12.10.2026): so wählen, dass die Fahrten nicht zu weit sind; die Heimmannschaft so, dass die Zahl der echten Heimspiele über alle Mannschaften gleichmäßig verteilt ist. - *Einbindung:* Eingabe in den Spielplandaten (CSV-Import der Umfrage auf Sperrtermine und Heimspieltermine), eigener Turnierplaner statt des Optimierers, Ergebnis als normaler Plan (Ansichten, Druck, click-TT-CSV). -- [ ] **Oberfläche:** eigene Seite, sobald Prüfen und Planen tragen. +- [x] **Oberfläche, Teil 1** (12.10.2026): Punkt „Klasse mit Kompaktspieltagen“ in der Einrichtung (nur bei Halbrunde; Attribut `kompaktspieltage` am Knoten `plan`: `ein`, `bestaetigt`, `aus`), Ansicht „Kompaktspieltage“ (`KompaktspieltageAnsichtViewModel`, `Kompaktplanung.Auswerten`): Meldungen aus der XML (Sonntag mit Heimtermin = Heim, ohne = auswärts, Sperrtermin = kann nicht, max. Heimspiele ≥ 2 = 4er möglich, „Nur Heim:“ aus der Bemerkung), Prüfung mit `Turnierpruefung`, Karte (`Kartenbau`, `Kompaktkarte`; Umrisse Natural Earth, `tools/karte/umrisse.py`), km in Luftlinie hin und zurück. +- [x] **Planen in der Oberfläche** (12.10.2026): `Kompaktplaner` (Engine) – randomisierte Tiefensuche über alle möglichen Spieltage (Dreier, Vierer, bei gerader Zahl Einzel; je Sonntag), Paarung mit den wenigsten Möglichkeiten zuerst, die ersten vier nach km mit Zufallsstreuung; jede Mannschaft höchstens einmal am Tag, mindestens einen Spieltag ausrichten (rechnerische Untergrenze bei 5/6 Mannschaften), höchstens eine Einzelbegegnung. Ohne Lösung schrittweise Verstöße, erst danach Mannschaften ohne Heimspieltag. Danach Sonntage verlegen (Stand, gemeinsamer Spieltag) und Heimrecht der Einzel tauschen (echte Heimspiele gleichmäßig); Pareto-Front über km, Stand, gemeinsamer Tag, Heimspiele; Varianten ausgewogen (10 % km ≈ ein Spieltag Rückstand ≈ ein fehlender gemeinsamer Tag), kürzeste Fahrten, gleichmäßiger Stand, gemeinsamer Spieltag. Prototyp in Python mit dem Plan 2026: ca. 2.900–3.450 km statt 4.040 km (Luftlinie, hin und zurück), Stand bis 1 Spieltag, alle 9 an einem Tag möglich. Festhalten (`kompaktfest`) und die anfangs vorgesehenen Fahrtwünsche hat Peter am 13.10.2026 gestrichen. Kompaktmodus des Hauptfensters (Peter, 12.10.2026): Kompaktansicht als Hauptansicht oben in der Navigation, ohne Kosten/Qualität/Meldungen und Verfahren, eigene Kacheln, ▶ berechnet Varianten; Ansicht nur, wenn die Klasse in Frage kommt. +- [x] **Vollständige Suche nur aus 4er-Spieltagen** (Peter, 13.10.2026: gültig sind in der Jugend-VOL nur reine 4er-Pläne; 3er nur zeigen, wenn kürzer): `Viererbaum` – Branch and Bound bei 9 bzw. 10 Mannschaften (jede richtet genau einen 4er aus), nur Spieltage ohne Verstoß mit möglichem Sonntag, Verzweigung über die engste Paarung, kürzeste zuerst, Schranke max(je Gast die nächsten freien Gastgeber, je Gastgeber die nächsten Gäste), Sonntage erst am vollständigen Plan; erste Ebene parallel. Abschätzung (Knuth-Stichproben, Python, Daten 2026): ohne Schranke ca. 8·10⁸ Knoten, mit Schranke ab einem Plan von 3.108 km ca. 10⁶. Danach zufällige Suche für Varianten und kürzere 3er-Pläne. +- [ ] **Offen:** Zeitwünsche aus den Bemerkungen, Routenplaner statt Luftlinie. *(Erledigt: Einzelbegegnungen unter der Woche bis 60 km; Druck als Übersicht für die Vereine, 14.10.2026.)* +- [ ] **Später, erst auf Nachfrage – einstellbare Gewichte der Kompaktplanung** (Peter, 14.10.2026): Die Punkte stehen fest im Code (10 je Verstoß gegen die Meldungen, 15 je Mannschaft ohne 3er-/4er-Heimspieltag, 2 ganz ohne Heimspiel, 3 je Dreier, wenn ein reiner 4er-Plan möglich ist, 3 je fehlendem Spitzen- bzw. Abstiegsspieltag (+0,5 früh), km-Punkte 10 · km/Bezug, Einzel-km doppelt; `Kompaktgesamt`, `Kompaktpunkte`). Solange Peter der einzige Nutzer ist, reicht das. Für andere Staffelleiter wären einstellbare Gewichte wie im normalen Modus konsequent – umsetzen erst, wenn jemand danach fragt. ## Änderungsprotokoll | Datum | Änderung | @@ -843,3 +855,7 @@ Befund: Billigere Pläne nach der Kostenfunktion des Originals sind nicht automa | 12.10.2026 | Einrichtung: erster Punkt „Daten aus click-TT ergänzen“ (Mannschaftsmeldung → Vorschlag für die Setzliste; Vermerk `mannschaftsmeldung` am Knoten `plan`, danach als erledigt markiert); **Abbrechen** schließt die Staffel; Öffnen und Neu sind während der Einrichtung gesperrt (ersetzt die Regel vom 30.09.2026) | | 12.10.2026 | Bericht „Terminwünsche“ (PDF) in der Einrichtung: neuer Baustein `AndiGenerator.Import` mit PdfPig (Apache-2.0, E11), Auswertung der Textzeilen in `Persistence.Terminwuensche`; setzt Spiellokal-Nummern aus dem Hallennamen, merkt Adressen (`spiellokal1`–`3`) und Bemerkung (`bemerkung`) am Knoten `team`, zeigt Unterschiede. Prüfung an 8 echten Berichten: Termine, Hallen, max. Heimspiele, Heimkoppel und Sperren stimmen mit der XML überein | | 12.10.2026 | Prüfung der 60-km-Regel (`Datenbearbeitung.WochenspielePruefen`, `Hallenentfernungen`; `Koordinate`, `Postleitzahlen` mit GeoNames-Tabelle): Hinweise auf der Seite „Daten aus click-TT“, Entfernungen und Marken auf der Seite „Wochenende (60-km-Regel)“ (siehe Abschnitt 12). Turniermodus heißt in Oberfläche und Anleitung **Kompaktspieltage**. | +| 12.10.2026 | Setzliste mit TTR-Werten: `Datenbearbeitung.MannschaftsstaerkenSpeichern`/`Mannschaftsstaerken` (Attribute `ttrschnitt` und `stammspieler` am Knoten `team`, nicht im Original), Anzeige „Ø“ je Zeile und Befehl „Nach TTR ordnen“. Leitstand: Säulen der Rechenkerne als `Lastsaeule` über die volle Höhe mit Spur für 100 %. | +| 12.10.2026 | Kompaktspieltage, Oberfläche Teil 1: Einrichtungspunkt „Klasse mit Kompaktspieltagen“, Ansicht mit Raster, Karte (Bundesländer aus Natural Earth, Städte aus der Postleitzahlen-Tabelle), Kennzahlen, Spieltagen und Hinweisen; Meldungen aus den Terminwünschen der XML. | +| 12.10.2026 | Halbrunden am Klassennamen: „(HR)“ → nur Hinrunde, „(FR)“ → nur Rückrunde, ohne Rückfrage; Frage nach Kompaktspieltagen nur bei überwiegend Spieltagen am Wochenende. | +| 12.10.2026 | Kompaktplaner und Kompaktmodus: Varianten, Kacheln; Kosten/Qualität/Meldungen und Verfahren im Kompaktmodus ausgeblendet. | diff --git a/Doku/PROJEKTWISSEN.md b/Doku/PROJEKTWISSEN.md new file mode 100644 index 0000000..dc3027b --- /dev/null +++ b/Doku/PROJEKTWISSEN.md @@ -0,0 +1,86 @@ +# Projektwissen für die Arbeit mit Claude + +Ergänzt `CLAUDE.md` (Regeln) um das, was sich in der Zusammenarbeit zwischen Peter und Claude angesammelt hat: wie wir +arbeiten, was Peter wichtig ist, wie die Kompaktspieltage gebaut sind und was offen ist. Stand: 14.10.2026 (Version 0.9.8). +Entscheidungen mit Begründung stehen ausführlich im [MIGRATIONSPLAN](MIGRATIONSPLAN.md); Änderungen für Anwender im +[CHANGELOG](../CHANGELOG.md). + +## Arbeitsweise + +- **Ablauf je Änderung:** verstehen → umsetzen (mit Tests) → bauen und testen → Peter prüft in der Oberfläche + (oft mit Bildschirmfoto) → nachbessern → CHANGELOG/Anleitung → Commit nach grünem Build. +- **Rückmeldungen** kommen kurz, oft mit Bildschirmfoto. Erst die Ursache benennen, dann die Lösung; nichts als + „funktioniert“ melden, was nicht gebaut und – wo möglich – geprüft ist. +- **Antworten** auf Deutsch, knapp; bei Fragen zur Gestaltung lieber einmal kurz nachfragen als groß umbauen. +- **Branches:** Gearbeitet wird auf Feature-Branches (derzeit `feature/setzliste-qttr`); Peter führt sie über Pull + Requests auf GitHub nach `main` zusammen und pusht selbst. +- **Werkzeuge:** `bauen.cmd` (Build + Tests, Protokoll `build-log.txt`), `bildschirmfotos.cmd` (Bilder der Anleitung, + Protokoll `bildschirmfotos-log.txt`), `anleitung.cmd` (PDF der Anleitung), `starten.cmd` (Programm starten), `abdeckung.cmd` (Testabdeckung). +- **Langsame/zeitabhängige Tests:** HiGHS- und Kompaktplaner-Tests (`KompaktplanerTests`, Sammlung `MessungenSammlung`) + laufen seriell und hängen von der Rechnerlast ab. Zeitgrenzen nur erhöhen, wenn die Suche dann von selbst früher endet + (vollständige 4er-Suche ja; HiGHS-Schritte mit Ausschluss-Schnitten nein – die rechnen ihren ganzen Zeitanteil). + Lieber die Zusicherung robust machen. + +## Was Peter wichtig ist (gelernte Vorlieben) + +- Oberfläche ruhig und verständlich; Hinweise erst zeigen, wenn sie etwas bedeuten (z. B. keine Hinweise ohne Plan). +- Karten: gerade Linien (keine Bögen), Pfeilspitzen am Rand des Ziels, Ausgangshalle grün, Ziel in Linienfarbe, + zweifarbige Pfeile aus zwei Hälften (nicht dicker als ein einfacher Pfeil), kurze Fahrten im Vordergrund mit hellem + Rand, Namen nie abgeschnitten, Maßstab nur „N km“. Beschriftung im Kompaktmodus überall der volle Mannschaftsname + (keine Orts- oder Kurznamen – in Hamburg hieße jeder Ort „Hamburg“); auf der Karte notfalls weiter weg mit + Hinweislinie, sonst Nummer am Punkt und Name in einer Legende. Lage der Hallen nur aus PLZ (bzw. GPS): in Städten + fallen Hallen mit gleicher PLZ zusammen – offen: Punkte je Halle, Fahrten zur tatsächlichen Halle, genauere Lage. Die Feinheiten (Abstände in Pixeln) stehen als Kommentare im + `Kartenzeichner` – nicht ohne Rückfrage ändern. +- Raster: ★ Ausrichter, ● Gast, zentriert; Ring schwarz = Spitzenspieltag, blau = Abstiegsspieltag; Einzelbegegnung gelb. +- Druck für die Vereine: nur, was Vereine brauchen (keine Hinweise für die Staffelleitung wie „Halle verlegen“ oder + Verstöße); jede Mannschaft genau eine Seite; großzügige Abstände. +- Gewichte der Kompaktplanung bleiben vorerst fest im Code (einstellbar erst auf Nachfrage, siehe MIGRATIONSPLAN 12). +- Signierung darf nichts kosten. + +## Kompaktspieltage (Jugend, Halbrunde) – Kurzüberblick + +- **Formen:** 4er-Spieltag (Ausrichter + Partner je gegen die zwei Gäste, 4 Spiele in 2 Runden), 3er-Spieltag (jeder + gegen jeden), Einzelbegegnung (bei gerader Mannschaftszahl; unter der Woche bis 60 km). +- **Planung:** `Kompaktplaner`/`Kompaktlauf` (Engine). Gezielte Konstruktion als ganzzahlige Optimierung mit HiGHS + (`Kompaktoptimierung`, `Ganzzahlmodell`); Portfolio paralleler Läufe mit verschiedenen Startwerten, der erste Beweis + beendet die anderen; Zufallssuche liefert Startpunkte. Ohne HiGHS: vollständige 4er-Suche (`Viererbaum`) und Zufallssuche. +- **Punkte** (`Kompaktgesamt`, `Kompaktpunkte`): 10 je Verstoß, 15 je Mannschaft ohne 3er/4er-Heimspieltag (+2 ohne + jedes Heimspiel), 3 je Dreier wenn reiner 4er-Plan möglich, 3 je fehlendem Spitzen-/Abstiegsspieltag (+0,5 früh), + km-Punkte 10 · km/Bezug (Einzel-km doppelt), Stand, gemeinsamer Spieltag, gleich weite Fahrten. +- **Meldungen im Raster** (`Datenbearbeitung.Kompaktmeldungen`): Heim/Auswärts/Beides/Kann nicht → Heimtermin, + Sperrtermin, Zeile „Nur Heim: …“ in der Bemerkung; Uhrzeit 00:00 = Standard (3er 10 Uhr, 4er 11 Uhr). +- **Gespeicherte Pläne** speichern das Spiellokal (Attribut `location`, nur wenn vorhanden); daran ist der Ausrichter + beim 4er-Spieltag wieder erkennbar. +- **Export für click-TT:** Prüfung mit den Kompakt-Hinweisen; Spiellokal nur bei Spielen des Ausrichters + (`Spiellokale.AmTag`); die Spiele der zweiten „Heimmannschaft“ verlegt die Staffelleitung in click-TT. +- **Druck** (`Kompaktdruck`): Raster mit Legende → Karte aller Fahrten mit km je Mannschaft → Spieltage → je Mannschaft + eine Seite mit Karte ihrer Fahrten. +- **Testdaten:** echte Klassen nur in `Referenz/jugend` (privat). Öffentlich die erfundene Klasse + `testdaten/kompakt/Jugend_19_Testliga_(HR).xml` (`python3 tools/kompaktklasse/erzeugen.py`). + +## Anleitung + +- Quelle `Doku/ANLEITUNG.md` (Kapitel 1–10; gegliedert nach den zwei Arten der Planung: 3.5 Gegenüberstellung, + 5 normale Runden, 6 Kompaktspieltage, 7–9 gemeinsam; Druck in 8.2), Bilder `Doku/Anleitung/Bilder` + (01–34), PDF `Doku/Anleitung.pdf` (wird mit dem Programm ausgeliefert). +- Bilder neu: `bildschirmfotos.cmd` (Test `tests/AndiGenerator.UI.Tests/Bildschirmfotos.cs`; neue Bilder dort ergänzen). +- PDF neu: unter Windows `anleitung.cmd` (portables pandoc, Playwright für .NET mit Edge; Protokoll + `anleitung-log.txt`), unter Linux pandoc + Node/Playwright – siehe `tools/anleitung/README.md`. Auf Peters Rechner + gibt es weder pandoc noch Node noch WSL, deshalb der Windows-Weg. +- Nach Änderungen an der Oberfläche: betroffene Bilder neu erzeugen und ansehen (die übrigen zurücksetzen, weil die + rechnenden Ansichten von Lauf zu Lauf leicht abweichen). Das PDF erst vor einem Pull Request neu erzeugen. + +## Offene Punkte (Stand 14.10.2026) + +- Karte, zweiter Schritt: Punkte je Halle statt je Mannschaft, Fahrten zur tatsächlichen Halle des Spieltags (auch km), + Hinweis bzw. genauere Lage, wenn Hallen mit gleicher PLZ zusammenfallen (Hamburg: Ritterstraße/Richardstraße). +- Kompaktspieltage: vor der Berechnung sagen, dass es ohne Verstöße nicht geht und warum (Mannschaften ohne Meldung + bzw. ohne Heimtermin) – vorgeschlagen, noch nicht entschieden. Fortschritt steht bei großen Modellen noch 20–40 s still, + bis HiGHS sich meldet. +- Gewichte der Kompaktplanung einstellbar – erst auf Nachfrage (MIGRATIONSPLAN 12). +- Idee: 6er-Spieltage (vier Mannschaften, jeder gegen jeden) bei gerader Mannschaftszahl (10/12) statt vieler + Einzelbegegnungen – vorher klären, ob die Wettspielordnung drei Spiele am Tag erlaubt. +- Kompaktspieltage: Zeitwünsche aus den Bemerkungen, Routenplaner statt Luftlinie. +- Testabdeckung der Presentation-Schicht (Bearbeiten im Raster) könnte höher sein. +- Optional: Im Wunschtermin-Dialog „Standard“ statt 00:00 anzeigen. +- Optional: Langsame Planer-Tests als eigene Kategorie, damit `bauen.cmd` schneller ist. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 381e8b0..36e75d8 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -6,6 +6,8 @@ Ausnahme Schriften: Die SIL Open Font License (OFL 1.1) erlaubt das Einbetten un Ausnahme Daten: Die Postleitzahlen-Tabelle `src/AndiGenerator.Persistence/Geo/plz-de.csv.gz` (Postleitzahl, Ort, Breite, Länge) ist aus `DE.zip` von **GeoNames** (https://www.geonames.org, Postleitzahlen-Export https://download.geonames.org/export/zip/) abgeleitet: auf diese vier Spalten gekürzt und komprimiert (`tools/geonames/plz_tabelle.py`). Die Daten stehen unter **Creative Commons Attribution 4.0** (CC BY 4.0, https://creativecommons.org/licenses/by/4.0/); Namensnennung: „Postleitzahlen-Daten: GeoNames (geonames.org), CC BY 4.0, gekürzt“. Sie dienen nur der Entfernung in Luftlinie zwischen Hallen (Prüfung der 60-km-Regel) und sind keine Programmbibliothek. +Die Umrisse der Bundesländer `src/AndiGenerator.Persistence/Geo/umrisse-de.json` stammen aus **Natural Earth** (https://www.naturalearthdata.com, „Admin 1 – States, Provinces“, Maßstab 1:10 Mio.), vereinfacht mit `tools/karte/umrisse.py`. Natural Earth ist gemeinfrei (public domain); eine Namensnennung ist nicht nötig, steht aber in der Ansicht „Kompaktspieltage“. Die Lage der Städte in `staedte-de.json` ist aus der GeoNames-Tabelle oben abgeleitet. + | Paket | Lizenz | Verwendung | |---|---|---| | .NET 10 Runtime/BCL | MIT | Laufzeit | @@ -14,6 +16,7 @@ Ausnahme Daten: Die Postleitzahlen-Tabelle `src/AndiGenerator.Persistence/Geo/pl | Dock.Avalonia, Dock.Avalonia.Themes.Fluent, Dock.Model.Mvvm | MIT | Andock-Layout der Ansichten (E15) | | CommunityToolkit.Mvvm | MIT | ViewModels (E3) | | PdfPig (UglyToad.PdfPig) | Apache-2.0 | Bericht „Terminwünsche“ aus click-TT lesen (nur im Baustein `AndiGenerator.Import`) | +| HiGHS (Paket Highs.Native, © die HiGHS-Entwickler, University of Edinburgh) | MIT | Löser für die gezielte Konstruktion der Kompaktspieltage (ganzzahlige Optimierung, nur die native Bibliothek über die C-Schnittstelle, im Baustein `AndiGenerator.Import`; der Rechenkern kennt nur seine Schnittstelle); https://highs.dev | | Velopack | MIT | Installation und Updates über GitHub Releases (E12); Version gemeinsam mit `VPK_VERSION` in `release.yml` | | Schriften IBM Plex Sans und IBM Plex Mono (je Regular, Medium, SemiBold, Bold), © 2017 IBM Corp. | SIL OFL 1.1 | Schrift der Oberfläche, eingebettet unter `src/AndiGenerator.UI/Assets/Fonts` mit `OFL.txt` | | xunit | Apache-2.0 | nur Tests | diff --git a/anleitung.cmd b/anleitung.cmd new file mode 100644 index 0000000..062d1c3 --- /dev/null +++ b/anleitung.cmd @@ -0,0 +1,18 @@ +@echo off +rem Erzeugt Doku\Anleitung.pdf aus Doku\ANLEITUNG.md neu (pandoc und Edge, ohne Installation; siehe tools\anleitung\README.md). +rem Dauert beim ersten Mal laenger (pandoc und NuGet-Pakete werden geladen). Protokoll: anleitung-log.txt. +chcp 65001 >nul +cd /d "%~dp0" +set "LOG=%~dp0anleitung-log.txt" +echo Erzeuge die Anleitung als PDF ... +powershell -NoProfile -ExecutionPolicy Bypass -File tools\anleitung\anleitung.ps1 > "%LOG%" 2>&1 +set RC=%ERRORLEVEL% +type "%LOG%" +echo. +if not "%RC%"=="0" ( + echo ERGEBNIS: FEHLGESCHLAGEN - das Protokoll steht in anleitung-log.txt. + pause + goto :eof +) +echo ERGEBNIS: Doku\Anleitung.pdf ist neu erzeugt. +timeout /t 10 >nul diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Kompaktmeldungen.cs b/src/AndiGenerator.Application/Datenbearbeitung.Kompaktmeldungen.cs new file mode 100644 index 0000000..c882246 --- /dev/null +++ b/src/AndiGenerator.Application/Datenbearbeitung.Kompaktmeldungen.cs @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Persistence.Plandaten; +using AndiGenerator.Persistence.Terminwuensche; + +namespace AndiGenerator.Application; + +/// +/// Meldungen der Kompaktspieltage direkt im Raster erfassen (nicht im Original; Peter, 13.10.2026). Gespeichert wird wie +/// bisher: Heimtermin = Heim möglich, Sperrtermin = kann nicht, ohne Eintrag = auswärts; „nur Heim“ steht als Zeile +/// „Nur Heim: TT.MM., …“ in der Bemerkung der Mannschaft. +/// +public sealed partial class Datenbearbeitung +{ + /// Attribut am Knoten plan: die im Raster angelegten Tage, z. B. 12.09.2026;03.10.2026. + private const string KompakttageAttribut = "kompakttage"; + + /// Liest die im Raster angelegten Tage (sie erscheinen als Zeilen, auch wenn dort niemand einen Heimtermin hat). + /// Die Tage, aufsteigend. + public IReadOnlyList Kompakttage() => + Arbeitsstand.Lesen(KompakttageAttribut) + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(t => DateOnly.TryParseExact(t, "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly d) ? d : (DateOnly?)null) + .OfType() + .Distinct() + .Order() + .ToList(); + + /// Setzt die Meldung einer Mannschaft an einem Tag. + /// Mannschaftsname. + /// Der Tag. + /// Die Meldung. + public void KompaktfeldSetzen(string mannschaft, DateOnly tag, Kompaktfeld feld) + { + List tage = Heimtage(mannschaft).ToList(); + bool heim = feld is Kompaktfeld.Heim or Kompaktfeld.Beides; + bool hatHeimtermin = tage.Exists(h => h.IstHeimtag && Am(h, tag)); + tage.RemoveAll(h => Am(h, tag) && (h.Sperrtermin || !heim)); + if (feld == Kompaktfeld.Leer) + { + tage.Add(new Heimtag(tag.ToDateTime(TimeOnly.MinValue), true, false, false, 0, false, TimeOnly.MinValue, 0, false, string.Empty)); + } + else if (heim && !hatHeimtermin) + { + tage.Add(NeuerHeimtermin(mannschaft, tag, tage)); + } + + HeimtageSpeichern(mannschaft, tage.OrderBy(h => h.Datum)); + DatenKnoten team = Team(mannschaft); + string bemerkung = team.Lesen(BemerkungAttribut); + string neu = Kompaktplanung.NurHeimAendern(bemerkung, tag, feld == Kompaktfeld.Heim); + if (neu != bemerkung) + { + team.Setzen(BemerkungAttribut, neu); + } + } + + /// Liest den Heimtermin einer Mannschaft an einem Tag. + /// Mannschaftsname. + /// Der Tag. + /// Der Termin oder null, wenn sie an dem Tag keinen Heimtermin hat. + public Kompakttermin? KompaktterminLesen(string mannschaft, DateOnly tag) + { + if (Heimtage(mannschaft).FirstOrDefault(h => h.IstHeimtag && Am(h, tag)) is not Heimtag h) + { + return null; + } + + TimeOnly zeit = TimeOnly.FromDateTime(h.Datum); + return new Kompakttermin(zeit == TimeOnly.MinValue ? null : zeit, h.Spiellokal, h.MaxParallel >= 2); + } + + /// Ändert Beginn, Spiellokal und Tische des Heimtermins einer Mannschaft an einem Tag. + /// Mannschaftsname. + /// Der Tag. + /// Die neuen Angaben. + public void KompaktterminSetzen(string mannschaft, DateOnly tag, Kompakttermin termin) + { + ArgumentNullException.ThrowIfNull(termin); + List tage = Heimtage(mannschaft).ToList(); + int i = tage.FindIndex(h => h.IstHeimtag && Am(h, tag)); + if (i < 0) + { + return; + } + + tage[i] = tage[i] with + { + Datum = tag.ToDateTime(termin.Zeit ?? TimeOnly.MinValue), + Spiellokal = Spiellokale.Korrigieren(termin.Spiellokal), + MaxParallel = termin.ZweiTische ? 2 : 1, + }; + HeimtageSpeichern(mannschaft, tage.OrderBy(h => h.Datum)); + } + + /// + /// Legt einen weiteren Tag im Raster an (gespeichert mit der Staffel): Alle Mannschaften ohne Eintrag an diesem Tag bekommen + /// einen Sperrtermin (leeres Feld); Felder füllt man danach einzeln. + /// + /// Der Tag. + public void KompakttagAnlegen(DateOnly tag) + { + Arbeitsstand.Setzen( + KompakttageAttribut, + string.Join(';', Kompakttage().Append(tag).Distinct().Order().Select(t => t.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)))); + List ohneEintrag = Teams(Arbeitsstand) + .Select(t => t.Lesen("teamname")) + .Where(n => n.Length > 0) + .Distinct(StringComparer.Ordinal) + .Where(n => !Heimtage(n).Any(h => Am(h, tag))) + .ToList(); + foreach (string name in ohneEintrag) + { + KompaktfeldSetzen(name, tag, Kompaktfeld.Leer); + } + } + + /// + /// Die Spiellokale, die für eine Mannschaft in Frage kommen: ihr Standardlokal, alle ihrer Heimtermine und alle mit + /// Adresse aus den Terminwünschen, jeweils mit dem Namen der Halle. + /// + /// Mannschaftsname. + /// Die Spiellokale nach Nummer; mindestens „1“. + public IReadOnlyList KompaktLokale(string mannschaft) + { + DatenKnoten team = Team(mannschaft); + var lokale = new SortedSet(StringComparer.Ordinal) { "1" }; + lokale.UnionWith(Enumerable.Range(1, 5).Select(n => n.ToString(CultureInfo.InvariantCulture)).Where(n => team.Lesen(AdressAttribut + n).Length > 0)); + string standardlokal = Spiellokale.Korrigieren(Team(mannschaft).Lesen("location")); + if (standardlokal.Length > 0) + { + lokale.Add(standardlokal); + } + + lokale.UnionWith(Heimtage(mannschaft).Where(h => h.IstHeimtag && h.Spiellokal.Length > 0).Select(h => h.Spiellokal)); + return lokale.Select(n => new Kompaktlokal(n, Hallenname(mannschaft, n))).ToList(); + } + + /// Name und Adresse einer Halle aus den Terminwünschen. + /// Mannschaftsname. + /// Nummer des Spiellokals; leer = Standardlokal der Mannschaft. + /// Der Text oder leer, wenn unbekannt. + public string Hallenname(string mannschaft, string lokal) + { + DatenKnoten? team = Teams(Arbeitsstand).FirstOrDefault(t => t.Lesen("teamname") == mannschaft); + if (team is null) + { + return string.Empty; + } + + string nummer = Spiellokale.Korrigieren(lokal.Length > 0 ? lokal : team.Lesen("location")); + string text = team.Lesen(AdressAttribut + (nummer.Length > 0 ? nummer : "1")); + return text.Length == 0 ? string.Empty : Spiellokaladresse.Aus(text).Text; + } + + /// + /// Setzt ein Spiellokal für die ganze Mannschaft: als ihr Standardlokal und für alle ihre Heimtermine. + /// + /// Mannschaftsname. + /// Nummer des Spiellokals. + public void KompaktLokalFuerAlle(string mannschaft, string lokal) + { + string nummer = Spiellokale.Korrigieren(lokal); + Team(mannschaft).Setzen("location", nummer); + HeimtageSpeichern(mannschaft, Heimtage(mannschaft).Select(h => h.IstHeimtag ? h with { Spiellokal = nummer } : h).ToList()); + } + + private static bool Am(Heimtag h, DateOnly tag) => DateOnly.FromDateTime(h.Datum) == tag; + + /// + /// Ein neuer Heimtermin ohne Uhrzeit (00:00 = Standardbeginn: 3er-Spieltag 10:00, 4er-Spieltag 11:00; Peter, 13.10.2026), + /// im Spiellokal der Mannschaft (sonst dem ihrer meisten Heimtermine), zwei Tische (4er-Spieltag möglich). + /// + private Heimtag NeuerHeimtermin(string mannschaft, DateOnly tag, List tage) + { + string lokal = Spiellokale.Korrigieren(Team(mannschaft).Lesen("location")); + if (lokal.Length == 0) + { + lokal = tage.Where(h => h.IstHeimtag).GroupBy(h => h.Spiellokal).OrderByDescending(g => g.Count()).Select(g => g.Key).FirstOrDefault() ?? string.Empty; + } + + return new Heimtag(tag.ToDateTime(TimeOnly.MinValue), false, false, false, 0, false, TimeOnly.MinValue, 2, false, lokal); + } +} diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Kompaktspieltage.cs b/src/AndiGenerator.Application/Datenbearbeitung.Kompaktspieltage.cs new file mode 100644 index 0000000..df856d3 --- /dev/null +++ b/src/AndiGenerator.Application/Datenbearbeitung.Kompaktspieltage.cs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Geo; + +namespace AndiGenerator.Application; + +/// Kompaktspieltage (nicht im Original): Schalter der Staffel, Bemerkungen und Lage der Hallen. +public sealed partial class Datenbearbeitung +{ + /// Attribut am Knoten plan: ein, bestaetigt oder aus (fehlt = noch nicht gefragt). + private const string KompaktAttribut = "kompaktspieltage"; + + /// Holt, ob die Staffel mit Kompaktspieltagen geplant wird. + public Kompaktmodus Kompaktspieltage => Arbeitsstand.Lesen(KompaktAttribut) switch + { + "ein" => Kompaktmodus.Ein, + "bestaetigt" => Kompaktmodus.Bestaetigt, + "aus" => Kompaktmodus.Aus, + _ => Kompaktmodus.Offen, + }; + + /// Setzt, ob die Staffel mit Kompaktspieltagen geplant wird. + /// Der neue Zustand. + public void KompaktspieltageSetzen(Kompaktmodus modus) + { + string wert = modus switch + { + Kompaktmodus.Ein => "ein", + Kompaktmodus.Bestaetigt => "bestaetigt", + Kompaktmodus.Aus => "aus", + _ => string.Empty, + }; + Arbeitsstand.Setzen(KompaktAttribut, wert); + } + + /// Liest die Bemerkungen der Vereine aus dem übernommenen Bericht „Terminwünsche“. + /// Mannschaftsname → Bemerkung; Mannschaften ohne Bemerkung fehlen. + public IReadOnlyDictionary Bemerkungen() => + Teams(Arbeitsstand) + .Select(t => (Name: t.Lesen("teamname"), Text: t.Lesen(BemerkungAttribut))) + .Where(x => x.Text.Length > 0) + .GroupBy(x => x.Name, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Text, StringComparer.Ordinal); + + /// Liest die Lage der Hallen aus den gespeicherten Adressen (Postleitzahl bzw. GPS-Angabe). + /// Mannschaftsname → Lage; Mannschaften ohne bekannte Halle fehlen. + public IReadOnlyDictionary Hallenkoordinaten() => HallenLagen([]); + + /// + /// Liest die Adresse der Halle jeder Mannschaft, wie sie im Bericht „Terminwünsche“ steht (Name, Straße, PLZ und Ort), + /// z. B. zum Eintragen beim Verlegen in click-TT. + /// + /// Mannschaftsname → Adresse; Mannschaften ohne gespeicherte Adresse fehlen. + public IReadOnlyDictionary Hallenadressen() => + Teams(Arbeitsstand) + .Select(t => (Name: t.Lesen("teamname"), Text: GespeicherteAdresse(t, Hallennummer(t))?.Text ?? string.Empty)) + .Where(x => x.Text.Length > 0) + .GroupBy(x => x.Name, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Text, StringComparer.Ordinal); +} diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs b/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs index cc08552..f50943c 100644 --- a/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs +++ b/src/AndiGenerator.Application/Datenbearbeitung.Setzliste.cs @@ -4,6 +4,7 @@ using System.Globalization; using AndiGenerator.Persistence.Aufstellungen; +using AndiGenerator.Persistence.Gemeinsam; using AndiGenerator.Persistence.Plandaten; namespace AndiGenerator.Application; @@ -14,6 +15,12 @@ public sealed partial class Datenbearbeitung /// Attribut am Knoten plan: wann welche Mannschaftsmeldung übernommen wurde (nicht im Original). private const string MannschaftsmeldungAttribut = "mannschaftsmeldung"; + /// Attribut am Knoten team: Schnitt der TTR-Werte der gewerteten Stammspieler (nicht im Original). + private const string TtrAttribut = "ttrschnitt"; + + /// Attribut am Knoten team: Zahl der Stammspieler laut Mannschaftsmeldung (nicht im Original). + private const string StammspielerAttribut = "stammspieler"; + /// Holt den Vermerk, welche Mannschaftsmeldung wann übernommen wurde, oder einen leeren Text. public string MannschaftsmeldungVermerk => Arbeitsstand.Lesen(MannschaftsmeldungAttribut); @@ -87,6 +94,44 @@ public Setzlistenvorschlag SetzlisteVorschlagen(IReadOnlyList + /// Speichert die Stärke jeder Mannschaft aus der Mannschaftsmeldung (TTR-Schnitt und Stammspieler) an der Mannschaft; + /// Mannschaften ohne Aufstellung verlieren einen früheren Wert. + /// + /// Die Stärken aus dem Vorschlag. + public void MannschaftsstaerkenSpeichern(IEnumerable staerken) + { + ArgumentNullException.ThrowIfNull(staerken); + var nachName = staerken.ToDictionary(s => s.Mannschaft, StringComparer.Ordinal); + foreach (DatenKnoten team in Teams(Arbeitsstand)) + { + if (!nachName.TryGetValue(team.Lesen("teamname"), out Mannschaftsstaerke? s) || (!s.HatAufstellung && !team.Hat(TtrAttribut))) + { + continue; + } + + team.Setzen(TtrAttribut, s.HatAufstellung ? Math.Round(s.Schnitt).ToString("0", CultureInfo.InvariantCulture) : string.Empty); + team.Setzen(StammspielerAttribut, s.HatAufstellung ? s.Stammspieler.ToString(CultureInfo.InvariantCulture) : string.Empty); + } + } + + /// Liest die gespeicherten Stärken (TTR-Schnitt aus der Mannschaftsmeldung) je Mannschaft. + /// Mannschaftsname → Stärke; Mannschaften ohne gespeicherten Wert fehlen. + public IReadOnlyDictionary Mannschaftsstaerken() + { + var ergebnis = new Dictionary(StringComparer.Ordinal); + foreach (DatenKnoten team in Teams(Arbeitsstand)) + { + if (double.TryParse(team.Lesen(TtrAttribut), NumberStyles.Float, CultureInfo.InvariantCulture, out double schnitt) && schnitt > 0) + { + string name = team.Lesen("teamname"); + ergebnis[name] = new Mannschaftsstaerke(name, schnitt, Math.Max(1, DelphiKompatibel.ZahlLesen(team.Lesen(StammspielerAttribut), 1))); + } + } + + return ergebnis; + } + /// Schreibt die Setzliste wie das Original (FormToData): der Knoten wird neu aufgebaut. /// Die Setzliste. public void SetzlisteSpeichern(Setzlistendaten daten) diff --git a/src/AndiGenerator.Application/Datenbearbeitung.Wochenspielpruefung.cs b/src/AndiGenerator.Application/Datenbearbeitung.Wochenspielpruefung.cs index e2f411d..b0d31d4 100644 --- a/src/AndiGenerator.Application/Datenbearbeitung.Wochenspielpruefung.cs +++ b/src/AndiGenerator.Application/Datenbearbeitung.Wochenspielpruefung.cs @@ -126,18 +126,7 @@ public void WochenspielSperrenEntfernen(IEnumerable gesetzt) /// Lage der Halle: Spiellokal der Mannschaft (Nummer am Team, sonst die häufigste der Heimtermine, sonst 1). private static Koordinate? HallenLage(DatenKnoten team, IReadOnlyList meldungen) { - int nummer = DelphiKompatibel.ZahlLesen(team.Lesen("location"), 0); - if (nummer is < 1 or > 3) - { - nummer = team.KinderMitNamen("homegameday") - .Select(k => DelphiKompatibel.ZahlLesen(k.Lesen("location"), 0)) - .Where(n => n is >= 1 and <= 3) - .GroupBy(n => n) - .OrderByDescending(g => g.Count()) - .Select(g => g.Key) - .FirstOrDefault(1); - } - + int nummer = Hallennummer(team); Spiellokaladresse? adresse = MeldungZu(team, meldungen) is Vereinsmeldung m ? m.Spiellokale.GetValueOrDefault(nummer) ?? m.Spiellokale.Values.FirstOrDefault() : GespeicherteAdresse(team, nummer); @@ -149,6 +138,21 @@ public void WochenspielSperrenEntfernen(IEnumerable gesetzt) }; } + /// Nummer des Spiellokals der Mannschaft (1 bis 3): die eingetragene, sonst die ihrer meisten Heimtermine. + private static int Hallennummer(DatenKnoten team) + { + int nummer = DelphiKompatibel.ZahlLesen(team.Lesen("location"), 0); + return nummer is >= 1 and <= 3 + ? nummer + : team.KinderMitNamen("homegameday") + .Select(k => DelphiKompatibel.ZahlLesen(k.Lesen("location"), 0)) + .Where(n => n is >= 1 and <= 3) + .GroupBy(n => n) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .FirstOrDefault(1); + } + private static Spiellokaladresse? GespeicherteAdresse(DatenKnoten team, int nummer) { string text = team.Lesen(AdressAttribut + nummer.ToString(CultureInfo.InvariantCulture)); diff --git a/src/AndiGenerator.Application/Halbrundenkennzeichen.cs b/src/AndiGenerator.Application/Halbrundenkennzeichen.cs new file mode 100644 index 0000000..ede4c8a --- /dev/null +++ b/src/AndiGenerator.Application/Halbrundenkennzeichen.cs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Kennzeichen einer Klasse, die nur eine Halbrunde spielt, im Klassennamen (nicht im Original). +public enum Halbrundenkennzeichen +{ + /// Kein Kennzeichen: Vor- und Rückrunde. + Keins, + + /// „(HR)“: nur Hinrunde bzw. Herbstrunde. + Hinrunde, + + /// „(FR)“: nur Finalrunde bzw. Frühlingsrunde, also die Rückrunde. + Rueckrunde, +} diff --git a/src/AndiGenerator.Application/Halbrundenname.cs b/src/AndiGenerator.Application/Halbrundenname.cs new file mode 100644 index 0000000..364b657 --- /dev/null +++ b/src/AndiGenerator.Application/Halbrundenname.cs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Text.RegularExpressions; + +namespace AndiGenerator.Application; + +/// +/// Erkennt am Klassennamen, ob eine Klasse nur eine Halbrunde spielt (nicht im Original): Der Verband kennzeichnet sie mit +/// „(HR)“ für Hinrunde bzw. Herbstrunde und „(FR)“ für Finalrunde bzw. Frühlingsrunde. +/// +public static partial class Halbrundenname +{ + /// Liest das Kennzeichen aus dem Klassennamen. + /// Name der Staffel, z. B. „Jugend 19 Verbandsoberliga (HR)“. + /// Das Kennzeichen oder . + public static Halbrundenkennzeichen Kennzeichen(string name) + { + ArgumentNullException.ThrowIfNull(name); + Match treffer = Muster().Match(name); + if (!treffer.Success) + { + return Halbrundenkennzeichen.Keins; + } + + return string.Equals(treffer.Groups[1].Value, "HR", StringComparison.OrdinalIgnoreCase) + ? Halbrundenkennzeichen.Hinrunde + : Halbrundenkennzeichen.Rueckrunde; + } + + [GeneratedRegex(@"\(\s*(HR|FR)\s*\)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, 1000)] + private static partial Regex Muster(); +} diff --git a/src/AndiGenerator.Application/Highsbruecke.cs b/src/AndiGenerator.Application/Highsbruecke.cs new file mode 100644 index 0000000..848812a --- /dev/null +++ b/src/AndiGenerator.Application/Highsbruecke.cs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Engine.Turniere; +using AndiGenerator.Import; + +namespace AndiGenerator.Application; + +/// Reicht den Löser HiGHS (Baustein Import) an den Rechenkern weiter (nicht im Original). +public sealed class Highsbruecke : IGanzzahlloeser +{ + /// Holt einen Wert, der angibt, ob HiGHS auf diesem Rechner geladen werden kann. + public static bool Verfuegbar => HighsLoeser.Verfuegbar; + + /// + public (double[]? Werte, bool Optimal) Loesen(Ganzzahlmodell modell, double[]? start, int startwert, Func aufhoeren, TimeSpan? zeitgrenze = null) + { + ArgumentNullException.ThrowIfNull(modell); + var problem = new Ganzzahlproblem( + [.. modell.Kosten], + [.. modell.SpalteOben], + [.. modell.ZeileUnten], + [.. modell.ZeileOben], + [.. modell.ZeilenAnfang], + [.. modell.EintragSpalten], + [.. modell.EintragWerte]); + return HighsLoeser.Loesen(problem, start, startwert, aufhoeren, zeitgrenze); + } +} diff --git a/src/AndiGenerator.Application/Kompaktauswertung.cs b/src/AndiGenerator.Application/Kompaktauswertung.cs new file mode 100644 index 0000000..45e1a48 --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktauswertung.cs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Engine.Turniere; + +namespace AndiGenerator.Application; + +/// Auswertung eines Plans mit Kompaktspieltagen gegen die Meldungen (nicht im Original). +/// Die Sonntage der Halbrunde. +/// Die Mannschaften in der Reihenfolge der Staffel. +/// Die erkannten Kompaktspieltage nach Datum. +/// Alle Befunde als Text (Verstöße, fehlende Meldungen, Widersprüche). +/// Größter Abstand im Stand: Unterschied der gespielten Spiele zwischen zwei Mannschaften. +/// Größte Zahl von Mannschaften, die an einem Sonntag spielen. +public sealed record Kompaktauswertung( + IReadOnlyList Tage, + IReadOnlyList Mannschaften, + IReadOnlyList Spieltage, + IReadOnlyList Befunde, + int GroessterAbstand, + int MeisteAmTag) +{ + /// Holt die Kilometer Luftlinie aller Mannschaften, hin und zurück. + public double KmGesamt => Mannschaften.Sum(m => m.Km); + + /// Holt die Zahl der Verstöße gegen die Meldungen. + public int Verstoesse => Spieltage.Sum(s => s.Hinweise.Count); + + /// Holt die Zahl der Mannschaften ohne Heimspieltag. + public int OhneHeimspieltag => Mannschaften.Count(m => m.Ausgerichtet == 0); + + /// Holt die Formen der Spieltage, z. B. „9× 4er“ oder „6× 4er · 2× 3er · 4× Einzel“. + public string Formen + { + get + { + var teile = new List(); + int vierer = Spieltage.Count(s => s.Form == Turnierform.Vierer); + int dreier = Spieltage.Count(s => s.Form == Turnierform.Dreier); + int einzel = Spieltage.Count(s => s.Form == Turnierform.Einzel); + int sonstige = Spieltage.Count - vierer - dreier - einzel; + Hinzufuegen(teile, vierer, "4er"); + Hinzufuegen(teile, dreier, "3er"); + Hinzufuegen(teile, einzel, "Einzel"); + Hinzufuegen(teile, sonstige, "unbekannt"); + return teile.Count == 0 ? "keine Spieltage" : string.Join(" · ", teile); + } + } + + private static void Hinzufuegen(List teile, int anzahl, string form) + { + if (anzahl > 0) + { + teile.Add(string.Create(CultureInfo.InvariantCulture, $"{anzahl}× {form}")); + } + } +} diff --git a/src/AndiGenerator.Application/Kompaktfeld.cs b/src/AndiGenerator.Application/Kompaktfeld.cs new file mode 100644 index 0000000..8dec8ae --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktfeld.cs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Meldung einer Mannschaft an einem Tag, wie sie im Raster der Kompaktspieltage erfasst wird (nicht im Original). +public enum Kompaktfeld +{ + /// Kann nicht (Sperrtermin). + Leer, + + /// Nur auswärts (kein Heimtermin, nicht gesperrt). + Auswaerts, + + /// Nur Heim (Heimtermin und „Nur Heim“ in der Bemerkung). + Heim, + + /// Heim oder auswärts (Heimtermin). + Beides, +} diff --git a/src/AndiGenerator.Application/Kompaktlokal.cs b/src/AndiGenerator.Application/Kompaktlokal.cs new file mode 100644 index 0000000..8988106 --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktlokal.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Ein Spiellokal einer Mannschaft zur Wahl im Raster der Kompaktspieltage (nicht im Original). +/// Nummer des Spiellokals („1“ bis „5“). +/// Name und Adresse der Halle aus den Terminwünschen oder leer. +public sealed record Kompaktlokal(string Nummer, string Halle) +{ + /// + public override string ToString() => Halle.Length > 0 ? $"{Nummer} – {Halle}" : $"Spiellokal {Nummer}"; +} diff --git a/src/AndiGenerator.Application/Kompaktmannschaft.cs b/src/AndiGenerator.Application/Kompaktmannschaft.cs new file mode 100644 index 0000000..87b5817 --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktmannschaft.cs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Geo; +using AndiGenerator.Domain.Turniere; + +namespace AndiGenerator.Application; + +/// +/// Eine Mannschaft in der Auswertung der Kompaktspieltage (nicht im Original). Überall, auch in Raster und Karte, steht ihr +/// voller Name wie in click-TT (ab der zweiten Mannschaft mit Nummer): Ein aus dem Vereinsnamen abgeleiteter Kurzname war +/// weder Ort noch eindeutig (Peter, 14.10.2026). +/// +/// Name der Mannschaft. +/// Lage der Halle oder null, wenn keine Adresse bekannt ist. +/// Adresse der Halle aus dem Bericht „Terminwünsche“, sonst der Name der Halle aus click-TT oder leer. +/// Meldung je Sonntag oder null, wenn die Mannschaft nichts gemeldet hat. +/// Bemerkung des Vereins aus dem Bericht „Terminwünsche“ oder leer. +/// Kilometer Luftlinie aller Fahrten zu Kompaktspieltagen, hin und zurück. +/// Zahl der ausgerichteten Kompaktspieltage (ohne Einzelbegegnungen). +public sealed record Kompaktmannschaft( + string Name, + Koordinate? Lage, + string Halle, + Turniermeldung? Meldung, + string Bemerkung, + double Km, + int Ausgerichtet) +{ + /// Holt die Zahl der Einzelbegegnungen, in denen die Mannschaft Heimmannschaft ist (zählen als Heimspieltag mit). + public int EinzelHeim { get; init; } + + /// Holt je Heimtermin der Halbrunde Halle und Beginn als Text (für den Tooltip im Raster). + public IReadOnlyDictionary Termine { get; init; } = new Dictionary(); +} diff --git a/src/AndiGenerator.Application/Kompaktmodus.cs b/src/AndiGenerator.Application/Kompaktmodus.cs new file mode 100644 index 0000000..d46a3ca --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktmodus.cs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Ob eine Staffel mit Kompaktspieltagen geplant wird (nicht im Original). +public enum Kompaktmodus +{ + /// Noch nicht gefragt. + Offen, + + /// Eingeschaltet; beim nächsten Öffnen wird noch einmal gefragt (zum Zurücknehmen). + Ein, + + /// Eingeschaltet und bestätigt. + Bestaetigt, + + /// Ausgeschaltet: normale Planung. + Aus, +} diff --git a/src/AndiGenerator.Application/Kompaktplanung.Planen.cs b/src/AndiGenerator.Application/Kompaktplanung.Planen.cs new file mode 100644 index 0000000..9974bc8 --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktplanung.Planen.cs @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Geo; +using AndiGenerator.Domain.Optionen; +using AndiGenerator.Domain.Stammdaten; +using AndiGenerator.Domain.Turniere; +using AndiGenerator.Engine.Turniere; + +namespace AndiGenerator.Application; + +/// Kompaktspieltage planen (nicht im Original): Aufgabe für den Kompaktplaner und Spiele eines geplanten Spieltags. +public static partial class Kompaktplanung +{ + /// Abstand der Runden eines Spieltags. + private static readonly TimeSpan Rundenabstand = TimeSpan.FromHours(3); + + /// + /// Die Einstellungen des Kompaktplaners: Standard, mit dem Löser HiGHS für die gezielte Konstruktion, wenn er sich + /// laden lässt. + /// + /// Die Einstellungen. + public static Kompaktplanoptionen Optionen() => + Kompaktplanoptionen.Standard with { Loeser = Highsbruecke.Verfuegbar ? new Highsbruecke() : null }; + + /// Stellt die Aufgabe für den Kompaktplaner zusammen: Spieltermine der Halbrunde (Sonntage, Samstage und Wochentage mit Heimtermin), Meldungen, Luftlinie, Spitze und Keller. + /// Die Staffel. + /// Arbeitskopie der Spielplandaten. + /// Eingestellte Rundenplanung. + /// Heutiges Datum. + /// Die Aufgabe. + public static Kompaktaufgabe Aufgabe(Staffel staffel, Datenbearbeitung daten, Rundenplanung planung, DateOnly heute) + { + ArgumentNullException.ThrowIfNull(staffel); + ArgumentNullException.ThrowIfNull(daten); + IReadOnlyList turniere = Turniererkennung.Erkennen(staffel.BestehenderSpielplan); + List tage = Spieltermine(staffel, turniere, Rueckrunde(staffel, planung), heute, daten.Kompakttage()); + IReadOnlyDictionary bemerkungen = daten.Bemerkungen(); + List meldungen = staffel.Mannschaften + .Select(m => Meldung(m, tage, bemerkungen.GetValueOrDefault(m.Name, string.Empty), [])) + .OfType() + .ToList(); + IReadOnlyDictionary lagen = daten.Hallenkoordinaten(); + double Entfernung(string a, string b) => + lagen.TryGetValue(a, out Koordinate x) && lagen.TryGetValue(b, out Koordinate y) ? x.Luftlinie(y) : 0; + List namen = staffel.Mannschaften.Select(m => m.Name).ToList(); + (IReadOnlyList spitze, IReadOnlyList keller, string? hinweis) = Staerkegruppen(namen, daten.Mannschaftsstaerken()); + return new Kompaktaufgabe(namen, tage, meldungen, Entfernung) + { + Spitze = spitze, + Keller = keller, + Hinweise = hinweis is null ? [] : [hinweis], + }; + } + + /// + /// Spitzen- und Abstiegsgruppe nach dem QTTR-Schnitt (Mannschaftsaufstellung): die vier stärksten (stärkste zuerst) und + /// die vier schwächsten (schwächste zuerst), bei 6 oder 7 Mannschaften je drei (für 3er-Spieltage). Nur, wenn für alle + /// ein Schnitt bekannt ist. + /// + /// Die Mannschaften. + /// QTTR-Schnitt je Mannschaft. + /// Spitze, Keller (leer, wenn nicht möglich) und ggf. ein Hinweis. + public static (IReadOnlyList Spitze, IReadOnlyList Keller, string? Hinweis) Staerkegruppen( + IReadOnlyList namen, + IReadOnlyDictionary staerken) + { + ArgumentNullException.ThrowIfNull(namen); + ArgumentNullException.ThrowIfNull(staerken); + int groesse = 0; + if (namen.Count >= 8) + { + groesse = 4; + } + else if (namen.Count >= 6) + { + groesse = 3; + } + + if (groesse == 0) + { + return ([], [], null); + } + + List ohne = namen.Where(n => !staerken.TryGetValue(n, out Mannschaftsstaerke? st) || !st.HatAufstellung || st.Schnitt <= 0).ToList(); + if (ohne.Count > 0) + { + return ([], [], "Spitzen- und Abstiegsspieltag: Für " + string.Join(", ", ohne) + " fehlt der QTTR-Schnitt (Mannschaftsaufstellung in der Einrichtung laden)."); + } + + List sortiert = namen.OrderByDescending(n => staerken[n].Schnitt).ThenBy(n => n, StringComparer.Ordinal).ToList(); + return (sortiert.Take(groesse).ToList(), Enumerable.Reverse(sortiert).Take(groesse).ToList(), null); + } + + /// + /// Die Spiele eines geplanten Spieltags: Beginn zur eingetragenen Uhrzeit des Heimtermins des Ausrichters, ohne Uhrzeit + /// (00:00) um 10 Uhr beim 3er-, um 11 Uhr beim 4er-Spieltag und bei der Einzelbegegnung; Runden im Abstand von drei Stunden. Spiellokal nur bei + /// den Spielen des Ausrichters (das des Heimtermins, sonst das der Mannschaft, sonst „1“ – so ist der Ausrichter auch in + /// einem gespeicherten Plan erkennbar); die übrigen werden in click-TT in seine Halle verlegt. + /// + /// Der Spieltag. + /// Die Staffel (Heimtermine des Ausrichters). + /// Die Spiele. + public static IReadOnlyList Spiele(Planturnier turnier, Staffel staffel) + { + ArgumentNullException.ThrowIfNull(turnier); + ArgumentNullException.ThrowIfNull(staffel); + Heimspieltermin? termin = staffel.Mannschaften + .Where(m => m.Name == turnier.Ausrichter) + .SelectMany(m => m.Heimspieltermine) + .FirstOrDefault(t => DateOnly.FromDateTime(t.Zeitpunkt) == turnier.Tag); + TimeSpan standard = turnier.Form == Turnierform.Dreier ? TimeSpan.FromHours(10) : TimeSpan.FromHours(11); + + // Ohne Uhrzeit (00:00) gilt der Standardbeginn; eine eingetragene Uhrzeit gilt so, wie sie ist (Peter, 13.10.2026). + TimeSpan beginn = termin is not null && termin.Zeitpunkt.TimeOfDay > TimeSpan.Zero ? termin.Zeitpunkt.TimeOfDay : standard; + DateTime start = turnier.Tag.ToDateTime(TimeOnly.MinValue) + beginn; + string lokal = new[] { termin?.Spiellokal, staffel.Mannschaften.FirstOrDefault(m => m.Name == turnier.Ausrichter)?.Spiellokal } + .FirstOrDefault(l => !string.IsNullOrEmpty(l)) ?? "1"; + return turnier.Begegnungen() + .Select(b => new Spiel(start + (Rundenabstand * b.Runde), b.Heim, b.Gast, b.Heim == turnier.Ausrichter ? lokal : string.Empty)) + .ToList(); + } + + /// Die Spiele aller geplanten Spieltage. + /// Die Spieltage. + /// Die Staffel. + /// Die Spiele nach Spieltagen. + public static IReadOnlyList Spiele(IEnumerable turniere, Staffel staffel) + { + ArgumentNullException.ThrowIfNull(turniere); + return turniere.SelectMany(t => Spiele(t, staffel)).ToList(); + } +} diff --git a/src/AndiGenerator.Application/Kompaktplanung.cs b/src/AndiGenerator.Application/Kompaktplanung.cs new file mode 100644 index 0000000..a728290 --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktplanung.cs @@ -0,0 +1,412 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Text.RegularExpressions; +using AndiGenerator.Domain.Geo; +using AndiGenerator.Domain.Optionen; +using AndiGenerator.Domain.Stammdaten; +using AndiGenerator.Domain.Turniere; +using AndiGenerator.Engine.Turniere; + +namespace AndiGenerator.Application; + +/// +/// Kompaktspieltage (nicht im Original): Meldungen aus den Terminwünschen der XML-Datei und Auswertung des Plans aus +/// click-TT. Sonntag mit Heimtermin = Heimspieltag möglich, ohne Eintrag = auswärts möglich, Sperrtermin = kann nicht; +/// „Nur Heim“ steht als Zeile „Nur Heim: TT.MM., …“ in der Bemerkung des Vereins. Entfernungen in Luftlinie. +/// +public static partial class Kompaktplanung +{ + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + /// Wertet den Plan aus click-TT einer Staffel mit Kompaktspieltagen aus. + /// Die Staffel. + /// Arbeitskopie der Spielplandaten (Bemerkungen und Hallenadressen). + /// Eingestellte Rundenplanung (wählt mit dem Klassennamen die Halbrunde). + /// Heutiges Datum (wählt sonst ohne Plan die Halbrunde). + /// Die Auswertung. + public static Kompaktauswertung Auswerten(Staffel staffel, Datenbearbeitung daten, Rundenplanung planung, DateOnly heute) + { + ArgumentNullException.ThrowIfNull(staffel); + return AuswertenFuer(staffel, daten, planung, heute, Turniererkennung.Erkennen(staffel.BestehenderSpielplan)); + } + + /// Wertet einen Plan des Kompaktplaners aus (Ausrichter wie geplant). + /// Die Staffel. + /// Arbeitskopie der Spielplandaten (Bemerkungen und Hallenadressen). + /// Eingestellte Rundenplanung. + /// Heutiges Datum. + /// Die geplanten Spieltage. + /// Die Auswertung. + public static Kompaktauswertung Auswerten(Staffel staffel, Datenbearbeitung daten, Rundenplanung planung, DateOnly heute, IReadOnlyList plan) + { + ArgumentNullException.ThrowIfNull(staffel); + ArgumentNullException.ThrowIfNull(plan); + List turniere = plan + .Select(p => new Turnier(p.Tag, p.Form, p.Mannschaften, Spiele(p, staffel), [p.Ausrichter])) + .OrderBy(t => t.Tag) + .ToList(); + return AuswertenFuer(staffel, daten, planung, heute, turniere); + } + + /// + /// Wertet einen Plan aus seinen Spielen aus, z. B. den gesicherten Plan: Die Spieltage werden erkannt; kommen beim + /// 4er-Spieltag zwei Mannschaften als Ausrichter in Frage, ist es die, deren Spiele ein Spiellokal haben. + /// + /// Die Staffel. + /// Arbeitskopie der Spielplandaten (Bemerkungen und Hallenadressen). + /// Eingestellte Rundenplanung. + /// Heutiges Datum. + /// Die Spiele des Plans. + /// Die Auswertung. + public static Kompaktauswertung SpieleAuswerten(Staffel staffel, Datenbearbeitung daten, Rundenplanung planung, DateOnly heute, IEnumerable spiele) + { + ArgumentNullException.ThrowIfNull(staffel); + ArgumentNullException.ThrowIfNull(spiele); + List turniere = Turniererkennung.Erkennen(spiele).Select(AusrichterMitLokal).ToList(); + return AuswertenFuer(staffel, daten, planung, heute, turniere); + } + + /// + /// Ob die Frage nach Kompaktspieltagen kommt: Die Klasse spielt nur eine Halbrunde („(HR)“ bzw. „(FR)“ im Namen oder + /// Rundenplanung „Halbrunde“) und hat überwiegend Spieltage am Wochenende. + /// + /// Die Staffel. + /// Eingestellte Rundenplanung. + /// Ob beim Öffnen eine Halbrunde vorgeschlagen wird (Runde kürzer als 6 Monate). + /// true, wenn nach Kompaktspieltagen gefragt wird. + public static bool InFrage(Staffel staffel, Rundenplanung planung, bool halbrundeVorgeschlagen) + { + ArgumentNullException.ThrowIfNull(staffel); + bool halbrunde = halbrundeVorgeschlagen + || planung == Rundenplanung.Halbrunde + || Halbrundenname.Kennzeichen(staffel.Name) != Halbrundenkennzeichen.Keins; + return halbrunde && Vermutet(staffel); + } + + /// + /// Vermutet Kompaktspieltage aus den Daten: Im Plan aus click-TT spielen Mannschaften zweimal am selben Tag (3er- oder + /// 4er-Spieltage), oder die Termine (Terminwünsche und Plan) liegen überwiegend am Wochenende – insgesamt oder bei den + /// meisten Mannschaften. Zwei oder drei Termine unter der Woche für einzelne Spiele ändern daran nichts; ohne Termine + /// wird nicht widersprochen. + /// + /// Die Staffel. + /// true, wenn die Daten nach Kompaktspieltagen aussehen. + public static bool Vermutet(Staffel staffel) + { + ArgumentNullException.ThrowIfNull(staffel); + if (Turniererkennung.Erkennen(staffel.BestehenderSpielplan).Any(t => t.Form is Turnierform.Dreier or Turnierform.Vierer)) + { + return true; + } + + HashSet tage = staffel.Mannschaften + .SelectMany(m => m.Heimspieltermine) + .Select(t => DateOnly.FromDateTime(t.Zeitpunkt)) + .Concat(staffel.BestehenderSpielplan.Where(s => s.Zeitpunkt.HasValue).Select(s => DateOnly.FromDateTime(s.Zeitpunkt!.Value))) + .ToHashSet(); + int wochenende = tage.Count(t => t.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday); + if (WochenendeUeberwiegt(wochenende, tage.Count - wochenende)) + { + return true; + } + + // Je Mannschaft gezählt (Peter, 13.10.2026): Eine Mannschaft spielt am Wochenende, wenn höchstens zwei ihrer + // Heimtermine unter der Woche liegen (für Einzelbegegnungen) und der Rest am Wochenende – oder das Wochenende + // ohnehin überwiegt. Die Klasse zählt, wenn das bei den meisten Mannschaften so ist. + List mannschaftAmWochenende = staffel.Mannschaften + .Select(m => m.Heimspieltermine.Select(t => DateOnly.FromDateTime(t.Zeitpunkt)).Distinct().ToList()) + .Where(d => d.Count > 0) + .Select(d => (Wochenende: d.Count(t => t.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday), Werktage: d.Count(t => t.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)))) + .Select(z => (z.Werktage <= 2 && z.Wochenende > 0) || WochenendeUeberwiegt(z.Wochenende, z.Werktage)) + .ToList(); + int amWochenende = mannschaftAmWochenende.Count(w => w); + return mannschaftAmWochenende.Count > 0 && WochenendeUeberwiegt(amWochenende, mannschaftAmWochenende.Count - amWochenende); + } + + /// Überwiegend Wochenende: mindestens doppelt so viele Tage wie unter der Woche – bis zu drei Werktage stören nicht. + /// Anzahl der Tage am Wochenende. + /// Anzahl der Tage unter der Woche. + /// true, wenn das Wochenende überwiegt oder es keine Termine gibt. + public static bool WochenendeUeberwiegt(int wochenende, int werktage) => + wochenende + werktage == 0 || wochenende >= 2 * werktage || (werktage <= 3 && wochenende > werktage); + + /// Die zu planende Halbrunde: aus der Rundenplanung oder dem Klassennamen; null, wenn offen. + /// Die Staffel. + /// Eingestellte Rundenplanung. + /// true für die Rückrunde, false für die Hinrunde. + public static bool? Rueckrunde(Staffel staffel, Rundenplanung planung) + { + ArgumentNullException.ThrowIfNull(staffel); + if (planung is Rundenplanung.NurVorrunde or Rundenplanung.NurRueckrunde or Rundenplanung.Corona) + { + return planung != Rundenplanung.NurVorrunde; + } + + return Halbrundenname.Kennzeichen(staffel.Name) switch + { + Halbrundenkennzeichen.Hinrunde => false, + Halbrundenkennzeichen.Rueckrunde => true, + _ => null, + }; + } + + /// Sonntage aus der Zeile „Nur Heim: TT.MM., …“ der Bemerkung. + /// Bemerkung des Vereins. + /// Sonntage der Halbrunde (für Daten ohne Jahr). + /// Die Sonntage. + public static IReadOnlyList NurHeim(string bemerkung, IReadOnlyList tage) + { + ArgumentNullException.ThrowIfNull(bemerkung); + ArgumentNullException.ThrowIfNull(tage); + var ergebnis = new List(); + foreach (Match zeile in NurHeimZeile().Matches(bemerkung)) + { + foreach (GroupCollection d in Datum().Matches(zeile.Groups[1].Value).Select(m => m.Groups)) + { + int tag = int.Parse(d[1].Value, CultureInfo.InvariantCulture); + int monat = int.Parse(d[2].Value, CultureInfo.InvariantCulture); + int? jahr = d[3].Success ? Jahr(d[3].Value) : null; + ergebnis.AddRange(tage.Where(t => t.Day == tag && t.Month == monat && (jahr is null || t.Year == jahr))); + } + } + + return ergebnis.Distinct().Order().ToList(); + } + + /// + /// Nimmt einen Tag in die Zeile „Nur Heim: TT.MM., …“ der Bemerkung auf oder entfernt ihn (Erfassen im Raster). Die übrige + /// Bemerkung bleibt, wie sie ist; ohne Tage entfällt die Zeile. + /// + /// Bisherige Bemerkung. + /// Der Tag. + /// true = aufnehmen, false = entfernen. + /// Die neue Bemerkung. + public static string NurHeimAendern(string bemerkung, DateOnly tag, bool nurHeim) + { + ArgumentNullException.ThrowIfNull(bemerkung); + Match zeile = NurHeimZeile().Match(bemerkung); + var daten = new SortedSet<(int Monat, int Tag)>(); + if (zeile.Success) + { + foreach (GroupCollection d in Datum().Matches(zeile.Groups[1].Value).Select(m => m.Groups)) + { + daten.Add((int.Parse(d[2].Value, CultureInfo.InvariantCulture), int.Parse(d[1].Value, CultureInfo.InvariantCulture))); + } + } + + if (nurHeim) + { + daten.Add((tag.Month, tag.Day)); + } + else + { + daten.Remove((tag.Month, tag.Day)); + } + + string neu = daten.Count == 0 + ? string.Empty + : "Nur Heim: " + string.Join(", ", daten.Select(d => string.Create(CultureInfo.InvariantCulture, $"{d.Tag:00}.{d.Monat:00}."))); + if (!zeile.Success) + { + return neu.Length == 0 ? bemerkung : string.Join('\n', new[] { bemerkung.TrimEnd(), neu }.Where(t => t.Length > 0)); + } + + // Zeilenumbruch hinter der Zeile erhalten (der Ausdruck schließt Leerraum hinter dem letzten Datum ein). + string schluss = zeile.Value[zeile.Value.TrimEnd().Length..]; + string ergebnis = bemerkung[..zeile.Index] + neu + (neu.Length > 0 ? schluss : string.Empty) + bemerkung[(zeile.Index + zeile.Length)..]; + return neu.Length > 0 ? ergebnis : ergebnis.Replace("\n\n", "\n", StringComparison.Ordinal).Trim(); + } + + private static Kompaktauswertung AuswertenFuer(Staffel staffel, Datenbearbeitung daten, Rundenplanung planung, DateOnly heute, IReadOnlyList turniere) + { + ArgumentNullException.ThrowIfNull(daten); + List tage = Spieltermine(staffel, turniere, Rueckrunde(staffel, planung), heute, daten.Kompakttage()); + IReadOnlyDictionary bemerkungen = daten.Bemerkungen(); + IReadOnlyDictionary lagen = daten.Hallenkoordinaten(); + IReadOnlyDictionary adressen = daten.Hallenadressen(); + var befunde = new List(); + var meldungen = new List(); + foreach (Mannschaft m in staffel.Mannschaften) + { + string bemerkung = bemerkungen.GetValueOrDefault(m.Name, string.Empty); + if (Meldung(m, tage, bemerkung, befunde) is Turniermeldung meldung) + { + meldungen.Add(meldung); + } + } + + List namen = staffel.Mannschaften.Select(m => m.Name).ToList(); + Turnierpruefungsergebnis pruefung = Turnierpruefung.Pruefen(turniere, namen, meldungen); + + // Ohne Plan (weder aus click-TT noch berechnet) fehlt jede Paarung – das muss nicht als Hinweis dastehen. + if (turniere.Count > 0) + { + befunde.AddRange(pruefung.Befunde); + } + + double Entfernung(string a, string b) => + lagen.TryGetValue(a, out Koordinate x) && lagen.TryGetValue(b, out Koordinate y) ? 2 * x.Luftlinie(y) : 0; + + List spieltage = pruefung.Turniere.Select(b => Spieltag(b, Entfernung)).ToList(); + List mannschaften = staffel.Mannschaften.Select(m => new Kompaktmannschaft( + m.Name, + lagen.TryGetValue(m.Name, out Koordinate lage) ? lage : null, + adressen.GetValueOrDefault(m.Name) ?? m.Heimspieltermine.Select(t => t.Halle).FirstOrDefault(h => h.Length > 0) ?? string.Empty, + meldungen.Find(x => x.Mannschaft == m.Name), + bemerkungen.GetValueOrDefault(m.Name, string.Empty), + spieltage.Where(s => s.Ausrichter is not null && s.Gaeste.Contains(m.Name)).Sum(s => Entfernung(m.Name, s.Ausrichter!)), + pruefung.Ausgerichtet.GetValueOrDefault(m.Name)) + { + EinzelHeim = spieltage.Count(s => s.Form == Turnierform.Einzel && s.Ausrichter == m.Name), + Termine = m.Heimspieltermine + .Where(h => tage.Contains(DateOnly.FromDateTime(h.Zeitpunkt))) + .GroupBy(h => DateOnly.FromDateTime(h.Zeitpunkt)) + .ToDictionary(g => g.Key, g => Termintext(g.First(), daten, m.Name)), + }).ToList(); + return new Kompaktauswertung(tage, mannschaften, spieltage, befunde, GroessterAbstand(spieltage, namen), MeisteAmTag(spieltage)); + } + + /// Halle und Beginn eines Heimtermins, z. B. „Halle: Sporthalle Nord, Hauptstr. 1, 56000 Ort · Beginn: Standard (3er 10:00, 4er 11:00)“. + private static string Termintext(Heimspieltermin h, Datenbearbeitung daten, string mannschaft) + { + string halle = h.Halle.Length > 0 ? h.Halle : daten.Hallenname(mannschaft, h.Spiellokal); + if (halle.Length == 0) + { + halle = h.Spiellokal.Length > 0 ? "Spiellokal " + h.Spiellokal : "Spiellokal der Mannschaft"; + } + + string beginn = h.Zeitpunkt.TimeOfDay > TimeSpan.Zero ? h.Zeitpunkt.ToString("HH:mm", Deutsch) + " Uhr" : "Standard (3er 10:00, 4er 11:00)"; + string tische = h.MaxParalleleSpiele != 1 ? string.Empty : " · nur ein Tisch (kein 4er-Spieltag)"; + return $"Halle: {halle} · Beginn: {beginn}{tische}"; + } + + /// + /// Die möglichen Spieltermine der Halbrunde (die gewählte, sonst die mit Spielen aus click-TT bzw. – ohne Spiele – die + /// laufende oder nächste): alle Sonntage, dazu Samstage und Wochentage mit einem Heimtermin und die im Raster angelegten Tage; + /// ohne spielfreie Tage. + /// + private static List Spieltermine(Staffel staffel, IReadOnlyList turniere, bool? rueckrunde, DateOnly heute, IReadOnlyList angelegt) + { + DateOnly beginn = staffel.Beginn ?? turniere.Select(t => t.Tag).DefaultIfEmpty(heute).Min(); + DateOnly ende = staffel.Ende ?? turniere.Select(t => t.Tag).DefaultIfEmpty(heute).Max(); + if (staffel.Rueckrundenbeginn is DateOnly mitte) + { + DateOnly bezug = turniere.Count > 0 ? turniere[0].Tag : heute; + bool zweite = rueckrunde ?? bezug >= mitte; + (beginn, ende) = zweite ? (mitte, ende) : (beginn, mitte.AddDays(-1)); + } + + var tage = new SortedSet(); + for (DateOnly t = beginn.AddDays(((int)DayOfWeek.Sunday - (int)beginn.DayOfWeek + 7) % 7); t <= ende; t = t.AddDays(7)) + { + tage.Add(t); + } + + // Dazu Samstage (Kompaktspieltage) und Wochentage (nur Einzelbegegnungen) mit einem Heimtermin einer Mannschaft + // sowie die Tage des Plans aus click-TT (Peter, 13.10.2026). + tage.UnionWith(staffel.Mannschaften + .SelectMany(m => m.Heimspieltermine) + .Select(h => DateOnly.FromDateTime(h.Zeitpunkt)) + .Concat(turniere.Select(t => t.Tag)) + .Where(t => t >= beginn && t <= ende)); + + // Und die im Raster angelegten Zeilen (Peter, 13.10.2026). + tage.UnionWith(angelegt.Where(t => t >= beginn && t <= ende)); + tage.ExceptWith(staffel.SpielfreieTage); + return [.. tage]; + } + + private static Turniermeldung? Meldung(Mannschaft m, List tage, string bemerkung, List befunde) + { + var heim = m.Heimspieltermine.Select(t => DateOnly.FromDateTime(t.Zeitpunkt)).Where(tage.Contains).ToHashSet(); + var gesperrt = m.Sperrtermine.Where(tage.Contains).ToHashSet(); + if (heim.Count == 0 && gesperrt.Count == 0) + { + return null; + } + + IReadOnlyList nurHeim = NurHeim(bemerkung, tage); + befunde.AddRange(nurHeim.Where(t => !heim.Contains(t)) + .Select(t => $"{m.Name}: „Nur Heim“ am {t.ToString("dd.MM.", Deutsch)}, aber an diesem Sonntag ist kein Heimtermin eingetragen.")); + var bereitschaft = new Dictionary(); + foreach (DateOnly tag in tage) + { + Turnierbereitschaft b = Turnierbereitschaft.None; + if (!gesperrt.Contains(tag)) + { + b = heim.Contains(tag) ? Turnierbereitschaft.Heim : Turnierbereitschaft.None; + b |= Auswaerts(tag, nurHeim) ? Turnierbereitschaft.Auswaerts : Turnierbereitschaft.None; + } + + bereitschaft[tag] = b; + } + + string halle = m.Heimspieltermine.Select(t => t.Halle).FirstOrDefault(h => h.Length > 0) ?? string.Empty; + return new Turniermeldung(m.Name, bereitschaft, halle, m.Heimspieltermine.Any(t => t.MaxParalleleSpiele >= 2), bemerkung); + } + + /// + /// Ob die Mannschaft an dem (nicht gesperrten) Tag auswärts spielen kann: wenn er nicht „Nur Heim“ ist. Das gilt an jedem + /// Tag, auch samstags (Peter, 13.10.2026: im Raster frei erfassbar; bisher galt samstags der Sonntag desselben Wochenendes). + /// + private static bool Auswaerts(DateOnly tag, IReadOnlyList nurHeim) => !nurHeim.Contains(tag); + + private static Turnier AusrichterMitLokal(Turnier t) + { + if (t.MoeglicheAusrichter.Count < 2) + { + return t; + } + + List mitLokal = t.MoeglicheAusrichter.Where(m => t.Spiele.Any(s => s.Heim == m && s.Spiellokal.Length > 0)).ToList(); + return mitLokal.Count == 1 ? t with { MoeglicheAusrichter = mitLokal } : t; + } + + private static Kompaktspieltag Spieltag(Turnierbewertung b, Func entfernung) + { + Turnier t = b.Turnier; + IReadOnlyList verlegen = b.Ausrichter is string a && t.Form != Turnierform.Einzel + ? t.Spiele.Where(s => s.Heim != a).ToList() + : []; + double km = b.Ausrichter is string ausrichter ? t.Mannschaften.Where(m => m != ausrichter).Sum(m => entfernung(m, ausrichter)) : 0; + return new Kompaktspieltag(t.Tag, t.Form, b.Ausrichter, t.Mannschaften, t.Spiele, verlegen, b.Hinweise, b.Ausweichtage, km); + } + + /// Nach jedem Spieltag: Unterschied der Zahl gespielter Spiele; der größte Wert der Halbrunde. + private static int GroessterAbstand(List spieltage, List namen) + { + var gespielt = namen.ToDictionary(n => n, _ => 0, StringComparer.Ordinal); + int groesster = 0; + foreach (IGrouping tag in spieltage.GroupBy(s => s.Tag).OrderBy(g => g.Key)) + { + foreach (Spiel spiel in tag.SelectMany(s => s.Spiele)) + { + gespielt[spiel.Heim] = gespielt.GetValueOrDefault(spiel.Heim) + 1; + gespielt[spiel.Gast] = gespielt.GetValueOrDefault(spiel.Gast) + 1; + } + + groesster = Math.Max(groesster, gespielt.Values.Max() - gespielt.Values.Min()); + } + + return groesster; + } + + private static int MeisteAmTag(List spieltage) => + spieltage.GroupBy(s => s.Tag).Select(g => g.SelectMany(s => s.Mannschaften).Distinct(StringComparer.Ordinal).Count()).DefaultIfEmpty(0).Max(); + + private static int Jahr(string text) + { + int jahr = int.Parse(text, CultureInfo.InvariantCulture); + return jahr < 100 ? 2000 + jahr : jahr; + } + + /// „Nur Heim:“ und danach nur Daten (der Bericht fügt Zeilen der Bemerkung zusammen; was folgt, gehört nicht dazu). + [GeneratedRegex(@"nur\s+heim(?:spiel|spiele)?\s*:\s*((?:\d{1,2}\.\d{1,2}\.(?:\d{4}|\d{2}(?!\d))?(?:\s*[,;]\s*|\s+und\s+|\s+)?)+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex NurHeimZeile(); + + [GeneratedRegex(@"(\d{1,2})\.(\d{1,2})\.(\d{4}|\d{2}(?!\d))?", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] + private static partial Regex Datum(); +} diff --git a/src/AndiGenerator.Application/Kompaktspieltag.cs b/src/AndiGenerator.Application/Kompaktspieltag.cs new file mode 100644 index 0000000..3c33ebf --- /dev/null +++ b/src/AndiGenerator.Application/Kompaktspieltag.cs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Stammdaten; +using AndiGenerator.Engine.Turniere; + +namespace AndiGenerator.Application; + +/// Ein Kompaktspieltag im Plan (nicht im Original). +/// Datum. +/// 3er, 4er oder Einzelbegegnung. +/// Ausrichtende Mannschaft (bei der Einzelbegegnung die Heimmannschaft) oder null, wenn keine passt. +/// Alle beteiligten Mannschaften. +/// Die Spiele, nach Uhrzeit. +/// +/// Spiele, deren Heimmannschaft nicht der Ausrichter ist: In click-TT wird bei ihnen die Halle des Ausrichters eingetragen. +/// +/// Verstöße gegen die Meldungen und andere Befunde. +/// Andere Sonntage, an denen der Spieltag zu den Meldungen passen würde. +/// Kilometer Luftlinie aller Gäste, hin und zurück. +public sealed record Kompaktspieltag( + DateOnly Tag, + Turnierform Form, + string? Ausrichter, + IReadOnlyList Mannschaften, + IReadOnlyList Spiele, + IReadOnlyList HalleVerlegen, + IReadOnlyList Hinweise, + IReadOnlyList Ausweichtage, + double Km) +{ + /// Holt die Gäste (alle außer dem Ausrichter). + public IReadOnlyList Gaeste { get; } = Mannschaften.Where(m => m != Ausrichter).ToList(); +} diff --git a/src/AndiGenerator.Application/Kompakttermin.cs b/src/AndiGenerator.Application/Kompakttermin.cs new file mode 100644 index 0000000..5aec1ca --- /dev/null +++ b/src/AndiGenerator.Application/Kompakttermin.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Application; + +/// Heimtermin einer Mannschaft an einem Tag im Raster der Kompaktspieltage (nicht im Original). +/// Beginn, oder null = Standard (3er-Spieltag 10:00, 4er-Spieltag 11:00). +/// Nummer des Spiellokals (leer = Standardlokal der Mannschaft). +/// Zwei Spiele parallel möglich (4er-Spieltag). +public sealed record Kompakttermin(TimeOnly? Zeit, string Spiellokal, bool ZweiTische); diff --git a/src/AndiGenerator.Application/Optionsabweichungen.cs b/src/AndiGenerator.Application/Optionsabweichungen.cs index 06a912e..dfcc858 100644 --- a/src/AndiGenerator.Application/Optionsabweichungen.cs +++ b/src/AndiGenerator.Application/Optionsabweichungen.cs @@ -42,7 +42,9 @@ public static IReadOnlyList Meldungen(Berechnungsoptionen optionen, Staf ergebnis.Add(string.Empty); } - string? runde = optionen.Rundenplanung switch + // Bei „(HR)“ bzw. „(FR)“ im Klassennamen ist die Halbrunde fest vorgegeben, also keine Abweichung (nicht im Original). + Rundenplanung planung = Halbrundenname.Kennzeichen(staffel.Name) == Halbrundenkennzeichen.Keins ? optionen.Rundenplanung : Rundenplanung.Beide; + string? runde = planung switch { Rundenplanung.Halbrunde => "Halbrunde wird generiert", Rundenplanung.NurVorrunde => "Nur Vorrunde wird generiert", diff --git a/src/AndiGenerator.Application/Plansitzung.cs b/src/AndiGenerator.Application/Plansitzung.cs index f359359..6370afa 100644 --- a/src/AndiGenerator.Application/Plansitzung.cs +++ b/src/AndiGenerator.Application/Plansitzung.cs @@ -50,7 +50,7 @@ private Plansitzung(string pfad, DatenKnoten stand, bool istClickTt, bool erster } string optionsdatei = Path.Combine(Staffelordner, OptionenDatei.Dateiname); - Optionen = File.Exists(optionsdatei) ? OptionenDatei.Laden(optionsdatei) : Berechnungsoptionen.Standard; + Optionen = MitHalbrunde(File.Exists(optionsdatei) ? OptionenDatei.Laden(optionsdatei) : Berechnungsoptionen.Standard); } /// Pfad der geöffneten Datei. @@ -71,11 +71,22 @@ private Plansitzung(string pfad, DatenKnoten stand, bool istClickTt, bool erster /// Staffelordner des Originals, aus dem Optionen und gemerkte Pläne beim ersten Öffnen kopiert wurden; sonst null. public string? UebernommenAus { get; } - /// Aktuelle Berechnungsoptionen. + /// Aktuelle Berechnungsoptionen (bei „(HR)“ bzw. „(FR)“ im Klassennamen immer mit der passenden Halbrunde). public Berechnungsoptionen Optionen { get; private set; } /// Die Optionen weichen von den Standardoptionen ab (Original getDiffToDefault). - public bool OptionenWeichenVomStandardAb => !Gleich(Optionen, Berechnungsoptionen.Standard); + public bool OptionenWeichenVomStandardAb => !Gleich(Optionen, MitHalbrunde(Berechnungsoptionen.Standard)); + + /// + /// Holt die vom Klassennamen vorgegebene Rundenplanung (nicht im Original): „(HR)“ → nur Hinrunde, „(FR)“ → nur + /// Rückrunde; sonst null. + /// + public Rundenplanung? VorgegebeneRundenplanung => Halbrundenname.Kennzeichen(Staffel.Name) switch + { + Halbrundenkennzeichen.Hinrunde => Rundenplanung.NurVorrunde, + Halbrundenkennzeichen.Rueckrunde => Rundenplanung.NurRueckrunde, + _ => null, + }; /// /// Basis der Staffelordner von AndiGenerator.NET: %LOCALAPPDATA%\AndiGenerator.NET. Die Daten des Originals @@ -249,21 +260,29 @@ public void OptionenSpeichern(Berechnungsoptionen neu) { ArgumentNullException.ThrowIfNull(neu); Directory.CreateDirectory(Staffelordner); - OptionenDatei.Speichern(Path.Combine(Staffelordner, OptionenDatei.Dateiname), neu); - Optionen = neu; + Optionen = MitHalbrunde(neu); + OptionenDatei.Speichern(Path.Combine(Staffelordner, OptionenDatei.Dateiname), Optionen); } /// /// Rechnet in dieser Sitzung mit den Standardoptionen, ohne die gespeicherten Optionen zu überschreiben. /// Das Original setzt sie in diesem Fall zurück und speichert sofort (Befund #34); hier bleibt die Datei unverändert. /// - public void StandardoptionenFuerDieseSitzung() => Optionen = Berechnungsoptionen.Standard; + public void StandardoptionenFuerDieseSitzung() => Optionen = MitHalbrunde(Berechnungsoptionen.Standard); - /// Vorschlag zur Rundenplanung wie im Original beim Öffnen (ohne die fest einkodierte Corona-Abfrage 2020). + /// + /// Vorschlag zur Rundenplanung wie im Original beim Öffnen (ohne die fest einkodierte Corona-Abfrage 2020). Neu: Bei + /// „(HR)“ bzw. „(FR)“ im Klassennamen ist die Halbrunde vorgegeben, es gibt also keinen Vorschlag. + /// /// Heutiges Datum. /// Der Vorschlag oder . public Rundenvorschlag RundeVorschlagen(DateOnly heute) { + if (VorgegebeneRundenplanung is not null) + { + return Rundenvorschlag.Keiner; + } + if (Staffel.Beginn is DateOnly beginn && Staffel.Ende is DateOnly ende && Math.Abs(ende.DayNumber - beginn.DayNumber) < 6 * 30 && Optionen.Rundenplanung != Rundenplanung.Halbrunde) @@ -295,7 +314,7 @@ public GemerkterPlanEintrag PlanMerken(string name, IEnumerable spiele) ArgumentNullException.ThrowIfNull(spiele); Directory.CreateDirectory(Staffelordner); string pfad = Persistence.Gemeinsam.Staffelordner.GemerkterPlanPfad(Staffelordner, name.Trim()); - GespeicherterPlanDatei.Speichern(pfad, spiele.Select(s => new GespeichertesSpiel(s.Zeitpunkt, s.Heim, s.Gast))); + GespeicherterPlanDatei.Speichern(pfad, spiele.Select(s => new GespeichertesSpiel(s.Zeitpunkt, s.Heim, s.Gast, s.Spiellokal))); return new GemerkterPlanEintrag(name.Trim(), pfad, File.GetLastWriteTime(pfad)); } @@ -315,18 +334,18 @@ 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))); + GespeicherterPlanDatei.Speichern(pfad, spiele.Select(s => new GespeichertesSpiel(s.Zeitpunkt, s.Heim, s.Gast, s.Spiellokal))); return new GemerkterPlanEintrag(GesicherterPlanName, pfad, File.GetLastWriteTime(pfad)); } /// Lädt die Spiele eines gemerkten Plans. /// Eintrag aus . - /// Die Spiele (ohne Spiellokal). + /// Die Spiele, mit Spiellokal, sofern gespeichert (Kompaktspieltage: der Ausrichter). /// Die Datei ist ungültig. public IReadOnlyList GemerktenPlanLaden(GemerkterPlanEintrag eintrag) { PruefenGehoertZurStaffel(eintrag); - return GespeicherterPlanDatei.Laden(eintrag.Pfad).Select(s => new Spiel(s.Zeitpunkt, s.Heim, s.Gast, string.Empty)).ToList(); + return GespeicherterPlanDatei.Laden(eintrag.Pfad).Select(s => new Spiel(s.Zeitpunkt, s.Heim, s.Gast, s.Spiellokal)).ToList(); } /// Entfernt einen gemerkten Plan. @@ -433,18 +452,26 @@ public IReadOnlyList HarteFehler(IReadOnlyList spiele) /// Spiele des Plans. public void CsvExportieren(string pfad, IReadOnlyList spiele) { - ArgumentException.ThrowIfNullOrEmpty(pfad); - ArgumentNullException.ThrowIfNull(spiele); - bool halbrunde = Optionen.Rundenplanung == Rundenplanung.Halbrunde; - var lokale = new Spiellokale(stand, halbrunde); - DateTime beginn = (Staffel.Beginn ?? DateOnly.FromDateTime(DelphiKompatibel.DelphiNull)).ToDateTime(TimeOnly.MinValue); - DateTime rueckrunde = (Staffel.Rueckrundenbeginn ?? DateOnly.FromDateTime(DelphiKompatibel.DelphiNull)).ToDateTime(TimeOnly.MinValue); - var optionen = new CsvExportOptionen(beginn, rueckrunde, halbrunde, Rundenpruefung.Erzeugen(Staffel, Optionen)); - ClickTtCsvExport.Schreiben( - pfad, - spiele.Select(s => new CsvSpiel(s.Zeitpunkt, s.Heim, s.Gast, lokale.Ermitteln(s.Zeitpunkt, s.Heim, s.Gast))), - Staffel.Mannschaften.Select(m => new CsvMannschaft(m.Name, m.VereinsId, m.Id)), - optionen); + var lokale = new Spiellokale(stand, Optionen.Rundenplanung == Rundenplanung.Halbrunde); + CsvSchreiben(pfad, spiele, s => lokale.Ermitteln(s.Zeitpunkt, s.Heim, s.Gast)); + } + + /// + /// Exportiert einen Plan mit Kompaktspieltagen als CSV für click-TT (nicht im Original): Spiele des Ausrichters erhalten + /// das Spiellokal seines Heimtermins an diesem Tag (sonst sein Standardlokal); Spiele anderer Heimmannschaften bleiben + /// leer – ihre Halle ist die des Ausrichters, und click-TT kennt im Import nur die Nummern der Hallen des Heimvereins. + /// + /// Zieldatei. + /// Spiele des Plans. + /// Die Kompaktspieltage des Plans (mit den Spielen, deren Halle verlegt wird). + public void KompaktCsvExportieren(string pfad, IReadOnlyList spiele, IReadOnlyList spieltage) + { + ArgumentNullException.ThrowIfNull(spieltage); + var verlegt = spieltage.SelectMany(t => t.HalleVerlegen).Select(s => (s.Zeitpunkt, s.Heim, s.Gast)).ToHashSet(); + var lokale = new Spiellokale(stand, Optionen.Rundenplanung == Rundenplanung.Halbrunde); + CsvSchreiben(pfad, spiele, s => s.Zeitpunkt is DateTime z && !verlegt.Contains((s.Zeitpunkt, s.Heim, s.Gast)) + ? lokale.AmTag(DateOnly.FromDateTime(z), s.Heim) + : string.Empty); } private static bool Gleich(Berechnungsoptionen a, Berechnungsoptionen b) => @@ -503,4 +530,23 @@ private void PruefenGehoertZurStaffel(GemerkterPlanEintrag eintrag) throw new ArgumentException("Der Plan gehört nicht zu dieser Staffel.", nameof(eintrag)); } } + + /// Setzt die vom Klassennamen vorgegebene Halbrunde in die Optionen ein. + private Berechnungsoptionen MitHalbrunde(Berechnungsoptionen optionen) => + VorgegebeneRundenplanung is Rundenplanung runde ? optionen with { Rundenplanung = runde } : optionen; + + private void CsvSchreiben(string pfad, IReadOnlyList spiele, Func lokal) + { + ArgumentException.ThrowIfNullOrEmpty(pfad); + ArgumentNullException.ThrowIfNull(spiele); + bool halbrunde = Optionen.Rundenplanung == Rundenplanung.Halbrunde; + DateTime beginn = (Staffel.Beginn ?? DateOnly.FromDateTime(DelphiKompatibel.DelphiNull)).ToDateTime(TimeOnly.MinValue); + DateTime rueckrunde = (Staffel.Rueckrundenbeginn ?? DateOnly.FromDateTime(DelphiKompatibel.DelphiNull)).ToDateTime(TimeOnly.MinValue); + var optionen = new CsvExportOptionen(beginn, rueckrunde, halbrunde, Rundenpruefung.Erzeugen(Staffel, Optionen)); + ClickTtCsvExport.Schreiben( + pfad, + spiele.Select(s => new CsvSpiel(s.Zeitpunkt, s.Heim, s.Gast, lokal(s))), + Staffel.Mannschaften.Select(m => new CsvMannschaft(m.Name, m.VereinsId, m.Id)), + optionen); + } } diff --git a/src/AndiGenerator.Domain/Geo/Landesumriss.cs b/src/AndiGenerator.Domain/Geo/Landesumriss.cs new file mode 100644 index 0000000..b602c74 --- /dev/null +++ b/src/AndiGenerator.Domain/Geo/Landesumriss.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Domain.Geo; + +/// Umriss eines Bundeslandes für die Karte (nicht im Original). +/// Name, z. B. „Rheinland-Pfalz“. +/// Geschlossene Linienzüge (Festland und Inseln), je Punkt Breite und Länge. +public sealed record Landesumriss(string Name, IReadOnlyList> Ringe); diff --git a/src/AndiGenerator.Domain/Geo/Stadt.cs b/src/AndiGenerator.Domain/Geo/Stadt.cs new file mode 100644 index 0000000..094b492 --- /dev/null +++ b/src/AndiGenerator.Domain/Geo/Stadt.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Domain.Geo; + +/// Stadt zur Orientierung auf der Karte (nicht im Original). +/// Name, z. B. „Koblenz“. +/// Bedeutung: 1 = Großstadt oder Landeshauptstadt, 2 = Großstadt, 3 = regionales Zentrum. +/// Lage. +public sealed record Stadt(string Name, int Rang, Koordinate Lage); diff --git a/src/AndiGenerator.Engine/Turniere/Ganzzahlmodell.cs b/src/AndiGenerator.Engine/Turniere/Ganzzahlmodell.cs new file mode 100644 index 0000000..73fb0ee --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Ganzzahlmodell.cs @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Ein ganzzahliges lineares Programm (nicht im Original): Spalten sind Ja/Nein-Entscheidungen (0 oder 1) mit Kosten, +/// Zeilen sind Summen von Spalten zwischen einer unteren und oberen Grenze; gesucht ist die Wahl mit den kleinsten Kosten. +/// Gelöst wird es mit einem . +/// +public sealed class Ganzzahlmodell +{ + private readonly List kosten = []; + private readonly List spalteOben = []; + private readonly List zeilenAnfang = [0]; + private readonly List spalten = []; + private readonly List werte = []; + private readonly List zeileUnten = []; + private readonly List zeileOben = []; + + /// Holt die Zahl der Spalten. + public int Spalten => kosten.Count; + + /// Holt die Zahl der Zeilen. + public int Zeilen => zeileUnten.Count; + + /// Holt die Kosten der Spalten. + public IReadOnlyList Kosten => kosten; + + /// Holt die oberen Grenzen der Spalten (0 = ausgeschlossen, 1 = möglich). + public IReadOnlyList SpalteOben => spalteOben; + + /// Holt, wo die Einträge jeder Zeile beginnen (zeilenweise, ein Eintrag mehr als Zeilen). + public IReadOnlyList ZeilenAnfang => zeilenAnfang; + + /// Holt die Spalten der Einträge. + public IReadOnlyList EintragSpalten => spalten; + + /// Holt die Werte der Einträge. + public IReadOnlyList EintragWerte => werte; + + /// Holt die unteren Grenzen der Zeilen. + public IReadOnlyList ZeileUnten => zeileUnten; + + /// Holt die oberen Grenzen der Zeilen ( = keine). + public IReadOnlyList ZeileOben => zeileOben; + + /// Fügt eine Ja/Nein-Spalte hinzu. + /// Kosten, wenn die Spalte gewählt wird. + /// false schließt die Spalte aus. + /// Die Nummer der Spalte. + public int Spalte(double preis, bool moeglich = true) + { + kosten.Add(preis); + spalteOben.Add(moeglich ? 1 : 0); + return kosten.Count - 1; + } + + /// Fügt eine Zeile hinzu: ≤ Summe der Spalten (mit Faktor 1) ≤ . + /// Die Spalten der Summe. + /// Untere Grenze. + /// Obere Grenze. + public void Zeile(IEnumerable eintraege, double unten, double oben) + { + foreach (int s in eintraege) + { + spalten.Add(s); + werte.Add(1); + } + + zeilenAnfang.Add(spalten.Count); + zeileUnten.Add(unten); + zeileOben.Add(oben); + } +} diff --git a/src/AndiGenerator.Engine/Turniere/IGanzzahlloeser.cs b/src/AndiGenerator.Engine/Turniere/IGanzzahlloeser.cs new file mode 100644 index 0000000..033b247 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/IGanzzahlloeser.cs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Löst ein (nicht im Original). Der Rechenkern bleibt frei von Drittbibliotheken; die +/// Anwendung reicht den Löser HiGHS herein (). +/// +public interface IGanzzahlloeser +{ + /// Löst das Modell. + /// Das Modell. + /// Eine bekannte Lösung als Startpunkt (Werte der Spalten), oder null. + /// Startwert der Zufallszahlen des Lösers (parallele Läufe mit verschiedenen Werten suchen verschieden). + /// + /// Wird während der Suche gefragt; true beendet sie mit der bis dahin besten Lösung. Darf blockieren (Pause). + /// + /// Harte Zeitgrenze, die auch ohne Rückfrage gilt, oder null. + /// + /// Die Werte der Spalten der besten gefundenen Lösung, oder null, wenn keine gefunden wurde; und ob sie beweisbar + /// die beste ist. + /// + (double[]? Werte, bool Optimal) Loesen(Ganzzahlmodell modell, double[]? start, int startwert, Func aufhoeren, TimeSpan? zeitgrenze = null); +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktaufgabe.cs b/src/AndiGenerator.Engine/Turniere/Kompaktaufgabe.cs new file mode 100644 index 0000000..269a333 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktaufgabe.cs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Turniere; + +namespace AndiGenerator.Engine.Turniere; + +/// Eingaben des Kompaktplaners (nicht im Original). +/// Alle Mannschaften der Staffel. +/// Mögliche Spieltage (Sonntage der Halbrunde). +/// Meldungen der Mannschaften; ohne Meldung gilt jeder Tag als möglich. +/// Einfache Entfernung zwischen den Hallen zweier Mannschaften in km (Luftlinie). +public sealed record Kompaktaufgabe( + IReadOnlyList Mannschaften, + IReadOnlyList Tage, + IReadOnlyList Meldungen, + Func Entfernung) +{ + /// + /// Holt die vier stärksten Mannschaften (nach QTTR-Schnitt, die stärkste zuerst) für einen Spitzenspieltag oder leer. + /// Zusätzlich wird der kürzeste Plan gesucht, in dem sie einen 4er-Spieltag bilden (die beiden ersten spielen + /// gegeneinander) und die vier schwächsten ebenso – so spät wie möglich. + /// + public IReadOnlyList Spitze { get; init; } = []; + + /// Holt die vier schwächsten Mannschaften (die schwächste zuerst) für einen Abstiegsspieltag oder leer. + public IReadOnlyList Keller { get; init; } = []; + + /// Holt Hinweise zur Aufgabe (z. B. fehlende QTTR-Werte), die mit dem Ergebnis gezeigt werden. + public IReadOnlyList Hinweise { get; init; } = []; +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktauftrag.cs b/src/AndiGenerator.Engine/Turniere/Kompaktauftrag.cs new file mode 100644 index 0000000..0d205e1 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktauftrag.cs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Was die suchen soll (nicht im Original). +/// Kilometer, auf die sich die Kilometerpunkte beziehen (der kürzeste Plan); null = Schätzung. +/// Nur 4er-Spieltage (und Einzelbegegnungen). +/// 3 Punkte je 3er-Spieltag (wenn ein Plan nur aus 4er-Spieltagen geht). +/// Spitzen- und Abstiegsspieltag werten. +/// Spitzen- und Abstiegsspieltag müssen vorkommen. +internal sealed record Kompaktauftrag(double? KmBezug, bool NurVierer, bool DreierStrafe, bool MitGruppen, bool GruppenPflicht) +{ + /// Holt Pläne, deren Spieltage (unabhängig vom Tag) nicht noch einmal gewählt werden sollen. + public IReadOnlyList Ausser { get; init; } = []; +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktauswahl.cs b/src/AndiGenerator.Engine/Turniere/Kompaktauswahl.cs new file mode 100644 index 0000000..190e6cb --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktauswahl.cs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Wählt aus den besten Plänen die Varianten: ausgewogen, kürzeste Fahrten, gleichmäßiger Stand, gemeinsamer +/// Spieltag und gleich weite Fahrten. Fällt eine Wahl auf denselben Plan, wird er einmal mit beiden Namen gezeigt. +/// +internal static class Kompaktauswahl +{ + public const string Ausgewogen = "Ausgewogen"; + public const string KuerzesteFahrten = "Kürzeste Fahrten"; + public const string GleichmaessigerStand = "Gleichmäßiger Stand"; + public const string GemeinsamerSpieltag = "Gemeinsamer Spieltag"; + public const string GleichWeit = "Gleich weite Fahrten"; + + /// Die Varianten, die ausgewogene zuerst. + /// Die besten Pläne. + /// Auch die ausgewogene Variante (entfällt, wenn es ein bestes Gesamtpaket gibt). + public static List Varianten(IReadOnlyList front, bool mitAusgewogen) + { + if (front.Count == 0) + { + return []; + } + + double kmMin = front.Min(l => l.Bewertungskm); + int abstandMin = front.Min(l => l.Abstand); + int gemeinsamMax = front.Max(l => l.Gemeinsam); + var wahl = new List<(string Name, Kompaktloesung Loesung)> + { + (Ausgewogen, front.OrderBy(l => Abweichung(l, kmMin, abstandMin, gemeinsamMax)).ThenBy(l => l.Bewertungskm).First()), + (KuerzesteFahrten, front.OrderBy(l => l.Bewertungskm).ThenBy(l => l.Abstand).ThenByDescending(l => l.Gemeinsam).First()), + (GleichmaessigerStand, front.OrderBy(l => l.Abstand).ThenBy(l => l.SummeAbstand).ThenBy(l => l.Bewertungskm).First()), + (GemeinsamerSpieltag, front.Where(l => l.Gemeinsam == gemeinsamMax).OrderBy(l => l.Bewertungskm).ThenBy(l => l.Abstand).First()), + (GleichWeit, front.OrderBy(l => l.KmSpanne).ThenBy(l => l.Bewertungskm).First()), + }; + if (!mitAusgewogen) + { + wahl.RemoveAt(0); + } + + return wahl + .GroupBy(w => w.Loesung) + .Select(g => new Kompaktvariante(string.Join(" · ", g.Select(w => w.Name)), g.Key.Planturniere(), g.Key.Kennzahlen())) + .ToList(); + } + + /// + /// Abweichung vom jeweils Besten: 10 % mehr Kilometer wiegen so viel wie ein Spieltag Rückstand im Stand (2 Spiele) + /// oder wie ein fehlender gemeinsamer Spieltag. + /// + private static double Abweichung(Kompaktloesung l, double kmMin, int abstandMin, int gemeinsamMax) + { + double km = kmMin > 0 ? ((l.Bewertungskm / kmMin) - 1) / 0.1 : 0; + return km + ((l.Abstand - abstandMin) / 2.0) + (l.Gemeinsam < gemeinsamMax ? 1 : 0); + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktergebnis.cs b/src/AndiGenerator.Engine/Turniere/Kompaktergebnis.cs new file mode 100644 index 0000000..21093f0 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktergebnis.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Ergebnis eines Laufs des Kompaktplaners. +/// Die Vorschläge, der ausgewogene zuerst; leer, wenn kein vollständiger Plan gefunden wurde. +/// Zahl der Suchversuche. +/// Hinweise zu Eingaben und Ergebnis. +public sealed record Kompaktergebnis(IReadOnlyList Varianten, long Geprueft, IReadOnlyList Hinweise); diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktfortschritt.cs b/src/AndiGenerator.Engine/Turniere/Kompaktfortschritt.cs new file mode 100644 index 0000000..c82b715 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktfortschritt.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Stand eines Laufs des Kompaktplaners. +/// Was gerade geschieht, z. B. „Schritt 1 von 4 – Nur 4er-Spieltage: …“. +/// Erledigter Anteil des ganzen Laufs von 0 bis 1. +public sealed record Kompaktfortschritt(string Text, double Anteil); diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktfront.cs b/src/AndiGenerator.Engine/Turniere/Kompaktfront.cs new file mode 100644 index 0000000..b44925b --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktfront.cs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Die besten Pläne aller Suchen (threadsicher): zuerst zählen wenige Mannschaften ohne Heimspieltag, wenige +/// Verstöße und wenige 3er-Spieltage (geht es nur mit 4er-Spieltagen, dann so); unter den Plänen mit den wenigsten bleiben die, die kein anderer in allen Kennzahlen übertrifft +/// (Kilometer, Stand, gemeinsamer Spieltag, Verteilung der Heimspiele, gleich weite Fahrten). +/// +internal sealed class Kompaktfront +{ + private const int Hoechstzahl = 400; + + private readonly Lock sperre = new(); + private readonly bool mitGruppen; + private readonly List loesungen = []; + + /// Initialisiert eine Front. + public Kompaktfront() + : this(false) + { + } + + /// Initialisiert eine Front. + /// Auch die Zahl der Gruppenspieltage zählt (für Pläne mit 3er-Spieltagen). + public Kompaktfront(bool mitGruppen) + { + this.mitGruppen = mitGruppen; + } + + /// + /// Holt oder setzt, ob Pläne nur aus 4er-Spieltagen vorgehen (wenn sie rechnerisch aufgehen, z. B. 9 oder 10 + /// Mannschaften). Sonst ist die Zahl der 3er-Spieltage nur eines der Ziele, damit verschiedene Pläne zur Wahl bleiben. + /// + public bool ViererVorrang { get; set; } + + /// Holt einen Wert, der angibt, ob noch kein Plan gefunden wurde. + public bool Leer + { + get + { + lock (sperre) + { + return loesungen.Count == 0; + } + } + } + + /// Nimmt einen Plan auf, wenn ihn keiner übertrifft. + /// true, wenn der Plan aufgenommen wurde. + public bool Hinzufuegen(Kompaktloesung neu) + { + lock (sperre) + { + if (loesungen.Count > 0) + { + int vergleich = Grundwertung(neu).CompareTo(Grundwertung(loesungen[0])); + if (vergleich > 0) + { + return false; + } + + if (vergleich < 0) + { + loesungen.Clear(); + } + } + + if (loesungen.Count >= Hoechstzahl || loesungen.Exists(l => Uebertrifft(l, neu) || l.Schluessel == neu.Schluessel)) + { + return false; + } + + loesungen.RemoveAll(l => Uebertrifft(neu, l)); + loesungen.Add(neu); + return true; + } + } + + /// Alle Pläne. + public List Alle() + { + lock (sperre) + { + return loesungen.ToList(); + } + } + + /// Was vor allem anderen zählt: erst keine Verstöße (Peter, 13.10.2026), dann Mannschaften ohne Heimspieltag, ggf. reine 4er-Pläne. + private (int Verstoesse, int OhneHeimspieltag, int MitDreiern) Grundwertung(Kompaktloesung l) => + (l.Verstoesse, l.OhneHeimspieltag, ViererVorrang && l.Dreier > 0 ? 1 : 0); + + /// Ob a in keiner Kennzahl schlechter und in mindestens einer besser ist als b. + private bool Uebertrifft(Kompaktloesung a, Kompaktloesung b) + { + bool gruppenGleich = !mitGruppen || a.GruppenTage == b.GruppenTage; + bool nichtSchlechter = a.Bewertungskm <= b.Bewertungskm && a.Abstand <= b.Abstand && a.Gemeinsam >= b.Gemeinsam && a.Heimspiele <= b.Heimspiele && a.KmSpanne <= b.KmSpanne + && a.Dreier <= b.Dreier && a.Einzel <= b.Einzel && (!mitGruppen || a.GruppenTage >= b.GruppenTage); + bool besser = a.Bewertungskm < b.Bewertungskm || a.Abstand < b.Abstand || a.Gemeinsam > b.Gemeinsam || a.Heimspiele < b.Heimspiele || a.KmSpanne < b.KmSpanne + || a.Dreier < b.Dreier || a.Einzel < b.Einzel || !gruppenGleich; + return nichtSchlechter && besser; + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktgesamt.cs b/src/AndiGenerator.Engine/Turniere/Kompaktgesamt.cs new file mode 100644 index 0000000..5d207ce --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktgesamt.cs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Gesamtwertung eines Plans aus Kompaktspieltagen (Peter, 13.10.2026: möglichst viele Ziele möglichst gut). Kleiner ist +/// besser; ein Punkt entspricht jeweils: 10 % mehr Kilometer als der kürzeste Plan, ein Spieltag Rückstand im Stand +/// (2 Spiele über dem kleinsten möglichen Abstand), kein gemeinsamer Spieltag mit zwei 4er-Spieltagen; drei Punkte ein +/// fehlender Spitzen- bzw. Abstiegsspieltag, liegt einer früh in der Halbrunde, bis zu einem halben Punkt. Ungleich weite Fahrten: +/// ein Punkt je ein Viertel der mittleren Kilometer einer Mannschaft, um die der Unterschied zwischen der Mannschaft mit +/// den meisten und der mit den wenigsten Kilometern über dem kleinsten möglichen liegt. Verstöße gegen die +/// Meldungen, Mannschaften ohne Heimspieltag und 3er-Spieltage wiegen deutlich schwerer. +/// +internal static class Kompaktgesamt +{ + /// Die Wertung. + /// Der Plan. + /// Kilometer des kürzesten Plans. + /// So viele Mannschaften sollen mindestens einmal am selben Tag spielen. + /// Kleinster Unterschied der Kilometer zwischen den Mannschaften unter den verglichenen Plänen. + /// Mittlere Kilometer einer Mannschaft (Maßstab für den Unterschied). + /// Punkte, kleiner ist besser. + public static double Wertung(Kompaktloesung l, double kmMin, int gemeinsamZiel, double spanneMin, double kmJeMannschaft) + { + double km = kmMin > 0 ? Math.Max(0, (l.Bewertungskm / kmMin) - 1) / 0.1 : 0; + double stand = Math.Max(0, l.Abstand - 2) / 2.0; + double gemeinsam = l.Gemeinsam < gemeinsamZiel ? 1 : 0; + double gruppen = (3.0 * l.GruppenFehlen) + (0.5 * l.GruppenFrueh); + double gleich = kmJeMannschaft > 0 ? Math.Max(0, l.KmSpanne - spanneMin) / (0.25 * kmJeMannschaft) : 0; + double pflicht = (10.0 * l.Verstoesse) + (15.0 * l.OhneHeimspieltag) + (3.0 * l.Dreier); + return km + stand + gemeinsam + gruppen + gleich + pflicht; + } + + /// Vergleich nach der Wertung (für das Verlegen der Spieltage). + /// Kilometer des kürzesten Plans. + /// Ziel für den gemeinsamen Spieltag. + /// Kleinster Unterschied der Kilometer zwischen den Mannschaften. + /// Mittlere Kilometer einer Mannschaft. + /// Der Vergleich. + public static Comparison Vergleich(double kmMin, int gemeinsamZiel, double spanneMin, double kmJeMannschaft) => + (a, b) => Wertung(a, kmMin, gemeinsamZiel, spanneMin, kmJeMannschaft).CompareTo(Wertung(b, kmMin, gemeinsamZiel, spanneMin, kmJeMannschaft)); +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktkandidat.cs b/src/AndiGenerator.Engine/Turniere/Kompaktkandidat.cs new file mode 100644 index 0000000..d809e4a --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktkandidat.cs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Ein möglicher Spieltag der Suche: Form, Mannschaften (Ausrichter zuerst) und Tag als Nummern, abgedeckte Paarungen. +/// Form. +/// Mannschaften in der Reihenfolge von . +/// Nummer des Tages. +/// Nummern der abgedeckten Paarungen. +/// Kilometer der Gäste, hin und zurück. +/// Verstöße gegen die Meldungen an diesem Tag. +internal sealed class Kompaktkandidat(Turnierform form, int[] mannschaften, int tag, int[] paare, double km, int verstoesse) +{ + public Turnierform Form { get; } = form; + + public int[] Mannschaften { get; } = mannschaften; + + public int Tag { get; } = tag; + + public int[] Paare { get; } = paare; + + public double Km { get; } = km; + + public int Verstoesse { get; } = verstoesse; + + public bool IstEinzel => Form == Turnierform.Einzel; + + public int Ausrichter => Mannschaften[0]; + + /// Derselbe Spieltag an einem anderen Tag. + public Kompaktkandidat AmTag(int neuerTag, int neueVerstoesse) => new(Form, Mannschaften, neuerTag, Paare, Km, neueVerstoesse); +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktkennzahlen.cs b/src/AndiGenerator.Engine/Turniere/Kompaktkennzahlen.cs new file mode 100644 index 0000000..e6a134f --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktkennzahlen.cs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Kennzahlen eines Plans aus Kompaktspieltagen. +/// Kilometer aller Gäste, hin und zurück (Luftlinie). +/// Größter Unterschied der Zahl gespielter Spiele zwischen zwei Mannschaften nach einem Spieltag. +/// Größte Zahl von Mannschaften, die am selben Tag spielen. +/// Verstöße gegen die Meldungen. +/// Mannschaften, die keinen Spieltag ausrichten. +/// Größter Unterschied der echten Heimspiele (Spiele in der eigenen Halle). +/// Zahl der 4er-Spieltage. +/// Zahl der 3er-Spieltage. +/// Zahl der Einzelbegegnungen. +/// Unterschied zwischen der Mannschaft mit den meisten und der mit den wenigsten Kilometern. +public sealed record Kompaktkennzahlen(double Km, int Abstand, int Gemeinsam, int Verstoesse, int OhneHeimspieltag, int Heimspiele, int Vierer, int Dreier, int Einzel, double KmSpanne) +{ + /// Holt die Kilometer der Einzelbegegnungen (sie zählen in der Bewertung doppelt). + public double EinzelKm { get; init; } + + /// Holt die Kilometer für die Bewertung: Einzelfahrten doppelt. + public double Bewertungskm => Km + EinzelKm; +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktlauf.cs b/src/AndiGenerator.Engine/Turniere/Kompaktlauf.cs new file mode 100644 index 0000000..59448ae --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktlauf.cs @@ -0,0 +1,501 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Diagnostics; +using System.Globalization; + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Ein Lauf des Kompaktplaners: zuerst die gezielte Konstruktion nach den Kriterien (), +/// ohne sie (HiGHS nicht verfügbar) wie bisher, wo es geht, die vollständige Suche nach dem kürzesten Plan nur aus 4er-Spieltagen +/// (), danach mehrere zufällige Suchen parallel bis zum Ende der Zeit (auch mit 3er-Spieltagen). +/// Findet sich kein Plan, werden schrittweise Verstöße gegen die Meldungen erlaubt, erst danach Mannschaften ohne +/// Heimspieltag. +/// +internal sealed class Kompaktlauf(Kompaktmodell modell, Kompaktplanoptionen optionen, Kompaktsteuerung steuerung, Action? fortschritt) +{ + /// Höchstzahl erlaubter Verstöße gegen die Meldungen. + public const int MaxVerstoesse = 6; + + private const int VersucheJeStufe = 200; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private readonly List<(string Name, Kompaktloesung Loesung)> ohneVerstoss = []; + private readonly List optimierungshinweise = []; + private readonly Stopwatch meldeuhr = Stopwatch.StartNew(); + private readonly Lock sperre = new(); + private long geprueft; + private long letzteMeldung = -1000; + private int stufe; + private int phasen = 1; + private int phase; + private TimeSpan suchbeginn; + + /// Holt die besten Pläne. + public Kompaktfront Front { get; } = new(); + + /// Holt die Zahl der Suchversuche. + public long Geprueft => Interlocked.Read(ref geprueft); + + /// + /// Holt die besten Pläne mit 3er-Spieltagen (ohne Verstöße): Daraus werden Varianten, wenn sie etwas können, was kein + /// Plan nur aus 4er-Spieltagen kann. + /// + public Kompaktfront Dreierfront { get; } = new(true); + + /// Holt einen Hinweis der Nachsuche ohne Verstöße, oder null. + public string? OhneVerstossHinweis { get; private set; } + + /// Holt die vollständige Suche nach 4er-Plänen, oder null, wenn sie für diese Aufgabe nicht geht. + public Viererbaum? Baum { get; private set; } + + /// Holt die Suche mit Spitzen- und Abstiegsspieltag, oder null, wenn sie nicht lief. + public Viererbaum? Gruppenbaum { get; private set; } + + /// Holt den kürzesten Plan mit Spitzen- und Abstiegsspieltag, oder null. + public Kompaktloesung? Gruppenloesung { get; private set; } + + /// Holt das beste Gesamtpaket () unter den gesammelten kurzen 4er-Plänen, oder null. + public Kompaktloesung? Gesamtloesung { get; private set; } + + /// Holt einen Wert, der angibt, ob die Pläne gezielt konstruiert wurden (). + public bool Optimiert { get; private set; } + + /// Holt das Signal „beenden“. + private CancellationToken Abbruch => steuerung.Token; + + /// Holt die Rechenzeit der Zufallssuche (ohne Pausen). + private TimeSpan Suchzeit => steuerung.Laufzeit - suchbeginn; + + /// + /// Die Pläne ganz ohne Verstöße aus der Nachsuche, wenn die übrige Suche nur Pläne mit Verstößen fand: mit Namen + /// („Ohne Verstöße“, „Ohne Verstöße, mit Einzelspielen“). + /// + /// Eine Kopie der Liste. + public List<(string Name, Kompaktloesung Loesung)> OhneVerstossPlaene() + { + lock (sperre) + { + return ohneVerstoss.ToList(); + } + } + + /// Die Hinweise der gezielten Konstruktion (was gefunden wurde und ob es beweisbar das Beste ist). + /// Eine Kopie der Liste. + public List Optimierungshinweise() + { + lock (sperre) + { + return optimierungshinweise.ToList(); + } + } + + /// Sucht, bis die Zeit abgelaufen, die Zahl der Versuche erreicht oder der Lauf abgebrochen ist. + public void Ausfuehren() + { + Front.ViererVorrang = modell.NurViererMoeglich; + if (optionen.Loeser is IGanzzahlloeser loeser) + { + Optimiert = true; + phasen = 2; + Optimieren(loeser); + phase++; + } + else + { + Durchsuchen(); + } + + suchbeginn = steuerung.Laufzeit; + int threads = Math.Max(1, optionen.Threads); + if (threads == 1) + { + Arbeiten(0); + } + else + { + Parallel.For(0, threads, new ParallelOptions { MaxDegreeOfParallelism = threads }, Arbeiten); + } + + OhneVerstoesseSuchen(); + Senden("Fertig.", 1, true); + } + + /// Bisheriger Weg ohne Optimierung: vollständige 4er-Suche, Spitzen- und Abstiegsspieltag, Gesamtpaket. + private void Durchsuchen() + { + int threads = Math.Max(1, optionen.Threads); + bool vierer = Viererbaum.Moeglich(modell); + bool gruppen = vierer && modell.Gruppen.Count > 0 && modell.Gruppen[0].Length == 4; + phasen = 1 + (vierer ? 2 : 0) + (gruppen ? 1 : 0); + if (vierer) + { + Baum = new Viererbaum(modell, [], "Nur 4er-Spieltage"); + Viererbaum baum = Baum; + baum.Suchen(threads, optionen.ExaktDauer, steuerung, text => Melden(text, baum.Anteil)); + if (Baum.Beste is IReadOnlyList turniere) + { + Aufnehmen(new Kompaktloesung(modell, turniere)); + } + + phase++; + } + + if (gruppen) + { + Gruppenbaum = new Viererbaum(modell, modell.Gruppen, "Mit Spitzen- und Abstiegsspieltag"); + Viererbaum baum = Gruppenbaum; + baum.Suchen(threads, optionen.ExaktDauer, steuerung, text => Melden(text, baum.Anteil)); + if (Gruppenbaum.Beste is IReadOnlyList gruppenturniere) + { + // Sonntage bleiben, wie die Suche sie gelegt hat (Gruppenspieltage so spät wie möglich). + Gruppenloesung = Kompaktverbesserung.EinzelAusgleichen(modell, new Kompaktloesung(modell, gruppenturniere)); + } + + phase++; + } + + if (vierer) + { + GesamtpaketSuchen(threads); + phase++; + } + } + + /// + /// Konstruiert die Pläne gezielt () auf allen Kernen: zugleich den kürzesten Plan nur + /// aus 4er-Spieltagen (wenn das aufgehen kann) und den kürzesten überhaupt; dann den mit den wenigsten Punkten (bestes + /// Gesamtpaket); zuletzt zugleich einen mit Spitzen- und Abstiegsspieltag (wenn ihn das Gesamtpaket nicht hat) und + /// weitere Pläne mit anderen Spieltagen zur Auswahl. Jeder Schritt rechnet mehrere Läufe des Lösers parallel. + /// + private void Optimieren(IGanzzahlloeser loeser) + { + var optimierung = new Kompaktoptimierung(modell, loeser); + TimeSpan beginn = steuerung.Laufzeit; + TimeSpan gesamt = optionen.ExaktDauer; + int kerne = Math.Max(1, optionen.Threads); + bool gruppen = modell.Gruppen.Count > 0; + int schritte = (modell.NurViererMoeglich ? 1 : 0) + 2 + (gruppen ? 1 : 0) + 2; + int schritt = 0; + + Kompaktloesung? Schritt(string text, Kompaktauftrag auftrag, Kompaktloesung? start, double anteilZeit, int parallel) + { + TimeSpan seit = steuerung.Laufzeit; + TimeSpan grenze = TimeSpan.FromTicks(Math.Min((long)(gesamt.Ticks * anteilZeit), (gesamt - (seit - beginn)).Ticks)); + if (grenze <= TimeSpan.Zero || Abbruch.IsCancellationRequested) + { + Interlocked.Increment(ref schritt); + return null; + } + + bool Aufhoeren() + { + steuerung.Warten(); + TimeSpan verstrichen = steuerung.Laufzeit - seit; + double anteil = (Volatile.Read(ref schritt) + Math.Min(1, verstrichen / grenze)) / schritte; + Melden(string.Create(Deutsch, $"{text} ({verstrichen.TotalSeconds:N0} s, {parallel} Läufe parallel) …"), anteil); + return Abbruch.IsCancellationRequested || verstrichen > grenze; + } + + // HiGHS bekommt die Zeit des Schritts mit Reserve als harte Grenze: Sie greift auch in Phasen ohne Rückfrage + // (Stopp, Fortschritt); die Reserve deckt kurze Pausen, in denen die Uhr des Lösers weiterläuft. + TimeSpan hart = grenze + TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(15).Ticks, grenze.Ticks / 2)); + (Kompaktloesung? l, bool optimal) = optimierung.Suchen(auftrag, start, Math.Max(1, parallel), Aufhoeren, hart); + Interlocked.Increment(ref schritt); + if (l is null) + { + return null; + } + + Aufnehmen(l); + string beweis = optimal ? "bestmöglich" : "nicht bewiesen bestmöglich (Zeit)"; + lock (sperre) + { + optimierungshinweise.Add(string.Create(Deutsch, $"{text}: {l.Km:N0} km, {l.Vierer}× 4er, {l.Dreier}× 3er, {l.Einzel} Einzel, {l.Verstoesse} Verstöße – {beweis}.")); + } + + return l; + } + + // Startpunkte aus der Zufallssuche, damit der Löser sofort einen vollständigen Plan hat. + Kompaktloesung? vierer = null; + Kompaktloesung? kurz = null; + if (modell.NurViererMoeglich) + { + Parallel.Invoke( + () => vierer = Schritt("Kürzester Plan nur aus 4er-Spieltagen", new Kompaktauftrag(null, true, false, false, false), Zufallsplan(true), 0.25, kerne / 2), + () => kurz = Schritt("Kürzester Plan", new Kompaktauftrag(null, false, false, false, false), Zufallsplan(false), 0.25, kerne - (kerne / 2))); + } + else + { + kurz = Schritt("Kürzester Plan", new Kompaktauftrag(null, false, false, false, false), Zufallsplan(false), 0.25, kerne); + } + + double? bezug = (vierer ?? kurz)?.Bewertungskm; + var punkte = new Kompaktauftrag(bezug, false, vierer is not null, gruppen, false); + Kompaktloesung? best = Schritt("Plan mit den wenigsten Punkten", punkte, vierer ?? kurz, 0.3, kerne); + if (best is not null && bezug is double kmMin) + { + double spanneMin = Front.Alle().Select(l => l.KmSpanne).DefaultIfEmpty(best.KmSpanne).Min(); + Comparison vergleich = Kompaktgesamt.Vergleich(kmMin, 4 * (modell.N / 4), spanneMin, kmMin / modell.N); + Gesamtloesung = Kompaktverbesserung.TageVerteilen(modell, Kompaktverbesserung.EinzelAusgleichen(modell, best), vergleich); + Front.Hinzufuegen(Gesamtloesung); + } + + if (best is null) + { + return; + } + + // Zugleich: Spitzen- und Abstiegsspieltag (falls nötig) und zwei weitere Pläne mit anderen Spieltagen. + bool mitGruppen = gruppen && best.GruppenFehlen > 0; + int teile = mitGruppen ? 3 : 2; + Parallel.Invoke( + () => + { + if (mitGruppen && Schritt("Plan mit Spitzen- und Abstiegsspieltag", punkte with { GruppenPflicht = true }, null, 0.15, kerne / teile) is Kompaktloesung g) + { + Gruppenloesung = Kompaktverbesserung.EinzelAusgleichen(modell, g); + } + }, + () => Schritt("Weiterer Plan mit anderen Spieltagen", punkte with { Ausser = [best] }, kurz, 0.15, kerne / teile), + () => Schritt("Weiterer Plan mit anderen Spieltagen", punkte with { Ausser = [best, .. new[] { kurz }.OfType()] }, vierer, 0.15, kerne - (2 * (kerne / teile)))); + } + + /// Ein Plan der Zufallssuche ohne Verstöße als Startpunkt für den Löser (höchstens zwei Sekunden), oder null. + private Kompaktloesung? Zufallsplan(bool nurVierer) + { + var suche = new Kompaktsuche(modell, optionen.Startwert); + var uhr = Stopwatch.StartNew(); + while (uhr.Elapsed < TimeSpan.FromSeconds(2) && !Abbruch.IsCancellationRequested) + { + if (suche.Versuch(0, modell.MindestensOhneAusrichtung, nurVierer) is IReadOnlyList turniere) + { + return new Kompaktloesung(modell, turniere); + } + } + + return null; + } + + /// Meldet den Stand höchstens fünfmal je Sekunde; der Anteil gilt für den laufenden Schritt. + private void Melden(string text, double anteilImSchritt) => Senden(text, anteilImSchritt, false); + + private void Senden(string text, double anteilImSchritt, bool immer) + { + if (fortschritt is null) + { + return; + } + + long jetzt = meldeuhr.ElapsedMilliseconds; + long letzte = Interlocked.Read(ref letzteMeldung); + if (!immer && (jetzt - letzte < 200 || Interlocked.CompareExchange(ref letzteMeldung, jetzt, letzte) != letzte)) + { + return; + } + + int schritt = Math.Min(phase, phasen - 1); + double anteil = Math.Clamp((schritt + Math.Clamp(anteilImSchritt, 0, 1)) / phasen, 0, 1); + fortschritt(new Kompaktfortschritt(string.Create(Deutsch, $"Schritt {schritt + 1} von {phasen} – {text}"), anteil)); + } + + /// + /// Das beste Gesamtpaket: Für jeden gesammelten kurzen 4er-Plan (mit und ohne Spitzen- und Abstiegsspieltag) werden die + /// Spieltage so auf die Sonntage verlegt, dass die Gesamtwertung am besten ist; der beste gilt. + /// + private void GesamtpaketSuchen(int threads) + { + if (Baum?.Beste is not IReadOnlyList kuerzeste) + { + return; + } + + double kmMin = kuerzeste.Sum(k => k.Km); + int ziel = 4 * (modell.N / 4); + List> plaene = [.. Baum.Gesammelt(), .. Gruppenbaum?.Gesammelt() ?? []]; + + // Der Unterschied der Kilometer hängt nicht von den Sonntagen ab: kleinster Wert vorab über alle gesammelten Pläne. + double spanneMin = plaene.Select(p => new Kompaktloesung(modell, p).KmSpanne).DefaultIfEmpty(0).Min(); + Comparison vergleich = Kompaktgesamt.Vergleich(kmMin, ziel, spanneMin, kmMin / modell.N); + var parallel = new ParallelOptions { MaxDegreeOfParallelism = threads, CancellationToken = Abbruch }; + int fertig = 0; + try + { + Parallel.ForEach(plaene, parallel, plan => + { + steuerung.Warten(); + int nummer = Interlocked.Increment(ref fertig); + Melden(string.Create(Deutsch, $"Bestes Gesamtpaket: {nummer:N0} von {plaene.Count:N0} kurzen Plänen bewertet …"), nummer / (double)plaene.Count); + Kompaktloesung l = Kompaktverbesserung.EinzelAusgleichen(modell, new Kompaktloesung(modell, plan)); + l = Kompaktverbesserung.TageVerteilen(modell, l, vergleich); + Front.Hinzufuegen(l); + lock (sperre) + { + if (Gesamtloesung is null || vergleich(l, Gesamtloesung) < 0) + { + Gesamtloesung = l; + } + } + }); + } + catch (OperationCanceledException) + { + // Abgebrochen: das bis dahin beste Gesamtpaket gilt. + Senden("Gesamtpaket abgebrochen.", 1, true); + } + } + + /// + /// Nachsuche, wenn kein gefundener Plan ohne Verstöße ist (Peter, 13.10.2026): vollständig nach einem Plan ganz ohne + /// Verstoß, zuerst mit möglichst wenigen Mannschaften ohne Heimspieltag, dann – wenn dabei eine Mannschaft ohne + /// Heimspiel bleibt – mit Einzelspielen, sodass jede wenigstens ein Heimspiel hat. + /// + private void OhneVerstoesseSuchen() + { + // Die gezielte Konstruktion sucht schon nach möglichst wenigen Verstößen. + if (Optimiert || Abbruch.IsCancellationRequested || Front.Alle().Exists(l => l.Verstoesse == 0)) + { + return; + } + + var baum = new Meldungsbaum(modell); + TimeSpan beginn = steuerung.Laufzeit; + TimeSpan Rest() => optionen.ExaktDauer - (steuerung.Laufzeit - beginn); + int grund = modell.N % 2 == 0 ? modell.N / 2 : 0; + Kompaktloesung? ohneEinzel = null; + for (int ohne = modell.MindestensOhneAusrichtung; ohne <= modell.N && Rest() > TimeSpan.Zero; ohne++) + { + Melden(string.Create(Deutsch, $"Pläne ohne Verstöße: höchstens {ohne} Mannschaft(en) ohne Heimspieltag …"), 1); + if (baum.Suchen(grund, ohne, false, Rest(), steuerung) is IReadOnlyList turniere) + { + ohneEinzel = new Kompaktloesung(modell, turniere); + Merken("Ohne Verstöße", ohneEinzel); + break; + } + + if (baum.Abgebrochen) + { + break; + } + } + + if (ohneEinzel is null) + { + OhneVerstossHinweis = baum.Abgebrochen || Rest() <= TimeSpan.Zero + ? "Ein Plan ganz ohne Verstöße wurde in der verfügbaren Zeit nicht gefunden." + : "Mit diesen Meldungen gibt es keinen Plan ganz ohne Verstöße (vollständig geprüft)."; + return; + } + + if (ohneEinzel.OhneHeimspieltag <= modell.MindestensOhneAusrichtung) + { + return; + } + + for (int extra = 1; extra <= modell.N / 2 && Rest() > TimeSpan.Zero; extra++) + { + Melden(string.Create(Deutsch, $"Pläne ohne Verstöße mit {extra} Einzelspiel(en), jede Mannschaft mit Heimspiel …"), 1); + if (baum.Suchen(grund + extra, modell.MindestensOhneAusrichtung, true, Rest(), steuerung) is IReadOnlyList turniere) + { + Merken("Ohne Verstöße, mit Einzelspielen", new Kompaktloesung(modell, turniere)); + return; + } + + if (baum.Abgebrochen) + { + return; + } + } + } + + private void Merken(string name, Kompaktloesung loesung) + { + lock (sperre) + { + ohneVerstoss.Add((name, loesung)); + } + } + + /// Erlaubte Verstöße und Mannschaften ohne Heimspieltag einer Stufe. + private (int Verstoesse, int OhneAusrichtung) Grenzen(int s) => + s <= MaxVerstoesse + ? (s, modell.MindestensOhneAusrichtung) + : (MaxVerstoesse, Math.Min(modell.N, modell.MindestensOhneAusrichtung + s - MaxVerstoesse)); + + private void Arbeiten(int nummer) + { + var suche = new Kompaktsuche(modell, unchecked(optionen.Startwert + (nummer * 7919))); + int versuche = 0; + while (Weiter(versuche)) + { + steuerung.Warten(); + versuche++; + int s = Volatile.Read(ref stufe); + (int verstoesse, int ohne) = Grenzen(s); + + // Jeder zweite Versuch nur mit 4er-Spieltagen, wenn das rechnerisch aufgehen kann. + bool nurVierer = modell.NurViererMoeglich && versuche % 2 == 0; + IReadOnlyList? turniere = suche.Versuch(verstoesse, ohne, nurVierer); + long anzahl = Interlocked.Increment(ref geprueft); + if (turniere is not null) + { + Aufnehmen(new Kompaktloesung(modell, turniere)); + } + else + { + Lockern(s, versuche); + } + + if (anzahl % 64 == 0) + { + double anteil = optionen.Versuche is int max + ? anzahl / (double)(max * Math.Max(1, optionen.Threads)) + : Suchzeit / optionen.Dauer; + Melden(string.Create(Deutsch, $"Weitere Varianten (auch mit 3er-Spieltagen): {anzahl:N0} Versuche …"), anteil); + } + } + } + + private bool Weiter(int versuche) => + !Abbruch.IsCancellationRequested && (optionen.Versuche is int hoechstens ? versuche < hoechstens : Suchzeit < optionen.Dauer); + + /// Ohne gefundenen Plan nach einer gewissen Zeit die nächste Stufe erlauben. + private void Lockern(int s, int versuche) + { + if (!Front.Leer || s >= MaxVerstoesse + modell.N) + { + return; + } + + bool faellig = optionen.Versuche is not null + ? versuche % VersucheJeStufe == 0 + : Suchzeit > optionen.Dauer * (s + 1) / 16; + if (faellig) + { + Interlocked.CompareExchange(ref stufe, s + 1, s); + } + } + + private void Aufnehmen(Kompaktloesung loesung) + { + Kompaktloesung ausgeglichen = Kompaktverbesserung.EinzelAusgleichen(modell, loesung); + if (ausgeglichen.Dreier > 0 && ausgeglichen.Verstoesse == 0 && ausgeglichen.OhneHeimspieltag <= modell.MindestensOhneAusrichtung + && Dreierfront.Hinzufuegen(ausgeglichen)) + { + Dreierfront.Hinzufuegen(Kompaktverbesserung.TageVerteilen(modell, ausgeglichen, Kompaktverbesserung.Stand)); + Dreierfront.Hinzufuegen(Kompaktverbesserung.TageVerteilen(modell, ausgeglichen, Kompaktverbesserung.Gemeinsam)); + } + + if (!Front.Hinzufuegen(ausgeglichen)) + { + return; + } + + Front.Hinzufuegen(Kompaktverbesserung.TageVerteilen(modell, ausgeglichen, Kompaktverbesserung.Stand)); + Front.Hinzufuegen(Kompaktverbesserung.TageVerteilen(modell, ausgeglichen, Kompaktverbesserung.Gemeinsam)); + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktloesung.cs b/src/AndiGenerator.Engine/Turniere/Kompaktloesung.cs new file mode 100644 index 0000000..421d501 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktloesung.cs @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; + +namespace AndiGenerator.Engine.Turniere; + +/// Ein vollständiger Plan der Suche mit seinen Kennzahlen. +internal sealed class Kompaktloesung +{ + private readonly Kompaktmodell modell; + private string? schluessel; + + /// Bewertet einen Plan. + public Kompaktloesung(Kompaktmodell modell, IReadOnlyList turniere) + { + this.modell = modell; + Turniere = turniere.ToArray(); + Km = Turniere.Sum(k => k.Km); + EinzelKm = Turniere.Where(k => k.IstEinzel).Sum(k => k.Km); + Verstoesse = Turniere.Sum(k => k.Verstoesse); + var ausgerichtet = new int[modell.N]; + var heimspiele = new int[modell.N]; + foreach (Kompaktkandidat k in Turniere) + { + heimspiele[k.Ausrichter] += k.IstEinzel ? 1 : 2; + ausgerichtet[k.Ausrichter] += k.IstEinzel ? 0 : 1; + } + + OhneHeimspieltag = ausgerichtet.Count(a => a == 0); + OhneHeimspiel = heimspiele.Count(h => h == 0); + Vierer = Turniere.Count(k => k.Form == Turnierform.Vierer); + Dreier = Turniere.Count(k => k.Form == Turnierform.Dreier); + Einzel = Turniere.Count(k => k.IstEinzel); + Heimspiele = modell.N == 0 ? 0 : heimspiele.Max() - heimspiele.Min(); + (Abstand, SummeAbstand, Gemeinsam) = Stand(); + (GruppenFehlen, GruppenFrueh, GruppenTage) = Gruppen(); + var kmJe = new double[modell.N]; + foreach (Kompaktkandidat k in Turniere) + { + for (int i = 1; i < k.Mannschaften.Length; i++) + { + kmJe[k.Mannschaften[i]] += 2 * modell.Entfernung(k.Mannschaften[i], k.Ausrichter); + } + } + + KmSpanne = modell.N == 0 ? 0 : kmJe.Max() - kmJe.Min(); + } + + public Kompaktkandidat[] Turniere { get; } + + public double Km { get; } + + /// Holt die Kilometer der Einzelbegegnungen (Gast hin und zurück). + public double EinzelKm { get; } + + /// + /// Holt die Kilometer für die Bewertung: Fahrten zu Einzelbegegnungen zählen doppelt (Peter, 13.10.2026), damit lange + /// Einzelfahrten – eine Mannschaft fährt weit für ein einziges Spiel – vermieden werden. + /// + public double Bewertungskm => Km + EinzelKm; + + public int Verstoesse { get; } + + public int OhneHeimspieltag { get; } + + public int Heimspiele { get; } + + /// Holt, wie viele Mannschaften gar kein Heimspiel haben (weder Spieltag noch Einzelbegegnung zuhause). + public int OhneHeimspiel { get; } + + public int Vierer { get; } + + public int Dreier { get; } + + public int Einzel { get; } + + /// Holt den größten Unterschied gespielter Spiele nach einem Spieltag. + public int Abstand { get; } + + /// Holt die Summe der Unterschiede nach allen Spieltagen (feinere Wertung des Stands). + public int SummeAbstand { get; } + + /// Holt die größte Zahl von Mannschaften an einem Tag. + public int Gemeinsam { get; } + + /// Holt den Unterschied zwischen der Mannschaft mit den meisten und der mit den wenigsten Kilometern. + public double KmSpanne { get; } + + /// + /// Holt, wie viele der Gruppen (Spitze, Keller) keinen eigenen Spieltag haben: weder einen 4er-Spieltag der ganzen Gruppe + /// noch einen 3er-Spieltag ihrer drei stärksten bzw. schwächsten Mannschaften. + /// + public int GruppenFehlen { get; } + + /// Holt, wie früh die Gruppenspieltage liegen: je Gruppe 0 = am letzten, 1 = am ersten Spieltag des Plans. + public double GruppenFrueh { get; } + + /// + /// Holt, wie viele Gruppen einen eigenen Spieltag haben: einen 4er-Spieltag der ganzen Gruppe oder wenigstens einen + /// 3er-Spieltag ihrer drei stärksten bzw. schwächsten Mannschaften. + /// + public int GruppenTage { get; } + + /// Holt einen Text, der den Plan eindeutig beschreibt (zum Erkennen doppelter Pläne). + public string Schluessel => schluessel ??= string.Join( + ';', + Turniere.Select(k => string.Create(CultureInfo.InvariantCulture, $"{k.Tag}:{(int)k.Form}:{string.Join(',', k.Mannschaften)}")).Order(StringComparer.Ordinal)); + + /// Die Kennzahlen für die Ausgabe. + public Kompaktkennzahlen Kennzahlen() => new(Km, Abstand, Gemeinsam, Verstoesse, OhneHeimspieltag, Heimspiele, Vierer, Dreier, Einzel, KmSpanne) { EinzelKm = EinzelKm }; + + /// Die Spieltage mit Namen, nach Datum. + public List Planturniere() => Turniere + .OrderBy(k => k.Tag) + .ThenBy(k => k.IstEinzel) + .ThenBy(k => modell.Namen[k.Ausrichter], StringComparer.Ordinal) + .Select(k => new Planturnier(modell.Tage[k.Tag], k.Form, k.Mannschaften.Select(m => modell.Namen[m]).ToList())) + .ToList(); + + private static void Zaehlen(Kompaktkandidat k, int[] gespielt) + { + foreach (int m in k.Mannschaften) + { + gespielt[m] += k.IstEinzel ? 1 : 2; + } + } + + private (int Fehlen, double Frueh, int Tage) Gruppen() + { + if (modell.Gruppen.Count == 0 || Turniere.Length == 0) + { + return (0, 0, 0); + } + + int erster = Turniere.Min(k => k.Tag); + int letzter = Turniere.Max(k => k.Tag); + int fehlen = 0; + int tage = 0; + double frueh = 0; + foreach (int[] g in modell.Gruppen) + { + // Ein 4er-Spieltag der Gruppe (die ersten beiden gegeneinander) oder ein 3er-Spieltag ihrer ersten drei (Peter, + // 13.10.2026: in Plänen aus 3er-Spieltagen bilden die drei Stärksten bzw. Schwächsten die Gruppe). + Kompaktkandidat? k = Array.Find(Turniere, x => x.Form == Turnierform.Vierer && Kompaktoptimierung.IstGruppentag(x, g)) + ?? Array.Find(Turniere, x => Kompaktoptimierung.IstGruppentag(x, g)); + if (k is null) + { + fehlen++; + continue; + } + + tage++; + if (letzter > erster) + { + frueh += (double)(letzter - k.Tag) / (letzter - erster); + } + } + + return (fehlen, frueh, tage); + } + + private (int Abstand, int Summe, int Gemeinsam) Stand() + { + var gespielt = new int[modell.N]; + var amTag = new int[modell.T]; + var gespieltAmTag = new bool[modell.T]; + foreach (Kompaktkandidat k in Turniere) + { + amTag[k.Tag] += k.Mannschaften.Length; + gespieltAmTag[k.Tag] = true; + } + + int abstand = 0; + int summe = 0; + for (int tag = 0; tag < modell.T; tag++) + { + if (!gespieltAmTag[tag]) + { + continue; + } + + foreach (Kompaktkandidat k in Turniere.Where(x => x.Tag == tag)) + { + Zaehlen(k, gespielt); + } + + int unterschied = gespielt.Max() - gespielt.Min(); + abstand = Math.Max(abstand, unterschied); + summe += unterschied; + } + + return (abstand, summe, amTag.DefaultIfEmpty(0).Max()); + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktmodell.cs b/src/AndiGenerator.Engine/Turniere/Kompaktmodell.cs new file mode 100644 index 0000000..8735945 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktmodell.cs @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Turniere; + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Aufbereitete Aufgabe des Kompaktplaners: Mannschaften, Tage und Paarungen als Nummern, Meldungen als Tabellen und alle +/// möglichen Spieltage (Dreier, Vierer, bei gerader Mannschaftszahl Einzelbegegnungen) je Paarung, ohne Verstöße zuerst. +/// +internal sealed class Kompaktmodell +{ + /// Höchstzahl der Verstöße gegen die Meldungen, die ein einzelner Spieltag haben darf. + public const int MaxVerstoesseJeSpieltag = 2; + + /// Einzelbegegnungen unter der Woche nur bis zu dieser Entfernung (Luftlinie, km; 60-km-Regel). + public const double EinzelUnterDerWocheKm = 60; + + /// So viele „Verstöße“ hat ein Spieltag, der an diesem Tag gar nicht geht (z. B. ein 4er-Spieltag am Mittwoch). + private const int Unzulaessig = 99; + + private readonly double[] km; + private readonly bool[] heim; + private readonly bool[] auswaerts; + private readonly bool[] zweiTische; + private readonly bool[] wochenende; + private readonly int[] paarIndex; + private readonly List hinweise = []; + + /// Bereitet die Aufgabe auf. + public Kompaktmodell(Kompaktaufgabe aufgabe) + { + Namen = aufgabe.Mannschaften.Distinct(StringComparer.Ordinal).ToArray(); + N = Namen.Length; + Tage = aufgabe.Tage.Distinct().Order().ToArray(); + T = Tage.Length; + wochenende = Tage.Select(t => t.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday).ToArray(); + km = new double[N * N]; + heim = new bool[N * T]; + auswaerts = new bool[N * T]; + zweiTische = new bool[N]; + paarIndex = new int[N * N]; + Paare = PaareNummerieren(); + EntfernungenLesen(aufgabe.Entfernung); + MeldungenLesen(aufgabe.Meldungen); + var kandidaten = new List(); + for (int h = 0; h < N; h++) + { + DreierErzeugen(h, kandidaten); + ViererErzeugen(h, kandidaten); + EinzelErzeugen(h, kandidaten); + } + + JePaar = Verteilen(kandidaten); + JePaarOhneDreier = Verteilen(kandidaten.Where(k => k.Form != Turnierform.Dreier).ToList()); + KmSkala = Math.Max(1, kandidaten.Select(k => k.Km).DefaultIfEmpty(0).Average()); + hinweise.AddRange(aufgabe.Hinweise); + Gruppen = GruppenLesen(aufgabe.Spitze, aufgabe.Keller); + + // Bei gerader Zahl bleibt je Mannschaft eine Einzelbegegnung; jeder Dreier oder Vierer deckt mindestens drei + // Paarungen. Bei wenigen Mannschaften (z. B. 5 oder 6) reichen die Spieltage nicht für einen Heimspieltag je Mannschaft. + int turnierpaare = Paare - (N % 2 == 0 ? N / 2 : 0); + MindestensOhneAusrichtung = Math.Max(0, N - (turnierpaare / 3)); + + // Nur aus 4er-Spieltagen (Peter, 13.10.2026: muss gefunden werden, wenn es geht) – lohnt sich nur, wenn die Paarungen + // genau aufgehen und trotzdem jede Mannschaft ausrichten kann (z. B. 9 oder 10 Mannschaften, nicht 8). + NurViererMoeglich = turnierpaare > 0 && turnierpaare % 4 == 0 && N - (turnierpaare / 4) <= MindestensOhneAusrichtung; + + // Vierer bevorzugen nur, wenn genug Paarungen für einen 4er-Spieltag je Mannschaft da sind; bei 6 bis 8 Mannschaften + // braucht es 3er-Spieltage, damit möglichst jede ausrichten kann. + ViererBevorzugen = turnierpaare >= 4 * N; + } + + public string[] Namen { get; } + + public int N { get; } + + public DateOnly[] Tage { get; } + + public int T { get; } + + public int Paare { get; } + + /// Holt je Paarung die möglichen Spieltage, nach Verstößen aufsteigend. + public Kompaktkandidat[][] JePaar { get; } + + /// Holt je Paarung die möglichen Vierer und Einzelbegegnungen (Suche nur mit 4er-Spieltagen). + public Kompaktkandidat[][] JePaarOhneDreier { get; } + + /// Holt einen Wert, der angibt, ob ein Plan nur aus 4er-Spieltagen (und Einzelbegegnungen) rechnerisch möglich ist. + public bool NurViererMoeglich { get; } + + /// Holt einen Wert, der angibt, ob die Suche 4er-Spieltage bevorzugt (ab 9 Mannschaften). + public bool ViererBevorzugen { get; } + + /// Holt, wie viele Mannschaften rechnerisch mindestens ohne Heimspieltag bleiben. + public int MindestensOhneAusrichtung { get; } + + /// Holt Spitzen- und Abstiegsgruppe (je vier Mannschaften, die ersten beiden spielen gegeneinander) oder leer. + public IReadOnlyList Gruppen { get; } + + /// Holt die mittleren Kilometer eines Spieltags (Maßstab für die Zufallsstreuung der Suche). + public double KmSkala { get; } + + /// Holt Hinweise zu den Eingaben (z. B. fehlende QTTR-Werte). + public IReadOnlyList Hinweise => hinweise; + + /// + /// Verstöße gegen die Meldungen: Ausrichter kann nicht zuhause (beim Vierer: oder nicht an zwei Tischen), Gäste nicht + /// auswärts. Unter der Woche gehen nur Einzelbegegnungen bis 60 km Luftlinie (Peter, 13.10.2026: Kompaktspieltage nur + /// samstags und sonntags); alles andere dort gilt als unzulässig. + /// + public int Verstoesse(Turnierform form, int[] mannschaften, int tag) + { + int ausrichter = mannschaften[0]; + if (!wochenende[tag] && (form != Turnierform.Einzel || km[(mannschaften[1] * N) + ausrichter] > EinzelUnterDerWocheKm)) + { + return Unzulaessig; + } + + bool kannAusrichten = heim[(ausrichter * T) + tag] && (form != Turnierform.Vierer || zweiTische[ausrichter]); + int gaeste = 0; + for (int i = 1; i < mannschaften.Length; i++) + { + gaeste += auswaerts[(mannschaften[i] * T) + tag] ? 0 : 1; + } + + return (kannAusrichten ? 0 : 1) + gaeste; + } + + /// Ob die Mannschaft an diesem Sonntag zuhause spielen kann. + /// Nummer der Mannschaft. + /// Nummer des Sonntags. + /// true, wenn ein Heimspiel gemeldet ist. + public bool KannHeim(int mannschaft, int tag) => heim[(mannschaft * T) + tag]; + + /// Ein Spieltag mit allen abgeleiteten Angaben. + public Kompaktkandidat Kandidat(Turnierform form, int[] mannschaften, int tag) => + new(form, mannschaften, tag, PaareVon(form, mannschaften), Strecke(mannschaften), Verstoesse(form, mannschaften, tag)); + + /// Die Nummern der Paarungen eines Spieltags. + public int[] PaareFuer(Turnierform form, int[] mannschaften) => PaareVon(form, mannschaften); + + /// Kilometer der Gäste zur Halle des Ausrichters, hin und zurück. + public double StreckeFuer(int[] mannschaften) => Strecke(mannschaften); + + /// Einfache Entfernung zwischen den Hallen zweier Mannschaften. + public double Entfernung(int von, int nach) => km[(von * N) + nach]; + + private Kompaktkandidat[][] Verteilen(List kandidaten) + { + var je = new List[Paare]; + for (int p = 0; p < Paare; p++) + { + je[p] = []; + } + + foreach (Kompaktkandidat k in kandidaten) + { + foreach (int p in k.Paare) + { + je[p].Add(k); + } + } + + return je.Select(l => l.OrderBy(k => k.Verstoesse).ToArray()).ToArray(); + } + + private int PaareNummerieren() + { + int p = 0; + for (int a = 0; a < N; a++) + { + for (int b = a + 1; b < N; b++) + { + paarIndex[(a * N) + b] = p; + paarIndex[(b * N) + a] = p; + p++; + } + } + + return p; + } + + private int Paar(int a, int b) => paarIndex[(a * N) + b]; + + private void EntfernungenLesen(Func entfernung) + { + for (int a = 0; a < N; a++) + { + for (int b = 0; b < N; b++) + { + km[(a * N) + b] = a == b ? 0 : Math.Max(0, entfernung(Namen[a], Namen[b])); + } + } + } + + private void MeldungenLesen(IReadOnlyList meldungen) + { + for (int i = 0; i < N; i++) + { + // Ohne Meldung gilt jedes Wochenende als möglich (das meldet schon die Prüfung der Meldungen), unter der Woche + // nur auswärts. + Turniermeldung? meldung = Turnierpruefung.Meldung(Namen[i], meldungen); + zweiTische[i] = meldung?.ZweiTische ?? true; + for (int d = 0; d < T; d++) + { + Turnierbereitschaft standard = wochenende[d] ? Turnierbereitschaft.Heim | Turnierbereitschaft.Auswaerts : Turnierbereitschaft.Auswaerts; + Turnierbereitschaft b = meldung?.An(Tage[d]) ?? standard; + heim[(i * T) + d] = b.HasFlag(Turnierbereitschaft.Heim); + auswaerts[(i * T) + d] = b.HasFlag(Turnierbereitschaft.Auswaerts); + } + } + } + + private int[] PaareVon(Turnierform form, int[] m) => form switch + { + Turnierform.Vierer => [Paar(m[0], m[2]), Paar(m[0], m[3]), Paar(m[1], m[2]), Paar(m[1], m[3])], + Turnierform.Dreier => [Paar(m[0], m[1]), Paar(m[0], m[2]), Paar(m[1], m[2])], + _ => [Paar(m[0], m[1])], + }; + + /// Kilometer der Gäste zur Halle des Ausrichters, hin und zurück. + private double Strecke(int[] mannschaften) + { + double summe = 0; + for (int i = 1; i < mannschaften.Length; i++) + { + summe += 2 * km[(mannschaften[i] * N) + mannschaften[0]]; + } + + return summe; + } + + private void Hinzufuegen(Turnierform form, int[] mannschaften, List kandidaten) + { + int[] paare = PaareVon(form, mannschaften); + double strecke = Strecke(mannschaften); + for (int d = 0; d < T; d++) + { + int verstoesse = Verstoesse(form, mannschaften, d); + if (verstoesse <= MaxVerstoesseJeSpieltag) + { + kandidaten.Add(new Kompaktkandidat(form, mannschaften, d, paare, strecke, verstoesse)); + } + } + } + + private void DreierErzeugen(int h, List kandidaten) + { + for (int a = 0; a < N; a++) + { + for (int b = a + 1; b < N; b++) + { + if (a != h && b != h) + { + Hinzufuegen(Turnierform.Dreier, [h, a, b], kandidaten); + } + } + } + } + + /// Vierer: Ausrichter h und Partner b spielen gegen c und e. + private void ViererErzeugen(int h, List kandidaten) + { + for (int b = 0; b < N; b++) + { + if (b == h) + { + continue; + } + + for (int c = 0; c < N; c++) + { + for (int e = c + 1; e < N; e++) + { + if (c != h && c != b && e != h && e != b) + { + Hinzufuegen(Turnierform.Vierer, [h, b, c, e], kandidaten); + } + } + } + } + } + + /// Einzelbegegnungen nur bei gerader Mannschaftszahl (dann bleibt für jede Mannschaft genau eine übrig). + private void EinzelErzeugen(int h, List kandidaten) + { + if (N % 2 != 0) + { + return; + } + + for (int a = 0; a < N; a++) + { + if (a != h) + { + Hinzufuegen(Turnierform.Einzel, [h, a], kandidaten); + } + } + } + + private List GruppenLesen(IReadOnlyList spitze, IReadOnlyList keller) + { + int[][] gruppen = [Nummern(spitze), Nummern(keller)]; + + // Je vier Mannschaften (4er-Spieltage) oder bei kleinen Klassen je drei (3er-Spieltage), alle verschieden. + bool gueltig = Array.TrueForAll(gruppen, g => g.Length is 3 or 4 && g.Length == gruppen[0].Length && Array.TrueForAll(g, m => m >= 0)) + && gruppen.SelectMany(g => g).Distinct().Count() == gruppen.Sum(g => g.Length); + return gueltig ? [.. gruppen] : []; + } + + private int[] Nummern(IReadOnlyList namen) => namen.Select(n => Array.IndexOf(Namen, n)).ToArray(); +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktoptimierung.cs b/src/AndiGenerator.Engine/Turniere/Kompaktoptimierung.cs new file mode 100644 index 0000000..e383d4c --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktoptimierung.cs @@ -0,0 +1,357 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Diagnostics; +using System.Globalization; + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Konstruiert Pläne aus Kompaktspieltagen gezielt nach den Kriterien statt zufällig (nicht im Original; Peter, +/// 13.10.2026): Jeder mögliche Spieltag an jedem möglichen Tag ist eine Ja/Nein-Entscheidung, jede Paarung kommt genau +/// einmal vor, jede Mannschaft spielt höchstens einmal am Tag und richtet höchstens zwei Spieltage aus. Gesucht ist der +/// Plan mit den wenigsten Punkten (wie in der Bewertung der Oberfläche): 10 Punkte je Verstoß gegen die Meldungen, 15 je +/// Mannschaft ohne 3er- oder 4er-Heimspieltag (Peter, 13.10.2026: erhöht), 2 je Mannschaft ganz ohne Heimspiel, 1 Punkt je 10 % mehr Kilometer als der Bezug +/// (Einzelfahrten doppelt), 3 je 3er-Spieltag, wenn ein Plan nur aus 4er-Spieltagen geht, 3 je fehlendem Spitzen- bzw. +/// Abstiegsspieltag (die vier Stärksten bzw. Schwächsten als 4er-Spieltag oder die drei als 3er-Spieltag), bis zu einem +/// halben Punkt, wenn er früh liegt. Der Löser (, HiGHS) findet beweisbar den besten Plan, wenn die +/// Zeit reicht. +/// +internal sealed class Kompaktoptimierung +{ + private readonly Kompaktmodell modell; + private readonly IGanzzahlloeser loeser; + private readonly Kompaktkandidat[] alle; + private readonly string[] block; + private readonly bool[][] gruppenTag; + + /// Bereitet die Optimierung vor. + /// Die aufbereitete Aufgabe. + /// Der Löser. + public Kompaktoptimierung(Kompaktmodell modell, IGanzzahlloeser loeser) + { + this.modell = modell; + this.loeser = loeser; + var gesehen = new HashSet(ReferenceEqualityComparer.Instance); + alle = modell.JePaar.SelectMany(p => p).Where(gesehen.Add).ToArray(); + block = alle.Select(Block).ToArray(); + gruppenTag = modell.Gruppen.Select(g => alle.Select(k => IstGruppentag(k, g)).ToArray()).ToArray(); + KmSchaetzung = Schaetzen(); + } + + /// Holt eine untere Schätzung der Kilometer (Maßstab, solange kein kürzester Plan bekannt ist). + public double KmSchaetzung { get; } + + /// Ob ein Spieltag Spitzen- bzw. Abstiegsspieltag der Gruppe ist: 4er-Spieltag der vier (die ersten beiden gegeneinander) oder 3er-Spieltag der ersten drei. + /// Der Spieltag. + /// Die Gruppe, die stärkste bzw. schwächste Mannschaft zuerst. + /// true, wenn er es ist. + public static bool IstGruppentag(Kompaktkandidat k, int[] g) + { + if (k.Form == Turnierform.Dreier) + { + return g.Length >= 3 && g[..3].All(k.Mannschaften.Contains); + } + + if (k.Form != Turnierform.Vierer || g.Length != 4 || !g.All(k.Mannschaften.Contains)) + { + return false; + } + + bool ErsteSeite(int t) => t == k.Mannschaften[0] || t == k.Mannschaften[1]; + return ErsteSeite(g[0]) != ErsteSeite(g[1]); + } + + /// + /// Sucht den besten Plan für einen Auftrag. Mehrere Läufe des Lösers mit verschiedenen Startwerten rechnen parallel + /// (der Löser selbst rechnet in einem Thread); sobald einer den besten Plan bewiesen hat, hören die anderen auf, sonst + /// gilt der beste Plan aller Läufe. + /// + /// Was gesucht wird. + /// Ein bekannter Plan als Startpunkt, oder null. + /// Zahl paralleler Läufe. + /// Wird während der Suche gefragt (darf für die Pause blockieren). + /// Harte Zeitgrenze für den Löser (auch ohne Rückfrage), oder null. + /// Der Plan, oder null, wenn keiner gefunden wurde; und ob er beweisbar der beste ist. + public (Kompaktloesung? Loesung, bool Optimal) Suchen(Kompaktauftrag auftrag, Kompaktloesung? start, int parallel, Func aufhoeren, TimeSpan? zeitgrenze = null) + { + ArgumentNullException.ThrowIfNull(auftrag); + ArgumentNullException.ThrowIfNull(aufhoeren); + var uhr = Stopwatch.StartNew(); + TimeSpan? Rest() => zeitgrenze is TimeSpan g ? TimeSpan.FromTicks(Math.Max(TimeSpan.TicksPerSecond, (g - uhr.Elapsed).Ticks)) : null; + + // Erst nur Spieltage ohne Verstoß (kleineres Modell, meist reicht das), sonst auch mit Verstößen (10 Punkte je Verstoß). + (Kompaktloesung? loesung, bool optimal) = SuchenBis(auftrag, 0, start, parallel, aufhoeren, Rest()); + if (loesung is null && !aufhoeren()) + { + (loesung, optimal) = SuchenBis(auftrag, Kompaktmodell.MaxVerstoesseJeSpieltag, start, parallel, aufhoeren, Rest()); + } + + return (loesung, optimal); + } + + private static string Block(Kompaktkandidat k) => string.Create(CultureInfo.InvariantCulture, $"{(int)k.Form}:{string.Join(',', k.Mannschaften)}"); + + /// Sucht nur mit Spieltagen bis zu so vielen Verstößen, in mehreren Läufen parallel. + private (Kompaktloesung? Loesung, bool Optimal) SuchenBis(Kompaktauftrag auftrag, int verstoesse, Kompaktloesung? start, int parallel, Func aufhoeren, TimeSpan? zeitgrenze) + { + (Ganzzahlmodell m, int[] spalteZuKandidat) = Aufbauen(auftrag, verstoesse); + double[]? startwerte = start is null ? null : Startwerte(m, spalteZuKandidat, start, auftrag); + int bewiesen = 0; + bool Aufhoeren() => Volatile.Read(ref bewiesen) != 0 || aufhoeren(); + var laeufe = new (double[]? Werte, bool Optimal)[Math.Max(1, parallel)]; + Parallel.For(0, laeufe.Length, new ParallelOptions { MaxDegreeOfParallelism = laeufe.Length }, i => + { + laeufe[i] = loeser.Loesen(m, startwerte, i + 1, Aufhoeren, zeitgrenze); + if (laeufe[i].Optimal) + { + Interlocked.Exchange(ref bewiesen, 1); + } + }); + + Kompaktloesung? beste = null; + double besteKosten = double.PositiveInfinity; + bool optimal = false; + foreach ((double[]? werte, bool bewiesenerLauf) in laeufe.Append<(double[]? Werte, bool Optimal)>((startwerte, false)).Where(l => l.Werte is not null)) + { + List wahl = []; + double kosten = 0; + for (int s = 0; s < werte!.Length; s++) + { + if (werte[s] > 0.5) + { + kosten += m.Kosten[s]; + if (s < spalteZuKandidat.Length) + { + wahl.Add(alle[spalteZuKandidat[s]]); + } + } + } + + if (!Gueltig(wahl)) + { + continue; + } + + if (kosten < besteKosten - 1e-9) + { + beste = new Kompaktloesung(modell, wahl); + besteKosten = kosten; + optimal = bewiesenerLauf; + } + else if (bewiesenerLauf && kosten <= besteKosten + 1e-9) + { + optimal = true; + } + } + + return (beste, optimal); + } + + /// Prüft selbst, ob ein Plan vollständig ist: jede Paarung genau einmal, niemand zweimal am Tag. + private bool Gueltig(List wahl) + { + var gedeckt = new int[modell.Paare]; + var belegt = new HashSet<(int Mannschaft, int Tag)>(); + foreach (Kompaktkandidat k in wahl) + { + foreach (int p in k.Paare) + { + gedeckt[p]++; + } + + if (!Array.TrueForAll(k.Mannschaften, t => belegt.Add((t, k.Tag)))) + { + return false; + } + } + + return Array.TrueForAll(gedeckt, x => x == 1); + } + + private (Ganzzahlmodell Modell, int[] SpalteZuKandidat) Aufbauen(Kompaktauftrag auftrag, int verstoesse) + { + var m = new Ganzzahlmodell(); + int n = modell.N; + double bezug = auftrag.KmBezug ?? KmSchaetzung; + double letzter = Math.Max(1, modell.T - 1); + var je = new List(); + for (int i = 0; i < alle.Length; i++) + { + Kompaktkandidat k = alle[i]; + if ((auftrag.NurVierer && k.Form == Turnierform.Dreier) || k.Verstoesse > verstoesse) + { + continue; + } + + double punkte = (10.0 * k.Km * (k.IstEinzel ? 2 : 1) / bezug) + (10.0 * k.Verstoesse); + if (auftrag.DreierStrafe && k.Form == Turnierform.Dreier) + { + punkte += 3; + } + + if (auftrag.MitGruppen) + { + for (int g = 0; g < gruppenTag.Length; g++) + { + punkte += gruppenTag[g][i] ? 0.5 * (letzter - k.Tag) / letzter : 0; + } + } + + m.Spalte(punkte); + je.Add(i); + } + + int[] spalten = [.. je]; + int ohneHeimspieltag = m.Spalten; + for (int t = 0; t < n; t++) + { + m.Spalte(15); + } + + int ohneHeimspiel = m.Spalten; + for (int t = 0; t < n; t++) + { + m.Spalte(2); + } + + int gruppeFehlt = m.Spalten; + if (auftrag.MitGruppen) + { + for (int g = 0; g < gruppenTag.Length; g++) + { + m.Spalte(3, !auftrag.GruppenPflicht); + } + } + + IEnumerable Spalten(Func wo) => Enumerable.Range(0, spalten.Length).Where(s => wo(alle[spalten[s]])); + + // Jede Paarung genau einmal. + for (int p = 0; p < modell.Paare; p++) + { + int paar = p; + m.Zeile(Spalten(k => Array.IndexOf(k.Paare, paar) >= 0), 1, 1); + } + + // Jede Mannschaft höchstens einmal am Tag. + var amTag = new Dictionary<(int, int), List>(); + for (int s = 0; s < spalten.Length; s++) + { + Kompaktkandidat k = alle[spalten[s]]; + foreach (int t in k.Mannschaften) + { + if (!amTag.TryGetValue((t, k.Tag), out List? liste)) + { + liste = []; + amTag[(t, k.Tag)] = liste; + } + + liste.Add(s); + } + } + + foreach (List liste in amTag.Values.Where(l => l.Count > 1)) + { + m.Zeile(liste, 0, 1); + } + + for (int t = 0; t < n; t++) + { + int mannschaft = t; + + // Höchstens zwei Spieltage ausrichten; sonst zählt sie als „ohne Heimspieltag“ bzw. „ganz ohne Heimspiel“. + m.Zeile(Spalten(k => !k.IstEinzel && k.Ausrichter == mannschaft), 0, 2); + m.Zeile([.. Spalten(k => !k.IstEinzel && k.Ausrichter == mannschaft), ohneHeimspieltag + t], 1, double.PositiveInfinity); + m.Zeile([.. Spalten(k => k.Ausrichter == mannschaft), ohneHeimspiel + t], 1, double.PositiveInfinity); + + // Höchstens eine Einzelbegegnung je Mannschaft. + m.Zeile(Spalten(k => k.IstEinzel && k.Mannschaften.Contains(mannschaft)), 0, 1); + } + + if (auftrag.MitGruppen) + { + for (int g = 0; g < gruppenTag.Length; g++) + { + bool[] tag = gruppenTag[g]; + m.Zeile([.. Enumerable.Range(0, spalten.Length).Where(s => tag[spalten[s]]), gruppeFehlt + g], 1, double.PositiveInfinity); + } + } + + // Andere Pläne als die schon gefundenen: nicht dieselben Spieltage (an welchen Tagen auch immer). + foreach (Kompaktloesung frueher in auftrag.Ausser) + { + var bloecke = frueher.Turniere.Select(Block).ToHashSet(StringComparer.Ordinal); + m.Zeile(Enumerable.Range(0, spalten.Length).Where(s => bloecke.Contains(block[spalten[s]])), 0, bloecke.Count - 1); + } + + return (m, spalten); + } + + /// + /// Die Spaltenwerte eines bekannten Plans: seine Spieltage, „ohne Heimspieltag“, „ganz ohne Heimspiel“ und „Gruppe + /// fehlt“ passend; null, wenn ein Spieltag im Modell nicht vorkommt oder der Plan ausgeschlossen ist. + /// + private double[]? Startwerte(Ganzzahlmodell m, int[] spalteZuKandidat, Kompaktloesung start, Kompaktauftrag auftrag) + { + var spalteVon = new Dictionary<(string, int), int>(); + for (int s = 0; s < spalteZuKandidat.Length; s++) + { + spalteVon[(block[spalteZuKandidat[s]], alle[spalteZuKandidat[s]].Tag)] = s; + } + + // Ein Startplan mit denselben Spieltagen wie ein ausgeschlossener Plan passt nicht. + var startbloecke = start.Turniere.Select(Block).ToHashSet(StringComparer.Ordinal); + if (auftrag.Ausser.Any(f => f.Turniere.All(k => startbloecke.Contains(Block(k))))) + { + return null; + } + + var werte = new double[m.Spalten]; + int n = modell.N; + var gruppentag = new bool[gruppenTag.Length]; + foreach (Kompaktkandidat k in start.Turniere) + { + if (!spalteVon.TryGetValue((Block(k), k.Tag), out int s)) + { + return null; + } + + werte[s] = 1; + for (int g = 0; g < gruppentag.Length; g++) + { + gruppentag[g] |= gruppenTag[g][spalteZuKandidat[s]]; + } + } + + int basis = spalteZuKandidat.Length; + for (int t = 0; t < n; t++) + { + int mannschaft = t; + werte[basis + t] = Array.Exists(start.Turniere, k => !k.IstEinzel && k.Ausrichter == mannschaft) ? 0 : 1; + werte[basis + n + t] = Array.Exists(start.Turniere, k => k.Ausrichter == mannschaft) ? 0 : 1; + } + + if (auftrag.MitGruppen) + { + for (int g = 0; g < gruppentag.Length; g++) + { + werte[basis + (2 * n) + g] = gruppentag[g] ? 0 : 1; + } + } + + return werte; + } + + /// Je Paarung der günstigste Anteil eines Spieltags (Kilometer geteilt durch seine Paarungen), aufsummiert. + private double Schaetzen() + { + double summe = 0; + foreach (Kompaktkandidat[] je in modell.JePaar) + { + summe += je.Select(k => k.Km * (k.IstEinzel ? 2 : 1) / k.Paare.Length).DefaultIfEmpty(0).Min(); + } + + return Math.Max(1, summe); + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktplaner.cs b/src/AndiGenerator.Engine/Turniere/Kompaktplaner.cs new file mode 100644 index 0000000..e1f493d --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktplaner.cs @@ -0,0 +1,374 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Plant eine Staffel mit Kompaktspieltagen (nicht im Original): sucht Pläne aus 3er- und 4er-Spieltagen (bei gerader +/// Mannschaftszahl mit je einer Einzelbegegnung), in denen jede Paarung genau einmal vorkommt, jede Mannschaft höchstens +/// einmal am Tag spielt und mindestens einen Spieltag ausrichtet, möglichst ohne Verstöße gegen die Meldungen. Daraus +/// werden Varianten gewählt: ausgewogen, kürzeste Fahrten (Luftlinie), gleichmäßiger Stand und gemeinsamer Spieltag. +/// +public static class Kompaktplaner +{ + /// Name der Variante mit Spitzen- und Abstiegsspieltag. + public const string SpitzeUndKeller = "Spitzen- und Abstiegsspieltag"; + + /// Anfang des Namens der Varianten mit 3er-Spieltagen, z. B. „Mit 3er-Spieltagen: kürzer“. + public const string MitDreiern = "Mit 3er-Spieltagen: "; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + /// Plant. + /// Die Aufgabe. + /// Dauer, Threads und Startwert. + /// Bricht die Suche ab; die bis dahin besten Pläne werden zurückgegeben. + /// Wird gelegentlich (aus einem Hintergrund-Thread) mit dem Stand aufgerufen (Text und Anteil bis 100 %). + /// Varianten und Hinweise. + public static Kompaktergebnis Planen(Kompaktaufgabe aufgabe, Kompaktplanoptionen optionen, CancellationToken abbruch, Action? fortschritt) + { + using var steuerung = new Kompaktsteuerung(abbruch); + return Planen(aufgabe, optionen, steuerung, fortschritt); + } + + /// Plant; die Steuerung kann anhalten, fortsetzen, beenden und den bisher besten Stand abfragen. + /// Die Aufgabe. + /// Dauer, Threads und Startwert. + /// Pause, Fortsetzen, Beenden und Zwischenstand. + /// Wird gelegentlich (aus einem Hintergrund-Thread) mit dem Stand aufgerufen (Text und Anteil bis 100 %). + /// Varianten und Hinweise. + public static Kompaktergebnis Planen(Kompaktaufgabe aufgabe, Kompaktplanoptionen optionen, Kompaktsteuerung steuerung, Action? fortschritt) + { + ArgumentNullException.ThrowIfNull(aufgabe); + ArgumentNullException.ThrowIfNull(optionen); + ArgumentNullException.ThrowIfNull(steuerung); + var modell = new Kompaktmodell(aufgabe); + if (modell.N < 3 || modell.T == 0) + { + return new Kompaktergebnis([], 0, [.. modell.Hinweise, "Zu wenige Mannschaften oder keine Spieltage – es gibt nichts zu planen."]); + } + + var lauf = new Kompaktlauf(modell, optionen, steuerung, fortschritt); + steuerung.Zwischenstandquelle = () => Ergebnis(lauf, modell); + try + { + lauf.Ausfuehren(); + } + finally + { + steuerung.Zwischenstandquelle = null; + } + + return Ergebnis(lauf, modell); + } + + /// + /// Fasst die Varianten mit dem besten Gesamtpaket (die erste) zusammen: Ein Ziel einer anderen Variante, das das + /// Gesamtpaket mindestens ebenso gut trifft (bei gleich vielen oder weniger Verstößen und Mannschaften ohne + /// Heimspieltag), entfällt dort und wird beim Gesamtpaket unter genannt; eine + /// Variante ohne verbleibendes Ziel entfällt ganz. + /// + /// Die Varianten, das beste Gesamtpaket zuerst. + /// Das Gesamtpaket hat Spitzen- und Abstiegsspieltag. + /// Die verbleibenden Varianten, das Gesamtpaket zuerst. + internal static List Zusammenfassen(List varianten, bool gesamtMitGruppen) + { + if (varianten.Count == 0) + { + return varianten; + } + + Kompaktvariante gesamt = varianten[0]; + var zugleich = new List(); + var rest = new List(); + foreach (Kompaktvariante v in varianten.Skip(1)) + { + string[] ziele = v.Name.Split(" · "); + string[] erfuellt = ziele.Where(z => Erfuellt(gesamt.Kennzahlen, v.Kennzahlen, z, gesamtMitGruppen)).ToArray(); + zugleich.AddRange(erfuellt.Where(z => !zugleich.Contains(z))); + if (erfuellt.Length < ziele.Length) + { + rest.Add(v with { Name = string.Join(" · ", ziele.Except(erfuellt)) }); + } + } + + rest.Insert(0, gesamt with { Zugleich = zugleich }); + return rest; + } + + /// + /// Varianten mit 3er-Spieltagen, höchstens drei: nur Pläne, die ein Ziel besser treffen als jeder Plan nur aus + /// 4er-Spieltagen – kürzere Fahrten, mehr Mannschaften am selben Sonntag, deutlich gleich weitere Fahrten (mindestens + /// 10 % kleinerer Unterschied), gleichmäßigerer Stand oder Spitzen- bzw. Abstiegsspieltag zu dritt, wenn es mit + /// 4er-Spieltagen keinen gibt. + /// + /// Pläne mit 3er-Spieltagen. + /// Pläne nur aus 4er-Spieltagen. + /// Ein 4er-Plan hat schon Spitzen- und Abstiegsspieltag. + /// Die Varianten. + internal static List Dreiervarianten(IReadOnlyList dreier, IReadOnlyList vierer, bool viererMitGruppen) + { + if (dreier.Count == 0 || vierer.Count == 0) + { + return []; + } + + var wahl = new List<(string Ziel, Kompaktloesung Loesung)>(); + Kompaktloesung kurz = dreier.OrderBy(l => l.Km).ThenBy(l => l.Abstand).First(); + if (kurz.Km < vierer.Min(l => l.Km) - 0.5) + { + wahl.Add(("kürzer", kurz)); + } + + Kompaktloesung gemeinsam = dreier.OrderByDescending(l => l.Gemeinsam).ThenBy(l => l.Km).First(); + if (gemeinsam.Gemeinsam > vierer.Max(l => l.Gemeinsam)) + { + wahl.Add((string.Create(Deutsch, $"{gemeinsam.Gemeinsam} am selben Sonntag"), gemeinsam)); + } + + Kompaktloesung gleich = dreier.OrderBy(l => l.KmSpanne).ThenBy(l => l.Km).First(); + if (gleich.KmSpanne < 0.9 * vierer.Min(l => l.KmSpanne)) + { + wahl.Add(("gleich weite Fahrten", gleich)); + } + + Kompaktloesung stand = dreier.OrderBy(l => l.Abstand).ThenBy(l => l.Km).First(); + if (stand.Abstand < vierer.Min(l => l.Abstand)) + { + wahl.Add(("gleichmäßiger Stand", stand)); + } + + Kompaktloesung? gruppen = dreier.Where(l => l.GruppenTage > 0).OrderByDescending(l => l.GruppenTage).ThenBy(l => l.GruppenFrueh).ThenBy(l => l.Km).FirstOrDefault(); + if (!viererMitGruppen && gruppen is not null) + { + wahl.Add(("Spitze/Abstieg zu dritt", gruppen)); + } + + return wahl + .GroupBy(w => w.Loesung) + .Take(3) + .Select(g => new Kompaktvariante(MitDreiern + string.Join(" · ", g.Select(w => w.Ziel)), g.Key.Planturniere(), g.Key.Kennzahlen())) + .ToList(); + } + + /// Die Varianten und Hinweise aus dem Stand eines Laufs (am Ende oder zwischendurch in der Pause). + private static Kompaktergebnis Ergebnis(Kompaktlauf lauf, Kompaktmodell modell) + { + var hinweise = new List(modell.Hinweise); + + // Einmal lesen: in der Pause kann ein Suchschritt den Wert gerade setzen. + Kompaktloesung? gesamtpaket = lauf.Gesamtloesung; + List front = lauf.Front.Alle(); + + // In der Pause während der vollständigen 4er-Suche: deren bisher kürzester Plan steht noch nicht in der Front. + if (front.Count == 0 && lauf.Baum?.Beste is IReadOnlyList bisher) + { + front.Add(Kompaktverbesserung.EinzelAusgleichen(modell, new Kompaktloesung(modell, bisher))); + } + + hinweise.AddRange(lauf.Optimierungshinweise()); + hinweise.AddRange(Viererhinweise(lauf.Baum)); + hinweise.AddRange(Ergebnishinweise(front, modell)); + List varianten = Kompaktauswahl.Varianten(front, gesamtpaket is null); + if (gesamtpaket is Kompaktloesung gesamt) + { + varianten.Insert(0, new Kompaktvariante("Bestes Gesamtpaket", gesamt.Planturniere(), gesamt.Kennzahlen())); + hinweise.Add(Gesamthinweis(gesamt, front, modell)); + } + + if (lauf.Gruppenloesung is Kompaktloesung gruppen) + { + varianten.Add(new Kompaktvariante(SpitzeUndKeller, gruppen.Planturniere(), gruppen.Kennzahlen())); + } + + // Was das beste Gesamtpaket schon erfüllt, braucht keine eigene Variante; es steht auf seiner Karte (Peter, 13.10.2026). + bool gesamtMitGruppen = gesamtpaket is { GruppenFehlen: 0 } && modell.Gruppen.Count > 0; + if (gesamtpaket is not null) + { + varianten = Zusammenfassen(varianten, gesamtMitGruppen); + } + + hinweise.AddRange(Gruppenhinweise(lauf, modell, front, gesamtMitGruppen)); + DreierAnhaengen(lauf, modell, front, varianten, hinweise); + OhneVerstossAnhaengen(lauf, modell, varianten, hinweise); + return new Kompaktergebnis(varianten, lauf.Geprueft, hinweise); + } + + /// Hängt die Varianten mit 3er-Spieltagen an (Peter, 13.10.2026: nur, wenn sie etwas können, was kein Plan nur aus 4er-Spieltagen kann). + private static void DreierAnhaengen(Kompaktlauf lauf, Kompaktmodell modell, List front, List varianten, List hinweise) + { + if (front.Count > 0 && front.TrueForAll(l => l.Dreier == 0)) + { + List vierer = [.. front, .. new[] { lauf.Gesamtloesung, lauf.Gruppenloesung }.OfType()]; + bool viererMitGruppen = modell.Gruppen.Count > 0 && vierer.Exists(l => l.GruppenFehlen == 0); + List dreier = Dreiervarianten(lauf.Dreierfront.Alle(), vierer, viererMitGruppen); + varianten.AddRange(dreier); + if (dreier.Count == 0) + { + hinweise.Add("Mit 3er-Spieltagen ging nichts besser als mit Plänen nur aus 4er-Spieltagen – keine Fahrten kürzer, nicht mehr Mannschaften am selben Sonntag, nicht gleich weiter, kein gleichmäßigerer Stand."); + } + } + else if (modell.Gruppen.Count > 0 + && lauf.Dreierfront.Alle().Where(l => l.GruppenTage > 0).OrderByDescending(l => l.GruppenTage).ThenBy(l => l.GruppenFrueh).ThenBy(l => l.Km).FirstOrDefault() is Kompaktloesung zuDritt) + { + // Ohne reine 4er-Pläne: Spitzen- und Abstiegsspieltag als 4er-Spieltag der Gruppe oder wenigstens zu dritt; + // die Spieltage so verlegt, dass die Gruppenspieltage möglichst spät liegen. + Kompaktloesung spaet = Kompaktverbesserung.TageVerteilen(modell, zuDritt, (a, b) => (a.GruppenFrueh, a.Abstand).CompareTo((b.GruppenFrueh, b.Abstand))); + varianten.Add(new Kompaktvariante(SpitzeUndKeller, spaet.Planturniere(), spaet.Kennzahlen())); + } + } + + /// Hängt die Pläne der Nachsuche ganz ohne Verstöße an und sagt, was sie dafür in Kauf nehmen. + private static void OhneVerstossAnhaengen(Kompaktlauf lauf, Kompaktmodell modell, List varianten, List hinweise) + { + if (lauf.OhneVerstossHinweis is string hinweis) + { + hinweise.Add(hinweis); + } + + foreach ((string name, Kompaktloesung l) in lauf.OhneVerstossPlaene()) + { + varianten.Add(new Kompaktvariante(name, l.Planturniere(), l.Kennzahlen())); + List ohne = Enumerable.Range(0, modell.N) + .Where(t => !Array.Exists(l.Turniere, k => !k.IstEinzel && k.Ausrichter == t)) + .Select(t => modell.Namen[t]) + .ToList(); + int extra = l.Einzel - (modell.N % 2 == 0 ? modell.N / 2 : 0); + string text = ohne.Count == 0 + ? "jede Mannschaft richtet einen Spieltag aus" + : "ohne Heimspieltag: " + string.Join(", ", ohne); + if (extra > 0) + { + text += string.Create(Deutsch, $"; dafür {extra} Einzelspiel(e), sodass jede Mannschaft wenigstens ein Heimspiel hat"); + } + + hinweise.Add(string.Create(Deutsch, $"{name}: {l.Km:N0} km, {l.Vierer}× 4er, {l.Dreier}× 3er – {text}.")); + } + } + + /// Beschreibt das beste Gesamtpaket: Mehr-Kilometer, Stand, gemeinsamer Spieltag, Spitzen- und Abstiegsspieltag. + private static string Gesamthinweis(Kompaktloesung l, List front, Kompaktmodell modell) + { + double kmMin = front.Count == 0 ? l.Km : Math.Min(l.Km, front.Min(x => x.Km)); + double prozent = kmMin > 0 ? 100 * ((l.Km / kmMin) - 1) : 0; + string gruppen = string.Empty; + if (modell.Gruppen.Count > 0) + { + gruppen = l.GruppenFehlen == 0 ? ", mit Spitzen- und Abstiegsspieltag" : string.Create(Deutsch, $", {2 - l.GruppenFehlen} von 2 Gruppenspieltagen"); + } + + return string.Create( + Deutsch, + $"Bestes Gesamtpaket: {l.Km:N0} km (+{prozent:N1} %), zwischen den Mannschaften {l.KmSpanne:N0} km Unterschied, Stand höchstens {l.Abstand} Spiele auseinander, bis zu {l.Gemeinsam} von {modell.N} an einem Tag{gruppen}."); + } + + /// Hinweise zu Spitzen- und Abstiegsspieltag: wann sie liegen und was sie an Kilometern kosten. + private static List Gruppenhinweise(Kompaktlauf lauf, Kompaktmodell modell, List front, bool gesamtMitGruppen) + { + if (modell.Gruppen.Count == 0) + { + return []; + } + + if (lauf.Gruppenbaum is null && !lauf.Optimiert) + { + return [modell.Gruppen[0].Length == 3 + ? "Spitzen- und Abstiegsspieltag: Bei 6 oder 7 Mannschaften bilden die drei Stärksten bzw. Schwächsten je einen 3er-Spieltag; gesucht wird unter den gefundenen Plänen (Variante „Spitzen- und Abstiegsspieltag“). Bei 7 Mannschaften mit nur 3er-Spieltagen geht immer nur einer von beiden – zwei 3er-Spieltage haben dort stets eine Mannschaft gemeinsam." + : "Spitzen- und Abstiegsspieltag werden vollständig nur bei 9 oder 10 Mannschaften gesucht; sonst unter den gefundenen Plänen, notfalls als 3er-Spieltag der drei Stärksten bzw. Schwächsten."]; + } + + Kompaktloesung? gezeigt = gesamtMitGruppen ? lauf.Gesamtloesung : lauf.Gruppenloesung; + if (gezeigt is not Kompaktloesung loesung) + { + if (lauf.Gruppenbaum is null) + { + return ["Ein Plan mit Spitzen- und Abstiegsspieltag wurde nicht gefunden."]; + } + + return [lauf.Gruppenbaum.Vollstaendig + ? "Mit diesen Meldungen gibt es keinen Plan nur aus 4er-Spieltagen mit Spitzen- und Abstiegsspieltag." + : "Ein Plan mit Spitzen- und Abstiegsspieltag wurde in der verfügbaren Zeit nicht gefunden."]; + } + + string Tag(int[] gruppe) + { + Kompaktkandidat? k = Array.Find(loesung.Turniere, x => x.Form == Turnierform.Vierer && Kompaktoptimierung.IstGruppentag(x, gruppe)) + ?? Array.Find(loesung.Turniere, x => Kompaktoptimierung.IstGruppentag(x, gruppe)); + return k is null ? "?" : modell.Tage[k.Tag].ToString("dd.MM.", Deutsch); + } + + double mehr = front.Count == 0 ? 0 : loesung.Km - front.Min(l => l.Km); + string kosten = mehr > 0.5 ? string.Create(Deutsch, $"{mehr:N0} km mehr als der kürzeste Plan") : "ohne Mehr-Kilometer"; + string wo = gesamtMitGruppen ? "im besten Gesamtpaket, " : string.Empty; + return [$"Spitzenspieltag am {Tag(modell.Gruppen[0])}, Abstiegsspieltag am {Tag(modell.Gruppen[1])} ({wo}{kosten})."]; + } + + /// Ob das Gesamtpaket das Ziel einer anderen Variante mindestens ebenso gut trifft. + private static bool Erfuellt(Kompaktkennzahlen g, Kompaktkennzahlen k, string ziel, bool gesamtMitGruppen) + { + if (g.Verstoesse > k.Verstoesse || g.OhneHeimspieltag > k.OhneHeimspieltag) + { + return false; + } + + return ziel switch + { + Kompaktauswahl.KuerzesteFahrten => g.Km <= k.Km + 0.5, + Kompaktauswahl.GleichmaessigerStand => g.Abstand <= k.Abstand, + Kompaktauswahl.GemeinsamerSpieltag => g.Gemeinsam >= k.Gemeinsam, + Kompaktauswahl.GleichWeit => g.KmSpanne <= k.KmSpanne + 0.5, + Kompaktauswahl.Ausgewogen => true, + SpitzeUndKeller => gesamtMitGruppen, + _ => false, + }; + } + + private static List Viererhinweise(Viererbaum? baum) + { + if (baum is null) + { + return []; + } + + string knoten = baum.Knoten.ToString("N0", Deutsch); + if (baum.Beste is not IReadOnlyList beste) + { + return [baum.Vollstaendig + ? $"Es gibt keinen Plan nur aus 4er-Spieltagen, der alle Meldungen einhält ({knoten} Teilpläne vollständig durchsucht)." + : $"Die Suche nach Plänen nur aus 4er-Spieltagen wurde nach {knoten} Teilplänen abgebrochen, ohne einen zu finden."]; + } + + string km = beste.Sum(k => k.Km).ToString("N0", Deutsch); + return [baum.Vollstaendig + ? $"Alle Pläne nur aus 4er-Spieltagen vollständig durchsucht ({knoten} Teilpläne): Der kürzeste hat {km} km." + : $"Pläne nur aus 4er-Spieltagen nicht vollständig durchsucht (Zeit abgelaufen, {knoten} Teilpläne): Der kürzeste gefundene hat {km} km."]; + } + + private static List Ergebnishinweise(List front, Kompaktmodell modell) + { + if (front.Count == 0) + { + return ["Es wurde kein vollständiger Plan gefunden. Zu wenige mögliche Sonntage in den Meldungen."]; + } + + var liste = new List(); + Kompaktloesung beste = front[0]; + if (beste.Verstoesse > 0) + { + liste.Add($"Ohne Verstöße gegen die Meldungen ließ sich kein Plan finden; die Varianten haben {beste.Verstoesse} Verstoß/Verstöße."); + } + + if (beste.OhneHeimspieltag > 0) + { + string grund = beste.OhneHeimspieltag <= modell.MindestensOhneAusrichtung + ? $"Bei {modell.N} Mannschaften gibt es dafür zu wenige Spieltage." + : "Mit den Meldungen ließ sich das nicht anders lösen."; + liste.Add($"{beste.OhneHeimspieltag} Mannschaft(en) richten keinen Spieltag aus. {grund}"); + } + + return liste; + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktplanoptionen.cs b/src/AndiGenerator.Engine/Turniere/Kompaktplanoptionen.cs new file mode 100644 index 0000000..12a7f61 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktplanoptionen.cs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Einstellungen eines Laufs des Kompaktplaners. +/// Dauer der zufälligen Suche (auch mit 3er-Spieltagen). +/// Anzahl paralleler Suchen. +/// Startwert der Zufallszahlen. +/// +/// Feste Zahl von Suchversuchen je Suche statt der Dauer (mit einer Suche reproduzierbar, für Tests); null = bis +/// die Dauer abgelaufen ist. +/// +/// Höchstdauer der vollständigen Suche nach dem kürzesten Plan nur aus 4er-Spieltagen. +public sealed record Kompaktplanoptionen(TimeSpan Dauer, int Threads, int Startwert, int? Versuche, TimeSpan ExaktDauer) +{ + /// + /// Holt die Standardeinstellungen auf allen Prozessorkernen bis auf einen: bis zu zwei Minuten gezielte Konstruktion + /// (bzw. vollständige Suche nach 4er-Plänen), danach zehn Sekunden zufällige Suche. + /// + public static Kompaktplanoptionen Standard => + new(TimeSpan.FromSeconds(10), Math.Max(1, Environment.ProcessorCount - 1), Random.Shared.Next(), null, TimeSpan.FromMinutes(2)); + + /// + /// Holt den Löser, mit dem die Pläne zuerst gezielt nach den Kriterien konstruiert werden (ganzzahlige Optimierung, + /// höchstens ); ohne ihn wie bisher vollständige 4er-Suche und Zufallssuche. + /// + public IGanzzahlloeser? Loeser { get; init; } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktsteuerung.cs b/src/AndiGenerator.Engine/Turniere/Kompaktsteuerung.cs new file mode 100644 index 0000000..eee008d --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktsteuerung.cs @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Diagnostics; + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Steuert eine laufende Planung der Kompaktspieltage (nicht im Original): anhalten, fortsetzen, beenden und den bisher +/// besten Stand abfragen. Während der Pause läuft keine Zeit ab; die Höchstdauern der Suchschritte gelten für die +/// tatsächliche Rechenzeit (). +/// +public sealed class Kompaktsteuerung : IDisposable +{ + private readonly CancellationTokenSource stopp; + private readonly ManualResetEventSlim weiter = new(true); + private readonly Stopwatch laufzeit = Stopwatch.StartNew(); + private readonly Lock sperre = new(); + + /// Initialisiert eine Steuerung. + public Kompaktsteuerung() + : this(CancellationToken.None) + { + } + + /// Initialisiert eine Steuerung, die auch ein äußerer Abbruch beendet. + /// Äußerer Abbruch. + public Kompaktsteuerung(CancellationToken abbruch) + { + stopp = CancellationTokenSource.CreateLinkedTokenSource(abbruch); + } + + /// Holt das Signal „beenden“. + public CancellationToken Token => stopp.Token; + + /// Holt einen Wert, der angibt, ob die Planung angehalten ist. + public bool Pausiert => !weiter.IsSet; + + /// Holt die Rechenzeit seit dem Start ohne die Pausen. + public TimeSpan Laufzeit => laufzeit.Elapsed; + + /// Holt oder setzt, wie der bisher beste Stand gebildet wird (setzt der Planer während des Laufs). + internal Func? Zwischenstandquelle { get; set; } + + /// Hält die Planung an; die Suchschritte warten an der nächsten Gelegenheit. + public void Pausieren() + { + lock (sperre) + { + if (weiter.IsSet && !stopp.IsCancellationRequested) + { + laufzeit.Stop(); + weiter.Reset(); + } + } + } + + /// Setzt eine angehaltene Planung fort. + public void Fortsetzen() + { + lock (sperre) + { + if (!weiter.IsSet) + { + laufzeit.Start(); + weiter.Set(); + } + } + } + + /// Beendet die Planung; sie liefert die bis dahin besten Pläne. + public void Stoppen() + { + stopp.Cancel(); + Fortsetzen(); + } + + /// Der bisher beste Stand (z. B. in der Pause), oder null, wenn die Planung noch nicht läuft. + /// Varianten und Hinweise wie am Ende der Planung. + public Kompaktergebnis? Zwischenstand() => Zwischenstandquelle?.Invoke(); + + /// + public void Dispose() + { + stopp.Dispose(); + weiter.Dispose(); + } + + /// Wartet, solange die Planung angehalten ist (endet sofort beim Beenden). + internal void Warten() + { + if (weiter.IsSet) + { + return; + } + + try + { + weiter.Wait(stopp.Token); + } + catch (OperationCanceledException) + { + // Beendet: die Suche merkt es selbst am Signal. + } + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktsuche.cs b/src/AndiGenerator.Engine/Turniere/Kompaktsuche.cs new file mode 100644 index 0000000..1f78d35 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktsuche.cs @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Eine Suche des Kompaktplaners (je Thread eine): randomisierte Tiefensuche, die die Paarungen genau einmal überdeckt. +/// Gewählt wird jeweils die Paarung mit den wenigsten passenden Spieltagen; deren Spieltage werden nach Kilometern +/// (mit Zufallsstreuung) und Verstößen sortiert, die ersten wenigen werden versucht. Jede Mannschaft spielt höchstens +/// einmal am Tag, richtet mindestens einen Spieltag aus und hat höchstens eine Einzelbegegnung. +/// +internal sealed class Kompaktsuche +{ + /// So viele Spieltage werden je Paarung versucht. + private const int Breite = 4; + + /// Gewicht der Kilometer gegenüber dem Zufall; je Versuch eines davon. + private static readonly double[] Gewichte = [0.3, 0.7, 0.9, 1.0]; + + private readonly Kompaktmodell modell; + private readonly Random zufall; + private readonly bool[] gedeckt; + private readonly bool[] belegt; + private readonly int[] ausgerichtet; + private readonly int[] einzel; + private readonly List gewaehlt = []; + private readonly int knotengrenze; + private int offen; + private int ohneAusrichtung; + private int verstoesse; + private int erlaubt; + private int erlaubtOhne; + private bool nurVierer; + private int knoten; + private double gewicht; + + /// Initialisiert eine Suche. + public Kompaktsuche(Kompaktmodell modell, int startwert) + { + this.modell = modell; + zufall = new Random(startwert); + gedeckt = new bool[modell.Paare]; + belegt = new bool[modell.N * modell.T]; + ausgerichtet = new int[modell.N]; + einzel = new int[modell.N]; + + // Gelingt ein Versuch, dann meist fast ohne Rückschritte; lange Suchen lohnen sich nicht (Versuch 12.10.2026). + knotengrenze = modell.Paare + 10; + } + + /// Ein Suchversuch. + /// So viele Verstöße gegen die Meldungen sind erlaubt. + /// So viele Mannschaften dürfen ohne Heimspieltag bleiben. + /// Nur 4er-Spieltage (und Einzelbegegnungen) verwenden. + /// Alle Spieltage oder null, wenn der Versuch keinen Plan fand. + public IReadOnlyList? Versuch(int erlaubteVerstoesse, int erlaubtOhneAusrichtung, bool nurViererSpieltage) + { + Zuruecksetzen(); + erlaubt = erlaubteVerstoesse; + erlaubtOhne = erlaubtOhneAusrichtung; + nurVierer = nurViererSpieltage; + gewicht = Gewichte[zufall.Next(Gewichte.Length)]; + knoten = 0; + return Suchen() ? gewaehlt.ToList() : null; + } + + private void Zuruecksetzen() + { + Array.Clear(gedeckt); + Array.Clear(belegt); + Array.Clear(ausgerichtet); + Array.Clear(einzel); + gewaehlt.Clear(); + offen = modell.Paare; + ohneAusrichtung = modell.N; + verstoesse = 0; + } + + private bool Suchen() + { + knoten++; + if (knoten > knotengrenze) + { + return false; + } + + if (offen == 0) + { + return ohneAusrichtung <= erlaubtOhne; + } + + // Jeder weitere Dreier oder Vierer deckt mindestens drei (nur Vierer: vier) Paarungen und hat genau einen Ausrichter. + if ((ohneAusrichtung - erlaubtOhne) * (nurVierer ? 4 : 3) > offen) + { + return false; + } + + int paar = EngstesPaar(); + if (paar < 0) + { + return false; + } + + foreach (Kompaktkandidat k in Reihenfolge(paar)) + { + Setzen(k, 1); + if (Suchen()) + { + return true; + } + + Setzen(k, -1); + } + + return false; + } + + /// Die offene Paarung mit den wenigsten passenden Spieltagen; -1, wenn eine keinen mehr hat. + private int EngstesPaar() + { + int bestes = -1; + int wenigste = int.MaxValue; + for (int p = 0; p < modell.Paare; p++) + { + if (gedeckt[p]) + { + continue; + } + + int anzahl = Zaehlen(p, wenigste); + if (anzahl < wenigste) + { + wenigste = anzahl; + bestes = p; + if (anzahl <= 1) + { + break; + } + } + } + + return wenigste == 0 ? -1 : bestes; + } + + private int Zaehlen(int paar, int grenze) + { + int rest = erlaubt - verstoesse; + int anzahl = 0; + foreach (Kompaktkandidat k in Liste(paar)) + { + if (k.Verstoesse > rest || anzahl >= grenze) + { + break; + } + + anzahl += Passt(k) ? 1 : 0; + } + + return anzahl; + } + + private List Reihenfolge(int paar) + { + int rest = erlaubt - verstoesse; + var liste = new List<(double Wert, Kompaktkandidat Kandidat)>(); + foreach (Kompaktkandidat k in Liste(paar)) + { + if (k.Verstoesse > rest) + { + break; + } + + if (Passt(k)) + { + liste.Add((Bewerten(k), k)); + } + } + + liste.Sort((a, b) => a.Wert.CompareTo(b.Wert)); + return liste.Take(Breite).Select(x => x.Kandidat).ToList(); + } + + private Kompaktkandidat[] Liste(int paar) => nurVierer ? modell.JePaarOhneDreier[paar] : modell.JePaar[paar]; + + /// Kleiner ist besser: Verstöße, Kilometer mit Zufallsstreuung, Bonus für Mannschaften ohne Heimspieltag und für 4er-Spieltage. + private double Bewerten(Kompaktkandidat k) + { + double skala = modell.KmSkala; + double wert = (k.Verstoesse * 10 * skala) + (k.Km * gewicht) + (zufall.NextDouble() * 2 * skala * (1 - gewicht)); + if (!k.IstEinzel && ausgerichtet[k.Ausrichter] == 0) + { + wert -= skala; + } + + return k.Form == Turnierform.Vierer && modell.ViererBevorzugen ? wert - (skala / 2) : wert; + } + + private bool Passt(Kompaktkandidat k) + { + int[] paare = k.Paare; + for (int i = 0; i < paare.Length; i++) + { + if (gedeckt[paare[i]]) + { + return false; + } + } + + int[] mannschaften = k.Mannschaften; + for (int i = 0; i < mannschaften.Length; i++) + { + if (belegt[(mannschaften[i] * modell.T) + k.Tag]) + { + return false; + } + } + + return !k.IstEinzel || (einzel[mannschaften[0]] == 0 && einzel[mannschaften[1]] == 0); + } + + private void Setzen(Kompaktkandidat k, int schritt) + { + bool an = schritt > 0; + foreach (int p in k.Paare) + { + gedeckt[p] = an; + } + + foreach (int m in k.Mannschaften) + { + belegt[(m * modell.T) + k.Tag] = an; + } + + offen -= schritt * k.Paare.Length; + verstoesse += schritt * k.Verstoesse; + if (k.IstEinzel) + { + einzel[k.Mannschaften[0]] += schritt; + einzel[k.Mannschaften[1]] += schritt; + } + else + { + AusrichtungZaehlen(k.Ausrichter, schritt); + } + + if (an) + { + gewaehlt.Add(k); + } + else + { + gewaehlt.RemoveAt(gewaehlt.Count - 1); + } + } + + private void AusrichtungZaehlen(int mannschaft, int schritt) + { + if (schritt > 0 && ausgerichtet[mannschaft] == 0) + { + ohneAusrichtung--; + } + + ausgerichtet[mannschaft] += schritt; + if (schritt < 0 && ausgerichtet[mannschaft] == 0) + { + ohneAusrichtung++; + } + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktvariante.cs b/src/AndiGenerator.Engine/Turniere/Kompaktvariante.cs new file mode 100644 index 0000000..66c6c8a --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktvariante.cs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Ein Vorschlag des Kompaktplaners. +/// Wofür die Variante steht, z. B. „Ausgewogen“ oder „Kürzeste Fahrten · Gleichmäßiger Stand“. +/// Die Spieltage nach Datum. +/// Die Kennzahlen. +public sealed record Kompaktvariante(string Name, IReadOnlyList Turniere, Kompaktkennzahlen Kennzahlen) +{ + /// Holt die Ziele anderer Varianten, die dieser Plan (das beste Gesamtpaket) zugleich erfüllt, z. B. „Gemeinsamer Spieltag“. + public IReadOnlyList Zugleich { get; init; } = []; +} diff --git a/src/AndiGenerator.Engine/Turniere/Kompaktverbesserung.cs b/src/AndiGenerator.Engine/Turniere/Kompaktverbesserung.cs new file mode 100644 index 0000000..845ecc5 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Kompaktverbesserung.cs @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Verbessert einen gefundenen Plan, ohne die Kilometer zu ändern: Spieltage auf andere Sonntage verlegen (für den +/// Stand oder einen gemeinsamen Spieltag) und das Heimrecht von Einzelbegegnungen tauschen (echte Heimspiele +/// gleichmäßig). Verstöße gegen die Meldungen werden nicht mehr. +/// +internal static class Kompaktverbesserung +{ + private const int Runden = 20; + + /// Ziel „gleichmäßiger Stand“: kleiner ist besser. + public static int Stand(Kompaktloesung a, Kompaktloesung b) => + (a.Verstoesse, a.Abstand, a.SummeAbstand, -a.Gemeinsam).CompareTo((b.Verstoesse, b.Abstand, b.SummeAbstand, -b.Gemeinsam)); + + /// Ziel „gemeinsamer Spieltag“: kleiner ist besser. + public static int Gemeinsam(Kompaktloesung a, Kompaktloesung b) => + (a.Verstoesse, -a.Gemeinsam, a.Abstand, a.SummeAbstand).CompareTo((b.Verstoesse, -b.Gemeinsam, b.Abstand, b.SummeAbstand)); + + /// Verlegt Spieltag um Spieltag auf den Sonntag, der das Ziel am meisten verbessert, bis nichts mehr hilft. + public static Kompaktloesung TageVerteilen(Kompaktmodell modell, Kompaktloesung loesung, Comparison ziel) + { + Kompaktloesung beste = loesung; + for (int runde = 0; runde < Runden; runde++) + { + Kompaktloesung? besser = BesterZug(modell, beste, ziel); + if (besser is null) + { + break; + } + + beste = besser; + } + + return beste; + } + + /// + /// Tauscht das Heimrecht von Einzelbegegnungen, solange es besser wird: zuerst sollen möglichst alle Mannschaften + /// wenigstens ein Heimspiel haben (wer keinen Spieltag ausrichtet, bekommt so sein Einzel zuhause), dann die echten + /// Heimspiele gleichmäßig verteilt, dann die Fahrten kurz sein (Einzelfahrten doppelt gezählt). + /// + public static Kompaktloesung EinzelAusgleichen(Kompaktmodell modell, Kompaktloesung loesung) + { + Kompaktloesung beste = loesung; + bool verbessert = true; + for (int runde = 0; verbessert && runde < Runden; runde++) + { + (beste, verbessert) = EinzelRunde(modell, beste); + } + + return beste; + } + + private static (Kompaktloesung Loesung, bool Verbessert) EinzelRunde(Kompaktmodell modell, Kompaktloesung loesung) + { + Kompaktloesung beste = loesung; + bool verbessert = false; + for (int i = 0; i < beste.Turniere.Length; i++) + { + Kompaktkandidat k = beste.Turniere[i]; + if (!k.IstEinzel) + { + continue; + } + + Kompaktkandidat getauscht = modell.Kandidat(Turnierform.Einzel, [k.Mannschaften[1], k.Mannschaften[0]], k.Tag); + if (getauscht.Verstoesse > k.Verstoesse) + { + continue; + } + + var turniere = (Kompaktkandidat[])beste.Turniere.Clone(); + turniere[i] = getauscht; + var probe = new Kompaktloesung(modell, turniere); + if ((probe.OhneHeimspiel, probe.Heimspiele, probe.Bewertungskm).CompareTo((beste.OhneHeimspiel, beste.Heimspiele, beste.Bewertungskm)) < 0) + { + beste = probe; + verbessert = true; + } + } + + return (beste, verbessert); + } + + private static Kompaktloesung? BesterZug(Kompaktmodell modell, Kompaktloesung loesung, Comparison ziel) + { + Kompaktloesung? beste = null; + Kompaktkandidat[] turniere = loesung.Turniere; + for (int i = 0; i < turniere.Length; i++) + { + for (int tag = 0; tag < modell.T; tag++) + { + Kompaktloesung? probe = Verlegen(modell, turniere, i, tag); + if (probe is not null && ziel(probe, beste ?? loesung) < 0) + { + beste = probe; + } + } + } + + return beste; + } + + private static Kompaktloesung? Verlegen(Kompaktmodell modell, Kompaktkandidat[] turniere, int index, int tag) + { + Kompaktkandidat k = turniere[index]; + if (tag == k.Tag || !Frei(turniere, index, tag)) + { + return null; + } + + int verstoesse = modell.Verstoesse(k.Form, k.Mannschaften, tag); + if (verstoesse > k.Verstoesse) + { + return null; + } + + var neu = (Kompaktkandidat[])turniere.Clone(); + neu[index] = k.AmTag(tag, verstoesse); + return new Kompaktloesung(modell, neu); + } + + /// Ob keine Mannschaft des Spieltags am Tag schon spielt. + private static bool Frei(Kompaktkandidat[] turniere, int index, int tag) + { + int[] mannschaften = turniere[index].Mannschaften; + for (int j = 0; j < turniere.Length; j++) + { + if (j != index && turniere[j].Tag == tag && turniere[j].Mannschaften.Intersect(mannschaften).Any()) + { + return false; + } + } + + return true; + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Meldungsbaum.cs b/src/AndiGenerator.Engine/Turniere/Meldungsbaum.cs new file mode 100644 index 0000000..662c6d6 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Meldungsbaum.cs @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Vollständige Suche nach einem Plan ganz ohne Verstöße gegen die Meldungen (nicht im Original): aus 3er- und +/// 4er-Spieltagen, notfalls mit Einzelbegegnungen (Peter, 13.10.2026: „Notfalls plane lieber ein Einzelspiel ein.“). Sie +/// läuft, wenn die übrige Suche nur Pläne mit Verstößen fand. Verzweigt wird über die Paarung mit den wenigsten passenden +/// Spieltagen; Einzelbegegnungen nur, wenn nichts anderes passt. Abgeschnitten wird, sobald die noch offenen Paarungen +/// nicht mehr für einen Heimspieltag jeder Mannschaft ohne einen reichen. Zuerst die Hälfte der Zeit kurze Versuche in +/// zufälliger Reihenfolge (findet Pläne schnell, wo es viele gibt), dann die vollständige Suche, der kürzeste Spieltag +/// zuerst (beweist auch, dass es keinen gibt). Ohne Ergebnis innerhalb der Frist bleibt das offen (). +/// +internal sealed class Meldungsbaum +{ + /// So viele Knoten hat ein kurzer Versuch. + private const long VersuchsKnoten = 4000; + + /// Höchstens so lange kurze Versuche. + private static readonly TimeSpan VersuchsZeit = TimeSpan.FromSeconds(10); + + private readonly Kompaktmodell modell; + private readonly Kompaktkandidat[][] jePaar; + private readonly bool[] gedeckt; + private readonly bool[] belegt; + private readonly int[] ausgerichtet; + private readonly int[] heimspiele; + private readonly List gewaehlt = []; + private Random? zufall; + private long knoten; + private long knotengrenze; + private bool erschoepft; + private int einzel; + private int maxEinzel; + private int maxOhne; + private bool einzelZaehlt; + private TimeSpan frist; + private Kompaktsteuerung? lenkung; + private TimeSpan beginn; + + /// Bereitet die Suche vor: je Paarung die Spieltage ohne Verstoß, dazu alle Einzelbegegnungen ohne Verstoß. + /// Das Modell der Aufgabe. + public Meldungsbaum(Kompaktmodell modell) + { + this.modell = modell; + var je = new List[modell.Paare]; + for (int p = 0; p < modell.Paare; p++) + { + je[p] = modell.JePaar[p].Where(k => k.Verstoesse == 0 && !k.IstEinzel).ToList(); + } + + for (int h = 0; h < modell.N; h++) + { + for (int g = 0; g < modell.N; g++) + { + for (int d = 0; h != g && d < modell.T; d++) + { + Kompaktkandidat k = modell.Kandidat(Turnierform.Einzel, [h, g], d); + if (k.Verstoesse == 0) + { + je[k.Paare[0]].Add(k); + } + } + } + } + + jePaar = je.Select(l => l.OrderBy(k => k.IstEinzel).ThenBy(k => k.Km).ToArray()).ToArray(); + gedeckt = new bool[modell.Paare]; + belegt = new bool[modell.N * modell.T]; + ausgerichtet = new int[modell.N]; + heimspiele = new int[modell.N]; + } + + /// Holt einen Wert, der angibt, ob die letzte Suche wegen der Frist oder eines Abbruchs unvollständig blieb. + public bool Abgebrochen { get; private set; } + + /// Holt die Rechenzeit der laufenden Suche (ohne Pausen). + private TimeSpan Vergangen => lenkung is null ? TimeSpan.Zero : lenkung.Laufzeit - beginn; + + /// Sucht einen Plan ohne Verstoß. + /// So viele Einzelbegegnungen sind erlaubt. + /// So viele Mannschaften dürfen ohne Heimspiel bleiben. + /// Ein Heimspiel in einer Einzelbegegnung zählt als Heimspiel der Mannschaft. + /// Längstens so lange. + /// Pause und Beenden; die Dauer zählt nur die Rechenzeit. + /// Die Spieltage oder null. + public IReadOnlyList? Suchen(int hoechstensEinzel, int hoechstensOhne, bool einzelZaehlt, TimeSpan dauer, Kompaktsteuerung steuerung) + { + maxEinzel = hoechstensEinzel; + maxOhne = hoechstensOhne; + this.einzelZaehlt = einzelZaehlt; + frist = dauer; + lenkung = steuerung; + Abgebrochen = false; + beginn = steuerung.Laufzeit; + + // Kurze Versuche in zufälliger Reihenfolge, höchstens die halbe Zeit und höchstens zehn Sekunden. Endet ein Versuch + // ohne an die Knotengrenze zu stoßen, ist alles durchsucht: Es gibt keinen Plan. + TimeSpan versuchszeit = TimeSpan.FromTicks(Math.Min(dauer.Ticks / 2, VersuchsZeit.Ticks)); + int versuch = 0; + while (Vergangen < versuchszeit && !steuerung.Token.IsCancellationRequested) + { + zufall = new Random(++versuch); + if (Lauf(VersuchsKnoten) is IReadOnlyList gefunden) + { + return gefunden; + } + + if (!erschoepft && !Abgebrochen) + { + return null; + } + } + + // Dann vollständig. + zufall = null; + return Lauf(long.MaxValue); + } + + private IReadOnlyList? Lauf(long grenze) + { + Array.Clear(gedeckt); + Array.Clear(belegt); + Array.Clear(ausgerichtet); + Array.Clear(heimspiele); + gewaehlt.Clear(); + einzel = 0; + knoten = 0; + knotengrenze = grenze; + erschoepft = false; + return Weiter() ? gewaehlt.ToList() : null; + } + + private bool Weiter() + { + if ((knoten & 1023) == 0) + { + lenkung?.Warten(); + } + + if (lenkung?.Token.IsCancellationRequested == true || Vergangen > frist) + { + Abgebrochen = true; + return false; + } + + if (++knoten > knotengrenze) + { + erschoepft = true; + return false; + } + + if (OhneMoeglichkeit() > maxOhne || OhneZuViele()) + { + return false; + } + + int paar = -1; + int wenigste = int.MaxValue; + for (int p = 0; p < modell.Paare && wenigste > 1; p++) + { + if (gedeckt[p]) + { + continue; + } + + int zahl = jePaar[p].Count(Frei); + if (zahl == 0) + { + return false; + } + + if (zahl < wenigste) + { + (paar, wenigste) = (p, zahl); + } + } + + if (paar < 0) + { + return Ohne() <= maxOhne; + } + + Kompaktkandidat[] wahl = jePaar[paar].Where(Frei).ToArray(); + if (zufall is not null) + { + zufall.Shuffle(wahl); + Array.Sort(wahl, (a, b) => a.IstEinzel.CompareTo(b.IstEinzel)); + } + + foreach (Kompaktkandidat k in wahl) + { + Setzen(k, true); + if (Weiter()) + { + return true; + } + + Setzen(k, false); + if (Abgebrochen || erschoepft) + { + return false; + } + } + + return false; + } + + private bool Frei(Kompaktkandidat k) => + (!k.IstEinzel || einzel < maxEinzel) + && !Array.Exists(k.Paare, p => gedeckt[p]) + && !Array.Exists(k.Mannschaften, m => belegt[(m * modell.T) + k.Tag]); + + private void Setzen(Kompaktkandidat k, bool an) + { + int schritt = an ? 1 : -1; + foreach (int p in k.Paare) + { + gedeckt[p] = an; + } + + foreach (int m in k.Mannschaften) + { + belegt[(m * modell.T) + k.Tag] = an; + } + + heimspiele[k.Ausrichter] += schritt; + if (k.IstEinzel) + { + einzel += schritt; + } + else + { + ausgerichtet[k.Ausrichter] += schritt; + } + + if (an) + { + gewaehlt.Add(k); + } + else + { + gewaehlt.RemoveAt(gewaehlt.Count - 1); + } + } + + /// + /// Ob die offenen Paarungen nicht mehr für einen eigenen Heimspiel-Spieltag jeder Mannschaft ohne Heimspiel reichen + /// (über die erlaubte Zahl hinaus): ein 3er- oder 4er-Spieltag braucht mindestens drei Paarungen, ein Einzelspiel eine. + /// + private bool OhneZuViele() + { + int offen = gedeckt.Count(g => !g); + int restEinzel = einzelZaehlt ? Math.Min(offen, maxEinzel - einzel) : 0; + int moeglich = restEinzel + ((offen - restEinzel) / 3); + return Ohne() - maxOhne > moeglich; + } + + private bool Hat(int t) => (einzelZaehlt ? heimspiele[t] : ausgerichtet[t]) > 0; + + private int Ohne() => Enumerable.Range(0, modell.N).Count(t => !Hat(t)); + + /// Mannschaften ohne Heimspiel, die auch an keinem freien Sonntag mehr zuhause spielen können. + private int OhneMoeglichkeit() => + Enumerable.Range(0, modell.N).Count(t => !Hat(t) && !Enumerable.Range(0, modell.T).Any(d => modell.KannHeim(t, d) && !belegt[(t * modell.T) + d])); +} diff --git a/src/AndiGenerator.Engine/Turniere/Planturnier.cs b/src/AndiGenerator.Engine/Turniere/Planturnier.cs new file mode 100644 index 0000000..0f7ac3e --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Planturnier.cs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Ein geplanter Kompaktspieltag (nicht im Original): Tag, Form und Mannschaften in fester Reihenfolge. +/// Der Tag. +/// Dreier, Vierer oder Einzelbegegnung. +/// +/// Der Ausrichter zuerst. Beim Vierer an zweiter Stelle die Mannschaft, die mit dem Ausrichter gegen die beiden übrigen +/// spielt (in click-TT bei einem ihrer beiden Spiele formal Heimmannschaft, verlegt in die Halle des Ausrichters); bei der +/// Einzelbegegnung Heim- und Gastmannschaft. +/// +public sealed record Planturnier(DateOnly Tag, Turnierform Form, IReadOnlyList Mannschaften) +{ + /// Holt den Ausrichter (bei der Einzelbegegnung die Heimmannschaft). + public string Ausrichter => Mannschaften[0]; + + /// + /// Die Begegnungen nach Runden: beim Vierer zwei Runden mit je zwei Spielen parallel (Ausrichter – Gast 1, + /// Partner – Gast 2; Ausrichter – Gast 2, Gast 1 – Partner – so ist der Partner nur einmal „Heimmannschaft“, + /// Peter, 13.10.2026), beim Dreier drei Runden + /// (Ausrichter – Gast 1, Gast 1 – Gast 2, Ausrichter – Gast 2), bei der Einzelbegegnung ein Spiel. + /// + /// Runde (ab 0), Heim- und Gastmannschaft. + public IReadOnlyList<(int Runde, string Heim, string Gast)> Begegnungen() + { + IReadOnlyList m = Mannschaften; + return Form switch + { + Turnierform.Vierer => [(0, m[0], m[2]), (0, m[1], m[3]), (1, m[0], m[3]), (1, m[2], m[1])], + Turnierform.Dreier => [(0, m[0], m[1]), (1, m[1], m[2]), (2, m[0], m[2])], + _ => [(0, m[0], m[1])], + }; + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Viererbaum.cs b/src/AndiGenerator.Engine/Turniere/Viererbaum.cs new file mode 100644 index 0000000..f13294d --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Viererbaum.cs @@ -0,0 +1,532 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Diagnostics; +using System.Globalization; +using System.Numerics; + +namespace AndiGenerator.Engine.Turniere; + +/// +/// Vollständige Suche nach dem kürzesten Plan nur aus 4er-Spieltagen (Branch and Bound, nicht im Original), wenn die +/// Paarungen genau aufgehen und jede Mannschaft genau einen 4er-Spieltag ausrichtet: 9 Mannschaften, oder 10 mit je einer +/// Einzelbegegnung. Nur Spieltage ohne Verstoß gegen die Meldungen mit mindestens einem möglichen Sonntag. Verzweigt wird +/// über die Paarung mit den wenigsten passenden Spieltagen, der kürzeste zuerst; abgeschnitten wird mit einer unteren +/// Schranke der noch fehlenden Fahrten (je Gast die nächsten freien Gastgeber, je Gastgeber die nächsten Gäste). Die +/// Sonntage werden für jeden kürzeren vollständigen Plan verteilt; geht das nicht, zählt er nicht. Die ersten +/// Verzweigungen laufen parallel. Mit Gruppen (Spitzen- und Abstiegsspieltag) bildet jede Gruppe einen 4er-Spieltag, die +/// ersten beiden einer Gruppe spielen gegeneinander, und diese Spieltage liegen so spät wie möglich. +/// +internal sealed class Viererbaum +{ + private const int Meldetakt = 1 << 14; + + /// So viele kurze Pläne werden gesammelt (für das beste Gesamtpaket aus Kilometern, Stand und Spieltagen). + private const int Sammelzahl = 300; + + /// Gesammelt werden Pläne bis zu so viel mehr Kilometern als der kürzeste. + private const double Toleranz = 1.08; + private const int GaesteJeSpieltag = 3; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private readonly Kompaktmodell modell; + private readonly IReadOnlyList gruppen; + private readonly string bezeichnung; + private readonly HashSet spaet = []; + private readonly List bloecke = []; + private readonly int[][] jePaar; + private readonly int[][] gastgeberNachNaehe; + private readonly int[][] gaesteNachNaehe; + private readonly int gastSoll; + private readonly ulong alle; + private readonly Lock sperre = new(); + private readonly Stopwatch uhr = new(); + private readonly List<(double Km, List Turniere)> sammlung = []; + private double beste = double.MaxValue; + private double grenze = double.MaxValue; + private long knoten; + private long naechsteMeldung; + private long erledigt; + private int gesamtArbeit; + private TimeSpan frist; + private TimeSpan beginn; + private Kompaktsteuerung? steuerung; + private CancellationTokenSource? zeitende; + + /// Bereitet die Suche vor. + /// Die Aufgabe. + /// Gruppen, die je einen 4er-Spieltag bilden müssen (leer = keine). + /// Anfang der Fortschrittsmeldung, z. B. „Nur 4er-Spieltage“. + public Viererbaum(Kompaktmodell modell, IReadOnlyList gruppen, string bezeichnung) + { + this.modell = modell; + this.gruppen = gruppen; + this.bezeichnung = bezeichnung; + int n = modell.N; + alle = modell.Paare >= 64 ? ulong.MaxValue : (1UL << modell.Paare) - 1; + + // Je Mannschaft (n - 1) bzw. (n - 2) Gegner in 4er-Spieltagen, zwei je Spieltag, einer davon als Ausrichter. + gastSoll = ((n % 2 == 0 ? n - 2 : n - 1) / 2) - 1; + BloeckeErzeugen(); + jePaar = Enumerable.Range(0, modell.Paare) + .Select(p => Enumerable.Range(0, bloecke.Count).Where(b => ((bloecke[b].Paare >> p) & 1) != 0).OrderBy(b => bloecke[b].Km).ToArray()) + .ToArray(); + gastgeberNachNaehe = Enumerable.Range(0, n).Select(t => Enumerable.Range(0, n).Where(h => h != t).OrderBy(h => modell.Entfernung(t, h)).ToArray()).ToArray(); + gaesteNachNaehe = Enumerable.Range(0, n).Select(h => Enumerable.Range(0, n).Where(t => t != h).OrderBy(t => modell.Entfernung(t, h)).ToArray()).ToArray(); + } + + /// Holt den kürzesten gefundenen Plan mit Sonntagen oder null. + public IReadOnlyList? Beste { get; private set; } + + /// Holt einen Wert, der angibt, ob alle Pläne durchsucht wurden (der gefundene ist dann der kürzeste). + public bool Vollstaendig { get; private set; } + + /// + /// Holt den erledigten Anteil der Suche (0 bis 1): der Anteil der fertig durchsuchten ersten Verzweigungen oder, wenn + /// das mehr ist, der Anteil der abgelaufenen Zeit – spätestens dann endet die Suche. Sonst stünde der Balken bei großen + /// Aufgaben (10 Mannschaften) lange auf 0 %, weil die ersten Verzweigungen sehr groß sind. + /// + public double Anteil + { + get + { + double verzweigungen = gesamtArbeit == 0 ? 0 : Interlocked.Read(ref erledigt) / (double)gesamtArbeit; + double zeit = frist > TimeSpan.Zero && steuerung is not null ? Math.Min(1, (steuerung.Laufzeit - beginn) / frist) : 0; + return Math.Max(verzweigungen, zeit); + } + } + + /// Holt die Zahl der geprüften Teilpläne. + public long Knoten => Interlocked.Read(ref knoten); + + /// Ob die vollständige Suche für diese Aufgabe geht: 9 oder 10 Mannschaften. + public static bool Moeglich(Kompaktmodell modell) + { + int turnierpaare = modell.Paare - (modell.N % 2 == 0 ? modell.N / 2 : 0); + return modell.N is >= 5 and <= 11 + && modell.T is > 0 and <= 64 + && turnierpaare == 4 * modell.N; + } + + /// Die gesammelten kurzen Pläne mit Sonntagen, der kürzeste zuerst. + /// Die Pläne. + public List> Gesammelt() + { + lock (sperre) + { + return sammlung.Select(x => (IReadOnlyList)x.Turniere).ToList(); + } + } + + /// Sucht, bis alles durchsucht, die Rechenzeit (ohne Pausen) abgelaufen oder die Planung beendet ist. + public void Suchen(int threads, TimeSpan dauer, Kompaktsteuerung lenkung, Action? fortschritt) + { + using var zeit = CancellationTokenSource.CreateLinkedTokenSource(lenkung.Token); + steuerung = lenkung; + zeitende = zeit; + frist = dauer; + beginn = lenkung.Laufzeit; + CancellationToken token = zeit.Token; + uhr.Start(); + var arbeit = new List<(Viererzweig Wurzel, Viererblock? Erster)>(); + foreach (Viererzweig wurzel in Wurzeln()) + { + if (wurzel.Gedeckt == alle) + { + arbeit.Add((wurzel, null)); + } + else + { + arbeit.AddRange((Kandidaten(wurzel) ?? []).Select(b => (wurzel, (Viererblock?)bloecke[b]))); + } + } + + try + { + var parallel = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, threads), CancellationToken = token }; + gesamtArbeit = arbeit.Count; + Parallel.ForEach(arbeit, parallel, a => + { + Teilbaum(a.Wurzel, a.Erster, token, fortschritt); + Interlocked.Increment(ref erledigt); + }); + } + catch (OperationCanceledException) + { + // Zeit abgelaufen oder abgebrochen: der bis dahin kürzeste Plan gilt. + Vollstaendig = false; + return; + } + + Vollstaendig = !token.IsCancellationRequested; + } + + private static bool Passt(Viererzweig z, Viererblock b) => + (b.Paare & z.Gedeckt) == 0 + && (b.IstEinzel ? (z.Einzel & b.TeamMaske) == 0 : ((z.Ausrichter >> b.Ausrichter) & 1) == 0); + + /// Ob in einem 4er-Spieltag die Mannschaften x und y gegeneinander spielen (auf verschiedenen Seiten stehen). + private static bool Gegeneinander(Viererblock b, int x, int y) + { + bool ErsteSeite(int t) => t == b.Mannschaften[0] || t == b.Mannschaften[1]; + return ErsteSeite(x) != ErsteSeite(y); + } + + private Viererblock Block(Turnierform form, int[] mannschaften, ulong tage) + { + ulong paare = 0; + foreach (int p in modell.PaareFuer(form, mannschaften)) + { + paare |= 1UL << p; + } + + int maske = mannschaften.Aggregate(0, (m, t) => m | (1 << t)); + return new Viererblock(form, mannschaften, paare, maske, modell.StreckeFuer(mannschaften), tage); + } + + /// Alle 4er-Spieltage (bei gerader Zahl auch Einzelbegegnungen) ohne Verstoß an mindestens einem Sonntag. + private void BloeckeErzeugen() + { + int n = modell.N; + for (int h = 0; h < n; h++) + { + for (int b = 0; b < n; b++) + { + ViererFuer(h, b); + if (n % 2 == 0 && b != h) + { + Hinzufuegen(Turnierform.Einzel, [h, b]); + } + } + } + } + + private void ViererFuer(int h, int b) + { + int n = modell.N; + for (int c = 0; c < n; c++) + { + for (int e = c + 1; e < n; e++) + { + if (b != h && c != h && c != b && e != h && e != b) + { + Hinzufuegen(Turnierform.Vierer, [h, b, c, e]); + } + } + } + } + + private void Hinzufuegen(Turnierform form, int[] mannschaften) + { + ulong tage = 0; + for (int d = 0; d < modell.T; d++) + { + if (modell.Verstoesse(form, mannschaften, d) == 0) + { + tage |= 1UL << d; + } + } + + if (tage != 0) + { + bloecke.Add(Block(form, mannschaften, tage)); + } + } + + /// Anfänge der Suche: je eine Wahl der Gruppenspieltage. + private List Wurzeln() + { + var wurzel = new Viererzweig(modell.N); + var ergebnis = new List(); + GruppenWaehlen(wurzel, 0, ergebnis); + return ergebnis; + } + + private void GruppenWaehlen(Viererzweig zweig, int index, List ergebnis) + { + if (index == gruppen.Count) + { + ergebnis.Add(zweig.Kopie()); + return; + } + + int[] g = gruppen[index]; + int maske = g.Aggregate(0, (m, t) => m | (1 << t)); + foreach (Viererblock b in bloecke.Where(b => !b.IstEinzel && b.TeamMaske == maske && Gegeneinander(b, g[0], g[1]) && Passt(zweig, b))) + { + spaet.Add(b); + zweig.Setzen(b, 1); + GruppenWaehlen(zweig, index + 1, ergebnis); + zweig.Setzen(b, -1); + } + } + + private void Teilbaum(Viererzweig wurzel, Viererblock? erster, CancellationToken token, Action? fortschritt) + { + if (erster is null) + { + Blatt(wurzel); + return; + } + + if (wurzel.Km + erster.Km >= Volatile.Read(ref grenze)) + { + return; + } + + Viererzweig zweig = wurzel.Kopie(); + zweig.Setzen(erster, 1); + Tiefe(zweig, token, fortschritt); + Interlocked.Add(ref knoten, zweig.Knoten); + } + + private void Tiefe(Viererzweig z, CancellationToken token, Action? fortschritt) + { + Zaehlen(z, fortschritt); + if (token.IsCancellationRequested) + { + return; + } + + if (z.Gedeckt == alle) + { + Blatt(z); + return; + } + + if (z.Km + Schranke(z) >= Volatile.Read(ref grenze) || Kandidaten(z) is not List kandidaten) + { + return; + } + + foreach (int nummer in kandidaten) + { + Viererblock b = bloecke[nummer]; + if (z.Km + b.Km >= Volatile.Read(ref grenze)) + { + break; + } + + z.Setzen(b, 1); + Tiefe(z, token, fortschritt); + z.Setzen(b, -1); + } + } + + private void Zaehlen(Viererzweig z, Action? fortschritt) + { + z.Knoten++; + if (z.Knoten < Meldetakt) + { + return; + } + + long gesamt = Interlocked.Add(ref knoten, z.Knoten); + z.Knoten = 0; + if (steuerung is not null) + { + // In der Pause hier warten; die Höchstdauer zählt nur die Rechenzeit. + steuerung.Warten(); + if (steuerung.Laufzeit - beginn > frist) + { + zeitende?.Cancel(); + } + } + + long jetzt = uhr.ElapsedMilliseconds; + long faellig = Interlocked.Read(ref naechsteMeldung); + if (fortschritt is not null && jetzt >= faellig && Interlocked.CompareExchange(ref naechsteMeldung, jetzt + 500, faellig) == faellig) + { + double km = Volatile.Read(ref beste); + string bisher = km < double.MaxValue ? string.Create(Deutsch, $", kürzester bisher {km:N0} km") : string.Empty; + fortschritt(string.Create(Deutsch, $"{bezeichnung}: {gesamt:N0} Teilpläne geprüft{bisher} …")); + } + } + + /// Die passenden Spieltage für die offene Paarung mit den wenigsten; null, wenn eine keinen mehr hat. + private List? Kandidaten(Viererzweig z) + { + int bestes = -1; + int wenigste = int.MaxValue; + for (int p = 0; p < modell.Paare; p++) + { + if (((z.Gedeckt >> p) & 1) != 0) + { + continue; + } + + int anzahl = Anzahl(z, p, wenigste); + if (anzahl < wenigste) + { + wenigste = anzahl; + bestes = p; + if (anzahl <= 1) + { + break; + } + } + } + + return bestes < 0 || wenigste == 0 ? null : jePaar[bestes].Where(b => Passt(z, bloecke[b])).ToList(); + } + + private int Anzahl(Viererzweig z, int paar, int grenze) + { + int anzahl = 0; + int[] liste = jePaar[paar]; + for (int i = 0; i < liste.Length && anzahl < grenze; i++) + { + anzahl += Passt(z, bloecke[liste[i]]) ? 1 : 0; + } + + return anzahl; + } + + /// Untere Schranke der noch fehlenden Kilometer. + private double Schranke(Viererzweig z) => Math.Max(GastSchranke(z), GastgeberSchranke(z)); + + /// Jede Mannschaft fährt noch so oft zu einem 4er-Spieltag, wie ihr fehlt – mindestens zu den nächsten freien Gastgebern. + private double GastSchranke(Viererzweig z) + { + double summe = 0; + for (int t = 0; t < modell.N; t++) + { + int rest = gastSoll - z.Gast[t]; + for (int i = 0; rest > 0 && i < gastgeberNachNaehe[t].Length; i++) + { + int h = gastgeberNachNaehe[t][i]; + if (((z.Ausrichter >> h) & 1) == 0 && !z.Besucht[(t * modell.N) + h]) + { + summe += 2 * modell.Entfernung(t, h); + rest--; + } + } + + if (rest > 0) + { + return double.MaxValue; + } + } + + return summe; + } + + /// Jeder Gastgeber ohne Spieltag bekommt noch drei Gäste – mindestens die nächsten, die noch fahren können. + private double GastgeberSchranke(Viererzweig z) + { + double summe = 0; + for (int h = 0; h < modell.N; h++) + { + if (((z.Ausrichter >> h) & 1) != 0) + { + continue; + } + + int rest = GaesteJeSpieltag; + for (int i = 0; rest > 0 && i < gaesteNachNaehe[h].Length; i++) + { + int t = gaesteNachNaehe[h][i]; + if (z.Gast[t] < gastSoll && !z.Besucht[(t * modell.N) + h]) + { + summe += 2 * modell.Entfernung(t, h); + rest--; + } + } + + if (rest > 0) + { + return double.MaxValue; + } + } + + return summe; + } + + /// + /// Ein vollständiger Plan: Sonntage verteilen; ist er kürzer als der bisher beste, gilt er. Pläne bis 8 % über dem + /// kürzesten werden gesammelt (höchstens 300, die kürzesten), die Grenze der Suche folgt der Sammlung. + /// + private void Blatt(Viererzweig z) + { + if (z.Km >= Volatile.Read(ref grenze)) + { + return; + } + + List alleBloecke = [.. z.Bloecke]; + int[]? tage = Sonntage(alleBloecke); + if (tage is null) + { + return; + } + + List plan = alleBloecke.Select((b, i) => modell.Kandidat(b.Form, b.Mannschaften, tage[i])).ToList(); + lock (sperre) + { + if (z.Km < beste) + { + Volatile.Write(ref beste, z.Km); + Beste = plan; + } + + sammlung.Add((z.Km, plan)); + sammlung.Sort((a, b) => a.Km.CompareTo(b.Km)); + if (sammlung.Count > Sammelzahl) + { + sammlung.RemoveAt(sammlung.Count - 1); + } + + double neu = beste * Toleranz; + if (sammlung.Count >= Sammelzahl) + { + neu = Math.Min(neu, sammlung[^1].Km); + } + + sammlung.RemoveAll(x => x.Km > neu); + Volatile.Write(ref grenze, neu); + } + } + + /// Verteilt die Spieltage auf Sonntage (jede Mannschaft höchstens einmal am Tag); null, wenn es nicht geht. + private int[]? Sonntage(List liste) + { + // Gruppenspieltage zuerst und so spät wie möglich, die übrigen nach der Zahl ihrer möglichen Sonntage. + int[] reihenfolge = Enumerable.Range(0, liste.Count) + .OrderBy(i => spaet.Contains(liste[i]) ? 0 : 1) + .ThenBy(i => BitOperations.PopCount(liste[i].Tage)) + .ToArray(); + var belegt = new int[modell.T]; + var tag = new int[liste.Count]; + return Zuordnen(0) ? tag : null; + + bool Zuordnen(int k) + { + if (k == reihenfolge.Length) + { + return true; + } + + Viererblock b = liste[reihenfolge[k]]; + bool rueckwaerts = spaet.Contains(b); + for (int i = 0; i < modell.T; i++) + { + int d = rueckwaerts ? modell.T - 1 - i : i; + if (((b.Tage >> d) & 1) != 0 && (belegt[d] & b.TeamMaske) == 0) + { + belegt[d] |= b.TeamMaske; + tag[reihenfolge[k]] = d; + if (Zuordnen(k + 1)) + { + return true; + } + + belegt[d] &= ~b.TeamMaske; + } + } + + return false; + } + } +} diff --git a/src/AndiGenerator.Engine/Turniere/Viererblock.cs b/src/AndiGenerator.Engine/Turniere/Viererblock.cs new file mode 100644 index 0000000..48bdef2 --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Viererblock.cs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Ein 4er-Spieltag (oder eine Einzelbegegnung) der vollständigen Suche, noch ohne Sonntag. +/// Vierer oder Einzel. +/// Mannschaften, Ausrichter zuerst. +/// Abgedeckte Paarungen als Bitmaske. +/// Beteiligte Mannschaften als Bitmaske. +/// Kilometer der Gäste, hin und zurück. +/// Sonntage ohne Verstoß gegen die Meldungen als Bitmaske. +internal sealed class Viererblock(Turnierform form, int[] mannschaften, ulong paare, int teamMaske, double km, ulong tage) +{ + public Turnierform Form { get; } = form; + + public int[] Mannschaften { get; } = mannschaften; + + public ulong Paare { get; } = paare; + + public int TeamMaske { get; } = teamMaske; + + public double Km { get; } = km; + + public ulong Tage { get; } = tage; + + public bool IstEinzel => Form == Turnierform.Einzel; + + public int Ausrichter => Mannschaften[0]; +} diff --git a/src/AndiGenerator.Engine/Turniere/Viererzweig.cs b/src/AndiGenerator.Engine/Turniere/Viererzweig.cs new file mode 100644 index 0000000..98599af --- /dev/null +++ b/src/AndiGenerator.Engine/Turniere/Viererzweig.cs @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Engine.Turniere; + +/// Stand eines Zweigs der vollständigen Suche (je Thread einer): gewählte Spieltage, gedeckte Paarungen, Gastgeber, Gäste. +internal sealed class Viererzweig +{ + private readonly int n; + + /// Initialisiert einen leeren Zweig. + public Viererzweig(int n) + { + this.n = n; + Gast = new int[n]; + Besucht = new bool[n * n]; + } + + /// Holt die gedeckten Paarungen. + public ulong Gedeckt { get; private set; } + + /// Holt die Mannschaften, die schon einen 4er-Spieltag ausrichten. + public int Ausrichter { get; private set; } + + /// Holt die Mannschaften mit Einzelbegegnung. + public int Einzel { get; private set; } + + /// Holt die Kilometer der gewählten Spieltage. + public double Km { get; private set; } + + /// Holt je Mannschaft, wie oft sie als Gast zu einem 4er-Spieltag fährt. + public int[] Gast { get; } + + /// Holt je Mannschaft und Gastgeber, ob sie schon dorthin fährt (Index Gast · n + Gastgeber). + public bool[] Besucht { get; } + + /// Holt die gewählten Spieltage. + public List Bloecke { get; } = []; + + /// Holt oder setzt die Zahl der in diesem Zweig geprüften Teilpläne seit der letzten Meldung. + public long Knoten { get; set; } + + /// Eine unabhängige Kopie (für einen weiteren Thread). + public Viererzweig Kopie() + { + var kopie = new Viererzweig(n); + foreach (Viererblock b in Bloecke) + { + kopie.Setzen(b, 1); + } + + return kopie; + } + + /// Nimmt einen Spieltag auf (schritt 1) oder wieder heraus (schritt -1, nur den zuletzt aufgenommenen). + public void Setzen(Viererblock b, int schritt) + { + bool an = schritt > 0; + Gedeckt = an ? Gedeckt | b.Paare : Gedeckt & ~b.Paare; + Km += schritt * b.Km; + if (b.IstEinzel) + { + Einzel = an ? Einzel | b.TeamMaske : Einzel & ~b.TeamMaske; + } + else + { + int bit = 1 << b.Ausrichter; + Ausrichter = an ? Ausrichter | bit : Ausrichter & ~bit; + for (int i = 1; i < b.Mannschaften.Length; i++) + { + int g = b.Mannschaften[i]; + Gast[g] += schritt; + Besucht[(g * n) + b.Ausrichter] = an; + } + } + + if (an) + { + Bloecke.Add(b); + } + else + { + Bloecke.RemoveAt(Bloecke.Count - 1); + } + } +} diff --git a/src/AndiGenerator.Import/AndiGenerator.Import.csproj b/src/AndiGenerator.Import/AndiGenerator.Import.csproj index e502002..e5347c0 100644 --- a/src/AndiGenerator.Import/AndiGenerator.Import.csproj +++ b/src/AndiGenerator.Import/AndiGenerator.Import.csproj @@ -5,6 +5,8 @@ + + diff --git a/src/AndiGenerator.Import/Ganzzahlproblem.cs b/src/AndiGenerator.Import/Ganzzahlproblem.cs new file mode 100644 index 0000000..1e80c37 --- /dev/null +++ b/src/AndiGenerator.Import/Ganzzahlproblem.cs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Import; + +/// +/// Ein ganzzahliges lineares Programm für (nicht im Original): Ja/Nein-Spalten mit Kosten und +/// Zeilen als Summen zwischen Grenzen, zeilenweise gespeichert; gesucht ist die Wahl mit den kleinsten Kosten. +/// +/// Kosten je Spalte. +/// Obere Grenze je Spalte (0 oder 1; die untere ist 0). +/// Untere Grenze je Zeile. +/// Obere Grenze je Zeile ( = keine). +/// Wo die Einträge jeder Zeile beginnen (ein Eintrag mehr als Zeilen). +/// Spalte je Eintrag. +/// Faktor je Eintrag. +public sealed record Ganzzahlproblem(double[] Kosten, double[] SpalteOben, double[] ZeileUnten, double[] ZeileOben, int[] ZeilenAnfang, int[] Spalten, double[] Werte); diff --git a/src/AndiGenerator.Import/HighsLoeser.cs b/src/AndiGenerator.Import/HighsLoeser.cs new file mode 100644 index 0000000..4b9ec72 --- /dev/null +++ b/src/AndiGenerator.Import/HighsLoeser.cs @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Runtime.InteropServices; + +namespace AndiGenerator.Import; + +/// +/// Löst ein ganzzahliges lineares Programm () mit HiGHS (nicht im Original; https://highs.dev, +/// MIT-Lizenz, Paket Highs.Native) über dessen C-Schnittstelle. Der Löser fragt während der Suche regelmäßig nach, ob er +/// aufhören soll; dabei kann die Planung auch anhalten (Pause). +/// +public static class HighsLoeser +{ + private const string Bibliothek = "highs"; + + /// Zeilenweise Matrix (kHighsMatrixFormatRowwise). + private const int Zeilenweise = 2; + + /// Minimieren (kHighsObjSenseMinimize). + private const int Minimieren = 1; + + /// Ganzzahlige Spalte (kHighsVarTypeInteger). + private const int Ganzzahlig = 1; + + /// Status „beweisbar beste Lösung“ (kHighsModelStatusOptimal). + private const int Optimal = 7; + + /// Mehr Aufwand für Heuristiken, damit früh gute Lösungen da sind (Standard 0,05). + private const double Heuristik = 0.2; + + /// Rückruf während der ganzzahligen Suche mit der Frage nach Abbruch (kCallbackMipInterrupt). + private const int MipUnterbrechung = 6; + + /// Ab so vielen Spalten ohne Vorverarbeitung (die üblichen Modelle haben 2.000 bis 4.000). + private const int GrossesModell = 10_000; + + private static int verfuegbar; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Rueckruf(int art, IntPtr meldung, IntPtr datenAus, IntPtr datenEin, IntPtr nutzerdaten); + + /// Holt einen Wert, der angibt, ob HiGHS geladen werden kann. + public static bool Verfuegbar + { + get + { + if (verfuegbar == 0) + { + bool geht; + try + { + IntPtr h = Erzeugen(); + geht = h != IntPtr.Zero; + if (geht) + { + Zerstoeren(h); + } + } + catch (DllNotFoundException) + { + geht = false; + } + catch (EntryPointNotFoundException) + { + geht = false; + } + catch (BadImageFormatException) + { + geht = false; + } + + verfuegbar = geht ? 1 : -1; + } + + return verfuegbar > 0; + } + } + + /// Löst das Problem. + /// Das Problem. + /// Eine bekannte Lösung als Startpunkt (Werte der Spalten), oder null. + /// Startwert der Zufallszahlen von HiGHS. + /// + /// Wird während der Suche (im Thread des Lösers) gefragt; true beendet sie mit der bis dahin besten Lösung. + /// Darf blockieren (Pause). + /// + /// Harte Zeitgrenze für HiGHS (auch für Phasen ohne Rückruf), oder null. + /// + /// Die Werte der Spalten (0 oder 1) der besten gefundenen Lösung, oder null, wenn keine gefunden wurde; und ob sie + /// beweisbar die beste ist. + /// + public static (double[]? Werte, bool Optimal) Loesen(Ganzzahlproblem problem, double[]? start, int startwert, Func aufhoeren, TimeSpan? zeitgrenze = null) + { + ArgumentNullException.ThrowIfNull(problem); + ArgumentNullException.ThrowIfNull(aufhoeren); + IntPtr h = Erzeugen(); + if (h == IntPtr.Zero) + { + return (null, false); + } + + Rueckruf rueckruf = (_, _, _, datenEin, _) => + { + // Das erste Feld der Eingabedaten ist „user_interrupt“. + if (datenEin != IntPtr.Zero && aufhoeren()) + { + Marshal.WriteInt32(datenEin, 1); + } + }; + + try + { + SetzeSchalter(h, "output_flag", 0); + SetzeZahl(h, "mip_heuristic_effort", Heuristik); + + // Große Modelle (z. B. mit Verstößen, wenn Mannschaften keine Heimtermine haben) ohne Vorverarbeitung: Deren + // „Probing“ dauerte bei 25.000 Spalten rund 20 Minuten – ohne Rückruf, also ohne Fortschritt und ohne Abbruch. + // Ohne sie findet HiGHS in Sekunden erste Lösungen (Peter, 14.10.2026). + if (problem.Kosten.Length > GrossesModell) + { + SetzeText(h, "presolve", "off"); + } + + // Harte Zeitgrenze als Rückfallebene für Phasen ohne Rückruf. + if (zeitgrenze is TimeSpan grenze && grenze > TimeSpan.Zero) + { + SetzeZahl(h, "time_limit", grenze.TotalSeconds); + } + + SetzeGanzzahl(h, "random_seed", Math.Abs(startwert % 2_000_000_000)); + double unendlich = Unendlich(h); + int n = problem.Kosten.Length; + int m = problem.ZeileUnten.Length; + double[] zeileOben = problem.ZeileOben.Select(x => double.IsPositiveInfinity(x) ? unendlich : x).ToArray(); + int status = UebergebeMip( + h, + n, + m, + problem.Spalten.Length, + Zeilenweise, + Minimieren, + 0, + problem.Kosten, + new double[n], + problem.SpalteOben, + problem.ZeileUnten, + zeileOben, + problem.ZeilenAnfang, + problem.Spalten, + problem.Werte, + Enumerable.Repeat(Ganzzahlig, n).ToArray()); + if (status < 0) + { + return (null, false); + } + + if (start is not null && start.Length == n) + { + SetzeLoesung(h, start, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); + } + + SetzeRueckruf(h, Marshal.GetFunctionPointerForDelegate(rueckruf), IntPtr.Zero); + StarteRueckruf(h, MipUnterbrechung); + if (Ausfuehren(h) < 0) + { + return (null, false); + } + + var werte = new double[n]; + LeseLoesung(h, werte, new double[n], new double[m], new double[m]); + return (werte, Modellstatus(h) == Optimal); + } + finally + { + Zerstoeren(h); + GC.KeepAlive(rueckruf); + } + } + + [DllImport(Bibliothek, EntryPoint = "Highs_create", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr Erzeugen(); + + [DllImport(Bibliothek, EntryPoint = "Highs_destroy", CallingConvention = CallingConvention.Cdecl)] + private static extern void Zerstoeren(IntPtr highs); + + [DllImport(Bibliothek, EntryPoint = "Highs_passMip", CallingConvention = CallingConvention.Cdecl)] + private static extern int UebergebeMip( + IntPtr highs, + int spalten, + int zeilen, + int eintraege, + int format, + int richtung, + double versatz, + double[] kosten, + double[] spalteUnten, + double[] spalteOben, + double[] zeileUnten, + double[] zeileOben, + int[] anfang, + int[] index, + double[] wert, + int[] ganzzahlig); + + [DllImport(Bibliothek, EntryPoint = "Highs_run", CallingConvention = CallingConvention.Cdecl)] + private static extern int Ausfuehren(IntPtr highs); + + [DllImport(Bibliothek, EntryPoint = "Highs_getModelStatus", CallingConvention = CallingConvention.Cdecl)] + private static extern int Modellstatus(IntPtr highs); + + [DllImport(Bibliothek, EntryPoint = "Highs_getSolution", CallingConvention = CallingConvention.Cdecl)] + private static extern int LeseLoesung(IntPtr highs, [Out] double[] spalten, [Out] double[] spaltenDual, [Out] double[] zeilen, [Out] double[] zeilenDual); + + [DllImport(Bibliothek, EntryPoint = "Highs_setBoolOptionValue", CallingConvention = CallingConvention.Cdecl)] + private static extern int SetzeSchalter(IntPtr highs, [MarshalAs(UnmanagedType.LPUTF8Str)] string option, int wert); + + [DllImport(Bibliothek, EntryPoint = "Highs_setDoubleOptionValue", CallingConvention = CallingConvention.Cdecl)] + private static extern int SetzeZahl(IntPtr highs, [MarshalAs(UnmanagedType.LPUTF8Str)] string option, double wert); + + [DllImport(Bibliothek, EntryPoint = "Highs_setStringOptionValue", CallingConvention = CallingConvention.Cdecl)] + private static extern int SetzeText(IntPtr highs, [MarshalAs(UnmanagedType.LPUTF8Str)] string option, [MarshalAs(UnmanagedType.LPUTF8Str)] string wert); + + [DllImport(Bibliothek, EntryPoint = "Highs_setIntOptionValue", CallingConvention = CallingConvention.Cdecl)] + private static extern int SetzeGanzzahl(IntPtr highs, [MarshalAs(UnmanagedType.LPUTF8Str)] string option, int wert); + + [DllImport(Bibliothek, EntryPoint = "Highs_setSolution", CallingConvention = CallingConvention.Cdecl)] + private static extern int SetzeLoesung(IntPtr highs, double[] spalten, IntPtr zeilen, IntPtr spaltenDual, IntPtr zeilenDual); + + [DllImport(Bibliothek, EntryPoint = "Highs_getInfinity", CallingConvention = CallingConvention.Cdecl)] + private static extern double Unendlich(IntPtr highs); + + [DllImport(Bibliothek, EntryPoint = "Highs_setCallback", CallingConvention = CallingConvention.Cdecl)] + private static extern int SetzeRueckruf(IntPtr highs, IntPtr rueckruf, IntPtr nutzerdaten); + + [DllImport(Bibliothek, EntryPoint = "Highs_startCallback", CallingConvention = CallingConvention.Cdecl)] + private static extern int StarteRueckruf(IntPtr highs, int art); +} diff --git a/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj b/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj index 8b26a1c..49e9134 100644 --- a/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj +++ b/src/AndiGenerator.Persistence/AndiGenerator.Persistence.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/AndiGenerator.Persistence/Geo/Kartendaten.cs b/src/AndiGenerator.Persistence/Geo/Kartendaten.cs new file mode 100644 index 0000000..c8174d4 --- /dev/null +++ b/src/AndiGenerator.Persistence/Geo/Kartendaten.cs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using System.Text.Json; +using AndiGenerator.Domain.Geo; + +namespace AndiGenerator.Persistence.Geo; + +/// +/// Eingebettete Kartendaten (nicht im Original): Umrisse der Bundesländer aus Natural Earth (gemeinfrei, vereinfacht mit +/// tools/karte/umrisse.py) und einige Städte zur Orientierung (Lage aus der Postleitzahlen-Tabelle). +/// +public static class Kartendaten +{ + private static readonly Lazy> UmrisseDaten = new(UmrisseLaden); + private static readonly Lazy> StaedteDaten = new(StaedteLaden); + + /// Holt die 16 Bundesländer. + public static IReadOnlyList Laender => UmrisseDaten.Value; + + /// Holt die Städte, die wichtigsten zuerst. + public static IReadOnlyList Staedte => StaedteDaten.Value; + + private static IReadOnlyList UmrisseLaden() + { + using JsonDocument dokument = Dokument("umrisse-de.json"); + var laender = new List(); + foreach (JsonElement land in dokument.RootElement.GetProperty("laender").EnumerateArray()) + { + var ringe = new List>(); + foreach (JsonElement ring in land.GetProperty("ringe").EnumerateArray()) + { + // Je Punkt [Länge, Breite] wie in GeoJSON. + ringe.Add(ring.EnumerateArray().Select(p => new Koordinate(p[1].GetDouble(), p[0].GetDouble())).ToList()); + } + + laender.Add(new Landesumriss(land.GetProperty("name").GetString() ?? string.Empty, ringe)); + } + + return laender; + } + + private static IReadOnlyList StaedteLaden() + { + using JsonDocument dokument = Dokument("staedte-de.json"); + return dokument.RootElement.EnumerateArray() + .Select(s => new Stadt( + s.GetProperty("name").GetString() ?? string.Empty, + s.GetProperty("rang").GetInt32(), + new Koordinate(s.GetProperty("lat").GetDouble(), s.GetProperty("lon").GetDouble()))) + .OrderBy(s => s.Rang) + .ThenBy(s => s.Name, StringComparer.Create(CultureInfo.GetCultureInfo("de-DE"), ignoreCase: false)) + .ToList(); + } + + private static JsonDocument Dokument(string name) + { + using Stream quelle = typeof(Kartendaten).Assembly.GetManifestResourceStream(name) + ?? throw new InvalidOperationException($"Die Kartendaten „{name}“ fehlen im Programm."); + return JsonDocument.Parse(quelle); + } +} diff --git a/src/AndiGenerator.Persistence/Geo/staedte-de.json b/src/AndiGenerator.Persistence/Geo/staedte-de.json new file mode 100644 index 0000000..8321997 --- /dev/null +++ b/src/AndiGenerator.Persistence/Geo/staedte-de.json @@ -0,0 +1 @@ +[{"name": "Berlin", "rang": 1, "lat": 52.505, "lon": 13.385}, {"name": "Hamburg", "rang": 1, "lat": 53.572, "lon": 10.015}, {"name": "München", "rang": 1, "lat": 48.144, "lon": 11.56}, {"name": "Köln", "rang": 1, "lat": 50.94, "lon": 6.961}, {"name": "Frankfurt", "rang": 1, "lat": 50.119, "lon": 8.654}, {"name": "Stuttgart", "rang": 1, "lat": 48.768, "lon": 9.186}, {"name": "Düsseldorf", "rang": 1, "lat": 51.221, "lon": 6.794}, {"name": "Leipzig", "rang": 1, "lat": 51.355, "lon": 12.369}, {"name": "Dortmund", "rang": 1, "lat": 51.508, "lon": 7.473}, {"name": "Essen", "rang": 1, "lat": 51.45, "lon": 7.015}, {"name": "Bremen", "rang": 1, "lat": 53.093, "lon": 8.81}, {"name": "Dresden", "rang": 1, "lat": 51.054, "lon": 13.763}, {"name": "Hannover", "rang": 1, "lat": 52.376, "lon": 9.746}, {"name": "Nürnberg", "rang": 1, "lat": 49.444, "lon": 11.079}, {"name": "Kiel", "rang": 1, "lat": 54.331, "lon": 10.129}, {"name": "Schwerin", "rang": 1, "lat": 53.415, "lon": 11.724}, {"name": "Magdeburg", "rang": 1, "lat": 52.127, "lon": 11.618}, {"name": "Potsdam", "rang": 1, "lat": 52.388, "lon": 13.064}, {"name": "Erfurt", "rang": 1, "lat": 50.993, "lon": 11.029}, {"name": "Wiesbaden", "rang": 1, "lat": 50.078, "lon": 8.245}, {"name": "Mainz", "rang": 1, "lat": 49.993, "lon": 8.249}, {"name": "Saarbrücken", "rang": 1, "lat": 49.235, "lon": 6.993}, {"name": "Duisburg", "rang": 2, "lat": 51.43, "lon": 6.748}, {"name": "Bochum", "rang": 2, "lat": 51.474, "lon": 7.219}, {"name": "Wuppertal", "rang": 2, "lat": 51.26, "lon": 7.165}, {"name": "Bielefeld", "rang": 2, "lat": 52.017, "lon": 8.544}, {"name": "Bonn", "rang": 2, "lat": 50.72, "lon": 7.112}, {"name": "Münster", "rang": 2, "lat": 51.619, "lon": 7.911}, {"name": "Mannheim", "rang": 2, "lat": 49.496, "lon": 8.481}, {"name": "Karlsruhe", "rang": 2, "lat": 49.006, "lon": 8.397}, {"name": "Augsburg", "rang": 2, "lat": 48.365, "lon": 10.898}, {"name": "Aachen", "rang": 2, "lat": 50.768, "lon": 6.103}, {"name": "Braunschweig", "rang": 2, "lat": 52.268, "lon": 10.526}, {"name": "Chemnitz", "rang": 2, "lat": 50.821, "lon": 12.91}, {"name": "Freiburg", "rang": 2, "lat": 47.999, "lon": 7.839}, {"name": "Lübeck", "rang": 2, "lat": 53.871, "lon": 10.703}, {"name": "Rostock", "rang": 2, "lat": 54.106, "lon": 12.112}, {"name": "Kassel", "rang": 2, "lat": 51.316, "lon": 9.477}, {"name": "Ludwigshafen", "rang": 2, "lat": 49.487, "lon": 8.418}, {"name": "Oldenburg", "rang": 2, "lat": 53.147, "lon": 8.211}, {"name": "Osnabrück", "rang": 2, "lat": 52.271, "lon": 8.037}, {"name": "Heidelberg", "rang": 2, "lat": 49.407, "lon": 8.691}, {"name": "Darmstadt", "rang": 2, "lat": 49.872, "lon": 8.653}, {"name": "Regensburg", "rang": 2, "lat": 49.018, "lon": 12.099}, {"name": "Würzburg", "rang": 2, "lat": 49.792, "lon": 9.94}, {"name": "Ulm", "rang": 2, "lat": 48.405, "lon": 9.972}, {"name": "Koblenz", "rang": 3, "lat": 50.354, "lon": 7.577}, {"name": "Trier", "rang": 3, "lat": 49.753, "lon": 6.637}, {"name": "Kaiserslautern", "rang": 3, "lat": 49.44, "lon": 7.755}, {"name": "Siegen", "rang": 3, "lat": 50.876, "lon": 8.021}, {"name": "Gießen", "rang": 3, "lat": 50.585, "lon": 8.679}, {"name": "Fulda", "rang": 3, "lat": 50.554, "lon": 9.675}, {"name": "Worms", "rang": 3, "lat": 49.63, "lon": 8.333}, {"name": "Bad Kreuznach", "rang": 3, "lat": 49.843, "lon": 7.851}, {"name": "Neuwied", "rang": 3, "lat": 50.442, "lon": 7.492}, {"name": "Idar-Oberstein", "rang": 3, "lat": 49.714, "lon": 7.308}, {"name": "Wetzlar", "rang": 3, "lat": 50.551, "lon": 8.503}, {"name": "Marburg", "rang": 3, "lat": 50.801, "lon": 8.764}, {"name": "Pirmasens", "rang": 3, "lat": 49.202, "lon": 7.611}, {"name": "Landau", "rang": 3, "lat": 49.198, "lon": 8.117}, {"name": "Speyer", "rang": 3, "lat": 49.321, "lon": 8.431}, {"name": "Bingen", "rang": 3, "lat": 49.967, "lon": 7.899}, {"name": "Montabaur", "rang": 3, "lat": 50.436, "lon": 7.823}, {"name": "Bitburg", "rang": 3, "lat": 49.968, "lon": 6.527}, {"name": "Wittlich", "rang": 3, "lat": 49.986, "lon": 6.893}, {"name": "Cochem", "rang": 3, "lat": 50.145, "lon": 7.164}, {"name": "Mayen", "rang": 3, "lat": 50.328, "lon": 7.223}, {"name": "Andernach", "rang": 3, "lat": 50.431, "lon": 7.404}, {"name": "Halle", "rang": 2, "lat": 51.48, "lon": 11.968}, {"name": "Limburg", "rang": 3, "lat": 50.383, "lon": 8.051}, {"name": "Altenkirchen", "rang": 3, "lat": 50.686, "lon": 7.642}, {"name": "Simmern", "rang": 3, "lat": 49.982, "lon": 7.524}] \ No newline at end of file diff --git a/src/AndiGenerator.Persistence/Geo/umrisse-de.json b/src/AndiGenerator.Persistence/Geo/umrisse-de.json new file mode 100644 index 0000000..0402030 --- /dev/null +++ b/src/AndiGenerator.Persistence/Geo/umrisse-de.json @@ -0,0 +1 @@ +{"quelle":"Natural Earth 1:10m Admin 1 (gemeinfrei), vereinfacht","laender":[{"name":"Baden-Württemberg","ringe":[[[9.55,47.534],[9.273,47.65],[9.197,47.656],[9.183,47.67],[9.017,47.679],[8.982,47.662],[8.906,47.652],[8.882,47.656],[8.853,47.671],[8.838,47.681],[8.838,47.688],[8.856,47.691],[8.798,47.72],[8.771,47.721],[8.762,47.701],[8.77,47.695],[8.717,47.695],[8.7,47.723],[8.72,47.747],[8.713,47.757],[8.682,47.759],[8.657,47.788],[8.644,47.791],[8.635,47.785],[8.63,47.763],[8.617,47.757],[8.608,47.762],[8.602,47.795],[8.583,47.8],[8.558,47.801],[8.542,47.795],[8.552,47.779],[8.537,47.774],[8.464,47.764],[8.445,47.743],[8.438,47.723],[8.402,47.707],[8.391,47.692],[8.398,47.676],[8.391,47.665],[8.458,47.64],[8.476,47.64],[8.52,47.657],[8.568,47.663],[8.607,47.656],[8.602,47.633],[8.594,47.643],[8.58,47.639],[8.581,47.6],[8.561,47.589],[8.522,47.622],[8.492,47.62],[8.462,47.606],[8.449,47.584],[8.354,47.581],[8.306,47.592],[8.289,47.616],[8.233,47.622],[8.179,47.616],[8.162,47.604],[8.114,47.588],[8.087,47.567],[8.042,47.561],[7.912,47.561],[7.898,47.588],[7.82,47.595],[7.786,47.563],[7.767,47.556],[7.683,47.544],[7.61,47.565],[7.636,47.565],[7.647,47.572],[7.66,47.597],[7.586,47.585],[7.59,47.608],[7.537,47.665],[7.518,47.675],[7.512,47.707],[7.538,47.732],[7.538,47.748],[7.526,47.783],[7.529,47.797],[7.543,47.829],[7.562,47.839],[7.558,47.879],[7.579,47.906],[7.585,47.94],[7.621,47.971],[7.619,48.003],[7.576,48.054],[7.573,48.095],[7.613,48.179],[7.687,48.253],[7.702,48.311],[7.737,48.327],[7.751,48.341],[7.738,48.407],[7.751,48.444],[7.769,48.47],[7.776,48.498],[7.813,48.519],[7.802,48.548],[7.802,48.582],[7.81,48.615],[7.852,48.659],[7.896,48.677],[7.959,48.721],[7.97,48.757],[8.017,48.762],[8.039,48.791],[8.09,48.808],[8.148,48.904],[8.179,48.943],[8.2,48.958],[8.23,48.966],[8.263,48.988],[8.357,49.11],[8.387,49.232],[8.48,49.297],[8.483,49.31],[8.46,49.315],[8.498,49.376],[8.49,49.411],[8.505,49.431],[8.494,49.443],[8.443,49.452],[8.443,49.459],[8.464,49.466],[8.464,49.473],[8.428,49.534],[8.408,49.555],[8.417,49.579],[8.45,49.588],[8.474,49.586],[8.566,49.527],[8.617,49.545],[8.599,49.61],[8.676,49.623],[8.692,49.602],[8.687,49.586],[8.692,49.574],[8.718,49.542],[8.765,49.524],[8.808,49.523],[8.828,49.503],[8.89,49.506],[8.893,49.494],[8.877,49.475],[8.852,49.485],[8.829,49.466],[8.833,49.435],[8.814,49.423],[8.811,49.407],[8.846,49.396],[8.891,49.425],[8.901,49.447],[8.914,49.438],[8.94,49.447],[8.943,49.462],[8.927,49.482],[8.954,49.503],[9.035,49.501],[9.076,49.519],[9.102,49.518],[9.127,49.508],[9.075,49.572],[9.099,49.565],[9.111,49.575],[9.122,49.571],[9.179,49.579],[9.259,49.579],[9.29,49.611],[9.286,49.627],[9.294,49.633],[9.377,49.65],[9.404,49.64],[9.423,49.651],[9.415,49.663],[9.424,49.711],[9.397,49.718],[9.383,49.7],[9.369,49.698],[9.371,49.715],[9.367,49.721],[9.314,49.732],[9.32,49.75],[9.312,49.766],[9.328,49.778],[9.352,49.786],[9.401,49.784],[9.473,49.796],[9.545,49.779],[9.574,49.782],[9.602,49.771],[9.649,49.783],[9.655,49.775],[9.647,49.766],[9.647,49.729],[9.639,49.724],[9.632,49.697],[9.673,49.688],[9.682,49.707],[9.707,49.721],[9.713,49.704],[9.727,49.687],[9.764,49.712],[9.797,49.72],[9.833,49.689],[9.836,49.649],[9.882,49.611],[9.86,49.585],[9.86,49.568],[9.839,49.548],[9.849,49.543],[9.864,49.545],[9.873,49.561],[9.913,49.579],[9.919,49.572],[9.912,49.562],[9.93,49.55],[9.936,49.482],[10.028,49.483],[10.066,49.509],[10.066,49.52],[10.057,49.527],[10.085,49.544],[10.103,49.536],[10.099,49.519],[10.128,49.511],[10.121,49.492],[10.132,49.472],[10.112,49.442],[10.14,49.433],[10.172,49.382],[10.14,49.384],[10.122,49.37],[10.122,49.335],[10.154,49.317],[10.147,49.299],[10.159,49.269],[10.15,49.266],[10.131,49.273],[10.127,49.268],[10.144,49.25],[10.145,49.229],[10.161,49.212],[10.148,49.203],[10.219,49.155],[10.242,49.16],[10.263,49.148],[10.264,49.127],[10.254,49.11],[10.23,49.103],[10.261,49.091],[10.259,49.079],[10.278,49.066],[10.274,49.05],[10.354,49.033],[10.367,49.011],[10.415,48.983],[10.459,48.939],[10.467,48.915],[10.445,48.906],[10.456,48.899],[10.448,48.85],[10.459,48.831],[10.454,48.814],[10.439,48.8],[10.44,48.775],[10.432,48.756],[10.441,48.742],[10.471,48.734],[10.495,48.699],[10.438,48.668],[10.432,48.678],[10.444,48.699],[10.431,48.705],[10.372,48.664],[10.351,48.689],[10.32,48.69],[10.294,48.703],[10.272,48.683],[10.269,48.669],[10.287,48.647],[10.333,48.623],[10.334,48.617],[10.311,48.605],[10.324,48.579],[10.323,48.562],[10.311,48.54],[10.319,48.533],[10.268,48.528],[10.254,48.518],[10.251,48.505],[10.153,48.466],[10.142,48.469],[10.138,48.483],[10.13,48.488],[10.064,48.471],[10.041,48.45],[10.044,48.437],[9.981,48.381],[9.99,48.37],[10.003,48.369],[10.054,48.305],[10.109,48.148],[10.139,48.094],[10.138,48.057],[10.13,48.035],[10.101,47.98],[10.085,47.966],[10.11,47.934],[10.106,47.898],[10.097,47.875],[10.086,47.866],[10.104,47.86],[10.13,47.817],[10.109,47.802],[10.087,47.801],[10.079,47.79],[10.101,47.769],[10.117,47.763],[10.123,47.711],[10.131,47.691],[10.127,47.677],[10.09,47.679],[10.085,47.658],[10.076,47.65],[10.053,47.68],[10.009,47.675],[9.953,47.659],[9.905,47.663],[9.865,47.675],[9.834,47.665],[9.76,47.62],[9.715,47.605],[9.68,47.612],[9.655,47.596],[9.614,47.589],[9.55,47.534]]]},{"name":"Bayern","ringe":[[[12.101,50.314],[12.076,50.315],[12.107,50.291],[12.111,50.277],[12.109,50.264],[12.08,50.243],[12.09,50.231],[12.139,50.211],[12.175,50.183],[12.186,50.156],[12.179,50.118],[12.188,50.103],[12.233,50.081],[12.241,50.071],[12.247,50.045],[12.415,49.983],[12.451,49.981],[12.468,49.97],[12.471,49.956],[12.463,49.931],[12.518,49.912],[12.524,49.905],[12.513,49.87],[12.483,49.827],[12.462,49.831],[12.453,49.78],[12.437,49.769],[12.395,49.758],[12.384,49.743],[12.42,49.7],[12.496,49.67],[12.505,49.659],[12.505,49.646],[12.497,49.634],[12.507,49.63],[12.502,49.62],[12.547,49.596],[12.562,49.551],[12.583,49.522],[12.623,49.51],[12.627,49.46],[12.644,49.429],[12.709,49.405],[12.729,49.391],[12.762,49.343],[12.778,49.333],[12.857,49.322],[12.875,49.324],[12.871,49.337],[12.885,49.34],[12.94,49.327],[13.0,49.295],[13.011,49.268],[13.056,49.238],[13.149,49.155],[13.179,49.118],[13.207,49.107],[13.267,49.106],[13.3,49.094],[13.372,49.039],[13.384,49.021],[13.384,48.993],[13.399,48.978],[13.459,48.945],[13.482,48.938],[13.502,48.966],[13.516,48.97],[13.539,48.967],[13.59,48.945],[13.609,48.946],[13.611,48.927],[13.637,48.893],[13.673,48.876],[13.725,48.867],[13.796,48.78],[13.816,48.766],[13.784,48.715],[13.817,48.696],[13.804,48.687],[13.801,48.675],[13.806,48.643],[13.802,48.612],[13.774,48.569],[13.734,48.56],[13.717,48.522],[13.673,48.535],[13.658,48.551],[13.624,48.565],[13.521,48.585],[13.455,48.573],[13.44,48.561],[13.459,48.516],[13.438,48.479],[13.436,48.431],[13.42,48.392],[13.406,48.377],[13.308,48.32],[13.274,48.307],[13.136,48.291],[13.033,48.264],[12.933,48.209],[12.863,48.197],[12.822,48.161],[12.739,48.113],[12.742,48.087],[12.76,48.065],[12.831,48.015],[12.862,47.963],[12.931,47.925],[12.965,47.872],[12.991,47.847],[12.927,47.777],[12.917,47.75],[12.892,47.724],[12.908,47.712],[12.964,47.705],[13.02,47.713],[13.045,47.697],[13.064,47.674],[13.075,47.647],[13.072,47.622],[13.057,47.598],[13.038,47.584],[13.041,47.562],[13.028,47.542],[13.037,47.493],[13.002,47.466],[12.97,47.476],[12.943,47.47],[12.883,47.499],[12.828,47.537],[12.804,47.541],[12.779,47.555],[12.774,47.58],[12.786,47.603],[12.813,47.612],[12.752,47.649],[12.762,47.667],[12.653,47.675],[12.598,47.663],[12.538,47.631],[12.497,47.629],[12.445,47.656],[12.424,47.692],[12.408,47.694],[12.351,47.682],[12.294,47.69],[12.239,47.679],[12.223,47.696],[12.242,47.732],[12.177,47.706],[12.182,47.692],[12.205,47.672],[12.207,47.646],[12.202,47.63],[12.174,47.605],[11.936,47.611],[11.852,47.599],[11.841,47.595],[11.831,47.578],[11.82,47.575],[11.62,47.59],[11.589,47.57],[11.569,47.527],[11.551,47.514],[11.483,47.503],[11.435,47.51],[11.413,47.506],[11.365,47.469],[11.389,47.462],[11.392,47.455],[11.384,47.445],[11.306,47.435],[11.259,47.401],[11.237,47.394],[11.213,47.396],[11.215,47.423],[11.194,47.429],[11.169,47.424],[11.084,47.39],[10.979,47.391],[10.966,47.396],[10.955,47.41],[10.963,47.421],[10.96,47.433],[10.91,47.469],[10.858,47.485],[10.852,47.493],[10.892,47.515],[10.859,47.531],[10.761,47.514],[10.74,47.528],[10.608,47.562],[10.584,47.562],[10.537,47.53],[10.483,47.533],[10.454,47.546],[10.452,47.555],[10.462,47.57],[10.457,47.579],[10.429,47.577],[10.414,47.573],[10.431,47.542],[10.42,47.493],[10.434,47.488],[10.448,47.473],[10.452,47.439],[10.428,47.396],[10.412,47.381],[10.372,47.367],[10.343,47.331],[10.306,47.302],[10.239,47.278],[10.16,47.271],[10.155,47.273],[10.169,47.29],[10.191,47.298],[10.19,47.317],[10.209,47.354],[10.209,47.372],[10.191,47.379],[10.147,47.374],[10.131,47.363],[10.11,47.367],[10.083,47.359],[10.073,47.365],[10.064,47.396],[10.053,47.405],[10.076,47.417],[10.081,47.427],[10.072,47.439],[10.023,47.488],[9.994,47.477],[9.983,47.481],[9.972,47.506],[9.949,47.524],[9.946,47.541],[9.919,47.532],[9.859,47.541],[9.833,47.535],[9.813,47.542],[9.808,47.576],[9.782,47.588],[9.753,47.582],[9.704,47.531],[9.677,47.523],[9.613,47.522],[9.554,47.533],[9.614,47.589],[9.655,47.596],[9.68,47.612],[9.715,47.605],[9.76,47.62],[9.834,47.665],[9.865,47.675],[9.905,47.663],[9.953,47.659],[10.009,47.675],[10.053,47.68],[10.076,47.65],[10.085,47.658],[10.09,47.679],[10.127,47.677],[10.131,47.691],[10.123,47.711],[10.117,47.763],[10.101,47.769],[10.079,47.79],[10.087,47.801],[10.109,47.802],[10.13,47.817],[10.104,47.86],[10.086,47.866],[10.097,47.875],[10.106,47.898],[10.11,47.934],[10.085,47.966],[10.101,47.98],[10.13,48.035],[10.138,48.057],[10.139,48.094],[10.109,48.148],[10.054,48.305],[10.003,48.369],[9.99,48.37],[9.981,48.381],[10.044,48.437],[10.041,48.45],[10.064,48.471],[10.13,48.488],[10.138,48.483],[10.142,48.469],[10.153,48.466],[10.251,48.505],[10.254,48.518],[10.268,48.528],[10.319,48.533],[10.311,48.54],[10.323,48.562],[10.324,48.579],[10.311,48.605],[10.334,48.617],[10.333,48.623],[10.287,48.647],[10.269,48.669],[10.272,48.683],[10.294,48.703],[10.32,48.69],[10.351,48.689],[10.372,48.664],[10.431,48.705],[10.444,48.699],[10.432,48.678],[10.438,48.668],[10.495,48.699],[10.471,48.734],[10.441,48.742],[10.432,48.756],[10.44,48.775],[10.439,48.8],[10.454,48.814],[10.459,48.831],[10.448,48.85],[10.456,48.899],[10.445,48.906],[10.467,48.915],[10.468,48.921],[10.439,48.961],[10.415,48.983],[10.373,49.005],[10.357,49.032],[10.274,49.05],[10.278,49.066],[10.259,49.079],[10.261,49.091],[10.23,49.103],[10.254,49.11],[10.264,49.127],[10.263,49.148],[10.242,49.16],[10.219,49.155],[10.148,49.203],[10.161,49.212],[10.145,49.229],[10.144,49.25],[10.127,49.268],[10.131,49.273],[10.15,49.266],[10.159,49.269],[10.147,49.299],[10.154,49.317],[10.122,49.335],[10.122,49.37],[10.14,49.384],[10.172,49.382],[10.14,49.433],[10.112,49.442],[10.132,49.472],[10.121,49.492],[10.128,49.511],[10.099,49.519],[10.103,49.536],[10.085,49.544],[10.057,49.527],[10.066,49.52],[10.066,49.509],[10.028,49.483],[9.936,49.482],[9.93,49.55],[9.912,49.562],[9.919,49.572],[9.913,49.579],[9.873,49.561],[9.864,49.545],[9.849,49.543],[9.839,49.548],[9.86,49.568],[9.86,49.585],[9.882,49.611],[9.836,49.649],[9.833,49.689],[9.795,49.721],[9.764,49.712],[9.727,49.687],[9.705,49.721],[9.682,49.707],[9.678,49.691],[9.653,49.688],[9.631,49.699],[9.639,49.724],[9.647,49.729],[9.647,49.766],[9.655,49.775],[9.649,49.783],[9.602,49.771],[9.574,49.782],[9.545,49.779],[9.473,49.796],[9.401,49.784],[9.34,49.783],[9.312,49.766],[9.32,49.75],[9.314,49.732],[9.367,49.721],[9.371,49.715],[9.369,49.698],[9.383,49.7],[9.397,49.718],[9.424,49.711],[9.415,49.663],[9.423,49.651],[9.404,49.64],[9.377,49.65],[9.294,49.633],[9.286,49.627],[9.29,49.611],[9.259,49.579],[9.179,49.579],[9.122,49.571],[9.111,49.575],[9.073,49.611],[9.111,49.64],[9.099,49.686],[9.135,49.698],[9.133,49.717],[9.149,49.744],[9.123,49.754],[9.12,49.762],[9.141,49.787],[9.133,49.796],[9.113,49.794],[9.099,49.8],[9.091,49.815],[9.091,49.843],[9.069,49.831],[9.027,49.907],[9.028,49.985],[9.053,49.982],[9.041,50.0],[9.042,50.012],[9.032,50.021],[9.026,50.045],[9.012,50.05],[8.992,50.046],[8.977,50.053],[9.006,50.066],[9.012,50.099],[9.111,50.126],[9.155,50.111],[9.149,50.098],[9.188,50.12],[9.194,50.137],[9.235,50.144],[9.329,50.139],[9.378,50.123],[9.407,50.092],[9.446,50.091],[9.507,50.108],[9.517,50.116],[9.512,50.143],[9.529,50.167],[9.498,50.179],[9.509,50.207],[9.508,50.236],[9.541,50.237],[9.558,50.226],[9.58,50.229],[9.598,50.224],[9.661,50.234],[9.665,50.238],[9.658,50.254],[9.673,50.275],[9.749,50.306],[9.743,50.347],[9.767,50.402],[9.779,50.414],[9.802,50.419],[9.828,50.401],[9.859,50.399],[9.981,50.437],[10.013,50.461],[10.055,50.517],[10.126,50.563],[10.13,50.548],[10.197,50.548],[10.218,50.536],[10.231,50.513],[10.328,50.489],[10.337,50.478],[10.341,50.459],[10.399,50.423],[10.392,50.409],[10.401,50.397],[10.449,50.399],[10.466,50.38],[10.493,50.366],[10.5,50.354],[10.552,50.359],[10.566,50.34],[10.591,50.326],[10.6,50.293],[10.594,50.271],[10.607,50.25],[10.604,50.238],[10.609,50.231],[10.617,50.224],[10.687,50.22],[10.721,50.207],[10.727,50.21],[10.732,50.242],[10.739,50.248],[10.758,50.243],[10.798,50.248],[10.829,50.241],[10.846,50.247],[10.841,50.27],[10.813,50.277],[10.799,50.292],[10.769,50.293],[10.715,50.322],[10.722,50.357],[10.758,50.358],[10.789,50.386],[10.81,50.382],[10.875,50.394],[10.896,50.382],[10.943,50.387],[10.988,50.35],[10.998,50.364],[11.025,50.361],[11.05,50.348],[11.113,50.365],[11.121,50.354],[11.138,50.35],[11.156,50.329],[11.135,50.309],[11.143,50.301],[11.192,50.272],[11.208,50.285],[11.221,50.278],[11.255,50.275],[11.255,50.288],[11.266,50.305],[11.259,50.321],[11.271,50.341],[11.269,50.353],[11.28,50.359],[11.259,50.403],[11.263,50.428],[11.249,50.473],[11.316,50.493],[11.325,50.508],[11.347,50.519],[11.427,50.513],[11.439,50.497],[11.421,50.486],[11.421,50.443],[11.454,50.428],[11.477,50.427],[11.487,50.397],[11.526,50.379],[11.556,50.382],[11.57,50.395],[11.621,50.387],[11.785,50.414],[11.823,50.401],[11.824,50.391],[11.829,50.391],[11.905,50.415],[11.932,50.417],[11.981,50.385],[11.989,50.349],[12.043,50.328],[12.094,50.323],[12.101,50.314]]]},{"name":"Berlin","ringe":[[[13.474,52.664],[13.501,52.644],[13.503,52.61],[13.569,52.563],[13.612,52.545],[13.648,52.539],[13.649,52.532],[13.625,52.498],[13.626,52.487],[13.665,52.49],[13.696,52.471],[13.751,52.45],[13.74,52.439],[13.734,52.42],[13.709,52.405],[13.707,52.378],[13.7,52.369],[13.671,52.362],[13.654,52.342],[13.644,52.342],[13.637,52.376],[13.626,52.384],[13.585,52.389],[13.528,52.405],[13.484,52.399],[13.458,52.417],[13.425,52.407],[13.426,52.381],[13.376,52.387],[13.346,52.407],[13.309,52.401],[13.294,52.415],[13.252,52.405],[13.214,52.417],[13.156,52.397],[13.118,52.402],[13.092,52.414],[13.117,52.44],[13.109,52.455],[13.114,52.474],[13.157,52.501],[13.141,52.524],[13.148,52.576],[13.145,52.585],[13.155,52.6],[13.217,52.587],[13.209,52.604],[13.221,52.626],[13.262,52.633],[13.289,52.657],[13.303,52.656],[13.303,52.635],[13.31,52.63],[13.364,52.624],[13.38,52.626],[13.412,52.649],[13.438,52.642],[13.474,52.664]]]},{"name":"Brandenburg","ringe":[[[14.608,52.596],[14.645,52.577],[14.617,52.541],[14.609,52.518],[14.628,52.507],[14.632,52.497],[14.592,52.45],[14.54,52.422],[14.545,52.382],[14.59,52.309],[14.584,52.291],[14.607,52.276],[14.697,52.253],[14.712,52.236],[14.712,52.215],[14.693,52.2],[14.707,52.179],[14.686,52.121],[14.696,52.107],[14.761,52.077],[14.735,52.054],[14.726,52.026],[14.699,51.993],[14.696,51.931],[14.687,51.912],[14.653,51.879],[14.596,51.842],[14.583,51.825],[14.586,51.804],[14.634,51.769],[14.648,51.717],[14.72,51.676],[14.732,51.658],[14.742,51.633],[14.744,51.607],[14.713,51.567],[14.707,51.541],[14.663,51.542],[14.574,51.565],[14.509,51.557],[14.377,51.516],[14.339,51.51],[14.269,51.532],[14.221,51.536],[14.159,51.527],[14.106,51.506],[14.067,51.474],[14.044,51.428],[13.97,51.359],[13.961,51.357],[13.938,51.361],[13.766,51.348],[13.534,51.364],[13.458,51.407],[13.411,51.421],[13.252,51.385],[13.239,51.399],[13.219,51.403],[13.188,51.385],[13.174,51.407],[13.198,51.433],[13.2,51.458],[13.189,51.476],[13.21,51.491],[13.2,51.524],[13.165,51.545],[13.172,51.566],[13.166,51.573],[13.137,51.587],[13.115,51.585],[13.062,51.615],[13.082,51.638],[13.15,51.673],[13.178,51.697],[13.182,51.709],[13.159,51.741],[13.158,51.782],[13.237,51.855],[13.179,51.861],[13.161,51.844],[13.132,51.858],[13.066,51.853],[12.992,51.882],[12.962,51.904],[12.877,51.911],[12.859,51.935],[12.844,51.942],[12.795,51.946],[12.667,51.994],[12.655,51.994],[12.646,51.982],[12.608,51.965],[12.544,51.978],[12.467,52.022],[12.45,52.011],[12.421,52.012],[12.365,52.04],[12.361,52.058],[12.299,52.11],[12.256,52.131],[12.246,52.148],[12.208,52.178],[12.2,52.197],[12.222,52.232],[12.248,52.252],[12.233,52.268],[12.209,52.279],[12.207,52.307],[12.235,52.35],[12.229,52.368],[12.232,52.386],[12.257,52.427],[12.248,52.445],[12.266,52.456],[12.287,52.484],[12.288,52.506],[12.295,52.516],[12.256,52.514],[12.23,52.537],[12.212,52.542],[12.193,52.519],[12.156,52.516],[12.146,52.532],[12.162,52.549],[12.123,52.553],[12.151,52.58],[12.162,52.636],[12.205,52.641],[12.226,52.673],[12.228,52.69],[12.21,52.703],[12.19,52.736],[12.209,52.752],[12.195,52.779],[12.23,52.799],[12.234,52.81],[12.208,52.862],[12.175,52.872],[12.111,52.867],[12.11,52.881],[12.093,52.892],[11.983,52.883],[11.897,52.902],[11.832,52.928],[11.851,52.955],[11.807,52.96],[11.763,52.995],[11.712,52.982],[11.694,52.984],[11.681,53.012],[11.622,53.022],[11.636,53.037],[11.636,53.046],[11.626,53.05],[11.581,53.043],[11.491,53.061],[11.461,53.079],[11.438,53.084],[11.384,53.067],[11.344,53.066],[11.291,53.099],[11.274,53.118],[11.352,53.116],[11.394,53.108],[11.399,53.133],[11.415,53.138],[11.447,53.138],[11.502,53.119],[11.528,53.121],[11.548,53.13],[11.555,53.146],[11.545,53.177],[11.53,53.195],[11.536,53.205],[11.6,53.237],[11.688,53.251],[11.732,53.233],[11.744,53.216],[11.786,53.228],[11.839,53.226],[11.836,53.24],[11.816,53.25],[11.834,53.256],[11.861,53.251],[11.884,53.268],[11.94,53.283],[11.959,53.278],[11.996,53.298],[12.012,53.3],[12.023,53.329],[12.049,53.34],[12.064,53.356],[12.123,53.367],[12.148,53.354],[12.192,53.369],[12.204,53.366],[12.226,53.343],[12.296,53.331],[12.339,53.316],[12.415,53.281],[12.43,53.268],[12.512,53.273],[12.624,53.256],[12.664,53.236],[12.74,53.225],[12.742,53.201],[12.753,53.194],[12.823,53.198],[12.866,53.185],[12.953,53.195],[12.959,53.189],[12.946,53.174],[12.975,53.165],[13.001,53.168],[13.041,53.195],[13.102,53.223],[13.113,53.241],[13.146,53.258],[13.168,53.257],[13.19,53.237],[13.23,53.233],[13.249,53.271],[13.282,53.288],[13.33,53.287],[13.388,53.271],[13.469,53.302],[13.494,53.319],[13.526,53.367],[13.539,53.397],[13.553,53.405],[13.607,53.411],[13.63,53.424],[13.648,53.444],[13.709,53.475],[13.779,53.477],[13.809,53.485],[13.779,53.525],[13.781,53.544],[13.798,53.542],[13.823,53.519],[13.869,53.508],[13.871,53.486],[13.921,53.427],[14.05,53.428],[14.096,53.419],[14.117,53.436],[14.142,53.44],[14.177,53.429],[14.243,53.427],[14.24,53.382],[14.224,53.36],[14.188,53.326],[14.104,53.292],[14.103,53.273],[14.249,53.259],[14.263,53.265],[14.269,53.279],[14.296,53.287],[14.324,53.309],[14.391,53.334],[14.403,53.279],[14.442,53.252],[14.381,53.19],[14.378,53.177],[14.39,53.132],[14.357,53.067],[14.343,53.049],[14.313,53.029],[14.144,52.96],[14.165,52.896],[14.161,52.877],[14.15,52.865],[14.124,52.851],[14.135,52.837],[14.153,52.828],[14.216,52.818],[14.249,52.795],[14.382,52.738],[14.432,52.686],[14.461,52.675],[14.49,52.651],[14.55,52.636],[14.608,52.596]],[[13.626,52.487],[13.625,52.498],[13.649,52.532],[13.648,52.539],[13.612,52.545],[13.569,52.563],[13.503,52.61],[13.501,52.644],[13.474,52.664],[13.438,52.642],[13.412,52.649],[13.38,52.626],[13.364,52.624],[13.31,52.63],[13.303,52.635],[13.303,52.656],[13.289,52.657],[13.262,52.633],[13.221,52.626],[13.209,52.604],[13.217,52.587],[13.152,52.598],[13.145,52.585],[13.148,52.576],[13.141,52.524],[13.157,52.501],[13.114,52.474],[13.109,52.455],[13.117,52.44],[13.092,52.415],[13.118,52.402],[13.156,52.397],[13.214,52.417],[13.252,52.405],[13.294,52.415],[13.309,52.401],[13.346,52.407],[13.376,52.387],[13.426,52.381],[13.425,52.407],[13.458,52.417],[13.484,52.399],[13.528,52.405],[13.585,52.389],[13.626,52.384],[13.637,52.376],[13.643,52.345],[13.654,52.342],[13.667,52.36],[13.703,52.372],[13.705,52.398],[13.734,52.42],[13.74,52.439],[13.751,52.45],[13.696,52.471],[13.665,52.49],[13.626,52.487]]]},{"name":"Freie Hansestadt Bremen","ringe":[[[8.557,53.52],[8.566,53.547],[8.513,53.617],[8.562,53.606],[8.631,53.604],[8.62,53.588],[8.642,53.539],[8.639,53.517],[8.6,53.49],[8.588,53.5],[8.573,53.502],[8.557,53.52]],[[8.519,53.226],[8.564,53.212],[8.59,53.215],[8.677,53.19],[8.746,53.192],[8.751,53.181],[8.779,53.17],[8.824,53.168],[8.895,53.139],[8.912,53.139],[8.926,53.159],[8.936,53.163],[8.975,53.137],[8.958,53.122],[8.99,53.094],[8.979,53.082],[8.973,53.053],[8.915,53.025],[8.866,53.048],[8.863,53.037],[8.844,53.033],[8.772,53.055],[8.705,53.05],[8.702,53.083],[8.638,53.13],[8.631,53.16],[8.617,53.177],[8.578,53.18],[8.513,53.2],[8.496,53.224],[8.519,53.226]]]},{"name":"Hamburg","ringe":[[[9.764,53.548],[9.819,53.54],[9.832,53.544],[9.82,53.557],[9.782,53.57],[9.741,53.573],[9.753,53.608],[9.768,53.629],[9.81,53.618],[9.806,53.599],[9.816,53.593],[9.927,53.646],[9.953,53.65],[9.984,53.647],[10.014,53.673],[10.06,53.678],[10.086,53.719],[10.148,53.714],[10.187,53.728],[10.194,53.725],[10.193,53.718],[10.165,53.685],[10.205,53.662],[10.209,53.645],[10.223,53.634],[10.211,53.584],[10.173,53.574],[10.183,53.529],[10.214,53.519],[10.248,53.489],[10.321,53.456],[10.328,53.433],[10.276,53.431],[10.242,53.407],[10.187,53.404],[10.154,53.414],[10.131,53.431],[10.111,53.434],[10.083,53.451],[10.032,53.441],[10.028,53.427],[9.985,53.415],[9.945,53.423],[9.913,53.416],[9.905,53.441],[9.896,53.449],[9.878,53.445],[9.861,53.432],[9.817,53.457],[9.794,53.488],[9.768,53.508],[9.764,53.548]]]},{"name":"Hessen","ringe":[[[9.949,51.382],[9.93,51.372],[9.923,51.36],[9.93,51.34],[9.943,51.327],[9.947,51.304],[9.965,51.3],[9.984,51.286],[10.049,51.277],[10.054,51.271],[10.049,51.258],[10.069,51.244],[10.077,51.225],[10.173,51.206],[10.233,51.18],[10.209,51.151],[10.199,51.116],[10.174,51.145],[10.126,51.136],[10.14,51.122],[10.169,51.115],[10.147,51.081],[10.141,51.059],[10.193,51.045],[10.205,51.032],[10.209,51.021],[10.191,50.997],[10.133,50.995],[10.123,50.999],[10.121,51.009],[10.051,51.007],[10.026,50.996],[10.024,50.982],[10.043,50.962],[10.041,50.941],[10.013,50.937],[9.963,50.943],[9.952,50.936],[9.956,50.925],[9.97,50.929],[9.974,50.906],[9.995,50.919],[10.009,50.921],[10.033,50.909],[10.056,50.883],[10.026,50.859],[10.023,50.833],[9.949,50.815],[9.942,50.796],[9.946,50.777],[9.926,50.773],[9.921,50.764],[9.936,50.751],[9.933,50.733],[9.913,50.705],[9.913,50.693],[9.881,50.667],[9.878,50.644],[9.883,50.634],[9.905,50.638],[9.948,50.627],[9.96,50.64],[9.947,50.653],[9.951,50.663],[10.004,50.677],[10.06,50.666],[10.074,50.645],[10.076,50.622],[10.046,50.605],[10.048,50.57],[10.057,50.554],[10.044,50.522],[10.055,50.517],[10.001,50.448],[9.918,50.414],[9.847,50.398],[9.828,50.401],[9.802,50.419],[9.779,50.414],[9.767,50.402],[9.743,50.347],[9.749,50.306],[9.673,50.275],[9.658,50.254],[9.665,50.238],[9.661,50.234],[9.598,50.224],[9.58,50.229],[9.558,50.226],[9.541,50.237],[9.508,50.236],[9.509,50.207],[9.498,50.179],[9.529,50.167],[9.512,50.143],[9.517,50.116],[9.507,50.108],[9.446,50.091],[9.407,50.092],[9.378,50.123],[9.329,50.139],[9.235,50.144],[9.194,50.137],[9.188,50.12],[9.149,50.098],[9.155,50.111],[9.111,50.126],[9.012,50.099],[9.006,50.066],[8.977,50.053],[8.992,50.046],[9.012,50.05],[9.026,50.045],[9.032,50.021],[9.042,50.012],[9.041,50.0],[9.053,49.982],[9.028,49.985],[9.027,49.907],[9.069,49.831],[9.091,49.843],[9.091,49.815],[9.099,49.8],[9.113,49.794],[9.133,49.796],[9.141,49.787],[9.12,49.762],[9.123,49.754],[9.149,49.744],[9.133,49.717],[9.135,49.698],[9.099,49.686],[9.111,49.64],[9.073,49.611],[9.111,49.575],[9.102,49.565],[9.08,49.574],[9.075,49.567],[9.099,49.547],[9.127,49.508],[9.102,49.518],[9.076,49.519],[9.035,49.501],[8.954,49.503],[8.927,49.482],[8.943,49.462],[8.94,49.447],[8.914,49.438],[8.901,49.447],[8.891,49.425],[8.852,49.397],[8.814,49.403],[8.813,49.422],[8.833,49.435],[8.831,49.469],[8.852,49.485],[8.877,49.475],[8.893,49.494],[8.89,49.506],[8.828,49.503],[8.808,49.523],[8.765,49.524],[8.718,49.542],[8.692,49.574],[8.687,49.586],[8.692,49.602],[8.676,49.623],[8.599,49.61],[8.617,49.545],[8.566,49.527],[8.474,49.586],[8.45,49.588],[8.417,49.579],[8.361,49.664],[8.362,49.691],[8.379,49.71],[8.406,49.722],[8.439,49.727],[8.474,49.757],[8.477,49.768],[8.424,49.767],[8.39,49.815],[8.38,49.843],[8.34,49.891],[8.355,49.926],[8.335,49.96],[8.228,50.038],[8.201,50.046],[8.174,50.046],[8.049,50.018],[7.962,49.983],[7.878,49.973],[7.86,49.99],[7.85,50.011],[7.771,50.053],[7.839,50.084],[7.847,50.107],[7.859,50.119],[7.896,50.121],[7.922,50.109],[7.93,50.136],[7.904,50.149],[7.896,50.162],[7.922,50.2],[7.993,50.228],[8.023,50.216],[8.044,50.236],[8.034,50.246],[8.042,50.262],[8.096,50.262],[8.122,50.272],[8.107,50.311],[8.08,50.33],[8.049,50.371],[8.032,50.377],[8.026,50.388],[7.973,50.4],[7.988,50.412],[8.0,50.44],[8.019,50.455],[7.999,50.466],[8.004,50.521],[8.053,50.546],[8.07,50.544],[8.093,50.528],[8.115,50.529],[8.137,50.542],[8.164,50.586],[8.144,50.603],[8.12,50.65],[8.144,50.698],[8.167,50.708],[8.175,50.744],[8.143,50.764],[8.134,50.781],[8.283,50.879],[8.303,50.88],[8.32,50.862],[8.364,50.862],[8.395,50.875],[8.397,50.889],[8.427,50.909],[8.444,50.916],[8.464,50.914],[8.47,50.944],[8.491,50.973],[8.51,50.98],[8.545,51.021],[8.535,51.05],[8.537,51.063],[8.521,51.085],[8.525,51.093],[8.627,51.094],[8.661,51.088],[8.706,51.106],[8.696,51.13],[8.752,51.178],[8.732,51.25],[8.72,51.255],[8.723,51.269],[8.64,51.253],[8.617,51.24],[8.602,51.244],[8.594,51.258],[8.576,51.27],[8.594,51.296],[8.682,51.362],[8.809,51.382],[8.856,51.375],[8.894,51.39],[8.935,51.391],[8.95,51.412],[8.917,51.421],[8.913,51.45],[8.898,51.467],[8.901,51.476],[8.958,51.497],[9.015,51.509],[9.079,51.488],[9.087,51.473],[9.085,51.457],[9.109,51.443],[9.154,51.438],[9.193,51.456],[9.212,51.457],[9.224,51.487],[9.296,51.518],[9.336,51.554],[9.355,51.593],[9.333,51.607],[9.418,51.627],[9.44,51.649],[9.448,51.644],[9.467,51.655],[9.487,51.653],[9.492,51.634],[9.508,51.628],[9.528,51.629],[9.549,51.64],[9.585,51.626],[9.621,51.631],[9.631,51.617],[9.654,51.608],[9.666,51.585],[9.686,51.572],[9.671,51.559],[9.638,51.572],[9.634,51.566],[9.645,51.55],[9.62,51.552],[9.613,51.524],[9.595,51.518],[9.616,51.484],[9.638,51.47],[9.63,51.458],[9.637,51.447],[9.639,51.409],[9.633,51.401],[9.579,51.398],[9.563,51.381],[9.571,51.361],[9.535,51.367],[9.569,51.342],[9.669,51.32],[9.711,51.292],[9.726,51.291],[9.77,51.315],[9.785,51.339],[9.775,51.342],[9.751,51.331],[9.719,51.351],[9.713,51.363],[9.792,51.39],[9.802,51.397],[9.796,51.408],[9.833,51.398],[9.849,51.377],[9.861,51.372],[9.876,51.382],[9.863,51.409],[9.902,51.419],[9.906,51.404],[9.949,51.382]]]},{"name":"Mecklenburg-Vorpommern","ringe":[[[14.264,53.7],[14.261,53.656],[14.297,53.598],[14.297,53.529],[14.304,53.509],[14.36,53.444],[14.37,53.392],[14.391,53.334],[14.324,53.309],[14.296,53.287],[14.269,53.279],[14.263,53.265],[14.249,53.259],[14.103,53.273],[14.104,53.292],[14.188,53.326],[14.224,53.36],[14.24,53.382],[14.243,53.427],[14.177,53.429],[14.142,53.44],[14.117,53.436],[14.096,53.419],[14.05,53.428],[13.921,53.427],[13.871,53.486],[13.869,53.508],[13.823,53.519],[13.798,53.542],[13.781,53.544],[13.779,53.525],[13.809,53.485],[13.779,53.477],[13.709,53.475],[13.648,53.444],[13.63,53.424],[13.607,53.411],[13.553,53.405],[13.539,53.397],[13.526,53.367],[13.494,53.319],[13.469,53.302],[13.388,53.271],[13.33,53.287],[13.282,53.288],[13.249,53.271],[13.23,53.233],[13.19,53.237],[13.168,53.257],[13.146,53.258],[13.113,53.241],[13.102,53.223],[13.041,53.195],[13.001,53.168],[12.975,53.165],[12.946,53.174],[12.959,53.189],[12.953,53.195],[12.866,53.185],[12.823,53.198],[12.753,53.194],[12.742,53.201],[12.74,53.225],[12.664,53.236],[12.624,53.256],[12.512,53.273],[12.43,53.268],[12.415,53.281],[12.339,53.316],[12.296,53.331],[12.226,53.343],[12.204,53.366],[12.192,53.369],[12.148,53.354],[12.123,53.367],[12.064,53.356],[12.049,53.34],[12.023,53.329],[12.012,53.3],[11.996,53.298],[11.959,53.278],[11.94,53.283],[11.884,53.268],[11.861,53.251],[11.834,53.256],[11.816,53.25],[11.836,53.24],[11.839,53.226],[11.786,53.228],[11.744,53.216],[11.732,53.233],[11.688,53.251],[11.6,53.237],[11.536,53.205],[11.53,53.195],[11.545,53.177],[11.555,53.146],[11.548,53.13],[11.528,53.121],[11.502,53.119],[11.447,53.138],[11.415,53.138],[11.399,53.133],[11.394,53.108],[11.352,53.116],[11.274,53.118],[11.256,53.136],[11.225,53.153],[11.194,53.141],[11.156,53.174],[11.143,53.173],[11.089,53.144],[10.93,53.228],[10.839,53.26],[10.775,53.3],[10.739,53.308],[10.732,53.352],[10.71,53.368],[10.676,53.374],[10.601,53.372],[10.626,53.397],[10.641,53.451],[10.669,53.459],[10.691,53.458],[10.704,53.478],[10.76,53.488],[10.823,53.516],[10.833,53.548],[10.83,53.57],[10.908,53.572],[10.917,53.568],[10.929,53.548],[10.954,53.566],[10.942,53.57],[10.954,53.578],[10.94,53.629],[10.953,53.634],[10.956,53.65],[10.943,53.681],[10.915,53.697],[10.857,53.703],[10.806,53.741],[10.758,53.749],[10.771,53.793],[10.751,53.841],[10.763,53.865],[10.885,53.919],[10.907,53.915],[10.907,53.905],[10.913,53.902],[10.961,53.912],[10.911,53.925],[10.904,53.931],[10.906,53.961],[10.923,53.961],[10.965,53.985],[11.053,54.008],[11.175,54.018],[11.191,54.01],[11.204,53.989],[11.245,53.978],[11.243,53.945],[11.294,53.941],[11.334,53.961],[11.358,53.941],[11.4,53.941],[11.439,53.909],[11.458,53.906],[11.477,53.953],[11.472,53.969],[11.498,53.982],[11.492,54.002],[11.526,54.023],[11.519,54.036],[11.553,54.03],[11.573,54.039],[11.591,54.066],[11.625,54.084],[11.63,54.096],[11.622,54.1],[11.625,54.11],[11.658,54.142],[11.69,54.155],[11.729,54.159],[11.812,54.15],[12.051,54.18],[12.088,54.194],[12.092,54.114],[12.115,54.098],[12.105,54.11],[12.095,54.146],[12.105,54.157],[12.134,54.167],[12.143,54.18],[12.101,54.174],[12.109,54.183],[12.187,54.243],[12.289,54.275],[12.342,54.304],[12.378,54.345],[12.438,54.396],[12.486,54.447],[12.502,54.474],[12.534,54.488],[12.534,54.482],[12.527,54.482],[12.548,54.462],[12.591,54.452],[12.819,54.452],[12.857,54.441],[12.88,54.449],[12.908,54.445],[12.921,54.433],[12.896,54.42],[12.871,54.417],[12.764,54.422],[12.695,54.434],[12.67,54.42],[12.624,54.423],[12.615,54.42],[12.608,54.406],[12.602,54.414],[12.582,54.406],[12.588,54.394],[12.602,54.392],[12.595,54.386],[12.554,54.391],[12.536,54.384],[12.527,54.372],[12.491,54.39],[12.438,54.388],[12.418,54.361],[12.424,54.345],[12.397,54.352],[12.39,54.345],[12.396,54.333],[12.392,54.321],[12.363,54.31],[12.38,54.277],[12.369,54.269],[12.412,54.251],[12.437,54.248],[12.458,54.256],[12.443,54.255],[12.41,54.269],[12.426,54.29],[12.472,54.31],[12.479,54.332],[12.561,54.359],[12.541,54.365],[12.541,54.372],[12.559,54.376],[12.574,54.372],[12.574,54.379],[12.593,54.369],[12.677,54.392],[12.67,54.406],[12.681,54.411],[12.718,54.414],[12.718,54.406],[12.692,54.396],[12.684,54.372],[12.705,54.4],[12.735,54.378],[12.746,54.386],[12.769,54.38],[12.787,54.392],[12.795,54.377],[12.818,54.358],[12.855,54.358],[12.876,54.365],[12.904,54.4],[12.929,54.414],[13.009,54.438],[13.034,54.434],[13.021,54.426],[13.029,54.402],[13.04,54.392],[13.096,54.372],[13.077,54.353],[13.081,54.337],[13.109,54.31],[13.115,54.28],[13.171,54.277],[13.171,54.269],[13.143,54.263],[13.176,54.265],[13.289,54.234],[13.322,54.195],[13.349,54.18],[13.322,54.167],[13.347,54.167],[13.384,54.178],[13.403,54.174],[13.383,54.153],[13.395,54.149],[13.418,54.153],[13.45,54.11],[13.484,54.091],[13.507,54.091],[13.507,54.098],[13.479,54.118],[13.479,54.125],[13.603,54.139],[13.712,54.174],[13.715,54.164],[13.698,54.159],[13.698,54.153],[13.808,54.105],[13.773,54.046],[13.746,54.036],[13.798,53.999],[13.839,53.986],[13.849,53.968],[13.849,53.956],[13.906,53.943],[13.908,53.934],[13.89,53.913],[13.819,53.878],[13.808,53.865],[13.817,53.853],[13.863,53.841],[13.869,53.83],[13.9,53.809],[13.911,53.81],[13.984,53.774],[14.017,53.77],[14.037,53.755],[14.069,53.755],[14.106,53.743],[14.187,53.74],[14.215,53.745],[14.234,53.762],[14.25,53.76],[14.264,53.752],[14.267,53.735],[14.213,53.708],[14.264,53.7]],[[14.21,53.938],[14.193,53.911],[14.175,53.906],[14.193,53.894],[14.201,53.878],[14.087,53.87],[14.048,53.879],[13.996,53.854],[13.938,53.845],[13.875,53.844],[13.84,53.85],[13.822,53.865],[13.932,53.899],[13.939,53.928],[13.902,53.97],[13.904,53.995],[13.973,53.989],[13.966,53.982],[13.959,53.941],[13.975,53.957],[13.987,53.961],[14.028,53.961],[14.014,53.947],[14.048,53.941],[14.04,53.954],[14.053,53.978],[14.051,54.005],[14.037,54.022],[14.014,54.016],[13.982,54.056],[13.946,54.067],[13.916,54.056],[13.925,54.036],[13.891,54.01],[13.856,54.002],[13.858,54.016],[13.876,54.036],[13.871,54.049],[13.864,54.05],[13.823,54.038],[13.794,54.018],[13.774,54.023],[13.782,54.042],[13.809,54.075],[13.815,54.101],[13.806,54.117],[13.753,54.153],[13.778,54.174],[13.796,54.178],[13.815,54.174],[13.856,54.118],[13.882,54.102],[14.017,54.06],[14.21,53.938]],[[11.376,53.982],[11.39,54.005],[11.427,54.023],[11.468,54.031],[11.498,54.023],[11.458,53.961],[11.445,53.961],[11.451,53.986],[11.447,53.997],[11.439,53.997],[11.433,53.989],[11.438,53.975],[11.423,53.968],[11.407,53.968],[11.376,53.982]],[[11.528,54.075],[11.534,54.085],[11.616,54.112],[11.553,54.073],[11.54,54.057],[11.534,54.057],[11.528,54.075]],[[13.143,54.598],[13.119,54.586],[13.103,54.53],[13.075,54.509],[13.068,54.468],[13.061,54.468],[13.064,54.505],[13.102,54.596],[13.11,54.603],[13.151,54.605],[13.157,54.588],[13.15,54.591],[13.15,54.578],[13.143,54.578],[13.143,54.598]],[[13.698,54.392],[13.742,54.35],[13.766,54.345],[13.74,54.33],[13.73,54.317],[13.723,54.278],[13.691,54.284],[13.712,54.287],[13.716,54.293],[13.712,54.304],[13.679,54.294],[13.651,54.297],[13.651,54.304],[13.688,54.313],[13.712,54.332],[13.66,54.33],[13.617,54.318],[13.691,54.345],[13.691,54.352],[13.65,54.352],[13.627,54.339],[13.599,54.352],[13.5,54.345],[13.429,54.311],[13.406,54.285],[13.36,54.275],[13.356,54.249],[13.369,54.256],[13.363,54.263],[13.418,54.266],[13.423,54.233],[13.407,54.228],[13.347,54.237],[13.312,54.251],[13.335,54.284],[13.308,54.269],[13.315,54.263],[13.284,54.263],[13.268,54.256],[13.21,54.276],[13.199,54.284],[13.205,54.297],[13.19,54.3],[13.15,54.29],[13.143,54.298],[13.149,54.305],[13.178,54.304],[13.178,54.31],[13.115,54.338],[13.149,54.377],[13.174,54.384],[13.205,54.372],[13.212,54.379],[13.223,54.373],[13.267,54.386],[13.239,54.394],[13.234,54.403],[13.239,54.42],[13.225,54.421],[13.212,54.431],[13.157,54.427],[13.182,54.453],[13.26,54.468],[13.26,54.475],[13.273,54.482],[13.233,54.493],[13.225,54.502],[13.234,54.509],[13.233,54.516],[13.185,54.508],[13.166,54.519],[13.159,54.541],[13.143,54.543],[13.166,54.549],[13.26,54.554],[13.288,54.54],[13.298,54.52],[13.309,54.535],[13.301,54.55],[13.334,54.573],[13.369,54.585],[13.342,54.55],[13.363,54.543],[13.349,54.524],[13.375,54.526],[13.384,54.532],[13.39,54.543],[13.363,54.55],[13.363,54.557],[13.377,54.565],[13.417,54.525],[13.411,54.509],[13.431,54.493],[13.507,54.488],[13.5,54.496],[13.521,54.516],[13.521,54.524],[13.5,54.557],[13.521,54.565],[13.521,54.571],[13.508,54.57],[13.487,54.557],[13.441,54.557],[13.424,54.575],[13.399,54.58],[13.369,54.612],[13.273,54.561],[13.253,54.565],[13.25,54.576],[13.257,54.595],[13.284,54.619],[13.293,54.642],[13.291,54.647],[13.225,54.635],[13.231,54.653],[13.254,54.665],[13.391,54.688],[13.419,54.688],[13.445,54.68],[13.445,54.674],[13.391,54.651],[13.383,54.636],[13.395,54.604],[13.425,54.585],[13.466,54.577],[13.596,54.591],[13.64,54.586],[13.671,54.566],[13.677,54.54],[13.651,54.516],[13.585,54.487],[13.575,54.472],[13.588,54.438],[13.61,54.414],[13.698,54.392]]]},{"name":"Niedersachsen","ringe":[[[7.072,52.841],[7.162,52.933],[7.184,52.966],[7.193,52.998],[7.194,53.034],[7.185,53.105],[7.172,53.138],[7.195,53.185],[7.195,53.245],[7.216,53.256],[7.234,53.274],[7.237,53.286],[7.23,53.297],[7.255,53.32],[7.292,53.324],[7.367,53.303],[7.343,53.324],[7.303,53.333],[7.075,53.338],[7.038,53.349],[7.024,53.376],[7.023,53.451],[7.051,53.513],[7.06,53.522],[7.12,53.516],[7.142,53.537],[7.14,53.55],[7.131,53.557],[7.089,53.574],[7.087,53.587],[7.101,53.598],[7.226,53.666],[7.305,53.685],[7.459,53.695],[7.48,53.682],[7.504,53.679],[7.651,53.697],[7.951,53.722],[8.031,53.708],[8.025,53.673],[8.053,53.636],[8.094,53.605],[8.128,53.591],[8.119,53.572],[8.157,53.564],[8.168,53.553],[8.165,53.535],[8.155,53.526],[8.116,53.511],[8.059,53.502],[8.086,53.456],[8.1,53.447],[8.124,53.454],[8.152,53.45],[8.187,53.43],[8.205,53.411],[8.237,53.407],[8.259,53.412],[8.293,53.44],[8.315,53.475],[8.32,53.509],[8.292,53.529],[8.231,53.525],[8.249,53.59],[8.271,53.613],[8.329,53.618],[8.348,53.613],[8.388,53.577],[8.428,53.565],[8.513,53.555],[8.552,53.544],[8.552,53.536],[8.506,53.508],[8.49,53.486],[8.484,53.457],[8.485,53.401],[8.491,53.376],[8.504,53.358],[8.507,53.383],[8.498,53.475],[8.513,53.496],[8.557,53.52],[8.573,53.502],[8.603,53.49],[8.639,53.517],[8.643,53.532],[8.62,53.588],[8.631,53.604],[8.562,53.606],[8.513,53.617],[8.487,53.667],[8.486,53.7],[8.53,53.771],[8.573,53.858],[8.607,53.882],[8.652,53.892],[8.685,53.888],[8.745,53.854],[8.782,53.842],[8.861,53.831],[8.976,53.842],[9.016,53.841],[9.035,53.854],[9.115,53.865],[9.194,53.865],[9.211,53.872],[9.224,53.866],[9.275,53.865],[9.305,53.845],[9.403,53.742],[9.485,53.69],[9.498,53.66],[9.567,53.6],[9.601,53.586],[9.637,53.581],[9.697,53.557],[9.764,53.548],[9.768,53.508],[9.794,53.488],[9.817,53.457],[9.857,53.433],[9.896,53.449],[9.905,53.441],[9.913,53.416],[9.945,53.423],[9.975,53.414],[10.028,53.427],[10.032,53.441],[10.083,53.451],[10.111,53.434],[10.131,53.431],[10.154,53.414],[10.187,53.404],[10.242,53.407],[10.266,53.427],[10.285,53.433],[10.351,53.432],[10.528,53.372],[10.57,53.364],[10.601,53.372],[10.676,53.374],[10.71,53.368],[10.732,53.352],[10.739,53.308],[10.775,53.3],[10.839,53.26],[10.93,53.228],[11.089,53.144],[11.143,53.173],[11.156,53.174],[11.194,53.141],[11.225,53.153],[11.256,53.136],[11.291,53.099],[11.344,53.066],[11.384,53.067],[11.438,53.084],[11.461,53.079],[11.491,53.061],[11.581,53.043],[11.562,53.01],[11.515,53.007],[11.507,53.001],[11.496,52.969],[11.5,52.943],[11.47,52.94],[11.409,52.905],[11.253,52.876],[11.214,52.898],[11.165,52.903],[11.107,52.901],[11.051,52.912],[11.015,52.912],[10.999,52.906],[10.989,52.882],[10.942,52.859],[10.886,52.847],[10.795,52.846],[10.759,52.835],[10.757,52.786],[10.774,52.762],[10.794,52.748],[10.801,52.722],[10.837,52.71],[10.885,52.651],[10.928,52.614],[10.957,52.623],[10.977,52.616],[10.974,52.608],[10.948,52.589],[10.946,52.563],[10.954,52.545],[11.002,52.498],[10.953,52.494],[10.939,52.462],[11.011,52.403],[11.038,52.395],[11.073,52.372],[11.064,52.354],[11.019,52.349],[10.999,52.34],[11.003,52.329],[11.023,52.322],[11.034,52.306],[11.015,52.287],[11.045,52.262],[11.061,52.261],[11.064,52.244],[11.078,52.251],[11.085,52.231],[11.078,52.221],[11.037,52.211],[11.021,52.194],[11.027,52.178],[11.054,52.168],[11.058,52.153],[11.048,52.14],[11.014,52.119],[10.949,52.1],[10.949,52.094],[10.969,52.084],[10.97,52.069],[10.963,52.06],[10.661,52.045],[10.652,52.043],[10.651,52.03],[10.622,52.011],[10.57,52.007],[10.59,51.976],[10.629,51.958],[10.645,51.957],[10.618,51.943],[10.613,51.925],[10.635,51.912],[10.64,51.884],[10.604,51.856],[10.582,51.848],[10.578,51.805],[10.585,51.778],[10.619,51.759],[10.644,51.721],[10.664,51.707],[10.669,51.67],[10.698,51.637],[10.649,51.62],[10.631,51.607],[10.629,51.601],[10.654,51.578],[10.65,51.562],[10.618,51.573],[10.557,51.557],[10.514,51.556],[10.431,51.591],[10.375,51.586],[10.378,51.566],[10.358,51.536],[10.306,51.511],[10.291,51.491],[10.24,51.472],[10.19,51.478],[10.172,51.449],[10.147,51.441],[10.15,51.432],[10.085,51.431],[10.069,51.42],[10.042,51.432],[9.949,51.382],[9.906,51.404],[9.904,51.417],[9.898,51.419],[9.863,51.409],[9.876,51.382],[9.861,51.372],[9.849,51.377],[9.833,51.398],[9.796,51.408],[9.802,51.397],[9.792,51.39],[9.713,51.363],[9.719,51.351],[9.751,51.331],[9.775,51.342],[9.785,51.339],[9.77,51.315],[9.722,51.29],[9.711,51.292],[9.669,51.32],[9.569,51.342],[9.535,51.367],[9.571,51.361],[9.563,51.381],[9.579,51.398],[9.636,51.404],[9.637,51.447],[9.63,51.458],[9.638,51.47],[9.616,51.484],[9.595,51.518],[9.613,51.524],[9.62,51.552],[9.645,51.55],[9.634,51.566],[9.638,51.572],[9.671,51.559],[9.686,51.572],[9.666,51.585],[9.654,51.608],[9.631,51.617],[9.621,51.631],[9.585,51.626],[9.549,51.64],[9.528,51.629],[9.5,51.63],[9.483,51.655],[9.467,51.655],[9.448,51.644],[9.44,51.649],[9.394,51.64],[9.379,51.64],[9.373,51.647],[9.375,51.665],[9.39,51.678],[9.391,51.734],[9.407,51.753],[9.434,51.767],[9.452,51.798],[9.453,51.821],[9.462,51.83],[9.44,51.841],[9.461,51.865],[9.322,51.856],[9.338,51.893],[9.326,51.916],[9.302,51.923],[9.255,51.922],[9.275,51.938],[9.286,51.973],[9.243,51.984],[9.228,51.98],[9.219,51.97],[9.171,51.986],[9.199,52.017],[9.177,52.057],[9.189,52.079],[9.177,52.095],[9.141,52.108],[9.143,52.129],[9.064,52.138],[8.996,52.134],[9.007,52.156],[8.988,52.188],[9.046,52.194],[9.038,52.219],[9.073,52.239],[8.987,52.26],[8.982,52.268],[8.995,52.322],[9.008,52.339],[9.049,52.34],[9.074,52.349],[9.128,52.408],[9.109,52.452],[9.13,52.465],[9.132,52.477],[9.081,52.498],[9.026,52.48],[8.999,52.463],[8.961,52.409],[8.827,52.389],[8.711,52.399],[8.704,52.48],[8.676,52.511],[8.643,52.524],[8.566,52.507],[8.497,52.507],[8.464,52.49],[8.448,52.455],[8.424,52.446],[8.367,52.441],[8.301,52.451],[8.321,52.41],[8.334,52.398],[8.399,52.38],[8.428,52.367],[8.441,52.354],[8.46,52.304],[8.461,52.214],[8.464,52.205],[8.498,52.189],[8.492,52.176],[8.476,52.162],[8.426,52.141],[8.401,52.112],[8.267,52.134],[8.181,52.083],[8.11,52.067],[8.075,52.075],[8.038,52.071],[7.97,52.039],[7.912,52.056],[7.892,52.09],[7.922,52.112],[7.99,52.123],[8.017,52.17],[7.99,52.182],[7.948,52.18],[7.934,52.195],[7.908,52.198],[7.901,52.205],[7.925,52.23],[7.916,52.251],[7.946,52.274],[7.93,52.303],[7.973,52.315],[7.906,52.377],[7.823,52.363],[7.802,52.376],[7.71,52.407],[7.703,52.413],[7.692,52.453],[7.681,52.462],[7.601,52.474],[7.582,52.436],[7.602,52.415],[7.591,52.41],[7.576,52.383],[7.562,52.372],[7.514,52.37],[7.416,52.314],[7.368,52.303],[7.301,52.272],[7.122,52.263],[7.053,52.238],[7.027,52.239],[7.012,52.267],[7.012,52.285],[7.04,52.329],[7.048,52.365],[7.034,52.391],[6.973,52.451],[6.951,52.437],[6.9,52.432],[6.741,52.454],[6.715,52.462],[6.695,52.476],[6.684,52.526],[6.672,52.542],[6.744,52.56],[6.704,52.583],[6.71,52.609],[6.702,52.619],[6.737,52.635],[6.771,52.641],[6.865,52.642],[6.918,52.632],[6.968,52.637],[7.018,52.626],[7.037,52.647],[7.053,52.791],[7.072,52.841]],[[8.564,53.212],[8.53,53.225],[8.496,53.224],[8.513,53.2],[8.578,53.18],[8.617,53.177],[8.631,53.16],[8.638,53.13],[8.702,53.083],[8.705,53.05],[8.772,53.055],[8.844,53.033],[8.863,53.037],[8.866,53.048],[8.915,53.025],[8.973,53.053],[8.979,53.082],[8.99,53.094],[8.958,53.122],[8.975,53.137],[8.936,53.163],[8.926,53.159],[8.914,53.141],[8.904,53.138],[8.861,53.149],[8.824,53.168],[8.779,53.17],[8.751,53.181],[8.746,53.192],[8.69,53.188],[8.59,53.215],[8.564,53.212]],[[6.798,53.604],[6.722,53.591],[6.722,53.585],[6.742,53.578],[6.757,53.563],[6.726,53.577],[6.695,53.57],[6.677,53.576],[6.66,53.599],[6.747,53.618],[6.785,53.62],[6.798,53.604]],[[6.874,53.673],[6.911,53.684],[7.047,53.694],[7.085,53.687],[6.874,53.673]],[[7.133,53.708],[7.179,53.724],[7.233,53.729],[7.346,53.728],[7.347,53.723],[7.296,53.711],[7.174,53.701],[7.133,53.708]],[[7.367,53.728],[7.403,53.737],[7.436,53.728],[7.421,53.721],[7.367,53.728]],[[8.12,53.721],[8.133,53.734],[8.152,53.739],[8.196,53.728],[8.172,53.726],[8.151,53.714],[8.131,53.718],[8.12,53.714],[8.12,53.721]],[[7.517,53.762],[7.607,53.763],[7.627,53.751],[7.514,53.746],[7.511,53.728],[7.482,53.727],[7.473,53.732],[7.47,53.745],[7.475,53.757],[7.517,53.762]],[[7.661,53.762],[7.686,53.781],[7.728,53.787],[7.813,53.783],[7.813,53.776],[7.748,53.761],[7.719,53.76],[7.695,53.77],[7.677,53.758],[7.661,53.762]],[[7.874,53.776],[7.855,53.785],[7.877,53.795],[7.914,53.795],[7.956,53.783],[7.875,53.788],[7.874,53.776]]]},{"name":"Nordrhein-Westfalen","ringe":[[[6.772,52.109],[6.831,52.113],[6.885,52.16],[6.948,52.183],[6.982,52.214],[7.026,52.231],[7.027,52.239],[7.053,52.238],[7.122,52.263],[7.301,52.272],[7.368,52.303],[7.416,52.314],[7.514,52.37],[7.562,52.372],[7.576,52.383],[7.591,52.41],[7.602,52.415],[7.582,52.436],[7.594,52.468],[7.609,52.476],[7.685,52.46],[7.706,52.41],[7.802,52.376],[7.816,52.365],[7.829,52.363],[7.906,52.377],[7.97,52.318],[7.97,52.31],[7.93,52.303],[7.946,52.274],[7.916,52.251],[7.925,52.23],[7.901,52.205],[7.908,52.198],[7.934,52.195],[7.948,52.18],[7.99,52.182],[8.017,52.17],[7.99,52.123],[7.922,52.112],[7.892,52.09],[7.912,52.056],[7.974,52.038],[8.038,52.071],[8.075,52.075],[8.11,52.067],[8.181,52.083],[8.267,52.134],[8.401,52.112],[8.426,52.141],[8.476,52.162],[8.492,52.176],[8.498,52.189],[8.464,52.205],[8.461,52.214],[8.46,52.304],[8.441,52.354],[8.428,52.367],[8.399,52.38],[8.334,52.398],[8.321,52.41],[8.301,52.451],[8.367,52.441],[8.424,52.446],[8.448,52.455],[8.464,52.49],[8.497,52.507],[8.566,52.507],[8.643,52.524],[8.676,52.511],[8.704,52.48],[8.711,52.399],[8.827,52.389],[8.961,52.409],[8.999,52.463],[9.026,52.48],[9.091,52.497],[9.134,52.475],[9.126,52.461],[9.109,52.452],[9.128,52.408],[9.074,52.349],[9.049,52.34],[9.008,52.339],[8.995,52.322],[8.982,52.268],[8.987,52.26],[9.073,52.239],[9.038,52.219],[9.046,52.194],[8.988,52.188],[9.007,52.156],[8.996,52.134],[9.064,52.138],[9.143,52.129],[9.141,52.108],[9.177,52.095],[9.189,52.079],[9.177,52.057],[9.199,52.017],[9.171,51.986],[9.219,51.97],[9.228,51.98],[9.243,51.984],[9.286,51.973],[9.275,51.938],[9.255,51.922],[9.302,51.923],[9.326,51.916],[9.338,51.893],[9.322,51.856],[9.461,51.865],[9.44,51.841],[9.462,51.83],[9.453,51.821],[9.452,51.798],[9.434,51.767],[9.407,51.753],[9.391,51.734],[9.39,51.678],[9.372,51.656],[9.379,51.64],[9.44,51.649],[9.418,51.627],[9.333,51.607],[9.355,51.593],[9.336,51.554],[9.296,51.518],[9.224,51.487],[9.212,51.457],[9.193,51.456],[9.154,51.438],[9.109,51.443],[9.085,51.457],[9.087,51.473],[9.079,51.488],[9.015,51.509],[8.958,51.497],[8.901,51.476],[8.898,51.467],[8.913,51.45],[8.917,51.421],[8.95,51.412],[8.935,51.391],[8.894,51.39],[8.856,51.375],[8.809,51.382],[8.728,51.373],[8.682,51.362],[8.654,51.344],[8.594,51.296],[8.576,51.27],[8.594,51.258],[8.602,51.244],[8.617,51.24],[8.64,51.253],[8.723,51.269],[8.72,51.255],[8.732,51.25],[8.752,51.178],[8.696,51.13],[8.706,51.106],[8.661,51.088],[8.627,51.094],[8.525,51.093],[8.521,51.085],[8.537,51.063],[8.535,51.05],[8.545,51.021],[8.51,50.98],[8.491,50.973],[8.47,50.944],[8.466,50.917],[8.427,50.909],[8.397,50.889],[8.395,50.875],[8.364,50.862],[8.32,50.862],[8.303,50.88],[8.283,50.879],[8.197,50.827],[8.187,50.812],[8.133,50.78],[8.143,50.764],[8.175,50.742],[8.167,50.708],[8.147,50.7],[8.134,50.684],[8.096,50.697],[8.056,50.691],[8.034,50.726],[7.981,50.774],[7.984,50.803],[7.97,50.837],[7.943,50.832],[7.886,50.867],[7.846,50.879],[7.86,50.922],[7.789,50.93],[7.758,50.911],[7.754,50.9],[7.759,50.87],[7.77,50.855],[7.768,50.836],[7.738,50.842],[7.676,50.81],[7.699,50.792],[7.694,50.776],[7.677,50.779],[7.662,50.764],[7.618,50.752],[7.599,50.733],[7.572,50.735],[7.532,50.721],[7.449,50.706],[7.387,50.704],[7.38,50.699],[7.383,50.668],[7.373,50.644],[7.34,50.629],[7.283,50.619],[7.226,50.621],[7.216,50.634],[7.199,50.638],[7.186,50.619],[7.155,50.601],[7.118,50.604],[7.105,50.589],[7.094,50.586],[7.062,50.593],[6.999,50.567],[6.939,50.555],[6.913,50.529],[6.885,50.522],[6.888,50.477],[6.874,50.462],[6.84,50.465],[6.821,50.486],[6.76,50.471],[6.761,50.454],[6.753,50.437],[6.778,50.424],[6.781,50.393],[6.8,50.37],[6.798,50.357],[6.718,50.342],[6.702,50.344],[6.671,50.362],[6.625,50.35],[6.618,50.353],[6.623,50.366],[6.586,50.377],[6.528,50.368],[6.458,50.341],[6.445,50.359],[6.398,50.383],[6.392,50.372],[6.427,50.334],[6.398,50.323],[6.373,50.323],[6.338,50.368],[6.351,50.434],[6.332,50.447],[6.323,50.466],[6.337,50.481],[6.256,50.495],[6.207,50.486],[6.183,50.507],[6.191,50.515],[6.171,50.518],[6.177,50.53],[6.161,50.544],[6.179,50.562],[6.233,50.587],[6.251,50.608],[6.249,50.614],[6.18,50.616],[6.159,50.622],[6.148,50.637],[6.161,50.642],[6.081,50.713],[6.012,50.709],[6.011,50.737],[5.993,50.744],[5.997,50.763],[5.973,50.782],[5.969,50.795],[5.984,50.792],[5.998,50.801],[6.004,50.813],[6.0,50.83],[6.009,50.84],[6.046,50.844],[6.057,50.853],[6.063,50.871],[6.055,50.891],[6.064,50.908],[6.0,50.929],[5.996,50.952],[6.004,50.974],[5.968,50.971],[5.928,50.978],[5.875,50.965],[5.874,50.991],[5.858,51.019],[5.852,51.043],[5.88,51.052],[5.92,51.03],[5.938,51.031],[5.983,51.075],[6.075,51.12],[6.132,51.14],[6.147,51.152],[6.126,51.165],[6.157,51.179],[6.134,51.184],[6.084,51.163],[6.061,51.171],[6.054,51.189],[6.057,51.212],[6.065,51.232],[6.148,51.318],[6.194,51.345],[6.208,51.388],[6.193,51.399],[6.205,51.458],[6.193,51.509],[6.086,51.596],[6.082,51.608],[6.099,51.644],[6.031,51.663],[6.009,51.681],[6.022,51.71],[6.012,51.716],[5.978,51.73],[5.939,51.732],[5.94,51.743],[5.962,51.756],[5.964,51.777],[5.951,51.796],[5.928,51.807],[5.931,51.816],[5.948,51.823],[5.989,51.827],[6.043,51.847],[6.084,51.854],[6.156,51.842],[6.093,51.885],[6.127,51.897],[6.225,51.863],[6.265,51.866],[6.287,51.846],[6.342,51.837],[6.345,51.821],[6.382,51.828],[6.387,51.838],[6.378,51.86],[6.463,51.848],[6.529,51.876],[6.628,51.898],[6.717,51.899],[6.744,51.908],[6.79,51.951],[6.811,51.961],[6.809,51.98],[6.778,52.001],[6.675,52.035],[6.673,52.05],[6.68,52.06],[6.711,52.067],[6.75,52.103],[6.772,52.109]]]},{"name":"Rheinland-Pfalz","ringe":[[[6.101,50.063],[6.111,50.106],[6.129,50.134],[6.116,50.145],[6.121,50.162],[6.159,50.172],[6.165,50.182],[6.16,50.205],[6.147,50.214],[6.19,50.243],[6.253,50.257],[6.272,50.267],[6.269,50.286],[6.286,50.304],[6.299,50.309],[6.362,50.307],[6.375,50.315],[6.373,50.323],[6.398,50.323],[6.426,50.332],[6.392,50.372],[6.395,50.382],[6.406,50.382],[6.445,50.359],[6.458,50.341],[6.528,50.368],[6.586,50.377],[6.623,50.366],[6.618,50.353],[6.625,50.35],[6.671,50.362],[6.702,50.344],[6.718,50.342],[6.795,50.356],[6.801,50.363],[6.781,50.393],[6.778,50.424],[6.753,50.437],[6.761,50.454],[6.76,50.471],[6.821,50.486],[6.84,50.465],[6.874,50.462],[6.888,50.477],[6.885,50.522],[6.913,50.529],[6.939,50.555],[6.999,50.567],[7.062,50.593],[7.094,50.586],[7.105,50.589],[7.118,50.604],[7.155,50.601],[7.186,50.619],[7.199,50.638],[7.216,50.634],[7.226,50.621],[7.283,50.619],[7.34,50.629],[7.373,50.644],[7.383,50.668],[7.38,50.699],[7.387,50.704],[7.449,50.706],[7.532,50.721],[7.572,50.735],[7.599,50.733],[7.618,50.752],[7.662,50.764],[7.677,50.779],[7.696,50.777],[7.699,50.792],[7.676,50.81],[7.738,50.842],[7.77,50.839],[7.77,50.855],[7.758,50.875],[7.758,50.911],[7.802,50.933],[7.86,50.922],[7.846,50.879],[7.886,50.867],[7.943,50.832],[7.97,50.837],[7.984,50.803],[7.981,50.774],[8.034,50.726],[8.056,50.691],[8.096,50.697],[8.134,50.684],[8.12,50.65],[8.144,50.603],[8.164,50.586],[8.137,50.542],[8.115,50.529],[8.093,50.528],[8.07,50.544],[8.053,50.546],[8.004,50.521],[7.999,50.466],[8.019,50.455],[8.0,50.44],[7.988,50.412],[7.973,50.4],[8.026,50.388],[8.032,50.377],[8.049,50.371],[8.08,50.33],[8.107,50.311],[8.122,50.281],[8.121,50.269],[8.096,50.262],[8.046,50.263],[8.033,50.248],[8.044,50.236],[8.023,50.216],[7.993,50.228],[7.922,50.2],[7.896,50.162],[7.904,50.149],[7.93,50.136],[7.922,50.109],[7.896,50.121],[7.859,50.119],[7.847,50.107],[7.839,50.084],[7.771,50.053],[7.85,50.011],[7.86,49.99],[7.878,49.973],[7.962,49.983],[8.049,50.018],[8.174,50.046],[8.213,50.044],[8.335,49.96],[8.355,49.926],[8.34,49.891],[8.38,49.843],[8.39,49.815],[8.416,49.778],[8.424,49.767],[8.477,49.768],[8.474,49.757],[8.439,49.727],[8.406,49.722],[8.379,49.71],[8.362,49.691],[8.361,49.664],[8.417,49.579],[8.408,49.555],[8.428,49.534],[8.464,49.473],[8.464,49.466],[8.443,49.459],[8.443,49.452],[8.494,49.443],[8.505,49.431],[8.49,49.411],[8.498,49.376],[8.46,49.315],[8.483,49.31],[8.48,49.297],[8.387,49.232],[8.357,49.11],[8.284,49.01],[8.23,48.966],[8.2,48.958],[8.189,48.966],[8.09,48.979],[7.932,49.035],[7.857,49.032],[7.774,49.048],[7.727,49.036],[7.685,49.043],[7.635,49.038],[7.609,49.062],[7.521,49.084],[7.471,49.128],[7.483,49.137],[7.476,49.152],[7.415,49.158],[7.41,49.169],[7.349,49.169],[7.295,49.205],[7.288,49.24],[7.296,49.25],[7.317,49.252],[7.33,49.264],[7.337,49.286],[7.353,49.298],[7.358,49.316],[7.387,49.337],[7.383,49.348],[7.391,49.361],[7.375,49.373],[7.291,49.389],[7.293,49.399],[7.252,49.431],[7.246,49.446],[7.305,49.482],[7.309,49.49],[7.306,49.503],[7.294,49.507],[7.291,49.534],[7.273,49.562],[7.271,49.578],[7.238,49.566],[7.208,49.564],[7.138,49.601],[7.065,49.605],[6.999,49.629],[6.971,49.619],[6.942,49.628],[6.86,49.594],[6.712,49.55],[6.67,49.549],[6.605,49.53],[6.49,49.534],[6.349,49.525],[6.351,49.567],[6.356,49.578],[6.418,49.639],[6.403,49.656],[6.418,49.67],[6.492,49.701],[6.483,49.708],[6.486,49.713],[6.5,49.712],[6.486,49.734],[6.486,49.743],[6.496,49.748],[6.494,49.764],[6.503,49.796],[6.462,49.805],[6.397,49.809],[6.334,49.84],[6.303,49.835],[6.287,49.861],[6.223,49.887],[6.19,49.938],[6.173,49.956],[6.162,49.942],[6.122,49.996],[6.114,50.036],[6.096,50.049],[6.109,50.064],[6.101,50.063]]]},{"name":"Saarland","ringe":[[[6.345,49.455],[6.342,49.493],[6.349,49.525],[6.49,49.534],[6.605,49.53],[6.67,49.549],[6.712,49.55],[6.86,49.594],[6.942,49.628],[6.971,49.619],[6.999,49.629],[7.065,49.605],[7.138,49.601],[7.208,49.564],[7.238,49.566],[7.271,49.578],[7.273,49.562],[7.291,49.534],[7.294,49.507],[7.306,49.503],[7.309,49.49],[7.305,49.482],[7.246,49.446],[7.252,49.431],[7.293,49.399],[7.291,49.389],[7.375,49.373],[7.391,49.361],[7.383,49.348],[7.387,49.337],[7.358,49.316],[7.353,49.298],[7.337,49.286],[7.33,49.264],[7.317,49.252],[7.296,49.25],[7.287,49.231],[7.295,49.205],[7.349,49.169],[7.372,49.166],[7.351,49.159],[7.334,49.134],[7.297,49.128],[7.274,49.105],[7.241,49.114],[7.155,49.114],[7.08,49.142],[7.071,49.136],[7.073,49.12],[7.043,49.108],[7.021,49.119],[7.009,49.146],[7.009,49.182],[6.988,49.182],[6.914,49.207],[6.833,49.209],[6.828,49.196],[6.839,49.163],[6.822,49.148],[6.714,49.159],[6.701,49.173],[6.709,49.198],[6.703,49.214],[6.681,49.207],[6.664,49.244],[6.64,49.253],[6.64,49.271],[6.556,49.326],[6.55,49.338],[6.556,49.347],[6.573,49.348],[6.578,49.356],[6.519,49.402],[6.512,49.425],[6.495,49.436],[6.403,49.465],[6.345,49.455]]]},{"name":"Sachsen","ringe":[[[14.398,51.013],[14.376,51.017],[14.357,51.032],[14.319,51.04],[14.288,51.037],[14.264,51.021],[14.238,50.982],[14.304,50.97],[14.294,50.964],[14.293,50.953],[14.318,50.938],[14.382,50.921],[14.376,50.896],[14.347,50.88],[14.268,50.884],[14.232,50.879],[14.191,50.848],[14.035,50.803],[13.96,50.802],[13.923,50.78],[13.893,50.78],[13.879,50.764],[13.882,50.743],[13.835,50.724],[13.771,50.725],[13.692,50.712],[13.557,50.707],[13.516,50.691],[13.527,50.676],[13.523,50.662],[13.493,50.643],[13.498,50.629],[13.448,50.597],[13.429,50.594],[13.407,50.601],[13.381,50.625],[13.369,50.628],[13.322,50.602],[13.306,50.576],[13.285,50.569],[13.232,50.582],[13.199,50.525],[13.185,50.509],[13.16,50.497],[13.107,50.499],[13.06,50.491],[13.026,50.498],[13.01,50.493],[12.997,50.434],[12.953,50.404],[12.918,50.405],[12.817,50.443],[12.788,50.434],[12.738,50.432],[12.692,50.395],[12.625,50.4],[12.51,50.389],[12.475,50.369],[12.467,50.342],[12.398,50.315],[12.367,50.279],[12.336,50.259],[12.334,50.243],[12.309,50.217],[12.306,50.199],[12.314,50.167],[12.3,50.161],[12.283,50.162],[12.273,50.172],[12.254,50.217],[12.23,50.235],[12.242,50.248],[12.24,50.257],[12.204,50.26],[12.178,50.269],[12.169,50.303],[12.149,50.312],[12.101,50.314],[12.094,50.323],[12.043,50.328],[11.989,50.349],[11.981,50.385],[11.96,50.394],[11.941,50.415],[11.905,50.415],[11.897,50.423],[11.906,50.433],[11.934,50.442],[11.948,50.462],[11.945,50.475],[11.926,50.488],[11.934,50.506],[11.989,50.546],[12.01,50.547],[12.031,50.533],[12.049,50.532],[12.081,50.561],[12.118,50.557],[12.145,50.563],[12.16,50.584],[12.159,50.604],[12.187,50.626],[12.253,50.626],[12.302,50.652],[12.322,50.674],[12.279,50.68],[12.265,50.701],[12.269,50.718],[12.241,50.722],[12.249,50.743],[12.273,50.75],[12.289,50.763],[12.257,50.782],[12.258,50.793],[12.331,50.806],[12.371,50.826],[12.424,50.824],[12.463,50.857],[12.504,50.871],[12.508,50.882],[12.528,50.877],[12.598,50.878],[12.611,50.87],[12.647,50.892],[12.647,50.9],[12.626,50.929],[12.602,50.952],[12.532,50.98],[12.491,51.028],[12.484,51.058],[12.329,51.088],[12.265,51.084],[12.223,51.089],[12.215,51.095],[12.211,51.112],[12.179,51.137],[12.184,51.164],[12.153,51.17],[12.153,51.178],[12.168,51.197],[12.165,51.227],[12.142,51.253],[12.132,51.292],[12.146,51.314],[12.182,51.325],[12.186,51.344],[12.17,51.375],[12.168,51.414],[12.159,51.426],[12.156,51.446],[12.191,51.47],[12.195,51.529],[12.225,51.544],[12.235,51.562],[12.298,51.551],[12.356,51.568],[12.431,51.572],[12.438,51.578],[12.443,51.601],[12.452,51.604],[12.532,51.602],[12.545,51.596],[12.641,51.606],[12.662,51.601],[12.683,51.613],[12.701,51.633],[12.715,51.638],[12.782,51.627],[12.862,51.668],[12.891,51.67],[12.902,51.655],[12.915,51.65],[12.964,51.638],[12.978,51.628],[13.015,51.635],[13.044,51.618],[13.073,51.612],[13.115,51.585],[13.137,51.587],[13.169,51.571],[13.165,51.545],[13.196,51.529],[13.21,51.496],[13.207,51.486],[13.189,51.476],[13.2,51.458],[13.193,51.423],[13.174,51.407],[13.184,51.387],[13.219,51.403],[13.239,51.399],[13.252,51.385],[13.411,51.421],[13.458,51.407],[13.534,51.364],[13.766,51.348],[13.97,51.359],[14.044,51.428],[14.067,51.474],[14.106,51.506],[14.159,51.527],[14.221,51.536],[14.269,51.532],[14.339,51.51],[14.377,51.516],[14.509,51.557],[14.574,51.565],[14.663,51.542],[14.707,51.541],[14.71,51.53],[14.732,51.516],[14.911,51.469],[14.945,51.449],[14.955,51.435],[14.947,51.423],[14.961,51.335],[15.019,51.272],[15.014,51.243],[15.022,51.237],[14.991,51.189],[14.98,51.123],[14.96,51.105],[14.955,51.064],[14.86,50.917],[14.81,50.877],[14.81,50.858],[14.795,50.832],[14.775,50.813],[14.759,50.81],[14.7,50.816],[14.648,50.839],[14.613,50.846],[14.608,50.853],[14.612,50.871],[14.635,50.909],[14.629,50.921],[14.562,50.906],[14.55,50.912],[14.576,50.951],[14.574,50.975],[14.542,50.999],[14.503,51.009],[14.482,51.037],[14.473,51.023],[14.462,51.02],[14.426,51.021],[14.398,51.013]]]},{"name":"Sachsen-Anhalt","ringe":[[[10.713,51.631],[10.698,51.637],[10.669,51.67],[10.668,51.698],[10.661,51.711],[10.644,51.721],[10.619,51.759],[10.584,51.78],[10.578,51.805],[10.58,51.845],[10.629,51.871],[10.64,51.884],[10.635,51.912],[10.613,51.925],[10.618,51.943],[10.645,51.957],[10.629,51.958],[10.59,51.976],[10.57,52.007],[10.622,52.011],[10.651,52.03],[10.652,52.043],[10.661,52.045],[10.963,52.06],[10.97,52.069],[10.969,52.084],[10.949,52.094],[10.949,52.1],[11.014,52.119],[11.048,52.14],[11.058,52.153],[11.054,52.168],[11.027,52.178],[11.021,52.194],[11.037,52.211],[11.078,52.221],[11.085,52.231],[11.078,52.251],[11.064,52.244],[11.061,52.261],[11.045,52.262],[11.015,52.287],[11.034,52.306],[11.023,52.322],[11.003,52.329],[10.999,52.34],[11.019,52.349],[11.064,52.354],[11.073,52.372],[11.038,52.395],[11.011,52.403],[10.939,52.462],[10.953,52.494],[11.002,52.498],[10.954,52.545],[10.946,52.563],[10.948,52.589],[10.974,52.608],[10.977,52.616],[10.957,52.623],[10.928,52.614],[10.885,52.651],[10.837,52.71],[10.801,52.722],[10.794,52.748],[10.774,52.762],[10.757,52.786],[10.759,52.835],[10.795,52.846],[10.886,52.847],[10.942,52.859],[10.989,52.882],[10.999,52.906],[11.015,52.912],[11.051,52.912],[11.107,52.901],[11.165,52.903],[11.214,52.898],[11.253,52.876],[11.409,52.905],[11.47,52.94],[11.502,52.944],[11.496,52.969],[11.51,53.004],[11.552,53.006],[11.562,53.01],[11.581,53.043],[11.626,53.05],[11.636,53.046],[11.636,53.037],[11.622,53.022],[11.681,53.012],[11.694,52.984],[11.712,52.982],[11.763,52.995],[11.807,52.96],[11.851,52.955],[11.832,52.928],[11.897,52.902],[11.983,52.883],[12.097,52.891],[12.112,52.878],[12.111,52.867],[12.175,52.872],[12.203,52.866],[12.214,52.855],[12.234,52.81],[12.23,52.799],[12.195,52.779],[12.209,52.752],[12.19,52.736],[12.21,52.703],[12.228,52.69],[12.226,52.673],[12.205,52.641],[12.162,52.636],[12.151,52.58],[12.123,52.553],[12.162,52.549],[12.146,52.532],[12.156,52.516],[12.193,52.519],[12.212,52.542],[12.23,52.537],[12.256,52.514],[12.295,52.516],[12.288,52.506],[12.287,52.484],[12.266,52.456],[12.248,52.445],[12.257,52.427],[12.232,52.386],[12.229,52.368],[12.235,52.35],[12.206,52.302],[12.209,52.279],[12.233,52.268],[12.248,52.252],[12.222,52.232],[12.199,52.194],[12.217,52.166],[12.246,52.148],[12.256,52.131],[12.299,52.11],[12.361,52.058],[12.365,52.04],[12.426,52.01],[12.45,52.011],[12.463,52.021],[12.472,52.02],[12.497,52.0],[12.562,51.972],[12.608,51.965],[12.646,51.982],[12.655,51.994],[12.667,51.994],[12.795,51.946],[12.844,51.942],[12.859,51.935],[12.877,51.911],[12.962,51.904],[12.992,51.882],[13.066,51.853],[13.132,51.858],[13.161,51.844],[13.179,51.861],[13.237,51.855],[13.158,51.782],[13.159,51.741],[13.182,51.709],[13.178,51.697],[13.15,51.673],[13.082,51.638],[13.062,51.615],[13.044,51.618],[13.009,51.637],[12.978,51.628],[12.964,51.638],[12.902,51.655],[12.891,51.67],[12.862,51.668],[12.782,51.627],[12.715,51.638],[12.701,51.633],[12.683,51.613],[12.662,51.601],[12.641,51.606],[12.545,51.596],[12.532,51.602],[12.452,51.604],[12.443,51.601],[12.438,51.578],[12.431,51.572],[12.356,51.568],[12.298,51.551],[12.235,51.562],[12.225,51.544],[12.195,51.529],[12.191,51.47],[12.156,51.446],[12.159,51.426],[12.168,51.414],[12.17,51.375],[12.185,51.351],[12.185,51.331],[12.179,51.323],[12.146,51.314],[12.133,51.289],[12.142,51.253],[12.165,51.227],[12.168,51.197],[12.153,51.178],[12.153,51.17],[12.184,51.164],[12.179,51.137],[12.211,51.112],[12.218,51.091],[12.265,51.084],[12.267,51.076],[12.249,51.057],[12.244,51.036],[12.267,51.026],[12.276,51.013],[12.252,50.982],[12.217,50.954],[12.219,50.937],[12.201,50.928],[12.154,50.953],[12.126,50.944],[12.068,50.956],[11.998,50.949],[11.993,50.96],[11.961,50.971],[11.931,50.998],[11.861,51.023],[11.739,51.015],[11.726,51.023],[11.697,51.064],[11.694,51.088],[11.668,51.101],[11.628,51.096],[11.535,51.1],[11.505,51.089],[11.45,51.091],[11.43,51.097],[11.417,51.122],[11.428,51.129],[11.42,51.16],[11.389,51.19],[11.345,51.204],[11.333,51.216],[11.346,51.229],[11.422,51.244],[11.452,51.271],[11.367,51.357],[11.315,51.383],[11.289,51.389],[10.999,51.409],[10.973,51.418],[10.965,51.428],[10.972,51.467],[10.945,51.5],[10.947,51.523],[10.926,51.54],[10.912,51.565],[10.919,51.576],[10.939,51.572],[10.952,51.577],[10.942,51.599],[10.803,51.633],[10.713,51.631]]]},{"name":"Schleswig-Holstein","ringe":[[[8.824,54.906],[9.195,54.85],[9.212,54.842],[9.226,54.806],[9.244,54.802],[9.332,54.803],[9.385,54.819],[9.405,54.808],[9.451,54.81],[9.511,54.828],[9.53,54.841],[9.554,54.847],[9.58,54.866],[9.583,54.83],[9.632,54.82],[9.693,54.818],[9.735,54.807],[9.802,54.783],[9.824,54.757],[9.895,54.769],[9.891,54.79],[9.915,54.791],[9.948,54.78],[9.97,54.763],[9.99,54.721],[10.018,54.701],[10.005,54.696],[9.97,54.701],[9.932,54.685],[9.929,54.674],[9.996,54.679],[10.011,54.667],[10.034,54.673],[10.039,54.667],[10.032,54.653],[10.038,54.619],[10.027,54.56],[10.01,54.534],[9.966,54.506],[9.925,54.493],[9.84,54.482],[9.84,54.475],[9.883,54.462],[10.125,54.496],[10.143,54.492],[10.204,54.461],[10.184,54.449],[10.197,54.41],[10.191,54.397],[10.159,54.375],[10.142,54.372],[10.148,54.365],[10.142,54.359],[10.156,54.343],[10.142,54.324],[10.164,54.329],[10.18,54.341],[10.224,54.414],[10.272,54.42],[10.318,54.443],[10.361,54.441],[10.443,54.406],[10.601,54.365],[10.69,54.316],[10.731,54.31],[10.777,54.315],[10.868,54.359],[10.935,54.38],[11.019,54.386],[11.019,54.379],[10.976,54.378],[11.011,54.377],[11.034,54.368],[11.104,54.393],[11.136,54.386],[11.088,54.352],[11.067,54.359],[11.077,54.342],[11.094,54.284],[11.087,54.271],[11.094,54.225],[11.09,54.208],[11.067,54.184],[10.95,54.139],[10.893,54.103],[10.836,54.09],[10.822,54.097],[10.805,54.095],[10.753,54.05],[10.766,54.024],[10.8,54.001],[10.875,53.995],[10.887,53.964],[10.906,53.961],[10.904,53.931],[10.911,53.925],[10.961,53.912],[10.913,53.902],[10.907,53.905],[10.907,53.915],[10.885,53.919],[10.812,53.883],[10.767,53.867],[10.751,53.841],[10.771,53.793],[10.759,53.748],[10.806,53.741],[10.857,53.703],[10.915,53.697],[10.946,53.677],[10.956,53.65],[10.953,53.634],[10.94,53.629],[10.954,53.578],[10.942,53.57],[10.954,53.566],[10.929,53.548],[10.917,53.568],[10.908,53.572],[10.83,53.57],[10.833,53.548],[10.823,53.516],[10.76,53.488],[10.704,53.478],[10.691,53.458],[10.669,53.459],[10.641,53.451],[10.622,53.391],[10.601,53.372],[10.57,53.364],[10.528,53.372],[10.386,53.424],[10.328,53.433],[10.321,53.456],[10.248,53.489],[10.214,53.519],[10.187,53.525],[10.173,53.574],[10.211,53.584],[10.223,53.634],[10.209,53.645],[10.205,53.662],[10.165,53.685],[10.193,53.718],[10.192,53.727],[10.177,53.727],[10.148,53.714],[10.089,53.72],[10.078,53.713],[10.06,53.678],[10.014,53.673],[9.984,53.647],[9.953,53.65],[9.927,53.646],[9.816,53.593],[9.806,53.599],[9.81,53.618],[9.768,53.629],[9.753,53.608],[9.741,53.573],[9.692,53.576],[9.584,53.612],[9.551,53.635],[9.532,53.711],[9.436,53.749],[9.412,53.779],[9.397,53.811],[9.395,53.831],[9.359,53.84],[9.258,53.886],[9.093,53.892],[9.03,53.907],[9.008,53.892],[8.949,53.913],[8.916,53.937],[8.833,54.036],[8.891,54.048],[8.975,54.024],[8.997,54.03],[9.013,54.061],[9.018,54.098],[8.974,54.128],[8.977,54.146],[8.953,54.15],[8.918,54.146],[8.861,54.125],[8.813,54.18],[8.847,54.234],[8.84,54.256],[8.922,54.274],[8.963,54.318],[8.94,54.315],[8.881,54.29],[8.888,54.304],[8.775,54.29],[8.739,54.294],[8.678,54.269],[8.652,54.275],[8.623,54.29],[8.603,54.312],[8.6,54.338],[8.614,54.348],[8.696,54.359],[8.696,54.365],[8.651,54.378],[8.635,54.365],[8.626,54.376],[8.629,54.388],[8.648,54.406],[8.67,54.401],[8.751,54.414],[8.886,54.418],[9.005,54.466],[9.011,54.482],[9.012,54.506],[8.986,54.529],[8.904,54.581],[8.888,54.602],[8.858,54.606],[8.826,54.598],[8.819,54.606],[8.853,54.619],[8.818,54.674],[8.805,54.688],[8.689,54.735],[8.694,54.76],[8.648,54.818],[8.643,54.844],[8.661,54.896],[8.733,54.889],[8.824,54.906]],[[8.963,54.524],[8.956,54.519],[8.957,54.509],[8.911,54.475],[8.888,54.468],[8.844,54.466],[8.824,54.471],[8.813,54.482],[8.826,54.491],[8.813,54.502],[8.844,54.52],[8.88,54.529],[8.919,54.53],[8.963,54.524]],[[11.256,54.479],[11.314,54.414],[11.188,54.426],[11.17,54.42],[11.179,54.414],[11.113,54.415],[11.101,54.423],[11.099,54.452],[11.058,54.455],[11.032,54.447],[11.026,54.459],[11.008,54.455],[11.006,54.468],[11.034,54.509],[11.073,54.531],[11.121,54.536],[11.207,54.514],[11.232,54.502],[11.256,54.479]],[[8.332,54.694],[8.382,54.639],[8.401,54.633],[8.354,54.619],[8.33,54.631],[8.292,54.667],[8.32,54.699],[8.36,54.715],[8.361,54.71],[8.332,54.694]],[[8.498,54.756],[8.553,54.754],[8.576,54.746],[8.594,54.729],[8.566,54.688],[8.535,54.686],[8.511,54.694],[8.485,54.688],[8.395,54.715],[8.414,54.736],[8.437,54.748],[8.498,54.756]],[[8.36,54.948],[8.355,54.913],[8.388,54.894],[8.437,54.887],[8.654,54.894],[8.629,54.882],[8.504,54.886],[8.446,54.866],[8.377,54.866],[8.362,54.877],[8.344,54.879],[8.307,54.865],[8.295,54.832],[8.296,54.759],[8.284,54.742],[8.277,54.783],[8.289,54.891],[8.298,54.913],[8.374,55.035],[8.416,55.065],[8.463,55.051],[8.402,55.049],[8.395,55.044],[8.422,55.042],[8.43,55.03],[8.422,55.016],[8.381,55.003],[8.362,54.987],[8.352,54.967],[8.36,54.948]],[[8.635,54.543],[8.699,54.559],[8.701,54.548],[8.693,54.523],[8.67,54.502],[8.641,54.492],[8.619,54.493],[8.596,54.514],[8.602,54.53],[8.635,54.543]],[[7.904,54.18],[7.899,54.178],[7.88,54.194],[7.895,54.195],[7.904,54.18]],[[7.912,54.192],[7.93,54.195],[7.933,54.19],[7.918,54.187],[7.912,54.192]]]},{"name":"Thüringen","ringe":[[[9.95,51.303],[9.941,51.331],[9.93,51.34],[9.924,51.356],[9.93,51.372],[10.042,51.432],[10.069,51.42],[10.085,51.431],[10.15,51.432],[10.147,51.441],[10.172,51.449],[10.19,51.478],[10.21,51.479],[10.219,51.471],[10.232,51.47],[10.291,51.491],[10.302,51.507],[10.335,51.521],[10.365,51.542],[10.38,51.572],[10.375,51.586],[10.431,51.591],[10.514,51.556],[10.557,51.557],[10.618,51.573],[10.645,51.561],[10.653,51.566],[10.654,51.578],[10.629,51.601],[10.649,51.62],[10.698,51.637],[10.713,51.631],[10.803,51.633],[10.849,51.618],[10.878,51.617],[10.946,51.595],[10.952,51.577],[10.939,51.572],[10.919,51.576],[10.912,51.565],[10.926,51.54],[10.947,51.523],[10.945,51.5],[10.972,51.467],[10.965,51.428],[10.973,51.418],[10.999,51.409],[11.289,51.389],[11.315,51.383],[11.367,51.357],[11.452,51.271],[11.422,51.244],[11.346,51.229],[11.333,51.216],[11.345,51.204],[11.389,51.19],[11.42,51.16],[11.428,51.129],[11.417,51.122],[11.433,51.096],[11.505,51.089],[11.535,51.1],[11.628,51.096],[11.668,51.101],[11.694,51.088],[11.697,51.064],[11.726,51.023],[11.739,51.015],[11.861,51.023],[11.931,50.998],[11.961,50.971],[11.993,50.96],[11.998,50.949],[12.068,50.956],[12.126,50.944],[12.154,50.953],[12.201,50.928],[12.219,50.937],[12.217,50.954],[12.275,51.01],[12.271,51.023],[12.244,51.036],[12.249,51.057],[12.267,51.076],[12.265,51.084],[12.329,51.088],[12.484,51.058],[12.491,51.028],[12.532,50.98],[12.602,50.952],[12.626,50.929],[12.647,50.9],[12.647,50.892],[12.611,50.87],[12.598,50.878],[12.528,50.877],[12.508,50.882],[12.504,50.871],[12.463,50.857],[12.424,50.824],[12.371,50.826],[12.331,50.806],[12.258,50.793],[12.257,50.782],[12.289,50.763],[12.273,50.75],[12.249,50.743],[12.241,50.722],[12.269,50.718],[12.265,50.701],[12.279,50.68],[12.322,50.674],[12.302,50.652],[12.253,50.626],[12.187,50.626],[12.159,50.604],[12.16,50.584],[12.145,50.563],[12.118,50.557],[12.081,50.561],[12.049,50.532],[12.031,50.533],[12.01,50.547],[11.989,50.546],[11.928,50.497],[11.928,50.486],[11.947,50.471],[11.934,50.442],[11.9,50.429],[11.897,50.423],[11.905,50.415],[11.872,50.403],[11.824,50.391],[11.823,50.401],[11.785,50.414],[11.621,50.387],[11.57,50.395],[11.556,50.382],[11.526,50.379],[11.487,50.397],[11.477,50.427],[11.454,50.428],[11.421,50.443],[11.421,50.486],[11.439,50.497],[11.427,50.513],[11.347,50.519],[11.325,50.508],[11.316,50.493],[11.249,50.473],[11.263,50.428],[11.259,50.403],[11.28,50.359],[11.269,50.353],[11.271,50.341],[11.259,50.321],[11.266,50.305],[11.255,50.288],[11.255,50.275],[11.221,50.278],[11.208,50.285],[11.192,50.272],[11.143,50.301],[11.135,50.309],[11.156,50.329],[11.138,50.35],[11.121,50.354],[11.113,50.365],[11.05,50.348],[11.025,50.361],[10.998,50.364],[10.988,50.35],[10.943,50.387],[10.896,50.382],[10.875,50.394],[10.81,50.382],[10.789,50.386],[10.758,50.358],[10.722,50.357],[10.715,50.322],[10.769,50.293],[10.799,50.292],[10.813,50.277],[10.841,50.27],[10.846,50.247],[10.829,50.241],[10.798,50.248],[10.758,50.243],[10.739,50.248],[10.732,50.242],[10.727,50.21],[10.721,50.207],[10.662,50.225],[10.617,50.224],[10.605,50.235],[10.607,50.25],[10.595,50.268],[10.6,50.293],[10.591,50.326],[10.566,50.34],[10.552,50.359],[10.526,50.352],[10.497,50.354],[10.493,50.366],[10.466,50.38],[10.449,50.399],[10.425,50.395],[10.397,50.4],[10.392,50.409],[10.399,50.423],[10.341,50.459],[10.337,50.478],[10.328,50.489],[10.231,50.513],[10.218,50.536],[10.197,50.548],[10.13,50.548],[10.126,50.563],[10.055,50.517],[10.047,50.517],[10.045,50.526],[10.057,50.554],[10.048,50.57],[10.046,50.605],[10.076,50.622],[10.074,50.645],[10.057,50.668],[9.998,50.676],[9.951,50.663],[9.947,50.653],[9.96,50.64],[9.948,50.627],[9.905,50.638],[9.887,50.633],[9.88,50.637],[9.881,50.667],[9.913,50.693],[9.913,50.705],[9.933,50.733],[9.936,50.751],[9.921,50.764],[9.926,50.773],[9.946,50.777],[9.942,50.796],[9.949,50.815],[10.023,50.833],[10.026,50.859],[10.056,50.883],[10.033,50.909],[10.009,50.921],[9.995,50.919],[9.974,50.906],[9.97,50.929],[9.953,50.927],[9.953,50.939],[10.041,50.941],[10.043,50.962],[10.024,50.982],[10.026,50.996],[10.051,51.007],[10.121,51.009],[10.123,50.999],[10.133,50.995],[10.191,50.997],[10.209,51.021],[10.205,51.032],[10.193,51.045],[10.141,51.059],[10.147,51.081],[10.169,51.115],[10.14,51.122],[10.126,51.136],[10.174,51.145],[10.199,51.116],[10.209,51.151],[10.233,51.18],[10.173,51.206],[10.077,51.225],[10.069,51.244],[10.049,51.258],[10.054,51.271],[10.049,51.277],[9.984,51.286],[9.95,51.303]]]}]} \ No newline at end of file diff --git a/src/AndiGenerator.Persistence/Plandaten/GespeicherterPlanDatei.cs b/src/AndiGenerator.Persistence/Plandaten/GespeicherterPlanDatei.cs index 470974a..8d4538e 100644 --- a/src/AndiGenerator.Persistence/Plandaten/GespeicherterPlanDatei.cs +++ b/src/AndiGenerator.Persistence/Plandaten/GespeicherterPlanDatei.cs @@ -10,7 +10,8 @@ namespace AndiGenerator.Persistence.Plandaten; /// Gemerkter Plan („Plan merken“ im Hauptfenster, Original TPlan.SaveScheduleToXml/LoadScheduleFromXml): /// <andigenerator><plan><schedule><game datetime hometeamname guestteamname/>… /// im Staffelordner (), eine Datei je Plan, Dateiname = kodierter Planname + .xml. -/// Spiellokale werden nicht gespeichert. +/// Ein Spiellokal steht nur dann als Attribut location am Spiel, wenn es eins gibt (nicht im Original, das es +/// beim Laden übergeht); Pläne ohne Spiellokale sind byte-gleich mit denen des Originals. /// public static class GespeicherterPlanDatei { @@ -38,6 +39,11 @@ public static DatenKnoten Erzeugen(IEnumerable spiele) game.Setzen("datetime", DelphiKompatibel.DatumZeitSchreiben(spiel.Zeitpunkt)); game.Setzen("hometeamname", spiel.Heim); game.Setzen("guestteamname", spiel.Gast); + string lokal = Spiellokale.Korrigieren(spiel.Spiellokal); + if (lokal.Length > 0) + { + game.Setzen("location", lokal); + } } return plan; @@ -101,7 +107,7 @@ public static IReadOnlyList AusKnoten(DatenKnoten plan) throw new PlanDatenFormatException($"Ungültiger Termin '{game.Lesen("datetime")}'.", ex); } - spiele.Add(new GespeichertesSpiel(zeitpunkt, game.Lesen("hometeamname"), game.Lesen("guestteamname"))); + spiele.Add(new GespeichertesSpiel(zeitpunkt, game.Lesen("hometeamname"), game.Lesen("guestteamname"), Spiellokale.Korrigieren(game.Lesen("location")))); } return spiele; diff --git a/src/AndiGenerator.Persistence/Plandaten/GespeichertesSpiel.cs b/src/AndiGenerator.Persistence/Plandaten/GespeichertesSpiel.cs index 5be5a17..d182c7a 100644 --- a/src/AndiGenerator.Persistence/Plandaten/GespeichertesSpiel.cs +++ b/src/AndiGenerator.Persistence/Plandaten/GespeichertesSpiel.cs @@ -8,4 +8,8 @@ namespace AndiGenerator.Persistence.Plandaten; /// Termin; null = nicht terminiert (im Original 0.0 = 30.12.1899 00:00). /// Name der Heimmannschaft. /// Name der Gastmannschaft. -public sealed record GespeichertesSpiel(DateTime? Zeitpunkt, string Heim, string Gast); +/// +/// Spiellokal „1“ bis „5“ oder leer (nicht im Original): Bei Kompaktspieltagen trägt es nur der Ausrichter; so bleibt er beim +/// Wiederöffnen erkennbar. Leer wird es nicht geschrieben, die Datei bleibt dann wie im Original. +/// +public sealed record GespeichertesSpiel(DateTime? Zeitpunkt, string Heim, string Gast, string Spiellokal = ""); diff --git a/src/AndiGenerator.Persistence/Plandaten/Spiellokale.cs b/src/AndiGenerator.Persistence/Plandaten/Spiellokale.cs index e0b7fc6..27f35ad 100644 --- a/src/AndiGenerator.Persistence/Plandaten/Spiellokale.cs +++ b/src/AndiGenerator.Persistence/Plandaten/Spiellokale.cs @@ -26,6 +26,7 @@ public sealed class Spiellokale private readonly Dictionary<(DateTime, string, string), string> vorgegeben = []; private readonly Dictionary> heimtermine = new(StringComparer.Ordinal); + private readonly Dictionary mannschaftslokale = new(StringComparer.Ordinal); /// Wertet den (mit den Änderungen zusammengeführten) Datenbaum einer Staffel aus. /// Wurzelknoten plan. @@ -90,6 +91,7 @@ bool Zulaessig(DateTime termin) => } heimtermine.TryAdd(mannschaft.Lesen("teamname"), termine); + mannschaftslokale.TryAdd(mannschaft.Lesen("teamname"), mannschaftsLokal); } } catch (FormatException ex) @@ -127,6 +129,22 @@ public string Ermitteln(DateTime? zeitpunkt, string heim, string gast) : string.Empty; } + /// + /// Ermittelt das Spiellokal eines Spiels am Kompaktspieltag (nicht im Original): das des Heimtermins der Heimmannschaft an + /// diesem Tag, gleich zu welcher Uhrzeit (die Spiele eines Spieltags beginnen nacheinander); ohne Heimtermin an diesem + /// Tag das Spiellokal der Mannschaft. + /// + /// Tag des Spiels. + /// Name der Heimmannschaft (der Ausrichter). + /// Das Spiellokal oder ein leerer Text, wenn keins eingetragen ist. + public string AmTag(DateOnly tag, string heim) + { + string? amTag = heimtermine.TryGetValue(heim, out Dictionary? termine) + ? termine.Where(t => DateOnly.FromDateTime(t.Key) == tag).OrderBy(t => t.Key).Select(t => t.Value).FirstOrDefault() + : null; + return amTag ?? mannschaftslokale.GetValueOrDefault(heim, string.Empty); + } + private static DateTime? DatumOderNull(string text) => text.Length == 0 ? null : DelphiKompatibel.DateFromString(text).ToDateTime(TimeOnly.MinValue); } diff --git a/src/AndiGenerator.Presentation/DruckauswahlViewModel.cs b/src/AndiGenerator.Presentation/DruckauswahlViewModel.cs index 39ffce2..fb95d2f 100644 --- a/src/AndiGenerator.Presentation/DruckauswahlViewModel.cs +++ b/src/AndiGenerator.Presentation/DruckauswahlViewModel.cs @@ -10,6 +10,9 @@ namespace AndiGenerator.Presentation; /// /// Druckauswahl (Original TFormPrintSelection): welche Teile gedruckt werden, das Format und – anders als im /// Original, das immer den angezeigten Plan druckt – der Plan, aus dem Kosten, Spielplan und Mannschaftspläne stammen. +/// Im Kompaktmodus gibt es nichts zu wählen (Peter, 14.10.2026): Gedruckt wird nur die Übersicht für die Vereine, im +/// Hochformat, mit dem in der Ansicht „Kompaktspieltage“ gewählten Plan – die übrigen Teile sind dort doppelt +/// (Terminwünsche, Spielplan, Mannschaftspläne) oder ohne Bedeutung (Nachbarmannschaften, Diagramme). /// public sealed class DruckauswahlViewModel : ObservableObject { @@ -21,9 +24,10 @@ public sealed class DruckauswahlViewModel : ObservableObject private bool mannschaftsplaene = true; private bool mannschaftsplaeneMitNachbarn = true; private bool diagramme = true; + private bool kompaktspieltage; private bool querformat; - /// Initialisiert die Auswahl; alle Teile sind vorgewählt. + /// Initialisiert die Auswahl für normale Runden; alle Teile sind vorgewählt. /// Planquellen, die einen Plan haben. public DruckauswahlViewModel(IReadOnlyList quellen) { @@ -32,6 +36,17 @@ public DruckauswahlViewModel(IReadOnlyList quellen) quelle = quellen.Count > 0 ? quellen[0] : null; } + /// Holt einen Wert, der angibt, ob im Kompaktmodus gedruckt wird (nur die Übersicht für die Vereine). + public bool IstKompakt { get; private init; } + + /// Holt den Namen des Plans, wie ihn die Ansicht „Kompaktspieltage“ nennt (nur im Kompaktmodus). + public string Planname { get; private init; } = string.Empty; + + /// Holt den Text im Kompaktmodus: welcher Plan gedruckt wird bzw. dass es noch keinen gibt. + public string Kompakthinweis => HatPlan + ? "Gedruckt wird der Plan, der in der Ansicht Kompaktspieltage gewählt ist: " + Planname + "." + : "Noch kein Plan vorhanden – erst mit ▶ Pläne berechnen oder in der Ansicht Kompaktspieltage einen Plan wählen."; + /// Holt die Planquellen, die einen Plan haben. public IReadOnlyList Quellen { get; } @@ -100,6 +115,13 @@ public bool Diagramme set => Setzen(ref diagramme, value); } + /// Holt oder setzt einen Wert, der angibt, ob die Übersicht der Kompaktspieltage für die Vereine gedruckt wird. + public bool Kompaktspieltage + { + get => kompaktspieltage; + set => Setzen(ref kompaktspieltage, value); + } + /// Holt oder setzt einen Wert, der angibt, ob im Querformat gedruckt wird. public bool Querformat { @@ -109,7 +131,26 @@ public bool Querformat /// Holt einen Wert, der angibt, ob mindestens ein druckbarer Teil gewählt ist. public bool IstGueltig => - Terminwuensche || Nachbartermine || (Quelle is not null && (Kosten || Spielplan || Mannschaftsplaene || MannschaftsplaeneMitNachbarn || Diagramme)); + Terminwuensche || Nachbartermine || (Quelle is not null && (Kosten || Kompaktspieltage || Spielplan || Mannschaftsplaene || MannschaftsplaeneMitNachbarn || Diagramme)); + + /// Erzeugt die Auswahl im Kompaktmodus: nur die Übersicht der Kompaktspieltage für die Vereine, im Hochformat. + /// Die Quelle des in der Ansicht gewählten Plans oder null, solange es keinen gibt. + /// Der Name des Plans, wie ihn die Ansicht nennt. + /// Die Auswahl. + public static DruckauswahlViewModel Kompakt(PlanQuelle? quelle, string planname) + { + ArgumentNullException.ThrowIfNull(planname); + var auswahl = new DruckauswahlViewModel(quelle is null ? [] : [quelle]) { IstKompakt = true, Planname = planname }; + auswahl.terminwuensche = false; + auswahl.nachbartermine = false; + auswahl.kosten = false; + auswahl.spielplan = false; + auswahl.mannschaftsplaene = false; + auswahl.mannschaftsplaeneMitNachbarn = false; + auswahl.diagramme = false; + auswahl.kompaktspieltage = true; + return auswahl; + } private void Setzen(ref bool feld, bool wert, [CallerMemberName] string? name = null) { diff --git a/src/AndiGenerator.Presentation/EinrichtungViewModel.cs b/src/AndiGenerator.Presentation/EinrichtungViewModel.cs index 43c27d6..e000be4 100644 --- a/src/AndiGenerator.Presentation/EinrichtungViewModel.cs +++ b/src/AndiGenerator.Presentation/EinrichtungViewModel.cs @@ -58,7 +58,7 @@ public EinrichtungViewModel( aktiv = schritte.FirstOrDefault(s => s.Zustand == Schrittzustand.Offen) ?? schritte[0]; Zeigen(aktiv); UebernehmenCommand = new AsyncRelayCommand(UebernehmenAsync); - UeberspringenCommand = new RelayCommand(Ueberspringen); + UeberspringenCommand = new RelayCommand(Ueberspringen, () => !Aktiv.FehltPflicht); AbschliessenCommand = new AsyncRelayCommand(AbschliessenAsync); AbbrechenCommand = new AsyncRelayCommand(() => this.abbrechen()); } @@ -86,6 +86,7 @@ public Einrichtungsschritt Aktiv Zeigen(value); Meldung = string.Empty; OnPropertyChanged(nameof(UebernehmenText)); + UeberspringenCommand.NotifyCanExecuteChanged(); } } @@ -102,7 +103,7 @@ public Einrichtungsschritt Aktiv public string Meldung { get => meldung; - private set + internal set { if (SetProperty(ref meldung, value)) { @@ -151,10 +152,18 @@ private async Task UebernehmenAsync() // Ein Punkt mit mehreren Teilen (z. B. zwei Dateien) bleibt offen, bis alle erledigt sind. Abhaken(schritt, (schritt.IstVollstaendig?.Invoke() ?? true) ? Schrittzustand.Erledigt : Schrittzustand.Offen); + UeberspringenCommand.NotifyCanExecuteChanged(); } private async Task AbschliessenAsync() { + if (Schritte.FirstOrDefault(s => s.FehltPflicht) is Einrichtungsschritt pflicht) + { + Aktiv = pflicht; + Meldung = pflicht.Pflichthinweis.Length > 0 ? pflicht.Pflichthinweis : "Dieser Punkt muss erledigt werden."; + return; + } + foreach (Einrichtungsschritt schritt in Schritte.Where(s => s.IstEntscheidung && s.IstGewaehlt && s.Zustand == Schrittzustand.Offen).ToList()) { if (!await uebernehmen(schritt)) diff --git a/src/AndiGenerator.Presentation/Einrichtungsschritt.cs b/src/AndiGenerator.Presentation/Einrichtungsschritt.cs index dd84859..2630c94 100644 --- a/src/AndiGenerator.Presentation/Einrichtungsschritt.cs +++ b/src/AndiGenerator.Presentation/Einrichtungsschritt.cs @@ -77,6 +77,18 @@ public Einrichtungsschritt(int nummer, Erststarthinweis hinweis) /// public Func? IstVollstaendig { get; init; } + /// + /// Holt oder setzt die Prüfung eines Pflichtpunkts (nicht im Original): Solange sie false liefert, lässt sich der + /// Punkt nicht überspringen und die Einrichtung nicht abschließen; ohne Angabe ist der Punkt freiwillig. + /// + public Func? IstPflichtErfuellt { get; init; } + + /// Holt oder setzt die Meldung, wenn ein Pflichtpunkt noch fehlt. + public string Pflichthinweis { get; init; } = string.Empty; + + /// Holt einen Wert, der angibt, ob der Punkt erledigt sein muss und es noch nicht ist. + public bool FehltPflicht => !(IstPflichtErfuellt?.Invoke() ?? true); + /// Holt den Punkt des Erststart-Assistenten oder null bei Entscheidungen. public Erststartpunkt? Punkt { get; } @@ -155,6 +167,17 @@ internal set /// Holt den Befehl, der den Schritt zeigt. public IRelayCommand WaehlenCommand { get; } + /// + /// Wählt eine Option vor, als hätte der Anwender sie angeklickt: Sie gilt auch beim Abschließen, ohne dass „Auswahl + /// übernehmen“ gedrückt wurde (z. B. „Mit Kompaktspieltagen planen“ bei einer erkannten Kompaktklasse). + /// + /// Die Option. + public void Vorwaehlen(int index) + { + Auswahl = index; + IstGewaehlt = true; + } + private void Anzeigeaendern() { OnPropertyChanged(nameof(Status)); diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.Kompaktgespeichert.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.Kompaktgespeichert.cs new file mode 100644 index 0000000..d87cf2f --- /dev/null +++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.Kompaktgespeichert.cs @@ -0,0 +1,149 @@ +// 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.Turniere; +using AndiGenerator.Persistence.Gemeinsam; +using AndiGenerator.Rendering; + +namespace AndiGenerator.Presentation; + +/// +/// Gespeicherte Pläne im Kompaktmodus (nicht im Original; Peter, 13.10.2026): Der gesicherte und die gemerkten Pläne stehen +/// in der Ansicht „Kompaktspieltage“ neben den Varianten zur Wahl. Gewählt werden sie zum aktuellen Plan für Terminplan, +/// Druck und click-TT-Export – mit genau den gespeicherten Spielen (Uhrzeiten, Hallen, Heimrecht). +/// +public sealed partial class HauptfensterViewModel +{ + private IReadOnlyList kompaktGespeichert = []; + private Dictionary kompaktGespeichertJe = []; + + /// Holt die gespeicherten Pläne (gesichert, gemerkt) als Varianten. + internal IReadOnlyList KompaktGespeichertePlaene => kompaktGespeichert; + + /// Kennzahlen eines Plans aus seiner Auswertung (für gespeicherte Pläne, die der Planer nicht kennt). + private static Kompaktkennzahlen Kennzahlen(Kompaktauswertung a) => + new( + a.KmGesamt, + a.GroessterAbstand, + a.MeisteAmTag, + a.Verstoesse, + a.OhneHeimspieltag, + 0, + a.Spieltage.Count(s => s.Form == Turnierform.Vierer), + a.Spieltage.Count(s => s.Form == Turnierform.Dreier), + a.Spieltage.Count(s => s.Form == Turnierform.Einzel), + Kompaktpunkte.Spanne(a)) + { + EinzelKm = a.Spieltage.Where(s => s.Form == Turnierform.Einzel).Sum(s => s.Km), + }; + + /// Die Spieltage eines ausgewerteten Plans, der Ausrichter zuerst. + private static List Planturniere(Kompaktauswertung a) => + a.Spieltage + .Select(s => new Planturnier(s.Tag, s.Form, s.Ausrichter is string ausrichter ? [ausrichter, .. s.Gaeste] : s.Mannschaften)) + .ToList(); + + /// Die Hinweise der Kompaktauswertung zu einem Plan vor dem CSV-Export, höchstens 15 Zeilen. + private static List KompaktExportHinweise(Kompaktauswertung auswertung) + { + const int Hoechstens = 15; + IReadOnlyList befunde = auswertung.Befunde; + List liste = befunde.Take(Hoechstens).Select(b => "• " + b).ToList(); + if (befunde.Count > Hoechstens) + { + liste.Add(string.Create(CultureInfo.InvariantCulture, $"… und {befunde.Count - Hoechstens} weitere (siehe Hinweise in der Ansicht Kompaktspieltage)")); + } + + return liste; + } + + /// Liest den gesicherten und die gemerkten Pläne der Staffel und wertet sie aus (nach Öffnen, Sichern, Merken). + private void KompaktGespeichertAufbauen() + { + kompaktGespeichert = []; + kompaktGespeichertJe = []; + if (sitzung is not Plansitzung s || !KompaktEingeschaltet) + { + return; + } + + var eintraege = new List<(string Name, GemerkterPlanEintrag Eintrag)>(); + if (s.GesicherterPlan() is GemerkterPlanEintrag gesichert) + { + eintraege.Add(("Gesicherter Plan", gesichert)); + } + + eintraege.AddRange(s.GemerktePlaene().Select(e => ("Gemerkt: " + e.Name, e))); + Datenbearbeitung daten = s.DatenBearbeiten(); + DateOnly heute = DateOnly.FromDateTime(DateTime.Today); + var plaene = eintraege + .Select(x => (x.Name, x.Eintrag, Stand: GemerktenStandLaden(s, x.Eintrag))) + .Where(x => x.Stand is not null) + .Select(x => (x.Name, x.Eintrag, Auswertung: Kompaktplanung.SpieleAuswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute, x.Stand!.Spiele))) + .Where(x => x.Auswertung.Spieltage.Count > 0) + .ToList(); + var liste = new List(); + foreach ((string name, GemerkterPlanEintrag eintrag, Kompaktauswertung auswertung) in plaene) + { + var variante = new Kompaktvariante(name, Planturniere(auswertung), Kennzahlen(auswertung)); + liste.Add(variante); + kompaktGespeichertJe[variante] = (eintrag, auswertung); + } + + kompaktGespeichert = liste; + GespeichertePunkte(Kompaktpunkte.Bezug(kompaktVarianten.Count > 0 ? kompaktVarianten : kompaktGespeichert)); + } + + /// Die Punkte der gespeicherten Pläne, mit demselben Bezug wie die Varianten. + private void GespeichertePunkte(Kompaktbezug? bezug) + { + if (bezug is null) + { + return; + } + + (IReadOnlyList spitze, IReadOnlyList keller) = KompaktGruppen(); + IReadOnlyList> gruppen = spitze.Count >= 3 && spitze.Count == keller.Count ? [spitze, keller] : []; + foreach ((Kompaktvariante v, (_, Kompaktauswertung a)) in kompaktGespeichertJe) + { + kompaktPunkte[v] = Kompaktpunkte.Berechnen(a, bezug, gruppen).Summe; + } + } + + /// Der gespeicherte Stand einer gewählten Variante, wenn sie ein gespeicherter Plan ist. + private Planstand? GespeicherterStand(Plansitzung s, Kompaktvariante? variante) => + variante is not null && kompaktGespeichertJe.TryGetValue(variante, out var gespeichert) ? GemerktenStandLaden(s, gespeichert.Eintrag) : null; + + /// + /// Die Druckauswahl im Kompaktmodus mit dem Plan, den die Ansicht zeigt: die gewählte Variante bzw. der gewählte + /// gespeicherte Plan, sonst der Plan aus click-TT, sofern er Spiele hat. + /// + private DruckauswahlViewModel KompaktDruckauswahl() + { + if (kompaktVariante is Kompaktvariante variante && laufenderStand is not null) + { + return DruckauswahlViewModel.Kompakt(PlanQuelle.Laufend, variante.Name); + } + + return PlanFuer(PlanQuelle.ClickTt) is { Spiele.Count: > 0 } + ? DruckauswahlViewModel.Kompakt(PlanQuelle.ClickTt, "Plan aus click-TT") + : DruckauswahlViewModel.Kompakt(null, string.Empty); + } + + /// + /// Die Abschnitte der Übersicht für die Vereine im Ausdruck: Raster mit Legende, Karte aller Fahrten, Spieltage und je + /// Mannschaft eine Seite mit Karte und Spieltagen. + /// + private IReadOnlyList KompaktDruckabschnitte(Plansitzung s, Planstand stand) + { + Datenbearbeitung daten = s.DatenBearbeiten(); + Kompaktauswertung a = Kompaktplanung.SpieleAuswerten(s.Staffel, daten, s.Optionen.Rundenplanung, DateOnly.FromDateTime(DateTime.Today), stand.Spiele); + (IReadOnlyList spitze, IReadOnlyList keller) = KompaktGruppen(); + Kompaktspieltag? spitzentag = spitze.Count >= 3 ? Kompaktpunkte.Gruppentag(a, spitze) : null; + Kompaktspieltag? kellertag = keller.Count >= 3 ? Kompaktpunkte.Gruppentag(a, keller) : null; + return Kompaktdruck.Abschnitte(a, daten.Hallenname, s.Staffel.Name, (spitzentag, kellertag)); + } +} diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.Kompaktspieltage.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.Kompaktspieltage.cs new file mode 100644 index 0000000..872e4c5 --- /dev/null +++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.Kompaktspieltage.cs @@ -0,0 +1,590 @@ +// 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.Domain.Stammdaten; +using AndiGenerator.Engine.Turniere; +using CommunityToolkit.Mvvm.Input; + +namespace AndiGenerator.Presentation; + +/// +/// Kompaktspieltage (nicht im Original): Ansicht, Schalter, Punkt der Einrichtung und der Kompaktmodus des Hauptfensters +/// (Planer statt Generierung, eigene Kacheln, ohne Kosten-, Qualitäts- und Meldungsansicht). +/// +public sealed partial class HauptfensterViewModel +{ + private KompaktspieltageAnsichtViewModel? kompaktAnsicht; + private bool kompaktMeldungenBearbeiten; + private Datenbearbeitung? kompaktLesekopie; + private IRelayCommand? kompaktCommand; + private IReadOnlyList kompaktVarianten = []; + private Dictionary kompaktPunkte = []; + private double? kompaktClickTtPunkte; + private IReadOnlyList kompaktPlanerhinweise = []; + private IReadOnlyList kompaktKacheln = []; + private Kompaktvariante? kompaktVariante; + private Kompaktauswertung? kompaktStand; + private bool kompaktRechnet; + private bool kompaktPausiert; + private Kompaktsteuerung? kompaktSteuerung; + private Kompaktvariante? kompaktBehalten; + private bool kompaktAktivBisher; + + /// Holt den Befehl „Kompaktspieltage“ (Ansicht als andockbares Dokument). + public IRelayCommand KompaktspieltageCommand => kompaktCommand ??= new RelayCommand(KompaktspieltageOeffnen, () => ZeigtArbeitsbereich); + + /// + /// Holt einen Wert, der angibt, ob die Ansicht „Kompaktspieltage“ angeboten wird: nur, wenn die Klasse dafür in Frage + /// kommt (Halbrunde mit überwiegend Spieltagen am Wochenende) oder wenn die Kompaktspieltage schon eingeschaltet sind. + /// + public bool ZeigtKompaktspieltage => sitzung is Plansitzung s && (Kompaktplanung.InFrage(s.Staffel, s.Optionen.Rundenplanung, false) || KompaktEingeschaltet); + + /// Holt einen Wert, der angibt, ob die Staffel mit Kompaktspieltagen geplant wird (Kompaktmodus). + public bool KompaktAktiv => ZeigtKompaktspieltage && KompaktEingeschaltet; + + /// Holt einen Wert, der angibt, ob Generierung, Verfahren und ihre Ansichten angeboten werden (nicht im Kompaktmodus). + public bool ZeigtGenerierung => !KompaktAktiv; + + /// Holt die Kacheln oben im Kompaktmodus. + public IReadOnlyList Kompaktkacheln + { + get => kompaktKacheln; + private set => SetProperty(ref kompaktKacheln, value); + } + + /// + /// Holt oder setzt einen Wert, der angibt, ob die Meldungen im Raster gerade bearbeitet werden; solange lässt sich keine + /// Berechnung starten (Peter, 13.10.2026). + /// + public bool KompaktMeldungenBearbeiten + { + get => kompaktMeldungenBearbeiten; + set + { + if (kompaktMeldungenBearbeiten != value) + { + kompaktMeldungenBearbeiten = value; + GenerierungCommand.NotifyCanExecuteChanged(); + } + } + } + + /// Holt einen Wert, der angibt, ob der Kompaktplaner gerade rechnet. + public bool KompaktRechnet + { + get => kompaktRechnet; + private set + { + if (SetProperty(ref kompaktRechnet, value)) + { + GenerierungCommand.NotifyCanExecuteChanged(); + StoppenCommand.NotifyCanExecuteChanged(); + GenerierungGeaendert(); + } + } + } + + /// Holt einen Wert, der angibt, ob die Planung der Kompaktspieltage angehalten ist (dann zeigt die Ansicht die bisher besten Pläne). + public bool KompaktPausiert + { + get => kompaktPausiert; + private set + { + if (SetProperty(ref kompaktPausiert, value)) + { + GenerierungGeaendert(); + } + } + } + + /// Holt, ob die geöffnete Staffel mit Kompaktspieltagen geplant wird. + internal Kompaktmodus Kompaktmodus => sitzung?.DatenBearbeiten().Kompaktspieltage ?? Kompaktmodus.Offen; + + /// Holt einen Wert, der angibt, ob die Kompaktspieltage eingeschaltet sind (die Ansicht zeigt dann Plan und Karte). + internal bool KompaktEingeschaltet => Kompaktmodus is Kompaktmodus.Ein or Kompaktmodus.Bestaetigt; + + /// Holt die Varianten der letzten Berechnung. + internal IReadOnlyList Kompaktvarianten => kompaktVarianten; + + /// Holt die Punkte des Plans aus click-TT (wie beim besten Gesamtpaket) oder null, wenn nicht berechnet. + internal double? KompaktClickTtPunkte => kompaktClickTtPunkte; + + /// Holt die gezeigte Variante oder null für den Plan aus click-TT. + internal Kompaktvariante? GewaehlteKompaktvariante => kompaktVariante; + + /// Holt die Hinweise der letzten Berechnung. + internal IReadOnlyList KompaktPlanerhinweise => kompaktPlanerhinweise; + + /// Holt einen Wert, der angibt, ob es einen Plan aus click-TT gibt. + internal bool HatClickTtPlan => sitzung?.Staffel.BestehenderSpielplan.Count > 0; + + /// Die Punkte einer Variante (wie beim besten Gesamtpaket, kleiner ist besser). + /// Die Variante. + /// Die Punkte oder null, wenn nicht berechnet. + internal double? KompaktPunkte(Kompaktvariante variante) => kompaktPunkte.TryGetValue(variante, out double p) ? p : null; + + /// Die Auswertung des gezeigten Plans (Variante oder Plan aus click-TT). + /// Die Auswertung oder null außerhalb des Kompaktmodus. + internal Kompaktauswertung? Kompaktauswertung() => kompaktStand; + + /// Die Namen der Mannschaften der Staffel. + /// Die Namen. + internal List KompaktMannschaften() => sitzung?.Staffel.Mannschaften.Select(m => m.Name).ToList() ?? []; + + /// Die vier stärksten und die vier schwächsten Mannschaften nach QTTR-Schnitt (leer, wenn nicht bekannt). + /// Spitze und Keller. + internal (IReadOnlyList Spitze, IReadOnlyList Keller) KompaktGruppen() + { + if (sitzung is not Plansitzung s) + { + return ([], []); + } + + (IReadOnlyList spitze, IReadOnlyList keller, _) = + Kompaktplanung.Staerkegruppen(s.Staffel.Mannschaften.Select(m => m.Name).ToList(), s.DatenBearbeiten().Mannschaftsstaerken()); + return (spitze, keller); + } + + /// Schaltet die Kompaktspieltage ein oder aus und speichert das mit der Staffel. + /// Der neue Zustand. + /// Eine Aufgabe. + internal async Task KompaktspieltageSchaltenAsync(Kompaktmodus modus) + { + if (sitzung is not Plansitzung s) + { + return; + } + + Datenbearbeitung daten = s.DatenBearbeiten(); + daten.KompaktspieltageSetzen(modus); + if (await UebernehmenAsync(s, daten)) + { + KompaktGespeichertAufbauen(); + KompaktAktualisieren(); + } + } + + /// Ändert die Meldungen (Raster im Bearbeitungsmodus), speichert sie wie die Spielplandaten und zeigt sie neu. + /// Die Änderung an der Arbeitskopie. + /// Eine Aufgabe. + internal async Task KompaktMeldungAendernAsync(Action aenderung) + { + if (sitzung is not Plansitzung s) + { + return; + } + + Datenbearbeitung daten = s.DatenBearbeiten(); + try + { + aenderung(daten); + } + catch (ArgumentException fehler) + { + await oberflaeche.MeldenAsync("Meldungen", fehler.Message); + return; + } + + if (await UebernehmenAsync(s, daten)) + { + KompaktAktualisieren(); + } + } + + /// Liest etwas aus den Spielplandaten (z. B. den Heimtermin unter dem Rahmen des Rasters). + /// Art des Ergebnisses. + /// Was gelesen wird. + /// Ergebnis ohne geöffnete Staffel. + /// Das Ergebnis. + internal T KompaktDatenLesen(Func lesen, T ersatz) + { + if (sitzung is not Plansitzung s) + { + return ersatz; + } + + // Eine Lesekopie für alle Bewegungen des Rahmens; sie wird bei jeder Änderung neu angelegt. + kompaktLesekopie ??= s.DatenBearbeiten(); + return lesen(kompaktLesekopie); + } + + /// Verwirft die Lesekopie der Spielplandaten (nach Änderungen von außen). + internal void KompaktLesekopieVerwerfen() => kompaktLesekopie = null; + + /// Berechnet Varianten (Startknopf im Kompaktmodus) und zeigt die ausgewogene. + /// Eine Aufgabe. + internal async Task KompaktPlanenAsync() + { + if (KompaktRechnet || sitzung is not Plansitzung s) + { + return; + } + + using var steuerung = new Kompaktsteuerung(); + kompaktSteuerung = steuerung; + kompaktBehalten = null; + KompaktRechnet = true; + kompaktAnsicht?.FortschrittZeigen(0, "Wird vorbereitet …"); + kompaktAnsicht?.Laden(); + Kompaktaufgabe aufgabe = Kompaktplanung.Aufgabe(s.Staffel, s.DatenBearbeiten(), s.Optionen.Rundenplanung, DateOnly.FromDateTime(DateTime.Today)); + Kompaktplanoptionen optionen = Kompaktplanung.Optionen(); + Statuszeile = string.Create(Deutsch, $"Kompaktspieltage werden geplant (auf {optionen.Threads} Prozessorkernen) …"); + Kompaktergebnis ergebnis; + try + { + // Beenden läuft über die Steuerung: der Planer liefert dann noch die bis dahin besten Pläne. + ergebnis = await Task.Run(() => Kompaktplaner.Planen(aufgabe, optionen, steuerung, KompaktFortschritt), CancellationToken.None); + } + catch (Exception fehler) when (fehler is not OperationCanceledException) + { + // Ein Fehler im Hintergrund darf nicht verloren gehen. + Statuszeile = "Kompaktspieltage: Berechnung abgebrochen."; + await oberflaeche.MeldenAsync("Kompaktspieltage", "Die Berechnung ist mit einem Fehler abgebrochen:" + Environment.NewLine + fehler.Message); + return; + } + finally + { + kompaktSteuerung = null; + KompaktPausiert = false; + KompaktRechnet = false; + } + + if (!ReferenceEquals(s, sitzung)) + { + return; + } + + KompaktErgebnisZeigen(s, ergebnis); + kompaktBehalten = null; + Statuszeile = ergebnis.Varianten.Count == 0 + ? "Kompaktspieltage: kein vollständiger Plan gefunden – Hinweise in der Ansicht „Kompaktspieltage“." + : string.Create(Deutsch, $"Kompaktspieltage: {ergebnis.Varianten.Count} Varianten aus {ergebnis.Geprueft:N0} Versuchen."); + } + + /// + /// Hält die laufende Planung an und zeigt die bisher besten Pläne (sie werden ersetzt, wenn die Planung später + /// bessere findet) bzw. setzt sie fort. + /// + /// Eine Aufgabe. + internal async Task KompaktPauseUmschaltenAsync() + { + if (kompaktSteuerung is not Kompaktsteuerung steuerung || sitzung is not Plansitzung s) + { + return; + } + + if (KompaktPausiert) + { + // Von den Plänen der Pause bleibt nur der gewählte; er steht am Ende neben den neuen. + kompaktBehalten = kompaktVariante; + kompaktVarianten = kompaktBehalten is null ? [] : [kompaktBehalten]; + kompaktPunkte = kompaktPunkte.Where(p => ReferenceEquals(p.Key, kompaktBehalten)).ToDictionary(); + KompaktPausiert = false; + steuerung.Fortsetzen(); + KompaktAktualisieren(); + Statuszeile = "Kompaktspieltage werden weiter geplant …"; + return; + } + + steuerung.Pausieren(); + KompaktPausiert = true; + Statuszeile = "Kompaktspieltage: angehalten – gezeigt werden die bisher besten Pläne."; + Kompaktergebnis? zwischenstand = await Task.Run(() => steuerung.Zwischenstand(), CancellationToken.None); + if (zwischenstand is not null && KompaktPausiert && ReferenceEquals(s, sitzung)) + { + KompaktErgebnisZeigen(s, zwischenstand); + kompaktAnsicht?.PauseZeigen("Angehalten – die bisher besten Pläne stehen zur Auswahl. Weiterrechnen behält nur den gewählten."); + } + } + + /// Beendet die laufende Planung; sie liefert die bis dahin besten Pläne. + internal void KompaktStoppen() => kompaktSteuerung?.Stoppen(); + + /// Zeigt einen Plan: eine Variante (sie wird zum aktuellen Plan für alle Ansichten, Druck und Export) oder den Plan aus click-TT. + /// Die Variante oder null für den Plan aus click-TT. + internal void KompaktplanWaehlen(Kompaktvariante? variante) + { + if (sitzung is not Plansitzung s) + { + return; + } + + kompaktVariante = variante; + if (variante is not null) + { + // Gespeicherte Pläne mit genau ihren Spielen (Uhrzeiten, Hallen, Heimrecht), Varianten aus ihren Spieltagen. + if (GespeicherterStand(s, variante) is Planstand gespeichert) + { + laufenderStand = gespeichert; + } + else + { + IReadOnlyList spiele = Kompaktplanung.Spiele(variante.Turniere, s.Staffel); + laufenderStand = new Planstand(spiele, s.Bewerten(spiele)); + } + + StartplaeneAufbauen(); + BefehleAktualisieren(); + AnsichtenAktualisieren(PlanQuellenArt.LaufendeGenerierung); + } + + KompaktAktualisieren(); + } + + /// Ein Plan als Text (Tag, Form, Mannschaften je Spieltag) zum Erkennen gleicher Pläne. + private static string Planschluessel(Kompaktvariante v) => + string.Join(';', v.Turniere.Select(t => string.Create(CultureInfo.InvariantCulture, $"{t.Tag:yyyy-MM-dd}|{t.Form}|{string.Join(',', t.Mannschaften)}"))); + + private static string KmText(double km) => km.ToString("N0", Deutsch) + " km"; + + /// Die vier Kacheln: Fahrten, Meldungen, Heimspieltage, Spieltage. + private static List KachelnBilden(Kompaktauswertung a, Kompaktvariante? variante) + { + string plan = variante?.Name ?? "Plan aus click-TT"; + if (a.Spieltage.Count == 0) + { + const string Leer = "Noch kein Plan – ▶ berechnet Varianten"; + return + [ + new Kompaktkachel("Fahrten (Luftlinie)", "–", Leer, false, false), + new Kompaktkachel("Meldungen", "–", Leer, false, false), + new Kompaktkachel("Heimspieltage", "–", Leer, false, false), + new Kompaktkachel("Spieltage", "–", Leer, false, false), + ]; + } + + Kompaktmannschaft? weiteste = a.Mannschaften.MaxBy(m => m.Km); + int wenigste = a.Mannschaften.Min(m => m.Ausgerichtet); + int meiste = a.Mannschaften.Max(m => m.Ausgerichtet); + string heim = wenigste == meiste ? wenigste.ToString(Deutsch) : string.Create(Deutsch, $"{wenigste}–{meiste}"); + return + [ + new Kompaktkachel("Fahrten (Luftlinie)", KmText(a.KmGesamt), weiteste is null ? plan : $"weiteste: {weiteste.Name} {KmText(weiteste.Km)} · Unterschied {KmText(a.Mannschaften.Max(m => m.Km) - a.Mannschaften.Min(m => m.Km))}", false, false), + new Kompaktkachel("Meldungen", a.Verstoesse == 0 ? "eingehalten" : string.Create(Deutsch, $"{a.Verstoesse} Verstöße"), plan, a.Verstoesse == 0, a.Verstoesse > 0), + new Kompaktkachel("Heimspieltage je Mannschaft", heim, Kompakterlaeuterung.Heimspieltage(a), false, a.OhneHeimspieltag > 0), + new Kompaktkachel( + "Spieltage", + a.Formen, + string.Create(Deutsch, $"Stand höchstens {a.GroessterAbstand} Spiele auseinander · bis zu {a.MeisteAmTag} von {a.Mannschaften.Count} an einem Tag"), + false, + false), + ]; + } + + /// + /// Punkt „Klasse mit Kompaktspieltagen“ der Einrichtung: nur bei einer Halbrunde („(HR)“ bzw. „(FR)“ im Namen, eingestellt + /// oder vorgeschlagen) mit überwiegend Spieltagen am Wochenende; noch nicht gefragt → einschalten oder normal planen, + /// eingeschaltet (noch nicht bestätigt) → beibehalten oder zurücknehmen. + /// + private static Einrichtungsschritt? KompaktschrittErstellen(Plansitzung neu, int nummer, Rundenvorschlag vorschlag) + { + Kompaktmodus modus = neu.DatenBearbeiten().Kompaktspieltage; + bool eingeschaltet = modus == Kompaktmodus.Ein; + if (modus is Kompaktmodus.Bestaetigt or Kompaktmodus.Aus + || (!eingeschaltet && !Kompaktplanung.InFrage(neu.Staffel, neu.Optionen.Rundenplanung, vorschlag == Rundenvorschlag.Halbrunde))) + { + return null; + } + + IReadOnlyList details = + [ + "Optimiert wird nach Luftlinie zwischen den Hallen (Adressen aus den Terminwünschen).", + "Gilt nur für eine Halbrunde. Lässt sich jederzeit in der Ansicht „Kompaktspieltage“ wieder ausschalten.", + ]; + string text = eingeschaltet + ? "Diese Staffel wird mit Kompaktspieltagen geplant. Falls das beim letzten Mal versehentlich eingeschaltet wurde, lässt es sich hier zurücknehmen. " + + KompaktspieltageAnsichtViewModel.Erklaerung + : KompaktspieltageAnsichtViewModel.Erklaerung; + IReadOnlyList optionen = eingeschaltet + ? [new Einrichtungsoption("Kompaktspieltage beibehalten", "wird danach nicht mehr gefragt"), new Einrichtungsoption("Kompaktspieltage ausschalten", "normale Planung")] + : [new Einrichtungsoption("Mit Kompaktspieltagen planen", "3er- und 4er-Spieltage, Karte und Prüfung"), new Einrichtungsoption("Normal planen", "jedes Spiel einzeln")]; + + // Die Erkennung wählt die erste Option vor (planen bzw. beibehalten); sie gilt auch beim Abschließen ohne Klick + // (Peter, 14.10.2026). „Normal planen“ bleibt mit einem Klick möglich. + var schritt = new Einrichtungsschritt(nummer, "Klasse mit Kompaktspieltagen", text, details, optionen); + schritt.Vorwaehlen(0); + return schritt; + } + + /// Nach Öffnen, Schließen oder Änderungen: Auswertung, Kacheln, Ansicht und Navigation aktualisieren; beim Wechsel in den oder aus dem Kompaktmodus die Ansichten umstellen. + private void KompaktAktualisieren() + { + kompaktLesekopie = null; + KompaktStandBerechnen(); + kompaktAnsicht?.Laden(); + OnPropertyChanged(nameof(ZeigtKompaktspieltage)); + OnPropertyChanged(nameof(KompaktAktiv)); + OnPropertyChanged(nameof(ZeigtGenerierung)); + OnPropertyChanged(nameof(GenerierungText)); + KompaktspieltageCommand.NotifyCanExecuteChanged(); + bool aktiv = KompaktAktiv; + if (aktiv != kompaktAktivBisher) + { + kompaktAktivBisher = aktiv; + AnsichtenFuerModus(aktiv); + } + } + + /// Übernimmt Varianten und Hinweise (am Ende oder in der Pause) und zeigt den Plan mit den wenigsten Punkten. + private void KompaktErgebnisZeigen(Plansitzung s, Kompaktergebnis ergebnis) + { + List varianten = [.. ergebnis.Varianten]; + if (kompaktBehalten is Kompaktvariante behalten && !varianten.Exists(v => Planschluessel(v) == Planschluessel(behalten))) + { + const string Zusatz = " (aus der Pause)"; + varianten.Add(behalten.Name.EndsWith(Zusatz, StringComparison.Ordinal) ? behalten : behalten with { Name = behalten.Name + Zusatz }); + } + + kompaktVarianten = NachPunkten(s, varianten); + kompaktPlanerhinweise = ergebnis.Hinweise; + KompaktspieltageOeffnen(); + KompaktplanWaehlen(kompaktVarianten.Count > 0 ? kompaktVarianten[0] : kompaktVariante); + } + + /// + /// Die Varianten aufsteigend nach Punkten (wie in der Erläuterung, bei Gleichstand in der Reihenfolge des Planers); + /// merkt sich die Punkte, auch die des Plans aus click-TT. + /// + private List NachPunkten(Plansitzung s, IReadOnlyList varianten) + { + kompaktPunkte = []; + kompaktClickTtPunkte = null; + if (Kompaktpunkte.Bezug(varianten) is not Kompaktbezug bezug) + { + return varianten.ToList(); + } + + Datenbearbeitung daten = s.DatenBearbeiten(); + DateOnly heute = DateOnly.FromDateTime(DateTime.Today); + (IReadOnlyList spitze, IReadOnlyList keller) = KompaktGruppen(); + IReadOnlyList> gruppen = spitze.Count >= 3 && spitze.Count == keller.Count ? [spitze, keller] : []; + foreach (Kompaktvariante v in varianten) + { + Kompaktauswertung a = Kompaktplanung.Auswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute, v.Turniere); + kompaktPunkte[v] = Kompaktpunkte.Berechnen(a, bezug, gruppen).Summe; + } + + if (HatClickTtPlan) + { + Kompaktauswertung clickTt = Kompaktplanung.Auswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute); + kompaktClickTtPunkte = clickTt.Spieltage.Count == 0 ? null : Kompaktpunkte.Berechnen(clickTt, bezug, gruppen).Summe; + } + + GespeichertePunkte(bezug); + return varianten.OrderBy(v => kompaktPunkte[v]).ToList(); + } + + /// Vergleich für „Plan sichern“ im Kompaktmodus: beide Pläne nach den Kennzahlen der Kompaktspieltage. + private Planvergleich KompaktVergleich(Plansitzung s, string anlass, IReadOnlyList aktuell, IReadOnlyList gesichert, DateTime gesichertAm) + { + Datenbearbeitung daten = s.DatenBearbeiten(); + DateOnly heute = DateOnly.FromDateTime(DateTime.Today); + Kompaktauswertung neu = Kompaktplanung.SpieleAuswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute, aktuell); + Kompaktauswertung alt = Kompaktplanung.SpieleAuswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute, gesichert); + (IReadOnlyList spitze, IReadOnlyList keller) = KompaktGruppen(); + + // Gleicher Bezug wie in der Erläuterung der Pläne: kürzester 4er-Plan und kleinster Unterschied der Varianten. + return Planvergleich.Kompakt(anlass, neu, alt, [spitze, keller], Kompaktpunkte.Bezug(kompaktVarianten), gesichertAm); + } + + private void KompaktStandBerechnen() + { + if (sitzung is not Plansitzung s || !KompaktEingeschaltet) + { + kompaktStand = null; + Kompaktkacheln = []; + return; + } + + Datenbearbeitung daten = s.DatenBearbeiten(); + DateOnly heute = DateOnly.FromDateTime(DateTime.Today); + if (GespeicherterStand(s, kompaktVariante) is Planstand gespeichert) + { + kompaktStand = Kompaktplanung.SpieleAuswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute, gespeichert.Spiele); + } + else + { + kompaktStand = kompaktVariante is Kompaktvariante v + ? Kompaktplanung.Auswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute, v.Turniere) + : Kompaktplanung.Auswerten(s.Staffel, daten, s.Optionen.Rundenplanung, heute); + } + + Kompaktkacheln = KachelnBilden(kompaktStand, kompaktVariante); + } + + /// Beim Öffnen und Schließen einer Staffel: Varianten verwerfen. + private void KompaktZuruecksetzen() + { + kompaktVarianten = []; + kompaktGespeichert = []; + kompaktGespeichertJe = []; + kompaktPunkte = []; + kompaktClickTtPunkte = null; + kompaktPlanerhinweise = []; + kompaktVariante = null; + } + + /// Kompaktmodus: die Ansicht „Kompaktspieltage“ ist die Hauptansicht, Kosten, Qualität und Meldungen entfallen; sonst wieder die Kosten. + private void AnsichtenFuerModus(bool aktiv) + { + if (aktiv) + { + KompaktspieltageOeffnen(); + foreach (AnsichtViewModel ansicht in ansichten.Where(a => a is KostenAnsichtViewModel or QualitaetAnsichtViewModel or MeldungenAnsichtViewModel).ToList()) + { + fabrik.CloseDockable(ansicht); + } + } + else if (sitzung is not null && !ansichten.OfType().Any()) + { + AnsichtOeffnen(new KostenAnsichtViewModel(this, NaechsteId())); + } + } + + private void KompaktFortschritt(Kompaktfortschritt stand) => + oberflaeche.AufOberflaeche(() => + { + Statuszeile = "Kompaktspieltage werden geplant – " + stand.Text; + kompaktAnsicht?.FortschrittZeigen(stand.Anteil * 100, stand.Text); + }); + + private void KompaktspieltageOeffnen() + { + if (kompaktAnsicht is null) + { + kompaktAnsicht = new KompaktspieltageAnsichtViewModel(this); + fabrik.AnsichtHinzufuegen(kompaktAnsicht); + } + else + { + fabrik.SetActiveDockable(kompaktAnsicht); + } + } + + /// Übernimmt die Wahl im Punkt „Klasse mit Kompaktspieltagen“. + private async Task KompaktschrittUebernehmenAsync(Plansitzung s, Einrichtungsschritt schritt) + { + if (!ReferenceEquals(s, sitzung)) + { + return false; + } + + Kompaktmodus bisher = s.DatenBearbeiten().Kompaktspieltage; + Kompaktmodus neu = (bisher, schritt.Auswahl) switch + { + (Kompaktmodus.Ein, 0) => Kompaktmodus.Bestaetigt, + (_, 0) => Kompaktmodus.Ein, + _ => Kompaktmodus.Aus, + }; + await KompaktspieltageSchaltenAsync(neu); + if (neu != Kompaktmodus.Aus) + { + KompaktspieltageOeffnen(); + } + + return true; + } +} diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.Runde.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.Runde.cs new file mode 100644 index 0000000..b5ec4b6 --- /dev/null +++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.Runde.cs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Application; +using AndiGenerator.Domain.Optionen; + +namespace AndiGenerator.Presentation; + +/// Rundenplanung: Punkt der Einrichtung (Original: Rückfrage beim Öffnen) und Vorgabe durch den Klassennamen. +public sealed partial class HauptfensterViewModel +{ + private const string Rundentitel = "Rundenplanung"; + + /// + /// Holt die vom Klassennamen vorgegebene Rundenplanung (nicht im Original): „(HR)“ → nur Hinrunde, „(FR)“ → nur + /// Rückrunde; sonst null. + /// + internal Rundenplanung? VorgegebeneRundenplanung => sitzung?.VorgegebeneRundenplanung; + + /// Erstellt den Punkt „Rundenplanung“: Halbrunde bzw. nur Rückrunde (Vorschlag) oder die ganze Runde. + private static Einrichtungsschritt RundenschrittErstellen(Plansitzung neu, int nummer, Rundenvorschlag vorschlag) + { + var ganzeRunde = new Einrichtungsoption("Die ganze Runde", "Vor- und Rückrunde planen"); + return vorschlag == Rundenvorschlag.Halbrunde + ? new Einrichtungsschritt( + nummer, + Rundentitel, + "Die Runde ist kürzer als 6 Monate. Welche Spiele sollen generiert werden?", + [], + [new Einrichtungsoption("Nur eine Halbrunde", "Vorschlag für kurze Runden"), ganzeRunde]) + : new Einrichtungsschritt( + nummer, + Rundentitel, + RueckrundenFrage(neu.Staffel.Rueckrundenbeginn), + [], + [new Einrichtungsoption("Nur die Rückrunde", "Vorschlag – die Vorrunde bleibt, wie sie ist"), ganzeRunde]); + } +} diff --git a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs index 662ce5b..1eaab19 100644 --- a/src/AndiGenerator.Presentation/HauptfensterViewModel.cs +++ b/src/AndiGenerator.Presentation/HauptfensterViewModel.cs @@ -24,7 +24,7 @@ namespace AndiGenerator.Presentation; /// Hauptfenster: geöffnete Staffel, Steuerung der Generierung, Statuszeile und die Ansichten im Andock-Layout /// (MIGRATIONSPLAN E15). Mehrere Ansichten können gleichzeitig sichtbar sein, jede mit eigener Planquelle. /// -public sealed class HauptfensterViewModel : ObservableObject, IDisposable +public sealed partial class HauptfensterViewModel : ObservableObject, IDisposable { /// Dateiname der Anleitung neben dem Programm. public const string Anleitungsdatei = "Anleitung.pdf"; @@ -38,6 +38,9 @@ public sealed class HauptfensterViewModel : ObservableObject, IDisposable private static readonly Dateifilter PlanDateien = new("click-TT- und Plandateien", ["*.xml"]); + /// Beim Öffnen auch die beiden weiteren Exporte aus click-TT: Mannschaftsmeldung (CSV) und Terminwünsche (PDF). + private static readonly Dateifilter ClickTtDateien = new("click-TT-Dateien (XML, Mannschaftsmeldung, Terminwünsche)", ["*.xml", "*.csv", "*.pdf"]); + private readonly IOberflaeche oberflaeche; private readonly Optimierungsdienst dienst; private readonly DockFabrik fabrik; @@ -134,6 +137,11 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster nachbarterminAnsicht = null; } + if (ReferenceEquals(e.Dockable, kompaktAnsicht)) + { + kompaktAnsicht = null; + } + if (ReferenceEquals(e.Dockable, maschinenraumAnsicht)) { maschinenraumAnsicht = null; @@ -152,7 +160,10 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster OeffnenCommand = new AsyncRelayCommand(OeffnenAsync, () => !ZeigtEinrichtung); NeuCommand = new AsyncRelayCommand(NeueDateiAsync, () => !ZeigtEinrichtung); SchliessenCommand = new AsyncRelayCommand(SchliessenAsync, () => IstGeoeffnet); - GenerierungCommand = new AsyncRelayCommand(GenerierungUmschaltenAsync, () => IstGeoeffnet); + + // Mehrfach gleichzeitig: Während die Planung der Kompaktspieltage (vom selben Knopf gestartet) noch läuft, muss der + // Knopf für Pause und Fortsetzen bedienbar bleiben. + GenerierungCommand = new AsyncRelayCommand(GenerierungUmschaltenAsync, () => IstGeoeffnet && !(KompaktMeldungenBearbeiten && !KompaktRechnet), AsyncRelayCommandOptions.AllowConcurrentExecutions); KostenoptimierungCommand = new RelayCommand(() => VerfahrenWaehlen(false), () => IstGeoeffnet); AutomodusCommand = new RelayCommand(() => VerfahrenWaehlen(true), () => IstGeoeffnet); ZuletztOeffnenCommand = new AsyncRelayCommand(ZuletztOeffnenAsync, _ => !ZeigtEinrichtung); @@ -162,7 +173,7 @@ public HauptfensterViewModel(IOberflaeche oberflaeche, Func fenster ZoomZuruecksetzenCommand = new RelayCommand(() => ZoombareAnsicht()?.Zoom.Zuruecksetzen()); StartenCommand = new AsyncRelayCommand(StartenAsync, () => IstGeoeffnet); PauseCommand = new RelayCommand(PauseUmschalten, () => Laeuft); - StoppenCommand = new RelayCommand(Stoppen, () => Laeuft); + StoppenCommand = new RelayCommand(Stoppen, () => Laeuft || KompaktRechnet); PlanMerkenCommand = new AsyncRelayCommand(PlanMerkenAsync, () => laufenderStand is not null); PlanLoeschenCommand = new AsyncRelayCommand( () => startplan.Quelle is PlanQuelle quelle ? GemerktenPlanEntfernenAsync(quelle) : Task.CompletedTask, @@ -291,19 +302,25 @@ private set /// public string GenerierungText => (Laeuft, Pausiert) switch { + (false, _) when KompaktRechnet && KompaktPausiert => "Fortsetzen (gezeigt werden die bisher besten Pläne)", + (false, _) when KompaktRechnet => "Anhalten und die bisher besten Pläne zeigen", + (false, _) when KompaktAktiv => "Kompaktspieltage planen: Varianten berechnen (etwa 10 Sekunden)", (false, _) => "Start", (true, true) => "Fortsetzen", _ => "Pausieren", }; - /// 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 Play-Symbol zeigt (vor dem Start). + public bool ZeigtStart => !Laeuft && !KompaktRechnet; + + /// Holt einen Wert, der angibt, ob der Hauptknopf das Symbol „Fortsetzen“ (Strich und Play) zeigt (in der Pause). + public bool ZeigtFortsetzen => (Laeuft && Pausiert) || (KompaktRechnet && KompaktPausiert); /// Holt einen Wert, der angibt, ob der Hauptknopf das Pause-Symbol zeigt (Generierung läuft). - public bool ZeigtPause => Laeuft && !Pausiert; + public bool ZeigtPause => (Laeuft && !Pausiert) || (KompaktRechnet && !KompaktPausiert); /// Holt einen Wert, der angibt, ob rechts daneben „Stopp“ steht (Generierung läuft oder ruht; sonst „Schließen“). - public bool ZeigtStopp => Laeuft; + public bool ZeigtStopp => Laeuft || KompaktRechnet; /// /// Holt einen Wert, der angibt, ob „Start mit“ gewählt werden kann: nur vor dem Start. Eine laufende oder angehaltene @@ -847,8 +864,13 @@ internal Druckdokument DruckdokumentErstellen(DruckauswahlViewModel auswahl) string plan = string.Empty; if (auswahl.Quelle is PlanQuelle quelle && PlanFuer(quelle) is Planstand stand) { + if (auswahl.Kompaktspieltage) + { + abschnitte.AddRange(KompaktDruckabschnitte(s, stand)); + } + abschnitte.AddRange(PlanAbschnitte(s, stand, auswahl)); - plan = quelle.Name; + plan = auswahl.IstKompakt ? auswahl.Planname : quelle.Name; } return new Druckdokument(s.Staffel.Name, plan, DateTime.Now, abschnitte); @@ -901,6 +923,13 @@ internal async Task GemerktenPlanEntfernenAsync(PlanQuelle quelle) QuellenNeuAufbauen(); } + /// Ob zwei Pläne dieselben Begegnungen an denselben Tagen haben, gleich in welcher Reihenfolge. + private static bool GleicheBegegnungen(IReadOnlyList a, IReadOnlyList b) + { + static string Schluessel(Spiel s) => string.Create(CultureInfo.InvariantCulture, $"{s.Zeitpunkt:yyyy-MM-dd}|{s.Heim}|{s.Gast}"); + return a.Count == b.Count && a.Select(Schluessel).Order(StringComparer.Ordinal).SequenceEqual(b.Select(Schluessel).Order(StringComparer.Ordinal), StringComparer.Ordinal); + } + /// Die Einteilung als Aufzählung für die Einrichtung: je Stufe eine Überschrift, darunter die Kriterien mit Nummer. private static List Einteilungszeilen(Stufeneinteilung einteilung) { @@ -992,6 +1021,7 @@ private static string Zeitangabe(DateTime zeitpunkt, DateTime heute) DiagrammAnsichtViewModel => "Diagramme", TerminwunschAnsichtViewModel => "Terminwuensche", NachbarterminAnsichtViewModel => "Nachbarn", + KompaktspieltageAnsichtViewModel => "Kompakt", OptionenAnsichtViewModel => "Einstellungen", _ => null, }; @@ -1042,13 +1072,97 @@ private static string RueckrundenFrage(DateOnly? rueckrunde) => rueckrunde is Da private string NaechsteId() => "Ansicht" + (naechsteAnsicht++).ToString(CultureInfo.InvariantCulture); + /// + /// Öffnen: die XML-Datei der Staffel, auf Wunsch zusammen mit Mannschaftsmeldung (CSV) und Terminwünschen (PDF) aus + /// click-TT (Peter, 14.10.2026). Passen sie zur Staffel, werden sie gleich übernommen und der Punkt „Daten aus click-TT + /// ergänzen“ der Einrichtung ist erledigt. + /// private async Task OeffnenAsync() { - string? pfad = await oberflaeche.DateiOeffnenAsync("Staffel öffnen", [PlanDateien]); - if (pfad is not null) + const string Titeltext = "Staffel öffnen"; + IReadOnlyList pfade = await oberflaeche.DateienOeffnenAsync(Titeltext, [ClickTtDateien, PlanDateien]); + if (pfade.Count == 0) { - await StaffelOeffnenAsync(pfad); + return; + } + + List Mit(string endung) => pfade.Where(p => string.Equals(Path.GetExtension(p), endung, StringComparison.OrdinalIgnoreCase)).ToList(); + List xml = Mit(".xml"); + List csv = Mit(".csv"); + List pdf = Mit(".pdf"); + string? problem = (xml.Count, csv.Count, pdf.Count, pfade.Count - xml.Count - csv.Count - pdf.Count) switch + { + (0, _, _, _) => "Bitte die XML-Datei der Staffel mit auswählen (Terminmeldung für Meinl-Generator).", + (> 1, _, _, _) => "Bitte nur eine XML-Datei auswählen – je Staffel eine.", + (_, > 1, _, _) => "Bitte höchstens eine Mannschaftsmeldung (CSV) auswählen.", + (_, _, > 1, _) => "Bitte höchstens einen Bericht „Terminwünsche“ (PDF) auswählen.", + (_, _, _, > 0) => "Ausgewählt werden können nur XML-, CSV- und PDF-Dateien aus click-TT.", + _ => null, + }; + if (problem is not null) + { + await oberflaeche.MeldenAsync(Titeltext, problem); + return; + } + + await StaffelOeffnenAsync(xml[0], csv.FirstOrDefault(), pdf.FirstOrDefault()); + } + + /// + /// Übernimmt die beim Öffnen mit ausgewählten Exporte aus click-TT – jeden nur, wenn er zur Staffel passt, d. h. jede + /// Mannschaft der Staffel darin vorkommt. Wie auf der Seite „Daten aus click-TT“ der Einrichtung (dort mit „Auswahl + /// übernehmen“), die Mannschaftsmeldung auch als Setzliste. + /// + /// Je Datei, die nicht passte oder nicht gelesen werden konnte, ein Hinweis; leer, wenn alles übernommen ist. + private async Task> ZusatzdateienUebernehmenAsync(Plansitzung s, string? csv, string? pdf) + { + static string? Hinweis(ZusatzdatenSeiteViewModel seite, string datei, string was, IReadOnlyList fehlend) + { + if (seite.HatFehler) + { + return $"{datei}: {seite.Fehler}"; + } + + return fehlend.Count > 0 ? $"{datei} passt nicht zu dieser Staffel – {was}: {string.Join(", ", fehlend)}." : null; + } + + var hinweise = new List(); + if (pdf is not null) + { + Datenbearbeitung bearbeitung = s.DatenBearbeiten(); + var seite = new ZusatzdatenSeiteViewModel(bearbeitung, oberflaeche); + seite.TerminwuenscheLaden(pdf); + string datei = $"Bericht „Terminwünsche“ ({Path.GetFileName(pdf)})"; + string? hinweis = Hinweis(seite, datei, "nicht darin", seite.NichtImBericht()); + hinweise.AddRange(hinweis is null ? await ZusatzseiteUebernehmenAsync(s, bearbeitung, seite, datei) : [hinweis]); + } + + if (csv is not null) + { + Datenbearbeitung bearbeitung = s.DatenBearbeiten(); + var seite = new ZusatzdatenSeiteViewModel(bearbeitung, oberflaeche); + seite.AufstellungenLaden(csv); + string datei = $"Mannschaftsmeldung ({Path.GetFileName(csv)})"; + string? hinweis = Hinweis(seite, datei, "ohne Aufstellung", seite.OhneAufstellung); + hinweise.AddRange(hinweis is null ? await ZusatzseiteUebernehmenAsync(s, bearbeitung, seite, datei) : [hinweis]); } + + return hinweise; + } + + /// Speichert eine Seite „Daten aus click-TT“ wie mit „Auswahl übernehmen“. + /// Leer, wenn gespeichert; sonst ein Hinweis. + private async Task> ZusatzseiteUebernehmenAsync(Plansitzung s, Datenbearbeitung bearbeitung, ZusatzdatenSeiteViewModel seite, string datei) + { + var dialog = new DatenDialogViewModel(bearbeitung, [seite]); + if (!dialog.Bestaetigen() || !await UebernehmenAsync(s, bearbeitung)) + { + return [$"{datei} konnte nicht übernommen werden."]; + } + + KopfAktualisieren(s); + NachAenderung(s); + return []; } /// @@ -1113,11 +1227,12 @@ private async Task SchliessenAsync() Stoppen(); Volatile.Write(ref sitzung, null); laufenderStand = null; + KompaktZuruecksetzen(); clickTtStand = null; gemerkteStaende.Clear(); Einrichtung = null; var offen = new List(ansichten); - offen.AddRange(new IDockable?[] { optionenAnsicht, terminwunschAnsicht, nachbarterminAnsicht, maschinenraumAnsicht, verlaufAnsicht }.OfType()); + offen.AddRange(new IDockable?[] { optionenAnsicht, terminwunschAnsicht, nachbarterminAnsicht, kompaktAnsicht, maschinenraumAnsicht, verlaufAnsicht }.OfType()); foreach (IDockable ansicht in offen) { fabrik.CloseDockable(ansicht); @@ -1127,6 +1242,7 @@ private async Task SchliessenAsync() optionenAnsicht = null; terminwunschAnsicht = null; nachbarterminAnsicht = null; + kompaktAnsicht = null; maschinenraumAnsicht = null; verlaufAnsicht = null; AnsichtOeffnen(new KostenAnsichtViewModel(this, NaechsteId())); @@ -1140,6 +1256,7 @@ private async Task SchliessenAsync() OnPropertyChanged(nameof(ZeigtArbeitsbereich)); OnPropertyChanged(nameof(AktiveAnsicht)); OnPropertyChanged(nameof(StartplanWaehlbar)); + KompaktAktualisieren(); StartenCommand.NotifyCanExecuteChanged(); GenerierungCommand.NotifyCanExecuteChanged(); KostenoptimierungCommand.NotifyCanExecuteChanged(); @@ -1157,7 +1274,7 @@ private async Task SchliessenAsync() AlleAnsichtenAktualisieren(); } - private async Task StaffelOeffnenAsync(string pfad) + private async Task StaffelOeffnenAsync(string pfad, string? mannschaftsmeldung = null, string? terminwuensche = null) { if (!await PlanSichernAsync("Eine andere Staffel wird geöffnet.")) { @@ -1181,6 +1298,7 @@ private async Task StaffelOeffnenAsync(string pfad) Stoppen(); Volatile.Write(ref sitzung, neu); laufenderStand = null; + KompaktZuruecksetzen(); clickTtStand = null; gemerkteStaende.Clear(); KopfAktualisieren(neu); @@ -1206,7 +1324,29 @@ private async Task StaffelOeffnenAsync(string pfad) optionenAnsicht?.Laden(); terminwunschAnsicht?.Laden(); nachbarterminAnsicht?.Laden(); + KompaktAktualisieren(); + + // Mit ausgewählte Exporte aus click-TT gleich übernehmen; was nicht passt, bleibt in der Einrichtung offen. + bool zusatz = mannschaftsmeldung is not null || terminwuensche is not null; + List hinweise = zusatz ? await ZusatzdateienUebernehmenAsync(neu, mannschaftsmeldung, terminwuensche) : []; Einrichtung = EinrichtungErstellen(neu); + if (hinweise.Count > 0) + { + string text = string.Join(Environment.NewLine, hinweise) + " Bitte die passende Datei unter „Daten aus click-TT ergänzen“ laden."; + if (Einrichtung is not null) + { + Einrichtung.Meldung = text; + } + else + { + await oberflaeche.MeldenAsync("Daten aus click-TT", text); + } + } + else if (zusatz) + { + Statuszeile += " Daten aus click-TT übernommen."; + } + return true; } @@ -1224,9 +1364,14 @@ private async Task StaffelOeffnenAsync(string pfad) // Als erster Punkt weitere Exporte aus click-TT (nicht im Original); schon übernommen: als erledigt markiert. // Erledigt erst, wenn Mannschaftsmeldung und Terminwünsche übernommen sind – auch über mehrere Sitzungen. static bool BeideGeladen(Datenbearbeitung d) => d.MannschaftsmeldungVermerk.Length > 0 && d.TerminwunschVermerk.Length > 0; + + // Für Kompaktspieltage ist der Bericht „Terminwünsche“ Pflicht: ohne Hallenadressen keine Karte und keine Entfernungen. + bool kompakt = neu.IstClickTtDatei && Kompaktplanung.InFrage(neu.Staffel, neu.Optionen.Rundenplanung, false); var zusatzschritt = new Einrichtungsschritt(1, "Daten aus click-TT ergänzen", ZusatzdatenSeiteViewModel.Einrichtungstext, [], []) { IstVollstaendig = () => BeideGeladen(neu.DatenBearbeiten()), + IstPflichtErfuellt = kompakt ? () => neu.DatenBearbeiten().TerminwunschVermerk.Length > 0 : null, + Pflichthinweis = "Für Kompaktspieltage wird der Bericht „Terminwünsche“ (PDF) gebraucht: Hallenadressen für Karte und Entfernungen, Bemerkungen der Vereine. Bitte zuerst laden.", }; if (BeideGeladen(neu.DatenBearbeiten())) { @@ -1271,19 +1416,16 @@ private async Task StaffelOeffnenAsync(string pfad) Einrichtungsschritt? rundenschritt = null; if (vorschlag != Rundenvorschlag.Keiner) { - bool halb = vorschlag == Rundenvorschlag.Halbrunde; - rundenschritt = new Einrichtungsschritt( - schritte.Count + 1, - "Rundenplanung", - 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"), - new Einrichtungsoption("Die ganze Runde", "Vor- und Rückrunde planen"), - ]); + rundenschritt = RundenschrittErstellen(neu, schritte.Count + 1, vorschlag); schritte.Add(rundenschritt); } + Einrichtungsschritt? kompaktschritt = KompaktschrittErstellen(neu, schritte.Count + 1, vorschlag); + if (kompaktschritt is not null) + { + schritte.Add(kompaktschritt); + } + if (neu.ErsterStart) { int bisher = schritte.Count; @@ -1295,7 +1437,9 @@ private async Task StaffelOeffnenAsync(string pfad) : new EinrichtungViewModel( neu.Staffel.Name + " einrichten", schritte, - schritt => EinrichtungsschrittUebernehmenAsync(neu, schritt, stufenschritt, rundenschritt, vorschlag), + schritt => ReferenceEquals(schritt, kompaktschritt) + ? KompaktschrittUebernehmenAsync(neu, schritt) + : EinrichtungsschrittUebernehmenAsync(neu, schritt, stufenschritt, rundenschritt, vorschlag), schritt => ReferenceEquals(schritt, zusatzschritt) ? ZusatzseiteErzeugen(neu) : SeiteErzeugen(neu, schritt), EinrichtungAbschliessen, SchliessenAsync); @@ -1406,7 +1550,7 @@ private async Task RundeUebernehmenAsync(Plansitzung s, Rundenvorschlag vo } catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) { - await oberflaeche.MeldenAsync("Rundenplanung", fehler.Message); + await oberflaeche.MeldenAsync(Rundentitel, fehler.Message); return false; } } @@ -1471,7 +1615,15 @@ private void Starten() /// Hauptknopf: startet eine neue Generierung bzw. hält die laufende an und setzt sie fort. private async Task GenerierungUmschaltenAsync() { - if (Laeuft) + if (KompaktRechnet) + { + await KompaktPauseUmschaltenAsync(); + } + else if (KompaktAktiv && !Laeuft) + { + await KompaktPlanenAsync(); + } + else if (Laeuft) { PauseUmschalten(); } @@ -1565,6 +1717,7 @@ private void GenerierungGeaendert() OnPropertyChanged(nameof(GenerierungText)); OnPropertyChanged(nameof(ZeigtPause)); OnPropertyChanged(nameof(ZeigtStart)); + OnPropertyChanged(nameof(ZeigtFortsetzen)); OnPropertyChanged(nameof(ZeigtStopp)); OnPropertyChanged(nameof(StartplanWaehlbar)); PlanLoeschenAktualisieren(); @@ -1665,7 +1818,17 @@ await oberflaeche.MeldenAsync( return true; } - var vergleich = new Planvergleich(anlass, aktuell.Bewertung, gesichert.Bewertung, Einteilung, eintrag.Geaendert); + // Dieselben Begegnungen an denselben Tagen (nur anders gespeichert oder sortiert): nichts zu vergleichen (Peter, 13.10.2026). + if (GleicheBegegnungen(aktuell.Spiele, gesichert.Spiele)) + { + sicherungEntschieden = aktuell.Spiele; + await oberflaeche.MeldenAsync("Plan sichern", $"{anlass} Der aktuelle Plan ist mit dem gesicherten identisch; es ist nichts zu sichern."); + return true; + } + + Planvergleich vergleich = KompaktAktiv + ? KompaktVergleich(s, anlass, aktuell.Spiele, gesichert.Spiele, eintrag.Geaendert) + : new Planvergleich(anlass, aktuell.Bewertung, gesichert.Bewertung, Einteilung, eintrag.Geaendert); switch (await oberflaeche.SicherungAbfragenAsync(vergleich)) { case Sicherungswahl.Ueberschreiben: @@ -1704,6 +1867,12 @@ private void PauseUmschalten() private void Stoppen() { + if (KompaktRechnet) + { + KompaktStoppen(); + return; + } + dienst.Stoppen(); Anzeige.KerneAnhalten(); maschinenraumAnsicht?.Uebernehmen(Optimierungsstand.Leer); @@ -1747,10 +1916,17 @@ private async Task CsvExportierenAsync() return; } - IReadOnlyList harteFehler = s.HarteFehler(stand.Spiele); - if (harteFehler.Count > 0) + // Kompaktspieltage finden nicht an den Heimterminen der Heimmannschaft statt; die normale Prüfung hielte fast jedes + // Spiel für ungültig. Dort gelten stattdessen die Hinweise der Kompaktauswertung (wie in der Ansicht). + Kompaktauswertung? kompakt = KompaktAktiv + ? Kompaktplanung.SpieleAuswerten(s.Staffel, s.DatenBearbeiten(), s.Optionen.Rundenplanung, DateOnly.FromDateTime(DateTime.Today), stand.Spiele) + : null; + (string kopf, IReadOnlyList befunde) = kompakt is not null + ? ("Der Kompaktplan hat Hinweise:", KompaktExportHinweise(kompakt)) + : ("Der Plan hat harte Fehler:", s.HarteFehler(stand.Spiele)); + if (befunde.Count > 0) { - string frage = "Der Plan hat harte Fehler:" + Environment.NewLine + string.Join(Environment.NewLine, harteFehler) + string frage = kopf + Environment.NewLine + string.Join(Environment.NewLine, befunde) + Environment.NewLine + Environment.NewLine + "Trotzdem exportieren?"; if (!await oberflaeche.FragenAsync("CSV exportieren", frage)) { @@ -1766,7 +1942,14 @@ private async Task CsvExportierenAsync() try { - s.CsvExportieren(pfad, stand.Spiele); + if (kompakt is not null) + { + s.KompaktCsvExportieren(pfad, stand.Spiele, kompakt.Spieltage); + } + else + { + s.CsvExportieren(pfad, stand.Spiele); + } } catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException) { @@ -1781,7 +1964,7 @@ private async Task DruckenAsync() return; } - var auswahl = new DruckauswahlViewModel(Planquellen.Where(q => PlanFuer(q) is not null).ToList()); + DruckauswahlViewModel auswahl = KompaktAktiv ? KompaktDruckauswahl() : new DruckauswahlViewModel(Planquellen.Where(q => PlanFuer(q) is not null).ToList()); if (!await oberflaeche.DruckauswahlAsync(auswahl) || !auswahl.IstGueltig) { return; @@ -1921,6 +2104,7 @@ private void NachAenderung(Plansitzung s) AlleAnsichtenAktualisieren(); terminwunschAnsicht?.Laden(); nachbarterminAnsicht?.Laden(); + KompaktAktualisieren(); } private void AnsichtOeffnen(AnsichtViewModel ansicht) @@ -1979,6 +2163,8 @@ private void QuellenNeuAufbauen() Planquellen = neu; StartplaeneAufbauen(); + KompaktGespeichertAufbauen(); + kompaktAnsicht?.Laden(); foreach (AnsichtViewModel ansicht in ansichten) { ansicht.QuellenUebernehmen(neu); @@ -2094,6 +2280,7 @@ private void AnsichtsbefehleAktualisieren() DiagrammansichtCommand.NotifyCanExecuteChanged(); TerminwunschansichtCommand.NotifyCanExecuteChanged(); NachbarterminansichtCommand.NotifyCanExecuteChanged(); + KompaktspieltageCommand.NotifyCanExecuteChanged(); MaschinenraumCommand.NotifyCanExecuteChanged(); VerlaufCommand.NotifyCanExecuteChanged(); } diff --git a/src/AndiGenerator.Presentation/IOberflaeche.cs b/src/AndiGenerator.Presentation/IOberflaeche.cs index 3de2f54..3ffc5a7 100644 --- a/src/AndiGenerator.Presentation/IOberflaeche.cs +++ b/src/AndiGenerator.Presentation/IOberflaeche.cs @@ -23,6 +23,12 @@ public interface IOberflaeche /// Der lokale Pfad oder null bei Abbruch. Task DateiOeffnenAsync(string titel, IReadOnlyList filter); + /// Lässt eine oder mehrere vorhandene Dateien auswählen. + /// Titel des Dialogs. + /// Dateifilter. + /// Die lokalen Pfade; leer bei Abbruch. + Task> DateienOeffnenAsync(string titel, IReadOnlyList filter); + /// Lässt eine Zieldatei auswählen. /// Titel des Dialogs. /// Vorgeschlagener Dateiname. diff --git a/src/AndiGenerator.Presentation/Kartenbau.cs b/src/AndiGenerator.Presentation/Kartenbau.cs new file mode 100644 index 0000000..bfafba0 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenbau.cs @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Geo; + +namespace AndiGenerator.Presentation; + +/// +/// Berechnet die Karte der Spielorte (nicht im Original): flächentreue Näherung um die mittlere Breite, Ausschnitt so +/// gezoomt, dass alle Spielorte mit Rand zu sehen sind; Beschriftungen ohne Überdeckung, nur wenige Städte. +/// +public static class Kartenbau +{ + /// Breite des Bilds in Bildpunkten. + public const double Breite = 600; + + private const double Gradmass = 100; + private const double Schrifthoehe = 15; + private const int HoechstensStaedte = 10; + + /// Baut das Kartenbild. + /// Spielorte (nur solche mit bekannter Lage). + /// Fahrten von Gast zu Ausrichter (Mannschaftsnamen), mit Kennzeichen für Einzelbegegnungen. + /// Umrisse der Bundesländer. + /// Städte, die wichtigsten zuerst. + /// Das Bild; ohne Spielorte ein leeres. + public static Kartenbild Bauen( + IReadOnlyList orte, + IReadOnlyList<(string Von, string Nach, bool Kraeftig, bool Einzel)> fahrten, + IReadOnlyList laender, + IReadOnlyList staedte) + { + ArgumentNullException.ThrowIfNull(orte); + ArgumentNullException.ThrowIfNull(fahrten); + ArgumentNullException.ThrowIfNull(laender); + ArgumentNullException.ThrowIfNull(staedte); + if (orte.Count == 0) + { + return Kartenbild.Leer; + } + + var projektion = new Projektion(orte.Select(o => o.Lage).ToList()); + var flaechen = new List>(); + var landnamen = new List(); + foreach (Landesumriss land in laender) + { + flaechen.AddRange(land.Ringe.Select(r => (IReadOnlyList)r.Select(projektion.Bild).ToList()).Where(projektion.Sichtbar)); + if (land.Ringe.Count > 0) + { + IReadOnlyList groesster = land.Ringe.MaxBy(r => r.Count)!; + Kartenpunkt mitte = projektion.Bild(new Koordinate(groesster.Average(k => k.Breite), groesster.Average(k => k.Laenge))); + if (mitte.X > 60 && mitte.X < Breite - 60 && mitte.Y > 20 && mitte.Y < projektion.Hoehe - 20) + { + landnamen.Add(new Kartenbeschriftung(land.Name.Replace("Freie Hansestadt ", string.Empty, StringComparison.Ordinal), mitte, mitte)); + } + } + } + + var belegt = new List<(double L, double O, double R, double U)>(); + bool Frei((double L, double O, double R, double U) b) => !belegt.Exists(c => b.L < c.R && b.R > c.L && b.O < c.U && b.U > c.O); + var hallen = new List(); + var punkte = orte.ToDictionary(o => o.Name, o => projektion.Bild(o.Lage), StringComparer.Ordinal); + belegt.AddRange(punkte.Values.Select(p => (p.X - 7, p.Y - 7, p.X + 7, p.Y + 7))); + foreach (Kartenort ort in orte) + { + Kartenpunkt p = punkte[ort.Name]; + double w = (ort.Beschriftung.Length * 7) + 4; + Kartenpunkt[] lagen = [new(p.X + 9, p.Y - 16), new(p.X + 9, p.Y + 4), new(p.X - 9 - w, p.Y - 16), new(p.X - 9 - w, p.Y + 4), new(p.X - (w / 2), p.Y + 9), new(p.X - (w / 2), p.Y - 28)]; + Kartenpunkt schrift = Array.Find(lagen, l => Frei((l.X, l.Y, l.X + w, l.Y + Schrifthoehe))); + if (schrift == default) + { + schrift = lagen[0]; + } + + belegt.Add((schrift.X, schrift.Y, schrift.X + w, schrift.Y + Schrifthoehe)); + hallen.Add(new Kartenhalle(ort, p, schrift)); + } + + var stadtnamen = new List(); + foreach (Stadt stadt in staedte) + { + Kartenpunkt p = projektion.Bild(stadt.Lage); + double w = (stadt.Name.Length * 6.3) + 10; + (double L, double O, double R, double U) box = (p.X - 4, p.Y - 9, p.X + w, p.Y + 6); + if (p.X < 20 || p.X + w > Breite - 10 || p.Y < 15 || p.Y > projektion.Hoehe - 28 || !Frei(box)) + { + continue; + } + + belegt.Add((box.L - 25, box.O - 12, box.R + 25, box.U + 12)); + stadtnamen.Add(new Kartenbeschriftung(stadt.Name, p, new Kartenpunkt(p.X + 6, p.Y - 8))); + if (stadtnamen.Count >= HoechstensStaedte) + { + break; + } + } + + List linien = Nebeneinander(fahrten.Where(f => punkte.ContainsKey(f.Von) && punkte.ContainsKey(f.Nach)).ToList(), punkte); + double kmJePunkt = projektion.Massstab * Gradmass / 111.2; + int massstab = new[] { 200, 100, 50, 20, 10, 5 }.FirstOrDefault(km => km * kmJePunkt <= 140, 5); + return new Kartenbild(Breite, projektion.Hoehe, flaechen, landnamen, stadtnamen, hallen, linien, massstab, massstab * kmJePunkt); + } + + /// + /// Fahrten zwischen denselben beiden Hallen (in beide Richtungen) bekommen einen seitlichen Versatz, damit ihre Linien + /// nebeneinander liegen statt übereinander. + /// + private static List Nebeneinander(List<(string Von, string Nach, bool Kraeftig, bool Einzel)> fahrten, Dictionary punkte) + { + var ergebnis = new List(); + foreach (IGrouping<(string, string), (string Von, string Nach, bool Kraeftig, bool Einzel)> gruppe in fahrten + .GroupBy(f => string.CompareOrdinal(f.Von, f.Nach) <= 0 ? (f.Von, f.Nach) : (f.Nach, f.Von))) + { + List<(string Von, string Nach, bool Kraeftig, bool Einzel)> liste = gruppe.ToList(); + for (int i = 0; i < liste.Count; i++) + { + (string von, string nach, bool kraeftig, bool einzel) = liste[i]; + + // Versatz gilt in Richtung der Gruppe (erster Name zum zweiten); in Gegenrichtung mit umgekehrtem Vorzeichen. + double versatz = i - ((liste.Count - 1) / 2.0); + if (von != gruppe.Key.Item1) + { + versatz = -versatz; + } + + ergebnis.Add(new Kartenfahrt(punkte[von], punkte[nach], kraeftig, einzel) { Versatz = versatz, Mehrfach = liste.Count > 1 }); + } + } + + return ergebnis; + } + + /// Gleichabstandsprojektion um die mittlere Breite der Spielorte, auf den Ausschnitt skaliert. + private sealed class Projektion + { + private readonly double faktorLaenge; + private readonly double links; + private readonly double oben; + + public Projektion(List orte) + { + double mitteBreite = orte.Average(o => o.Breite); + faktorLaenge = Math.Cos(mitteBreite * Math.PI / 180) * Gradmass; + double x0 = orte.Min(o => o.Laenge * faktorLaenge); + double x1 = orte.Max(o => o.Laenge * faktorLaenge); + double y0 = orte.Min(o => -o.Breite * Gradmass); + double y1 = orte.Max(o => -o.Breite * Gradmass); + + // Rand im Verhältnis zur Ausdehnung, Mindestausschnitt etwa 25 × 30 km, damit auch Stadtklassen (Hamburg: Hallen in + // rund 25 km) groß genug erscheinen; bisher mindestens rund 80 × 90 km und je Seite gut 8 km fester Rand (Peter, 14.10.2026). + double randX = Math.Max(((x1 - x0) * 0.25) + 4, (40 - (x1 - x0)) / 2); + double randY = Math.Max(((y1 - y0) * 0.25) + 4, (27 - (y1 - y0)) / 2); + (x0, x1, y0, y1) = (x0 - randX, x1 + randX, y0 - randY, y1 + randY); + + // Seitenverhältnis zwischen 0,55 und 1,0: die kürzere Seite wird verlängert. + double w = x1 - x0; + double h = y1 - y0; + if (h < 0.55 * w) + { + (y0, y1) = (y0 - (((0.55 * w) - h) / 2), y1 + (((0.55 * w) - h) / 2)); + } + else if (h > w) + { + (x0, x1) = (x0 - ((h - w) / 2), x1 + ((h - w) / 2)); + } + + links = x0; + oben = y0; + Massstab = Breite / (x1 - x0); + Hoehe = (y1 - y0) * Massstab; + } + + public double Massstab { get; } + + public double Hoehe { get; } + + public Kartenpunkt Bild(Koordinate k) => + new(((k.Laenge * faktorLaenge) - links) * Massstab, ((-k.Breite * Gradmass) - oben) * Massstab); + + public bool Sichtbar(IReadOnlyList ring) => + ring.Any(p => p.X > -Breite && p.X < 2 * Breite && p.Y > -Hoehe && p.Y < 2 * Hoehe); + } +} diff --git a/src/AndiGenerator.Presentation/Kartenbeschriftung.cs b/src/AndiGenerator.Presentation/Kartenbeschriftung.cs new file mode 100644 index 0000000..cde07d8 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenbeschriftung.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 Text im Kartenbild (Stadt oder Bundesland). +/// Der Text. +/// Bei Städten der Ort (Marke), bei Ländern die Mitte des Textes. +/// Linke obere Ecke des Textes. +public sealed record Kartenbeschriftung(string Text, Kartenpunkt Punkt, Kartenpunkt Schrift); diff --git a/src/AndiGenerator.Presentation/Kartenbild.cs b/src/AndiGenerator.Presentation/Kartenbild.cs new file mode 100644 index 0000000..4a6b4a2 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenbild.cs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// +/// Fertig berechnete Karte der Spielorte (nicht im Original): Umrisse der Bundesländer, einige Städte, Hallen und Fahrten, +/// in Bildpunkten eines Bilds von × . Die Oberfläche skaliert nur noch. +/// +/// Breite des Bilds. +/// Höhe des Bilds. +/// Umrisse der Bundesländer als geschlossene Linienzüge. +/// Namen der sichtbaren Bundesländer. +/// Einige Städte zur Orientierung. +/// Die Spielorte mit Beschriftung. +/// Die Fahrten. +/// Länge des Maßstabs in Kilometern. +/// Länge des Maßstabs in Bildpunkten. +public sealed record Kartenbild( + double Breite, + double Hoehe, + IReadOnlyList> Flaechen, + IReadOnlyList Laender, + IReadOnlyList Staedte, + IReadOnlyList Hallen, + IReadOnlyList Fahrten, + int MassstabKm, + double MassstabBreite) +{ + /// Holt ein leeres Bild (keine Spielorte bekannt). + public static Kartenbild Leer { get; } = new(600, 300, [], [], [], [], [], 0, 0); +} diff --git a/src/AndiGenerator.Presentation/Kartenfahrt.cs b/src/AndiGenerator.Presentation/Kartenfahrt.cs new file mode 100644 index 0000000..c31dbee --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenfahrt.cs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Eine Fahrt zu einem Kompaktspieltag im Kartenbild. +/// Halle des Gastes. +/// Halle des Ausrichters. +/// Teil der Auswahl (sonst blass, wenn alle Fahrten gezeigt werden). +/// Fahrt zu einer Einzelbegegnung (gelb wie in der Planmatrix). +public sealed record Kartenfahrt(Kartenpunkt Von, Kartenpunkt Nach, bool Kraeftig, bool Einzel) +{ + /// + /// Holt den seitlichen Versatz der Linie in Linienbreiten (quer zur Fahrtrichtung, positiv = links): Mehrere Fahrten + /// zwischen denselben beiden Hallen liegen so sichtbar nebeneinander; 0 = einzige Fahrt. + /// + public double Versatz { get; init; } + + /// Holt einen Wert, der angibt, ob es zwischen denselben Hallen mehrere Fahrten gibt (dann eigener Zielpunkt je Linie). + public bool Mehrfach { get; init; } +} diff --git a/src/AndiGenerator.Presentation/Kartenhalle.cs b/src/AndiGenerator.Presentation/Kartenhalle.cs new file mode 100644 index 0000000..f3832d4 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenhalle.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 Spielort im Kartenbild. +/// Der Spielort. +/// Lage im Bild. +/// Linke obere Ecke der Beschriftung. +public sealed record Kartenhalle(Kartenort Ort, Kartenpunkt Punkt, Kartenpunkt Schrift); diff --git a/src/AndiGenerator.Presentation/Kartenort.cs b/src/AndiGenerator.Presentation/Kartenort.cs new file mode 100644 index 0000000..a67900a --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenort.cs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Domain.Geo; + +namespace AndiGenerator.Presentation; + +/// Ein Spielort für die Karte. +/// Schlüssel des Spielorts (Name der Mannschaft), auf den sich die Fahrten beziehen. +/// Beschriftung auf der Karte (voller Mannschaftsname). +/// Lage der Halle. +/// Ausrichter in der Auswahl (größer, grün). +/// Nicht Teil der Auswahl (blass). +/// Text für den Tooltip, z. B. Halle und Ort. +public sealed record Kartenort(string Name, string Beschriftung, Koordinate Lage, bool Hervorgehoben, bool Gedimmt, string Hinweis); diff --git a/src/AndiGenerator.Presentation/Kartenpunkt.cs b/src/AndiGenerator.Presentation/Kartenpunkt.cs new file mode 100644 index 0000000..e44a8c8 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenpunkt.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; + +/// Punkt auf der Karte in Bildpunkten des Kartenbilds (Ursprung oben links). +/// Abstand vom linken Rand. +/// Abstand vom oberen Rand. +public readonly record struct Kartenpunkt(double X, double Y); diff --git a/src/AndiGenerator.Presentation/Kartenzeichner.cs b/src/AndiGenerator.Presentation/Kartenzeichner.cs new file mode 100644 index 0000000..522f56c --- /dev/null +++ b/src/AndiGenerator.Presentation/Kartenzeichner.cs @@ -0,0 +1,479 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Rendering; + +namespace AndiGenerator.Presentation; + +/// +/// Zeichnet ein auf eine Zeichenfläche (nicht im Original): Bundesländer, Städte, Fahrten und +/// Spielorte – dieselbe Zeichnung für die Ansicht „Kompaktspieltage“ und für den Ausdruck (Peter, 14.10.2026). +/// +/// Die Zeichenfläche. +public sealed class Kartenzeichner(IZeichenflaeche f) +{ + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + /// Weiß der Länder, der Ränder um Hallen und Pfeile und des Hofs um die Namen (Ton „Flaeche“). + private static readonly Farbe Hell = Farbe.Weiss; + + /// Grenzen und Namen der Bundesländer (Ton „Rand“ der Oberfläche). + private static readonly Farbe Grenze = new(0xD6, 0xD3, 0xCB); + + /// Gelb der Einzelbegegnungen, wie in der Planmatrix. + private static readonly Farbe EinzelGelb = new(0xEA, 0xB3, 0x08); + + /// Holt den Hintergrund der Karte (Ton „Schwebe“ der Oberfläche). + public static Farbe Hintergrund { get; } = new(0xF0, 0xEF, 0xEA); + + /// Zeichnet die Karte in die angegebene Größe (die Breite bestimmt den Maßstab). + /// Das Bild. + /// Breite der Zeichnung. + /// Höhe der Zeichnung (für den Maßstab unten). + public void Zeichnen(Kartenbild bild, double breite, double hoehe) + { + ArgumentNullException.ThrowIfNull(bild); + if (bild.Breite <= 0 || breite <= 0) + { + return; + } + + double s = breite / bild.Breite; + foreach (IReadOnlyList flaeche in bild.Flaechen.Where(x => x.Count > 2)) + { + f.Vieleck(flaeche.Select(p => (p.X * s, p.Y * s)).ToList(), Hell, Grenze, 1, geschlossen: true); + } + + foreach (Kartenbeschriftung name in bild.Laender) + { + string text = name.Text.ToUpper(Deutsch); + f.Text(text, (name.Punkt.X * s) - (f.TextBreite(text, 11, false) / 2), (name.Punkt.Y * s) - (11 * 1.31 / 2), 11, Grenze, false); + } + + // Namen erst hier setzen: Ihre Breite hängt von der gezeichneten Größe ab, nicht vom Maßstab der Karte. + (Dictionary namen, List staedte, Legende? legende) = Beschriften(bild, s, breite, hoehe); + foreach (Kartenbeschriftung stadt in staedte) + { + f.Rechteck((stadt.Punkt.X * s) - 2.5, (stadt.Punkt.Y * s) - 2.5, 5, 5, Farbe.Tinte2); + f.Text(stadt.Text, (stadt.Punkt.X * s) + 6, (stadt.Punkt.Y * s) - 8, 11, Farbe.Tinte2, false); + } + + // Gerade Linien; mehrere Fahrten zwischen denselben Hallen liegen nebeneinander. Kurze Fahrten, die unter den + // Hallenpunkten verschwänden, kommen erst nach den Hallen in den Vordergrund. + foreach (Kartenfahrt fahrt in bild.Fahrten.Where(x => !Kurz(x, s))) + { + if (Geteilt(bild, fahrt)) + { + HalbeFahrt(fahrt, s); + } + else + { + Fahrt(fahrt, s, umriss: false); + } + } + + foreach (Kartenhalle halle in bild.Hallen) + { + using (f.Deckkraft(halle.Ort.Gedimmt ? 0.45 : 1)) + { + HallePunkt(bild, halle, s); + Hallenname name = namen[halle]; + if (name.Leitlinie) + { + Leitlinie(halle, name, s); + } + + f.TextMitRand(name.Text, name.X + 2, name.Y, 12, Farbe.Tinte, true, Hell, 3); + } + } + + if (legende is not null) + { + LegendeZeichnen(legende); + } + + // Kurze Fahrten im Vordergrund mit hellem Umriss. + foreach (Kartenfahrt fahrt in bild.Fahrten.Where(x => Kurz(x, s))) + { + Fahrt(fahrt, s, umriss: true); + } + + // Pfeilspitzen am Rand des Ziels zeigen die Richtung der Fahrt (bei kurzen Fahrten fehlt der Platz). + foreach (Kartenfahrt fahrt in bild.Fahrten.Where(x => x.Kraeftig && !Kurz(x, s))) + { + if (Geteilt(bild, fahrt)) + { + // Mehrere Fahrten zum selben Ziel: ein gemeinsamer Pfeil, jede Fahrt ihre Hälfte in ihrer Farbe. + HalberPfeil(Linie(fahrt with { Versatz = 0 }, s), Math.Sign(fahrt.Versatz), Linienfarbe(fahrt)); + } + else + { + Pfeil(Linie(fahrt, s), Linienfarbe(fahrt)); + } + } + + if (bild.MassstabKm > 0) + { + double y = hoehe - 14; + f.Linie(14, y, 14 + (bild.MassstabBreite * s), y, Farbe.Tinte2, 2); + f.Text(bild.MassstabKm.ToString(Deutsch) + " km", 14, y - 17, 10.5, Farbe.Tinte2, false); + } + } + + /// + /// Anfang und Ende einer Fahrt in Bildpunkten, quer zur Fahrtrichtung um ihren Versatz verschoben: 2,2 Pixel je Stufe, + /// sodass mehrere Fahrten zwischen denselben Hallen lückenlos nebeneinander liegen wie ein zweifarbiger Pfeil. + /// + private static (V Von, V Nach) Linie(Kartenfahrt fahrt, double s) + { + var von = new V(fahrt.Von.X * s, fahrt.Von.Y * s); + var nach = new V(fahrt.Nach.X * s, fahrt.Nach.Y * s); + V richtung = nach - von; + double laenge = richtung.Laenge; + if (fahrt.Versatz == 0 || laenge < 1) + { + return (von, nach); + } + + V quer = new V(-richtung.Y / laenge, richtung.X / laenge) * (2.2 * fahrt.Versatz); + return (von + quer, nach + quer); + } + + /// Ob eine Fahrt so kurz ist, dass die Hallenpunkte sie verdecken würden. + private static bool Kurz(Kartenfahrt fahrt, double s) => (new V(fahrt.Nach.X - fahrt.Von.X, fahrt.Nach.Y - fahrt.Von.Y) * s).Laenge < 30; + + /// Ob eine Fahrt eine von genau zwei kräftigen Fahrten in dieselbe Richtung ist (dann ein Pfeil aus zwei Hälften). + private static bool Geteilt(Kartenbild bild, Kartenfahrt fahrt) => + fahrt.Kraeftig && bild.Fahrten.Count(x => x.Kraeftig && x.Von == fahrt.Von && x.Nach == fahrt.Nach) == 2; + + private static Farbe Linienfarbe(Kartenfahrt fahrt) => fahrt.Einzel ? EinzelGelb : Farbe.Akzent; + + private static (double X, double Y) P(V v) => (v.X, v.Y); + + /// Ein Kreisausschnitt ab einem Winkel (Grad, im Uhrzeigersinn auf dem Bildschirm) als Vieleck. + private static List<(double X, double Y)> Sektor(V mitte, double r, double anfang, double weite) + { + var punkte = new List<(double X, double Y)> { P(mitte) }; + int schritte = Math.Max(2, (int)Math.Ceiling(weite / 6)); + for (int i = 0; i <= schritte; i++) + { + double grad = (anfang + (weite * i / schritte)) * Math.PI / 180; + punkte.Add((mitte.X + (r * Math.Cos(grad)), mitte.Y + (r * Math.Sin(grad)))); + } + + return punkte; + } + + /// Die nahen Lagen eines Namens der Breite am Punkt: rechts, links, schräg darüber oder darunter, darüber, darunter. + private static (double X, double Y)[] NaheLagen(double x, double y, double w, double h) => + [ + (x + 10, y - 10), (x - 10 - w, y - 10), (x + 9, y - 20), (x + 9, y + 2), (x - 9 - w, y - 20), (x - 9 - w, y + 2), + (x - (w / 2), y + 9), (x - (w / 2), y - 9 - h), + ]; + + /// Weiter entfernte Lagen in acht Richtungen (mit Hinweislinie zum Punkt), zuerst die näheren. + private static IEnumerable<(double X, double Y)> FerneLagen(double x, double y, double w, double h) + { + foreach (double d in new[] { 28.0, 44.0, 62.0 }) + { + double e = d * 0.7; + yield return (x + d, y - (h / 2)); + yield return (x - d - w, y - (h / 2)); + yield return (x + e, y - e - h); + yield return (x + e, y + e); + yield return (x - e - w, y - e - h); + yield return (x - e - w, y + e); + yield return (x - (w / 2), y - d - h); + yield return (x - (w / 2), y + d); + } + } + + /// + /// Setzt die Namen der Hallen und Städte mit ihrer gemessenen Breite so, dass sie ganz im Bild liegen und sich nicht + /// überdecken: nah am Punkt (rechts, links, schräg, darüber, darunter), sonst weiter weg mit einer Hinweislinie. Findet + /// ein voller Mannschaftsname nirgends Platz (dichte Stadtklassen wie in Hamburg), steht am Punkt eine Nummer und der + /// Name in einer Legende in einer freien Ecke (Peter, 14.10.2026). Hallen der Auswahl zuerst, gedimmte zuletzt; Städte + /// nur, wo dann noch Platz ist. + /// + private (Dictionary Hallen, List Staedte, Legende? Legende) Beschriften(Kartenbild bild, double s, double breite, double hoehe) + { + const double H = 16; + var belegt = new List<(double L, double O, double R, double U)>(); + bool Frei((double L, double O, double R, double U) b) => + b.L >= 2 && b.O >= 2 && b.R <= breite - 2 && b.U <= hoehe - 2 && !belegt.Exists(c => b.L < c.R && b.R > c.L && b.O < c.U && b.U > c.O); + belegt.AddRange(bild.Hallen.Select(h => ((h.Punkt.X * s) - 9, (h.Punkt.Y * s) - 9, (h.Punkt.X * s) + 9, (h.Punkt.Y * s) + 9))); + if (bild.MassstabKm > 0) + { + belegt.Add((8, hoehe - 34, 24 + (bild.MassstabBreite * s), hoehe - 6)); + } + + (double X, double Y)? Erste(IEnumerable<(double X, double Y)> lagen, double w) => + lagen.Where(l => Frei((l.X, l.Y, l.X + w, l.Y + H))).Select(l => ((double X, double Y)?)l).FirstOrDefault(); + + var hallen = new Dictionary(); + var nummeriert = new List<(string Nummer, string Name)>(); + foreach (Kartenhalle halle in bild.Hallen.OrderBy(h => h.Ort.Gedimmt).ThenByDescending(h => h.Ort.Hervorgehoben)) + { + double x = halle.Punkt.X * s; + double y = halle.Punkt.Y * s; + string text = halle.Ort.Beschriftung; + double w = f.TextBreite(text, 12, true) + 6; + (double X, double Y)? lage = Erste(NaheLagen(x, y, w, H), w); + bool leitlinie = false; + if (lage is null) + { + lage = Erste(FerneLagen(x, y, w, H), w); + leitlinie = lage is not null; + } + + if (lage is null) + { + // Kein Platz für den Namen: eine Nummer am Punkt, der Name steht in der Legende. + text = (nummeriert.Count + 1).ToString(Deutsch); + nummeriert.Add((text, halle.Ort.Beschriftung)); + w = f.TextBreite(text, 12, true) + 6; + lage = Erste(NaheLagen(x, y, w, H), w) + ?? (Math.Clamp(x + 9, 2, Math.Max(2, breite - 2 - w)), Math.Clamp(y - 10, 2, Math.Max(2, hoehe - 2 - H))); + } + + (double lx, double ly) = lage.Value; + belegt.Add((lx, ly, lx + w, ly + H)); + hallen[halle] = new Hallenname(text, lx, ly, w, H, leitlinie); + } + + Legende? legende = null; + if (nummeriert.Count > 0) + { + const double Zeile = 15; + double lw = nummeriert.Max(n => f.TextBreite(n.Name, 11, false)) + 34; + double lh = (nummeriert.Count * Zeile) + 10; + (double X, double Y)[] ecken = [(6, 6), (breite - lw - 6, 6), (breite - lw - 6, hoehe - lh - 6), (6, hoehe - lh - 40)]; + int Ueberdeckt((double X, double Y) e) => belegt.Count(c => e.X < c.R && e.X + lw > c.L && e.Y < c.U && e.Y + lh > c.O); + (double ex, double ey) = ecken.MinBy(Ueberdeckt); + belegt.Add((ex, ey, ex + lw, ey + lh)); + legende = new Legende(ex, ey, lw, lh, nummeriert); + } + + var staedte = new List(); + foreach (Kartenbeschriftung stadt in bild.Staedte) + { + double x = stadt.Punkt.X * s; + double y = stadt.Punkt.Y * s; + (double L, double O, double R, double U) box = (x - 4, y - 9, x + 8 + f.TextBreite(stadt.Text, 11, false), y + 7); + if (Frei(box)) + { + belegt.Add(box); + staedte.Add(stadt); + } + } + + return (hallen, staedte, legende); + } + + /// Dünne Linie vom Rand des Hallenpunkts zur nächsten Stelle des weiter entfernten Namens. + private void Leitlinie(Kartenhalle halle, Hallenname name, double s) + { + var mitte = new V(halle.Punkt.X * s, halle.Punkt.Y * s); + var ziel = new V(Math.Clamp(mitte.X, name.X + 2, name.X + name.Breite - 2), Math.Clamp(mitte.Y, name.Y + 2, name.Y + name.Hoehe - 2)); + V r = ziel - mitte; + double laenge = r.Laenge; + if (laenge < 10) + { + return; + } + + V anfang = mitte + (r * (8 / laenge)); + f.Linie(anfang.X, anfang.Y, ziel.X, ziel.Y, Farbe.Tinte2, 0.8); + } + + /// Kasten mit den Namen der nummerierten Hallen. + private void LegendeZeichnen(Legende l) + { + f.Vieleck([(l.X, l.Y), (l.X + l.Breite, l.Y), (l.X + l.Breite, l.Y + l.Hoehe), (l.X, l.Y + l.Hoehe)], Hell, Grenze, 1, geschlossen: true); + for (int i = 0; i < l.Eintraege.Count; i++) + { + double y = l.Y + 5 + (i * 15); + (string nummer, string name) = l.Eintraege[i]; + f.Text(nummer, l.X + 8, y, 11, Farbe.Tinte, true); + f.Text(name, l.X + 28, y, 11, Farbe.Tinte, false); + } + } + + /// + /// Zeichnet den Punkt einer Halle. In einer Auswahl ist der Ausgangspunkt der Fahrten grün, ein Ziel in der Farbe seiner + /// Linie; kommen dort verschieden gefärbte Linien an (z. B. 3er-Spieltag und Einzelbegegnung), wird der Kreis im Winkel der + /// Linie in Halbkreise (bzw. Sektoren) je Linie geteilt. + /// + private void HallePunkt(Kartenbild bild, Kartenhalle halle, double s) + { + var mitte = new V(halle.Punkt.X * s, halle.Punkt.Y * s); + List ankommend = bild.Fahrten.Where(x => x.Kraeftig && x.Nach == halle.Punkt).OrderByDescending(x => x.Versatz).ToList(); + bool start = !halle.Ort.Hervorgehoben && bild.Fahrten.Any(x => x.Kraeftig && x.Von == halle.Punkt); + if (!halle.Ort.Hervorgehoben || ankommend.Count == 0) + { + f.Kreis(mitte.X, mitte.Y, start ? 8 : 5.5, start ? Farbe.Gut : Farbe.Akzent, Hell, 2); + return; + } + + const double Radius = 8; + if (ankommend.Select(x => x.Einzel).Distinct().Count() == 1) + { + f.Kreis(mitte.X, mitte.Y, Radius, ankommend[0].Einzel ? EinzelGelb : Farbe.Akzent, Hell, 2); + return; + } + + // Teilung im Winkel der Linie: die Linie mit dem größten Versatz (links der Fahrtrichtung) bekommt die linke Hälfte. + (V von, V nach) = Linie(ankommend[0], s); + double winkel = Math.Atan2(nach.Y - von.Y, nach.X - von.X) * 180 / Math.PI; + double teil = 360.0 / ankommend.Count; + for (int i = 0; i < ankommend.Count; i++) + { + f.Vieleck(Sektor(mitte, Radius, winkel + (i * teil), teil), Linienfarbe(ankommend[i]), null, 0, geschlossen: true); + } + + // Dünne helle Linie an den Grenzen der Hälften (bzw. Ausschnitte). + for (int i = 0; i < ankommend.Count; i++) + { + double grad = (winkel + (i * teil)) * Math.PI / 180; + f.Linie(mitte.X, mitte.Y, mitte.X + (Radius * Math.Cos(grad)), mitte.Y + (Radius * Math.Sin(grad)), Hell, 1); + } + + f.Kreis(mitte.X, mitte.Y, Radius, null, Hell, 2); + } + + /// + /// Zeichnet eine Fahrt. Kurze Fahrten (über den Hallenpunkten) als Pfeil vom Rand des Ausgangspunkts zur Mitte des Ziels + /// mit seitlich hellem Umriss. + /// + private void Fahrt(Kartenfahrt fahrt, double s, bool umriss) + { + double breite = fahrt.Kraeftig ? 2.5 : 1.5; + (V von, V nach) = Linie(fahrt, s); + Farbe farbe = Linienfarbe(fahrt); + using (f.Deckkraft(fahrt.Kraeftig ? 1 : 0.35)) + { + V r = nach - von; + double laenge = r.Laenge; + if (!umriss) + { + // Mit Pfeil (in der Auswahl) endet die Linie am Fuß der Spitze, damit Spitze bzw. Pfeilhälften sauber für sich + // stehen. + V ende = fahrt.Kraeftig && laenge > 30 ? nach - (r * (15.5 / laenge)) : nach; + f.Linie(von.X, von.Y, ende.X, ende.Y, farbe, breite); + return; + } + + if (laenge < 1) + { + return; + } + + V t = r / laenge; + var quer = t.Links; + + // Vom Rand der farbigen Fläche des Ausgangspunkts (Radius 7 innerhalb des hellen Rands) bis knapp vor die Mitte des + // Ziels; heller Umriss seitlich an Schaft und Spitze und ein minimaler am Fuß, nicht über die Spitze hinaus. + double ab = Math.Min(7, laenge / 2); + V anfang = von + (t * ab); + double kopf = Math.Min(7, (laenge - ab - 1.5) * 0.6); + V spitze = nach - (t * 1.5); + V basis = spitze - (t * kopf); + V links = basis + (quer * 3.3); + V rechts = basis - (quer * 3.3); + V fuss = anfang - t; + f.Linie(fuss.X, fuss.Y, basis.X, basis.Y, Hell, breite + 2); + f.Vieleck([P(links), P(spitze), P(rechts)], null, Hell, 2, geschlossen: false); + f.Linie(anfang.X, anfang.Y, basis.X, basis.Y, farbe, breite); + f.Vieleck([P(spitze), P(links), P(rechts)], farbe, null, 0, geschlossen: true); + } + } + + /// Kleine Pfeilspitze am Rand des Zielpunkts, in Richtung der Linie. + private void Pfeil((V Von, V Nach) linie, Farbe farbe) + { + V r = linie.Nach - linie.Von; + double laenge = r.Laenge; + if (laenge < 1) + { + return; + } + + V t = r / laenge; + var quer = t.Links; + V spitze = linie.Nach - (t * 9.5); + V basis = spitze - (t * 7); + f.Vieleck([P(spitze), P(basis + (quer * 3.8)), P(basis - (quer * 3.8))], farbe, null, 0, geschlossen: true); + } + + /// + /// Die Hälfte einer Pfeilspitze auf der Mittelachse: links ( = 1) oder rechts (-1) der + /// Fahrtrichtung, minimal über die Achse hinaus, damit zwischen den Hälften keine Fuge zu sehen ist. + /// + private void HalberPfeil((V Von, V Nach) achse, int seite, Farbe farbe) + { + V r = achse.Nach - achse.Von; + double laenge = r.Laenge; + if (laenge < 1 || seite == 0) + { + return; + } + + V t = r / laenge; + V quer = t.Links * seite; + V spitze = achse.Nach - (t * 9.5); + V basis = spitze - (t * 7); + f.Vieleck([P(spitze - (quer * 0.3)), P(basis + (quer * 3.8)), P(basis - (quer * 0.3))], farbe, null, 0, geschlossen: true); + } + + /// + /// Eine Hälfte des zweifarbigen Pfeils: halbe Breite, auf ihrer Seite der Mittelachse (minimal überlappend), sodass beide + /// zusammen nicht dicker erscheinen als ein einfacher Pfeil; endet am Fuß der Spitze. + /// + private void HalbeFahrt(Kartenfahrt fahrt, double s) + { + (V von, V nach) = Linie(fahrt with { Versatz = 0 }, s); + V r = nach - von; + double l = r.Laenge; + if (l < 1) + { + return; + } + + V t = r / l; + V quer = t.Links * (Math.Sign(fahrt.Versatz) * 0.55); + V ende = l > 30 ? nach - (t * 15.5) : nach; + f.Linie(von.X + quer.X, von.Y + quer.Y, ende.X + quer.X, ende.Y + quer.Y, Linienfarbe(fahrt), 1.4); + } + + /// Ein Punkt bzw. Richtungsvektor in Bildpunkten. + private readonly record struct V(double X, double Y) + { + public double Laenge => Math.Sqrt((X * X) + (Y * Y)); + + /// Holt den Vektor um 90° nach links gedreht (quer zur Richtung). + public V Links => Gedreht(this); + + public static V operator +(V a, V b) => new(a.X + b.X, a.Y + b.Y); + + public static V operator -(V a, V b) => new(a.X - b.X, a.Y - b.Y); + + public static V operator *(V a, double k) => new(a.X * k, a.Y * k); + + public static V operator /(V a, double k) => new(a.X / k, a.Y / k); + + private static V Gedreht(V v) + { + double quer = -v.Y; + double laengs = v.X; + return new V(quer, laengs); + } + } + + /// Gesetzter Name einer Halle: Text (Name oder Nummer), Lage und Größe, ob mit Hinweislinie. + private sealed record Hallenname(string Text, double X, double Y, double Breite, double Hoehe, bool Leitlinie); + + /// Legende der nummerierten Hallen: Lage, Größe und je Eintrag Nummer und Name. + private sealed record Legende(double X, double Y, double Breite, double Hoehe, IReadOnlyList<(string Nummer, string Name)> Eintraege); +} diff --git a/src/AndiGenerator.Presentation/Kernanzeige.cs b/src/AndiGenerator.Presentation/Kernanzeige.cs index 956afa7..ae3ee95 100644 --- a/src/AndiGenerator.Presentation/Kernanzeige.cs +++ b/src/AndiGenerator.Presentation/Kernanzeige.cs @@ -16,9 +16,6 @@ public sealed class Kernanzeige : ObservableObject /// 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; @@ -52,18 +49,9 @@ private set public double Hoehe { get => hoehe; - private set - { - if (SetProperty(ref hoehe, value)) - { - OnPropertyChanged(nameof(HoeheGross)); - } - } + private set => SetProperty(ref hoehe, value); } - /// 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 { diff --git a/src/AndiGenerator.Presentation/Kompaktbezug.cs b/src/AndiGenerator.Presentation/Kompaktbezug.cs new file mode 100644 index 0000000..99cdf5d --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktbezug.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Bezug der Punkte eines Plans aus Kompaktspieltagen. +/// Kilometer des kürzesten Plans (nur aus 4er-Spieltagen, wenn es solche gibt). +/// Kleinster Unterschied der Kilometer zwischen den Mannschaften unter diesen Plänen. +/// +/// Es gibt Pläne nur aus 4er-Spieltagen: Dann kosten 3er-Spieltage Punkte. Sonst (z. B. bei 6 bis 8 Mannschaften) sind sie +/// nötig und kosten nichts. +/// +public sealed record Kompaktbezug(double Km, double Spanne, bool MitVierern); diff --git a/src/AndiGenerator.Presentation/Kompaktdruck.cs b/src/AndiGenerator.Presentation/Kompaktdruck.cs new file mode 100644 index 0000000..e546b25 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktdruck.cs @@ -0,0 +1,510 @@ +// 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.Domain.Stammdaten; +using AndiGenerator.Engine.Turniere; +using AndiGenerator.Rendering; + +namespace AndiGenerator.Presentation; + +/// +/// Übersicht der Kompaktspieltage für die Vereine im Ausdruck (nicht im Original; Peter, 14.10.2026), je Teil eine neue Seite: +/// Klassenname und Raster „Meldungen und Plan“ mit Legende; die Karte aller Fahrten; je Spieltag Ausrichter, Halle und +/// Begegnungen mit Uhrzeit; je Mannschaft genau eine Seite mit der Karte ihrer Fahrten und ihren Spieltagen. +/// +public static class Kompaktdruck +{ + /// Breite der Karte aller Fahrten (volle Seitenbreite im Hochformat). + private const double Kartenbreite = 520; + + /// Breite der Karte je Mannschaft (Karte und Spieltage passen auf eine Seite). + private const double Mannschaftskarte = 440; + + /// Höchste Höhe der Karte je Mannschaft; eine höhere Karte wird schmaler gezeichnet. + private const double Mannschaftskartenhoehe = 340; + + /// Abstand zwischen den Teilen einer Seite. + private const double Luft = 18; + + // Raster wie in der Ansicht: Datumsspalte, Felder 30 × 22 mit 1 Abstand, Kopf mit senkrechten Namen. + private const double Datumsbreite = 64; + private const double Spaltenbreite = 32; + private const double Zeilenhoehe = 24; + private const double Legendenhoehe = 84; + + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + private static readonly Farbe HeimFarbe = new(0x8F, 0xCB, 0x9E); + private static readonly Farbe AuswaertsFarbe = new(0xCA, 0xDD, 0xF7); + private static readonly Farbe Zweiter = new(0xC2, 0x41, 0x0C); + private static readonly Farbe Dritter = new(0x7E, 0x22, 0xCE); + private static readonly Farbe EinzelGelb = new(0xEA, 0xB3, 0x08); + private static readonly Farbe SpitzeRing = new(0x11, 0x18, 0x27); + private static readonly Farbe KellerRing = new(0x1D, 0x4E, 0xD8); + private static readonly Farbe Randfarbe = new(0xD6, 0xD3, 0xCB); + + /// + /// Baut die Abschnitte (jeder beginnt auf einer neuen Seite): „Meldungen und Plan“, „Alle Fahrten“, „Kompaktspieltage“ und je + /// Mannschaft einen. + /// + /// Die Auswertung des Plans. + /// Adresse der Halle zu Mannschaft und Spiellokal (leer = Standardlokal); leer, wenn unbekannt. + /// Name der Klasse über dem Raster. + /// Spitzen- und Abstiegsspieltag des Plans (Ringe im Raster) oder null. + /// Die Abschnitte. + public static IReadOnlyList Abschnitte( + Kompaktauswertung auswertung, + Func halle, + string klasse, + (Kompaktspieltag? Spitze, Kompaktspieltag? Keller) gruppentage) + { + ArgumentNullException.ThrowIfNull(auswertung); + ArgumentNullException.ThrowIfNull(halle); + if (auswertung.Spieltage.Count == 0) + { + return [new Abschnitt("Kompaktspieltage", [new Textzeile("Der Plan hat keine Kompaktspieltage.", Druckbericht.Groesse, Farbe.Grau)])]; + } + + var abschnitte = new List + { + new("Meldungen und Plan", [Raster(auswertung, gruppentage, klasse)]), + new("Alle Fahrten", [Karte(Kompaktfahrtenkarte.Bauen(auswertung, auswertung.Spieltage, null, alleZeigen: true), Kartenbreite, 0), KmJeMannschaft(auswertung)]), + Spieltage(auswertung, halle), + }; + abschnitte.AddRange(Mannschaften(auswertung, halle)); + return abschnitte; + } + + /// + /// Das Raster „Meldungen und Plan“ mit Legende als Grafik, gezeichnet wie in der Ansicht; auf Wunsch mit dem Klassennamen + /// darüber. Der Name gehört zur Grafik, damit ein hohes Raster (viele Tage) als Ganzes auf die erste Seite verkleinert + /// wird, statt allein auf die nächste Seite zu rutschen und den Namen auf einer sonst leeren Seite zu lassen. + /// + /// Die Auswertung. + /// Spitzen- und Abstiegsspieltag (Ringe). + /// Name der Klasse über dem Raster oder null. + /// Die Grafik. + public static Grafik Raster(Kompaktauswertung a, (Kompaktspieltag? Spitze, Kompaktspieltag? Keller) gruppentage, string? klasse = null) + { + ArgumentNullException.ThrowIfNull(a); + List zeilen = a.Tage.Select(t => KompaktspieltageAnsichtViewModel.Rasterzeile(a, t, false, null, false, null, gruppentage)).ToList(); + double titelgroesse = Druckbericht.Groesse + 5; + double oben = klasse is null ? 0 : (titelgroesse * Seitenumbruch.Zeilenfaktor) + Luft; + double breite = Math.Max(Datumsbreite + (a.Mannschaften.Count * Spaltenbreite), 470); + double kopf = Kopfhoehe(a.Mannschaften.Select(m => m.Name)); + double hoehe = oben + kopf + (zeilen.Count * Zeilenhoehe) + Legendenhoehe; + return new Grafik( + breite, + hoehe, + f => + { + if (klasse is not null) + { + f.Text(klasse, 0, 0, titelgroesse, Farbe.Tinte, true); + } + + // Das Raster mittig über der (breiteren) Legende, beides zusammen mittig auf der Seite. + double links = Math.Max(0, (breite - Datumsbreite - (a.Mannschaften.Count * Spaltenbreite)) / 2); + RasterZeichnen(f, a, zeilen, (links, oben), kopf); + }) + { + Zentriert = true, + }; + } + + /// + /// Höhe des Rasterkopfs mit den senkrechten Mannschaftsnamen (12 pt), in der Ansicht und im Ausdruck gleich: Platz für die + /// längste Zeile (lange Namen stehen in zwei Zeilen, siehe ), mindestens 104. Geschätzt mit 6,8 je + /// Zeichen, weil die Höhe vor dem Zeichnen feststehen muss. + /// + /// Die Mannschaftsnamen. + /// Die Höhe. + public static double Kopfhoehe(IEnumerable namen) + { + ArgumentNullException.ThrowIfNull(namen); + int laengste = namen.SelectMany(Kopfzeilen).Select(z => z.Length).DefaultIfEmpty(0).Max(); + return Math.Max(104, Math.Ceiling(12 + (laengste * 6.8))); + } + + /// + /// Ein Mannschaftsname für den senkrechten Rasterkopf: bis 15 Zeichen eine Zeile, sonst zwei – geteilt nach „/“, „-“ oder + /// an einem Leerzeichen, und zwar dort, wo die längere Hälfte am kürzesten ist (Peter, 14.10.2026), z. B. + /// „TTG Mündersbach/“ und „Höchstenbach“. Ohne solche Stelle bleibt es bei einer Zeile. + /// + /// Der Mannschaftsname. + /// Eine oder zwei Zeilen. + public static IReadOnlyList Kopfzeilen(string name) + { + ArgumentNullException.ThrowIfNull(name); + const int Einzeilig = 15; + if (name.Length <= Einzeilig) + { + return [name]; + } + + // Teilstelle i: „/“ und „-“ bleiben am Ende der ersten Zeile, ein Leerzeichen entfällt. + var stellen = Enumerable.Range(1, name.Length - 2) + .Where(i => name[i] is '/' or '-' or ' ') + .Select(i => name[i] == ' ' ? (Erste: name[..i], Zweite: name[(i + 1)..]) : (Erste: name[..(i + 1)], Zweite: name[(i + 1)..])) + .Where(t => t.Erste.Trim().Length > 0 && t.Zweite.Trim().Length > 0) + .ToList(); + if (stellen.Count == 0) + { + return [name]; + } + + (string erste, string zweite) = stellen.MinBy(t => Math.Max(t.Erste.Length, t.Zweite.Length)); + return [erste.TrimEnd(), zweite.TrimStart()]; + } + + /// „km je Mannschaft“ wie in der Ansicht: je Mannschaft Ausrichtungen, ein Balken und die km, die weiteste zuerst. + /// Die Auswertung. + /// Die Grafik. + public static Grafik KmJeMannschaft(Kompaktauswertung a) + { + ArgumentNullException.ThrowIfNull(a); + const double Zeile = 20; + const double Kopf = 30; + List liste = a.Mannschaften.OrderByDescending(m => m.Km).ToList(); + double weiteste = Math.Max(1, liste.Select(m => m.Km).DefaultIfEmpty(0).Max()); + return new Grafik( + Kartenbreite, + Kopf + (liste.Count * Zeile), + f => + { + f.Text("km je Mannschaft", 0, 0, 13, Farbe.Tinte, true); + const string Hinweis = "Luftlinie, hin und zurück"; + f.Text(Hinweis, Kartenbreite - f.TextBreite(Hinweis, 10, false), 3, 10, Farbe.Tinte2, false); + static string Ausrichter(Kompaktmannschaft m) => m.EinzelHeim > 0 + ? string.Create(Deutsch, $"{m.Ausgerichtet}× Ausrichter, {m.EinzelHeim}× Einzel zuhause") + : string.Create(Deutsch, $"{m.Ausgerichtet}× Ausrichter"); + + // Spalten nach den gemessenen Namen (volle Mannschaftsnamen sind unterschiedlich lang), Balken bis vor die km. + double xAusrichter = Math.Max(140, liste.Select(m => f.TextBreite(m.Name, 11, false)).DefaultIfEmpty(0).Max() + 12); + double xBalken = Math.Max(350, xAusrichter + liste.Select(m => f.TextBreite(Ausrichter(m), 10, false)).DefaultIfEmpty(0).Max() + 12); + double balken = Math.Max(30, Math.Min(120, Kartenbreite - 50 - xBalken)); + for (int i = 0; i < liste.Count; i++) + { + Kompaktmannschaft m = liste[i]; + double y = Kopf + (i * Zeile); + string km = m.Km.ToString("N0", Deutsch); + f.Text(m.Name, 0, y, 11, Farbe.Tinte, false); + f.Text(Ausrichter(m), xAusrichter, y + 1, 10, Farbe.Tinte2, false); + f.Rechteck(xBalken, y + 4, balken * m.Km / weiteste, 7, Farbe.Akzent); + f.Text(km, Kartenbreite - f.TextBreite(km, 11, false), y, 11, Farbe.Tinte, false); + } + }, + Luft); + } + + /// Eine Karte als Grafik: Hintergrund, auf die Fläche beschnitten, darin der . + /// Das Kartenbild. + /// Breite der Grafik. + /// Abstand davor. + /// Die Grafik. + public static Grafik Karte(Kartenbild bild, double breite, double abstand) + { + ArgumentNullException.ThrowIfNull(bild); + double hoehe = bild.Breite <= 0 ? 0 : breite * bild.Hoehe / bild.Breite; + return new Grafik( + breite, + hoehe, + f => + { + using (f.Begrenzen(0, 0, breite, hoehe)) + { + f.Rechteck(0, 0, breite, hoehe, Kartenzeichner.Hintergrund); + new Kartenzeichner(f).Zeichnen(bild, breite, hoehe); + } + }, + abstand); + } + + /// Kopfzeile eines Spieltags, z. B. „So. 13.09.2026 · 4er-Spieltag · Ausrichter: TTC A“. + /// Der Spieltag. + /// Der Text. + public static string Kopf(Kompaktspieltag tag) + { + ArgumentNullException.ThrowIfNull(tag); + string datum = tag.Tag.ToString("ddd dd.MM.yyyy", Deutsch); + string ausrichter = tag.Ausrichter ?? "kein passender Ausrichter"; + return tag.Form switch + { + Turnierform.Vierer => $"{datum} · 4er-Spieltag · Ausrichter: {ausrichter}", + Turnierform.Dreier => $"{datum} · 3er-Spieltag · Ausrichter: {ausrichter}", + Turnierform.Einzel => $"{datum} · Einzelbegegnung · Heim: {ausrichter}", + _ => $"{datum} · Spieltag · Ausrichter: {ausrichter}", + }; + } + + /// Die Halle eines Spieltags: die des Ausrichters an diesem Tag, sonst seine Standardhalle. + /// Die Auswertung. + /// Der Spieltag. + /// Adresse zu Mannschaft und Spiellokal. + /// Die Adresse oder leer. + public static string Halle(Kompaktauswertung a, Kompaktspieltag tag, Func halle) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(halle); + if (tag.Ausrichter is not string ausrichter) + { + return string.Empty; + } + + string lokal = tag.Spiele.Where(s => s.Heim == ausrichter).Select(s => s.Spiellokal).FirstOrDefault(l => l.Length > 0) ?? string.Empty; + string adresse = halle(ausrichter, lokal); + return adresse.Length > 0 ? adresse : a.Mannschaften.FirstOrDefault(m => m.Name == ausrichter)?.Halle ?? string.Empty; + } + + private static void RasterZeichnen(IZeichenflaeche f, Kompaktauswertung a, List zeilen, (double Links, double Oben) lage, double kopf) + { + (double links, double oben) = lage; + for (int j = 0; j < a.Mannschaften.Count; j++) + { + // Eine Zeile mittig in der Spalte; zwei Zeilen nebeneinander (die erste links), zusammen mittig. + IReadOnlyList kopfzeilen = Kopfzeilen(a.Mannschaften[j].Name); + double mitte = links + Datumsbreite + (j * Spaltenbreite) + (Spaltenbreite / 2); + double x = mitte - 8 - ((kopfzeilen.Count - 1) * 7); + foreach (string zeile in kopfzeilen) + { + f.TextSenkrecht(zeile, x, oben + kopf - 4, 12, Farbe.Tinte2); + x += 14; + } + } + + for (int i = 0; i < zeilen.Count; i++) + { + double y = oben + kopf + (i * Zeilenhoehe); + f.Text(zeilen[i].Datum, links + Datumsbreite - 8 - f.TextBreite(zeilen[i].Datum, 11, false), y + 4, 11, Farbe.Tinte2, false); + for (int j = 0; j < zeilen[i].Zellen.Count; j++) + { + Feld(f, zeilen[i].Zellen[j], links + Datumsbreite + (j * Spaltenbreite) + 1, y + 1); + } + } + + Legende(f, oben + kopf + (zeilen.Count * Zeilenhoehe) + 12); + } + + /// Ein Feld des Rasters: Meldung als Farbe, Ring für Spitzen- und Abstiegsspieltag, ★ bzw. ● bzw. ?. + private static void Feld(IZeichenflaeche f, Kompaktzelle z, double x, double y) + { + const double B = 30; + const double H = 22; + if (z.IstHeim || z.IstAuswaerts) + { + f.Rechteck(x, y, B, H, z.IstHeim ? HeimFarbe : AuswaertsFarbe); + } + else if (z.IstBeides) + { + f.Vieleck([(x, y), (x + B, y), (x, y + H)], HeimFarbe, null, 0, geschlossen: true); + f.Vieleck([(x + B, y), (x + B, y + H), (x, y + H)], AuswaertsFarbe, null, 0, geschlossen: true); + } + + if (z.IstUnbekannt || z.Verstoss) + { + f.Vieleck([(x, y), (x + B, y), (x + B, y + H), (x, y + H)], null, z.Verstoss ? Farbe.Schlecht : Randfarbe, z.Verstoss ? 2 : 1, geschlossen: true); + } + + double mx = x + (B / 2); + double my = y + (H / 2); + if (z.Spitzenspieltag || z.Abstiegsspieltag) + { + f.Kreis(mx, my, 8.25, null, z.Spitzenspieltag ? SpitzeRing : KellerRing, 1.5); + } + + Farbe zeichen = Zeichenfarbe(z); + if (z.IstAusrichter) + { + Stern(f, mx, my, 5.5, zeichen); + } + else if (z.IstGast) + { + f.Kreis(mx, my, 4, zeichen, null, 0); + } + else if (z.IstUnbekanntZeichen) + { + f.Text("?", mx - (f.TextBreite("?", 13, false) / 2), my - 8.5, 13, Farbe.Tinte, false); + } + } + + private static Farbe Zeichenfarbe(Kompaktzelle z) + { + if (z.Einzel) + { + return EinzelGelb; + } + + if (z.DritterSpieltag) + { + return Dritter; + } + + return z.ZweiterSpieltag ? Zweiter : Farbe.Tinte; + } + + /// Fünfzackiger Stern um einen Mittelpunkt (wie ★). + private static void Stern(IZeichenflaeche f, double mx, double my, double r, Farbe farbe) + { + var punkte = new List<(double X, double Y)>(); + for (int i = 0; i < 10; i++) + { + double radius = i % 2 == 0 ? r : r * 0.42; + double winkel = (-90 + (i * 36)) * Math.PI / 180; + punkte.Add((mx + (radius * Math.Cos(winkel)), my + 0.6 + (radius * Math.Sin(winkel)))); + } + + f.Vieleck(punkte, farbe, null, 0, geschlossen: true); + } + + /// Legende unter dem Raster, wie in der Ansicht. + private static void Legende(IZeichenflaeche f, double y) + { + const double G = 10; + double x = 0; + void Eintrag(Action zeichen, string text, double breite) + { + zeichen(x, y); + f.Text(text, x + 22, y - 1, G, Farbe.Tinte2, false); + x += 22 + f.TextBreite(text, G, false) + breite; + } + + Eintrag((ex, ey) => f.Rechteck(ex, ey, 16, 12, HeimFarbe), "Heim möglich", 14); + Eintrag((ex, ey) => f.Rechteck(ex, ey, 16, 12, AuswaertsFarbe), "auswärts möglich", 14); + Eintrag( + (ex, ey) => + { + f.Vieleck([(ex, ey), (ex + 16, ey), (ex, ey + 12)], HeimFarbe, null, 0, geschlossen: true); + f.Vieleck([(ex + 16, ey), (ex + 16, ey + 12), (ex, ey + 12)], AuswaertsFarbe, null, 0, geschlossen: true); + }, + "beides", + 14); + Eintrag((ex, ey) => f.Vieleck([(ex, ey), (ex + 16, ey), (ex + 16, ey + 12), (ex, ey + 12)], null, Randfarbe, 1, geschlossen: true), "? keine Meldung", 14); + f.Text("leer: kann nicht", x, y - 1, G, Farbe.Tinte2, false); + + x = 0; + y += 20; + Eintrag((ex, ey) => Stern(f, ex + 8, ey + 6, 5.5, Farbe.Tinte), "richtet aus", 14); + Eintrag((ex, ey) => f.Kreis(ex + 8, ey + 6, 4, Farbe.Tinte, null, 0), "Gast", 14); + Eintrag( + (ex, ey) => + { + Stern(f, ex + 4, ey + 6, 5, Zweiter); + f.Kreis(ex + 14, ey + 6, 4, Zweiter, null, 0); + }, + "zweiter Spieltag am selben Tag", + 14); + Eintrag( + (ex, ey) => + { + Stern(f, ex + 4, ey + 6, 5, EinzelGelb); + f.Kreis(ex + 14, ey + 6, 4, EinzelGelb, null, 0); + }, + "Einzelbegegnung", + 14); + + x = 0; + y += 20; + Eintrag((ex, ey) => f.Vieleck([(ex, ey), (ex + 16, ey), (ex + 16, ey + 12), (ex, ey + 12)], null, Farbe.Schlecht, 2, geschlossen: true), "gegen die Meldung", 14); + Eintrag( + (ex, ey) => + { + f.Kreis(ex + 8, ey + 6, 7, null, SpitzeRing, 1.5); + Stern(f, ex + 8, ey + 6, 4, Farbe.Tinte); + }, + "Spitzenspieltag", + 14); + Eintrag( + (ex, ey) => + { + f.Kreis(ex + 8, ey + 6, 7, null, KellerRing, 1.5); + Stern(f, ex + 8, ey + 6, 4, Farbe.Tinte); + }, + "Abstiegsspieltag", + 14); + } + + private static Abschnitt Spieltage(Kompaktauswertung a, Func halle) + { + var zeilen = new List(); + foreach (Kompaktspieltag tag in a.Spieltage) + { + if (zeilen.Count > 0) + { + zeilen.Add(new Tabellenzeile([new(string.Empty)], Farbe.Tinte, Art: Zeilenart.Abstand)); + } + + zeilen.Add(new Tabellenzeile([new(Kopf(tag))], Farbe.Tinte, Fett: true, Art: Zeilenart.Ueberschrift)); + string adresse = Halle(a, tag, halle); + zeilen.Add(new Tabellenzeile([new(string.Empty), new("Halle: " + (adresse.Length > 0 ? adresse : "unbekannt"))], Farbe.Tinte2)); + zeilen.AddRange(tag.Spiele.Select(s => new Tabellenzeile([new(Uhrzeit(s)), new(s.Heim + " – " + s.Gast)], Farbe.Tinte))); + } + + List bausteine = zeilen.Count == 0 + ? [new Textzeile("Der Plan hat keine Kompaktspieltage.", Druckbericht.Groesse, Farbe.Grau)] + : [new Tabelle(["Beginn", "Begegnung"], zeilen, Druckbericht.Groesse, LetzteSpalteUmbrechen: true)]; + return new Abschnitt("Kompaktspieltage", bausteine); + } + + /// + /// Je Mannschaft eine Seite: Kopfzeile, Karte ihrer Fahrten und ihre Spieltage – je Spieltag eine Überschrift (Datum, + /// Form, Heim bzw. bei wem), darunter die Halle und je Spiel eine Zeile mit Beginn und Gegner, mit Luft zur Karte und + /// zwischen den Spieltagen (Peter, 14.10.2026). Die Karte ist höchstens hoch, damit + /// auch bei quadratischen Karten (Stadtklassen) alles auf eine Seite passt. + /// + private static IEnumerable Mannschaften(Kompaktauswertung a, Func halle) + { + foreach (Kompaktmannschaft m in a.Mannschaften) + { + List tage = a.Spieltage.Where(t => t.Mannschaften.Contains(m.Name)).ToList(); + if (tage.Count == 0) + { + continue; + } + + string kopf = string.Create(Deutsch, $"{Anzahl(m.Ausgerichtet, "Heimspieltag", "Heimspieltage")} · {m.Km:N0} km Luftlinie (hin und zurück)"); + var bausteine = new List { new Textzeile(kopf, Druckbericht.Groesse + 1, Farbe.Tinte2) }; + if (m.Lage is not null && tage.Exists(t => t.Ausrichter != m.Name)) + { + Kartenbild bild = Kompaktfahrtenkarte.Mannschaft(a, m.Name); + double breite = bild.Hoehe > 0 ? Math.Min(Mannschaftskarte, Mannschaftskartenhoehe * bild.Breite / bild.Hoehe) : Mannschaftskarte; + bausteine.Add(Karte(bild, breite, Luft) with { Zentriert = true }); + } + + var zeilen = new List(); + foreach (Kompaktspieltag tag in tage) + { + if (zeilen.Count > 0) + { + zeilen.Add(new Tabellenzeile([new(string.Empty)], Farbe.Tinte, Art: Zeilenart.Abstand)); + } + + string ort = tag.Ausrichter == m.Name ? "Heim (Ausrichter)" : "bei " + (tag.Ausrichter ?? "?"); + zeilen.Add(new Tabellenzeile([new($"{tag.Tag.ToString("ddd dd.MM.yyyy", Deutsch)} · {Form(tag)} · {ort}")], Farbe.Tinte, Fett: true, Art: Zeilenart.Ueberschrift)); + string adresse = Halle(a, tag, halle); + zeilen.Add(new Tabellenzeile([new(string.Empty), new("Halle: " + (adresse.Length > 0 ? adresse : "unbekannt"))], Farbe.Tinte2)); + zeilen.AddRange(tag.Spiele + .Where(s => s.Heim == m.Name || s.Gast == m.Name) + .Select(s => new Tabellenzeile([new(Uhrzeit(s)), new("gegen " + (s.Heim == m.Name ? s.Gast : s.Heim))], Farbe.Tinte))); + } + + bausteine.Add(new Tabelle(["Beginn", "Spiel"], zeilen, Druckbericht.Groesse, LetzteSpalteUmbrechen: true, AbstandDavor: Luft * 1.6)); + yield return new Abschnitt(m.Name, bausteine); + } + } + + private static string Form(Kompaktspieltag tag) => tag.Form switch + { + Turnierform.Vierer => "4er-Spieltag", + Turnierform.Dreier => "3er-Spieltag", + Turnierform.Einzel => "Einzelbegegnung", + _ => "Spieltag", + }; + + private static string Uhrzeit(Spiel s) => s.Zeitpunkt?.ToString("HH:mm", Deutsch) ?? string.Empty; + + private static string Anzahl(int n, string einzahl, string mehrzahl) => + string.Create(Deutsch, $"{n} {(n == 1 ? einzahl : mehrzahl)}"); +} diff --git a/src/AndiGenerator.Presentation/Kompakterlaeuterung.cs b/src/AndiGenerator.Presentation/Kompakterlaeuterung.cs new file mode 100644 index 0000000..ef0c673 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompakterlaeuterung.cs @@ -0,0 +1,235 @@ +// 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.Turniere; + +namespace AndiGenerator.Presentation; + +/// Erläuterung eines Plans in der Ansicht „Kompaktspieltage“: wofür die Variante steht und wie sie im Vergleich abschneidet. +internal static class Kompakterlaeuterung +{ + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private static readonly (string Name, string Zweck)[] Zwecke = + [ + ("Bestes Gesamtpaket", "Trifft alle Ziele zusammen am besten. Je ein Punkt Abzug: 10 % mehr Kilometer als der kürzeste Plan, ein Spieltag Rückstand im Stand, kein Sonntag mit zwei 4er-Spieltagen, ungleich weite Fahrten (je ein Viertel der mittleren Kilometer einer Mannschaft über dem kleinsten Unterschied); drei Punkte je fehlendem Spitzen- oder Abstiegsspieltag (liegt er früh in der Halbrunde, bis zu einem halben Punkt)."), + ("Gesicherter Plan", "Der zuletzt gesicherte Plan, genau so, wie er gesichert wurde (Uhrzeiten, Hallen, Heimrecht). Gewählt gilt er für Terminplan, Druck und den Export für click-TT."), + ("Gemerkt", "Ein gemerkter Plan, genau so, wie er gemerkt wurde (Uhrzeiten, Hallen, Heimrecht). Gewählt gilt er für Terminplan, Druck und den Export für click-TT."), + ("Ausgewogen", "Kompromiss: 10 % mehr Kilometer wiegen so viel wie ein Spieltag Rückstand im Stand oder ein fehlender gemeinsamer Spieltag."), + ("Kürzeste Fahrten", "Die wenigsten Kilometer (Luftlinie, hin und zurück; Einzelfahrten doppelt gezählt) – gibt es Pläne nur aus 4er-Spieltagen, unter diesen, bei 9 oder 10 Mannschaften nach vollständiger Suche."), + ("Gleichmäßiger Stand", "Nach jedem Sonntag liegt die Zahl der gespielten Spiele aller Mannschaften so nah beieinander wie möglich."), + ("Gemeinsamer Spieltag", "Möglichst viele Mannschaften spielen am selben Sonntag."), + ("Gleich weite Fahrten", "Alle Mannschaften fahren möglichst gleich weit: kleinster Unterschied zwischen der Mannschaft mit den meisten und der mit den wenigsten Kilometern."), + ("Spitzen- und Abstiegsspieltag", "Die vier stärksten und die vier schwächsten Mannschaften (QTTR-Schnitt) bilden je einen 4er-Spieltag – in Plänen aus 3er-Spieltagen (und bei 6 oder 7 Mannschaften) die drei stärksten und die drei schwächsten je einen 3er-Spieltag –, so spät wie möglich."), + ("Ohne Verstöße", "Hält alle Meldungen ein (vollständig gesucht, weil die übrigen Pläne Verstöße haben); dafür bleiben, wenn es nicht anders geht, Mannschaften ohne Heimspieltag."), + ("mit Einzelspielen", "Statt eine Mannschaft ganz ohne Heimspiel zu lassen, werden einzelne Paarungen als Einzelspiel angesetzt, sodass jede wenigstens ein Heimspiel hat."), + ("Mit 3er-Spieltagen", "Braucht 3er-Spieltage, trifft aber das genannte Ziel besser als jeder Plan nur aus 4er-Spieltagen: kürzer, mehr Mannschaften am selben Sonntag, gleich weitere Fahrten, gleichmäßigerer Stand oder Spitzen- bzw. Abstiegsspieltag zu dritt."), + ]; + + /// Die Zeilen der Erläuterung. + /// Die gezeigte Variante oder null für den Plan aus click-TT. + /// Auswertung des gezeigten Plans. + /// Alle Varianten der letzten Berechnung (zum Vergleich). + /// Die vier stärksten Mannschaften oder leer. + /// Die vier schwächsten Mannschaften oder leer. + /// Die Zeilen. + public static List Zeilen(Kompaktvariante? variante, Kompaktauswertung a, IReadOnlyList alle, IReadOnlyList spitze, IReadOnlyList keller) + { + var zeilen = new List(); + if (variante is null) + { + zeilen.Add("Der Plan, wie er in click-TT eingetragen ist – zum Vergleich mit den Varianten."); + } + else + { + zeilen.AddRange(Zwecke.Where(z => variante.Name.Contains(z.Name, StringComparison.Ordinal)).Select(z => z.Name + ": " + z.Zweck)); + if (variante.Zugleich.Count > 0) + { + zeilen.Add("Zugleich erfüllt: **" + string.Join(", ", variante.Zugleich) + "** – mindestens so gut wie die Variante dafür, die deshalb entfällt."); + } + } + + if (a.Spieltage.Count == 0) + { + return zeilen; + } + + zeilen.Add(Fahrten(a, alle)); + zeilen.Add(Stand(a, alle)); + if (Gemeinsam(a) is string gemeinsam) + { + zeilen.Add(gemeinsam); + } + + zeilen.Add(string.Create(Deutsch, $"Spieltage: **{a.Formen}**. **{Heimspieltage(a)}**.")); + if (a.Verstoesse > 0) + { + zeilen.Add(string.Create(Deutsch, $"Meldungen: **{a.Verstoesse} Verstöße** (rot umrandet im Raster).")); + } + + if (spitze.Count >= 3 && spitze.Count == keller.Count) + { + zeilen.Add(Gruppe("Spitzenspieltag", a, spitze) + " " + Gruppe("Abstiegsspieltag", a, keller)); + } + + if (Punkte(a, alle, spitze, keller) is string punkte) + { + zeilen.Add(punkte); + } + + return zeilen; + } + + /// Wer wie oft ausrichtet, in Worten: „Jede Mannschaft ein Heimspieltag“ oder welche Mannschaften zwei (oder keinen) haben. + /// Auswertung des Plans. + /// Der Text ohne Schlusspunkt. + public static string Heimspieltage(Kompaktauswertung a) + { + // Ein Einzel-Heimspiel zählt als Heimspieltag mit (Peter, 13.10.2026); die Pflicht erfüllt nur ein 3er/4er-Spieltag. + static int Heim(Kompaktmannschaft m) => m.Ausgerichtet + m.EinzelHeim; + static string Name(Kompaktmannschaft m) => m.EinzelHeim > 0 ? string.Create(Deutsch, $"{m.Name} ({m.EinzelHeim}× Einzel)") : m.Name; + int wenigste = a.Mannschaften.Min(Heim); + int meiste = a.Mannschaften.Max(Heim); + List teile = []; + if (wenigste == meiste) + { + teile.Add(wenigste == 1 ? "Jede Mannschaft ein Heimspieltag" : string.Create(Deutsch, $"Jede Mannschaft {wenigste} Heimspieltage")); + List mitEinzel = a.Mannschaften.Where(m => m.EinzelHeim > 0).ToList(); + if (mitEinzel.Count > 0) + { + teile.Add("davon als Einzelspiel: " + string.Join(", ", mitEinzel.Select(Name))); + } + } + else + { + foreach (IGrouping gruppe in a.Mannschaften.Where(m => Heim(m) != 1 || m.EinzelHeim > 0).GroupBy(Heim).OrderByDescending(g => g.Key)) + { + string anzahl = gruppe.Key switch + { + 0 => "Kein Heimspieltag", + 1 => "Ein Heimspieltag", + 2 => "Zwei Heimspieltage", + 3 => "Drei Heimspieltage", + _ => string.Create(Deutsch, $"{gruppe.Key} Heimspieltage"), + }; + teile.Add($"{anzahl}: {string.Join(", ", gruppe.Select(Name))}"); + } + + if (a.Mannschaften.Any(m => Heim(m) == 1 && m.EinzelHeim == 0)) + { + teile.Add("alle anderen einen"); + } + } + + List ohne = a.Mannschaften.Where(m => m.Ausgerichtet == 0 && Heim(m) > 0).Select(m => m.Name).ToList(); + if (ohne.Count > 0) + { + teile.Add("keinen 3er- oder 4er-Spieltag richtet aus: " + string.Join(", ", ohne)); + } + + return string.Join("; ", teile); + } + + private static string Fahrten(Kompaktauswertung a, IReadOnlyList alle) + { + Kompaktmannschaft? weiteste = a.Mannschaften.MaxBy(m => m.Km); + string text = string.Create(Deutsch, $"Fahrten: **{a.KmGesamt:N0} km**") + Vergleich4er(a, alle); + Kompaktmannschaft? wenigste = a.Mannschaften.MinBy(m => m.Km); + return weiteste is null || wenigste is null + ? text + "." + : text + string.Create(Deutsch, $"; am weitesten fährt **{weiteste.Name} ({weiteste.Km:N0} km)**, am wenigsten {wenigste.Name} ({wenigste.Km:N0} km) – **{weiteste.Km - wenigste.Km:N0} km Unterschied**."); + } + + /// + /// Vergleich mit dem kürzesten Plan nur aus 4er-Spieltagen; dazu, ob es nur mit 3er-Spieltagen kürzer geht. + /// + private static string Vergleich4er(Kompaktauswertung a, IReadOnlyList alle) + { + List vierer = alle.Where(v => v.Kennzahlen.Dreier == 0).Select(v => v.Kennzahlen.Km).ToList(); + if (vierer.Count == 0) + { + // Ohne Pläne nur aus 4er-Spieltagen (z. B. 6 bis 8 Mannschaften): Vergleich mit dem kürzesten überhaupt. + double kuerzester = alle.Select(v => v.Kennzahlen.Km).DefaultIfEmpty(a.KmGesamt).Min(); + double mehrAlsKuerzester = a.KmGesamt - kuerzester; + if (alle.Count == 0) + { + return string.Empty; + } + + return mehrAlsKuerzester > 0.5 + ? string.Create(Deutsch, $", **{mehrAlsKuerzester:N0} km ({100 * mehrAlsKuerzester / kuerzester:N1} %) mehr** als der kürzeste Plan") + : " – **der kürzeste Plan**"; + } + + double kuerzeste = vierer.Min(); + double mehr = a.KmGesamt - kuerzeste; + bool mitDreiern = a.Spieltage.Any(s => s.Form == Turnierform.Dreier); + if (mitDreiern && mehr < -0.5) + { + return string.Create(Deutsch, $", **{-mehr:N0} km weniger** als der kürzeste Plan nur aus 4er-Spieltagen – kürzer geht es nur mit 3er-Spieltagen"); + } + + string text = mehr > 0.5 + ? string.Create(Deutsch, $", **{mehr:N0} km ({100 * mehr / kuerzeste:N1} %) mehr** als der kürzeste Plan nur aus 4er-Spieltagen") + : " – **der kürzeste Plan nur aus 4er-Spieltagen**"; + double dreier = alle.Where(v => v.Kennzahlen.Dreier > 0).Select(v => v.Kennzahlen.Km).DefaultIfEmpty(double.MaxValue).Min(); + return !mitDreiern && dreier < kuerzeste - 0.5 + ? text + string.Create(Deutsch, $"; nur mit 3er-Spieltagen ginge es kürzer (**{dreier:N0} km**)") + : text; + } + + /// Die Punkte wie beim besten Gesamtpaket mit Begründung; bezogen auf die Pläne nur aus 4er-Spieltagen. + private static string? Punkte(Kompaktauswertung a, IReadOnlyList alle, IReadOnlyList spitze, IReadOnlyList keller) + { + if (Kompaktpunkte.Bezug(alle) is not Kompaktbezug bezug) + { + return null; + } + + IReadOnlyList> gruppen = spitze.Count >= 3 && spitze.Count == keller.Count ? [spitze, keller] : []; + (double summe, IReadOnlyList gruende) = Kompaktpunkte.Berechnen(a, bezug, gruppen); + return gruende.Count == 0 + ? "Punkte: **0** (kleiner ist besser) – trifft jedes Ziel so gut wie der jeweils beste Plan." + : string.Create(Deutsch, $"Punkte: **{summe:N1}** (kleiner ist besser, wie beim besten Gesamtpaket gezählt) – {string.Join("; ", gruende)}."); + } + + private static string Stand(Kompaktauswertung a, IReadOnlyList alle) + { + int bester = alle.Select(v => v.Kennzahlen.Abstand).DefaultIfEmpty(a.GroessterAbstand).Min(); + string vergleich = a.GroessterAbstand <= bester ? " – **so gleichmäßig wie die beste Variante**" : string.Create(Deutsch, $" (beste Variante: {bester})"); + return string.Create(Deutsch, $"Stand: nach jedem Spieltermin höchstens **{a.GroessterAbstand} Spiele** Unterschied zwischen zwei Mannschaften{vergleich}."); + } + + /// Der Sonntag, an dem die meisten spielen – nur, wenn es mehr sind als an einem einzelnen Spieltag. + private static string? Gemeinsam(Kompaktauswertung a) + { + int einzeln = a.Spieltage.Max(s => s.Mannschaften.Count); + var tag = a.Spieltage.GroupBy(s => s.Tag) + .Select(g => (Tag: g.Key, Anzahl: g.SelectMany(s => s.Mannschaften).Distinct(StringComparer.Ordinal).Count())) + .OrderByDescending(x => x.Anzahl).ThenBy(x => x.Tag) + .First(); + return tag.Anzahl <= einzeln + ? null + : string.Create(Deutsch, $"Gemeinsamer Spieltag: am {tag.Tag:dd.MM.} spielen **{tag.Anzahl} von {a.Mannschaften.Count} Mannschaften**."); + } + + private static string Gruppe(string titel, Kompaktauswertung a, IReadOnlyList gruppe) + { + Kompaktspieltag? s = a.Spieltage.FirstOrDefault(x => x.Mannschaften.Count == gruppe.Count && gruppe.All(x.Mannschaften.Contains)); + string namen = string.Join(", ", gruppe); + if (s is not null) + { + return string.Create(Deutsch, $"**{titel} am {s.Tag:dd.MM.}** ({namen})."); + } + + // Wenigstens zu dritt: die drei Stärksten bzw. Schwächsten an einem 3er-Spieltag. + List drei = gruppe.Take(3).ToList(); + Kompaktspieltag? dreier = a.Spieltage.FirstOrDefault(x => x.Form == Turnierform.Dreier && drei.All(x.Mannschaften.Contains)); + return dreier is null + ? $"**Kein {titel}** ({namen})." + : string.Create(Deutsch, $"**{titel} zu dritt am {dreier.Tag:dd.MM.}** ({string.Join(", ", drei)})."); + } +} diff --git a/src/AndiGenerator.Presentation/Kompaktfahrtenkarte.cs b/src/AndiGenerator.Presentation/Kompaktfahrtenkarte.cs new file mode 100644 index 0000000..305937e --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktfahrtenkarte.cs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Application; +using AndiGenerator.Engine.Turniere; +using AndiGenerator.Persistence.Geo; + +namespace AndiGenerator.Presentation; + +/// Baut die Karte der Fahrten zu Kompaktspieltagen – für die Ansicht und den Ausdruck (nicht im Original). +public static class Kompaktfahrtenkarte +{ + /// Baut die Karte der Fahrten zu den sichtbaren Spieltagen. + /// Die Auswertung des Plans. + /// Die Spieltage, deren Fahrten gezeigt werden. + /// Nur die Fahrten dieser Mannschaft oder null für alle. + /// Alle Fahrten der Halbrunde (blass, ohne Pfeile) statt einer Auswahl. + /// Das Bild. + public static Kartenbild Bauen(Kompaktauswertung a, IReadOnlyList sichtbar, string? team, bool alleZeigen) + { + ArgumentNullException.ThrowIfNull(a); + ArgumentNullException.ThrowIfNull(sichtbar); + var fahrten = sichtbar.Where(s => s.Ausrichter is not null) + .SelectMany(s => s.Gaeste.Where(g => team is null || g == team).Select(g => (Von: g, Nach: s.Ausrichter!, Kraeftig: !alleZeigen, Einzel: s.Form == Turnierform.Einzel))) + .ToList(); + var ausrichter = sichtbar.Select(s => s.Ausrichter).OfType().ToHashSet(StringComparer.Ordinal); + List orte = a.Mannschaften + .Where(m => m.Lage is not null) + .Select(m => new Kartenort( + m.Name, + m.Name, + m.Lage.GetValueOrDefault(), + !alleZeigen && ausrichter.Contains(m.Name), + team is not null && m.Name != team && !ausrichter.Contains(m.Name), + m.Halle.Length > 0 ? $"{m.Name} – {m.Halle}" : m.Name)) + .ToList(); + return Kartenbau.Bauen(orte, fahrten, Kartendaten.Laender, Kartendaten.Staedte); + } + + /// Die Fahrten einer Mannschaft zu ihren Auswärtsspieltagen (wie ein Klick auf die Mannschaft in der Ansicht). + /// Die Auswertung des Plans. + /// Die Mannschaft. + /// Das Bild. + public static Kartenbild Mannschaft(Kompaktauswertung a, string team) + { + ArgumentNullException.ThrowIfNull(a); + return Bauen(a, a.Spieltage.Where(s => s.Gaeste.Contains(team)).ToList(), team, alleZeigen: false); + } +} diff --git a/src/AndiGenerator.Presentation/Kompaktkachel.cs b/src/AndiGenerator.Presentation/Kompaktkachel.cs new file mode 100644 index 0000000..9b4cd96 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktkachel.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 Kachel oben im Hauptfenster im Kompaktmodus. +/// z. B. „Fahrten (Luftlinie)“. +/// Großer Wert, z. B. „2.961 km“. +/// Zeile darunter. +/// Wert grün darstellen. +/// Wert rot darstellen. +public sealed record Kompaktkachel(string Titel, string Wert, string Zusatz, bool Gut, bool Schlecht); diff --git a/src/AndiGenerator.Presentation/Kompaktkmzeile.cs b/src/AndiGenerator.Presentation/Kompaktkmzeile.cs new file mode 100644 index 0000000..780fed2 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktkmzeile.cs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Zeile „km je Mannschaft“. +/// Name der Mannschaft. +/// z. B. „1× Ausrichter“. +/// Kilometer als Text. +/// Länge des Balkens von 0 bis 1 (1 = weiteste Mannschaft). +/// Mannschaft ist ausgewählt. +public sealed record Kompaktkmzeile(string Name, string Zusatz, string Km, double Anteil, bool Gewaehlt) +{ + /// Holt die Breite des Balkens bei 120 Bildpunkten für den längsten. + public double Balken => Math.Max(2, Anteil * 120); +} diff --git a/src/AndiGenerator.Presentation/Kompaktplanwahl.cs b/src/AndiGenerator.Presentation/Kompaktplanwahl.cs new file mode 100644 index 0000000..420eccf --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktplanwahl.cs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Engine.Turniere; + +namespace AndiGenerator.Presentation; + +/// Ein Plan zur Wahl in der Ansicht „Kompaktspieltage“: der Plan aus click-TT oder eine Variante des Planers. +/// z. B. „Ausgewogen“. +/// Kennzahlen, z. B. „2.961 km · Stand höchstens 2 Spiele auseinander“. +/// Dieser Plan wird gezeigt. +/// Die Variante oder null für den Plan aus click-TT. +public sealed record Kompaktplanwahl(string Titel, string Zeile, bool Gewaehlt, Kompaktvariante? Variante) +{ + /// Holt die Ziele, die der Plan zugleich erfüllt (beim besten Gesamtpaket), z. B. „zugleich: Gemeinsamer Spieltag“, sonst leer. + public string Zugleich => Variante is { Zugleich.Count: > 0 } v ? "✓ zugleich: " + string.Join(" · ", v.Zugleich) : string.Empty; + + /// Holt einen Wert, der angibt, ob es ein gespeicherter Plan ist (gesichert oder gemerkt). + public bool Gespeichert { get; init; } + + /// Holt einen Wert, der angibt, ob der gewählte Plan gemerkt werden kann (eine berechnete Variante). + public bool KannMerken => Gewaehlt && Variante is not null && !Gespeichert; +} diff --git a/src/AndiGenerator.Presentation/Kompaktpunkte.cs b/src/AndiGenerator.Presentation/Kompaktpunkte.cs new file mode 100644 index 0000000..6f9d8d2 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktpunkte.cs @@ -0,0 +1,168 @@ +// 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.Turniere; + +namespace AndiGenerator.Presentation; + +/// +/// Punkte eines Plans aus Kompaktspieltagen wie beim besten Gesamtpaket (kleiner ist besser), mit Begründung: je ein Punkt +/// für 10 % mehr Kilometer als der kürzeste Plan nur aus 4er-Spieltagen (Fahrten zu Einzelbegegnungen zählen doppelt), einen Spieltag Rückstand im Stand (über 2 Spiele), +/// keinen Sonntag mit zwei 4er-Spieltagen, einen früh liegenden Spitzen- oder Abstiegsspieltag (bis zu einem halben Punkt) +/// und ein Viertel der mittleren Kilometer einer Mannschaft, um das der Unterschied zwischen den +/// Mannschaften über dem kleinsten liegt; Verstöße je 10, Mannschaften ohne 3er- oder 4er-Heimspieltag je 15, ein fehlender Spitzen- oder Abstiegsspieltag je 3, 3er-Spieltage je 3 Punkte, Einzelspiele bei ungerader +/// Mannschaftszahl je 2 Punkte. +/// +internal static class Kompaktpunkte +{ + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private static readonly string[] Gruppennamen = ["Spitzenspieltag", "Abstiegsspieltag"]; + + /// + /// Bezug der Punkte: Kilometer des kürzesten Plans und kleinster Unterschied zwischen den Mannschaften – unter den + /// Varianten nur aus 4er-Spieltagen, wenn es solche gibt, sonst unter allen (z. B. bei 6 bis 8 Mannschaften). + /// + /// Die berechneten Varianten. + /// Der Bezug oder null, wenn es keine Varianten gibt. + public static Kompaktbezug? Bezug(IEnumerable varianten) + { + List alle = varianten.Select(v => v.Kennzahlen).ToList(); + List vierer = alle.Where(k => k.Dreier == 0).ToList(); + List bezug = vierer.Count > 0 ? vierer : alle; + return bezug.Count == 0 ? null : new Kompaktbezug(bezug.Min(k => k.Bewertungskm), bezug.Min(k => k.KmSpanne), vierer.Count > 0); + } + + /// Unterschied der Kilometer zwischen der Mannschaft mit den meisten und der mit den wenigsten. + /// Auswertung des Plans. + /// Die Kilometer. + public static double Spanne(Kompaktauswertung a) => + a.Mannschaften.Count == 0 ? 0 : a.Mannschaften.Max(m => m.Km) - a.Mannschaften.Min(m => m.Km); + + /// Wie viele der Gruppen (Spitze, Keller) einen eigenen 4er-Spieltag haben. + /// Auswertung des Plans. + /// Die Gruppen zu je vier Mannschaften (die beiden Stärksten bzw. Schwächsten zuerst). + /// Die Zahl. + public static int Gruppentage(Kompaktauswertung a, IReadOnlyList> gruppen) => + gruppen.Count(g => Gruppentag(a, g) is not null); + + /// Die Punkte und ihre Gründe. + /// Auswertung des Plans. + /// Kürzester Plan, kleinster Unterschied und ob 3er-Spieltage Punkte kosten. + /// Spitze und Keller zu je vier Mannschaften, oder leer. + /// Summe und Gründe, z. B. „0,6 für 6,0 % mehr Kilometer …“. + public static (double Summe, IReadOnlyList Gruende) Berechnen(Kompaktauswertung a, Kompaktbezug bezug, IReadOnlyList> gruppen) + { + ArgumentNullException.ThrowIfNull(bezug); + double kmMin = bezug.Km; + double spanneMin = bezug.Spanne; + var gruende = new List(); + double summe = 0; + + void Teil(double punkte, string grund) + { + if (punkte >= 0.05) + { + summe += punkte; + gruende.Add(punkte.ToString("N1", Deutsch) + " für " + grund); + } + } + + int n = a.Mannschaften.Count; + + // Einzelfahrten zählen doppelt (Peter, 13.10.2026): Eine Mannschaft fährt dafür weit für ein einziges Spiel. + double einzelKm = a.Spieltage.Where(s => s.Form == Turnierform.Einzel).Sum(s => s.Km); + double bewertungskm = a.KmGesamt + einzelKm; + if (kmMin > 0 && bewertungskm > kmMin) + { + double anteil = (bewertungskm / kmMin) - 1; + string vergleich = bezug.MitVierern ? "der kürzeste Plan nur aus 4er-Spieltagen" : "der kürzeste Plan"; + string doppelt = einzelKm > 0.5 ? string.Create(Deutsch, $", Einzelfahrten ({einzelKm:N0} km) doppelt gezählt") : string.Empty; + Teil(anteil / 0.1, string.Create(Deutsch, $"{100 * anteil:N1} % mehr Kilometer als {vergleich}{doppelt}")); + } + + if (a.GroessterAbstand > 2) + { + Teil((a.GroessterAbstand - 2) / 2.0, string.Create(Deutsch, $"den Stand (bis {a.GroessterAbstand} Spiele auseinander)")); + } + + if (a.MeisteAmTag < 4 * (n / 4)) + { + Teil(1, "keinen Sonntag mit zwei 4er-Spieltagen"); + } + + GruppenWerten(a, gruppen, Teil); + double jeMannschaft = n > 0 ? kmMin / n : 0; + double mehrUnterschied = Spanne(a) - spanneMin; + if (jeMannschaft > 0 && mehrUnterschied > 0.5) + { + Teil(mehrUnterschied / (0.25 * jeMannschaft), string.Create(Deutsch, $"ungleich weite Fahrten ({mehrUnterschied:N0} km mehr Unterschied als der kleinste)")); + } + + Teil(10.0 * a.Verstoesse, string.Create(Deutsch, $"{a.Verstoesse} Verstöße gegen die Meldungen")); + Teil(15.0 * a.OhneHeimspieltag, string.Create(Deutsch, $"{a.OhneHeimspieltag} Mannschaften ohne 3er- oder 4er-Heimspieltag")); + int ganzOhne = a.Mannschaften.Count(m => m.Ausgerichtet + m.EinzelHeim == 0); + Teil(2.0 * ganzOhne, string.Create(Deutsch, $"{ganzOhne} Mannschaften ganz ohne Heimspiel (auch kein Einzel zuhause)")); + int dreier = a.Spieltage.Count(s => s.Form == Turnierform.Dreier); + if (bezug.MitVierern) + { + Teil(3.0 * dreier, string.Create(Deutsch, $"{dreier} 3er-Spieltage")); + } + + // Bei ungerader Zahl sind Einzelspiele ein Notbehelf (bei gerader gehört je Mannschaft eines dazu). + int einzel = a.Spieltage.Count(s => s.Form == Turnierform.Einzel); + if (n % 2 == 1) + { + Teil(2.0 * einzel, string.Create(Deutsch, $"{einzel} Einzelspiele")); + } + + return (summe, gruende); + } + + /// + /// Der Spitzen- bzw. Abstiegsspieltag: ein 4er-Spieltag der vier (die ersten beiden gegeneinander) oder ein 3er-Spieltag + /// der ersten drei (Peter, 13.10.2026: in Plänen aus 3er-Spieltagen bilden die drei Stärksten bzw. Schwächsten die Gruppe). + /// + /// Auswertung des Plans. + /// Die Gruppe, die stärkste bzw. schwächste Mannschaft zuerst. + /// Der Spieltag oder null. + public static Kompaktspieltag? Gruppentag(Kompaktauswertung a, IReadOnlyList g) + { + Kompaktspieltag? vierer = g.Count == 4 + ? a.Spieltage.FirstOrDefault(s => s.Form == Turnierform.Vierer && s.Mannschaften.Count == 4 && g.All(s.Mannschaften.Contains) + && s.Spiele.Any(sp => (sp.Heim == g[0] && sp.Gast == g[1]) || (sp.Heim == g[1] && sp.Gast == g[0]))) + : null; + return vierer ?? a.Spieltage.FirstOrDefault(s => s.Form == Turnierform.Dreier && g.Take(3).All(s.Mannschaften.Contains)); + } + + private static void GruppenWerten(Kompaktauswertung a, IReadOnlyList> gruppen, Action teil) + { + if (a.Spieltage.Count == 0) + { + return; + } + + DateOnly erster = a.Spieltage.Min(s => s.Tag); + DateOnly letzter = a.Spieltage.Max(s => s.Tag); + for (int i = 0; i < gruppen.Count && i < Gruppennamen.Length; i++) + { + if (gruppen[i].Count < 3) + { + continue; + } + + if (Gruppentag(a, gruppen[i]) is not Kompaktspieltag tag) + { + teil(3, "den fehlenden " + Gruppennamen[i]); + } + else if (letzter > erster) + { + double frueh = (letzter.DayNumber - tag.Tag.DayNumber) / (double)(letzter.DayNumber - erster.DayNumber); + teil(0.5 * frueh, string.Create(Deutsch, $"den frühen {Gruppennamen[i]} ({tag.Tag:dd.MM.})")); + } + } + } +} diff --git a/src/AndiGenerator.Presentation/Kompaktrasterzeile.cs b/src/AndiGenerator.Presentation/Kompaktrasterzeile.cs new file mode 100644 index 0000000..2ac7721 --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktrasterzeile.cs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Zeile im Raster der Kompaktspieltage: ein Sonntag. +/// Datum. +/// Beschriftung, z. B. „06.09.“. +/// Sonntag ist ausgewählt. +/// Je Mannschaft eine Zelle. +public sealed record Kompaktrasterzeile(DateOnly Tag, string Datum, bool Gewaehlt, IReadOnlyList Zellen); diff --git a/src/AndiGenerator.Presentation/Kompaktspalte.cs b/src/AndiGenerator.Presentation/Kompaktspalte.cs new file mode 100644 index 0000000..e1e78ca --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktspalte.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Spaltenkopf im Raster der Kompaktspieltage: eine Mannschaft. +/// Name der Mannschaft. +/// Mannschaft ist ausgewählt. +public sealed record Kompaktspalte(string Name, bool Gewaehlt) +{ + /// Holt die Beschriftung: der volle Name, lange Namen in zwei Zeilen wie im Ausdruck. + public string Beschriftung => string.Join('\n', Kompaktdruck.Kopfzeilen(Name)); +} diff --git a/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.Bearbeiten.cs b/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.Bearbeiten.cs new file mode 100644 index 0000000..8aec39e --- /dev/null +++ b/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.Bearbeiten.cs @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.Globalization; +using AndiGenerator.Application; +using CommunityToolkit.Mvvm.Input; + +namespace AndiGenerator.Presentation; + +/// +/// Bearbeitungsmodus des Rasters „Meldungen und Plan“ (Peter, 13.10.2026): Ein Rahmen steht auf einem Feld (Klick oder +/// Pfeiltasten); A, H, B und Entf setzen auswärts, nur Heim, beides bzw. kann nicht. Für Heimtermine lassen sich Beginn, +/// Spiellokal und zwei Tische einstellen, weitere Tage als Zeilen anlegen. Gespeichert wird sofort wie die Spielplandaten. +/// +public sealed partial class KompaktspieltageAnsichtViewModel +{ + private bool bearbeiten; + private (string Mannschaft, DateOnly Tag)? cursor; + private string cursorText = string.Empty; + private bool cursorHatTermin; + private string cursorZeit = string.Empty; + private Kompaktlokal? cursorLokal; + private IReadOnlyList cursorLokale = []; + private bool cursorZweiTische; + private DateTime? neuerTag; + private string bearbeitungsmeldung = string.Empty; + + /// Die zuletzt begonnene Änderung: Eingaben werden nacheinander gespeichert (schnelles Tippen geht nicht verloren). + private Task kette = Task.CompletedTask; + + /// Holt den Befehl „Feld wählen“ (Klick im Bearbeitungsmodus). + public IRelayCommand ZelleWaehlenCommand { get; } + + /// Holt den Befehl „Meldung setzen“: A, H, B oder - (kann nicht). + public IAsyncRelayCommand FeldSetzenCommand { get; } + + /// Holt den Befehl „Heimtermin übernehmen“ (Beginn, Spiellokal, Tische). + public IAsyncRelayCommand TerminSpeichernCommand { get; } + + /// Holt den Befehl „Tag anlegen“. + public IAsyncRelayCommand TagAnlegenCommand { get; } + + /// Holt den Befehl „Beginn löschen“: zurück zum Standard (3er 10:00, 4er 11:00), sofort gespeichert. + public IAsyncRelayCommand ZeitLoeschenCommand { get; } + + /// Holt den Befehl „Spiellokal für alle Heimtermine der Mannschaft“. + public IAsyncRelayCommand LokalFuerAlleCommand { get; } + + /// Holt oder setzt einen Wert, der angibt, ob das Raster bearbeitet wird (sonst nur Anzeige). + public bool Bearbeiten + { + get => bearbeiten; + set + { + if (!SetProperty(ref bearbeiten, value)) + { + return; + } + + hauptfenster.KompaktMeldungenBearbeiten = value; + hauptfenster.KompaktLesekopieVerwerfen(); + + if (value && cursor is null && auswertung is { Mannschaften.Count: > 0, Tage.Count: > 0 } a) + { + cursor = (a.Mannschaften[0].Name, a.Tage[0]); + } + + Bearbeitungsmeldung = string.Empty; + AuswahlZeigen(); + CursorLaden(); + } + } + + /// Holt, welches Feld der Rahmen zeigt und was dort gemeldet ist. + public string CursorText + { + get => cursorText; + private set => SetProperty(ref cursorText, value); + } + + /// Holt einen Wert, der angibt, ob das Feld einen Heimtermin hat (dann Beginn, Spiellokal, Tische). + public bool CursorHatTermin + { + get => cursorHatTermin; + private set => SetProperty(ref cursorHatTermin, value); + } + + /// Holt oder setzt den Beginn des Heimtermins, z. B. „10:30“; leer = Standard (3er 10:00, 4er 11:00). + public string CursorZeit + { + get => cursorZeit; + set => SetProperty(ref cursorZeit, value); + } + + /// Holt oder setzt das Spiellokal des Heimtermins. + public Kompaktlokal? CursorLokal + { + get => cursorLokal; + set => SetProperty(ref cursorLokal, value); + } + + /// Holt die Spiellokale der Mannschaft zur Wahl. + public IReadOnlyList CursorLokale + { + get => cursorLokale; + private set => SetProperty(ref cursorLokale, value); + } + + /// Holt oder setzt, ob zwei Spiele parallel gehen (4er-Spieltag möglich). + public bool CursorZweiTische + { + get => cursorZweiTische; + set => SetProperty(ref cursorZweiTische, value); + } + + /// Holt oder setzt den Tag, der als Zeile angelegt werden soll. + public DateTime? NeuerTag + { + get => neuerTag; + set => SetProperty(ref neuerTag, value); + } + + /// Holt eine Meldung zur letzten Eingabe (z. B. ungültige Uhrzeit) oder leer. + public string Bearbeitungsmeldung + { + get => bearbeitungsmeldung; + private set => SetProperty(ref bearbeitungsmeldung, value); + } + + /// Bewegt den Rahmen (Pfeiltasten), am Rand bleibt er stehen. + /// Spalten nach rechts (negativ: links). + /// Zeilen nach unten (negativ: oben). + public void CursorBewegen(int spalten, int zeilen) + { + if (!Bearbeiten || auswertung is not { Mannschaften.Count: > 0, Tage.Count: > 0 } a) + { + return; + } + + int s = cursor is { } c ? Math.Max(0, a.Mannschaften.ToList().FindIndex(m => m.Name == c.Mannschaft)) : 0; + int z = cursor is { } d ? Math.Max(0, a.Tage.ToList().IndexOf(d.Tag)) : 0; + s = Math.Clamp(s + spalten, 0, a.Mannschaften.Count - 1); + z = Math.Clamp(z + zeilen, 0, a.Tage.Count - 1); + CursorSetzen(a.Mannschaften[s].Name, a.Tage[z]); + } + + /// Liest eine Uhrzeit wie „10“, „10:00“ oder „9.30“. + private static TimeOnly? ZeitLesen(string text) + { + string t = text.Trim(); + if (int.TryParse(t, NumberStyles.None, CultureInfo.InvariantCulture, out int stunde) && stunde is >= 0 and < 24) + { + return new TimeOnly(stunde, 0); + } + + return TimeOnly.TryParseExact(t, ["H:mm", "HH:mm", "H.mm", "HH.mm"], CultureInfo.InvariantCulture, DateTimeStyles.None, out TimeOnly zeit) ? zeit : null; + } + + private void CursorSetzen(string? mannschaft, DateOnly? tag) + { + if (!Bearbeiten || mannschaft is null || tag is not DateOnly t) + { + return; + } + + cursor = (mannschaft, t); + Bearbeitungsmeldung = string.Empty; + + // Nur den Rahmen umsetzen, nicht das ganze Raster neu aufbauen. + foreach (Kompaktzelle zelle in Zeilen.SelectMany(z => z.Zellen)) + { + zelle.IstCursor = zelle.Mannschaft == mannschaft && zelle.Tag == t; + } + + CursorLaden(); + } + + /// Liest den Heimtermin und die Spiellokale des Felds unter dem Rahmen. + private void CursorLaden() + { + if (!Bearbeiten || cursor is not (string mannschaft, DateOnly tag)) + { + CursorText = string.Empty; + CursorHatTermin = false; + return; + } + + CursorText = Zeilen.SelectMany(z => z.Zellen).FirstOrDefault(z => z.IstCursor)?.Hinweis ?? string.Empty; + (Kompakttermin? termin, IReadOnlyList lokale) = hauptfenster.KompaktDatenLesen( + d => (d.KompaktterminLesen(mannschaft, tag), d.KompaktLokale(mannschaft)), + ((Kompakttermin?)null, (IReadOnlyList)Array.Empty())); + CursorHatTermin = termin is not null; + CursorLokale = lokale; + CursorZeit = termin?.Zeit?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? string.Empty; + string lokal = termin?.Spiellokal ?? string.Empty; + CursorLokal = lokale.FirstOrDefault(l => l.Nummer == lokal) ?? lokale.FirstOrDefault(); + CursorZweiTische = termin?.ZweiTische ?? false; + } + + private async Task FeldSetzenAsync(string? art) + { + if (!Bearbeiten || cursor is not (string mannschaft, DateOnly tag)) + { + return; + } + + Kompaktfeld? feld = art?.ToUpperInvariant() switch + { + "A" => Kompaktfeld.Auswaerts, + "H" => Kompaktfeld.Heim, + "B" => Kompaktfeld.Beides, + "-" => Kompaktfeld.Leer, + _ => null, + }; + if (feld is Kompaktfeld f) + { + await NacheinanderAsync(() => hauptfenster.KompaktMeldungAendernAsync(d => d.KompaktfeldSetzen(mannschaft, tag, f))); + } + } + + private async Task TerminSpeichernAsync() + { + if (!Bearbeiten || cursor is not (string mannschaft, DateOnly tag) || !CursorHatTermin) + { + return; + } + + TimeOnly? zeit = null; + if (CursorZeit.Trim().Length > 0) + { + zeit = ZeitLesen(CursorZeit); + if (zeit is null) + { + Bearbeitungsmeldung = "Bitte den Beginn als Uhrzeit eingeben, z. B. 10:30 – oder leer lassen für den Standard (3er 10:00, 4er 11:00)."; + return; + } + } + + Bearbeitungsmeldung = string.Empty; + var termin = new Kompakttermin(zeit, CursorLokal?.Nummer ?? string.Empty, CursorZweiTische); + await NacheinanderAsync(() => hauptfenster.KompaktMeldungAendernAsync(d => d.KompaktterminSetzen(mannschaft, tag, termin))); + } + + /// Führt eine Änderung aus, wenn die vorige gespeichert ist, und liest danach das Feld unter dem Rahmen neu. + private async Task NacheinanderAsync(Func aenderung) + { + // Alles läuft im Oberflächen-Thread: die Kette wird ohne Sperre weitergereicht. + Task vorher = kette; + var fertig = new TaskCompletionSource(); + kette = fertig.Task; + try + { + await vorher; + await aenderung(); + } + finally + { + fertig.SetResult(); + } + + CursorLaden(); + } + + private async Task ZeitLoeschenAsync() + { + CursorZeit = string.Empty; + await TerminSpeichernAsync(); + } + + private async Task LokalFuerAlleAsync() + { + if (!Bearbeiten || cursor is not (string mannschaft, _) || CursorLokal is not Kompaktlokal lokal) + { + return; + } + + await NacheinanderAsync(() => hauptfenster.KompaktMeldungAendernAsync(d => d.KompaktLokalFuerAlle(mannschaft, lokal.Nummer))); + } + + private async Task TagAnlegenAsync() + { + if (!Bearbeiten || NeuerTag is not DateTime gewaehlt || auswertung is not { Mannschaften.Count: > 0 } a) + { + return; + } + + DateOnly tag = DateOnly.FromDateTime(gewaehlt); + if (!a.Tage.Contains(tag)) + { + await NacheinanderAsync(() => hauptfenster.KompaktMeldungAendernAsync(d => d.KompakttagAnlegen(tag))); + } + + if (auswertung is { } neu && neu.Tage.Contains(tag)) + { + Bearbeitungsmeldung = string.Empty; + NeuerTag = null; + CursorSetzen(neu.Mannschaften[0].Name, tag); + } + else + { + Bearbeitungsmeldung = string.Create(Deutsch, $"Der {tag:dd.MM.yyyy} liegt nicht in der geplanten Halbrunde oder ist spielfrei."); + } + } +} diff --git a/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.Raster.cs b/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.Raster.cs new file mode 100644 index 0000000..355244e --- /dev/null +++ b/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.Raster.cs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Application; +using AndiGenerator.Domain.Turniere; +using AndiGenerator.Engine.Turniere; + +namespace AndiGenerator.Presentation; + +/// Zeilen des Rasters „Meldungen und Plan“ – für die Ansicht und den Ausdruck. +public sealed partial class KompaktspieltageAnsichtViewModel +{ + /// + /// Eine Zeile des Rasters „Meldungen und Plan“ (auch für den Ausdruck); gedimmt ist, was nicht zur Auswahl gehört + /// (andere Mannschaften bzw. andere Tage). + /// + /// Die Auswertung. + /// Der Tag der Zeile. + /// Ob der Tag gewählt ist. + /// Die gewählte Mannschaft oder null. + /// Ob ein anderer Tag gewählt ist (die Zeile wird gedimmt). + /// Feld mit dem Rahmen beim Bearbeiten oder null. + /// Spitzen- und Abstiegsspieltag des Plans (für die Ringe). + /// Die Zeile. + internal static Kompaktrasterzeile Rasterzeile( + Kompaktauswertung a, + DateOnly tag, + bool gewaehlt, + string? team, + bool andererTag, + (string Mannschaft, DateOnly Tag)? rahmen, + (Kompaktspieltag? Spitze, Kompaktspieltag? Keller) gruppentage) + { + var zellen = new List(); + foreach (Kompaktmannschaft m in a.Mannschaften) + { + Turnierbereitschaft? b = m.Meldung?.An(tag); + Kompaktspieltag? s = a.Spieltage.FirstOrDefault(x => x.Tag == tag && x.Mannschaften.Contains(m.Name)); + bool ausrichter = s is not null && s.Ausrichter == m.Name; + bool verstoss = s is not null && b is Turnierbereitschaft bb + && !bb.HasFlag(ausrichter ? Turnierbereitschaft.Heim : Turnierbereitschaft.Auswaerts); + string zeichen = (s, b) switch + { + (not null, _) => ausrichter ? "★" : "●", + (null, null) => "?", + _ => string.Empty, + }; + string meldung = b switch + { + null => "keine Meldung", + Turnierbereitschaft.None => "kann nicht", + Turnierbereitschaft.Heim => "nur Heim", + Turnierbereitschaft.Auswaerts => "nur auswärts", + _ => "Heim oder auswärts", + }; + string rolle = string.Empty; + if (s is not null) + { + rolle = ausrichter ? " · richtet aus" : " · Gast bei " + (s.Ausrichter ?? "?"); + } + + List amTag = a.Spieltage.Where(x => x.Tag == tag).ToList(); + string termin = m.Termine.TryGetValue(tag, out string? text) ? Environment.NewLine + text : string.Empty; + zellen.Add(new Kompaktzelle(Meldungsart(b), zeichen, verstoss, andererTag || (team is not null && team != m.Name), string.Create(Deutsch, $"{m.Name}, {tag:dd.MM.yyyy}: {meldung}{rolle}{termin}")) + { + Spieltag = s is null ? 0 : amTag.IndexOf(s), + Einzel = s?.Form == Turnierform.Einzel, + Mannschaft = m.Name, + Tag = tag, + IstCursor = rahmen == (m.Name, tag), + Spitzenspieltag = s is not null && ReferenceEquals(s, gruppentage.Spitze), + Abstiegsspieltag = s is not null && ReferenceEquals(s, gruppentage.Keller), + }); + } + + // Sonntage wie bisher nur mit Datum, Samstage und Wochentage mit Kürzel (z. B. „Sa 12.09.“). + string datum = tag.DayOfWeek == DayOfWeek.Sunday ? tag.ToString("dd.MM.", Deutsch) : tag.ToString("ddd dd.MM.", Deutsch); + return new Kompaktrasterzeile(tag, datum, gewaehlt, zellen); + } +} diff --git a/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.cs b/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.cs new file mode 100644 index 0000000..ebc8e25 --- /dev/null +++ b/src/AndiGenerator.Presentation/KompaktspieltageAnsichtViewModel.cs @@ -0,0 +1,479 @@ +// 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.Domain.Turniere; +using AndiGenerator.Engine.Turniere; +using CommunityToolkit.Mvvm.Input; +using Dock.Model.Mvvm.Controls; + +namespace AndiGenerator.Presentation; + +/// +/// Ansicht „Kompaktspieltage“ (nicht im Original): Meldungen und Plan als Raster, Karte der Spielorte mit den Fahrten, +/// Pläne (Plan aus click-TT und Varianten des Planers), Spieltage und Hinweise. Ein Klick auf einen Sonntag, eine Mannschaft oder einen Spieltag zeigt dessen +/// Fahrten; ein zweiter Klick wieder alle. Entfernungen in Luftlinie. +/// +public sealed partial class KompaktspieltageAnsichtViewModel : Document +{ + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private readonly HauptfensterViewModel hauptfenster; + private Kompaktauswertung? auswertung; + private (char Art, string Schluessel) auswahl = ('-', string.Empty); + private bool eingeschaltet; + private string kopfzeile = string.Empty; + private string kartentitel = string.Empty; + private Kartenbild karte = Kartenbild.Leer; + private IReadOnlyList plaene = []; + private IReadOnlyList planerhinweise = []; + private IReadOnlyList mannschaftsnamen = []; + private bool rechnet; + private bool angehalten; + private double fortschritt; + private string fortschrittText = string.Empty; + private string erlaeuterungstitel = string.Empty; + private IReadOnlyList erlaeuterung = []; + private IReadOnlyList spalten = []; + private IReadOnlyList zeilen = []; + private IReadOnlyList kmZeilen = []; + private IReadOnlyList spieltage = []; + private IReadOnlyList hinweise = []; + private (IReadOnlyList Spitze, IReadOnlyList Keller) gruppen = ([], []); + + /// Initialisiert die Ansicht. + /// Hauptfenster. + public KompaktspieltageAnsichtViewModel(HauptfensterViewModel hauptfenster) + { + ArgumentNullException.ThrowIfNull(hauptfenster); + this.hauptfenster = hauptfenster; + Id = "Kompaktspieltage"; + Title = "Kompaktspieltage"; + CanClose = true; + CanFloat = true; + EinschaltenCommand = new AsyncRelayCommand(() => hauptfenster.KompaktspieltageSchaltenAsync(Kompaktmodus.Bestaetigt)); + AusschaltenCommand = new AsyncRelayCommand(() => hauptfenster.KompaktspieltageSchaltenAsync(Kompaktmodus.Aus)); + TagWaehlenCommand = new RelayCommand(z => Waehlen('T', z?.Tag.ToString("O", CultureInfo.InvariantCulture))); + MannschaftWaehlenCommand = new RelayCommand(n => Waehlen('M', n)); + SpieltagWaehlenCommand = new RelayCommand(k => Waehlen('S', k?.Nummer.ToString(CultureInfo.InvariantCulture))); + PlanWaehlenCommand = new RelayCommand(w => hauptfenster.KompaktplanWaehlen(w?.Variante)); + ZelleWaehlenCommand = new RelayCommand(z => CursorSetzen(z?.Mannschaft, z?.Tag)); + FeldSetzenCommand = new AsyncRelayCommand(FeldSetzenAsync, AsyncRelayCommandOptions.AllowConcurrentExecutions); + TerminSpeichernCommand = new AsyncRelayCommand(TerminSpeichernAsync); + TagAnlegenCommand = new AsyncRelayCommand(TagAnlegenAsync); + LokalFuerAlleCommand = new AsyncRelayCommand(LokalFuerAlleAsync); + ZeitLoeschenCommand = new AsyncRelayCommand(ZeitLoeschenAsync); + Laden(); + } + + /// Holt die Erklärung der Kompaktspieltage (auch für die Einrichtung). + public static string Erklaerung => + "Bei Kompaktspieltagen treffen sich drei oder vier Mannschaften an einem Sonntag in einer Halle: beim 3er-Spieltag spielt " + + "jede gegen jede (drei Spiele), beim 4er-Spieltag spielen der Ausrichter und eine zweite Mannschaft je gegen die beiden " + + "anderen (vier Spiele in zwei Runden, zwei Spiele parallel). Jede Mannschaft soll mindestens einen Heimspieltag " + + "ausrichten. Die Meldungen stehen in den Terminwünschen aus click-TT: Uhrzeit am Sonntag = Heimspieltag möglich, ohne " + + "Eintrag = auswärts möglich, „frei“ = kann nicht, max. Heimspiele 2 = 4er-Spieltag möglich; „Nur Heim: TT.MM., …“ in " + + "den Bemerkungen. Im Raster lassen sich die Meldungen auch direkt erfassen (Schalter „Bearbeiten“). Entfernungen werden in Luftlinie zwischen den Hallen berechnet."; + + /// Holt den Befehl „Kompaktspieltage einschalten“. + public IAsyncRelayCommand EinschaltenCommand { get; } + + /// Holt den Befehl „Kompaktspieltage ausschalten“. + public IAsyncRelayCommand AusschaltenCommand { get; } + + /// Holt den Befehl „Sonntag wählen“. + public IRelayCommand TagWaehlenCommand { get; } + + /// Holt den Befehl „Mannschaft wählen“. + public IRelayCommand MannschaftWaehlenCommand { get; } + + /// Holt den Befehl „Spieltag wählen“. + public IRelayCommand SpieltagWaehlenCommand { get; } + + /// Holt den Befehl „Plan merken…“ für den gewählten Plan. + public IAsyncRelayCommand MerkenCommand => hauptfenster.PlanMerkenCommand; + + /// Holt den Befehl „Plan zeigen“ (Variante oder Plan aus click-TT). + public IRelayCommand PlanWaehlenCommand { get; } + + /// Holt einen Wert, der angibt, ob die Staffel mit Kompaktspieltagen geplant wird. + public bool Eingeschaltet + { + get => eingeschaltet; + private set => SetProperty(ref eingeschaltet, value); + } + + /// Holt die Kopfzeile, z. B. „Jugend 19 Verbandsoberliga · 15 Sonntage · 9 Mannschaften“. + public string Kopfzeile + { + get => kopfzeile; + private set => SetProperty(ref kopfzeile, value); + } + + /// Holt einen Wert, der angibt, ob der Planer gerade rechnet. + public bool Rechnet + { + get => rechnet; + private set => SetProperty(ref rechnet, value); + } + + /// Holt einen Wert, der angibt, ob die Berechnung angehalten ist (dann stehen die bisher besten Pläne zur Wahl). + public bool Angehalten + { + get => angehalten; + private set => SetProperty(ref angehalten, value); + } + + /// Holt den Fortschritt der Berechnung in Prozent. + public double Fortschritt + { + get => fortschritt; + private set => SetProperty(ref fortschritt, value); + } + + /// Holt, was die Berechnung gerade tut. + public string FortschrittText + { + get => fortschrittText; + private set => SetProperty(ref fortschrittText, value); + } + + /// Holt die Überschrift der Erläuterung, z. B. „Bestes Gesamtpaket“. + public string Erlaeuterungstitel + { + get => erlaeuterungstitel; + private set => SetProperty(ref erlaeuterungstitel, value); + } + + /// Holt die Erläuterung des gezeigten Plans: wofür er steht und wie er im Vergleich abschneidet. + public IReadOnlyList Erlaeuterung + { + get => erlaeuterung; + private set => SetProperty(ref erlaeuterung, value); + } + + /// Holt die Pläne zur Wahl: Plan aus click-TT und die Varianten der letzten Berechnung. + public IReadOnlyList Plaene + { + get => plaene; + private set => SetProperty(ref plaene, value); + } + + /// Holt die Hinweise der letzten Berechnung. + public IReadOnlyList Planerhinweise + { + get => planerhinweise; + private set + { + if (SetProperty(ref planerhinweise, value)) + { + OnPropertyChanged(nameof(HatPlanerhinweise)); + } + } + } + + /// Holt einen Wert, der angibt, ob es Hinweise der Berechnung gibt. + public bool HatPlanerhinweise => Planerhinweise.Count > 0; + + /// Holt die Namen der Mannschaften. + public IReadOnlyList Mannschaftsnamen + { + get => mannschaftsnamen; + private set => SetProperty(ref mannschaftsnamen, value); + } + + /// Holt die Spaltenköpfe des Rasters. + public IReadOnlyList Spalten + { + get => spalten; + private set + { + if (SetProperty(ref spalten, value)) + { + OnPropertyChanged(nameof(Kopfhoehe)); + } + } + } + + /// Holt die Höhe der Spaltenköpfe: Platz für den längsten vollen Mannschaftsnamen (senkrecht), wie im Ausdruck. + public double Kopfhoehe => Kompaktdruck.Kopfhoehe(spalten.Select(s => s.Name)); + + /// Holt die Zeilen des Rasters (je Sonntag). + public IReadOnlyList Zeilen + { + get => zeilen; + private set => SetProperty(ref zeilen, value); + } + + /// Holt die Karte. + public Kartenbild Karte + { + get => karte; + private set => SetProperty(ref karte, value); + } + + /// Holt die Überschrift der Karte, z. B. „Fahrten von Konz · 681 km“. + public string Kartentitel + { + get => kartentitel; + private set => SetProperty(ref kartentitel, value); + } + + /// Holt die Zeilen „km je Mannschaft“, die weiteste zuerst. + public IReadOnlyList KmZeilen + { + get => kmZeilen; + private set => SetProperty(ref kmZeilen, value); + } + + /// Holt die Karten der Spieltage. + public IReadOnlyList Spieltage + { + get => spieltage; + private set + { + if (SetProperty(ref spieltage, value)) + { + OnPropertyChanged(nameof(Spieltagbreite)); + } + } + } + + /// + /// Holt die Breite der Spieltagskarten (alle gleich, damit die Reihen ordentlich bleiben): Platz für die längste Zeile aus + /// Uhrzeit und Begegnung mit vollen Mannschaftsnamen, zwischen 310 und 560; Längeres bricht um (Peter, 14.10.2026). + /// Geschätzt mit 6,9 je Zeichen bei 12,5 pt, dazu Innenabstand und Rand der Karte. + /// + public double Spieltagbreite => + Math.Clamp(Math.Ceiling((spieltage.SelectMany(s => s.Spiele).Select(z => z.Length).DefaultIfEmpty(0).Max() * 6.9) + 34), 310, 560); + + /// Holt die Hinweise: Verstöße, fehlende Meldungen und Bemerkungen der Vereine. + public IReadOnlyList Hinweise + { + get => hinweise; + private set => SetProperty(ref hinweise, value); + } + + /// Holt einen Wert, der angibt, ob ein Plan gezeigt wird (aus click-TT oder berechnet); sonst entfallen die km je Mannschaft. + public bool HatPlan => auswertung?.Spieltage.Count > 0; + + /// Holt einen Wert, der angibt, ob Hallen ohne bekannte Lage fehlen (dann Hinweis auf die Terminwünsche). + public bool FehlenLagen => auswertung?.Mannschaften.Any(m => m.Lage is null) ?? false; + + /// Zeigt den Fortschritt der Berechnung. + /// Erledigt in Prozent. + /// Was gerade geschieht. + internal void FortschrittZeigen(double prozent, string text) + { + Fortschritt = prozent; + FortschrittText = string.Create(Deutsch, $"{prozent:N0} % – {text}"); + } + + /// Zeigt unter dem Balken, dass die Berechnung angehalten ist (der Balken bleibt stehen). + /// Der Hinweis. + internal void PauseZeigen(string text) => FortschrittText = string.Create(Deutsch, $"{Fortschritt:N0} % – {text}"); + + /// Liest Staffel und Plan neu (nach Öffnen oder Änderung). + internal void Laden() + { + Eingeschaltet = hauptfenster.KompaktEingeschaltet; + Rechnet = hauptfenster.KompaktRechnet; + Angehalten = hauptfenster.KompaktPausiert; + Planerhinweise = hauptfenster.KompaktPlanerhinweise; + auswertung = Eingeschaltet ? hauptfenster.Kompaktauswertung() : null; + Plaene = PlaeneBilden(); + Mannschaftsnamen = hauptfenster.KompaktMannschaften(); + if (auswertung is null) + { + Bearbeiten = false; + Kopfzeile = hauptfenster.Staffelname; + Spalten = []; + Zeilen = []; + KmZeilen = []; + Spieltage = []; + Hinweise = []; + Erlaeuterungstitel = string.Empty; + Erlaeuterung = []; + Karte = Kartenbild.Leer; + OnPropertyChanged(nameof(FehlenLagen)); + OnPropertyChanged(nameof(HatPlan)); + return; + } + + Kompaktauswertung a = auswertung; + (IReadOnlyList spitze, IReadOnlyList keller) = hauptfenster.KompaktGruppen(); + gruppen = (spitze, keller); + Kompaktvariante? gezeigt = hauptfenster.GewaehlteKompaktvariante; + if (gezeigt is null && !hauptfenster.HatClickTtPlan) + { + // Ohne Plan aus click-TT und noch ohne Varianten gibt es nichts zu erläutern. + Erlaeuterungstitel = string.Empty; + Erlaeuterung = []; + } + else + { + Erlaeuterungstitel = gezeigt?.Name ?? "Plan aus click-TT"; + Erlaeuterung = Kompakterlaeuterung.Zeilen(gezeigt, a, hauptfenster.Kompaktvarianten, spitze, keller); + } + + Kopfzeile = string.Create(Deutsch, $"{hauptfenster.Staffelname} · {a.Tage.Count} mögliche Termine · {a.Mannschaften.Count} Mannschaften"); + Hinweise = [.. Planerhinweise.Select(h => "ℹ " + h), .. HinweiseBilden(a)]; + OnPropertyChanged(nameof(FehlenLagen)); + OnPropertyChanged(nameof(HatPlan)); + AuswahlZeigen(); + } + + private static List HinweiseBilden(Kompaktauswertung a) + { + var liste = new List(a.Befunde.Select(b => "! " + b)); + liste.AddRange(a.Mannschaften.Where(m => m.Lage is null).Select(m => $"? {m.Name}: Halle ohne bekannte Lage – Terminwünsche (PDF) in der Einrichtung laden.")); + liste.AddRange(a.Mannschaften.Where(m => m.Bemerkung.Length > 0).Select(m => $"„ {m.Name}: {m.Bemerkung}")); + return liste; + } + + private static string Meldungsart(Turnierbereitschaft? b) => b switch + { + null => "unbekannt", + Turnierbereitschaft.Heim => "heim", + Turnierbereitschaft.Auswaerts => "auswaerts", + Turnierbereitschaft.Heim | Turnierbereitschaft.Auswaerts => "beides", + _ => "nicht", + }; + + private static string Ausrichtertext(Kompaktmannschaft m) => m.EinzelHeim > 0 + ? string.Create(Deutsch, $"{m.Ausgerichtet}× Ausrichter, {m.EinzelHeim}× Einzel zuhause") + : string.Create(Deutsch, $"{m.Ausgerichtet}× Ausrichter"); + + private static Kompaktspieltagkarte Spieltagkarte(int nummer, Kompaktspieltag s, Kompaktauswertung a, bool gewaehlt) + { + string form = s.Form switch + { + Turnierform.Dreier => "3er", + Turnierform.Vierer => "4er", + Turnierform.Einzel => "Einzel", + _ => "unbekannt", + }; + string halle = a.Mannschaften.FirstOrDefault(m => m.Name == s.Ausrichter)?.Halle ?? string.Empty; + string verlegen = string.Empty; + if (s.HalleVerlegen.Count > 0) + { + string ziel = halle.Length > 0 ? " – in diese Halle:" : string.Empty; + verlegen = "In click-TT Halle verlegen: " + string.Join(", ", s.HalleVerlegen.Select(g => g.Heim + " – " + g.Gast)) + ziel; + } + + return new Kompaktspieltagkarte( + nummer, + s.Tag.ToString("ddd dd.MM.yyyy", Deutsch), + form, + (s.Form == Turnierform.Einzel ? "Heim: " : "★ ") + (s.Ausrichter ?? "kein passender Ausrichter"), + s.Km.ToString("N0", Deutsch) + " km", + s.Spiele.Select(g => string.Create(Deutsch, $"{g.Zeitpunkt:HH:mm} {g.Heim} – {g.Gast}")).ToList(), + verlegen, + string.Join(", ", s.Gaeste) + " reisen an", + s.Hinweise, + gewaehlt) + { + Adresse = halle, + }; + } + + private static string Planzeile(Kompaktkennzahlen k, int mannschaften, double? punkte) + { + var formen = new List(); + if (k.Vierer > 0) + { + formen.Add(string.Create(Deutsch, $"{k.Vierer}× 4er")); + } + + if (k.Dreier > 0) + { + formen.Add(string.Create(Deutsch, $"{k.Dreier}× 3er")); + } + + if (k.Einzel > 0) + { + formen.Add(string.Create(Deutsch, $"{k.Einzel}× Einzel")); + } + + string zeile = string.Join(" · ", formen) + + string.Create(Deutsch, $" · {k.Km:N0} km (Unterschied {k.KmSpanne:N0} km) · Stand höchstens {k.Abstand} Spiele auseinander · bis zu {k.Gemeinsam} von {mannschaften} an einem Tag"); + zeile = k.Verstoesse > 0 ? zeile + string.Create(Deutsch, $" · {k.Verstoesse} Verstöße") : zeile; + return punkte is double p ? string.Create(Deutsch, $"{p:N1} Punkte · ") + zeile : zeile; + } + + private List PlaeneBilden() + { + Kompaktvariante? gezeigt = hauptfenster.GewaehlteKompaktvariante; + int mannschaften = hauptfenster.KompaktMannschaften().Count; + var liste = new List(); + if (hauptfenster.HatClickTtPlan) + { + string zeile = hauptfenster.KompaktClickTtPunkte is double p ? string.Create(Deutsch, $"wie in click-TT eingetragen · {p:N1} Punkte") : "wie in click-TT eingetragen"; + liste.Add(new Kompaktplanwahl("Plan aus click-TT", zeile, gezeigt is null, null)); + } + + // Gespeicherte Pläne (gesichert, gemerkt): gewählt gelten sie mit genau ihren Spielen für Druck und click-TT-Export. + liste.AddRange(hauptfenster.KompaktGespeichertePlaene.Select(v => + new Kompaktplanwahl(v.Name, Planzeile(v.Kennzahlen, mannschaften, hauptfenster.KompaktPunkte(v)), ReferenceEquals(v, gezeigt), v) { Gespeichert = true })); + liste.AddRange(hauptfenster.Kompaktvarianten.Select(v => new Kompaktplanwahl(v.Name, Planzeile(v.Kennzahlen, mannschaften, hauptfenster.KompaktPunkte(v)), ReferenceEquals(v, gezeigt), v))); + return liste; + } + + private void Waehlen(char art, string? schluessel) + { + if (schluessel is null) + { + return; + } + + auswahl = auswahl == (art, schluessel) ? ('-', string.Empty) : (art, schluessel); + AuswahlZeigen(); + } + + private void AuswahlZeigen() + { + if (auswertung is not Kompaktauswertung a) + { + return; + } + + string? team = auswahl.Art == 'M' ? auswahl.Schluessel : null; + DateOnly? tag = auswahl.Art == 'T' ? DateOnly.ParseExact(auswahl.Schluessel, "O", CultureInfo.InvariantCulture) : null; + int? nummer = auswahl.Art == 'S' ? int.Parse(auswahl.Schluessel, CultureInfo.InvariantCulture) : null; + List<(int Nummer, Kompaktspieltag Spieltag)> alle = a.Spieltage.Select((s, i) => (i, s)).ToList(); + List gewaehlt = alle + .Where(x => (tag is null || x.Spieltag.Tag == tag) && (nummer is null || x.Nummer == nummer) && (team is null || x.Spieltag.Gaeste.Contains(team))) + .Select(x => x.Spieltag) + .ToList(); + + Spalten = a.Mannschaften.Select(m => new Kompaktspalte(m.Name, m.Name == team)).ToList(); + (string, DateOnly)? rahmen = Bearbeiten ? cursor : null; + Kompaktspieltag? spitzentag = gruppen.Spitze.Count >= 3 ? Kompaktpunkte.Gruppentag(a, gruppen.Spitze) : null; + Kompaktspieltag? kellertag = gruppen.Keller.Count >= 3 ? Kompaktpunkte.Gruppentag(a, gruppen.Keller) : null; + Zeilen = a.Tage.Select(t => Rasterzeile(a, t, tag == t, team, tag is not null && tag != t, rahmen, (spitzentag, kellertag))).ToList(); + double weiteste = Math.Max(1, a.Mannschaften.Select(m => m.Km).DefaultIfEmpty(0).Max()); + KmZeilen = a.Mannschaften.OrderByDescending(m => m.Km) + .Select(m => new Kompaktkmzeile(m.Name, Ausrichtertext(m), m.Km.ToString("N0", Deutsch), m.Km / weiteste, m.Name == team)) + .ToList(); + + // Markiert sind die Spieltage der Auswahl: des Sonntags, der angeklickte, oder alle der Mannschaft (Heim und auswärts). + Spieltage = alle + .Select(x => Spieltagkarte( + x.Nummer, + x.Spieltag, + a, + (tag is not null && x.Spieltag.Tag == tag) || nummer == x.Nummer || (team is not null && x.Spieltag.Mannschaften.Contains(team)))) + .ToList(); + Kartentitel = (auswahl.Art, team) switch + { + ('M', string t) => string.Create(Deutsch, $"Fahrten von {t} ·{a.Mannschaften.First(m => m.Name == t).Km:N0} km"), + ('T', _) => gewaehlt.Count == 0 ? string.Create(Deutsch, $"Am {tag:dd.MM.yyyy} kein Spieltag") : string.Create(Deutsch, $"Fahrten am {tag:dd.MM.yyyy}"), + ('S', _) => gewaehlt.Count == 0 ? "Spieltag" : string.Create(Deutsch, $"Spieltag {gewaehlt[0].Tag:dd.MM.yyyy} bei {gewaehlt[0].Ausrichter ?? string.Empty}"), + _ => "Alle Fahrten der Halbrunde", + }; + bool alleZeigen = auswahl.Art == '-'; + Karte = Kompaktfahrtenkarte.Bauen(a, alleZeigen ? a.Spieltage : gewaehlt, team, alleZeigen); + } +} diff --git a/src/AndiGenerator.Presentation/Kompaktspieltagkarte.cs b/src/AndiGenerator.Presentation/Kompaktspieltagkarte.cs new file mode 100644 index 0000000..134c63d --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktspieltagkarte.cs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +namespace AndiGenerator.Presentation; + +/// Karte eines Kompaktspieltags. +/// Laufende Nummer (für die Auswahl). +/// z. B. „So 06.09.2026“. +/// „3er“, „4er“ oder „Einzel“. +/// z. B. „★ ESV Engers“. +/// Kilometer der Gäste als Text. +/// Je Spiel „11:00 Engers – Konz“. +/// Hinweis zur Halle in click-TT oder leer. +/// z. B. „Konz, Trier-Olewig, Wirges reisen an“. +/// Verstöße gegen die Meldungen. +/// Spieltag ist ausgewählt. +public sealed record Kompaktspieltagkarte( + int Nummer, + string Datum, + string Form, + string Ausrichter, + string Km, + IReadOnlyList Spiele, + string Verlegen, + string Gaeste, + IReadOnlyList Hinweise, + bool Gewaehlt) +{ + /// Holt die Adresse der Halle des Ausrichters aus dem Bericht „Terminwünsche“ zum Eintragen beim Verlegen (zum Kopieren), oder leer. + public string Adresse { get; init; } = string.Empty; + + /// Holt einen Wert, der angibt, ob eine Adresse zum Verlegen angezeigt wird. + public bool HatAdresse => HatVerlegen && Adresse.Length > 0; + + /// Holt einen Wert, der angibt, ob es Verstöße gibt. + public bool HatHinweise => Hinweise.Count > 0; + + /// Holt einen Wert, der angibt, ob in click-TT eine Halle zu verlegen ist. + public bool HatVerlegen => Verlegen.Length > 0; + + /// Holt die Marke oben rechts, z. B. „4er“ oder „4er · Verstoß“. + public string Marke => HatHinweise ? Form + " · Verstoß" : Form; +} diff --git a/src/AndiGenerator.Presentation/Kompaktzelle.cs b/src/AndiGenerator.Presentation/Kompaktzelle.cs new file mode 100644 index 0000000..884b7cb --- /dev/null +++ b/src/AndiGenerator.Presentation/Kompaktzelle.cs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using System.ComponentModel; + +namespace AndiGenerator.Presentation; + +/// Zelle im Raster der Kompaktspieltage: Meldung einer Mannschaft an einem Sonntag und ihre Rolle im Plan. +/// Art der Meldung: heim, auswaerts, beides, nicht oder unbekannt. +/// Inhalt: ★ Ausrichter, ● Gast, ? keine Meldung, sonst leer (auch bei „kann nicht“). +/// Die Rolle im Plan widerspricht der Meldung. +/// Gehört nicht zur gewählten Mannschaft. +/// Tooltip. +public sealed record Kompaktzelle(string Meldung, string Zeichen, bool Verstoss, bool Gedimmt, string Hinweis) : INotifyPropertyChanged +{ + private bool istCursor; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Holt, zu welchem Spieltag des Sonntags die Mannschaft gehört, wenn an diesem Sonntag mehrere stattfinden (0 = erster + /// oder einziger, 1 = zweiter, 2 = dritter); ★ und ● erscheinen dann in verschiedenen Farben. + /// + public int Spieltag { get; init; } + + /// Holt einen Wert, der angibt, ob die Begegnung zum Spitzenspieltag gehört (schwarzer Ring um ★/●). + public bool Spitzenspieltag { get; init; } + + /// Holt einen Wert, der angibt, ob die Begegnung zum Abstiegsspieltag gehört (blauer Ring um ★/●). + public bool Abstiegsspieltag { get; init; } + + /// Holt den Namen der Mannschaft (Spalte). + public string Mannschaft { get; init; } = string.Empty; + + /// Holt den Tag (Zeile). + public DateOnly Tag { get; init; } + + /// + /// Holt oder setzt einen Wert, der angibt, ob der Rahmen zum Bearbeiten auf dieser Zelle steht (ändert sich beim Bewegen + /// des Rahmens, ohne dass das Raster neu aufgebaut wird). + /// + public bool IstCursor + { + get => istCursor; + set + { + if (istCursor != value) + { + istCursor = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IstCursor))); + } + } + } + + /// Holt einen Wert, der angibt, ob die Mannschaft an diesem Sonntag eine Einzelbegegnung spielt (eigene Farbe). + public bool Einzel { get; init; } + + /// Holt einen Wert, der angibt, ob die Mannschaft zum zweiten Spieltag des Sonntags gehört. + public bool ZweiterSpieltag => !Einzel && Spieltag == 1; + + /// Holt einen Wert, der angibt, ob die Mannschaft zum dritten (oder weiteren) Spieltag des Sonntags gehört. + public bool DritterSpieltag => !Einzel && Spieltag >= 2; + + /// Holt einen Wert, der angibt, ob nur Heim gemeldet ist. + public bool IstHeim => Meldung == "heim"; + + /// Holt einen Wert, der angibt, ob nur auswärts gemeldet ist. + public bool IstAuswaerts => Meldung == "auswaerts"; + + /// Holt einen Wert, der angibt, ob Heim und auswärts gemeldet sind. + public bool IstBeides => Meldung == "beides"; + + /// Holt einen Wert, der angibt, ob die Mannschaft nicht kann. + public bool IstNicht => Meldung == "nicht"; + + /// Holt einen Wert, der angibt, ob keine Meldung vorliegt. + public bool IstUnbekannt => Meldung == "unbekannt"; + + /// Holt einen Wert, der angibt, ob die Mannschaft an diesem Tag ausrichtet (★). + public bool IstAusrichter => Zeichen == "★"; + + /// Holt einen Wert, der angibt, ob die Mannschaft an diesem Tag Gast ist (●). + public bool IstGast => Zeichen == "●"; + + /// Holt einen Wert, der angibt, ob für die Mannschaft keine Meldung vorliegt (?). + public bool IstUnbekanntZeichen => Zeichen == "?"; + + /// Holt einen Wert, der angibt, ob die Mannschaft an diesem Tag spielt (★ oder ●). + public bool Spielt => Zeichen is "★" or "●"; +} diff --git a/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs b/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs index 89f7477..649dd8f 100644 --- a/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs +++ b/src/AndiGenerator.Presentation/OptionenAnsichtViewModel.cs @@ -80,6 +80,17 @@ public bool SpielfreiAmPflichtspieltag } } + /// Holt einen Wert, der angibt, ob die Rundenplanung wählbar ist (nicht bei „(HR)“ bzw. „(FR)“ im Klassennamen). + public bool RundenplanungWaehlbar => hauptfenster.VorgegebeneRundenplanung is null; + + /// Holt den Hinweis zur vom Klassennamen vorgegebenen Halbrunde (leer, wenn wählbar). + public string Rundenhinweis => hauptfenster.VorgegebeneRundenplanung switch + { + Rundenplanung.NurVorrunde => "„(HR)“ im Klassennamen: Es wird nur die Hinrunde geplant.", + Rundenplanung.NurRueckrunde => "„(FR)“ im Klassennamen: Es wird nur die Rückrunde geplant.", + _ => string.Empty, + }; + /// Holt oder setzt die Rundenplanung als Index in . public int RundenplanungIndex { @@ -199,6 +210,8 @@ internal void Laden() FreitagZaehltZumWochenende = o.FreitagZaehltZumWochenende; SpielfreiAmPflichtspieltag = o.SpielfreiAmPflichtspieltag; RundenplanungIndex = (int)o.Rundenplanung; + OnPropertyChanged(nameof(RundenplanungWaehlbar)); + OnPropertyChanged(nameof(Rundenhinweis)); Doppelrunde = o.Doppelrunde; AutomatischeMitte = o.AutomatischeMitte; (DateOnly auto1, DateOnly auto2) = hauptfenster.AutomatischeRundenmitten() ?? (default, default); diff --git a/src/AndiGenerator.Presentation/Planvergleich.cs b/src/AndiGenerator.Presentation/Planvergleich.cs index 599e692..b609e2d 100644 --- a/src/AndiGenerator.Presentation/Planvergleich.cs +++ b/src/AndiGenerator.Presentation/Planvergleich.cs @@ -6,19 +6,28 @@ using AndiGenerator.Application; using AndiGenerator.Engine.Inseln; using AndiGenerator.Engine.Referenz; +using AndiGenerator.Engine.Turniere; 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. +/// unterscheiden. Die Güte ordnet wie der Automodus: erst A, dann B, dann C, zuletzt die Kosten. Im Kompaktmodus +/// vergleicht er stattdessen die Kennzahlen der Kompaktspieltage (). /// public sealed class Planvergleich { /// So viele Kriterien mit Unterschieden zeigt der Vergleich höchstens. private const int UnterschiedeHoechstens = 8; + /// Erklärung der Bewertung im Kompaktmodus. + private const string Kompakthinweis = + "Bewertet wie das beste Gesamtpaket: je ein Punkt für 10 % mehr Kilometer, einen Spieltag Rückstand im Stand, " + + "keinen Sonntag mit zwei 4er-Spieltagen und ungleich weite Fahrten; Verstöße gegen die Meldungen je 10 Punkte, " + + "Mannschaften ohne 3er- oder 4er-Heimspieltag je 15, ein fehlender Spitzen- oder Abstiegsspieltag je 3, 3er-Spieltage je 3, " + + "Einzelspiele bei ungerader Mannschaftszahl je 2."; + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); /// Initialisiert den Vergleich. @@ -56,15 +65,20 @@ public Planvergleich(string anlass, Planbewertung aktuell, Planbewertung gesiche 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?"); + Empfehlung = EmpfehlungFuer(vergleich); + Frage = FrageFuer(anlass, gesichertAm); + Bewertungshinweis = "Bewertet wie im Automodus: erst die Verstöße der Stufe A, dann B, dann C, zuletzt die Kosten."; + } + + private Planvergleich(string frage, IReadOnlyList zeilen, int vergleich, string bewertungshinweis) + { + Frage = frage; + Zeilen = zeilen; + Unterschiede = []; + AktuellBesser = vergleich < 0; + GesichertBesser = vergleich > 0; + Empfehlung = EmpfehlungFuer(vergleich); + Bewertungshinweis = bewertungshinweis; } /// Holt die Frage mit dem Anlass. @@ -88,8 +102,82 @@ public Planvergleich(string anlass, Planbewertung aktuell, Planbewertung gesiche /// Holt das Urteil, z. B. „Der aktuelle Plan ist besser.“. public string Empfehlung { get; } + /// Holt die Erklärung, wonach bewertet wird. + public string Bewertungshinweis { get; } + + /// + /// Vergleich im Kompaktmodus: die Kennzahlen der Kompaktspieltage (Meldungen, Heimspieltage, Fahrten, Stand, + /// gemeinsamer Spieltag, Spitzen- und Abstiegsspieltag) und die Punkte wie beim besten Gesamtpaket. + /// + /// Was geschieht, z. B. „Die Staffel wird geschlossen.“. + /// Auswertung des aktuellen Plans. + /// Auswertung des gesicherten Plans. + /// Die vier stärksten und die vier schwächsten Mannschaften (leer, wenn nicht bekannt). + /// + /// Bezug der Punkte wie in der Erläuterung: Kilometer des kürzesten Plans nur aus 4er-Spieltagen und kleinster + /// Unterschied zwischen den Mannschaften unter den berechneten Varianten; null, wenn es keine gibt (dann der + /// bessere der beiden Pläne). + /// + /// Wann der gesicherte Plan gesichert wurde. + /// Der Vergleich. + public static Planvergleich Kompakt(string anlass, Kompaktauswertung aktuell, Kompaktauswertung gesichert, IReadOnlyList> gruppen, Kompaktbezug? bezug, DateTime gesichertAm) + { + ArgumentNullException.ThrowIfNull(aktuell); + ArgumentNullException.ThrowIfNull(gesichert); + ArgumentNullException.ThrowIfNull(gruppen); + List> bekannt = gruppen.Where(g => g.Count >= 3).ToList(); + static double Bewertungskm(Kompaktauswertung a) => a.KmGesamt + a.Spieltage.Where(s => s.Form == Turnierform.Einzel).Sum(s => s.Km); + Kompaktbezug b = bezug ?? new Kompaktbezug( + Math.Min(Bewertungskm(aktuell), Bewertungskm(gesichert)), + Math.Min(Kompaktpunkte.Spanne(aktuell), Kompaktpunkte.Spanne(gesichert)), + true); + double punkteNeu = Kompaktpunkte.Berechnen(aktuell, b, bekannt).Summe; + double punkteAlt = Kompaktpunkte.Berechnen(gesichert, b, bekannt).Summe; + List zeilen = + [ + Zahlzeile("Verstöße gegen die Meldungen", aktuell.Verstoesse, gesichert.Verstoesse), + Zahlzeile("Mannschaften ohne Heimspieltag", aktuell.OhneHeimspieltag, gesichert.OhneHeimspieltag), + new Vergleichszeile("Spieltage", aktuell.Formen, gesichert.Formen, false, false), + Kmzeile("Fahrten gesamt (Luftlinie)", aktuell.KmGesamt, gesichert.KmGesamt), + Kmzeile("Unterschied weiteste – kürzeste Mannschaft", Kompaktpunkte.Spanne(aktuell), Kompaktpunkte.Spanne(gesichert)), + Zahlzeile("Stand: größter Abstand (Spiele)", aktuell.GroessterAbstand, gesichert.GroessterAbstand), + new Vergleichszeile( + "Höchstens gemeinsam an einem Sonntag", + aktuell.MeisteAmTag.ToString(Deutsch), + gesichert.MeisteAmTag.ToString(Deutsch), + aktuell.MeisteAmTag > gesichert.MeisteAmTag, + aktuell.MeisteAmTag < gesichert.MeisteAmTag), + ]; + if (bekannt.Count > 0) + { + int neu = Kompaktpunkte.Gruppentage(aktuell, bekannt); + int alt = Kompaktpunkte.Gruppentage(gesichert, bekannt); + zeilen.Add(new Vergleichszeile( + "Spitzen- und Abstiegsspieltag", + string.Create(Deutsch, $"{neu} von {bekannt.Count}"), + string.Create(Deutsch, $"{alt} von {bekannt.Count}"), + neu > alt, + neu < alt)); + } + + int vergleich = Math.Abs(punkteNeu - punkteAlt) < 0.05 ? 0 : punkteNeu.CompareTo(punkteAlt); + zeilen.Add(new Vergleichszeile("Punkte (kleiner ist besser)", punkteNeu.ToString("N1", Deutsch), punkteAlt.ToString("N1", Deutsch), vergleich < 0, vergleich > 0)); + return new Planvergleich(FrageFuer(anlass, gesichertAm), zeilen, vergleich, Kompakthinweis); + } + private static string Schluessel(Qualitaetskriterium k) => k.Stufe + "|" + k.Name; + private static string EmpfehlungFuer(int vergleich) => vergleich switch + { + < 0 => "Der aktuelle Plan ist besser.", + > 0 => "Der gesicherte Plan ist besser.", + _ => "Beide Pläne sind gleich gut.", + }; + + private static string FrageFuer(string anlass, DateTime gesichertAm) => 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?"); + /// 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) { @@ -99,4 +187,7 @@ private static (int A, int B, int C) Summen(IReadOnlyList k private static Vergleichszeile Zahlzeile(string bezeichnung, int aktuell, int gesichert) => new(bezeichnung, aktuell.ToString(Deutsch), gesichert.ToString(Deutsch), aktuell < gesichert, aktuell > gesichert); + + private static Vergleichszeile Kmzeile(string bezeichnung, double aktuell, double gesichert) => + new(bezeichnung, aktuell.ToString("N0", Deutsch) + " km", gesichert.ToString("N0", Deutsch) + " km", aktuell < gesichert - 0.5, aktuell > gesichert + 0.5); } diff --git a/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs b/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs index aa868f7..37a70c6 100644 --- a/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/SetzlistenSeiteViewModel.cs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: GPL-3.0-only using System.Collections.ObjectModel; +using System.Globalization; using AndiGenerator.Application; using CommunityToolkit.Mvvm.Input; @@ -14,6 +15,9 @@ namespace AndiGenerator.Presentation; /// public sealed class SetzlistenSeiteViewModel : DatenSeiteViewModel { + private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); + + private IReadOnlyDictionary staerken = new Dictionary(); private bool aktiv; private int auswahl = -1; private int gezogen = -1; @@ -27,6 +31,7 @@ public SetzlistenSeiteViewModel(Datenbearbeitung bearbeitung) { HochCommand = new RelayCommand(() => Verschieben(-1), () => Auswahl > 0); RunterCommand = new RelayCommand(() => Verschieben(1), () => Auswahl >= 0 && Auswahl < Reihenfolge.Count - 1); + NachStaerkeCommand = new RelayCommand(NachStaerkeOrdnen, () => HatStaerken); Reihenfolge.CollectionChanged += (_, _) => ZeilenAufbauen(); Laden(); } @@ -90,6 +95,28 @@ public int Auswahl /// Holt den Befehl „Nach unten“. public IRelayCommand RunterCommand { get; } + /// Holt den Befehl „Nach TTR ordnen“: stärkste Mannschaft zuerst, Mannschaften ohne Wert am Ende. + public IRelayCommand NachStaerkeCommand { get; } + + /// Holt einen Wert, der angibt, ob TTR-Werte aus einer Mannschaftsmeldung gespeichert sind. + public bool HatStaerken => staerken.Count > 0; + + /// Holt die Erklärung der TTR-Werte mit Herkunft oder einen leeren Text. + public string StaerkenHinweis + { + get + { + if (!HatStaerken) + { + return string.Empty; + } + + string vermerk = Bearbeitung.MannschaftsmeldungVermerk; + string herkunft = vermerk.Length > 0 ? " (" + vermerk + ")" : string.Empty; + return "Ø = Schnitt der TTR-Werte der Stammspieler aus der Mannschaftsmeldung" + herkunft + "."; + } + } + /// public override string Marke => Aktiv ? "an" : "aus"; @@ -100,6 +127,10 @@ public int Auswahl public override void Laden() { Setzlistendaten daten = Bearbeitung.Setzliste(); + staerken = Bearbeitung.Mannschaftsstaerken(); + OnPropertyChanged(nameof(HatStaerken)); + OnPropertyChanged(nameof(StaerkenHinweis)); + NachStaerkeCommand.NotifyCanExecuteChanged(); aktiv = daten.Aktiv; OnPropertyChanged(nameof(Aktiv)); Reihenfolge.Clear(); @@ -215,6 +246,31 @@ internal void Verschieben(int richtung) Auswahl = nach; } + /// Ordnet nach dem TTR-Schnitt, die stärkste Mannschaft zuerst; ohne Wert bleibt die bisherige Folge am Ende. + private void NachStaerkeOrdnen() + { + List neu = + [ + .. Reihenfolge.Where(staerken.ContainsKey).OrderByDescending(n => staerken[n].Schnitt), + .. Reihenfolge.Where(n => !staerken.ContainsKey(n)), + ]; + for (int ziel = 0; ziel < neu.Count; ziel++) + { + int von = Reihenfolge.IndexOf(neu[ziel]); + if (von != ziel) + { + Reihenfolge.Move(von, ziel); + } + } + + Auswahl = Reihenfolge.Count > 0 ? 0 : -1; + AenderungMelden(); + } + + /// Stärke einer Mannschaft als Text, z. B. „Ø 1.623“ (TTR-Schnitt der Stammspieler), sonst leer. + private string StaerkeText(string name) => + staerken.TryGetValue(name, out Mannschaftsstaerke? s) ? "Ø " + s.Schnitt.ToString("N0", Deutsch) : string.Empty; + /// Baut die angezeigten Zeilen auf; beim Ziehen in der Reihenfolge nach dem Ablegen, mit Lücke. private void ZeilenAufbauen() { @@ -231,9 +287,9 @@ private void ZeilenAufbauen() 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)); + Zeilen.Add(new Setzlistenzeile(i + 1, namen[i], luecke, gezogenerName is null && i == Auswahl) { Staerke = StaerkeText(namen[i]) }); } - Schwebend = gezogenerName is null ? null : new Setzlistenzeile(ablage + 1, gezogenerName, false, false); + Schwebend = gezogenerName is null ? null : new Setzlistenzeile(ablage + 1, gezogenerName, false, false) { Staerke = StaerkeText(gezogenerName) }; } } diff --git a/src/AndiGenerator.Presentation/Setzlistenzeile.cs b/src/AndiGenerator.Presentation/Setzlistenzeile.cs index ed7782c..ba55b72 100644 --- a/src/AndiGenerator.Presentation/Setzlistenzeile.cs +++ b/src/AndiGenerator.Presentation/Setzlistenzeile.cs @@ -11,6 +11,9 @@ namespace AndiGenerator.Presentation; /// Die gewählte Zeile (für „Nach oben“/„Nach unten“). public sealed record Setzlistenzeile(int Platz, string Name, bool IstGezogen, bool IstGewaehlt) { + /// Holt die Stärke aus der Mannschaftsmeldung, z. B. „Ø 1.623“, oder einen leeren Text. + public string Staerke { get; init; } = string.Empty; + /// Holt den Platz als Text, z. B. „3.“. public string PlatzText => Platz.ToString(System.Globalization.CultureInfo.InvariantCulture) + "."; } diff --git a/src/AndiGenerator.Presentation/UeberViewModel.cs b/src/AndiGenerator.Presentation/UeberViewModel.cs index 62801bd..bda0a85 100644 --- a/src/AndiGenerator.Presentation/UeberViewModel.cs +++ b/src/AndiGenerator.Presentation/UeberViewModel.cs @@ -90,7 +90,7 @@ public UeberViewModel(IOberflaeche oberflaeche, string version, string? basis) public static string Drittbibliotheken => "Verwendet Avalonia, Dock, CommunityToolkit.Mvvm, SkiaSharp und Velopack (jeweils MIT-Lizenz), PdfPig (Apache-2.0) sowie die " + "Schriften IBM Plex Sans und Mono (SIL Open Font License 1.1). Postleitzahlen-Daten: GeoNames (geonames.org), CC BY 4.0, " - + "gekürzt. Einzelheiten in THIRD-PARTY-NOTICES.md."; + + "gekürzt; Kartenumrisse: Natural Earth (gemeinfrei). Einzelheiten in THIRD-PARTY-NOTICES.md."; /// Holt die Programmversion. public string Version { get; } diff --git a/src/AndiGenerator.Presentation/ZusatzdatenSeiteViewModel.cs b/src/AndiGenerator.Presentation/ZusatzdatenSeiteViewModel.cs index 5365fdf..b2eb308 100644 --- a/src/AndiGenerator.Presentation/ZusatzdatenSeiteViewModel.cs +++ b/src/AndiGenerator.Presentation/ZusatzdatenSeiteViewModel.cs @@ -82,6 +82,9 @@ public ZusatzdatenSeiteViewModel(Datenbearbeitung bearbeitung, IOberflaeche ober /// Holt einen Wert, der angibt, ob ein Bericht „Terminwünsche“ geladen ist. public bool HatTerminwuensche => abgleich is not null; + /// Holt die Mannschaften der Staffel ohne Aufstellung in der geladenen Mannschaftsmeldung (leer ohne Meldung). + public IReadOnlyList OhneAufstellung => vorschlag?.OhneAufstellung ?? []; + /// Holt die Erfolgsmeldung nach dem Laden der Terminwünsche (hervorgehoben unter dem Knopf) oder einen leeren Text. public string TerminwunschErgebnis { @@ -274,6 +277,7 @@ public override void Speichern() } Bearbeitung.MannschaftsmeldungVermerken(datei); + Bearbeitung.MannschaftsstaerkenSpeichern(vorschlag.Reihenfolge); if (AlsSetzliste) { Bearbeitung.SetzlisteSpeichern(new Setzlistendaten(true, vorschlag.Reihenfolge.Select(m => m.Mannschaft).ToList())); @@ -295,6 +299,11 @@ public override void Standard() Abgleichen(null, string.Empty); } + /// Die Mannschaften der Staffel, die im geladenen Bericht „Terminwünsche“ fehlen (leer ohne Bericht). + /// Die Namen. + public IReadOnlyList NichtImBericht() => + abgleich?.Mannschaften.Where(m => !m.Gefunden).Select(m => m.Mannschaft).ToList() ?? []; + /// Liest einen Bericht „Terminwünsche“ und zeigt den Abgleich; Fehler erscheinen in . /// Pfad der PDF-Datei. public void TerminwuenscheLaden(string pfad) diff --git a/src/AndiGenerator.Rendering/Grafik.cs b/src/AndiGenerator.Rendering/Grafik.cs index a104e68..2dc78e8 100644 --- a/src/AndiGenerator.Rendering/Grafik.cs +++ b/src/AndiGenerator.Rendering/Grafik.cs @@ -12,4 +12,8 @@ namespace AndiGenerator.Rendering; /// Höhe in Zeicheneinheiten. /// Zeichnet die Grafik ab (0, 0) auf eine Fläche. /// Freiraum über der Grafik in Punkt. -public sealed record Grafik(double Breite, double Hoehe, Action Zeichnung, double AbstandDavor = 0) : Baustein(AbstandDavor); +public sealed record Grafik(double Breite, double Hoehe, Action Zeichnung, double AbstandDavor = 0) : Baustein(AbstandDavor) +{ + /// Holt einen Wert, der angibt, ob die Grafik in der Nutzbreite zentriert wird (sonst linksbündig). + public bool Zentriert { get; init; } +} diff --git a/src/AndiGenerator.Rendering/IZeichenflaeche.cs b/src/AndiGenerator.Rendering/IZeichenflaeche.cs index ad89847..e23a1fc 100644 --- a/src/AndiGenerator.Rendering/IZeichenflaeche.cs +++ b/src/AndiGenerator.Rendering/IZeichenflaeche.cs @@ -56,12 +56,44 @@ public interface IZeichenflaeche /// Füllfarbe. void Rechteck(double x, double y, double breite, double hoehe, Farbe farbe); - /// Zeichnet einen gefüllten Kreis mit Rand. + /// Zeichnet einen Kreis, gefüllt und/oder mit Rand. /// Mittelpunkt x. /// Mittelpunkt y. /// Radius. - /// Füllfarbe. - /// Randfarbe. + /// Füllfarbe oder null. + /// Randfarbe oder null. /// Randstärke. - void Kreis(double mx, double my, double radius, Farbe fuellung, Farbe rand, double staerke); + void Kreis(double mx, double my, double radius, Farbe? fuellung, Farbe? rand, double staerke); + + /// Zeichnet ein Vieleck bzw. einen Linienzug, gefüllt und/oder mit Rand (spitze Ecken, gerade Enden). + /// Die Eckpunkte. + /// Füllfarbe oder null. + /// Randfarbe oder null. + /// Randstärke. + /// Ob der Linienzug zum ersten Punkt zurückkehrt. + void Vieleck(IReadOnlyList<(double X, double Y)> punkte, Farbe? fuellung, Farbe? rand, double staerke, bool geschlossen); + + /// Schreibt einen Text mit einem Rand um die Buchstaben (z. B. einem hellen Hof auf einer Karte). + /// Der Text. + /// Linke Kante. + /// Oberkante. + /// Schriftgröße in Punkt. + /// Schriftfarbe. + /// Fettschrift. + /// Farbe des Rands. + /// Breite des Rands. + void TextMitRand(string text, double x, double y, double groesse, Farbe farbe, bool fett, Farbe rand, double staerke); + + /// Zeichnet alles bis zum Ende des Bereichs mit verringerter Deckkraft. + /// Deckkraft von 0 bis 1. + /// Der Bereich; beendet ihn. + IDisposable Deckkraft(double wert); + + /// Beschneidet alles bis zum Ende des Bereichs auf ein Rechteck. + /// Linke Kante. + /// Oberkante. + /// Breite. + /// Höhe. + /// Der Bereich; beendet ihn. + IDisposable Begrenzen(double x, double y, double breite, double hoehe); } diff --git a/src/AndiGenerator.Rendering/Seitenumbruch.cs b/src/AndiGenerator.Rendering/Seitenumbruch.cs index 889d36d..cb82eca 100644 --- a/src/AndiGenerator.Rendering/Seitenumbruch.cs +++ b/src/AndiGenerator.Rendering/Seitenumbruch.cs @@ -167,7 +167,8 @@ private void Setzen(Grafik grafik) NeueSeite(); } - elemente.Add(new GrafikElement(format.Rand, y, skala, grafik.Zeichnung)); + double x = grafik.Zentriert ? format.Rand + ((format.Nutzbreite - (grafik.Breite * skala)) / 2) : format.Rand; + elemente.Add(new GrafikElement(x, y, skala, grafik.Zeichnung)); y += hoehe; } diff --git a/src/AndiGenerator.Rendering/VersetzteZeichenflaeche.cs b/src/AndiGenerator.Rendering/VersetzteZeichenflaeche.cs index 03d067c..85e9a45 100644 --- a/src/AndiGenerator.Rendering/VersetzteZeichenflaeche.cs +++ b/src/AndiGenerator.Rendering/VersetzteZeichenflaeche.cs @@ -25,9 +25,20 @@ public void Linie(double x1, double y1, double x2, double y2, Farbe farbe, doubl public void Rechteck(double x, double y, double breite, double hoehe, Farbe farbe) => ziel.Rechteck(X(x), Y(y), breite * skala, hoehe * skala, farbe); - public void Kreis(double mx, double my, double radius, Farbe fuellung, Farbe rand, double staerke) => + public void Kreis(double mx, double my, double radius, Farbe? fuellung, Farbe? rand, double staerke) => ziel.Kreis(X(mx), Y(my), radius * skala, fuellung, rand, staerke * skala); + public void Vieleck(IReadOnlyList<(double X, double Y)> punkte, Farbe? fuellung, Farbe? rand, double staerke, bool geschlossen) => + ziel.Vieleck(punkte.Select(p => (X(p.X), Y(p.Y))).ToList(), fuellung, rand, staerke * skala, geschlossen); + + public void TextMitRand(string text, double x, double y, double groesse, Farbe farbe, bool fett, Farbe rand, double staerke) => + ziel.TextMitRand(text, X(x), Y(y), groesse * skala, farbe, fett, rand, staerke * skala); + + public IDisposable Deckkraft(double wert) => ziel.Deckkraft(wert); + + public IDisposable Begrenzen(double x, double y, double breite, double hoehe) => + ziel.Begrenzen(X(x), Y(y), breite * skala, hoehe * skala); + private double X(double x) => x0 + (x * skala); private double Y(double y) => y0 + (y * skala); diff --git a/src/AndiGenerator.UI/Ansichten/KompaktspieltageAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/KompaktspieltageAnsicht.axaml new file mode 100644 index 0000000..58a4551 --- /dev/null +++ b/src/AndiGenerator.UI/Ansichten/KompaktspieltageAnsicht.axaml @@ -0,0 +1,433 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AndiGenerator.UI/Ansichten/KompaktspieltageAnsicht.axaml.cs b/src/AndiGenerator.UI/Ansichten/KompaktspieltageAnsicht.axaml.cs new file mode 100644 index 0000000..a50d931 --- /dev/null +++ b/src/AndiGenerator.UI/Ansichten/KompaktspieltageAnsicht.axaml.cs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Presentation; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace AndiGenerator.UI.Ansichten; + +/// +/// Ansicht „Kompaktspieltage“: Pläne, Raster aus Meldungen und Plan, Karte, Spieltage und Hinweise. Im Bearbeitungsmodus +/// wählt ein Klick oder eine Pfeiltaste ein Feld des Rasters; A, H, B und Entf setzen die Meldung. +/// +public partial class KompaktspieltageAnsicht : UserControl +{ + /// Initialisiert die Ansicht. + public KompaktspieltageAnsicht() + { + InitializeComponent(); + Raster.AddHandler(PointerPressedEvent, RasterGedrueckt, RoutingStrategies.Tunnel); + Raster.KeyDown += RasterTaste; + + // Den Fokus holen, ohne dass die Ansicht zum Raster rollt (sonst springt sie bei jedem Klick). + Raster.AddHandler(RequestBringIntoViewEvent, (_, e) => e.Handled |= ReferenceEquals(e.TargetObject, Raster)); + } + + private KompaktspieltageAnsichtViewModel? Modell => DataContext as KompaktspieltageAnsichtViewModel; + + private void RasterGedrueckt(object? sender, PointerPressedEventArgs e) + { + if (Modell is not { Bearbeiten: true } modell + || (e.Source as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(b => b.Classes.Contains("zelle")) is not { DataContext: Kompaktzelle zelle }) + { + return; + } + + modell.ZelleWaehlenCommand.Execute(zelle); + Raster.Focus(); + e.Handled = true; + } + + private void RasterTaste(object? sender, KeyEventArgs e) + { + if (Modell is not { Bearbeiten: true } modell || e.Source is TextBox or ComboBox or CalendarDatePicker) + { + return; + } + + string? art = e.Key switch + { + Key.A => "A", + Key.H => "H", + Key.B => "B", + Key.Delete or Key.Back or Key.Space => "-", + _ => null, + }; + (int Spalten, int Zeilen) schritt = e.Key switch + { + Key.Left => (-1, 0), + Key.Right => (1, 0), + Key.Up => (0, -1), + Key.Down => (0, 1), + _ => (0, 0), + }; + if (art is not null) + { + modell.FeldSetzenCommand.Execute(art); + } + else if (schritt != (0, 0)) + { + modell.CursorBewegen(schritt.Spalten, schritt.Zeilen); + } + else + { + return; + } + + e.Handled = true; + } +} diff --git a/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml index 3992c10..afe30f2 100644 --- a/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml +++ b/src/AndiGenerator.UI/Ansichten/MaschinenraumAnsicht.axaml @@ -6,15 +6,12 @@ x:DataType="p:MaschinenraumAnsichtViewModel"> - @@ -73,10 +70,10 @@ - + - + @@ -84,7 +81,7 @@ - + diff --git a/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml b/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml index 9b410ac..ff956ca 100644 --- a/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml +++ b/src/AndiGenerator.UI/Ansichten/OptionenAnsicht.axaml @@ -64,7 +64,8 @@ - + + diff --git a/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs b/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs index 57350f5..e065077 100644 --- a/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs +++ b/src/AndiGenerator.UI/Ansichten/QualitaetAnsicht.axaml.cs @@ -17,13 +17,22 @@ namespace AndiGenerator.UI.Ansichten; /// public partial class QualitaetAnsicht : UserControl { + /// So weit (Pixel, im Fenster) muss sich die Maus bewegen, bis die Zeile wirklich verschoben wird. + private const double Ziehschwelle = 4; + private bool ziehen; + private bool bewegt; + private Point start; private double griffhoehe; /// Initialisiert die Ansicht. public QualitaetAnsicht() { InitializeComponent(); + + // Den Fokus (für Escape) holen, ohne dass die umgebende Bildlaufleiste dorthin springt: Sonst rutscht die Liste unter + // dem ruhenden Mauszeiger weg, und ein Klick auf den Griff verschiebt die Zeile. + AddHandler(RequestBringIntoViewEvent, (_, e) => e.Handled |= ReferenceEquals(e.TargetObject, this)); Mausradzoom.Anmelden(this); Focusable = true; KeyDown += (_, e) => @@ -53,6 +62,8 @@ private void ListeGedrueckt(object? sender, PointerPressedEventArgs e) if (griff is { DataContext: Qualitaetszeile zeile } && Modell is { } modell && modell.ZiehenBeginnen(zeile)) { ziehen = true; + bewegt = false; + start = e.GetPosition(TopLevel.GetTopLevel(this)); Border? zeilenrand = griff.GetVisualAncestors().OfType().FirstOrDefault(b => b.Classes.Contains("zeile")); griffhoehe = zeilenrand is null ? 0 : e.GetPosition(zeilenrand).Y; Schwebezeile.Width = Liste.Bounds.Width; @@ -80,6 +91,13 @@ private void ListeBewegt(object? sender, PointerEventArgs e) Mitfuehren(e); + // Erst eine echte Mausbewegung (in Fensterkoordinaten, nicht bloßer Bildlauf) verschiebt die Zeile. + bewegt |= Point.Distance(e.GetPosition(TopLevel.GetTopLevel(this)), start) >= Ziehschwelle; + if (!bewegt) + { + return; + } + // Zeile unter dem Zeiger: obere Hälfte = davor, untere Hälfte = dahinter. foreach (Control behaelter in Liste.GetRealizedContainers()) { @@ -112,6 +130,6 @@ private void Beenden(bool uebernehmen, IPointer? zeiger) ziehen = false; zeiger?.Capture(null); - Modell?.ZiehenBeenden(uebernehmen); + Modell?.ZiehenBeenden(uebernehmen && bewegt); } } diff --git a/src/AndiGenerator.UI/AnsichtenLocator.cs b/src/AndiGenerator.UI/AnsichtenLocator.cs index 6ed90ac..cee1ef7 100644 --- a/src/AndiGenerator.UI/AnsichtenLocator.cs +++ b/src/AndiGenerator.UI/AnsichtenLocator.cs @@ -25,10 +25,12 @@ public sealed class AnsichtenLocator : IDataTemplate MeldungenAnsichtViewModel => new MeldungenAnsicht(), MaschinenraumAnsichtViewModel => new MaschinenraumAnsicht(), VerlaufAnsichtViewModel => new VerlaufAnsicht(), + KompaktspieltageAnsichtViewModel => new KompaktspieltageAnsicht(), _ => null, }; /// public bool Match(object? data) => data is KostenAnsichtViewModel or TerminplanAnsichtViewModel or QualitaetAnsichtViewModel or OptionenAnsichtViewModel - or TerminwunschAnsichtViewModel or NachbarterminAnsichtViewModel or DiagrammAnsichtViewModel or MeldungenAnsichtViewModel or MaschinenraumAnsichtViewModel or VerlaufAnsichtViewModel; + or TerminwunschAnsichtViewModel or NachbarterminAnsichtViewModel or DiagrammAnsichtViewModel or MeldungenAnsichtViewModel or MaschinenraumAnsichtViewModel or VerlaufAnsichtViewModel + or KompaktspieltageAnsichtViewModel; } diff --git a/src/AndiGenerator.UI/Auszeichnungstext.cs b/src/AndiGenerator.UI/Auszeichnungstext.cs new file mode 100644 index 0000000..a0aca50 --- /dev/null +++ b/src/AndiGenerator.UI/Auszeichnungstext.cs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Media; + +namespace AndiGenerator.UI; + +/// +/// Text mit hervorgehobenen Stellen: Was in der zwischen zwei „**“ steht, erscheint in der Farbe +/// und halbfett, z. B. „Fahrten: **3.083 km**“. +/// +public sealed class Auszeichnungstext : TextBlock +{ + /// Definiert die Eigenschaft . + public static readonly StyledProperty AuszeichnungProperty = AvaloniaProperty.Register(nameof(Auszeichnung)); + + /// Definiert die Eigenschaft . + public static readonly StyledProperty HervorhebungProperty = AvaloniaProperty.Register(nameof(Hervorhebung)); + + private const string Marke = "**"; + + /// Holt oder setzt den Text mit Auszeichnung („**“ um hervorgehobene Stellen). + public string? Auszeichnung + { + get => GetValue(AuszeichnungProperty); + set => SetValue(AuszeichnungProperty, value); + } + + /// Holt oder setzt die Farbe der hervorgehobenen Stellen. + public IBrush? Hervorhebung + { + get => GetValue(HervorhebungProperty); + set => SetValue(HervorhebungProperty, value); + } + + /// + protected override Type StyleKeyOverride => typeof(TextBlock); + + /// + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + ArgumentNullException.ThrowIfNull(change); + base.OnPropertyChanged(change); + if (change.Property == AuszeichnungProperty || change.Property == HervorhebungProperty) + { + Aufbauen(); + } + } + + private void Aufbauen() + { + var inlines = new InlineCollection(); + string[] teile = (Auszeichnung ?? string.Empty).Split(Marke); + for (int i = 0; i < teile.Length; i++) + { + if (teile[i].Length == 0) + { + continue; + } + + var lauf = new Run(teile[i]); + if (i % 2 == 1) + { + lauf.FontWeight = FontWeight.SemiBold; + if (Hervorhebung is IBrush farbe) + { + lauf.Foreground = farbe; + } + } + + inlines.Add(lauf); + } + + Inlines = inlines; + } +} diff --git a/src/AndiGenerator.UI/AvaloniaOberflaeche.cs b/src/AndiGenerator.UI/AvaloniaOberflaeche.cs index 1796017..47f0142 100644 --- a/src/AndiGenerator.UI/AvaloniaOberflaeche.cs +++ b/src/AndiGenerator.UI/AvaloniaOberflaeche.cs @@ -32,6 +32,19 @@ public sealed class AvaloniaOberflaeche(Window fenster) : IOberflaeche return dateien.Count == 0 ? null : dateien[0].TryGetLocalPath(); } + /// + public async Task> DateienOeffnenAsync(string titel, IReadOnlyList filter) + { + ArgumentNullException.ThrowIfNull(filter); + IReadOnlyList dateien = await fenster.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = titel, + AllowMultiple = true, + FileTypeFilter = filter.Select(Dateityp).ToList(), + }); + return dateien.Select(d => d.TryGetLocalPath()).OfType().ToList(); + } + /// public async Task DateiSpeichernAsync(string titel, string vorschlag, Dateifilter filter) { diff --git a/src/AndiGenerator.UI/AvaloniaZeichenflaeche.cs b/src/AndiGenerator.UI/AvaloniaZeichenflaeche.cs index 7887436..7f54831 100644 --- a/src/AndiGenerator.UI/AvaloniaZeichenflaeche.cs +++ b/src/AndiGenerator.UI/AvaloniaZeichenflaeche.cs @@ -13,12 +13,16 @@ namespace AndiGenerator.UI; /// Zeichenfläche auf einem Avalonia-Zeichenkontext (Bildschirm); Einheiten sind geräteunabhängige Pixel. /// Der Zeichenkontext. -internal sealed class AvaloniaZeichenflaeche(DrawingContext kontext) : IZeichenflaeche +/// Schrift (fett = SemiBold) oder null für die Standardschrift (fett = Bold). +internal sealed class AvaloniaZeichenflaeche(DrawingContext kontext, FontFamily? schrift = null) : IZeichenflaeche { private static readonly CultureInfo Deutsch = CultureInfo.GetCultureInfo("de-DE"); private static readonly Typeface Fett = new(FontFamily.Default, FontStyle.Normal, FontWeight.Bold); private static readonly ConcurrentDictionary Pinsel = new(); + private readonly Typeface normal = schrift is null ? Typeface.Default : new Typeface(schrift); + private readonly Typeface fettschrift = schrift is null ? Fett : new Typeface(schrift, FontStyle.Normal, FontWeight.SemiBold); + /// public double TextBreite(string text, double groesse, bool fett) => Formatiert(text, groesse, Farbe.Tinte, fett).WidthIncludingTrailingWhitespace; @@ -46,12 +50,61 @@ public void Rechteck(double x, double y, double breite, double hoehe, Farbe farb kontext.FillRectangle(Brush(farbe), new Rect(x, y, breite, hoehe)); /// - public void Kreis(double mx, double my, double radius, Farbe fuellung, Farbe rand, double staerke) => - kontext.DrawEllipse(Brush(fuellung), new ImmutablePen(Brush(rand), staerke), new Point(mx, my), radius, radius); + public void Kreis(double mx, double my, double radius, Farbe? fuellung, Farbe? rand, double staerke) => + kontext.DrawEllipse( + fuellung is Farbe f ? Brush(f) : null, + rand is Farbe r ? new ImmutablePen(Brush(r), staerke) : null, + new Point(mx, my), + radius, + radius); + + /// + public void Vieleck(IReadOnlyList<(double X, double Y)> punkte, Farbe? fuellung, Farbe? rand, double staerke, bool geschlossen) + { + ArgumentNullException.ThrowIfNull(punkte); + if (punkte.Count < 2) + { + return; + } + + var geometrie = new StreamGeometry(); + using (StreamGeometryContext g = geometrie.Open()) + { + g.BeginFigure(new Point(punkte[0].X, punkte[0].Y), fuellung is not null); + for (int i = 1; i < punkte.Count; i++) + { + g.LineTo(new Point(punkte[i].X, punkte[i].Y)); + } + + g.EndFigure(geschlossen); + } + + IPen? stift = rand is Farbe r ? new ImmutablePen(Brush(r), staerke, lineCap: PenLineCap.Flat, lineJoin: PenLineJoin.Miter) : null; + kontext.DrawGeometry(fuellung is Farbe f ? Brush(f) : null, stift, geometrie); + } + + /// + public void TextMitRand(string text, double x, double y, double groesse, Farbe farbe, bool fett, Farbe rand, double staerke) + { + FormattedText formatiert = Formatiert(text, groesse, farbe, fett); + var ort = new Point(x, y); + if (formatiert.BuildGeometry(ort) is Geometry umriss) + { + kontext.DrawGeometry(Brush(rand), new ImmutablePen(Brush(rand), staerke, lineJoin: PenLineJoin.Round), umriss); + } + + kontext.DrawText(formatiert, ort); + } + + /// + public IDisposable Deckkraft(double wert) => kontext.PushOpacity(wert); + + /// + public IDisposable Begrenzen(double x, double y, double breite, double hoehe) => kontext.PushClip(new Rect(x, y, breite, hoehe)); private static IImmutableSolidColorBrush Brush(Farbe farbe) => Pinsel.GetOrAdd(farbe, f => new ImmutableSolidColorBrush(Color.FromRgb(f.R, f.G, f.B))); - private static FormattedText Formatiert(string text, double groesse, Farbe farbe, bool fett) => - new(text, Deutsch, FlowDirection.LeftToRight, fett ? Fett : Typeface.Default, groesse, Brush(farbe)); + private FormattedText Formatiert(string text, double groesse, Farbe farbe, bool fett) => + new(text, Deutsch, FlowDirection.LeftToRight, fett ? fettschrift : normal, groesse, Brush(farbe)); } diff --git a/src/AndiGenerator.UI/Druckauswahldialog.axaml b/src/AndiGenerator.UI/Druckauswahldialog.axaml index c621065..c020717 100644 --- a/src/AndiGenerator.UI/Druckauswahldialog.axaml +++ b/src/AndiGenerator.UI/Druckauswahldialog.axaml @@ -7,30 +7,40 @@ SizeToContent="WidthAndHeight" CanResize="False" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" Classes="dialog"> - - - - - - - - - - - - - - - - - - + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AndiGenerator.UI/HauptfensterView.axaml b/src/AndiGenerator.UI/HauptfensterView.axaml index eca233e..eddd0d8 100644 --- a/src/AndiGenerator.UI/HauptfensterView.axaml +++ b/src/AndiGenerator.UI/HauptfensterView.axaml @@ -239,13 +239,16 @@ - + - - - + @@ -325,18 +328,19 @@ - + diff --git a/src/AndiGenerator.UI/Kompaktkarte.cs b/src/AndiGenerator.UI/Kompaktkarte.cs new file mode 100644 index 0000000..3d03c14 --- /dev/null +++ b/src/AndiGenerator.UI/Kompaktkarte.cs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using AndiGenerator.Presentation; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace AndiGenerator.UI; + +/// +/// Zeigt ein (Ansicht „Kompaktspieltage“, nicht im Original); gezeichnet wird es vom +/// , derselben Zeichnung wie im Ausdruck. Die Karte nimmt die verfügbare Breite und die passende +/// Höhe ein; über einem Spielort nennt ein Hinweis Mannschaft und Halle. +/// +public sealed class Kompaktkarte : Control +{ + /// Definiert die Eigenschaft . + public static readonly StyledProperty BildProperty = AvaloniaProperty.Register(nameof(Bild)); + + static Kompaktkarte() => AffectsRender(BildProperty); + + /// Initialisiert die Karte. + public Kompaktkarte() + { + ClipToBounds = true; + PropertyChanged += (_, e) => + { + if (e.Property == BildProperty) + { + InvalidateMeasure(); + } + }; + } + + /// Holt oder setzt das Bild. + public Kartenbild? Bild + { + get => GetValue(BildProperty); + set => SetValue(BildProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + base.Render(context); + if (Bild is not Kartenbild bild || Bounds.Width <= 0) + { + return; + } + + context.DrawRectangle(Farbe("Schwebe"), null, new Rect(Bounds.Size), 10, 10); + using (context.PushClip(new RoundedRect(new Rect(Bounds.Size), 10))) + { + FontFamily? schrift = global::Avalonia.Application.Current?.TryFindResource("Grundschrift", out object? f) == true && f is FontFamily ff ? ff : null; + new Kartenzeichner(new AvaloniaZeichenflaeche(context, schrift)).Zeichnen(bild, Bounds.Width, Bounds.Height); + } + } + + /// + protected override Size MeasureOverride(Size availableSize) + { + Kartenbild bild = Bild ?? Kartenbild.Leer; + const double MaxHoehe = 480; + double breite = double.IsInfinity(availableSize.Width) ? bild.Breite : availableSize.Width; + if (bild.Breite <= 0 || bild.Hoehe <= 0) + { + return new Size(breite, 0); + } + + // Höchstens so hoch, dass Raster und Karte nebeneinander überschaubar bleiben (Peter, 13.10.2026). + double hoehe = Math.Min(breite * bild.Hoehe / bild.Breite, Math.Min(availableSize.Height, MaxHoehe)); + return new Size(hoehe * bild.Breite / bild.Hoehe, hoehe); + } + + /// + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (Bild is not Kartenbild bild || Bounds.Width <= 0) + { + return; + } + + double s = Bounds.Width / bild.Breite; + Point p = e.GetPosition(this); + Kartenhalle? naechste = bild.Hallen + .Where(h => Math.Abs((h.Punkt.X * s) - p.X) < 10 && Math.Abs((h.Punkt.Y * s) - p.Y) < 10) + .MinBy(h => Math.Abs((h.Punkt.X * s) - p.X) + Math.Abs((h.Punkt.Y * s) - p.Y)); + ToolTip.SetTip(this, naechste?.Ort.Hinweis); + } + + private static IBrush Farbe(string name) => + global::Avalonia.Application.Current?.TryFindResource(name, out object? wert) == true && wert is IBrush b ? b : Brushes.Gray; +} diff --git a/src/AndiGenerator.UI/Lastsaeule.cs b/src/AndiGenerator.UI/Lastsaeule.cs new file mode 100644 index 0000000..80c7848 --- /dev/null +++ b/src/AndiGenerator.UI/Lastsaeule.cs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2017 Andreas Hofmann +// SPDX-FileCopyrightText: 2026 Peter Buchmann +// SPDX-License-Identifier: GPL-3.0-only + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace AndiGenerator.UI; + +/// +/// Säule eines Rechenkerns im Leitstand: Die Spur zeigt die volle Höhe (= so schnell wie der schnellste Kern, 100 %), +/// der Balken darin den Anteil. Die Säule füllt die verfügbare Höhe, die Skala passt also immer. +/// +public sealed class Lastsaeule : Control +{ + /// Definiert die Eigenschaft . + public static readonly StyledProperty AnteilProperty = AvaloniaProperty.Register(nameof(Anteil)); + + /// Definiert die Eigenschaft . + public static readonly StyledProperty FarbeProperty = AvaloniaProperty.Register(nameof(Farbe)); + + /// Definiert die Eigenschaft . + public static readonly StyledProperty SpurProperty = AvaloniaProperty.Register(nameof(Spur)); + + /// Rundung der Ecken in Pixeln. + private const double Rundung = 3; + + static Lastsaeule() => AffectsRender(AnteilProperty, FarbeProperty, SpurProperty); + + /// Holt oder setzt den Anteil von 0 bis 1 (1 = so schnell wie der schnellste Kern). + public double Anteil + { + get => GetValue(AnteilProperty); + set => SetValue(AnteilProperty, value); + } + + /// Holt oder setzt die Farbe des Balkens. + public IBrush? Farbe + { + get => GetValue(FarbeProperty); + set => SetValue(FarbeProperty, value); + } + + /// Holt oder setzt die Farbe der Spur (volle Höhe = 100 %). + public IBrush? Spur + { + get => GetValue(SpurProperty); + set => SetValue(SpurProperty, value); + } + + /// + public override void Render(DrawingContext context) + { + base.Render(context); + Rect flaeche = new(Bounds.Size); + if (flaeche.Width <= 0 || flaeche.Height <= 0) + { + return; + } + + if (Spur is IBrush spur) + { + context.DrawRectangle(spur, null, flaeche, Rundung, Rundung); + } + + double hoehe = flaeche.Height * Math.Clamp(Anteil, 0, 1); + if (hoehe > 0) + { + context.DrawRectangle(Farbe ?? Brushes.SteelBlue, null, new Rect(0, flaeche.Height - hoehe, flaeche.Width, hoehe), Rundung, Rundung); + } + } +} diff --git a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml index d726a62..4c0059b 100644 --- a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml +++ b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml @@ -51,12 +51,13 @@ - + + @@ -66,6 +67,7 @@ + + - + diff --git a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs index 694229b..b6507d3 100644 --- a/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs +++ b/src/AndiGenerator.UI/Seiten/SetzlistenSeite.axaml.cs @@ -17,13 +17,22 @@ namespace AndiGenerator.UI.Seiten; /// public partial class SetzlistenSeite : UserControl { + /// So weit (Pixel, im Fenster) muss sich die Maus bewegen, bis die Zeile wirklich verschoben wird. + private const double Ziehschwelle = 4; + private bool ziehen; + private bool bewegt; + private Point start; private double griffhoehe; /// Initialisiert die Seite. public SetzlistenSeite() { InitializeComponent(); + + // Den Fokus (für Escape) holen, ohne dass die umgebende Bildlaufleiste dorthin springt: Sonst rutscht die Liste unter + // dem ruhenden Mauszeiger weg, und ein Klick auf den Griff verschiebt die Zeile. + AddHandler(RequestBringIntoViewEvent, (_, e) => e.Handled |= ReferenceEquals(e.TargetObject, this)); Focusable = true; KeyDown += (_, e) => { @@ -55,6 +64,8 @@ private void ListeGedrueckt(object? sender, PointerPressedEventArgs e) if (amGriff && modell.ZiehenBeginnen(index)) { ziehen = true; + bewegt = false; + start = e.GetPosition(TopLevel.GetTopLevel(this)); griffhoehe = e.GetPosition(zeile).Y; Schwebezeile.Width = Liste.Bounds.Width; Mitfuehren(e); @@ -78,6 +89,13 @@ private void ListeBewegt(object? sender, PointerEventArgs e) Mitfuehren(e); + // Erst eine echte Mausbewegung (in Fensterkoordinaten, nicht bloßer Bildlauf) verschiebt die Zeile. + bewegt |= Point.Distance(e.GetPosition(TopLevel.GetTopLevel(this)), start) >= Ziehschwelle; + if (!bewegt) + { + return; + } + // Zeile unter dem Zeiger: obere Hälfte = davor, untere Hälfte = dahinter. foreach (Control behaelter in Liste.GetRealizedContainers()) { @@ -110,6 +128,6 @@ private void Beenden(bool uebernehmen, IPointer? zeiger) ziehen = false; zeiger?.Capture(null); - Modell?.ZiehenBeenden(uebernehmen); + Modell?.ZiehenBeenden(uebernehmen && bewegt); } } diff --git a/src/AndiGenerator.UI/Seiten/WunschterminDetail.axaml.cs b/src/AndiGenerator.UI/Seiten/WunschterminDetail.axaml.cs index f704742..6b0f033 100644 --- a/src/AndiGenerator.UI/Seiten/WunschterminDetail.axaml.cs +++ b/src/AndiGenerator.UI/Seiten/WunschterminDetail.axaml.cs @@ -36,6 +36,10 @@ public WunschterminDetail() AddHandler(PointerMovedEvent, Bewegt, RoutingStrategies.Tunnel, handledEventsToo: true); AddHandler(PointerReleasedEvent, Losgelassen, RoutingStrategies.Tunnel, handledEventsToo: true); AddHandler(PointerCaptureLostEvent, (_, _) => Abbrechen(), RoutingStrategies.Bubble, handledEventsToo: true); + + // Den Fokus (für Escape) holen, ohne dass die umgebende Bildlaufleiste dorthin springt: Sonst rutscht beim Klick auf + // die erste Woche das Raster unter dem ruhenden Mauszeiger weg, und der Klick wird zum Ziehen auf die nächste Woche. + AddHandler(RequestBringIntoViewEvent, (_, e) => e.Handled |= ReferenceEquals(e.TargetObject, this)); KeyDown += (_, e) => { if (gedrueckt is not null && e.Key == Key.Escape) @@ -59,7 +63,7 @@ private void Gedrueckt(object? sender, PointerPressedEventArgs e) } gedrueckt = zelle; - start = e.GetPosition(this); + start = e.GetPosition(TopLevel.GetTopLevel(this)); griff = e.GetPosition(karte); e.Pointer.Capture(this); Focus(); @@ -73,7 +77,8 @@ private void Bewegt(object? sender, PointerEventArgs e) return; } - Point jetzt = e.GetPosition(this); + // Die Schwelle gilt in Fensterkoordinaten: Bewegt sich nur der Inhalt (Bildlauf), wird daraus kein Ziehen. + Point jetzt = e.GetPosition(TopLevel.GetTopLevel(this)); if (!ziehen) { if (!zelle.HatTermin || Point.Distance(jetzt, start) < Ziehschwelle) diff --git a/src/AndiGenerator.UI/Sicherungsdialog.axaml b/src/AndiGenerator.UI/Sicherungsdialog.axaml index 4fb8ee4..a158020 100644 --- a/src/AndiGenerator.UI/Sicherungsdialog.axaml +++ b/src/AndiGenerator.UI/Sicherungsdialog.axaml @@ -16,8 +16,8 @@ - + @@ -52,8 +52,7 @@ - +