Web-GUI: fixierte Gruppen anzeigen, Strafprofil waehlen, Farben nutzen
CSVConfig.HandleGaesteliste liefert zusaetzlich FixierteGruppen (Name -> Tisch/Anzahl), damit fest platzierte Gaeste in der Gaesteliste sichtbar (grau hinterlegt) erscheinen. Durchgaengig "VIP" -> "fixiert" umbenannt. Backend: Strafprofile werden gecacht geladen, neuer Endpoint /strafprofile und Query-Parameter strafen an /berechnen, damit sich Profile (default vs. locker) in der GUI vergleichen lassen. SVG-Ausgabe nutzt das Farbschema aus farben.cfg (Rueckfall auf Palette). Frontend: tische.ini und CSV werden bei Auswahl sofort geladen, Auswahl der fixierten Gruppen und des Optimierungsprofils in der Oberflaeche. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+64
-9
@@ -44,6 +44,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from ga import Farm, Zyklus
|
||||
from CSVConfig import CSVConfig
|
||||
from Farben import Farbschema
|
||||
from Strukturdaten import Sitzplatzverteilung, Strafliste, Tisch, Tische
|
||||
|
||||
app = FastAPI(
|
||||
@@ -62,11 +63,43 @@ ZYKLUSDATEI = os.path.join( CFGDIR, 'zyklus.cfg' )
|
||||
STRAFENDATEI = os.path.join( CFGDIR, 'strafen.cfg' )
|
||||
ZYKLUSART = os.environ.get( 'PLATZ_ZYKLUSART', 'Adaptiv' )
|
||||
STRAFENART = os.environ.get( 'PLATZ_STRAFENART', 'default' )
|
||||
FARBENDATEI = os.path.join( CFGDIR, 'farben.cfg' )
|
||||
FARBENART = os.environ.get( 'PLATZ_FARBENART', 'grosse_gruppen' )
|
||||
|
||||
GAZyklus = Zyklus()
|
||||
GAZyklus.laden( ZYKLUSDATEI, ZYKLUSART )
|
||||
Strafen = Strafliste()
|
||||
Strafen.laden( STRAFENDATEI, STRAFENART )
|
||||
|
||||
# Strafprofile werden bei Bedarf geladen und gecacht, damit sich in der GUI
|
||||
# verschiedene Profile (z.B. 'default' vs 'locker') auswaehlen und vergleichen
|
||||
# lassen. Das Default-Profil wird schon beim Start geladen (faellt frueh auf,
|
||||
# falls die Datei/Sektion kaputt ist).
|
||||
StrafProfile = {}
|
||||
|
||||
|
||||
def strafprofil(Name):
|
||||
"""liefert (gecacht) die Strafliste einer Sektion aus strafen.cfg;
|
||||
wirft ValueError bei unbekannter Sektion"""
|
||||
if Name not in StrafProfile:
|
||||
SL = Strafliste()
|
||||
SL.laden( STRAFENDATEI, Name )
|
||||
StrafProfile[Name] = SL
|
||||
return StrafProfile[Name]
|
||||
|
||||
|
||||
def strafprofil_namen():
|
||||
"""alle in strafen.cfg vorhandenen Profilnamen (fuer die GUI-Auswahl)"""
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read( STRAFENDATEI )
|
||||
return cfg.sections()
|
||||
|
||||
|
||||
strafprofil( STRAFENART ) # Default vorwaermen / frueh scheitern
|
||||
# Farbprofil fuer die SVG-Ausgabe; faellt bei fehlender Datei/Sektion auf die
|
||||
# eingebaute Palette zurueck (Farbschema=None), damit der Server trotzdem laeuft.
|
||||
try:
|
||||
Farben = Farbschema().laden( FARBENDATEI, FARBENART )
|
||||
except (ValueError, OSError):
|
||||
Farben = None
|
||||
|
||||
# ---------------------------------------------------------------- Sessions
|
||||
SESSION_TTL_SEKUNDEN = 24 * 3600
|
||||
@@ -160,7 +193,7 @@ def _VarianteJSON(SV, GruppenNamen, Nr):
|
||||
G = SV.Gruppen.GruppeVonPid( P.Id )
|
||||
return GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id )
|
||||
except KeyError:
|
||||
return 'VIP'
|
||||
return 'fixiert'
|
||||
|
||||
TischListe = []
|
||||
for T in SV.Tische:
|
||||
@@ -220,6 +253,7 @@ def _ErgebnisJSON(S):
|
||||
GruppenNamen = S.get( 'gruppenNamen', {} )
|
||||
return {
|
||||
'lauf': S['laeufe'],
|
||||
'strafen': S.get( 'strafen', STRAFENART ), # verwendetes Strafprofil
|
||||
'varianten': [ _VarianteJSON( SV, GruppenNamen, Nr + 1 )
|
||||
for Nr, SV in enumerate( S['ergebnisse'] ) ],
|
||||
}
|
||||
@@ -389,7 +423,7 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)):
|
||||
Text = Rohdaten.decode( 'cp1252' ) # Excel/Windows-Fallback
|
||||
|
||||
try:
|
||||
[ PN, GN, VIPGruppe, VipHash, GruppenNamen ] = \
|
||||
[ PN, GN, VIPGruppe, VipHash, GruppenNamen, FixierteGruppen ] = \
|
||||
CSVConfig().HandleGaesteliste( Text )
|
||||
except ValueError as Fehler:
|
||||
raise HTTPException( status_code=400, detail=str(Fehler) )
|
||||
@@ -407,12 +441,15 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)):
|
||||
|
||||
return {
|
||||
'personen': GN.NLeute,
|
||||
'vips': VIPGruppe.Anzahl(),
|
||||
'fixiert': VIPGruppe.Anzahl(),
|
||||
'gruppen': [ {
|
||||
'id': G.Id,
|
||||
'name': GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id ),
|
||||
'anzahl': G.Anzahl(),
|
||||
} for G in GN ],
|
||||
# fest platzierte Gruppen (fuer die grau hinterlegte Anzeige)
|
||||
'fixierte': [ { 'name': Name, 'tisch': Info['tisch'], 'anzahl': Info['anzahl'] }
|
||||
for Name, Info in FixierteGruppen.items() ],
|
||||
}
|
||||
|
||||
|
||||
@@ -420,9 +457,14 @@ def PersonenHochladen(SitzungsId: str, datei: UploadFile = File(...)):
|
||||
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)' )):
|
||||
'zurueckgeben soll (aus Farm.TopN, dedupliziert)' ),
|
||||
strafen: str = Query( default=STRAFENART,
|
||||
description='Name des Strafpunkte-Profils (Sektion in strafen.cfg), '
|
||||
'z.B. default oder locker - zum Vergleich waehlbar' )):
|
||||
"""startet die GA-Optimierung; jeder Aufruf ist ein neuer Lauf und
|
||||
liefert bis zu 'varianten' unterschiedliche gute Sitzordnungen"""
|
||||
liefert bis zu 'varianten' unterschiedliche gute Sitzordnungen. Das
|
||||
Strafpunkte-Profil ('strafen') steuert die Bewertung waehrend der
|
||||
Optimierung - so lassen sich z.B. default und locker vergleichen."""
|
||||
S = _GibSession( SitzungsId )
|
||||
if S['tische'] is None:
|
||||
raise HTTPException( status_code=400,
|
||||
@@ -454,9 +496,14 @@ def Berechnen(SitzungsId: str,
|
||||
detail='Tisch %d hat %d Plaetze, aber %d VIPs sind '
|
||||
'dort fest gesetzt' % (Tid, T.Plaetze, AnzahlVIPs) )
|
||||
|
||||
try:
|
||||
SL = strafprofil( strafen )
|
||||
except ValueError as Fehler:
|
||||
raise HTTPException( status_code=400, detail=str(Fehler) )
|
||||
|
||||
try:
|
||||
GAFarm = Farm( GAZyklus, Sitzplatzverteilung,
|
||||
Tische=TE, Gruppen=P['GN'], Strafen=Strafen,
|
||||
Tische=TE, Gruppen=P['GN'], Strafen=SL,
|
||||
VIPListe=P['VipHash'], VIPs=P['VIPGruppe'] )
|
||||
except IndexError as Fehler:
|
||||
raise HTTPException( status_code=400, detail=str(Fehler) )
|
||||
@@ -481,9 +528,17 @@ def Berechnen(SitzungsId: str,
|
||||
|
||||
S['ergebnisse'] = Ergebnisse
|
||||
S['laeufe'] = S['laeufe'] + 1
|
||||
S['strafen'] = strafen # fuer die Anzeige, welches Profil den Lauf ergab
|
||||
return _ErgebnisJSON( S )
|
||||
|
||||
|
||||
@app.get( '/strafprofile' )
|
||||
def Strafprofile():
|
||||
"""verfuegbare Strafpunkte-Profile (Sektionen in strafen.cfg) fuer die
|
||||
Auswahl in der GUI; 'vorgabe' ist das voreingestellte Profil."""
|
||||
return { 'profile': strafprofil_namen(), 'vorgabe': STRAFENART }
|
||||
|
||||
|
||||
@app.get( '/sitzung/{SitzungsId}/ergebnis' )
|
||||
def Ergebnis(SitzungsId: str):
|
||||
"""alle Varianten des letzten Laufs als JSON (beste zuerst)"""
|
||||
@@ -513,7 +568,7 @@ def ErgebnisSVG(SitzungsId: str,
|
||||
os.close( Handle )
|
||||
try:
|
||||
S['ergebnisse'][variante - 1].alsSVG( TempPfad,
|
||||
GruppenNamen=S.get( 'gruppenNamen', {} ) )
|
||||
GruppenNamen=S.get( 'gruppenNamen', {} ), Farbschema=Farben )
|
||||
fsock = open( TempPfad, 'r' )
|
||||
SVG = fsock.read()
|
||||
fsock.close()
|
||||
|
||||
+46
-17
@@ -61,6 +61,7 @@
|
||||
padding: .3rem .7rem; margin: .15rem .3rem .15rem 0; font-size: .9rem; }
|
||||
.kennzahl b { color: var(--akzent); }
|
||||
.hinweis { font-size: .8rem; color: #6b6557; }
|
||||
#gruppenUebersicht tr.fixiert td { background: #e6e3dc; color: #555; }
|
||||
.varianten { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
|
||||
.variante {
|
||||
border: 1px solid var(--rand); border-radius: 6px; background: white;
|
||||
@@ -97,10 +98,9 @@
|
||||
<button type="button" class="sekundaer" onclick="presetReihe()">Preset: 4er-Reihe</button>
|
||||
<button type="button" class="sekundaer" onclick="presetRaster()">Preset: 3×3-Raster</button>
|
||||
<br>
|
||||
<label class="hinweis">Oder eine bestehende <code>tische.ini</code> laden
|
||||
(z. B. aus <code>work/hochzeit</code>):</label>
|
||||
<input type="file" id="tischDatei" accept=".ini,text/plain">
|
||||
<button type="button" class="sekundaer" onclick="tischDateiLaden()">tische.ini laden</button>
|
||||
<label class="hinweis">Oder eine bestehende <code>tische.ini</code> auswählen
|
||||
(z. B. aus <code>work/hochzeit</code>) — wird sofort geladen:</label>
|
||||
<input type="file" id="tischDatei" accept=".ini,text/plain" onchange="tischDateiLaden()">
|
||||
<br>
|
||||
<button type="button" onclick="tischeSpeichern()">Tische speichern</button>
|
||||
<div class="status" id="tischStatus"></div>
|
||||
@@ -110,9 +110,10 @@
|
||||
<h2>2. Gästeliste (CSV)</h2>
|
||||
<p class="hinweis">Spalten: <code>Vorname;Nachname;Titel;Gruppe;Tisch;Anzahl</code> —
|
||||
<code>Gruppe</code> hält Personen zusammen (leer = Einzelgast),
|
||||
<code>Tisch</code> setzt VIPs fest (z. B. Brautpaar an Tisch 1),
|
||||
<code>Tisch</code> fixiert Gäste an einem Tisch (z. B. Brautpaar an Tisch 1),
|
||||
<code>Anzahl</code> expandiert eine Zeile in mehrere Buchungen
|
||||
(anonyme Sammelbuchung). Datei hochladen <em>oder</em> direkt hier bearbeiten:</p>
|
||||
(anonyme Sammelbuchung). Eine Datei auswählen (wird sofort übernommen)
|
||||
<em>oder</em> hier direkt bearbeiten und übernehmen:</p>
|
||||
<textarea id="csvText"></textarea>
|
||||
<br>
|
||||
<input type="file" id="csvDatei" accept=".csv,text/csv">
|
||||
@@ -128,7 +129,11 @@
|
||||
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>
|
||||
„Berechnen“ startet einen frischen Lauf. Über das <em>Optimierungsprofil</em> lassen sich
|
||||
Bewertungs-Vorgehen vergleichen (z. B. <code>default</code> vs.
|
||||
<code>locker</code>): Profil wählen und neu berechnen.</p>
|
||||
<label class="hinweis">Optimierungsprofil:
|
||||
<select id="strafenWahl"></select></label>
|
||||
<button type="button" id="berechnenKnopf" onclick="berechnen()" disabled>Sitzordnung berechnen</button>
|
||||
<div class="status" id="rechnenStatus"></div>
|
||||
<div class="varianten" id="variantenListe"></div>
|
||||
@@ -259,6 +264,7 @@ async function tischDateiLaden() {
|
||||
'„Tische speichern“ ist nur nach Änderungen nötig.', 'ok');
|
||||
berechnenFreigeben();
|
||||
} catch (e) { status('tischStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||||
finally { eingabe.value = ''; } // damit dieselbe Datei erneut auswählbar ist
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ 2. Gäste */
|
||||
@@ -293,11 +299,10 @@ async function csvDateiGewaehlt(ev) {
|
||||
}
|
||||
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
|
||||
ev.target.value = ''; // damit dieselbe Datei erneut auswählbar ist
|
||||
// direkt uebernehmen: die ausgewaehlte Datei wird sofort geladen (die
|
||||
// Textarea ist bereits befuellt und dient gaesteHochladen als Quelle)
|
||||
await gaesteHochladen();
|
||||
}
|
||||
|
||||
async function gaesteHochladen() {
|
||||
@@ -308,16 +313,35 @@ async function gaesteHochladen() {
|
||||
try {
|
||||
const r = await api('/sitzung/' + sitzung + '/personen', { method: 'POST', body: form });
|
||||
status('gaesteStatus', '✓ ' + r.personen + ' Personen in ' + r.gruppen.length +
|
||||
' Gruppen, dazu ' + r.vips + ' VIPs mit festem Tisch.', 'ok');
|
||||
' Gruppen' + (r.fixiert ? ', dazu ' + r.fixiert +
|
||||
' fixierte Personen an festen Tischen' : '') + '.', 'ok');
|
||||
// regulaere Gruppen, danach die fest platzierten ("fixierten") Gruppen
|
||||
// grau hinterlegt
|
||||
const regulaer = r.gruppen.map(g =>
|
||||
'<tr><td>' + g.name + '</td><td>' + g.anzahl + '</td></tr>').join('');
|
||||
const fixiert = (r.fixierte || []).map(f =>
|
||||
'<tr class="fixiert"><td>' + f.name +
|
||||
' <span class="hinweis">(fixiert an Tisch ' + f.tisch + ')</span></td><td>' +
|
||||
f.anzahl + '</td></tr>').join('');
|
||||
const html = '<table><thead><tr><th>Gruppe</th><th>Personen</th></tr></thead><tbody>' +
|
||||
r.gruppen.map(g => '<tr><td>' + g.name + '</td><td>' + g.anzahl + '</td></tr>').join('') +
|
||||
'</tbody></table>';
|
||||
regulaer + fixiert + '</tbody></table>';
|
||||
document.getElementById('gruppenUebersicht').innerHTML = html;
|
||||
berechnenFreigeben();
|
||||
} catch (e) { status('gaesteStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ 3. Berechnen */
|
||||
async function strafprofileLaden() {
|
||||
// Auswahl der Strafprofile aus strafen.cfg fuellen (z.B. default, locker)
|
||||
try {
|
||||
const r = await api('/strafprofile');
|
||||
const sel = document.getElementById('strafenWahl');
|
||||
sel.innerHTML = r.profile.map(p =>
|
||||
'<option value="' + p + '"' + (p === r.vorgabe ? ' selected' : '') +
|
||||
'>' + p + '</option>').join('');
|
||||
} catch (e) { /* Auswahl bleibt leer -> Backend nutzt sein Default */ }
|
||||
}
|
||||
|
||||
async function berechnenFreigeben() {
|
||||
try {
|
||||
const s = await api('/sitzung/' + sitzung);
|
||||
@@ -332,9 +356,12 @@ async function berechnen() {
|
||||
knopf.disabled = true;
|
||||
status('rechnenStatus', 'Der genetische Algorithmus läuft…');
|
||||
try {
|
||||
const r = await api('/sitzung/' + sitzung + '/berechnen?varianten=3', { method: 'POST' });
|
||||
const profil = document.getElementById('strafenWahl').value || 'default';
|
||||
const r = await api('/sitzung/' + sitzung + '/berechnen?varianten=3&strafen=' +
|
||||
encodeURIComponent(profil), { method: 'POST' });
|
||||
letzterLauf = r;
|
||||
status('rechnenStatus', '✓ Lauf ' + r.lauf + ': ' + r.varianten.length +
|
||||
status('rechnenStatus', '✓ Lauf ' + r.lauf + ' (Optimierungsprofil „' + r.strafen + '“): ' +
|
||||
r.varianten.length +
|
||||
(r.varianten.length === 1
|
||||
? ' Variante gefunden (der Lauf konvergierte auf eine Sitzordnung).'
|
||||
: ' unterschiedliche Varianten zum Vergleich.'), 'ok');
|
||||
@@ -367,6 +394,7 @@ async function zeigeVariante(i) {
|
||||
|
||||
document.getElementById('kennzahlen').innerHTML =
|
||||
'<span class="kennzahl">Variante: <b>' + v.nr + '</b> (Lauf ' + letzterLauf.lauf + ')</span>' +
|
||||
'<span class="kennzahl">Optimierungsprofil: <b>' + letzterLauf.strafen + '</b></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>';
|
||||
@@ -439,6 +467,7 @@ function gruppeInteraktion(svg) {
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('csvText').value = beispielCSV;
|
||||
document.getElementById('csvDatei').addEventListener('change', csvDateiGewaehlt);
|
||||
strafprofileLaden();
|
||||
try {
|
||||
sitzung = await sitzungHolen();
|
||||
document.getElementById('sitzungsId').textContent = sitzung;
|
||||
|
||||
Reference in New Issue
Block a user