223 lines
12 KiB
HTML
223 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Newbury Exhibit — placement plan</title>
|
|
<style>
|
|
:root { --ink:#12131a; --dim:#666; --line:#cfd3da;
|
|
--red:#f65151; --yellow:#e0b400; --blue:#529eff; --anchor:#00a88f; --bldg:#c98a4b; }
|
|
* { box-sizing:border-box; }
|
|
html,body { margin:0; background:#f4f5f7; color:var(--ink);
|
|
font-family:'Inter',system-ui,sans-serif; }
|
|
#bar { position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid var(--line);
|
|
padding:10px 16px; display:flex; align-items:center; gap:14px; flex-wrap:wrap;
|
|
box-shadow:0 1px 4px rgba(0,0,0,0.06); }
|
|
#bar h1 { font-size:15px; margin:0; font-weight:700; }
|
|
#bar h1 span { color:var(--blue); }
|
|
#bar .spacer { flex:1; }
|
|
#bar label { font-size:12px; color:var(--dim); display:flex; align-items:center; gap:5px; }
|
|
#bar button { border:0; cursor:pointer; font-weight:600; font-size:13px; padding:8px 16px;
|
|
border-radius:8px; background:#111827; color:#fff; }
|
|
#bar button.ghost { background:#eef0f4; color:#111827; }
|
|
#wrap { padding:18px; max-width:1000px; margin:0 auto; }
|
|
.planbox { background:#fff; border:1px solid #e3e5ea; border-radius:12px; padding:16px; margin-bottom:16px; }
|
|
svg { display:block; width:100%; height:auto; }
|
|
table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
|
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid #eceef2; }
|
|
th { font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--dim); }
|
|
td.mono, th.mono { font-family:ui-monospace,monospace; }
|
|
.chip { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:6px; vertical-align:middle; }
|
|
.legend { display:flex; gap:18px; flex-wrap:wrap; font-size:12px; color:var(--dim); margin-top:10px; }
|
|
.legend span { display:flex; align-items:center; gap:6px; }
|
|
h2 { font-size:13px; margin:0 0 10px; }
|
|
.note { font-size:11px; color:var(--dim); margin-top:8px; line-height:1.5; }
|
|
@media print {
|
|
#bar { display:none; }
|
|
body { background:#fff; }
|
|
.planbox { border:none; box-shadow:none; page-break-inside:avoid; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="bar">
|
|
<h1>Newbury <span>placement plan</span></h1>
|
|
<div class="spacer"></div>
|
|
<label><input id="grid" type="checkbox" checked /> 100mm grid</label>
|
|
<label><input id="labels" type="checkbox" checked /> labels</label>
|
|
<button class="ghost" id="reloadBtn">Reload</button>
|
|
<button id="printBtn">Print</button>
|
|
</div>
|
|
|
|
<div id="wrap">
|
|
<div class="planbox">
|
|
<h2>Top-down plan (looking down at the table)</h2>
|
|
<div id="plan">loading…</div>
|
|
<div class="legend">
|
|
<span><i class="chip" style="background:var(--anchor)"></i> anchor (crest) — arrow = facing/yaw</span>
|
|
<span><i class="chip" style="background:var(--bldg)"></i> building footprint</span>
|
|
<span><i class="chip" style="background:var(--blue)"></i> ghost spawn / path</span>
|
|
</div>
|
|
<div class="note">Origin (0,0) is the front-left corner. X runs right (length), Z runs away from you (depth). Measure from that corner when placing pieces. Print at 100% for a to-scale reference, or just use the coordinate tables below.</div>
|
|
</div>
|
|
|
|
<div class="planbox">
|
|
<h2>Anchors — where to place each crest</h2>
|
|
<div id="anchorTable"></div>
|
|
</div>
|
|
|
|
<div class="planbox">
|
|
<h2>Buildings — footprints</h2>
|
|
<div id="bldgTable"></div>
|
|
</div>
|
|
|
|
<div class="planbox">
|
|
<h2>Ghost spawns</h2>
|
|
<div id="ghostTable"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
const A = '#00a88f', B = '#c98a4b';
|
|
const LURE = { Red:'#f65151', Yellow:'#e0b400', Blue:'#529eff' };
|
|
let scene = null;
|
|
|
|
async function load() {
|
|
try {
|
|
const r = await fetch('/api/scene');
|
|
scene = await r.json();
|
|
} catch (e) {
|
|
document.getElementById('plan').textContent = 'Could not load /api/scene: ' + e.message;
|
|
return;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
const showGrid = document.getElementById('grid').checked;
|
|
const showLabels = document.getElementById('labels').checked;
|
|
const W = scene.world.buildSize.x, D = scene.world.buildSize.z;
|
|
|
|
// SVG scaled so the longer side fits ~920px; keep mm aspect.
|
|
const pad = 46;
|
|
const scale = (920 - pad * 2) / Math.max(W, D);
|
|
const sw = W * scale + pad * 2, sh = D * scale + pad * 2;
|
|
const X = (x) => pad + x * scale;
|
|
const Z = (z) => pad + z * scale; // top-down: z downward on the page
|
|
|
|
let s = `<svg viewBox="0 0 ${sw} ${sh}" xmlns="http://www.w3.org/2000/svg" font-family="Inter,system-ui,sans-serif">`;
|
|
s += `<rect x="${pad}" y="${pad}" width="${W*scale}" height="${D*scale}" fill="#fafbfc" stroke="#b9bec8"/>`;
|
|
|
|
// grid
|
|
if (showGrid) {
|
|
for (let x = 0; x <= W; x += 100) {
|
|
s += `<line x1="${X(x)}" y1="${pad}" x2="${X(x)}" y2="${pad+D*scale}" stroke="#eceef2"/>`;
|
|
if (x % 500 === 0) s += `<text x="${X(x)}" y="${pad-6}" font-size="10" fill="#999" text-anchor="middle">${x}</text>`;
|
|
}
|
|
for (let z = 0; z <= D; z += 100) {
|
|
s += `<line x1="${pad}" y1="${Z(z)}" x2="${pad+W*scale}" y2="${Z(z)}" stroke="#eceef2"/>`;
|
|
if (z % 500 === 0) s += `<text x="${pad-8}" y="${Z(z)+3}" font-size="10" fill="#999" text-anchor="end">${z}</text>`;
|
|
}
|
|
}
|
|
|
|
// origin marker + axes
|
|
s += `<circle cx="${X(0)}" cy="${Z(0)}" r="4" fill="#111"/>`;
|
|
s += `<line x1="${X(0)}" y1="${Z(0)}" x2="${X(0)+40}" y2="${Z(0)}" stroke="#f65151" stroke-width="2"/><text x="${X(0)+46}" y="${Z(0)+4}" font-size="11" fill="#f65151">X</text>`;
|
|
s += `<line x1="${X(0)}" y1="${Z(0)}" x2="${X(0)}" y2="${Z(0)+40}" stroke="#529eff" stroke-width="2"/><text x="${X(0)+4}" y="${Z(0)+52}" font-size="11" fill="#529eff">Z</text>`;
|
|
|
|
// buildings (footprints)
|
|
for (const bl of (scene.blockers || [])) {
|
|
const x = X(bl.position.x), y = Z(bl.position.z);
|
|
const w = bl.size.x * scale, h = bl.size.z * scale;
|
|
s += `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${B}22" stroke="${B}" stroke-width="1.5"/>`;
|
|
if (showLabels) s += `<text x="${x + w/2}" y="${y + h/2 + 3}" font-size="10" fill="${B}" text-anchor="middle">${bl.label || 'bldg'}</text>`;
|
|
}
|
|
|
|
// ghost paths + spawns
|
|
for (const sp of (scene.spawns || [])) {
|
|
const col = LURE[sp.color] || LURE.Blue;
|
|
if (sp.motion === 'patrol' && sp.path && sp.path.length > 1) {
|
|
const pts = sp.path.map(p => `${X(p.x)},${Z(p.z)}`).join(' ');
|
|
s += `<polygon points="${pts}" fill="none" stroke="${col}" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.8"/>`;
|
|
for (const p of sp.path) s += `<circle cx="${X(p.x)}" cy="${Z(p.z)}" r="3" fill="${col}"/>`;
|
|
const h = sp.path[0];
|
|
if (showLabels) s += `<text x="${X(h.x)+6}" y="${Z(h.z)-6}" font-size="10" fill="${col}">${sp.label||sp.ghostId||'ghost'}</text>`;
|
|
} else if (sp.position) {
|
|
const x = X(sp.position.x), y = Z(sp.position.z);
|
|
s += `<circle cx="${x}" cy="${y}" r="6" fill="${col}" opacity="0.85"/>`;
|
|
if (sp.hoverRadiusMm) s += `<circle cx="${x}" cy="${y}" r="${sp.hoverRadiusMm*scale}" fill="none" stroke="${col}" stroke-width="1" stroke-dasharray="2 2" opacity="0.6"/>`;
|
|
if (showLabels) s += `<text x="${x+9}" y="${y+3}" font-size="10" fill="${col}">${sp.label||sp.ghostId||'ghost'}</text>`;
|
|
}
|
|
}
|
|
|
|
// anchors with orientation arrow (yaw)
|
|
for (const a of (scene.anchors || [])) {
|
|
const x = X(a.position.x), y = Z(a.position.z);
|
|
const sz = (a.fiducialSizeMm || 96) * scale;
|
|
s += `<rect x="${x - sz/2}" y="${y - sz/2}" width="${sz}" height="${sz}" fill="${A}22" stroke="${A}" stroke-width="1.5"/>`;
|
|
// yaw arrow: 0deg points +Z (down the page). rotate clockwise for +yaw.
|
|
const yaw = (a.rotationDeg && a.rotationDeg.y || 0) * Math.PI / 180;
|
|
const ax = x + Math.sin(yaw) * (sz/2 + 12), ay = y + Math.cos(yaw) * (sz/2 + 12);
|
|
s += `<line x1="${x}" y1="${y}" x2="${ax}" y2="${ay}" stroke="${A}" stroke-width="2"/>`;
|
|
s += `<circle cx="${ax}" cy="${ay}" r="3" fill="${A}"/>`;
|
|
if (showLabels) s += `<text x="${x}" y="${y - sz/2 - 6}" font-size="10" fill="${A}" text-anchor="middle">${a.label || ('id '+a.fiducialId)}</text>`;
|
|
}
|
|
|
|
s += '</svg>';
|
|
document.getElementById('plan').innerHTML = s;
|
|
renderTables();
|
|
}
|
|
|
|
function renderTables() {
|
|
// anchors
|
|
let t = `<table><thead><tr><th>Crest</th><th class="mono">id</th><th class="mono">X (mm)</th><th class="mono">Z (mm)</th><th class="mono">Y height</th><th class="mono">yaw°</th><th class="mono">size</th><th>mount</th></tr></thead><tbody>`;
|
|
for (const a of (scene.anchors || [])) {
|
|
t += `<tr><td><span class="chip" style="background:${A}"></span>${a.label||a.id}</td>`
|
|
+ `<td class="mono">${a.fiducialId}</td>`
|
|
+ `<td class="mono">${Math.round(a.position.x)}</td><td class="mono">${Math.round(a.position.z)}</td>`
|
|
+ `<td class="mono">${Math.round(a.position.y||0)}</td>`
|
|
+ `<td class="mono">${Math.round((a.rotationDeg&&a.rotationDeg.y)||0)}</td>`
|
|
+ `<td class="mono">${a.fiducialSizeMm||96}mm</td>`
|
|
+ `<td>${a.mount||'flat'}</td></tr>`;
|
|
}
|
|
t += `</tbody></table>`;
|
|
document.getElementById('anchorTable').innerHTML = (scene.anchors||[]).length ? t : '<div class="note">No anchors yet.</div>';
|
|
|
|
// buildings
|
|
let b = `<table><thead><tr><th>Building</th><th class="mono">corner X</th><th class="mono">corner Z</th><th class="mono">W (x)</th><th class="mono">H (y)</th><th class="mono">D (z)</th></tr></thead><tbody>`;
|
|
for (const bl of (scene.blockers || [])) {
|
|
b += `<tr><td><span class="chip" style="background:${B}"></span>${bl.label||bl.id}</td>`
|
|
+ `<td class="mono">${Math.round(bl.position.x)}</td><td class="mono">${Math.round(bl.position.z)}</td>`
|
|
+ `<td class="mono">${bl.size.x}</td><td class="mono">${bl.size.y}</td><td class="mono">${bl.size.z}</td></tr>`;
|
|
}
|
|
b += `</tbody></table>`;
|
|
document.getElementById('bldgTable').innerHTML = (scene.blockers||[]).length ? b : '<div class="note">No buildings yet.</div>';
|
|
|
|
// ghosts
|
|
let g = `<table><thead><tr><th>Ghost</th><th>lure</th><th>motion</th><th class="mono">X</th><th class="mono">Y</th><th class="mono">Z</th><th>detail</th></tr></thead><tbody>`;
|
|
for (const sp of (scene.spawns || [])) {
|
|
const col = LURE[sp.color] || LURE.Blue;
|
|
const home = sp.motion === 'patrol' ? (sp.path && sp.path[0]) : sp.position;
|
|
const detail = sp.motion === 'patrol' ? `${(sp.path||[]).length} pts · ${sp.patrolPeriodS||16}s loop`
|
|
: `r${sp.hoverRadiusMm||0} · bob${sp.bobAmplitudeMm||0}`;
|
|
g += `<tr><td><span class="chip" style="background:${col}"></span>${sp.label||sp.ghostId||sp.id}</td>`
|
|
+ `<td>${sp.color||'?'}</td><td>${sp.motion}</td>`
|
|
+ `<td class="mono">${home?Math.round(home.x):'-'}</td><td class="mono">${home?Math.round(home.y):'-'}</td><td class="mono">${home?Math.round(home.z):'-'}</td>`
|
|
+ `<td class="mono">${detail}</td></tr>`;
|
|
}
|
|
g += `</tbody></table>`;
|
|
document.getElementById('ghostTable').innerHTML = (scene.spawns||[]).length ? g : '<div class="note">No ghosts yet.</div>';
|
|
}
|
|
|
|
document.getElementById('grid').addEventListener('change', render);
|
|
document.getElementById('labels').addEventListener('change', render);
|
|
document.getElementById('reloadBtn').addEventListener('click', load);
|
|
document.getElementById('printBtn').addEventListener('click', () => window.print());
|
|
load();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|