Files
newbury-exhibit-v2/public/admin/layout.html
T

238 lines
11 KiB
HTML

<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Layout Editor — Newbury Exhibit</title></head>
<body>
<div id="app"></div>
<script type="module">
import { api, requireAuth, styles, nav } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
#wrap { display:grid; grid-template-columns: 1fr 300px; height: calc(100vh - 44px); }
#cv { background:#0d1226; width:100%; height:100%; display:block; touch-action:none; }
#side { background:#111730; padding:12px; overflow:auto; }
#side h2 { font-size:.9rem; margin:14px 0 6px; color:#8fb8ff; }
.row { display:flex; gap:6px; align-items:center; margin:4px 0; }
.row label { width:70px; }
.row input, .row select { flex:1; min-width:0; }
.tools { display:flex; gap:6px; padding:8px 12px; background:#0d1226; flex-wrap:wrap; }
.badge { font-size:.7rem; opacity:.6; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
app.innerHTML = `${nav('layout')}
<div class="tools">
<button data-add="anchor">+ Anchor</button>
<button data-add="building">+ Building</button>
<button data-add="spawn">+ Spawn point</button>
<span style="flex:1"></span>
<button id="reset" class="danger">Reset to seed</button>
<button id="save" class="primary">Save layout</button>
<span id="status" class="badge"></span>
</div>
<div id="wrap">
<canvas id="cv"></canvas>
<div id="side"><em style="opacity:.6">Select an item to edit it. Drag on the canvas to move. Scroll to zoom.</em></div>
</div>`;
let scene = await api('/api/scene');
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= [];
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let view = { x: 0, z: 0, scale: 120 }; // px per metre
let sel = null; // { kind, index }
let drag = null;
function fit() {
cv.width = cv.clientWidth * devicePixelRatio;
cv.height = cv.clientHeight * devicePixelRatio;
draw();
}
new ResizeObserver(fit).observe(cv);
const w2s = (x, z) => [cv.width / 2 + (x - view.x) * view.scale * devicePixelRatio,
cv.height / 2 + (z - view.z) * view.scale * devicePixelRatio];
const s2w = (px, py) => [(px * devicePixelRatio - cv.width / 2) / (view.scale * devicePixelRatio) + view.x,
(py * devicePixelRatio - cv.height / 2) / (view.scale * devicePixelRatio) + view.z];
function draw() {
ctx.clearRect(0, 0, cv.width, cv.height);
// grid (0.25 m)
ctx.strokeStyle = '#1a2244'; ctx.lineWidth = 1;
const step = 0.25 * view.scale * devicePixelRatio;
const ox = (cv.width / 2 - view.x * view.scale * devicePixelRatio) % step;
const oz = (cv.height / 2 - view.z * view.scale * devicePixelRatio) % step;
for (let x = ox; x < cv.width; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cv.height); ctx.stroke(); }
for (let y = oz; y < cv.height; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cv.width, y); ctx.stroke(); }
// buildings (occluders)
for (const [i, b] of scene.buildings.entries()) {
const [sx, sy] = w2s(b.position[0], b.position[2]);
const w = b.size[0] * view.scale * devicePixelRatio, d = b.size[2] * view.scale * devicePixelRatio;
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(b.yawDeg || 0) * Math.PI / 180);
ctx.fillStyle = isSel('building', i) ? 'rgba(82,158,255,.45)' : 'rgba(82,158,255,.22)';
ctx.strokeStyle = '#529eff';
ctx.fillRect(-w / 2, -d / 2, w, d); ctx.strokeRect(-w / 2, -d / 2, w, d);
ctx.restore();
label(b.name || `bldg ${i}`, sx, sy, '#9cc4ff');
}
// spawns
for (const [i, s] of scene.spawns.entries()) {
const [sx, sy] = w2s(s.position[0], s.position[2]);
ctx.beginPath(); ctx.arc(sx, sy, 9 * devicePixelRatio, 0, 7);
ctx.fillStyle = isSel('spawn', i) ? '#fff35d' : (s.enabled === false ? '#665' : '#b57f0b');
ctx.fill();
label(s.id || `spawn ${i}`, sx, sy - 14 * devicePixelRatio, '#ffe9a3');
}
// anchors
for (const [i, a] of scene.anchors.entries()) {
const [sx, sy] = w2s(a.position[0], a.position[2]);
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(a.yawDeg || 0) * Math.PI / 180);
const r = Math.max(8, (a.sizeMM / 1000) * view.scale) * devicePixelRatio;
ctx.fillStyle = isSel('anchor', i) ? '#51eaf1' : (a.enabled === false ? '#456' : (a.mount === 'wall' ? '#f65151' : '#3bc9a7'));
if (a.mount === 'wall') { ctx.fillRect(-r, -2.5 * devicePixelRatio, 2 * r, 5 * devicePixelRatio); // edge-on line
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -r); ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 2; ctx.stroke(); }
else ctx.fillRect(-r, -r, 2 * r, 2 * r);
ctx.restore();
label(`#${a.markerId}${a.mount === 'wall' ? ' ⊥' : ''}`, sx, sy + 16 * devicePixelRatio, '#a6fff0');
}
}
function label(txt, x, y, col) {
ctx.fillStyle = col; ctx.font = `${11 * devicePixelRatio}px system-ui`; ctx.textAlign = 'center';
ctx.fillText(txt, x, y - 12 * devicePixelRatio);
}
const isSel = (k, i) => sel && sel.kind === k && sel.index === i;
function hit(px, py) {
const [wx, wz] = s2w(px, py);
const near = (x, z, r) => Math.hypot(wx - x, wz - z) < r;
for (const [i, a] of scene.anchors.entries()) if (near(a.position[0], a.position[2], 0.12)) return { kind: 'anchor', index: i };
for (const [i, s] of scene.spawns.entries()) if (near(s.position[0], s.position[2], 0.12)) return { kind: 'spawn', index: i };
for (const [i, b] of scene.buildings.entries())
if (Math.abs(wx - b.position[0]) < b.size[0] / 2 + 0.05 && Math.abs(wz - b.position[2]) < b.size[2] / 2 + 0.05)
return { kind: 'building', index: i };
return null;
}
const objOf = (s) => s && ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index];
cv.addEventListener('pointerdown', (e) => {
cv.setPointerCapture(e.pointerId);
const h = hit(e.offsetX, e.offsetY);
sel = h;
drag = h ? { kind: 'move' } : { kind: 'pan', sx: e.offsetX, sy: e.offsetY, vx: view.x, vz: view.z };
panel(); draw();
});
cv.addEventListener('pointermove', (e) => {
if (!drag) return;
if (drag.kind === 'move' && sel) {
const [wx, wz] = s2w(e.offsetX, e.offsetY);
const o = objOf(sel);
o.position[0] = Math.round(wx * 200) / 200; // 5 mm snap
o.position[2] = Math.round(wz * 200) / 200;
panel(false); draw();
} else if (drag.kind === 'pan') {
view.x = drag.vx - (e.offsetX - drag.sx) / view.scale;
view.z = drag.vz - (e.offsetY - drag.sy) / view.scale;
draw();
}
});
cv.addEventListener('pointerup', () => drag = null);
cv.addEventListener('wheel', (e) => {
e.preventDefault();
view.scale = Math.min(600, Math.max(30, view.scale * (e.deltaY < 0 ? 1.12 : 0.89)));
draw();
}, { passive: false });
// ---------- add / delete ----------
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
const [wx, wz] = [view.x, view.z];
if (b.dataset.add === 'anchor') {
const used = new Set(scene.anchors.map(a => a.markerId));
let id = 0; while (used.has(id)) id++;
scene.anchors.push({ markerId: id, position: [wx, 0, wz], mount: 'flat', yawDeg: 0, pitchDeg: 0, rollDeg: 0, sizeMM: 60, enabled: true });
sel = { kind: 'anchor', index: scene.anchors.length - 1 };
} else if (b.dataset.add === 'building') {
scene.buildings.push({ name: 'building', position: [wx, 0, wz], size: [0.4, 0.3, 0.3], yawDeg: 0 });
sel = { kind: 'building', index: scene.buildings.length - 1 };
} else {
scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [wx, 0.25, wz], enabled: true });
sel = { kind: 'spawn', index: scene.spawns.length - 1 };
}
panel(); draw();
});
// ---------- property panel ----------
const side = document.getElementById('side');
function field(label, val, oncommit, opts) {
const id = 'f' + Math.random().toString(36).slice(2, 8);
setTimeout(() => {
const el = document.getElementById(id);
el.onchange = () => { oncommit(opts?.select ? el.value : parseFloat(el.value)); panel(false); draw(); };
});
if (opts?.select)
return `<div class="row"><label>${label}</label><select id="${id}">${opts.select.map(o => `<option ${o === val ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`;
return `<div class="row"><label>${label}</label><input id="${id}" type="number" step="${opts?.step ?? 0.01}" value="${val}"></div>`;
}
function textField(label, val, oncommit) {
const id = 'f' + Math.random().toString(36).slice(2, 8);
setTimeout(() => { const el = document.getElementById(id); el.onchange = () => { oncommit(el.value); panel(false); draw(); }; });
return `<div class="row"><label>${label}</label><input id="${id}" value="${val}"></div>`;
}
function panel(rebuild = true) {
if (!rebuild) return;
const o = objOf(sel);
if (!o) { side.innerHTML = '<em style="opacity:.6">Select an item to edit it.</em>'; return; }
let h = `<h2>${sel.kind} ${sel.index}</h2>`;
if (sel.kind === 'anchor') {
h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 });
h += field('x (m)', o.position[0], v => o.position[0] = v);
h += field('y (m)', o.position[1], v => o.position[1] = v);
h += field('z (m)', o.position[2], v => o.position[2] = v);
h += field('mount', o.mount, v => o.mount = v, { select: ['flat', 'wall', 'custom'] });
h += field('yaw °', o.yawDeg, v => o.yawDeg = v, { step: 1 });
h += field('pitch °', o.pitchDeg, v => o.pitchDeg = v, { step: 1 });
h += field('roll °', o.rollDeg, v => o.rollDeg = v, { step: 1 });
h += field('size mm', o.sizeMM, v => o.sizeMM = v, { step: 1 });
h += field('enabled', o.enabled ? 'yes' : 'no', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
h += `<p class="badge">flat = face-up on a surface · wall = vertical, yaw sets facing · custom = full yaw/pitch/roll</p>`;
} else if (sel.kind === 'building') {
h += textField('name', o.name || '', v => o.name = v);
h += field('x (m)', o.position[0], v => o.position[0] = v);
h += field('z (m)', o.position[2], v => o.position[2] = v);
h += field('width', o.size[0], v => o.size[0] = v);
h += field('height', o.size[1], v => o.size[1] = v);
h += field('depth', o.size[2], v => o.size[2] = v);
h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 });
} else {
h += textField('id', o.id, v => o.id = v);
h += field('x (m)', o.position[0], v => o.position[0] = v);
h += field('y (m)', o.position[1], v => o.position[1] = v);
h += field('z (m)', o.position[2], v => o.position[2] = v);
h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
}
h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
side.innerHTML = h;
document.getElementById('del').onclick = () => {
({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
sel = null; panel(); draw();
};
}
// ---------- save / reset ----------
const status = document.getElementById('status');
document.getElementById('save').onclick = async () => {
try { scene = await api('/api/scene', 'PUT', scene); status.textContent = 'Saved ' + new Date().toLocaleTimeString(); }
catch (e) { status.textContent = 'Save failed: ' + e.message; }
};
document.getElementById('reset').onclick = async () => {
if (!confirm('Reset layout to committed seed? Live edits will be lost.')) return;
scene = await api('/api/scene/reset', 'POST');
sel = null; panel(); draw();
};
fit(); panel();
</script>
</body></html>