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
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
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
+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] 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 <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
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. 35 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. 35 unterschiedliche gute Lösungen zurückzugeben:
`POST /berechnen?varianten=N` (15, 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,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
`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.
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,71,9 s (57 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
+10
View File
@@ -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:
+9
View File
@@ -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__':
+15
View File
@@ -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 ) )
+100 -30
View File
@@ -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()
+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; }
.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; }
</style>
</head>
<body>
<header>
<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>
<main>
@@ -104,12 +115,15 @@
</section>
<section>
<h2>3. Sitzordnung berechnen</h2>
<p class="hinweis">Jeder Klick startet den genetischen Algorithmus neu und liefert
eine neue Variante — die Wertigkeit (0 = perfekt, negativer = schlechter)
macht Varianten vergleichbar.</p>
<h2>3. Sitzordnung berechnen &amp; Varianten vergleichen</h2>
<p class="hinweis">Ein Lauf liefert bis zu drei <em>unterschiedliche</em> 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.</p>
<button type="button" id="berechnenKnopf" onclick="berechnen()" disabled>Sitzordnung berechnen</button>
<div class="status" id="rechnenStatus"></div>
<div class="varianten" id="variantenListe"></div>
<div id="kennzahlen"></div>
<div id="ergebnisTabellen"></div>
<img id="svgBild" alt="" style="display:none">
@@ -139,6 +153,23 @@ function status(id, text, klasse) {
}
/* ------------------------------------------------ 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) {
const tbody = document.querySelector('#tischTabelle tbody');
const nr = tid ?? tbody.children.length + 1;
@@ -190,7 +221,7 @@ async function tischeSpeichern() {
const r = await api('/sitzung/' + sitzung + '/tische', {
method: 'POST',
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(' ');
status('tischStatus', '✓ ' + r.tische.length + ' Tische, ' + r.plaetzeGesamt +
@@ -236,14 +267,30 @@ Gast;Kollegium;;Kollegen;;4
Karl;Einzelgast;;;;
`;
async function gaesteHochladen() {
const dateiInput = document.getElementById('csvDatei');
let blob;
if (dateiInput.files.length > 0) {
blob = dateiInput.files[0];
} else {
blob = new Blob([document.getElementById('csvText').value], { type: 'text/csv' });
async function csvDateiGewaehlt(ev) {
// Ausgewaehlte CSV sofort sichtbar in die Textarea laden (Single Source):
// dekodieren wie das Backend (utf-8-sig, sonst cp1252), BOM strippen.
const datei = ev.target.files[0];
if (!datei) return;
const puffer = await datei.arrayBuffer();
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();
form.append('datei', blob, 'gaeste.csv');
try {
@@ -266,20 +313,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 +336,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) =>
'<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 =
'<span class="kennzahl">Wertigkeit: <b>' + r.wertigkeit + '</b></span>' +
'<span class="kennzahl">Variante: <b>' + r.variante + '</b></span>' +
'<span class="kennzahl">getrennte Gruppen: <b>' + getrennte + '</b></span>';
'<span class="kennzahl">Variante: <b>' + v.nr + '</b> (Lauf ' + letzterLauf.lauf + ')</span>' +
'<span class="kennzahl">Wertigkeit: <b>' + v.kennzahlen.wertigkeit + '</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>';
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 +
' <span class="hinweis">(' + p.gruppe + ')</span>')).join(', ');
@@ -306,24 +371,51 @@ function zeigeErgebnis(r) {
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>' +
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('') +
'</tbody></table>';
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 */
window.addEventListener('DOMContentLoaded', async () => {
document.getElementById('csvText').value = beispielCSV;
presetReihe();
document.getElementById('csvDatei').addEventListener('change', csvDateiGewaehlt);
try {
const r = await api('/sitzung', { method: 'POST' });
sitzung = r.id;
sitzung = await sitzungHolen();
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) {
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>
</body>
</html>