Compare commits

..

2 Commits

Author SHA1 Message Date
Michael Stangl db69f3498d Funktion zum relode der Tische aus der Session und der Gäste laden 2026-07-12 17:03:23 +02:00
Michael Stangl f880ae1c97 in der Roademap weitergemacht 2026-07-12 17:02:14 +02:00
8 changed files with 799 additions and 83 deletions
+13
View File
@@ -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 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. via each node's stored predecessor (parent id) and followed back along the highlighted branch.
Pass `--seed <int>` 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 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 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 machine points at an unrelated project that also defines a `tests` package — this shadows
+56 -21
View File
@@ -160,10 +160,17 @@ Die folgenden Grundsatzfragen sind entschieden und gelten für alle Meilensteine
- [x] Python 2 → 3 Migration (bereits erledigt) - [x] Python 2 → 3 Migration (bereits erledigt)
- [x] Unittest-Suite mit Kernszenarien (bereits erledigt) - [x] Unittest-Suite mit Kernszenarien (bereits erledigt)
- [ ] `Farm` erweitern: `TopN()` statt nur `Bester()` — Grundlage für "mehrere Varianten" - [x] `Farm` erweitern: `TopN()` statt nur `Bester()` — Grundlage für "mehrere Varianten"
später. Kleine, risikoarme Änderung am bestehenden Code. später. Umgesetzt in `libs/ga.py` (`Farm.TopN(n)`: die `n` besten Lösungen absteigend,
- [ ] Determinismus-Option: `random.seed()` als Parameter durchreichen, damit ein Ergebnis geclamped auf `[0, len(Pool)]`).
bei Bedarf reproduzierbar ist (Support-Anfragen: "warum sitzt Tante Erna da?") - [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 <int>` 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 - [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) Neugründung — Ausgründung aus der Holding erst bei Erfolg (Konzeptentscheidung Nr. 7)
- [x] Namensfrage geklärt: **"Sitz & Platz"** für Deutschland, **Sitzwunder/SeatWonder** - [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 - [x] Einfache Browser-Testoberfläche (`web/static/index.html`): Tisch-Tabelle mit
Presets, CSV-Textfeld/-Upload, Berechnen-Button, Kennzahlen + SVG-Anzeige — Presets, CSV-Textfeld/-Upload, Berechnen-Button, Kennzahlen + SVG-Anzeige —
Zwischenschritt, wird durch den Konva-Tisch-Editor (unten) abgelöst Zwischenschritt, wird durch den Konva-Tisch-Editor (unten) abgelöst
- [ ] **Interaktiver Tisch-Editor** (Frontend, React/Vue + SVG): - [x] **Interaktiver Tisch-Editor** (Frontend): Kreise per Drag & Drop platzieren,
Kreise per Drag & Drop platzieren, Kapazität einstellen, Nachbarschaft automatisch Kapazität einstellen (+//Direkteingabe), Tische hinzufügen/löschen, Nachbarschaft
aus räumlicher Nähe ableiten (statt manueller `Nachbarliste`-Pflege in `tische.ini`) 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) - 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 - [ ] Hosting: einfacher Single-Server-Betrieb bei **Hetzner** reicht (Konzeptentscheidung
Nr. 6 — kein Kubernetes-Overkill für MVP) 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. **Ziel:** Nutzer bekommt mehrere Vorschläge statt einer festen Lösung, kann vergleichen.
- [ ] `Farm.TopN(n)` nutzen, um z.B. 35 unterschiedliche gute Lösungen zurückzugeben - [x] `Farm.TopN(n)` nutzen, um z.B. 35 unterschiedliche gute Lösungen zurückzugeben:
(ggf. mit verschiedenen Zufalls-Seeds parallel starten für Diversität) `POST /berechnen?varianten=N` (15, Default 3) nimmt `TopN` über den ganzen Endpool
- [ ] Vergleichsansicht im Frontend: Varianten nebeneinander, Kennzahlen sichtbar machen und dedupliziert über eine Sitzordnungs-Signatur (`frozenset` aus Tisch-Id ×
(Wertigkeit, Anzahl Gruppentrennungen, "Alleine sitzende Personen") Personen-Ids), damit Mutations-Verwandte mit identischer Sitzordnung nicht als
- [ ] Performance: bei sehr großen Sälen (200+ Personen) ggf. `Zyklus`-Parameter "Varianten" zählen. Liefert der Pool weniger unterschiedliche Sitzordnungen, kommen
(Anzahl Generationen/Population) an Problemgröße koppeln, damit Antwortzeit weniger zurück; ein weiterer Berechnen-Klick startet einen frischen Lauf.
im Web-Kontext (Sekunden, nicht Minuten) bleibt — Lasttest nötig Seed-parallele Läufe für noch mehr Diversität ("ggf.") waren nicht nötig
- [ ] Optional: Hintergrund-Job-Queue (z.B. Celery/RQ), falls Berechnung >5s dauert, ein Lauf liefert im Test bereits 3 paarweise verschiedene Varianten.
damit der Webserver nicht blockiert `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,71,9 s
(57 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 **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 `eval()``ast.literal_eval` (bzw. ein sicherer Mini-Auswerter für die String-Ausdrücke
@@ -331,10 +362,14 @@ parallel anlaufen — eher ~44,5 Monate bis M4-Abschluss.
nicht erst kurz vor Launch. nicht erst kurz vor Launch.
2. **GA-Performance bei großen Sälen** — erster Datenpunkt (2026-07-11): das Beispiel 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 `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 durch — weit unter der 5-s-Schwelle, ab der M2 eine Job-Queue vorsieht. Zweiter
der Test mit größeren Zyklen (mehr Generationen für bessere Lösungsqualität bei Datenpunkt (2026-07-12, M2-Lasttest): dasselbe Beispiel mit dem **Adaptiv**-Profil
vielen Trennungen) — die Lösungsgüte bei 4er-Tischen mit großen Gruppen (Wertigkeit (Web-Default) braucht ~1,71,9 s (57 Generationen, 3 Seeds, inkl. Python-Start) —
384) legt nahe, dass hier eher mehr GA-Iterationen nötig sind als bisher. 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. 3. **Wettbewerb ist real und etabliert** — siehe Abschnitt "Marktübersicht" oben.
PerfectTablePlan verfolgt seit Jahren denselben GA-Ansatz, allerdings als Desktop-Tool. PerfectTablePlan verfolgt seit Jahren denselben GA-Ansatz, allerdings als Desktop-Tool.
Differenzierung muss über Web-nativ + DSGVO-Hosting + detailliertere Regeln Differenzierung muss über Web-nativ + DSGVO-Hosting + detailliertere Regeln
+10
View File
@@ -30,6 +30,16 @@ class Person:
def __repr__(self): def __repr__(self):
return ' Person %s: "%s %s %s"' % ( self.Id, self._Titel, return ' Person %s: "%s %s %s"' % ( self.Id, self._Titel,
self._Vorname, self._Name) 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 @total_ordering
class Gruppe: class Gruppe:
+9
View File
@@ -659,6 +659,15 @@ class Farm:
print("+ speichern") print("+ speichern")
def Bester(self): def Bester(self):
return max(self.Pool) 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__': if __name__ == '__main__':
+15
View File
@@ -12,6 +12,7 @@ __license__ = "Python"
from ga import * from ga import *
from Strukturdaten import * from Strukturdaten import *
import optparse import optparse
import random
if __name__ == '__main__': if __name__ == '__main__':
@@ -50,8 +51,22 @@ if __name__ == '__main__':
parser.add_option( "--zyklus-art", dest="zyklusart", default="Adaptiv", parser.add_option( "--zyklus-art", dest="zyklusart", default="Adaptiv",
help="Name des GA-Profils in zyklus.cfg (Vorgabe: Adaptiv). " help="Name des GA-Profils in zyklus.cfg (Vorgabe: Adaptiv). "
"Weitere mitgelieferte Profile: Easy, Simple" ) "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() (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: if options.indir is None:
parser.error( "--indir ist erforderlich" ) parser.error( "--indir ist erforderlich" )
InDir = os.path.expandvars( os.path.expanduser( options.indir ) ) InDir = os.path.expandvars( os.path.expanduser( options.indir ) )
+100 -30
View File
@@ -10,15 +10,17 @@ Endpunkte (siehe auch /docs — interaktive Swagger-UI von FastAPI):
GET /sitzung/{id} Status der Session GET /sitzung/{id} Status der Session
POST /sitzung/{id}/tische Tisch-Layout als JSON (Ersatz tische.ini) 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}/personen Gaesteliste als CSV-Upload (Ersatz bestellung.json)
POST /sitzung/{id}/berechnen startet die GA-Optimierung POST /sitzung/{id}/berechnen startet die GA-Optimierung; liefert bis zu
GET /sitzung/{id}/ergebnis Ergebnis als JSON (Tische -> Personen) ?varianten=N unterschiedliche Loesungen (M2)
GET /sitzung/{id}/ergebnis.svg Ergebnis als SVG (alsSVG aus dem Kern) 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): Start (lokale Testinstanz, siehe bin/run_web.bat / bin/run_web.sh):
python -m uvicorn main:app --reload --app-dir web python -m uvicorn main:app --reload --app-dir web
""" """
import ast import ast
import configparser
import math import math
import os import os
import sys import sys
@@ -35,7 +37,7 @@ CFGDIR = os.environ.get( 'PLATZ_CFG', os.path.join( BASISDIR, 'cfg' ) )
if LIBSDIR not in sys.path: if LIBSDIR not in sys.path:
sys.path.insert( 0, LIBSDIR ) 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.responses import Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -149,11 +151,18 @@ def _TischeBauen(S):
return TE return TE
def _ErgebnisJSON(S): def _Signatur(SV):
"""baut die JSON-Darstellung des besten Ergebnisses einer Session""" """Sitzordnungs-Signatur einer Loesung: welche Personen an welchem Tisch.
SV = S['ergebnis'] Zwei Loesungen mit gleicher Signatur sind fuer den Nutzer dieselbe
GruppenNamen = S.get( 'gruppenNamen', {} ) 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): def GruppeVonPerson(P):
try: try:
G = SV.Gruppen.GruppeVonPid( P.Id ) G = SV.Gruppen.GruppeVonPid( P.Id )
@@ -193,14 +202,35 @@ def _ErgebnisJSON(S):
} ) } )
GruppenListe.sort( key=lambda g: g['id'] ) 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, '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, 'tische': TischListe,
'gruppen': GruppenListe, '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 # --------------------------------------------------------------- Endpunkte
@app.post( '/sitzung', status_code=201 ) @app.post( '/sitzung', status_code=201 )
def SitzungAnlegen(): def SitzungAnlegen():
@@ -212,8 +242,8 @@ def SitzungAnlegen():
'erzeugt': time.time(), 'erzeugt': time.time(),
'tische': None, 'tische': None,
'personen': None, 'personen': None,
'ergebnis': None, 'ergebnisse': [], # Varianten des letzten Laufs (beste zuerst)
'varianten': 0, 'laeufe': 0, # wie oft .../berechnen aufgerufen wurde
} }
return { 'id': SitzungsId, 'ttlStunden': SESSION_TTL_SEKUNDEN // 3600 } 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, 'tische': len( S['tische'] ) if S['tische'] else 0,
'personen': S['personen']['nLeute'] if S['personen'] else 0, 'personen': S['personen']['nLeute'] if S['personen'] else 0,
'vips': S['personen']['nVips'] if S['personen'] else 0, 'vips': S['personen']['nVips'] if S['personen'] else 0,
'berechnet': S['ergebnis'] is not None, 'berechnet': len( S['ergebnisse'] ) > 0,
'varianten': S['varianten'], 'laeufe': S['laeufe'],
'varianten': len( S['ergebnisse'] ),
} }
@@ -262,8 +293,10 @@ def TischeSetzen(SitzungsId: str, Layout: TischLayout):
'y': T.y, 'y': T.y,
'nachbarn': Nachbarn[T.id], 'nachbarn': Nachbarn[T.id],
} for T in Layout.tische ] } for T in Layout.tische ]
S['ergebnis'] = None # Nachbar-Radius fuer den Rueckweg merken (GET .../tische), damit der
S['varianten'] = 0 # grafische Editor dasselbe Layout inkl. Radius wieder laden kann.
S['nachbarRadius'] = Layout.nachbarRadius
S['ergebnisse'] = []
return { return {
'tische': S['tische'], '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' ) @app.post( '/sitzung/{SitzungsId}/tische-datei' )
def TischDateiHochladen(SitzungsId: str, datei: UploadFile = File(...)): def TischDateiHochladen(SitzungsId: str, datei: UploadFile = File(...)):
"""bestehende tische.ini hochladen (gleiches Format wie fuer die CLI, """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' ) detail='tische.ini enthaelt doppelte Tisch-Ids' )
S['tische'] = TischListe S['tische'] = TischListe
S['ergebnis'] = None S['ergebnisse'] = []
S['varianten'] = 0
return { return {
'tische': S['tische'], 'tische': S['tische'],
'plaetzeGesamt': sum( T['plaetze'] for T in 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(), 'nVips': VIPGruppe.Anzahl(),
} }
S['gruppenNamen'] = GruppenNamen S['gruppenNamen'] = GruppenNamen
S['ergebnis'] = None S['ergebnisse'] = []
S['varianten'] = 0
return { return {
'personen': GN.NLeute, 'personen': GN.NLeute,
@@ -379,8 +423,12 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)):
@app.post( '/sitzung/{SitzungsId}/berechnen' ) @app.post( '/sitzung/{SitzungsId}/berechnen' )
def Berechnen(SitzungsId: str): def Berechnen(SitzungsId: str,
"""startet die GA-Optimierung; jeder Aufruf erzeugt eine neue Variante""" 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 ) S = _GibSession( SitzungsId )
if S['tische'] is None: if S['tische'] is None:
raise HTTPException( status_code=400, raise HTTPException( status_code=400,
@@ -419,34 +467,56 @@ def Berechnen(SitzungsId: str):
except IndexError as Fehler: except IndexError as Fehler:
raise HTTPException( status_code=400, detail=str(Fehler) ) raise HTTPException( status_code=400, detail=str(Fehler) )
S['ergebnis'] = GAFarm.Bester() # Roadmap M2: nicht nur den Besten, sondern bis zu 'varianten'
S['varianten'] = S['varianten'] + 1 # 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 ) return _ErgebnisJSON( S )
@app.get( '/sitzung/{SitzungsId}/ergebnis' ) @app.get( '/sitzung/{SitzungsId}/ergebnis' )
def Ergebnis(SitzungsId: str): def Ergebnis(SitzungsId: str):
"""das beste bisher berechnete Ergebnis als JSON""" """alle Varianten des letzten Laufs als JSON (beste zuerst)"""
S = _GibSession( SitzungsId ) S = _GibSession( SitzungsId )
if S['ergebnis'] is None: if not S['ergebnisse']:
raise HTTPException( status_code=404, raise HTTPException( status_code=404,
detail='Noch nichts berechnet (POST .../berechnen)' ) detail='Noch nichts berechnet (POST .../berechnen)' )
return _ErgebnisJSON( S ) return _ErgebnisJSON( S )
@app.get( '/sitzung/{SitzungsId}/ergebnis.svg' ) @app.get( '/sitzung/{SitzungsId}/ergebnis.svg' )
def ErgebnisSVG(SitzungsId: str): def ErgebnisSVG(SitzungsId: str,
"""das beste bisher berechnete Ergebnis als SVG (alsSVG aus dem Kern)""" 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 ) S = _GibSession( SitzungsId )
if S['ergebnis'] is None: if not S['ergebnisse']:
raise HTTPException( status_code=404, raise HTTPException( status_code=404,
detail='Noch nichts berechnet (POST .../berechnen)' ) 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 # alsSVG schreibt in eine Datei -> Tempdatei schreiben, lesen, loeschen
Handle, TempPfad = tempfile.mkstemp( suffix='.svg' ) Handle, TempPfad = tempfile.mkstemp( suffix='.svg' )
os.close( Handle ) os.close( Handle )
try: try:
S['ergebnis'].alsSVG( TempPfad ) S['ergebnisse'][variante - 1].alsSVG( TempPfad )
fsock = open( TempPfad, 'r' ) fsock = open( TempPfad, 'r' )
SVG = fsock.read() SVG = fsock.read()
fsock.close() fsock.close()
+472
View File
@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sitz &amp; Platz — Tisch-Editor (M1)</title>
<style>
:root {
--akzent: #2f6f4f;
--akzent-hell: #e6f0ea;
--rand: #d9d4c8;
--hintergrund: #faf8f3;
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
margin: 0; background: var(--hintergrund); color: #2a2a24; line-height: 1.5;
}
header { background: var(--akzent); color: white; padding: 1rem 1.5rem; }
header h1 { margin: 0; font-size: 1.3rem; }
header p { margin: .2rem 0 0; opacity: .85; font-size: .85rem; }
header a { color: #d6eade; }
main { max-width: 68rem; margin: 0 auto; padding: 1rem 1.5rem 4rem; }
.werkzeug {
display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
background: white; border: 1px solid var(--rand); border-radius: 8px;
padding: .6rem .75rem; margin-bottom: 1rem;
}
.werkzeug .trenner { width: 1px; height: 1.6rem; background: var(--rand); margin: 0 .3rem; }
.werkzeug label { font-size: .82rem; color: #6b6557; display: inline-flex; align-items: center; gap: .3rem; }
button {
background: var(--akzent); color: white; border: 0; border-radius: 4px;
padding: .42rem .8rem; font-size: .88rem; cursor: pointer;
}
button.sekundaer { background: #8b8577; }
button.geist { background: white; color: var(--akzent); border: 1px solid var(--rand); }
button:disabled { background: #cfcabc; color: #f3f0e8; cursor: not-allowed; }
.buehne {
display: grid; grid-template-columns: 1fr 15rem; gap: 1rem; align-items: start;
}
@media (max-width: 48rem) { .buehne { grid-template-columns: 1fr; } }
#leinwand {
background: white; border: 1px solid var(--rand); border-radius: 8px;
overflow: auto; touch-action: none; min-height: 24rem;
}
#leinwand svg { display: block; }
.circle-tisch { cursor: grab; }
.circle-tisch:active { cursor: grabbing; }
aside {
background: white; border: 1px solid var(--rand); border-radius: 8px;
padding: 1rem; font-size: .9rem;
}
aside h2 { font-size: 1rem; margin: 0 0 .6rem; color: var(--akzent); }
aside .feld { margin: .5rem 0; }
aside input[type=number] { width: 4rem; }
.zahl { display: inline-block; background: #f1eee6; border-radius: 4px;
padding: .25rem .6rem; margin: .1rem .3rem .1rem 0; font-size: .85rem; }
.zahl b { color: var(--akzent); }
.status { font-size: .85rem; margin-top: .6rem; min-height: 1.2rem; }
.status.ok { color: var(--akzent); }
.status.fehler { color: #a03030; white-space: pre-wrap; }
.hinweis { font-size: .8rem; color: #6b6557; }
kbd { background: #f1eee6; border: 1px solid var(--rand); border-bottom-width: 2px;
border-radius: 3px; padding: 0 .3rem; font-size: .78rem; }
</style>
</head>
<body>
<header>
<h1>Sitz &amp; Platz <small style="font-weight:normal">— Interaktiver Tisch-Editor (Meilenstein 1)</small></h1>
<p>Tische per Drag &amp; Drop platzieren; Nachbarschaft wird aus der Nähe abgeleitet.
Sitzung: <code id="sitzungsId"></code> · weiter zu <a href="/">Gäste &amp; Berechnung</a></p>
</header>
<main>
<div class="werkzeug">
<button id="btnUndo" title="Rückgängig (Strg+Z)" disabled>↶ Rückgängig</button>
<button id="btnRedo" title="Wiederholen (Strg+Y)" disabled>↷ Wiederholen</button>
<span class="trenner"></span>
<button id="btnAdd" class="geist">+ Tisch</button>
<button id="btnReihe" class="sekundaer">Preset: 4er-Reihe</button>
<button id="btnRaster" class="sekundaer">Preset: 3×3-Raster</button>
<span class="trenner"></span>
<label>Nachbar-Radius
<input type="range" id="radius" min="0.5" max="3" step="0.1" value="1.5">
<b id="radiusWert">1,5</b>
</label>
<label>Raster
<select id="raster">
<option value="0.5" selected>0,5</option>
<option value="1">1</option>
<option value="1.5">1,5</option>
<option value="2">2</option>
</select>
</label>
<span class="trenner"></span>
<label>tische.ini <input type="file" id="iniDatei" accept=".ini,text/plain"></label>
<button id="btnIni" class="geist">laden</button>
<span class="trenner"></span>
<button id="btnSpeichern">Layout speichern</button>
</div>
<div class="buehne">
<div id="leinwand"></div>
<aside>
<h2>Ausgewählter Tisch</h2>
<div id="inspektor"><p class="hinweis">Kein Tisch ausgewählt. Auf einen Tisch
klicken, um Plätze zu ändern oder ihn zu löschen. Ziehen zum Verschieben.</p></div>
<hr style="border:0;border-top:1px solid var(--rand);margin:.8rem 0">
<div id="zahlen"></div>
<p class="hinweis" style="margin-top:.6rem">Tastatur: <kbd>Strg</kbd>+<kbd>Z</kbd> /
<kbd>Strg</kbd>+<kbd>Y</kbd> Undo/Redo · <kbd>Entf</kbd> löscht den
ausgewählten Tisch.</p>
</aside>
</div>
<div class="status" id="status"></div>
</main>
<script>
'use strict';
/* ============================================================ Zustand
state.tische ist die einzige Wahrheit; das SVG wird daraus gezeichnet
(state-getrieben, nie umgekehrt). Der Nachbar-Radius ist bewusst KEIN
Teil der Undo-History (Undo/Redo betrifft Tischgeometrie/-kapazität,
nicht diese Ableitungs-Einstellung), wird aber mitgespeichert. */
const state = { tische: [] };
let radius = 1.5;
let rasterSchritt = 0.5;
const ZIEH_TEMPO = 0.5; // Ziehgeschwindigkeit: Tisch folgt dem Zeiger halb so schnell
let selectedId = null;
let sitzung = null;
/* Undo/Redo: Schnappschuss-Stapel. Ein Schnappschuss ist ein tiefer Klon
von state.tische. Vor jeder verändernden Aktion wird der aktuelle
Zustand auf undoStack gelegt und redoStack geleert. */
const undoStack = [];
const redoStack = [];
const HIST_MAX = 200;
const klon = (t) => JSON.parse(JSON.stringify(t));
function pushHistory() {
undoStack.push(klon(state.tische));
if (undoStack.length > HIST_MAX) undoStack.shift();
redoStack.length = 0;
knoepfe();
}
function undo() {
if (!undoStack.length) return;
redoStack.push(klon(state.tische));
state.tische = undoStack.pop();
nachZustandswechsel();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(klon(state.tische));
state.tische = redoStack.pop();
nachZustandswechsel();
}
function nachZustandswechsel() {
if (selectedId !== null && !tischVonId(selectedId)) selectedId = null;
knoepfe(); zeichne(); inspektor();
}
function knoepfe() {
document.getElementById('btnUndo').disabled = undoStack.length === 0;
document.getElementById('btnRedo').disabled = redoStack.length === 0;
}
/* ============================================================ Helfer */
const tischVonId = (id) => state.tische.find(t => t.id === id);
function neueId() {
return state.tische.reduce((m, t) => Math.max(m, t.id), 0) + 1;
}
function snap(v) { return Math.round( Math.round(v / rasterSchritt) * rasterSchritt * 100 ) / 100; }
function radiusPx(plaetze) {
// Kreisgroesse waechst mit der Platzzahl (analog alsSVG im Kern)
return Math.max(18, Math.min(48, 15 + plaetze * 1.5));
}
/* ============================================================ Geometrie
Grid-Koordinaten (wie tische.ini) <-> Pixel. Bei jeder Zeichnung neu
bestimmt und in Modulvariablen abgelegt, damit pointerGrid() umrechnen
kann. */
const SCALE = 72, MARGIN = 34, PAD = 1;
let viewMinX = 0, viewMinY = 0, curW = 600, curH = 400;
function grenzen() {
if (!state.tische.length) return { minX: 0, maxX: 3, minY: 0, maxY: 2 };
const xs = state.tische.map(t => t.x), ys = state.tische.map(t => t.y);
return { minX: Math.min(...xs), maxX: Math.max(...xs),
minY: Math.min(...ys), maxY: Math.max(...ys) };
}
const px = (gx, min) => MARGIN + (gx - min) * SCALE;
function pointerGrid(e) {
const svg = document.querySelector('#leinwand svg');
const r = svg.getBoundingClientRect();
const sx = curW / r.width, sy = curH / r.height;
const X = (e.clientX - r.left) * sx, Y = (e.clientY - r.top) * sy;
return { x: (X - MARGIN) / SCALE + viewMinX, y: (Y - MARGIN) / SCALE + viewMinY };
}
/* ============================================================ Zeichnen */
function nachbarPaare() {
const paare = [];
for (let i = 0; i < state.tische.length; i++)
for (let j = i + 1; j < state.tische.length; j++) {
const a = state.tische[i], b = state.tische[j];
if (Math.hypot(a.x - b.x, a.y - b.y) <= radius) paare.push([a, b]);
}
return paare;
}
function zeichne() {
const g = grenzen();
viewMinX = g.minX - PAD; viewMinY = g.minY - PAD;
curW = Math.max(480, MARGIN * 2 + (g.maxX - g.minX + 2 * PAD) * SCALE);
curH = Math.max(320, MARGIN * 2 + (g.maxY - g.minY + 2 * PAD) * SCALE);
let s = `<svg xmlns="http://www.w3.org/2000/svg" width="${curW}" height="${curH}" `
+ `viewBox="0 0 ${curW} ${curH}" font-family="sans-serif">`;
s += `<rect width="100%" height="100%" fill="white"/>`;
// feines Grid an ganzzahligen Rasterlinien
for (let gx = Math.ceil(viewMinX); px(gx, viewMinX) <= curW; gx++)
s += `<line x1="${px(gx, viewMinX)}" y1="0" x2="${px(gx, viewMinX)}" y2="${curH}" stroke="#f0ede5"/>`;
for (let gy = Math.ceil(viewMinY); px(gy, viewMinY) <= curH; gy++)
s += `<line x1="0" y1="${px(gy, viewMinY)}" x2="${curW}" y2="${px(gy, viewMinY)}" stroke="#f0ede5"/>`;
// Nachbar-Linien (aus der Naehe abgeleitet, wie im Backend)
for (const [a, b] of nachbarPaare())
s += `<line x1="${px(a.x, viewMinX)}" y1="${px(a.y, viewMinY)}" `
+ `x2="${px(b.x, viewMinX)}" y2="${px(b.y, viewMinY)}" `
+ `stroke="#bcd3c6" stroke-width="2"/>`;
// Tische als Kreise
for (const t of state.tische) {
const cx = px(t.x, viewMinX), cy = px(t.y, viewMinY), r = radiusPx(t.plaetze);
const gewaehlt = t.id === selectedId;
s += `<g class="circle-tisch" data-id="${t.id}">`;
if (gewaehlt)
s += `<circle cx="${cx}" cy="${cy}" r="${r + 5}" fill="none" `
+ `stroke="#2f6f4f" stroke-width="2" stroke-dasharray="4 3"/>`;
s += `<circle cx="${cx}" cy="${cy}" r="${r}" `
+ `fill="${gewaehlt ? '#e6f0ea' : 'white'}" stroke="#2f6f4f" stroke-width="2"/>`;
s += `<text x="${cx}" y="${cy - 1}" text-anchor="middle" font-size="15" `
+ `font-weight="bold" fill="#2f6f4f">${t.id}</text>`;
s += `<text x="${cx}" y="${cy + 13}" text-anchor="middle" font-size="10" `
+ `fill="#6b6557">${t.plaetze} Pl.</text>`;
s += `</g>`;
}
s += `</svg>`;
document.getElementById('leinwand').innerHTML = s;
// Kennzahlen
const plaetze = state.tische.reduce((m, t) => m + t.plaetze, 0);
document.getElementById('zahlen').innerHTML =
`<span class="zahl">Tische: <b>${state.tische.length}</b></span>` +
`<span class="zahl">Plätze: <b>${plaetze}</b></span>` +
`<span class="zahl">Nachbar-Paare: <b>${nachbarPaare().length}</b></span>`;
}
/* ============================================================ Inspektor */
function inspektor() {
const el = document.getElementById('inspektor');
const t = selectedId !== null ? tischVonId(selectedId) : null;
if (!t) {
el.innerHTML = '<p class="hinweis">Kein Tisch ausgewählt. Auf einen Tisch ' +
'klicken, um Plätze zu ändern oder ihn zu löschen. Ziehen zum Verschieben.</p>';
return;
}
el.innerHTML =
`<div class="feld"><b>Tisch ${t.id}</b></div>` +
`<div class="feld">Plätze: ` +
`<button class="geist" id="plMinus"></button> ` +
`<input type="number" id="plaetze" min="1" max="100" value="${t.plaetze}"> ` +
`<button class="geist" id="plPlus">+</button></div>` +
`<div class="feld hinweis">Position: x=${t.x}, y=${t.y}</div>` +
`<div class="feld"><button class="sekundaer" id="loeschen">Tisch löschen</button></div>`;
document.getElementById('plMinus').onclick = () => plaetzeSetzen(t.plaetze - 1);
document.getElementById('plPlus').onclick = () => plaetzeSetzen(t.plaetze + 1);
document.getElementById('plaetze').onchange = (e) => plaetzeSetzen(parseInt(e.target.value, 10));
document.getElementById('loeschen').onclick = tischLoeschen;
}
function plaetzeSetzen(wert) {
const t = tischVonId(selectedId);
if (!t || isNaN(wert)) return;
wert = Math.max(1, Math.min(100, wert));
if (wert === t.plaetze) return;
pushHistory(); t.plaetze = wert; zeichne(); inspektor();
}
/* ============================================================ Auswahl */
function auswaehlen(id) { selectedId = id; zeichne(); inspektor(); }
/* ============================================================ Aktionen */
function tischHinzufuegen() {
const g = grenzen();
const x = state.tische.length ? snap((g.minX + g.maxX) / 2) : 0;
const y = state.tische.length ? snap((g.minY + g.maxY) / 2) : 0;
pushHistory();
const id = neueId();
state.tische.push({ id, plaetze: 8, x, y });
auswaehlen(id);
melde('Tisch ' + id + ' hinzugefügt.', 'ok');
}
function tischLoeschen() {
if (selectedId === null) return;
pushHistory();
state.tische = state.tische.filter(t => t.id !== selectedId);
const weg = selectedId; selectedId = null;
zeichne(); inspektor();
melde('Tisch ' + weg + ' gelöscht.', 'ok');
}
function presetReihe() {
pushHistory();
state.tische = [1, 2, 3, 4].map(i => ({ id: i, plaetze: 5, x: i - 1, y: 0 }));
selectedId = null; zeichne(); inspektor();
}
function presetRaster() {
pushHistory();
state.tische = [];
let id = 1;
for (let y = 0; y < 3; y++)
for (let x = 0; x < 3; x++) state.tische.push({ id: id++, plaetze: 8, x, y });
selectedId = null; zeichne(); inspektor();
}
/* ============================================================ Drag & Drop */
let dragId = null, dragMoved = false, dragStart = null;
let ankerPtr = null, ankerPos = null; // Zeiger- und Tischposition bei Zugbeginn
document.getElementById('leinwand').addEventListener('pointerdown', (e) => {
const el = e.target.closest('[data-id]');
if (!el) { if (selectedId !== null) auswaehlen(null); return; }
const id = parseInt(el.dataset.id, 10);
auswaehlen(id);
const gpt = pointerGrid(e), t = tischVonId(id);
dragId = id; dragMoved = false; dragStart = klon(state.tische);
// Anker merken: der Tisch wandert ab hier um ZIEH_TEMPO * Zeigerweg
ankerPtr = { x: gpt.x, y: gpt.y };
ankerPos = { x: t.x, y: t.y };
e.preventDefault();
});
window.addEventListener('pointermove', (e) => {
if (dragId === null) return;
const gpt = pointerGrid(e);
// halbe Geschwindigkeit: Verschiebung des Tisches = halber Zeigerweg
const nx = snap(ankerPos.x + (gpt.x - ankerPtr.x) * ZIEH_TEMPO);
const ny = snap(ankerPos.y + (gpt.y - ankerPtr.y) * ZIEH_TEMPO);
const t = tischVonId(dragId);
if (t && (t.x !== nx || t.y !== ny)) { t.x = nx; t.y = ny; dragMoved = true; zeichne(); inspektor(); }
});
window.addEventListener('pointerup', () => {
if (dragId === null) return;
if (dragMoved) {
// den ganzen Zug als EINEN Undo-Schritt ablegen (Vor-Zustand)
undoStack.push(dragStart);
if (undoStack.length > HIST_MAX) undoStack.shift();
redoStack.length = 0; knoepfe();
}
dragId = null; dragStart = null;
});
/* ============================================================ Tastatur */
window.addEventListener('keydown', (e) => {
const tippt = /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault(); undo();
} else if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'y' ||
(e.key.toLowerCase() === 'z' && e.shiftKey))) {
e.preventDefault(); redo();
} else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId !== null && !tippt) {
e.preventDefault(); tischLoeschen();
}
});
/* ============================================================ Backend */
function melde(text, klasse) {
const el = document.getElementById('status');
el.textContent = text; el.className = 'status ' + (klasse || '');
}
async function api(pfad, optionen) {
const antwort = await fetch(pfad, optionen);
const daten = await antwort.json().catch(() => null);
if (!antwort.ok) {
const d = daten && daten.detail
? (typeof daten.detail === 'string' ? daten.detail : JSON.stringify(daten.detail))
: antwort.statusText;
throw new Error(d);
}
return daten;
}
async function sitzungHolen() {
// Session mit der Testoberflaeche (index.html) ueber localStorage teilen
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;
}
async function layoutSpeichern() {
if (!state.tische.length) { melde('Mindestens ein Tisch nötig.', 'fehler'); return; }
const tische = state.tische.map(t => ({ id: t.id, plaetze: t.plaetze, x: t.x, y: t.y }));
try {
const r = await api('/sitzung/' + sitzung + '/tische', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tische, nachbarRadius: radius }),
});
melde('✓ ' + r.tische.length + ' Tische, ' + r.plaetzeGesamt +
' Plätze gespeichert. Weiter mit „Gäste & Berechnung“.', 'ok');
} catch (e) { melde('Fehler: ' + e.message, 'fehler'); }
}
async function iniLaden() {
const eingabe = document.getElementById('iniDatei');
if (!eingabe.files.length) { melde('Bitte zuerst eine tische.ini auswählen.', 'fehler'); return; }
const form = new FormData(); form.append('datei', eingabe.files[0]);
try {
const r = await api('/sitzung/' + sitzung + '/tische-datei', { method: 'POST', body: form });
pushHistory();
state.tische = r.tische.map(t => ({ id: t.id, plaetze: t.plaetze, x: t.x, y: t.y }));
selectedId = null; zeichne(); inspektor();
melde('✓ ' + r.tische.length + ' Tische aus Datei geladen.', 'ok');
} catch (e) { melde('Fehler: ' + e.message, 'fehler'); }
}
async function layoutLaden() {
// bereits in der (geteilten) Session gespeichertes Layout uebernehmen
try {
const r = await api('/sitzung/' + sitzung + '/tische');
if (r.tische && r.tische.length) {
state.tische = r.tische.map(t => ({ id: t.id, plaetze: t.plaetze, x: t.x, y: t.y }));
radius = r.nachbarRadius || 1.5;
document.getElementById('radius').value = radius;
document.getElementById('radiusWert').textContent = radius.toFixed(1).replace('.', ',');
return true;
}
} catch (e) { /* neue Session ohne Layout — Preset zeigen */ }
return false;
}
/* ============================================================ Verdrahtung */
document.getElementById('btnUndo').onclick = undo;
document.getElementById('btnRedo').onclick = redo;
document.getElementById('btnAdd').onclick = tischHinzufuegen;
document.getElementById('btnReihe').onclick = presetReihe;
document.getElementById('btnRaster').onclick = presetRaster;
document.getElementById('btnSpeichern').onclick = layoutSpeichern;
document.getElementById('btnIni').onclick = iniLaden;
document.getElementById('raster').onchange = (e) => { rasterSchritt = parseFloat(e.target.value); };
const radiusInput = document.getElementById('radius');
radiusInput.oninput = (e) => {
radius = parseFloat(e.target.value);
document.getElementById('radiusWert').textContent = radius.toFixed(1).replace('.', ',');
zeichne();
};
/* ============================================================ Start */
window.addEventListener('DOMContentLoaded', async () => {
try {
sitzung = await sitzungHolen();
document.getElementById('sitzungsId').textContent = sitzung;
} catch (e) {
document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message;
}
const geladen = sitzung ? await layoutLaden() : false;
if (!geladen) state.tische = [1, 2, 3, 4].map(i => ({ id: i, plaetze: 5, x: i - 1, y: 0 }));
knoepfe(); zeichne(); inspektor();
});
</script>
</body>
</html>
+124 -32
View File
@@ -53,12 +53,23 @@
padding: .3rem .7rem; margin: .15rem .3rem .15rem 0; font-size: .9rem; } padding: .3rem .7rem; margin: .15rem .3rem .15rem 0; font-size: .9rem; }
.kennzahl b { color: var(--akzent); } .kennzahl b { color: var(--akzent); }
.hinweis { font-size: .8rem; color: #6b6557; } .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; }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>Sitz &amp; Platz <small style="font-weight:normal">— Testoberfläche (Meilenstein 1)</small></h1> <h1>Sitz &amp; Platz <small style="font-weight:normal">— Testoberfläche (Meilenstein 1)</small></h1>
<p>Session-basiert, nichts wird dauerhaft gespeichert (TTL 24 h). Sitzung: <code id="sitzungsId">wird angelegt…</code></p> <p>Session-basiert, nichts wird dauerhaft gespeichert (TTL 24 h). Sitzung: <code id="sitzungsId">wird angelegt…</code>
· <a href="/editor.html" style="color:#d6eade">grafischer Tisch-Editor</a></p>
</header> </header>
<main> <main>
@@ -104,12 +115,15 @@
</section> </section>
<section> <section>
<h2>3. Sitzordnung berechnen</h2> <h2>3. Sitzordnung berechnen &amp; Varianten vergleichen</h2>
<p class="hinweis">Jeder Klick startet den genetischen Algorithmus neu und liefert <p class="hinweis">Ein Lauf liefert bis zu drei <em>unterschiedliche</em> gute
eine neue Variante — die Wertigkeit (0 = perfekt, negativer = schlechter) Sitzordnungen zum Vergleich — die Wertigkeit (0 = perfekt, negativer = schlechter),
macht Varianten vergleichbar.</p> 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.</p>
<button type="button" id="berechnenKnopf" onclick="berechnen()" disabled>Sitzordnung berechnen</button> <button type="button" id="berechnenKnopf" onclick="berechnen()" disabled>Sitzordnung berechnen</button>
<div class="status" id="rechnenStatus"></div> <div class="status" id="rechnenStatus"></div>
<div class="varianten" id="variantenListe"></div>
<div id="kennzahlen"></div> <div id="kennzahlen"></div>
<div id="ergebnisTabellen"></div> <div id="ergebnisTabellen"></div>
<img id="svgBild" alt="" style="display:none"> <img id="svgBild" alt="" style="display:none">
@@ -139,6 +153,23 @@ function status(id, text, klasse) {
} }
/* ------------------------------------------------ 1. Tische */ /* ------------------------------------------------ 1. Tische */
let geladenerRadius = 1.5; // zuletzt aus der Session geladener Nachbar-Radius
async function tischeAusSessionLaden() {
// Sektion 1 aus dem serverseitigen Session-Layout fuellen (z.B. was der
// grafische Editor gespeichert hat) statt fest das Preset zu zeigen.
try {
const r = await api('/sitzung/' + sitzung + '/tische');
if (r.tische && r.tische.length) {
tischeLeeren();
for (const t of r.tische) tischZeile(t.plaetze, t.x, t.y, t.id);
geladenerRadius = r.nachbarRadius || 1.5;
return;
}
} catch (e) { /* frische/abgelaufene Session — Preset als Fallback */ }
presetReihe();
}
function tischZeile(plaetze, x, y, tid) { function tischZeile(plaetze, x, y, tid) {
const tbody = document.querySelector('#tischTabelle tbody'); const tbody = document.querySelector('#tischTabelle tbody');
const nr = tid ?? tbody.children.length + 1; const nr = tid ?? tbody.children.length + 1;
@@ -190,7 +221,7 @@ async function tischeSpeichern() {
const r = await api('/sitzung/' + sitzung + '/tische', { const r = await api('/sitzung/' + sitzung + '/tische', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tische }), body: JSON.stringify({ tische, nachbarRadius: geladenerRadius }),
}); });
const nachbarn = r.tische.map(t => 'Tisch ' + t.id + ' ↔ [' + t.nachbarn.join(', ') + ']').join(' '); const nachbarn = r.tische.map(t => 'Tisch ' + t.id + ' ↔ [' + t.nachbarn.join(', ') + ']').join(' ');
status('tischStatus', '✓ ' + r.tische.length + ' Tische, ' + r.plaetzeGesamt + status('tischStatus', '✓ ' + r.tische.length + ' Tische, ' + r.plaetzeGesamt +
@@ -236,14 +267,30 @@ Gast;Kollegium;;Kollegen;;4
Karl;Einzelgast;;;; Karl;Einzelgast;;;;
`; `;
async function gaesteHochladen() { async function csvDateiGewaehlt(ev) {
const dateiInput = document.getElementById('csvDatei'); // Ausgewaehlte CSV sofort sichtbar in die Textarea laden (Single Source):
let blob; // dekodieren wie das Backend (utf-8-sig, sonst cp1252), BOM strippen.
if (dateiInput.files.length > 0) { const datei = ev.target.files[0];
blob = dateiInput.files[0]; if (!datei) return;
} else { const puffer = await datei.arrayBuffer();
blob = new Blob([document.getElementById('csvText').value], { type: 'text/csv' }); let text;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(puffer);
} catch (e) {
text = new TextDecoder('windows-1252').decode(puffer); // Excel/Windows
} }
text = text.replace(/^/, '');
document.getElementById('csvText').value = text;
// stale Ausgabe eines frueheren Uploads entfernen
document.getElementById('gruppenUebersicht').innerHTML = '';
status('gaesteStatus', 'Datei „' + datei.name + '“ geladen — zum Übernehmen ' +
'„Gästeliste übernehmen“ klicken.', 'ok');
ev.target.value = ''; // Textarea ist ab jetzt die alleinige Quelle
}
async function gaesteHochladen() {
// immer die (ggf. aus einer Datei befuellte) Textarea senden
const blob = new Blob([document.getElementById('csvText').value], { type: 'text/csv' });
const form = new FormData(); const form = new FormData();
form.append('datei', blob, 'gaeste.csv'); form.append('datei', blob, 'gaeste.csv');
try { try {
@@ -266,20 +313,22 @@ async function berechnenFreigeben() {
} catch (e) { /* Session ggf. abgelaufen — beim Berechnen gemeldet */ } } catch (e) { /* Session ggf. abgelaufen — beim Berechnen gemeldet */ }
} }
let letzterLauf = null; // Antwort des letzten Berechnen-Laufs (mit Varianten)
async function berechnen() { async function berechnen() {
const knopf = document.getElementById('berechnenKnopf'); const knopf = document.getElementById('berechnenKnopf');
knopf.disabled = true; knopf.disabled = true;
status('rechnenStatus', 'Der genetische Algorithmus läuft…'); status('rechnenStatus', 'Der genetische Algorithmus läuft…');
try { try {
const r = await api('/sitzung/' + sitzung + '/berechnen', { method: 'POST' }); const r = await api('/sitzung/' + sitzung + '/berechnen?varianten=3', { method: 'POST' });
status('rechnenStatus', '✓ Variante ' + r.variante + ' berechnet.', 'ok'); letzterLauf = r;
knopf.textContent = 'Neue Variante berechnen'; status('rechnenStatus', '✓ Lauf ' + r.lauf + ': ' + r.varianten.length +
zeigeErgebnis(r); (r.varianten.length === 1
const svg = await fetch('/sitzung/' + sitzung + '/ergebnis.svg'); ? ' Variante gefunden (der Lauf konvergierte auf eine Sitzordnung).'
const svgText = await svg.text(); : ' unterschiedliche Varianten zum Vergleich.'), 'ok');
const bild = document.getElementById('svgBild'); knopf.textContent = 'Neuen Lauf berechnen';
bild.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText); zeigeVariantenListe(r);
bild.style.display = 'block'; await zeigeVariante(0);
} catch (e) { } catch (e) {
status('rechnenStatus', 'Fehler: ' + e.message, 'fehler'); status('rechnenStatus', 'Fehler: ' + e.message, 'fehler');
} finally { } finally {
@@ -287,15 +336,31 @@ async function berechnen() {
} }
} }
function zeigeErgebnis(r) { function zeigeVariantenListe(r) {
const getrennte = r.gruppen.filter(g => g.teilungen > 0).length; document.getElementById('variantenListe').innerHTML = r.varianten.map((v, i) =>
'<button type="button" class="variante" data-i="' + i + '" onclick="zeigeVariante(' + i + ')">' +
'<b>Variante ' + v.nr + '</b>' +
'<span class="zeile">Wertigkeit: ' + v.kennzahlen.wertigkeit + '</span>' +
'<span class="zeile">getrennte Gruppen: ' + v.kennzahlen.getrennteGruppen +
' (' + v.kennzahlen.teilungenGesamt + ' Teilungen)</span>' +
'<span class="zeile">allein sitzend: ' + v.kennzahlen.alleinePersonen + '</span>' +
'</button>').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 = document.getElementById('kennzahlen').innerHTML =
'<span class="kennzahl">Wertigkeit: <b>' + r.wertigkeit + '</b></span>' + '<span class="kennzahl">Variante: <b>' + v.nr + '</b> (Lauf ' + letzterLauf.lauf + ')</span>' +
'<span class="kennzahl">Variante: <b>' + r.variante + '</b></span>' + '<span class="kennzahl">Wertigkeit: <b>' + v.kennzahlen.wertigkeit + '</b></span>' +
'<span class="kennzahl">getrennte Gruppen: <b>' + getrennte + '</b></span>'; '<span class="kennzahl">getrennte Gruppen: <b>' + v.kennzahlen.getrennteGruppen + '</b></span>' +
'<span class="kennzahl">allein sitzend: <b>' + v.kennzahlen.alleinePersonen + '</b></span>';
let html = '<table><thead><tr><th>Tisch</th><th>Belegung</th><th>Gäste (Gruppe)</th></tr></thead><tbody>'; let html = '<table><thead><tr><th>Tisch</th><th>Belegung</th><th>Gäste (Gruppe)</th></tr></thead><tbody>';
for (const t of r.tische) { for (const t of v.tische) {
const gaeste = t.personen.map(p => const gaeste = t.personen.map(p =>
((p.titel ? p.titel + ' ' : '') + p.vorname + ' ' + p.nachname + ((p.titel ? p.titel + ' ' : '') + p.vorname + ' ' + p.nachname +
' <span class="hinweis">(' + p.gruppe + ')</span>')).join(', '); ' <span class="hinweis">(' + p.gruppe + ')</span>')).join(', ');
@@ -306,24 +371,51 @@ function zeigeErgebnis(r) {
html += '<h2 style="margin-top:1rem">Gruppen</h2>' + html += '<h2 style="margin-top:1rem">Gruppen</h2>' +
'<table><thead><tr><th>Gruppe</th><th>Personen</th><th>Teilungen</th><th>Strafpunkte</th></tr></thead><tbody>' + '<table><thead><tr><th>Gruppe</th><th>Personen</th><th>Teilungen</th><th>Strafpunkte</th></tr></thead><tbody>' +
r.gruppen.map(g => '<tr><td>' + g.name + '</td><td>' + g.anzahl + '</td><td>' + v.gruppen.map(g => '<tr><td>' + g.name + '</td><td>' + g.anzahl + '</td><td>' +
g.teilungen + '</td><td>' + g.wert + '</td></tr>').join('') + g.teilungen + '</td><td>' + g.wert + '</td></tr>').join('') +
'</tbody></table>'; '</tbody></table>';
document.getElementById('ergebnisTabellen').innerHTML = html; 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 */ /* ------------------------------------------------ Start */
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
document.getElementById('csvText').value = beispielCSV; document.getElementById('csvText').value = beispielCSV;
presetReihe(); document.getElementById('csvDatei').addEventListener('change', csvDateiGewaehlt);
try { try {
const r = await api('/sitzung', { method: 'POST' }); sitzung = await sitzungHolen();
sitzung = r.id;
document.getElementById('sitzungsId').textContent = sitzung; document.getElementById('sitzungsId').textContent = sitzung;
// Tische aus der (ggf. mit dem Editor geteilten) Session laden;
// Preset nur, wenn die Session noch keine Tische hat
await tischeAusSessionLaden();
berechnenFreigeben();
} catch (e) { } catch (e) {
document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message; document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message;
presetReihe();
} }
}); });
// 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;
}
</script> </script>
</body> </body>
</html> </html>