00dffc59eb
- web/main.py: Session-basierte REST-API um den GA-Kern (POST /sitzung, .../tische, .../tische-datei, .../personen, .../berechnen, GET .../ergebnis[.svg]); Sessions in-memory mit 24h-TTL - web/static/index.html: einfache Browser-Testoberflaeche (Tisch-Tabelle mit Presets, tische.ini-Upload, CSV-Gaesteliste, Kennzahlen, SVG) - bin/run_web.bat/.sh + run_web_helper.ps1: start/stop/status inkl. Aufraeumen verwaister uvicorn-Reload-Kindprozesse (Zombie-Sockets) - work/hochzeit: 120 Hochzeitsgaeste, Ehrentafel, Kindertisch/Teenager- Tisch fest gesetzt, Kinderalter normalverteilt, ~85% Auslastung - work/innenhoefe: 300 Gaeste in zwei trapezfoermigen Innenhoefen mit 4er-Tischen, 12 VIPs vor der Buehne, Umfeld.svg mit Tanzflaeche/Band - Roadmap: Konzeptentscheidungen 1-11 (Zielgruppe, Einmalzahlung, MoR, Hetzner, Zwei-Marken-Strategie Sitz&Platz/SeatWonder), Namensrecherche im Anhang, M1-Fortschritt und GA-Lasttest-Datenpunkt dokumentiert Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
13 KiB
HTML
330 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Sitz & Platz — Testoberfläche (M1)</title>
|
||
<style>
|
||
:root {
|
||
--akzent: #2f6f4f;
|
||
--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; }
|
||
main { max-width: 60rem; margin: 0 auto; padding: 1rem 1.5rem 4rem; }
|
||
section {
|
||
background: white; border: 1px solid var(--rand);
|
||
border-radius: 8px; padding: 1rem 1.25rem; margin-top: 1.25rem;
|
||
}
|
||
h2 { font-size: 1.05rem; margin: 0 0 .75rem; color: var(--akzent); }
|
||
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
|
||
th, td { border: 1px solid var(--rand); padding: .3rem .5rem; text-align: left; }
|
||
th { background: #f1eee6; }
|
||
input[type=number] { width: 4.5rem; }
|
||
textarea {
|
||
width: 100%; min-height: 11rem; font-family: ui-monospace, monospace;
|
||
font-size: .85rem; border: 1px solid var(--rand); border-radius: 4px;
|
||
padding: .5rem;
|
||
}
|
||
button {
|
||
background: var(--akzent); color: white; border: 0;
|
||
border-radius: 4px; padding: .45rem .9rem; font-size: .9rem;
|
||
cursor: pointer; margin: .25rem .35rem .25rem 0;
|
||
}
|
||
button.sekundaer { background: #8b8577; }
|
||
button:disabled { background: #b9b4a6; cursor: not-allowed; }
|
||
.status { font-size: .85rem; margin-top: .5rem; min-height: 1.2rem; }
|
||
.status.ok { color: var(--akzent); }
|
||
.status.fehler { color: #a03030; white-space: pre-wrap; }
|
||
#svgBild { max-width: 100%; border: 1px solid var(--rand); border-radius: 4px;
|
||
margin-top: .75rem; background: white; }
|
||
.kennzahl { display: inline-block; background: #f1eee6; border-radius: 4px;
|
||
padding: .3rem .7rem; margin: .15rem .3rem .15rem 0; font-size: .9rem; }
|
||
.kennzahl b { color: var(--akzent); }
|
||
.hinweis { font-size: .8rem; 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>
|
||
</header>
|
||
<main>
|
||
|
||
<section>
|
||
<h2>1. Tische definieren</h2>
|
||
<p class="hinweis">Koordinaten sind Rasterpositionen (wie in tische.ini). Tische, die
|
||
näher als der Nachbar-Radius (1,5 Rastereinheiten) beieinander liegen, gelten
|
||
automatisch als Nachbarn — Gruppen, die geteilt werden müssen, kommen bevorzugt
|
||
an Nachbartische.</p>
|
||
<table id="tischTabelle">
|
||
<thead>
|
||
<tr><th>Tisch-Nr.</th><th>Plätze</th><th>X</th><th>Y</th><th></th></tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
<button type="button" onclick="tischZeile()">+ Tisch hinzufügen</button>
|
||
<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>
|
||
<br>
|
||
<button type="button" onclick="tischeSpeichern()">Tische speichern</button>
|
||
<div class="status" id="tischStatus"></div>
|
||
</section>
|
||
|
||
<section>
|
||
<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>Anzahl</code> expandiert eine Zeile in mehrere Buchungen
|
||
(anonyme Sammelbuchung). Datei hochladen <em>oder</em> direkt hier bearbeiten:</p>
|
||
<textarea id="csvText"></textarea>
|
||
<br>
|
||
<input type="file" id="csvDatei" accept=".csv,text/csv">
|
||
<br>
|
||
<button type="button" onclick="gaesteHochladen()">Gästeliste übernehmen</button>
|
||
<div class="status" id="gaesteStatus"></div>
|
||
<div id="gruppenUebersicht"></div>
|
||
</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>
|
||
<button type="button" id="berechnenKnopf" onclick="berechnen()" disabled>Sitzordnung berechnen</button>
|
||
<div class="status" id="rechnenStatus"></div>
|
||
<div id="kennzahlen"></div>
|
||
<div id="ergebnisTabellen"></div>
|
||
<img id="svgBild" alt="" style="display:none">
|
||
</section>
|
||
|
||
</main>
|
||
<script>
|
||
'use strict';
|
||
let sitzung = null;
|
||
|
||
async function api(pfad, optionen) {
|
||
const antwort = await fetch(pfad, optionen);
|
||
const daten = await antwort.json().catch(() => null);
|
||
if (!antwort.ok) {
|
||
const detail = daten && daten.detail
|
||
? (typeof daten.detail === 'string' ? daten.detail : JSON.stringify(daten.detail, null, 2))
|
||
: antwort.statusText;
|
||
throw new Error(detail);
|
||
}
|
||
return daten;
|
||
}
|
||
|
||
function status(id, text, klasse) {
|
||
const el = document.getElementById(id);
|
||
el.textContent = text;
|
||
el.className = 'status ' + (klasse || '');
|
||
}
|
||
|
||
/* ------------------------------------------------ 1. Tische */
|
||
function tischZeile(plaetze, x, y, tid) {
|
||
const tbody = document.querySelector('#tischTabelle tbody');
|
||
const nr = tid ?? tbody.children.length + 1;
|
||
const tr = document.createElement('tr');
|
||
if (tid !== undefined) tr.dataset.tid = tid; // echte Id aus tische.ini behalten
|
||
tr.innerHTML =
|
||
'<td class="tischNr">' + nr + '</td>' +
|
||
'<td><input type="number" min="1" max="100" value="' + (plaetze ?? 8) + '" class="plaetze"></td>' +
|
||
'<td><input type="number" step="1" value="' + (x ?? nr) + '" class="x"></td>' +
|
||
'<td><input type="number" step="1" value="' + (y ?? 1) + '" class="y"></td>' +
|
||
'<td><button type="button" class="sekundaer" onclick="this.closest(\'tr\').remove(); nummerieren()">✕</button></td>';
|
||
tbody.appendChild(tr);
|
||
}
|
||
function nummerieren() {
|
||
document.querySelectorAll('#tischTabelle tbody tr').forEach((tr, i) => {
|
||
if (tr.dataset.tid === undefined)
|
||
tr.querySelector('.tischNr').textContent = i + 1;
|
||
});
|
||
}
|
||
function tischeLeeren() {
|
||
document.querySelector('#tischTabelle tbody').innerHTML = '';
|
||
}
|
||
function presetReihe() {
|
||
tischeLeeren();
|
||
for (let i = 1; i <= 4; i++) tischZeile(5, i, 1);
|
||
}
|
||
function presetRaster() {
|
||
tischeLeeren();
|
||
for (let y = 1; y <= 3; y++)
|
||
for (let x = 1; x <= 3; x++)
|
||
tischZeile(8, x, y);
|
||
}
|
||
async function tischeSpeichern() {
|
||
const zeilen = [...document.querySelectorAll('#tischTabelle tbody tr')];
|
||
if (zeilen.length === 0) { status('tischStatus', 'Mindestens ein Tisch nötig.', 'fehler'); return; }
|
||
// Zeilen aus einer geladenen tische.ini behalten ihre echte Id
|
||
// (wichtig fuer VIP-Bindungen wie "Tisch 16"); neue Zeilen bekommen
|
||
// die naechste freie Id
|
||
const vergeben = new Set(zeilen.map(tr => parseInt(tr.dataset.tid, 10)).filter(n => !isNaN(n)));
|
||
let naechste = 1;
|
||
const freieId = () => { while (vergeben.has(naechste)) naechste++; vergeben.add(naechste); return naechste; };
|
||
const tische = zeilen.map(tr => ({
|
||
id: tr.dataset.tid !== undefined ? parseInt(tr.dataset.tid, 10) : freieId(),
|
||
plaetze: parseInt(tr.querySelector('.plaetze').value, 10),
|
||
x: parseFloat(tr.querySelector('.x').value),
|
||
y: parseFloat(tr.querySelector('.y').value),
|
||
}));
|
||
try {
|
||
const r = await api('/sitzung/' + sitzung + '/tische', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tische }),
|
||
});
|
||
const nachbarn = r.tische.map(t => 'Tisch ' + t.id + ' ↔ [' + t.nachbarn.join(', ') + ']').join(' ');
|
||
status('tischStatus', '✓ ' + r.tische.length + ' Tische, ' + r.plaetzeGesamt +
|
||
' Plätze gesamt. Abgeleitete Nachbarn: ' + nachbarn, 'ok');
|
||
berechnenFreigeben();
|
||
} catch (e) { status('tischStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||
}
|
||
|
||
async function tischDateiLaden() {
|
||
const eingabe = document.getElementById('tischDatei');
|
||
if (eingabe.files.length === 0) {
|
||
status('tischStatus', '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 });
|
||
tischeLeeren();
|
||
for (const t of r.tische) tischZeile(t.plaetze, t.x, t.y, t.id);
|
||
status('tischStatus', '✓ ' + r.tische.length + ' Tische aus Datei übernommen (' +
|
||
r.plaetzeGesamt + ' Plätze) — Layout ist bereits gespeichert, ' +
|
||
'„Tische speichern“ ist nur nach Änderungen nötig.', 'ok');
|
||
berechnenFreigeben();
|
||
} catch (e) { status('tischStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||
}
|
||
|
||
/* ------------------------------------------------ 2. Gäste */
|
||
const beispielCSV = `Vorname;Nachname;Titel;Gruppe;Tisch;Anzahl
|
||
Britta;Beispiel;;Brautpaar;1;
|
||
Bernd;Beispiel;Dr.;Brautpaar;1;
|
||
Anna;Huber;;Familie Huber;;
|
||
Berta;Huber;;Familie Huber;;
|
||
Carl;Huber;;Familie Huber;;
|
||
Doris;Huber;;Familie Huber;;
|
||
Emil;Meier;;Familie Meier;;
|
||
Frieda;Meier;;Familie Meier;;
|
||
Gustav;Meier;;Familie Meier;;
|
||
Hanna;Schmidt;;Studienfreunde;;
|
||
Igor;Petrov;;Studienfreunde;;
|
||
Julia;Weber;;Studienfreunde;;
|
||
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' });
|
||
}
|
||
const form = new FormData();
|
||
form.append('datei', blob, 'gaeste.csv');
|
||
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');
|
||
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>';
|
||
document.getElementById('gruppenUebersicht').innerHTML = html;
|
||
berechnenFreigeben();
|
||
} catch (e) { status('gaesteStatus', 'Fehler: ' + e.message, 'fehler'); }
|
||
}
|
||
|
||
/* ------------------------------------------------ 3. Berechnen */
|
||
async function berechnenFreigeben() {
|
||
try {
|
||
const s = await api('/sitzung/' + sitzung);
|
||
document.getElementById('berechnenKnopf').disabled = !(s.tische > 0 && s.personen > 0);
|
||
} catch (e) { /* Session ggf. abgelaufen — beim Berechnen gemeldet */ }
|
||
}
|
||
|
||
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';
|
||
} catch (e) {
|
||
status('rechnenStatus', 'Fehler: ' + e.message, 'fehler');
|
||
} finally {
|
||
knopf.disabled = false;
|
||
}
|
||
}
|
||
|
||
function zeigeErgebnis(r) {
|
||
const getrennte = r.gruppen.filter(g => g.teilungen > 0).length;
|
||
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>';
|
||
|
||
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) {
|
||
const gaeste = t.personen.map(p =>
|
||
((p.titel ? p.titel + ' ' : '') + p.vorname + ' ' + p.nachname +
|
||
' <span class="hinweis">(' + p.gruppe + ')</span>')).join(', ');
|
||
html += '<tr><td>Tisch ' + t.nummer + '</td><td>' + t.besetzt + '/' + t.plaetze +
|
||
'</td><td>' + gaeste + '</td></tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
|
||
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>' +
|
||
g.teilungen + '</td><td>' + g.wert + '</td></tr>').join('') +
|
||
'</tbody></table>';
|
||
document.getElementById('ergebnisTabellen').innerHTML = html;
|
||
}
|
||
|
||
/* ------------------------------------------------ Start */
|
||
window.addEventListener('DOMContentLoaded', async () => {
|
||
document.getElementById('csvText').value = beispielCSV;
|
||
presetReihe();
|
||
try {
|
||
const r = await api('/sitzung', { method: 'POST' });
|
||
sitzung = r.id;
|
||
document.getElementById('sitzungsId').textContent = sitzung;
|
||
} catch (e) {
|
||
document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message;
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|