update Tue 07/14/2026 11:38:16.92

This commit is contained in:
2026-07-14 11:38:17 +10:00
commit b2b555ef16
38 changed files with 7348 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
/* admin.js — shared auth + API helpers for admin pages. */
export async function api(path, method = 'GET', body) {
const res = await fetch(path, {
method,
headers: {
'Content-Type': 'application/json',
'x-exhibit-token': localStorage.getItem('exhibitToken') || '',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 401) { location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); }
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
return res.json();
}
export async function requireAuth() {
const info = await (await fetch('/api/info')).json();
if (!info.authRequired) return info;
// probe a guarded endpoint
const res = await fetch('/api/client-errors', { headers: { 'x-exhibit-token': localStorage.getItem('exhibitToken') || '' } });
if (res.status === 401) location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname);
return info;
}
export const styles = `
:root { color-scheme: dark; }
body { margin:0; background:#0a0d18; color:#e8ecff; font-family:system-ui,sans-serif; }
header { display:flex; gap:14px; align-items:center; padding:10px 16px; background:#111730; }
header a { color:#8fb8ff; text-decoration:none; font-size:.9rem; }
header a.active { color:#51eaf1; }
h1 { font-size:1.05rem; margin:0 auto 0 0; }
button { background:#2a3a6e; color:#fff; border:0; border-radius:6px; padding:6px 14px; cursor:pointer; }
button.primary { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:600; }
button.danger { background:#7a2437; }
input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; }
label { font-size:.8rem; opacity:.85; }
`;
export function nav(active) {
return `<header>
<h1>Newbury Exhibit — Admin</h1>
${['layout', 'playlist', 'print', 'calibrate', 'errors'].map(p =>
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
<a href="/">viewer ↗</a>
</header>`;
}
+92
View File
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Calibrate — Newbury Exhibit</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#000;color:#fff;font-family:monospace}
#cam{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;opacity:.85}
#ov{position:fixed;inset:0;pointer-events:none}
#out{position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,.65);padding:8px 10px;font-size:12px;white-space:pre;line-height:1.5}
</style></head>
<body>
<video id="cam" playsinline muted></video>
<canvas id="ov"></canvas>
<div id="out">Starting camera…</div>
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
<script type="module">
import * as THREE from 'three';
import { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
import { PoseEstimator } from '/js/ar/pose.js';
import { WorldFuser } from '/js/ar/fuse.js';
const scene = await (await fetch('/api/scene')).json();
const fuser = new WorldFuser(); fuser.setScene(scene);
const detector = createDetector();
const video = document.getElementById('cam');
const ov = document.getElementById('ov'); const octx = ov.getContext('2d');
const out = document.getElementById('out');
const cv2 = document.createElement('canvas'); const ctx = cv2.getContext('2d', { willReadFrequently: true });
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 } }, audio: false });
video.srcObject = stream; await video.play();
let poseEst = null;
const jitterBuf = new Map(); // markerId -> recent positions
function loop() {
requestAnimationFrame(loop);
if (video.readyState < 2) return;
const W = 640, H = Math.round(W * video.videoHeight / video.videoWidth);
if (cv2.width !== W) { cv2.width = W; cv2.height = H; }
ov.width = innerWidth; ov.height = innerHeight;
ctx.drawImage(video, 0, 0, W, H);
const img = ctx.getImageData(0, 0, W, H);
if (!poseEst) poseEst = new PoseEstimator(W / (2 * Math.tan(30 * Math.PI / 180)));
const markers = detectMarkers(detector, img, fuser.knownIds());
const sx = innerWidth / W, sy = innerHeight / H;
octx.clearRect(0, 0, ov.width, ov.height);
const lines = [];
const estimates = [];
for (const m of markers) {
// outline
octx.strokeStyle = '#51eaf1'; octx.lineWidth = 3; octx.beginPath();
m.corners.forEach((c, i) => i ? octx.lineTo(c.x * sx, c.y * sy) : octx.moveTo(c.x * sx, c.y * sy));
octx.closePath(); octx.stroke();
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
if (!e) continue;
estimates.push({ markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) });
const distMM = e.position.length();
const eul = new THREE.Euler().setFromQuaternion(e.quaternion, 'YXZ');
const buf = jitterBuf.get(m.id) || []; buf.push(e.position.clone()); if (buf.length > 30) buf.shift(); jitterBuf.set(m.id, buf);
const mean = buf.reduce((a, p) => a.add(p), new THREE.Vector3()).multiplyScalar(1 / buf.length);
const jit = Math.sqrt(buf.reduce((a, p) => a + p.distanceToSquared(mean), 0) / buf.length);
lines.push(`crest #${String(m.id).padStart(3)} dist ${(distMM / 10).toFixed(1)} cm` +
` yaw ${THREE.MathUtils.radToDeg(eul.y).toFixed(0).padStart(4)}°` +
` pitch ${THREE.MathUtils.radToDeg(eul.x).toFixed(0).padStart(4)}°` +
` jitter ${jit.toFixed(1)} mm err ${e.error.toFixed(1)}`);
}
const fused = fuser.fuse(estimates);
if (fused) lines.push('', `FUSED cam @ ${fused.position.toArray().map(v => v.toFixed(3)).join(', ')} m (${fused.markerCount} crest${fused.markerCount === 1 ? '' : 's'})`);
out.textContent = lines.join('\n') || 'No crests in view — show a printed Newbury Crest to the camera.';
}
loop();
</script>
</body></html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Client Errors — 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}
main{max-width:900px;margin:16px auto;padding:0 16px}
.err{background:#111730;border-radius:8px;padding:10px 12px;margin:8px 0;font:12px monospace;white-space:pre-wrap}
.err .meta{opacity:.55;margin-bottom:4px}
</style>`);
await requireAuth();
const app = document.getElementById('app');
async function render() {
const errs = await api('/api/client-errors');
app.innerHTML = `${nav('errors')}<main>
<div style="display:flex;align-items:center;gap:12px;margin:12px 0">
<h2 style="margin:0;font-size:1rem">Client errors (${errs.length})</h2>
<button id="refresh">Refresh</button>
</div>
${errs.slice().reverse().map(e => `<div class="err">
<div class="meta">${new Date(e.t).toLocaleString()} · ${e.page || '?'} · ${(e.ua || '').slice(0, 80)}</div>
[${e.kind}] ${e.msg}${e.src ? ` (${e.src}:${e.line})` : ''}</div>`).join('') ||
'<em style="opacity:.6">No errors reported. Lovely.</em>'}
</main>`;
document.getElementById('refresh').onclick = render;
}
render();
</script>
</body></html>
+237
View File
@@ -0,0 +1,237 @@
<!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>
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Login — Newbury Exhibit</title></head>
<body>
<div style="max-width:340px;margin:18vh auto;text-align:center">
<h1 style="font-size:1.3rem">Exhibit Admin</h1>
<input id="pw" type="password" placeholder="Admin password" style="width:100%;padding:10px;margin:12px 0;font-size:1rem">
<button id="go" class="primary" style="width:100%;padding:10px;font-size:1rem">Sign in</button>
<div id="err" style="color:#ff8b8b;margin-top:10px;font-size:.85rem"></div>
</div>
<script type="module">
import { styles } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}</style>`);
const go = async () => {
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: document.getElementById('pw').value }) });
if (!res.ok) { document.getElementById('err').textContent = 'Wrong password.'; return; }
localStorage.setItem('exhibitToken', (await res.json()).token);
location.href = new URLSearchParams(location.search).get('next') || '/admin/layout.html';
};
document.getElementById('go').onclick = go;
document.getElementById('pw').onkeydown = (e) => e.key === 'Enter' && go();
</script>
</body></html>
+99
View File
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Playlist — 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}
main { max-width:640px; margin:20px auto; padding:0 16px; }
.card { background:#111730; border-radius:10px; padding:16px; margin-bottom:16px; }
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
.row label { width:170px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; }
.chip { padding:4px 12px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.85rem; user-select:none; }
.chip.on { background:#2a6ea0; }
table { width:100%; font-size:.85rem; }
</style>`);
await requireAuth();
const app = document.getElementById('app');
let cfg = await api('/api/playlist');
const { ghosts } = await api('/api/ghosts');
const colors = ['Red', 'Yellow', 'Blue'];
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
function chips(list, selected, onToggle) {
return list.map(v => `<span class="chip ${selected.includes(v) ? 'on' : ''}" data-v="${v}">${v}</span>`).join('');
}
function rosterCount() {
const inc = cfg.include || {};
return ghosts.filter(g =>
(!inc.colors?.length || inc.colors.includes(g.color)) &&
(!inc.rarities?.length || inc.rarities.includes(g.rarity))).length;
}
function render() {
app.innerHTML = `${nav('playlist')}<main>
<div class="card">
<h2 style="margin-top:0">Rotation</h2>
<div class="row"><label>Concurrent ghosts (slots)</label><input id="slots" type="number" min="1" max="20" value="${cfg.slots}"></div>
<div class="row"><label>Dwell time (seconds)</label><input id="dwell" type="number" min="10" value="${cfg.dwellSeconds}"></div>
<div class="row"><label>Crossfade (seconds)</label><input id="fade" type="number" min="0" value="${cfg.crossfadeSeconds}"></div>
<div class="row"><label>Order</label>
<select id="order"><option ${cfg.order === 'shuffle' ? 'selected' : ''}>shuffle</option><option ${cfg.order === 'roster' ? 'selected' : ''}>roster</option></select></div>
</div>
<div class="card">
<h2 style="margin-top:0">Roster filter <span style="opacity:.6;font-size:.8rem">(${rosterCount()} ghosts match — empty = all)</span></h2>
<div class="row"><label>Colors</label><div class="chips" id="colors">${chips(colors, cfg.include?.colors || [])}</div></div>
<div class="row"><label>Rarities</label><div class="chips" id="rarities">${chips(rarities, cfg.include?.rarities || [])}</div></div>
</div>
<div class="card">
<h2 style="margin-top:0">Behavior</h2>
<div class="row"><label>Static chance (01)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
<div class="row"><label>Wander radius (m)</label><input id="wanderRadius" type="number" step="0.05" value="${cfg.behaviors.wanderRadius}"></div>
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
</div>
<div class="card">
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside these; empty = always on)</span></h2>
<div id="windows">${(cfg.timeWindows || []).map((w, i) =>
`<div class="row"><input type="time" value="${w.start}" data-i="${i}" data-k="start"> →
<input type="time" value="${w.end}" data-i="${i}" data-k="end">
<button data-del="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
<button id="addWin" style="margin-top:8px">+ Add window</button>
</div>
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts rotation)</button>
<div id="status" style="margin:10px 0;opacity:.7;font-size:.85rem"></div>
</main>`;
for (const grp of ['colors', 'rarities']) {
document.getElementById(grp).querySelectorAll('.chip').forEach(c => c.onclick = () => {
const arr = cfg.include[grp] = cfg.include[grp] || [];
const i = arr.indexOf(c.dataset.v);
i >= 0 ? arr.splice(i, 1) : arr.push(c.dataset.v);
render();
});
}
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.del, 1); render(); });
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
document.getElementById('save').onclick = async () => {
cfg.slots = +document.getElementById('slots').value;
cfg.dwellSeconds = +document.getElementById('dwell').value;
cfg.crossfadeSeconds = +document.getElementById('fade').value;
cfg.order = document.getElementById('order').value;
cfg.behaviors = {
staticChance: +document.getElementById('staticChance').value,
wanderRadius: +document.getElementById('wanderRadius').value,
wanderSpeed: +document.getElementById('wanderSpeed').value,
};
try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); }
catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
};
}
render();
</script>
</body></html>
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Print Crests — Newbury Exhibit</title>
<style>
body { font-family:system-ui,sans-serif; margin:0; }
.sheet { display:flex; flex-wrap:wrap; gap:8mm; padding:10mm; }
.crest { text-align:center; page-break-inside:avoid; }
.crest canvas { image-rendering:pixelated; border:0.5mm solid #000; }
.crest .cap { font-size:9pt; margin-top:2mm; }
@media print { header { display:none } }
</style></head>
<body>
<div id="hdr"></div>
<div class="sheet" id="sheet"></div>
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="module">
import { requireAuth, styles, nav } from './admin.js';
document.getElementById('hdr').innerHTML = nav('print');
document.head.insertAdjacentHTML('beforeend', `<style>@media screen {${styles}}</style>`);
await requireAuth();
const scene = await (await fetch('/api/scene')).json();
const dic = new AR.Dictionary('ARUCO_4X4_1000');
const MM2PX = 96 / 25.4; // CSS px per mm
const sheet = document.getElementById('sheet');
for (const a of scene.anchors) {
const div = document.createElement('div');
div.className = 'crest';
const cv = document.createElement('canvas');
const bits = 4 + 2; // 4x4 payload + 1-cell black border each side
const px = Math.round(a.sizeMM * MM2PX);
cv.width = cv.height = bits * 10;
cv.style.width = cv.style.height = px + 'px';
const ctx = cv.getContext('2d');
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height);
const code = dic.codeList[a.markerId]; // 16-char '0'/'1' string, row-major
ctx.fillStyle = '#fff';
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {
if (code[y * 4 + x] === '1') ctx.fillRect((x + 1) * 10, (y + 1) * 10, 10, 10);
}
div.appendChild(cv);
div.insertAdjacentHTML('beforeend',
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}` +
`${a.mount === 'wall' ? ` · yaw ${a.yawDeg}°` : ''}<br>` +
`pos ${a.position.map(v => v.toFixed(2)).join(', ')} m</div>`);
sheet.appendChild(div);
}
if (!scene.anchors.length) sheet.innerHTML = '<p style="padding:20px">No anchors in the scene yet — add some in the Layout editor.</p>';
</script>
</body></html>