Files
platz/web/static/index.html
T

422 lines
17 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
.varianten { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
.variante {
border: 1px solid var(--rand); border-radius: 6px; background: white;
padding: .5rem .8rem; cursor: pointer; font-size: .85rem; text-align: left;
color: #2a2a24; min-width: 11rem;
}
.variante.aktiv { border-color: var(--akzent); background: #e6f0ea;
box-shadow: 0 0 0 1px var(--akzent); }
.variante b { color: var(--akzent); font-size: 1rem; }
.variante .zeile { display: block; color: #6b6557; }
</style>
</head>
<body>
<header>
<h1>Sitz &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>
· <a href="/editor.html" style="color:#d6eade">grafischer Tisch-Editor</a></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 &amp; Varianten vergleichen</h2>
<p class="hinweis">Ein Lauf liefert bis zu drei <em>unterschiedliche</em> gute
Sitzordnungen zum Vergleich — die Wertigkeit (0 = perfekt, negativer = schlechter),
die Zahl getrennter Gruppen und allein sitzende Personen machen sie vergleichbar.
Variante anklicken, um Tische und Raumplan umzuschalten; jeder weitere Klick auf
„Berechnen“ startet einen frischen Lauf.</p>
<button type="button" id="berechnenKnopf" onclick="berechnen()" disabled>Sitzordnung berechnen</button>
<div class="status" id="rechnenStatus"></div>
<div class="varianten" id="variantenListe"></div>
<div id="kennzahlen"></div>
<div id="ergebnisTabellen"></div>
<img id="svgBild" alt="" style="display:none">
</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 */
let geladenerRadius = 1.5; // zuletzt aus der Session geladener Nachbar-Radius
async function tischeAusSessionLaden() {
// Sektion 1 aus dem serverseitigen Session-Layout fuellen (z.B. was der
// grafische Editor gespeichert hat) statt fest das Preset zu zeigen.
try {
const r = await api('/sitzung/' + sitzung + '/tische');
if (r.tische && r.tische.length) {
tischeLeeren();
for (const t of r.tische) tischZeile(t.plaetze, t.x, t.y, t.id);
geladenerRadius = r.nachbarRadius || 1.5;
return;
}
} catch (e) { /* frische/abgelaufene Session — Preset als Fallback */ }
presetReihe();
}
function tischZeile(plaetze, x, y, tid) {
const tbody = document.querySelector('#tischTabelle tbody');
const nr = tid ?? tbody.children.length + 1;
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, nachbarRadius: geladenerRadius }),
});
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 csvDateiGewaehlt(ev) {
// Ausgewaehlte CSV sofort sichtbar in die Textarea laden (Single Source):
// dekodieren wie das Backend (utf-8-sig, sonst cp1252), BOM strippen.
const datei = ev.target.files[0];
if (!datei) return;
const puffer = await datei.arrayBuffer();
let text;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(puffer);
} catch (e) {
text = new TextDecoder('windows-1252').decode(puffer); // Excel/Windows
}
text = text.replace(/^/, '');
document.getElementById('csvText').value = text;
// stale Ausgabe eines frueheren Uploads entfernen
document.getElementById('gruppenUebersicht').innerHTML = '';
status('gaesteStatus', 'Datei „' + datei.name + '“ geladen — zum Übernehmen ' +
'„Gästeliste übernehmen“ klicken.', 'ok');
ev.target.value = ''; // Textarea ist ab jetzt die alleinige Quelle
}
async function gaesteHochladen() {
// immer die (ggf. aus einer Datei befuellte) Textarea senden
const blob = new Blob([document.getElementById('csvText').value], { type: 'text/csv' });
const form = new FormData();
form.append('datei', blob, 'gaeste.csv');
try {
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 */ }
}
let letzterLauf = null; // Antwort des letzten Berechnen-Laufs (mit Varianten)
async function berechnen() {
const knopf = document.getElementById('berechnenKnopf');
knopf.disabled = true;
status('rechnenStatus', 'Der genetische Algorithmus läuft…');
try {
const r = await api('/sitzung/' + sitzung + '/berechnen?varianten=3', { method: 'POST' });
letzterLauf = r;
status('rechnenStatus', '✓ Lauf ' + r.lauf + ': ' + r.varianten.length +
(r.varianten.length === 1
? ' Variante gefunden (der Lauf konvergierte auf eine Sitzordnung).'
: ' unterschiedliche Varianten zum Vergleich.'), 'ok');
knopf.textContent = 'Neuen Lauf berechnen';
zeigeVariantenListe(r);
await zeigeVariante(0);
} catch (e) {
status('rechnenStatus', 'Fehler: ' + e.message, 'fehler');
} finally {
knopf.disabled = false;
}
}
function zeigeVariantenListe(r) {
document.getElementById('variantenListe').innerHTML = r.varianten.map((v, i) =>
'<button type="button" class="variante" data-i="' + i + '" onclick="zeigeVariante(' + i + ')">' +
'<b>Variante ' + v.nr + '</b>' +
'<span class="zeile">Wertigkeit: ' + v.kennzahlen.wertigkeit + '</span>' +
'<span class="zeile">getrennte Gruppen: ' + v.kennzahlen.getrennteGruppen +
' (' + v.kennzahlen.teilungenGesamt + ' Teilungen)</span>' +
'<span class="zeile">allein sitzend: ' + v.kennzahlen.alleinePersonen + '</span>' +
'</button>').join('');
}
async function zeigeVariante(i) {
if (!letzterLauf || !letzterLauf.varianten[i]) return;
const v = letzterLauf.varianten[i];
document.querySelectorAll('#variantenListe .variante').forEach(el =>
el.classList.toggle('aktiv', parseInt(el.dataset.i, 10) === i));
document.getElementById('kennzahlen').innerHTML =
'<span class="kennzahl">Variante: <b>' + v.nr + '</b> (Lauf ' + letzterLauf.lauf + ')</span>' +
'<span class="kennzahl">Wertigkeit: <b>' + v.kennzahlen.wertigkeit + '</b></span>' +
'<span class="kennzahl">getrennte Gruppen: <b>' + v.kennzahlen.getrennteGruppen + '</b></span>' +
'<span class="kennzahl">allein sitzend: <b>' + v.kennzahlen.alleinePersonen + '</b></span>';
let html = '<table><thead><tr><th>Tisch</th><th>Belegung</th><th>Gäste (Gruppe)</th></tr></thead><tbody>';
for (const t of v.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>' +
v.gruppen.map(g => '<tr><td>' + g.name + '</td><td>' + g.anzahl + '</td><td>' +
g.teilungen + '</td><td>' + g.wert + '</td></tr>').join('') +
'</tbody></table>';
document.getElementById('ergebnisTabellen').innerHTML = html;
// Raumplan der gewaehlten Variante
try {
const svg = await fetch('/sitzung/' + sitzung + '/ergebnis.svg?variante=' + v.nr);
const svgText = await svg.text();
const bild = document.getElementById('svgBild');
bild.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText);
bild.style.display = 'block';
} catch (e) { /* SVG optional — Tabellen stehen schon da */ }
}
/* ------------------------------------------------ Start */
window.addEventListener('DOMContentLoaded', async () => {
document.getElementById('csvText').value = beispielCSV;
document.getElementById('csvDatei').addEventListener('change', csvDateiGewaehlt);
try {
sitzung = await sitzungHolen();
document.getElementById('sitzungsId').textContent = sitzung;
// Tische aus der (ggf. mit dem Editor geteilten) Session laden;
// Preset nur, wenn die Session noch keine Tische hat
await tischeAusSessionLaden();
berechnenFreigeben();
} catch (e) {
document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message;
presetReihe();
}
});
// Session mit dem grafischen Editor (editor.html) ueber localStorage teilen:
// eine bestehende, noch gueltige Sitzung wiederverwenden statt jedes Mal
// eine neue anzulegen — so wirkt ein im Editor gebautes Layout hier weiter.
async function sitzungHolen() {
let id = localStorage.getItem('platzSitzung');
if (id) {
const ok = await fetch('/sitzung/' + id).then(r => r.ok).catch(() => false);
if (ok) return id;
}
const r = await api('/sitzung', { method: 'POST' });
localStorage.setItem('platzSitzung', r.id);
return r.id;
}
</script>
</body>
</html>