diff --git a/CLAUDE.md b/CLAUDE.md index 0442b40..dc4dc97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,19 @@ solution's ancestral branch highlighted and labelled with its id. Every solution running number (`Farm._NeueId`, `1..N`); the ancestry is not encoded in the id but recorded via each node's stored predecessor (parent id) and followed back along the highlighted branch. +Pass `--seed ` to make a run fully reproducible: `platz.py` calls `random.seed()` with +it before any GA action, so the same seed always yields the same seating (for support +questions like "why is Aunt Erna seated there?" — rerun with the same seed to get the exact +same plan). Reproducibility needs more than seeding the RNG: `Person` defines an Id-based +`__hash__`/`__eq__` (each booking has a unique running integer Id) so that the `set()` of +people in a `Gruppe` iterates/`pop()`s in a process-stable order — otherwise the order would +hinge on object memory addresses (and, for string-keyed containers, `PYTHONHASHSEED`), leaving +the run non-reproducible even with `--seed`. Integer Ids aren't affected by `PYTHONHASHSEED`, +so no environment tweaking is required. + +`Farm.TopN(n)` returns the `n` best solutions (descending), clamped to `[0, len(Pool)]` — the +list generalisation of `Farm.Bester()` (single best), for surfacing several good alternatives. + Run the test suite with `bin/run_tests.bat` / `bin/run_tests.sh` (or directly: `python -m unittest discover -s tests -p "test_*.py" -v`). A stray global `PYTHONPATH` entry on this machine points at an unrelated project that also defines a `tests` package — this shadows diff --git a/doc/roadmap.md b/doc/roadmap.md index f5c6c59..0972691 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -160,10 +160,17 @@ Die folgenden Grundsatzfragen sind entschieden und gelten für alle Meilensteine - [x] Python 2 → 3 Migration (bereits erledigt) - [x] Unittest-Suite mit Kernszenarien (bereits erledigt) -- [ ] `Farm` erweitern: `TopN()` statt nur `Bester()` — Grundlage für "mehrere Varianten" - später. Kleine, risikoarme Änderung am bestehenden Code. -- [ ] Determinismus-Option: `random.seed()` als Parameter durchreichen, damit ein Ergebnis - bei Bedarf reproduzierbar ist (Support-Anfragen: "warum sitzt Tante Erna da?") +- [x] `Farm` erweitern: `TopN()` statt nur `Bester()` — Grundlage für "mehrere Varianten" + später. Umgesetzt in `libs/ga.py` (`Farm.TopN(n)`: die `n` besten Lösungen absteigend, + geclamped auf `[0, len(Pool)]`). +- [x] Determinismus-Option: `random.seed()` als Parameter durchreichen, damit ein Ergebnis + bei Bedarf reproduzierbar ist (Support-Anfragen: "warum sitzt Tante Erna da?"). + Umgesetzt als `--seed ` in `libs/platz.py`. **Wichtig:** der RNG-Seed allein + reichte nicht — `Person` hasht im `set()` einer `Gruppe` sonst über `id()` + (Speicheradresse), wodurch `set.pop()`-Reihenfolge in `Gruppe.teilen()` pro Prozess + variierte. Behoben durch Id-basiertes `__hash__`/`__eq__` an `Person` + (`libs/Strukturdaten.py`); Integer-Ids sind zudem `PYTHONHASHSEED`-unabhängig. Auf + `work/hochzeit` verifiziert: gleicher Seed → bytegleicher Plan, anderer Seed → abweichend. - [x] Rechtsform geklärt: Betrieb über die bestehende **MiSta GmbH** (Impressum), keine Neugründung — Ausgründung aus der Holding erst bei Erfolg (Konzeptentscheidung Nr. 7) - [x] Namensfrage geklärt: **"Sitz & Platz"** für Deutschland, **Sitzwunder/SeatWonder** @@ -201,11 +208,22 @@ Session-basiert, nichts wird dauerhaft gespeichert (schont Datenschutz-Aufwand i - [x] Einfache Browser-Testoberfläche (`web/static/index.html`): Tisch-Tabelle mit Presets, CSV-Textfeld/-Upload, Berechnen-Button, Kennzahlen + SVG-Anzeige — Zwischenschritt, wird durch den Konva-Tisch-Editor (unten) abgelöst -- [ ] **Interaktiver Tisch-Editor** (Frontend, React/Vue + SVG): - Kreise per Drag & Drop platzieren, Kapazität einstellen, Nachbarschaft automatisch - aus räumlicher Nähe ableiten (statt manueller `Nachbarliste`-Pflege in `tische.ini`) +- [x] **Interaktiver Tisch-Editor** (Frontend): Kreise per Drag & Drop platzieren, + Kapazität einstellen (+/−/Direkteingabe), Tische hinzufügen/löschen, Nachbarschaft + live aus räumlicher Nähe abgeleitet (gleiche Radius-Logik wie `_NachbarnAbleiten` + im Backend, Radius per Slider). Umgesetzt als `web/static/editor.html` — + **buildfrei, Inline-SVG + Vanilla-JS** statt React/Konva, weil das bestehende + Frontend keine Build-Toolchain (npm/package.json) hat; das State-Modell + (`{id, plaetze, x, y}`) ist dasselbe wie für eine spätere Konva-Portierung. + Backend um `GET /sitzung/{id}/tische` (Layout-Round-Trip) ergänzt, Session wird + per `localStorage` mit der Testoberfläche geteilt. Nebenbei behoben: fehlender + `import configparser` in `web/main.py` (der `/tische-datei`-Upload wäre sonst mit + `NameError` abgestürzt). - Baut direkt auf `alsSVG()`-Geometrie auf (Radius-Berechnung ist bereits da) -- [ ] Undo/Redo, "Variante neu berechnen"-Button +- [x] **Undo/Redo** im Editor: Snapshot-History (Stapel je Vor-Zustand von `state.tische`), + ein Drag zählt als ein Schritt; Tastatur `Strg+Z` / `Strg+Y` (bzw. `Strg+Shift+Z`), + `Entf` löscht den ausgewählten Tisch. "Variante neu berechnen" liegt bereits als + wiederholter `POST .../berechnen` in der Testoberfläche vor (jeder Klick = neue Variante). - [ ] Hosting: einfacher Single-Server-Betrieb bei **Hetzner** reicht (Konzeptentscheidung Nr. 6 — kein Kubernetes-Overkill für MVP) @@ -225,15 +243,28 @@ verarbeitet werden (was bei jedem Webserver der Fall ist). **Ziel:** Nutzer bekommt mehrere Vorschläge statt einer festen Lösung, kann vergleichen. -- [ ] `Farm.TopN(n)` nutzen, um z.B. 3–5 unterschiedliche gute Lösungen zurückzugeben - (ggf. mit verschiedenen Zufalls-Seeds parallel starten für Diversität) -- [ ] Vergleichsansicht im Frontend: Varianten nebeneinander, Kennzahlen sichtbar machen - (Wertigkeit, Anzahl Gruppentrennungen, "Alleine sitzende Personen") -- [ ] Performance: bei sehr großen Sälen (200+ Personen) ggf. `Zyklus`-Parameter - (Anzahl Generationen/Population) an Problemgröße koppeln, damit Antwortzeit - im Web-Kontext (Sekunden, nicht Minuten) bleibt — Lasttest nötig -- [ ] Optional: Hintergrund-Job-Queue (z.B. Celery/RQ), falls Berechnung >5s dauert, - damit der Webserver nicht blockiert +- [x] `Farm.TopN(n)` nutzen, um z.B. 3–5 unterschiedliche gute Lösungen zurückzugeben: + `POST /berechnen?varianten=N` (1–5, Default 3) nimmt `TopN` über den ganzen Endpool + und dedupliziert über eine Sitzordnungs-Signatur (`frozenset` aus Tisch-Id × + Personen-Ids), damit Mutations-Verwandte mit identischer Sitzordnung nicht als + "Varianten" zählen. Liefert der Pool weniger unterschiedliche Sitzordnungen, kommen + weniger zurück; ein weiterer Berechnen-Klick startet einen frischen Lauf. + Seed-parallele Läufe für noch mehr Diversität ("ggf.") waren nicht nötig — + ein Lauf liefert im Test bereits 3 paarweise verschiedene Varianten. + `GET /ergebnis.svg?variante=k` rendert jede Variante einzeln. +- [x] Vergleichsansicht im Frontend: Varianten-Karten nebeneinander mit Kennzahlen + (Wertigkeit, getrennte Gruppen + Teilungen gesamt, allein sitzende Personen — + letzteres via `LeuteAllein()`, zählt nur Personen aus mehrköpfigen Gruppen ohne + Gruppenmitglied am Tisch); Klick auf eine Karte schaltet Tische-/Gruppen-Tabellen + und den SVG-Raumplan auf die Variante um. +- [x] Performance: Lasttest erledigt (2026-07-12) — `work/innenhoefe` (300 Gäste, + 88 Tische) rechnet mit dem **Adaptiv**-Profil (Web-Default) in ~1,7–1,9 s + (5–7 Generationen, inkl. Python-Start), Easy in ~0,3 s. Beides weit unter der + 5-s-Schwelle → `Zyklus`-Parameter-Kopplung an die Problemgröße ist **nicht nötig**; + erst wieder prüfen, falls Zyklen deutlich vergrößert werden (siehe Risiko Nr. 2). +- [x] Optional: Hintergrund-Job-Queue (z.B. Celery/RQ), falls Berechnung >5s dauert — + **entfällt nach dem Lasttest** (s.o.): Adaptiv bleibt bei ~2 s, der Webserver + blockiert nicht spürbar. Entscheidung dokumentiert, bei Bedarf reaktivierbar. **Tech-Debt / Modernisierung (Code-Review 2026-07-11):** Beim Umbau bereits erledigt sind `eval()` → `ast.literal_eval` (bzw. ein sicherer Mini-Auswerter für die String-Ausdrücke @@ -331,10 +362,14 @@ parallel anlaufen — eher ~4–4,5 Monate bis M4-Abschluss. nicht erst kurz vor Launch. 2. **GA-Performance bei großen Sälen** — erster Datenpunkt (2026-07-11): das Beispiel `work/innenhoefe` mit 300 Gästen an 88 Tischen rechnet mit dem Easy-Zyklus in ~0,4 s - durch — weit unter der 5-s-Schwelle, ab der M2 eine Job-Queue vorsieht. Offen bleibt - der Test mit größeren Zyklen (mehr Generationen für bessere Lösungsqualität bei - vielen Trennungen) — die Lösungsgüte bei 4er-Tischen mit großen Gruppen (Wertigkeit - −384) legt nahe, dass hier eher mehr GA-Iterationen nötig sind als bisher. + durch — weit unter der 5-s-Schwelle, ab der M2 eine Job-Queue vorsieht. Zweiter + Datenpunkt (2026-07-12, M2-Lasttest): dasselbe Beispiel mit dem **Adaptiv**-Profil + (Web-Default) braucht ~1,7–1,9 s (5–7 Generationen, 3 Seeds, inkl. Python-Start) — + Job-Queue und Parameter-Kopplung bleiben unnötig. Weiter offen ist nur die + Lösungsgüte-Frage: bei 4er-Tischen mit großen Gruppen (Wertigkeit ~−560) legen die + vielen Trennungen nahe, dass mehr GA-Iterationen (größere `Geduld`/`Start`-Werte) + bessere Pläne liefern könnten — das würde die Laufzeit dann wieder Richtung + 5-s-Schwelle schieben und wäre neu zu messen. 3. **Wettbewerb ist real und etabliert** — siehe Abschnitt "Marktübersicht" oben. PerfectTablePlan verfolgt seit Jahren denselben GA-Ansatz, allerdings als Desktop-Tool. Differenzierung muss über Web-nativ + DSGVO-Hosting + detailliertere Regeln diff --git a/libs/Strukturdaten.py b/libs/Strukturdaten.py index dd4f408..9772736 100755 --- a/libs/Strukturdaten.py +++ b/libs/Strukturdaten.py @@ -30,6 +30,16 @@ class Person: def __repr__(self): return ' Person %s: "%s %s %s"' % ( self.Id, self._Titel, self._Vorname, self._Name) + def __hash__(self): + # stabil ueber die (eindeutige, fortlaufende) Id hashen statt ueber + # die Objekt-Identitaet: sonst haengt die Iterations-/pop()-Reihenfolge + # der set() aus Person-Objekten (Gruppe._Personen) an der Speicher- + # adresse und variiert pro Prozesslauf - das machte selbst mit --seed + # den GA-Lauf nichtreproduzierbar. Integer-Ids sind zudem nicht von + # PYTHONHASHSEED betroffen, daher prozessstabil. + return hash( self.Id ) + def __eq__(self, other): + return isinstance( other, Person ) and self.Id == other.Id @total_ordering class Gruppe: diff --git a/libs/ga.py b/libs/ga.py index 37c8d31..3391198 100755 --- a/libs/ga.py +++ b/libs/ga.py @@ -659,6 +659,15 @@ class Farm: print("+ speichern") def Bester(self): return max(self.Pool) + def TopN(self, N): + """die N besten Loesungen, absteigend nach Wertigkeit sortiert. + + Erweiterung von Bester() (das nur die einzelne beste liefert): gibt + eine Liste der bis zu N besten Loesungen zurueck, damit sich mehrere + gute Alternativen betrachten lassen. N wird auf [0, len(Pool)] + begrenzt; bei N >= Poolgroesse ist es der ganze Pool (sortiert).""" + N = max( 0, min( N, len(self.Pool) ) ) + return sorted( self.Pool, reverse=True )[:N] if __name__ == '__main__': diff --git a/libs/platz.py b/libs/platz.py index 45e94cd..40c3655 100755 --- a/libs/platz.py +++ b/libs/platz.py @@ -12,6 +12,7 @@ __license__ = "Python" from ga import * from Strukturdaten import * import optparse +import random if __name__ == '__main__': @@ -50,8 +51,22 @@ if __name__ == '__main__': parser.add_option( "--zyklus-art", dest="zyklusart", default="Adaptiv", help="Name des GA-Profils in zyklus.cfg (Vorgabe: Adaptiv). " "Weitere mitgelieferte Profile: Easy, Simple" ) + parser.add_option( "--seed", dest="seed", type="int", default=None, + help="Zufalls-Startwert (int) fuer random.seed(), macht den Lauf " + "reproduzierbar. Ohne Angabe laeuft der GA nichtdeterministisch. " + "Fuer Support-Rueckfragen ('warum sitzt Tante Erna dort?') mit " + "demselben --seed exakt denselben Sitzplan erneut erzeugen" ) (options, args) = parser.parse_args() + # Determinismus auf Wunsch: den globalen Zufallsgenerator seeden, bevor + # irgendeine GA-Aktion laeuft. Der gesamte Algorithmus (ga.py und + # Strukturdaten.py nutzen 'from random import *') zieht aus demselben + # Modul-RNG, daher genuegt ein einziges random.seed() hier. + if options.seed is not None: + random.seed( options.seed ) + print( "Determinismus: random.seed(%d) - Lauf ist reproduzierbar" + % options.seed ) + if options.indir is None: parser.error( "--indir ist erforderlich" ) InDir = os.path.expandvars( os.path.expanduser( options.indir ) ) diff --git a/web/main.py b/web/main.py index 8aa410f..3e39f6b 100644 --- a/web/main.py +++ b/web/main.py @@ -10,15 +10,17 @@ Endpunkte (siehe auch /docs — interaktive Swagger-UI von FastAPI): GET /sitzung/{id} — Status der Session POST /sitzung/{id}/tische — Tisch-Layout als JSON (Ersatz tische.ini) POST /sitzung/{id}/personen — Gaesteliste als CSV-Upload (Ersatz bestellung.json) - POST /sitzung/{id}/berechnen — startet die GA-Optimierung - GET /sitzung/{id}/ergebnis — Ergebnis als JSON (Tische -> Personen) - GET /sitzung/{id}/ergebnis.svg — Ergebnis als SVG (alsSVG aus dem Kern) + POST /sitzung/{id}/berechnen — startet die GA-Optimierung; liefert bis zu + ?varianten=N unterschiedliche Loesungen (M2) + GET /sitzung/{id}/ergebnis — alle Varianten des letzten Laufs als JSON + GET /sitzung/{id}/ergebnis.svg — eine Variante als SVG (?variante=k, 1 = beste) Start (lokale Testinstanz, siehe bin/run_web.bat / bin/run_web.sh): python -m uvicorn main:app --reload --app-dir web """ import ast +import configparser import math import os import sys @@ -35,7 +37,7 @@ CFGDIR = os.environ.get( 'PLATZ_CFG', os.path.join( BASISDIR, 'cfg' ) ) if LIBSDIR not in sys.path: sys.path.insert( 0, LIBSDIR ) -from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi import FastAPI, File, HTTPException, Query, UploadFile from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field @@ -149,11 +151,18 @@ def _TischeBauen(S): return TE -def _ErgebnisJSON(S): - """baut die JSON-Darstellung des besten Ergebnisses einer Session""" - SV = S['ergebnis'] - GruppenNamen = S.get( 'gruppenNamen', {} ) +def _Signatur(SV): + """Sitzordnungs-Signatur einer Loesung: welche Personen an welchem Tisch. + Zwei Loesungen mit gleicher Signatur sind fuer den Nutzer dieselbe + Sitzordnung (der GA-Pool enthaelt viele Mutations-Verwandte) — die + Varianten-Auswahl dedupliziert damit (Roadmap M2: 'unterschiedliche + gute Loesungen').""" + return frozenset( + ( T.Id, frozenset( P.Id for P in T ) ) for T in SV.Tische ) + +def _VarianteJSON(SV, GruppenNamen, Nr): + """baut die JSON-Darstellung EINER Loesung (eine Variante)""" def GruppeVonPerson(P): try: G = SV.Gruppen.GruppeVonPid( P.Id ) @@ -193,14 +202,35 @@ def _ErgebnisJSON(S): } ) GruppenListe.sort( key=lambda g: g['id'] ) - return { + # Vergleichs-Kennzahlen (Roadmap M2: Wertigkeit, Gruppentrennungen, + # allein sitzende Personen — LeuteAllein zaehlt nur Personen aus + # mehrkoepfigen Gruppen ohne Gruppenmitglied am Tisch) + Kennzahlen = { 'wertigkeit': SV.value, - 'variante': S['varianten'], + 'getrennteGruppen': sum( 1 for G in GruppenListe if G['teilungen'] > 0 ), + 'teilungenGesamt': sum( G['teilungen'] for G in GruppenListe ), + 'alleinePersonen': sum( len( SV.LeuteAllein(T) ) for T in SV.Tische ), + } + + return { + 'nr': Nr, + 'wertigkeit': SV.value, + 'kennzahlen': Kennzahlen, 'tische': TischListe, 'gruppen': GruppenListe, } +def _ErgebnisJSON(S): + """baut die JSON-Darstellung aller Varianten des letzten Laufs""" + GruppenNamen = S.get( 'gruppenNamen', {} ) + return { + 'lauf': S['laeufe'], + 'varianten': [ _VarianteJSON( SV, GruppenNamen, Nr + 1 ) + for Nr, SV in enumerate( S['ergebnisse'] ) ], + } + + # --------------------------------------------------------------- Endpunkte @app.post( '/sitzung', status_code=201 ) def SitzungAnlegen(): @@ -212,8 +242,8 @@ def SitzungAnlegen(): 'erzeugt': time.time(), 'tische': None, 'personen': None, - 'ergebnis': None, - 'varianten': 0, + 'ergebnisse': [], # Varianten des letzten Laufs (beste zuerst) + 'laeufe': 0, # wie oft .../berechnen aufgerufen wurde } return { 'id': SitzungsId, 'ttlStunden': SESSION_TTL_SEKUNDEN // 3600 } @@ -227,8 +257,9 @@ def SitzungStatus(SitzungsId: str): 'tische': len( S['tische'] ) if S['tische'] else 0, 'personen': S['personen']['nLeute'] if S['personen'] else 0, 'vips': S['personen']['nVips'] if S['personen'] else 0, - 'berechnet': S['ergebnis'] is not None, - 'varianten': S['varianten'], + 'berechnet': len( S['ergebnisse'] ) > 0, + 'laeufe': S['laeufe'], + 'varianten': len( S['ergebnisse'] ), } @@ -262,8 +293,10 @@ def TischeSetzen(SitzungsId: str, Layout: TischLayout): 'y': T.y, 'nachbarn': Nachbarn[T.id], } for T in Layout.tische ] - S['ergebnis'] = None - S['varianten'] = 0 + # Nachbar-Radius fuer den Rueckweg merken (GET .../tische), damit der + # grafische Editor dasselbe Layout inkl. Radius wieder laden kann. + S['nachbarRadius'] = Layout.nachbarRadius + S['ergebnisse'] = [] return { 'tische': S['tische'], @@ -271,6 +304,19 @@ def TischeSetzen(SitzungsId: str, Layout: TischLayout): } +@app.get( '/sitzung/{SitzungsId}/tische' ) +def TischeHolen(SitzungsId: str): + """das gespeicherte Tisch-Layout zurueckgeben (fuer den grafischen + Editor: Layout aus einer geteilten Session wieder aufnehmen). + Die abgeleitete Nachbarliste ist pro Tisch enthalten; der zuletzt + verwendete nachbarRadius kommt separat mit (Vorgabe 1.5).""" + S = _GibSession( SitzungsId ) + return { + 'tische': S['tische'] or [], + 'nachbarRadius': S.get( 'nachbarRadius', 1.5 ), + } + + @app.post( '/sitzung/{SitzungsId}/tische-datei' ) def TischDateiHochladen(SitzungsId: str, datei: UploadFile = File(...)): """bestehende tische.ini hochladen (gleiches Format wie fuer die CLI, @@ -330,8 +376,7 @@ def TischDateiHochladen(SitzungsId: str, datei: UploadFile = File(...)): detail='tische.ini enthaelt doppelte Tisch-Ids' ) S['tische'] = TischListe - S['ergebnis'] = None - S['varianten'] = 0 + S['ergebnisse'] = [] return { 'tische': S['tische'], 'plaetzeGesamt': sum( T['plaetze'] for T in S['tische'] ), @@ -364,8 +409,7 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)): 'nVips': VIPGruppe.Anzahl(), } S['gruppenNamen'] = GruppenNamen - S['ergebnis'] = None - S['varianten'] = 0 + S['ergebnisse'] = [] return { 'personen': GN.NLeute, @@ -379,8 +423,12 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)): @app.post( '/sitzung/{SitzungsId}/berechnen' ) -def Berechnen(SitzungsId: str): - """startet die GA-Optimierung; jeder Aufruf erzeugt eine neue Variante""" +def Berechnen(SitzungsId: str, + varianten: int = Query( default=3, ge=1, le=5, + description='wieviele unterschiedliche Varianten der Lauf ' + 'zurueckgeben soll (aus Farm.TopN, dedupliziert)' )): + """startet die GA-Optimierung; jeder Aufruf ist ein neuer Lauf und + liefert bis zu 'varianten' unterschiedliche gute Sitzordnungen""" S = _GibSession( SitzungsId ) if S['tische'] is None: raise HTTPException( status_code=400, @@ -419,34 +467,56 @@ def Berechnen(SitzungsId: str): except IndexError as Fehler: raise HTTPException( status_code=400, detail=str(Fehler) ) - S['ergebnis'] = GAFarm.Bester() - S['varianten'] = S['varianten'] + 1 + # Roadmap M2: nicht nur den Besten, sondern bis zu 'varianten' + # UNTERSCHIEDLICHE gute Loesungen aus dem Endpool — Farm.TopN liefert + # absteigend sortiert, die Signatur-Deduplizierung wirft Loesungen mit + # identischer Sitzordnung raus (Mutations-Verwandte). Liefert der Pool + # weniger unterschiedliche Sitzordnungen, kommen eben weniger zurueck + # (ein weiterer Klick auf Berechnen erzeugt einen frischen Lauf). + Ergebnisse, Gesehen = [], set() + for SV in GAFarm.TopN( len( GAFarm.Pool ) ): + Sig = _Signatur( SV ) + if Sig in Gesehen: + continue + Gesehen.add( Sig ) + Ergebnisse.append( SV ) + if len( Ergebnisse ) == varianten: + break + + S['ergebnisse'] = Ergebnisse + S['laeufe'] = S['laeufe'] + 1 return _ErgebnisJSON( S ) @app.get( '/sitzung/{SitzungsId}/ergebnis' ) def Ergebnis(SitzungsId: str): - """das beste bisher berechnete Ergebnis als JSON""" + """alle Varianten des letzten Laufs als JSON (beste zuerst)""" S = _GibSession( SitzungsId ) - if S['ergebnis'] is None: + if not S['ergebnisse']: raise HTTPException( status_code=404, detail='Noch nichts berechnet (POST .../berechnen)' ) return _ErgebnisJSON( S ) @app.get( '/sitzung/{SitzungsId}/ergebnis.svg' ) -def ErgebnisSVG(SitzungsId: str): - """das beste bisher berechnete Ergebnis als SVG (alsSVG aus dem Kern)""" +def ErgebnisSVG(SitzungsId: str, + variante: int = Query( default=1, ge=1, + description='Nummer der Variante (1 = beste)' )): + """eine berechnete Variante als SVG (alsSVG aus dem Kern)""" S = _GibSession( SitzungsId ) - if S['ergebnis'] is None: + if not S['ergebnisse']: raise HTTPException( status_code=404, detail='Noch nichts berechnet (POST .../berechnen)' ) + if variante > len( S['ergebnisse'] ): + raise HTTPException( status_code=404, + detail='Variante %d gibt es nicht (letzter Lauf hat %d)' + % (variante, len( S['ergebnisse'] )) ) # alsSVG schreibt in eine Datei -> Tempdatei schreiben, lesen, loeschen Handle, TempPfad = tempfile.mkstemp( suffix='.svg' ) os.close( Handle ) try: - S['ergebnis'].alsSVG( TempPfad ) + S['ergebnisse'][variante - 1].alsSVG( TempPfad ) fsock = open( TempPfad, 'r' ) SVG = fsock.read() fsock.close() diff --git a/web/static/editor.html b/web/static/editor.html new file mode 100644 index 0000000..ea91d2a --- /dev/null +++ b/web/static/editor.html @@ -0,0 +1,472 @@ + + + + + +Sitz & Platz — Tisch-Editor (M1) + + + +
+

Sitz & Platz — Interaktiver Tisch-Editor (Meilenstein 1)

+

Tische per Drag & Drop platzieren; Nachbarschaft wird aus der Nähe abgeleitet. + Sitzung: · weiter zu Gäste & Berechnung

+
+
+ +
+ + + + + + + + + + + + + + +
+ +
+
+ +
+
+ +
+ + + diff --git a/web/static/index.html b/web/static/index.html index fde8556..a5d3d73 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -53,12 +53,23 @@ padding: .3rem .7rem; margin: .15rem .3rem .15rem 0; font-size: .9rem; } .kennzahl b { color: var(--akzent); } .hinweis { font-size: .8rem; color: #6b6557; } + .varianten { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; } + .variante { + border: 1px solid var(--rand); border-radius: 6px; background: white; + padding: .5rem .8rem; cursor: pointer; font-size: .85rem; text-align: left; + color: #2a2a24; min-width: 11rem; + } + .variante.aktiv { border-color: var(--akzent); background: #e6f0ea; + box-shadow: 0 0 0 1px var(--akzent); } + .variante b { color: var(--akzent); font-size: 1rem; } + .variante .zeile { display: block; color: #6b6557; }

Sitz & Platz — Testoberfläche (Meilenstein 1)

-

Session-basiert, nichts wird dauerhaft gespeichert (TTL 24 h). Sitzung: wird angelegt…

+

Session-basiert, nichts wird dauerhaft gespeichert (TTL 24 h). Sitzung: wird angelegt… + · grafischer Tisch-Editor

@@ -104,12 +115,15 @@
-

3. Sitzordnung berechnen

-

Jeder Klick startet den genetischen Algorithmus neu und liefert - eine neue Variante — die Wertigkeit (0 = perfekt, negativer = schlechter) - macht Varianten vergleichbar.

+

3. Sitzordnung berechnen & Varianten vergleichen

+

Ein Lauf liefert bis zu drei unterschiedliche gute + Sitzordnungen zum Vergleich — die Wertigkeit (0 = perfekt, negativer = schlechter), + die Zahl getrennter Gruppen und allein sitzende Personen machen sie vergleichbar. + Variante anklicken, um Tische und Raumplan umzuschalten; jeder weitere Klick auf + „Berechnen“ startet einen frischen Lauf.

+
@@ -266,20 +280,22 @@ async function berechnenFreigeben() { } catch (e) { /* Session ggf. abgelaufen — beim Berechnen gemeldet */ } } +let letzterLauf = null; // Antwort des letzten Berechnen-Laufs (mit Varianten) + async function berechnen() { const knopf = document.getElementById('berechnenKnopf'); knopf.disabled = true; status('rechnenStatus', 'Der genetische Algorithmus läuft…'); try { - const r = await api('/sitzung/' + sitzung + '/berechnen', { method: 'POST' }); - status('rechnenStatus', '✓ Variante ' + r.variante + ' berechnet.', 'ok'); - knopf.textContent = 'Neue Variante berechnen'; - zeigeErgebnis(r); - const svg = await fetch('/sitzung/' + sitzung + '/ergebnis.svg'); - const svgText = await svg.text(); - const bild = document.getElementById('svgBild'); - bild.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText); - bild.style.display = 'block'; + const r = await api('/sitzung/' + sitzung + '/berechnen?varianten=3', { method: 'POST' }); + letzterLauf = r; + status('rechnenStatus', '✓ Lauf ' + r.lauf + ': ' + r.varianten.length + + (r.varianten.length === 1 + ? ' Variante gefunden (der Lauf konvergierte auf eine Sitzordnung).' + : ' unterschiedliche Varianten zum Vergleich.'), 'ok'); + knopf.textContent = 'Neuen Lauf berechnen'; + zeigeVariantenListe(r); + await zeigeVariante(0); } catch (e) { status('rechnenStatus', 'Fehler: ' + e.message, 'fehler'); } finally { @@ -287,15 +303,31 @@ async function berechnen() { } } -function zeigeErgebnis(r) { - const getrennte = r.gruppen.filter(g => g.teilungen > 0).length; +function zeigeVariantenListe(r) { + document.getElementById('variantenListe').innerHTML = r.varianten.map((v, i) => + '').join(''); +} + +async function zeigeVariante(i) { + if (!letzterLauf || !letzterLauf.varianten[i]) return; + const v = letzterLauf.varianten[i]; + document.querySelectorAll('#variantenListe .variante').forEach(el => + el.classList.toggle('aktiv', parseInt(el.dataset.i, 10) === i)); + document.getElementById('kennzahlen').innerHTML = - 'Wertigkeit: ' + r.wertigkeit + '' + - 'Variante: ' + r.variante + '' + - 'getrennte Gruppen: ' + getrennte + ''; + 'Variante: ' + v.nr + ' (Lauf ' + letzterLauf.lauf + ')' + + 'Wertigkeit: ' + v.kennzahlen.wertigkeit + '' + + 'getrennte Gruppen: ' + v.kennzahlen.getrennteGruppen + '' + + 'allein sitzend: ' + v.kennzahlen.alleinePersonen + ''; let html = ''; - for (const t of r.tische) { + for (const t of v.tische) { const gaeste = t.personen.map(p => ((p.titel ? p.titel + ' ' : '') + p.vorname + ' ' + p.nachname + ' (' + p.gruppe + ')')).join(', '); @@ -306,10 +338,19 @@ function zeigeErgebnis(r) { html += '

Gruppen

' + '
TischBelegungGäste (Gruppe)
' + - r.gruppen.map(g => '').join('') + '
GruppePersonenTeilungenStrafpunkte
' + g.name + '' + g.anzahl + '' + + v.gruppen.map(g => '
' + g.name + '' + g.anzahl + '' + g.teilungen + '' + g.wert + '
'; document.getElementById('ergebnisTabellen').innerHTML = html; + + // Raumplan der gewaehlten Variante + try { + const svg = await fetch('/sitzung/' + sitzung + '/ergebnis.svg?variante=' + v.nr); + const svgText = await svg.text(); + const bild = document.getElementById('svgBild'); + bild.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText); + bild.style.display = 'block'; + } catch (e) { /* SVG optional — Tabellen stehen schon da */ } } /* ------------------------------------------------ Start */ @@ -317,13 +358,26 @@ window.addEventListener('DOMContentLoaded', async () => { document.getElementById('csvText').value = beispielCSV; presetReihe(); try { - const r = await api('/sitzung', { method: 'POST' }); - sitzung = r.id; + sitzung = await sitzungHolen(); document.getElementById('sitzungsId').textContent = sitzung; } catch (e) { document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message; } }); + +// Session mit dem grafischen Editor (editor.html) ueber localStorage teilen: +// eine bestehende, noch gueltige Sitzung wiederverwenden statt jedes Mal +// eine neue anzulegen — so wirkt ein im Editor gebautes Layout hier weiter. +async function sitzungHolen() { + let id = localStorage.getItem('platzSitzung'); + if (id) { + const ok = await fetch('/sitzung/' + id).then(r => r.ok).catch(() => false); + if (ok) return id; + } + const r = await api('/sitzung', { method: 'POST' }); + localStorage.setItem('platzSitzung', r.id); + return r.id; +}