Files
platz/web/static/editor.html
T
2026-07-12 17:02:14 +02:00

473 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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 — Tisch-Editor (M1)</title>
<style>
:root {
--akzent: #2f6f4f;
--akzent-hell: #e6f0ea;
--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; }
header a { color: #d6eade; }
main { max-width: 68rem; margin: 0 auto; padding: 1rem 1.5rem 4rem; }
.werkzeug {
display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
background: white; border: 1px solid var(--rand); border-radius: 8px;
padding: .6rem .75rem; margin-bottom: 1rem;
}
.werkzeug .trenner { width: 1px; height: 1.6rem; background: var(--rand); margin: 0 .3rem; }
.werkzeug label { font-size: .82rem; color: #6b6557; display: inline-flex; align-items: center; gap: .3rem; }
button {
background: var(--akzent); color: white; border: 0; border-radius: 4px;
padding: .42rem .8rem; font-size: .88rem; cursor: pointer;
}
button.sekundaer { background: #8b8577; }
button.geist { background: white; color: var(--akzent); border: 1px solid var(--rand); }
button:disabled { background: #cfcabc; color: #f3f0e8; cursor: not-allowed; }
.buehne {
display: grid; grid-template-columns: 1fr 15rem; gap: 1rem; align-items: start;
}
@media (max-width: 48rem) { .buehne { grid-template-columns: 1fr; } }
#leinwand {
background: white; border: 1px solid var(--rand); border-radius: 8px;
overflow: auto; touch-action: none; min-height: 24rem;
}
#leinwand svg { display: block; }
.circle-tisch { cursor: grab; }
.circle-tisch:active { cursor: grabbing; }
aside {
background: white; border: 1px solid var(--rand); border-radius: 8px;
padding: 1rem; font-size: .9rem;
}
aside h2 { font-size: 1rem; margin: 0 0 .6rem; color: var(--akzent); }
aside .feld { margin: .5rem 0; }
aside input[type=number] { width: 4rem; }
.zahl { display: inline-block; background: #f1eee6; border-radius: 4px;
padding: .25rem .6rem; margin: .1rem .3rem .1rem 0; font-size: .85rem; }
.zahl b { color: var(--akzent); }
.status { font-size: .85rem; margin-top: .6rem; min-height: 1.2rem; }
.status.ok { color: var(--akzent); }
.status.fehler { color: #a03030; white-space: pre-wrap; }
.hinweis { font-size: .8rem; color: #6b6557; }
kbd { background: #f1eee6; border: 1px solid var(--rand); border-bottom-width: 2px;
border-radius: 3px; padding: 0 .3rem; font-size: .78rem; }
</style>
</head>
<body>
<header>
<h1>Sitz &amp; Platz <small style="font-weight:normal">— Interaktiver Tisch-Editor (Meilenstein 1)</small></h1>
<p>Tische per Drag &amp; Drop platzieren; Nachbarschaft wird aus der Nähe abgeleitet.
Sitzung: <code id="sitzungsId"></code> · weiter zu <a href="/">Gäste &amp; Berechnung</a></p>
</header>
<main>
<div class="werkzeug">
<button id="btnUndo" title="Rückgängig (Strg+Z)" disabled>↶ Rückgängig</button>
<button id="btnRedo" title="Wiederholen (Strg+Y)" disabled>↷ Wiederholen</button>
<span class="trenner"></span>
<button id="btnAdd" class="geist">+ Tisch</button>
<button id="btnReihe" class="sekundaer">Preset: 4er-Reihe</button>
<button id="btnRaster" class="sekundaer">Preset: 3×3-Raster</button>
<span class="trenner"></span>
<label>Nachbar-Radius
<input type="range" id="radius" min="0.5" max="3" step="0.1" value="1.5">
<b id="radiusWert">1,5</b>
</label>
<label>Raster
<select id="raster">
<option value="0.5" selected>0,5</option>
<option value="1">1</option>
<option value="1.5">1,5</option>
<option value="2">2</option>
</select>
</label>
<span class="trenner"></span>
<label>tische.ini <input type="file" id="iniDatei" accept=".ini,text/plain"></label>
<button id="btnIni" class="geist">laden</button>
<span class="trenner"></span>
<button id="btnSpeichern">Layout speichern</button>
</div>
<div class="buehne">
<div id="leinwand"></div>
<aside>
<h2>Ausgewählter Tisch</h2>
<div id="inspektor"><p class="hinweis">Kein Tisch ausgewählt. Auf einen Tisch
klicken, um Plätze zu ändern oder ihn zu löschen. Ziehen zum Verschieben.</p></div>
<hr style="border:0;border-top:1px solid var(--rand);margin:.8rem 0">
<div id="zahlen"></div>
<p class="hinweis" style="margin-top:.6rem">Tastatur: <kbd>Strg</kbd>+<kbd>Z</kbd> /
<kbd>Strg</kbd>+<kbd>Y</kbd> Undo/Redo · <kbd>Entf</kbd> löscht den
ausgewählten Tisch.</p>
</aside>
</div>
<div class="status" id="status"></div>
</main>
<script>
'use strict';
/* ============================================================ Zustand
state.tische ist die einzige Wahrheit; das SVG wird daraus gezeichnet
(state-getrieben, nie umgekehrt). Der Nachbar-Radius ist bewusst KEIN
Teil der Undo-History (Undo/Redo betrifft Tischgeometrie/-kapazität,
nicht diese Ableitungs-Einstellung), wird aber mitgespeichert. */
const state = { tische: [] };
let radius = 1.5;
let rasterSchritt = 0.5;
const ZIEH_TEMPO = 0.5; // Ziehgeschwindigkeit: Tisch folgt dem Zeiger halb so schnell
let selectedId = null;
let sitzung = null;
/* Undo/Redo: Schnappschuss-Stapel. Ein Schnappschuss ist ein tiefer Klon
von state.tische. Vor jeder verändernden Aktion wird der aktuelle
Zustand auf undoStack gelegt und redoStack geleert. */
const undoStack = [];
const redoStack = [];
const HIST_MAX = 200;
const klon = (t) => JSON.parse(JSON.stringify(t));
function pushHistory() {
undoStack.push(klon(state.tische));
if (undoStack.length > HIST_MAX) undoStack.shift();
redoStack.length = 0;
knoepfe();
}
function undo() {
if (!undoStack.length) return;
redoStack.push(klon(state.tische));
state.tische = undoStack.pop();
nachZustandswechsel();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(klon(state.tische));
state.tische = redoStack.pop();
nachZustandswechsel();
}
function nachZustandswechsel() {
if (selectedId !== null && !tischVonId(selectedId)) selectedId = null;
knoepfe(); zeichne(); inspektor();
}
function knoepfe() {
document.getElementById('btnUndo').disabled = undoStack.length === 0;
document.getElementById('btnRedo').disabled = redoStack.length === 0;
}
/* ============================================================ Helfer */
const tischVonId = (id) => state.tische.find(t => t.id === id);
function neueId() {
return state.tische.reduce((m, t) => Math.max(m, t.id), 0) + 1;
}
function snap(v) { return Math.round( Math.round(v / rasterSchritt) * rasterSchritt * 100 ) / 100; }
function radiusPx(plaetze) {
// Kreisgroesse waechst mit der Platzzahl (analog alsSVG im Kern)
return Math.max(18, Math.min(48, 15 + plaetze * 1.5));
}
/* ============================================================ Geometrie
Grid-Koordinaten (wie tische.ini) <-> Pixel. Bei jeder Zeichnung neu
bestimmt und in Modulvariablen abgelegt, damit pointerGrid() umrechnen
kann. */
const SCALE = 72, MARGIN = 34, PAD = 1;
let viewMinX = 0, viewMinY = 0, curW = 600, curH = 400;
function grenzen() {
if (!state.tische.length) return { minX: 0, maxX: 3, minY: 0, maxY: 2 };
const xs = state.tische.map(t => t.x), ys = state.tische.map(t => t.y);
return { minX: Math.min(...xs), maxX: Math.max(...xs),
minY: Math.min(...ys), maxY: Math.max(...ys) };
}
const px = (gx, min) => MARGIN + (gx - min) * SCALE;
function pointerGrid(e) {
const svg = document.querySelector('#leinwand svg');
const r = svg.getBoundingClientRect();
const sx = curW / r.width, sy = curH / r.height;
const X = (e.clientX - r.left) * sx, Y = (e.clientY - r.top) * sy;
return { x: (X - MARGIN) / SCALE + viewMinX, y: (Y - MARGIN) / SCALE + viewMinY };
}
/* ============================================================ Zeichnen */
function nachbarPaare() {
const paare = [];
for (let i = 0; i < state.tische.length; i++)
for (let j = i + 1; j < state.tische.length; j++) {
const a = state.tische[i], b = state.tische[j];
if (Math.hypot(a.x - b.x, a.y - b.y) <= radius) paare.push([a, b]);
}
return paare;
}
function zeichne() {
const g = grenzen();
viewMinX = g.minX - PAD; viewMinY = g.minY - PAD;
curW = Math.max(480, MARGIN * 2 + (g.maxX - g.minX + 2 * PAD) * SCALE);
curH = Math.max(320, MARGIN * 2 + (g.maxY - g.minY + 2 * PAD) * SCALE);
let s = `<svg xmlns="http://www.w3.org/2000/svg" width="${curW}" height="${curH}" `
+ `viewBox="0 0 ${curW} ${curH}" font-family="sans-serif">`;
s += `<rect width="100%" height="100%" fill="white"/>`;
// feines Grid an ganzzahligen Rasterlinien
for (let gx = Math.ceil(viewMinX); px(gx, viewMinX) <= curW; gx++)
s += `<line x1="${px(gx, viewMinX)}" y1="0" x2="${px(gx, viewMinX)}" y2="${curH}" stroke="#f0ede5"/>`;
for (let gy = Math.ceil(viewMinY); px(gy, viewMinY) <= curH; gy++)
s += `<line x1="0" y1="${px(gy, viewMinY)}" x2="${curW}" y2="${px(gy, viewMinY)}" stroke="#f0ede5"/>`;
// Nachbar-Linien (aus der Naehe abgeleitet, wie im Backend)
for (const [a, b] of nachbarPaare())
s += `<line x1="${px(a.x, viewMinX)}" y1="${px(a.y, viewMinY)}" `
+ `x2="${px(b.x, viewMinX)}" y2="${px(b.y, viewMinY)}" `
+ `stroke="#bcd3c6" stroke-width="2"/>`;
// Tische als Kreise
for (const t of state.tische) {
const cx = px(t.x, viewMinX), cy = px(t.y, viewMinY), r = radiusPx(t.plaetze);
const gewaehlt = t.id === selectedId;
s += `<g class="circle-tisch" data-id="${t.id}">`;
if (gewaehlt)
s += `<circle cx="${cx}" cy="${cy}" r="${r + 5}" fill="none" `
+ `stroke="#2f6f4f" stroke-width="2" stroke-dasharray="4 3"/>`;
s += `<circle cx="${cx}" cy="${cy}" r="${r}" `
+ `fill="${gewaehlt ? '#e6f0ea' : 'white'}" stroke="#2f6f4f" stroke-width="2"/>`;
s += `<text x="${cx}" y="${cy - 1}" text-anchor="middle" font-size="15" `
+ `font-weight="bold" fill="#2f6f4f">${t.id}</text>`;
s += `<text x="${cx}" y="${cy + 13}" text-anchor="middle" font-size="10" `
+ `fill="#6b6557">${t.plaetze} Pl.</text>`;
s += `</g>`;
}
s += `</svg>`;
document.getElementById('leinwand').innerHTML = s;
// Kennzahlen
const plaetze = state.tische.reduce((m, t) => m + t.plaetze, 0);
document.getElementById('zahlen').innerHTML =
`<span class="zahl">Tische: <b>${state.tische.length}</b></span>` +
`<span class="zahl">Plätze: <b>${plaetze}</b></span>` +
`<span class="zahl">Nachbar-Paare: <b>${nachbarPaare().length}</b></span>`;
}
/* ============================================================ Inspektor */
function inspektor() {
const el = document.getElementById('inspektor');
const t = selectedId !== null ? tischVonId(selectedId) : null;
if (!t) {
el.innerHTML = '<p class="hinweis">Kein Tisch ausgewählt. Auf einen Tisch ' +
'klicken, um Plätze zu ändern oder ihn zu löschen. Ziehen zum Verschieben.</p>';
return;
}
el.innerHTML =
`<div class="feld"><b>Tisch ${t.id}</b></div>` +
`<div class="feld">Plätze: ` +
`<button class="geist" id="plMinus"></button> ` +
`<input type="number" id="plaetze" min="1" max="100" value="${t.plaetze}"> ` +
`<button class="geist" id="plPlus">+</button></div>` +
`<div class="feld hinweis">Position: x=${t.x}, y=${t.y}</div>` +
`<div class="feld"><button class="sekundaer" id="loeschen">Tisch löschen</button></div>`;
document.getElementById('plMinus').onclick = () => plaetzeSetzen(t.plaetze - 1);
document.getElementById('plPlus').onclick = () => plaetzeSetzen(t.plaetze + 1);
document.getElementById('plaetze').onchange = (e) => plaetzeSetzen(parseInt(e.target.value, 10));
document.getElementById('loeschen').onclick = tischLoeschen;
}
function plaetzeSetzen(wert) {
const t = tischVonId(selectedId);
if (!t || isNaN(wert)) return;
wert = Math.max(1, Math.min(100, wert));
if (wert === t.plaetze) return;
pushHistory(); t.plaetze = wert; zeichne(); inspektor();
}
/* ============================================================ Auswahl */
function auswaehlen(id) { selectedId = id; zeichne(); inspektor(); }
/* ============================================================ Aktionen */
function tischHinzufuegen() {
const g = grenzen();
const x = state.tische.length ? snap((g.minX + g.maxX) / 2) : 0;
const y = state.tische.length ? snap((g.minY + g.maxY) / 2) : 0;
pushHistory();
const id = neueId();
state.tische.push({ id, plaetze: 8, x, y });
auswaehlen(id);
melde('Tisch ' + id + ' hinzugefügt.', 'ok');
}
function tischLoeschen() {
if (selectedId === null) return;
pushHistory();
state.tische = state.tische.filter(t => t.id !== selectedId);
const weg = selectedId; selectedId = null;
zeichne(); inspektor();
melde('Tisch ' + weg + ' gelöscht.', 'ok');
}
function presetReihe() {
pushHistory();
state.tische = [1, 2, 3, 4].map(i => ({ id: i, plaetze: 5, x: i - 1, y: 0 }));
selectedId = null; zeichne(); inspektor();
}
function presetRaster() {
pushHistory();
state.tische = [];
let id = 1;
for (let y = 0; y < 3; y++)
for (let x = 0; x < 3; x++) state.tische.push({ id: id++, plaetze: 8, x, y });
selectedId = null; zeichne(); inspektor();
}
/* ============================================================ Drag & Drop */
let dragId = null, dragMoved = false, dragStart = null;
let ankerPtr = null, ankerPos = null; // Zeiger- und Tischposition bei Zugbeginn
document.getElementById('leinwand').addEventListener('pointerdown', (e) => {
const el = e.target.closest('[data-id]');
if (!el) { if (selectedId !== null) auswaehlen(null); return; }
const id = parseInt(el.dataset.id, 10);
auswaehlen(id);
const gpt = pointerGrid(e), t = tischVonId(id);
dragId = id; dragMoved = false; dragStart = klon(state.tische);
// Anker merken: der Tisch wandert ab hier um ZIEH_TEMPO * Zeigerweg
ankerPtr = { x: gpt.x, y: gpt.y };
ankerPos = { x: t.x, y: t.y };
e.preventDefault();
});
window.addEventListener('pointermove', (e) => {
if (dragId === null) return;
const gpt = pointerGrid(e);
// halbe Geschwindigkeit: Verschiebung des Tisches = halber Zeigerweg
const nx = snap(ankerPos.x + (gpt.x - ankerPtr.x) * ZIEH_TEMPO);
const ny = snap(ankerPos.y + (gpt.y - ankerPtr.y) * ZIEH_TEMPO);
const t = tischVonId(dragId);
if (t && (t.x !== nx || t.y !== ny)) { t.x = nx; t.y = ny; dragMoved = true; zeichne(); inspektor(); }
});
window.addEventListener('pointerup', () => {
if (dragId === null) return;
if (dragMoved) {
// den ganzen Zug als EINEN Undo-Schritt ablegen (Vor-Zustand)
undoStack.push(dragStart);
if (undoStack.length > HIST_MAX) undoStack.shift();
redoStack.length = 0; knoepfe();
}
dragId = null; dragStart = null;
});
/* ============================================================ Tastatur */
window.addEventListener('keydown', (e) => {
const tippt = /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault(); undo();
} else if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === 'y' ||
(e.key.toLowerCase() === 'z' && e.shiftKey))) {
e.preventDefault(); redo();
} else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId !== null && !tippt) {
e.preventDefault(); tischLoeschen();
}
});
/* ============================================================ Backend */
function melde(text, klasse) {
const el = document.getElementById('status');
el.textContent = text; el.className = 'status ' + (klasse || '');
}
async function api(pfad, optionen) {
const antwort = await fetch(pfad, optionen);
const daten = await antwort.json().catch(() => null);
if (!antwort.ok) {
const d = daten && daten.detail
? (typeof daten.detail === 'string' ? daten.detail : JSON.stringify(daten.detail))
: antwort.statusText;
throw new Error(d);
}
return daten;
}
async function sitzungHolen() {
// Session mit der Testoberflaeche (index.html) ueber localStorage teilen
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;
}
async function layoutSpeichern() {
if (!state.tische.length) { melde('Mindestens ein Tisch nötig.', 'fehler'); return; }
const tische = state.tische.map(t => ({ id: t.id, plaetze: t.plaetze, x: t.x, y: t.y }));
try {
const r = await api('/sitzung/' + sitzung + '/tische', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tische, nachbarRadius: radius }),
});
melde('✓ ' + r.tische.length + ' Tische, ' + r.plaetzeGesamt +
' Plätze gespeichert. Weiter mit „Gäste & Berechnung“.', 'ok');
} catch (e) { melde('Fehler: ' + e.message, 'fehler'); }
}
async function iniLaden() {
const eingabe = document.getElementById('iniDatei');
if (!eingabe.files.length) { melde('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 });
pushHistory();
state.tische = r.tische.map(t => ({ id: t.id, plaetze: t.plaetze, x: t.x, y: t.y }));
selectedId = null; zeichne(); inspektor();
melde('✓ ' + r.tische.length + ' Tische aus Datei geladen.', 'ok');
} catch (e) { melde('Fehler: ' + e.message, 'fehler'); }
}
async function layoutLaden() {
// bereits in der (geteilten) Session gespeichertes Layout uebernehmen
try {
const r = await api('/sitzung/' + sitzung + '/tische');
if (r.tische && r.tische.length) {
state.tische = r.tische.map(t => ({ id: t.id, plaetze: t.plaetze, x: t.x, y: t.y }));
radius = r.nachbarRadius || 1.5;
document.getElementById('radius').value = radius;
document.getElementById('radiusWert').textContent = radius.toFixed(1).replace('.', ',');
return true;
}
} catch (e) { /* neue Session ohne Layout — Preset zeigen */ }
return false;
}
/* ============================================================ Verdrahtung */
document.getElementById('btnUndo').onclick = undo;
document.getElementById('btnRedo').onclick = redo;
document.getElementById('btnAdd').onclick = tischHinzufuegen;
document.getElementById('btnReihe').onclick = presetReihe;
document.getElementById('btnRaster').onclick = presetRaster;
document.getElementById('btnSpeichern').onclick = layoutSpeichern;
document.getElementById('btnIni').onclick = iniLaden;
document.getElementById('raster').onchange = (e) => { rasterSchritt = parseFloat(e.target.value); };
const radiusInput = document.getElementById('radius');
radiusInput.oninput = (e) => {
radius = parseFloat(e.target.value);
document.getElementById('radiusWert').textContent = radius.toFixed(1).replace('.', ',');
zeichne();
};
/* ============================================================ Start */
window.addEventListener('DOMContentLoaded', async () => {
try {
sitzung = await sitzungHolen();
document.getElementById('sitzungsId').textContent = sitzung;
} catch (e) {
document.getElementById('sitzungsId').textContent = 'Fehler: ' + e.message;
}
const geladen = sitzung ? await layoutLaden() : false;
if (!geladen) state.tische = [1, 2, 3, 4].map(i => ({ id: i, plaetze: 5, x: i - 1, y: 0 }));
knoepfe(); zeichne(); inspektor();
});
</script>
</body>
</html>