422 lines
17 KiB
HTML
422 lines
17 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; }
|
||
.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 & 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. 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 & 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>
|