Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db69f3498d | |||
| f880ae1c97 |
@@ -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
@@ -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. 3–5 unterschiedliche gute Lösungen zurückzugeben
|
||||
(ggf. mit verschiedenen Zufalls-Seeds parallel starten für Diversität)
|
||||
- [ ] Vergleichsansicht im Frontend: Varianten nebeneinander, Kennzahlen sichtbar machen
|
||||
(Wertigkeit, Anzahl Gruppentrennungen, "Alleine sitzende Personen")
|
||||
- [ ] Performance: bei sehr großen Sälen (200+ Personen) ggf. `Zyklus`-Parameter
|
||||
(Anzahl Generationen/Population) an Problemgröße koppeln, damit Antwortzeit
|
||||
im Web-Kontext (Sekunden, nicht Minuten) bleibt — Lasttest nötig
|
||||
- [ ] Optional: Hintergrund-Job-Queue (z.B. Celery/RQ), falls Berechnung >5s dauert,
|
||||
damit der Webserver nicht blockiert
|
||||
- [x] `Farm.TopN(n)` nutzen, um z.B. 3–5 unterschiedliche gute Lösungen zurückzugeben:
|
||||
`POST /berechnen?varianten=N` (1–5, Default 3) nimmt `TopN` über den ganzen Endpool
|
||||
und dedupliziert über eine Sitzordnungs-Signatur (`frozenset` aus Tisch-Id ×
|
||||
Personen-Ids), damit Mutations-Verwandte mit identischer Sitzordnung nicht als
|
||||
"Varianten" zählen. Liefert der Pool weniger unterschiedliche Sitzordnungen, kommen
|
||||
weniger zurück; ein weiterer Berechnen-Klick startet einen frischen Lauf.
|
||||
Seed-parallele Läufe für noch mehr Diversität ("ggf.") waren nicht nötig —
|
||||
ein Lauf liefert im Test bereits 3 paarweise verschiedene Varianten.
|
||||
`GET /ergebnis.svg?variante=k` rendert jede Variante einzeln.
|
||||
- [x] Vergleichsansicht im Frontend: Varianten-Karten nebeneinander mit Kennzahlen
|
||||
(Wertigkeit, getrennte Gruppen + Teilungen gesamt, allein sitzende Personen —
|
||||
letzteres via `LeuteAllein()`, zählt nur Personen aus mehrköpfigen Gruppen ohne
|
||||
Gruppenmitglied am Tisch); Klick auf eine Karte schaltet Tische-/Gruppen-Tabellen
|
||||
und den SVG-Raumplan auf die Variante um.
|
||||
- [x] Performance: Lasttest erledigt (2026-07-12) — `work/innenhoefe` (300 Gäste,
|
||||
88 Tische) rechnet mit dem **Adaptiv**-Profil (Web-Default) in ~1,7–1,9 s
|
||||
(5–7 Generationen, inkl. Python-Start), Easy in ~0,3 s. Beides weit unter der
|
||||
5-s-Schwelle → `Zyklus`-Parameter-Kopplung an die Problemgröße ist **nicht nötig**;
|
||||
erst wieder prüfen, falls Zyklen deutlich vergrößert werden (siehe Risiko Nr. 2).
|
||||
- [x] Optional: Hintergrund-Job-Queue (z.B. Celery/RQ), falls Berechnung >5s dauert —
|
||||
**entfällt nach dem Lasttest** (s.o.): Adaptiv bleibt bei ~2 s, der Webserver
|
||||
blockiert nicht spürbar. Entscheidung dokumentiert, bei Bedarf reaktivierbar.
|
||||
|
||||
**Tech-Debt / Modernisierung (Code-Review 2026-07-11):** Beim Umbau bereits erledigt sind
|
||||
`eval()` → `ast.literal_eval` (bzw. ein sicherer Mini-Auswerter für die String-Ausdrücke
|
||||
@@ -331,10 +362,14 @@ parallel anlaufen — eher ~4–4,5 Monate bis M4-Abschluss.
|
||||
nicht erst kurz vor Launch.
|
||||
2. **GA-Performance bei großen Sälen** — erster Datenpunkt (2026-07-11): das Beispiel
|
||||
`work/innenhoefe` mit 300 Gästen an 88 Tischen rechnet mit dem Easy-Zyklus in ~0,4 s
|
||||
durch — weit unter der 5-s-Schwelle, ab der M2 eine Job-Queue vorsieht. Offen bleibt
|
||||
der Test mit größeren Zyklen (mehr Generationen für bessere Lösungsqualität bei
|
||||
vielen Trennungen) — die Lösungsgüte bei 4er-Tischen mit großen Gruppen (Wertigkeit
|
||||
−384) legt nahe, dass hier eher mehr GA-Iterationen nötig sind als bisher.
|
||||
durch — weit unter der 5-s-Schwelle, ab der M2 eine Job-Queue vorsieht. Zweiter
|
||||
Datenpunkt (2026-07-12, M2-Lasttest): dasselbe Beispiel mit dem **Adaptiv**-Profil
|
||||
(Web-Default) braucht ~1,7–1,9 s (5–7 Generationen, 3 Seeds, inkl. Python-Start) —
|
||||
Job-Queue und Parameter-Kopplung bleiben unnötig. Weiter offen ist nur die
|
||||
Lösungsgüte-Frage: bei 4er-Tischen mit großen Gruppen (Wertigkeit ~−560) legen die
|
||||
vielen Trennungen nahe, dass mehr GA-Iterationen (größere `Geduld`/`Start`-Werte)
|
||||
bessere Pläne liefern könnten — das würde die Laufzeit dann wieder Richtung
|
||||
5-s-Schwelle schieben und wäre neu zu messen.
|
||||
3. **Wettbewerb ist real und etabliert** — siehe Abschnitt "Marktübersicht" oben.
|
||||
PerfectTablePlan verfolgt seit Jahren denselben GA-Ansatz, allerdings als Desktop-Tool.
|
||||
Differenzierung muss über Web-nativ + DSGVO-Hosting + detailliertere Regeln
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 & 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 & Platz <small style="font-weight:normal">— Interaktiver Tisch-Editor (Meilenstein 1)</small></h1>
|
||||
<p>Tische per Drag & Drop platzieren; Nachbarschaft wird aus der Nähe abgeleitet.
|
||||
Sitzung: <code id="sitzungsId">…</code> · weiter zu <a href="/">Gäste & 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
@@ -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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user