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
+22
View File
@@ -0,0 +1,22 @@
/* detect.js — tuned ArUco 4x4 detection.
* Phantom-ID fix: reject any marker decoded with hamming distance > 0 (maxHamming: 0).
*/
export function createDetector() {
return new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000', maxHammingDistance: 0 });
}
export function detectMarkers(detector, imageData, knownIds) {
const markers = detector.detect(imageData);
// keep only markers that exist in the scene, with sane geometry
return markers.filter(m => knownIds.has(m.id) && quadArea(m.corners) > 100);
}
export function quadArea(c) {
// shoelace
let a = 0;
for (let i = 0; i < 4; i++) {
const p = c[i], q = c[(i + 1) % 4];
a += p.x * q.y - q.x * p.y;
}
return Math.abs(a) / 2;
}
+88
View File
@@ -0,0 +1,88 @@
/* fuse.js — fuseWorld: combine per-marker camera pose estimates into one world pose.
*
* Each detected marker yields a camera-in-world estimate:
* cameraWorld = anchorWorld * inverse(markerPoseInCamera)
* Estimates are fused by confidence-weighted quaternion slerp (incremental
* weighted average) and weighted position mean. Confidence = marker screen area
* (bigger/closer markers dominate). A light temporal smooth removes residual jitter.
*
* Requiring >= 2 visible markers is handled upstream by anchor placement density;
* fuseWorld itself works with 1..N.
*/
import * as THREE from 'three';
import { anchorWorldMatrix } from './pose.js';
const _inv = new THREE.Matrix4();
const _m = new THREE.Matrix4();
const _p = new THREE.Vector3();
const _q = new THREE.Quaternion();
const _s = new THREE.Vector3();
export class WorldFuser {
constructor() {
this.anchorMats = new Map(); // markerId -> Matrix4
this.smoothPos = null;
this.smoothQuat = null;
this.posAlpha = 0.35; // smoothing factors (higher = snappier)
this.quatAlpha = 0.35;
this.lastFuseT = 0;
}
setScene(scene) {
this.anchorMats.clear();
for (const a of scene.anchors || []) {
if (a.enabled === false) continue;
this.anchorMats.set(a.markerId, { mat: anchorWorldMatrix(a), sizeMM: a.sizeMM || 60 });
}
}
sizeFor(markerId) { return this.anchorMats.get(markerId)?.sizeMM || 60; }
knownIds() { return new Set(this.anchorMats.keys()); }
/** estimates: [{ markerId, position(mm), quaternion, area }] */
fuse(estimates) {
const MM = 0.001;
const cams = [];
for (const e of estimates) {
const entry = this.anchorMats.get(e.markerId);
if (!entry) continue;
// marker pose in camera space -> matrix (translate mm->m)
_m.compose(_p.copy(e.position).multiplyScalar(MM), e.quaternion, _s.set(1, 1, 1));
_inv.copy(_m).invert(); // camera in marker space
const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv);
const pos = new THREE.Vector3();
const quat = new THREE.Quaternion();
camWorld.decompose(pos, quat, _s);
cams.push({ pos, quat, w: Math.max(1, e.area) });
}
if (!cams.length) return null;
// weighted position mean + incremental weighted slerp for orientation
let wSum = cams[0].w;
const pos = cams[0].pos.clone().multiplyScalar(cams[0].w);
const quat = cams[0].quat.clone();
for (let i = 1; i < cams.length; i++) {
const c = cams[i];
// hemisphere alignment before slerp (quaternion double-cover)
if (quat.dot(c.quat) < 0) c.quat.set(-c.quat.x, -c.quat.y, -c.quat.z, -c.quat.w);
const t = c.w / (wSum + c.w);
quat.slerp(c.quat, t);
pos.add(c.pos.clone().multiplyScalar(c.w));
wSum += c.w;
}
pos.multiplyScalar(1 / wSum);
// temporal smoothing
const now = performance.now();
if (this.smoothPos && now - this.lastFuseT < 500) {
this.smoothPos.lerp(pos, this.posAlpha);
if (this.smoothQuat.dot(quat) < 0) quat.set(-quat.x, -quat.y, -quat.z, -quat.w);
this.smoothQuat.slerp(quat, this.quatAlpha);
} else {
this.smoothPos = pos.clone();
this.smoothQuat = quat.clone();
}
this.lastFuseT = now;
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length };
}
}
+93
View File
@@ -0,0 +1,93 @@
/* pose.js — marker pose estimation with the confirmed fixes:
* 1. POS-IT rotation used AS-IS; translation Y and Z negated (-t[1], -t[2]).
* 2. POS-IT planar ambiguity resolved by temporal consistency:
* pick the solution (bestError vs alternativeError) closest to the previous frame.
*
* Requires vendor chain loaded in order: cv -> svd -> posit1 -> aruco -> dictionary.
*/
import * as THREE from 'three';
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion();
export class PoseEstimator {
constructor(focalLength) {
this.focal = focalLength;
this.posits = new Map(); // sizeMM -> POS.Posit
this.prev = new Map(); // markerId -> { quat, pos, t }
this.prevTTL = 1500; // ms before history is considered stale
}
positFor(sizeMM) {
if (!this.posits.has(sizeMM)) this.posits.set(sizeMM, new POS.Posit(sizeMM, this.focal));
return this.posits.get(sizeMM);
}
/** corners: aruco marker corners, image-space; cx/cy: image center.
* Returns { position: THREE.Vector3 (mm, marker->camera), quaternion, error } */
estimate(markerId, corners, cx, cy, sizeMM) {
const centered = corners.map(c => ({ x: c.x - cx, y: (cy - c.y) }));
const pose = this.positFor(sizeMM).pose(centered);
if (!pose) return null;
const cand = [
this.candidate(pose.bestRotation, pose.bestTranslation, pose.bestError),
this.candidate(pose.alternativeRotation, pose.alternativeTranslation, pose.alternativeError),
];
// Temporal consistency: prefer the solution nearest the previous frame's quat.
const prev = this.prev.get(markerId);
let pick;
if (prev && (performance.now() - prev.t) < this.prevTTL) {
const d0 = Math.abs(cand[0].quaternion.dot(prev.quat));
const d1 = Math.abs(cand[1].quaternion.dot(prev.quat));
// only override error-order if the alternative is clearly more consistent
pick = (d1 > d0 + 0.05) ? cand[1] : (d0 > d1 + 0.05 ? cand[0] : (cand[0].error <= cand[1].error ? cand[0] : cand[1]));
} else {
pick = cand[0].error <= cand[1].error ? cand[0] : cand[1];
}
this.prev.set(markerId, { quat: pick.quaternion.clone(), pos: pick.position.clone(), t: performance.now() });
return pick;
}
candidate(rot, t, error) {
// Rotation as-is (row-major 3x3 -> Matrix4)
_m.set(
rot[0][0], rot[0][1], rot[0][2], 0,
rot[1][0], rot[1][1], rot[1][2], 0,
rot[2][0], rot[2][1], rot[2][2], 0,
0, 0, 0, 1
);
const quaternion = new THREE.Quaternion().setFromRotationMatrix(_m);
// Translation: negate Y and Z only (confirmed fix)
const position = new THREE.Vector3(t[0], -t[1], -t[2]);
return { position, quaternion, error };
}
}
/** Build the marker->world transform for an anchor.
* mount 'flat': marker printed face-up on a horizontal surface.
* mount 'wall': marker on a vertical surface; yawDeg = facing direction.
* mount 'custom': explicit yaw/pitch/roll (deg) applied in YXZ order.
* All mounts additionally honour yaw/pitch/roll offsets for fine trim.
*/
export function anchorWorldMatrix(anchor) {
const pos = new THREE.Vector3(...anchor.position);
const yaw = THREE.MathUtils.degToRad(anchor.yawDeg || 0);
const pitch = THREE.MathUtils.degToRad(anchor.pitchDeg || 0);
const roll = THREE.MathUtils.degToRad(anchor.rollDeg || 0);
// Base orientation by mount:
// flat: marker face-up — marker +Z (out of print face) -> world +Y
// wall: marker vertical — marker +Z faces world +Z when yawDeg = 0
const base = new THREE.Quaternion();
if (anchor.mount !== 'wall' && anchor.mount !== 'custom') {
base.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
}
// Trim (fully editable): yaw about world Y, then pitch/roll fine adjustment
const trim = new THREE.Quaternion().setFromEuler(new THREE.Euler(pitch, yaw, roll, 'YXZ'));
const q = trim.multiply(base);
return new THREE.Matrix4().compose(pos, q, new THREE.Vector3(1, 1, 1));
}
+193
View File
@@ -0,0 +1,193 @@
/* exhibit.js — main viewer.
* Pipeline: camera video -> ArUco detect (maxHamming 0) -> per-marker POS-IT pose
* (rotation as-is, -t[1] -t[2]) -> fuseWorld (confidence-weighted slerp) -> Three.js
* camera placed in world -> server-driven ghosts + building occlusion meshes.
*/
import * as THREE from 'three';
import { createDetector, detectMarkers, quadArea } from './ar/detect.js';
import { PoseEstimator } from './ar/pose.js';
import { WorldFuser } from './ar/fuse.js';
import { buildGhost } from './ghosts/loader.js';
import { ghostTransform } from './ghosts/behavior.js';
import { ExhibitNet, installErrorReporter } from './net.js';
const $ = (s) => document.querySelector(s);
let info = { mode: 'dev' };
let scene3, camera3, renderer, video, canvas2d, ctx2d;
let detector, poseEst, fuser;
let gradients = {}, modelManifest = null;
const activeGhosts = new Map(); // uid -> { rec, group }
let tracking = { markers: 0, lastSeen: 0 };
const clockOffsetSamples = [];
let clockOffset = 0; // serverNow - clientNow
async function boot() {
info = await (await fetch('/api/info')).json();
installErrorReporter(info.mode === 'dev');
const g = await (await fetch('/api/ghosts')).json();
gradients = g.gradients.gradients || g.gradients;
modelManifest = await (await fetch('/api/models')).json();
$('#start').addEventListener('click', start, { once: true });
}
async function start() {
$('#startScreen').classList.add('hidden');
$('#hud').classList.remove('hidden');
// camera
video = $('#cam');
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
});
video.srcObject = stream;
await video.play();
// detection canvas
canvas2d = document.createElement('canvas');
ctx2d = canvas2d.getContext('2d', { willReadFrequently: true });
// three.js
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
scene3 = new THREE.Scene();
camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.02, 50);
scene3.add(new THREE.AmbientLight(0xffffff, 1.2));
onResize(); addEventListener('resize', onResize);
detector = createDetector();
fuser = new WorldFuser();
// network
const net = new ExhibitNet();
net.on('scene', (m) => { fuser.setScene(m.scene); buildOcclusion(m.scene); })
.on('active', async (m) => {
for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
for (const rec of m.ghosts) await addGhost(rec);
})
.on('spawn', (m) => addGhost(m.ghost))
.on('despawn', (m) => scheduleRemove(m.uid))
.on('pong', (m) => {
const rtt = performance.now() - m.t;
clockOffsetSamples.push(m.server + rtt / 2 - Date.now());
if (clockOffsetSamples.length > 5) clockOffsetSamples.shift();
clockOffset = clockOffsetSamples.reduce((a, b) => a + b, 0) / clockOffsetSamples.length;
});
net.connect();
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
requestAnimationFrame(loop);
}
function onResize() {
renderer.setSize(innerWidth, innerHeight);
camera3.aspect = innerWidth / innerHeight;
camera3.updateProjectionMatrix();
}
// ---------- occlusion: invisible depth-only building volumes ----------
const occlusionGroup = new THREE.Group();
function buildOcclusion(scene) {
occlusionGroup.clear();
const mat = new THREE.MeshBasicMaterial({ colorWrite: false }); // depth-only
for (const b of scene.buildings || []) {
const geo = new THREE.BoxGeometry(b.size[0], b.size[1], b.size[2]);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
mesh.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
mesh.renderOrder = -1;
occlusionGroup.add(mesh);
}
if (!occlusionGroup.parent) scene3.add(occlusionGroup);
}
// ---------- ghosts ----------
async function addGhost(rec) {
if (activeGhosts.has(rec.uid)) return;
const group = await buildGhost(rec, gradients, modelManifest);
scene3.add(group);
activeGhosts.set(rec.uid, { rec, group });
toast(`${rec.name} appeared`);
}
function removeGhost(uid) {
const e = activeGhosts.get(uid);
if (!e) return;
scene3.remove(e.group);
activeGhosts.delete(uid);
}
function scheduleRemove(uid) {
const e = activeGhosts.get(uid);
if (!e) return removeGhost(uid);
// let the client-side crossfade (driven by rec.until) finish, then remove
const wait = Math.max(0, e.rec.until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000;
setTimeout(() => removeGhost(uid), Math.min(wait, 8000));
}
// ---------- main loop ----------
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
let frameCount = 0;
function loop(t) {
requestAnimationFrame(loop);
if (video && video.readyState >= 2) {
// downscale for detection speed
const W = 640;
const H = Math.round(W * video.videoHeight / video.videoWidth);
if (canvas2d.width !== W) { canvas2d.width = W; canvas2d.height = H; }
ctx2d.drawImage(video, 0, 0, W, H);
const img = ctx2d.getImageData(0, 0, W, H);
if (!poseEst) {
// focal length in detection-canvas pixels from camera FOV assumption (~60° h-fov)
poseEst = new PoseEstimator(W / (2 * Math.tan(THREE.MathUtils.degToRad(60) / 2)));
}
const markers = detectMarkers(detector, img, fuser.knownIds());
tracking.markers = markers.length;
if (markers.length) {
tracking.lastSeen = performance.now();
const estimates = markers.map(m => {
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
return e && { markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) };
}).filter(Boolean);
const fused = fuser.fuse(estimates);
if (fused) {
camera3.position.copy(fused.position);
camera3.quaternion.copy(fused.quaternion);
}
}
}
// ghosts: deterministic motion on synced clock
const now = Date.now() + clockOffset;
for (const { rec, group } of activeGhosts.values()) {
ghostTransform(rec, now, _tmp);
group.position.copy(_tmp.position);
group.rotation.y = _tmp.rotationY;
group.userData.setOpacity(_tmp.opacity * 0.92);
group.userData.tick(t / 1000);
}
// HUD
if ((frameCount++ & 15) === 0) {
const stale = performance.now() - tracking.lastSeen > 1500;
$('#trk').textContent = stale ? 'Point at a Newbury Crest' : `Tracking ${tracking.markers} crest${tracking.markers === 1 ? '' : 's'}`;
$('#trk').classList.toggle('warn', stale || tracking.markers < 2);
$('#cnt').textContent = `${activeGhosts.size} ghost${activeGhosts.size === 1 ? '' : 's'} nearby`;
}
renderer.render(scene3, camera3);
}
let toastTimer = null;
function toast(msg) {
const el = $('#toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
}
boot().catch(e => { console.error(e); alert('Failed to start: ' + e.message); });
+52
View File
@@ -0,0 +1,52 @@
/* behavior.js — deterministic ghost motion so all viewers see the same movement.
* Motion is a pure function of (server spawn record, wall-clock time): no per-client
* randomness, so phones stay in sync without streaming positions.
*/
import * as THREE from 'three';
function hashNoise(seed, k) {
// cheap deterministic pseudo-noise in [-1, 1]
const x = Math.sin(seed * 127.1 + k * 311.7) * 43758.5453;
return (x - Math.floor(x)) * 2 - 1;
}
export function ghostTransform(rec, nowMs, out) {
const t = (nowMs - rec.spawnedAt) / 1000;
const base = new THREE.Vector3(...rec.pos);
const b = rec.behavior || { type: 'static' };
if (b.type === 'wander') {
const s = b.seed || 1;
const R = b.radius ?? 0.6;
const v = b.speed ?? 0.15;
// smooth pseudo-random orbit-drift: two incommensurate sines per axis
const ph = t * v;
out.position.set(
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7
+ R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3,
base.y + 0.06 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28),
base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7
+ R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3
);
// face travel direction (finite difference)
const eps = 0.05;
const ahead = (t2) => new THREE.Vector3(
base.x + R * 0.9 * Math.sin(t2 * v + hashNoise(s, 1) * 6.28) * 0.7,
0,
base.z + R * 0.9 * Math.cos(t2 * v * 0.8 + hashNoise(s, 4) * 6.28) * 0.7);
const dir = ahead(t + eps).sub(ahead(t));
out.rotationY = Math.atan2(dir.x, dir.z);
} else {
const amp = b.bobAmp ?? 0.06;
const hz = b.bobHz ?? 0.4;
out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z);
out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway
}
// crossfade opacity: fade in on spawn, fade out approaching `until`
const fade = (rec.crossfade || 3) * 1000;
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
const outA = Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
out.opacity = Math.min(inA, outA);
return out;
}
+110
View File
@@ -0,0 +1,110 @@
/* loader.js — ghost visuals.
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
* every part rendered with the recovered Hidden Side gradient shader (top->bottom tint
* by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback
* is used so the exhibit always has something to show.
*
* Model manifest (data/models.json, editable via /api/models):
* { "models": [ { "id": "classic", "scale": 1.0,
* "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] }
*/
import * as THREE from 'three';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
const objLoader = new OBJLoader();
const objCache = new Map();
function gradientMaterial(top, bottom, opacity = 0.92) {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(top) },
bottomColor: { value: new THREE.Color(bottom) },
opacity: { value: opacity },
uMinY: { value: 0 },
uMaxY: { value: 1 },
uTime: { value: 0 },
},
vertexShader: `
varying float vY;
varying vec3 vNormal;
uniform float uMinY, uMaxY;
void main() {
vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0);
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying float vY;
varying vec3 vNormal;
uniform vec3 topColor, bottomColor;
uniform float opacity, uTime;
void main() {
vec3 c = mix(bottomColor, topColor, vY);
float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0);
c += rim * 0.35;
float pulse = 0.92 + 0.08 * sin(uTime * 2.2);
gl_FragColor = vec4(c, opacity * pulse);
}`,
});
}
async function loadOBJ(url) {
if (objCache.has(url)) return objCache.get(url).clone();
const obj = await objLoader.loadAsync(url);
objCache.set(url, obj);
return obj.clone();
}
function proceduralWisp() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.09, 20, 16));
body.scale.set(1, 1.35, 1);
body.position.y = 0.14;
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16));
tail.rotation.x = Math.PI;
tail.position.y = 0.0;
g.add(body, tail);
return g;
}
export async function buildGhost(ghost, gradients, manifest) {
const grad = gradients[ghost.color] || gradients.Blue;
const mat = gradientMaterial(grad.top, grad.bottom);
let group;
const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later
if (model && model.parts) {
group = new THREE.Group();
try {
for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) {
const url = model.parts[key];
if (!url) continue;
group.add(await loadOBJ(url));
}
if (model.scale) group.scale.setScalar(model.scale);
if (!group.children.length) group = proceduralWisp();
} catch (e) {
console.warn('ghost model load failed, using wisp fallback', e);
group = proceduralWisp();
}
} else {
group = proceduralWisp();
}
// apply gradient material to every mesh; compute Y-range for gradient mapping
const box = new THREE.Box3().setFromObject(group);
group.traverse(o => {
if (o.isMesh) {
o.material = mat;
o.material.uniforms.uMinY.value = box.min.y;
o.material.uniforms.uMaxY.value = box.max.y;
}
});
group.userData.material = mat;
group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; };
group.userData.tick = (t) => { mat.uniforms.uTime.value = t; };
return group;
}
+51
View File
@@ -0,0 +1,51 @@
/* net.js — WebSocket sync client + dev-mode error pipeline. */
export class ExhibitNet {
constructor() {
this.handlers = new Map();
this.ws = null;
this.retry = 1000;
}
on(type, fn) { this.handlers.set(type, fn); return this; }
connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
this.ws = new WebSocket(`${proto}://${location.host}/ws`);
this.ws.onopen = () => { this.retry = 1000; };
this.ws.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
const h = this.handlers.get(m.type);
if (h) h(m);
};
this.ws.onclose = () => {
setTimeout(() => this.connect(), this.retry);
this.retry = Math.min(this.retry * 2, 15000);
};
}
}
/* Dev-mode error checking: capture JS errors + unhandled rejections, show an
* on-screen overlay, and report to the server for the admin error log. */
export function installErrorReporter(devMode) {
const report = (payload) => {
fetch('/api/client-error', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ua: navigator.userAgent, page: location.pathname, ...payload }),
}).catch(() => {});
if (devMode) showOverlay(payload);
};
window.addEventListener('error', (e) =>
report({ kind: 'error', msg: String(e.message), src: e.filename, line: e.lineno }));
window.addEventListener('unhandledrejection', (e) =>
report({ kind: 'rejection', msg: String(e.reason && e.reason.message || e.reason) }));
return report;
}
let overlayEl = null;
function showOverlay(p) {
if (!overlayEl) {
overlayEl = document.createElement('div');
overlayEl.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:35vh;overflow:auto;' +
'background:rgba(120,0,0,.88);color:#fff;font:12px monospace;padding:8px;z-index:99999;white-space:pre-wrap';
document.body.appendChild(overlayEl);
}
overlayEl.textContent += `[${p.kind}] ${p.msg}${p.src ? ` (${p.src}:${p.line})` : ''}\n`;
}