Web-MVP (M1): FastAPI-API, Testoberflaeche, tische.ini-Upload, zwei neue Beispiele

- 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>
This commit is contained in:
Michael Stangl
2026-07-11 10:21:45 +02:00
parent 5124b7fab6
commit 00dffc59eb
20 changed files with 5706 additions and 20 deletions
+329
View File
@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sitz &amp; 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 &amp; Platz <small style="font-weight:normal">— Testoberfläche (Meilenstein 1)</small></h1>
<p>Session-basiert, nichts wird dauerhaft gespeichert (TTL 24 h). Sitzung: <code id="sitzungsId">wird angelegt…</code></p>
</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.&nbsp;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.&nbsp;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>