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