update Tue 07/14/2026 11:38:16.92
This commit is contained in:
@@ -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>`;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 (0–1)</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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user