wip(mobile): checkpoint the app shell and vision mockup in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-18 05:27:50 +00:00
1 parent a8b7680c7a
commit e532c5efb6
56 files changed
+23645

No files matched your search

+957
View File
@@ -0,0 +1,957 @@
/**
* Capture tab: one dial, two modes.
* Look — the God's-eye view projected around you (aircraft, satellites,
* ships) on the camera, aligned by compass + gyro. Drag to look on
* devices without motion sensors.
* Witness — signed photo/video with IMU, GPS, heading and a hash chain.
*/
import { icon } from './icons.js';
import {
$,
h,
esc,
clamp,
haptic,
toast,
fmtDist,
fmtAlt,
sparkPath,
reducedMotion,
} from './dom.js';
import { createCamera } from '../capture/camera.js';
import {
createSensors,
requestMotionPermission,
poseFromAzEl,
lookAngles,
projectDir,
dirFromAzEl,
} from '../capture/sensors.js';
import { seal, networkTimeOffset } from '../capture/provenance.js';
const KIND_COLOR = {
aircraft: '#7CC4FF',
helicopter: '#7CC4FF',
military: '#7CC4FF',
ship: '#38E1C6',
vessel: '#38E1C6',
satellite: '#C9A7FF',
station: '#FFFFFF',
quake: '#FF6A3D',
fire: '#FF6A3D',
};
const KIND_LABEL = {
aircraft: ['aircraft', 'aircraft'],
military: ['military', 'military'],
helicopter: ['helicopter', 'helicopters'],
ship: ['ship', 'ships'],
vessel: ['ship', 'ships'],
satellite: ['satellite', 'satellites'],
station: ['space station', 'space stations'],
};
const FALLBACK_POS = { lat: 37.7749, lon: -122.4194, alt: 16, simulated: true };
const GATE_KEY = 'gev.mobile.capture.gate';
export function createCapture(view, { globe, onSealed, onVerify }) {
view.innerHTML = `
<div class="cap" data-mode="look">
<video class="cap-video" playsinline muted autoplay></video>
<canvas class="cap-ar" aria-hidden="true"></canvas>
<div class="cap-level" aria-hidden="true"><i></i></div>
<div class="cap-top">
<div class="cap-tape glass-strong"><canvas></canvas><span class="cap-caret"></span></div>
<div class="hud glass-strong" aria-live="off">
<div class="hud-grid">
<span>LAT</span><b class="num" data-h="lat">—</b><span>LON</span><b class="num" data-h="lon">—</b>
<span>±</span><b class="num" data-h="acc">—</b><span>HDG</span><b class="num" data-h="hdg">—</b>
<span>PITCH</span><b class="num" data-h="pitch">—</b><span>ROLL</span><b class="num" data-h="roll">—</b>
</div>
<div class="hud-trace"><canvas></canvas><span>IMU |a|</span></div>
<div class="hud-rec"><span class="rec-dot"></span><span data-h="session">Session idle</span></div>
</div>
</div>
<div class="cap-status glass-strong" hidden></div>
<div class="cap-bottom">
<div class="cap-modes" role="tablist" aria-label="Capture mode">
<button role="tab" type="button" data-mode="look" aria-selected="true">Look</button>
<button role="tab" type="button" data-mode="witness" aria-selected="false">Witness</button>
</div>
<div class="cap-controls">
<button class="cap-thumb" type="button" aria-label="Last capture"><span>${icon('seal')}</span></button>
<button class="shutter" type="button" aria-label="Seal a photo. Hold for video"><span class="shutter-ring"></span><span class="shutter-core"></span></button>
<button class="cap-side icon-btn glass-strong" type="button" data-act="recenter" aria-label="Sensors">${icon('scope')}</button>
</div>
<div class="cap-legend"></div>
</div>
<div class="cap-gate" hidden>
<div class="gate-card glass-strong">
<div class="gate-ic">${icon('viewfinder')}</div>
<h2>See the sky. Seal what you see.</h2>
<p>Capture uses three sensors, only while this tab is open.</p>
<ul>
<li>${icon('camera')}<div><b>Camera</b><span>Look overlays aircraft, satellites and ships on the live view. Witness records the photo or clip you seal.</span></div></li>
<li>${icon('north')}<div><b>Motion &amp; compass</b><span>Aligns the overlay with where your phone points, and becomes part of the signed motion trace.</span></div></li>
<li>${icon('location')}<div><b>Location</b><span>Places contacts around you, and stamps each capture with where you stood.</span></div></li>
</ul>
<p class="gate-fine">${icon('lock')} Captures stay on this device. Each one is signed with a key generated here that cannot be exported.</p>
<button class="pill-btn primary gate-go" type="button">Continue</button>
<button class="pill-btn plain gate-skip" type="button">Not now — use a simulated sky</button>
</div>
</div>
<div class="cap-seal" hidden></div>
</div>`;
const root = $('.cap', view);
const video = $('.cap-video', view);
const ar = $('.cap-ar', view);
const actx = ar.getContext('2d');
const tape = $('.cap-tape canvas', view);
const tctx = tape.getContext('2d');
const traceC = $('.hud-trace canvas', view);
const trctx = traceC.getContext('2d');
const gate = $('.cap-gate', view);
const sealEl = $('.cap-seal', view);
const status = $('.cap-status', view);
const shutter = $('.shutter', view);
const level = $('.cap-level i', view);
const camera = createCamera(video);
const sensors = createSensors();
let mode = 'look';
let active = false;
let started = false;
let camOk = false;
let raf = 0;
let sim = { az: 250, el: 14 };
let contacts = [];
let contactsAt = 0;
let W = 0;
let H = 0;
let dpr = 1;
let lastCapture = null;
let netOffset = null;
const VFOV = 62;
/* ---------- lifecycle ---------- */
function resize() {
const r = root.getBoundingClientRect();
dpr = Math.min(2, devicePixelRatio || 1);
W = r.width;
H = r.height;
ar.width = Math.round(W * dpr);
ar.height = Math.round(H * dpr);
const tr = tape.getBoundingClientRect();
tape.width = Math.round(tr.width * dpr);
tape.height = Math.round(tr.height * dpr);
const hr = traceC.getBoundingClientRect();
traceC.width = Math.round(hr.width * dpr);
traceC.height = Math.round(hr.height * dpr);
}
new ResizeObserver(resize).observe(root);
async function begin({ withSensors }) {
gate.hidden = true;
try {
sessionStorage.setItem(GATE_KEY, '1');
} catch {
/* ignore */
}
started = true;
if (withSensors) {
const perm = await requestMotionPermission();
if (perm === 'denied')
showStatus('Motion access denied — drag to look around', 4000);
sensors.start();
try {
await camera.start();
camOk = true;
root.classList.add('has-camera');
} catch (err) {
camOk = false;
root.classList.remove('has-camera');
showStatus(
err?.name === 'NotAllowedError'
? 'Camera access was declined — showing a simulated sky'
: 'No camera here — showing a simulated sky',
4500,
);
}
}
networkTimeOffset().then((o) => (netOffset = o));
if (mode === 'witness') sensors.beginSession();
loop();
}
function enter() {
active = true;
resize();
let seen = false;
try {
seen = sessionStorage.getItem(GATE_KEY) === '1';
} catch {
/* ignore */
}
if (!started && !seen) gate.hidden = false;
else if (!started) begin({ withSensors: true });
else {
if (camOk) camera.start().catch(() => {});
sensors.start();
if (mode === 'witness' && !sensors.recording) sensors.beginSession();
loop();
}
}
function leave() {
active = false;
cancelAnimationFrame(raf);
raf = 0;
camera.stop();
sensors.endSession();
sensors.stop();
}
$('.gate-go', view).addEventListener('click', () =>
begin({ withSensors: true }),
);
$('.gate-skip', view).addEventListener('click', () =>
begin({ withSensors: false }),
);
function showStatus(text, ms = 2500) {
status.textContent = text;
status.hidden = false;
clearTimeout(showStatus.t);
showStatus.t = setTimeout(() => (status.hidden = true), ms);
}
/* ---------- mode dial ---------- */
function setMode(m) {
if (m === mode) return;
mode = m;
root.dataset.mode = m;
view
.querySelectorAll('.cap-modes [data-mode]')
.forEach((b) =>
b.setAttribute('aria-selected', String(b.dataset.mode === m)),
);
haptic('tick');
if (m === 'witness' && started) sensors.beginSession();
if (m === 'look') sensors.endSession();
}
$('.cap-modes', view).addEventListener('click', (e) => {
const b = e.target.closest('[data-mode]');
if (b) setMode(b.dataset.mode);
});
// Swipe the viewfinder sideways to change mode, like Camera.
let swipe = null;
root.addEventListener(
'touchstart',
(e) =>
(swipe = e.target.closest('.cap-bottom, .cap-seal, .cap-gate')
? null
: { x: e.touches[0].clientX, y: e.touches[0].clientY }),
{ passive: true },
);
root.addEventListener(
'touchend',
(e) => {
if (!swipe || mode === 'look') return (swipe = null);
const t = e.changedTouches[0];
const dx = t.clientX - swipe.x;
if (Math.abs(dx) > 70 && Math.abs(t.clientY - swipe.y) < 50)
setMode(dx > 0 ? 'look' : 'witness');
swipe = null;
},
{ passive: true },
);
/* ---------- drag to look (no sensors) ---------- */
let look = null;
ar.addEventListener('pointerdown', (e) => {
if (sensors.state.hasOrientation) return;
look = { x: e.clientX, y: e.clientY, az: sim.az, el: sim.el };
ar.setPointerCapture(e.pointerId);
});
ar.addEventListener('pointermove', (e) => {
if (!look) return;
const degPerPx = VFOV / H;
sim.az = (look.az - (e.clientX - look.x) * degPerPx + 360) % 360;
sim.el = clamp(look.el + (e.clientY - look.y) * degPerPx, -30, 85);
});
ar.addEventListener('pointerup', () => (look = null));
ar.addEventListener('pointercancel', () => (look = null));
ar.style.touchAction = 'none';
function observer() {
const f = sensors.state.fix;
return f
? { lat: f.lat, lon: f.lon, alt: f.alt || 0, acc: f.acc }
: FALLBACK_POS;
}
function pose() {
return sensors.pose() || poseFromAzEl(sim.az, sim.el);
}
/* ---------- render loop ---------- */
function loop() {
if (!active || raf) return;
const frame = () => {
raf = 0;
if (!active) return;
const p = pose();
if (mode === 'look') drawLook(p);
else drawWitness(p);
drawTape(p);
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
}
const stars = (() => {
let s = 11;
const r = () => (s = (s * 16807) % 2147483647) / 2147483647;
return Array.from({ length: 260 }, () => ({
az: r() * 360,
el: Math.asin(r()) * 57.3,
m: r(),
}));
})();
function drawSky(p) {
// Horizon position at the screen centre column.
const hz = projectDir(dirFromAzEl(p.az, 0), p, W, H, VFOV);
const hy = hz.behind ? (p.el > 0 ? H * 2 : -H) : hz.y;
const g = actx.createLinearGradient(0, hy - H, 0, hy);
g.addColorStop(0, '#03060f');
g.addColorStop(0.55, '#0a1630');
g.addColorStop(0.9, '#1b3557');
g.addColorStop(1, '#3b5f80');
actx.fillStyle = g;
actx.fillRect(0, 0, W, H);
actx.fillStyle = '#05080d';
actx.save();
// Ground polygon below the horizon line, honouring roll.
const l = projectDir(dirFromAzEl(p.az - 80, 0), p, W, H, VFOV);
const r = projectDir(dirFromAzEl(p.az + 80, 0), p, W, H, VFOV);
if (!l.behind && !r.behind) {
const k = (r.y - l.y) / (r.x - l.x || 1);
const yAt = (x) => l.y + k * (x - l.x);
actx.beginPath();
actx.moveTo(-10, yAt(-10));
actx.lineTo(W + 10, yAt(W + 10));
actx.lineTo(W + 10, H + 10);
actx.lineTo(-10, H + 10);
actx.closePath();
const gg = actx.createLinearGradient(0, hy, 0, hy + H * 0.6);
gg.addColorStop(0, '#1a2633');
gg.addColorStop(1, '#04060a');
actx.fillStyle = gg;
actx.fill();
} else if (p.el < 0) actx.fillRect(0, 0, W, H);
actx.restore();
for (const s of stars) {
if (s.el < 2) continue;
const q = projectDir(dirFromAzEl(s.az, s.el), p, W, H, VFOV);
if (q.behind || q.x < 0 || q.y < 0 || q.x > W || q.y > H) continue;
actx.fillStyle = `rgba(255,255,255,${0.25 + s.m * 0.6})`;
actx.fillRect(q.x, q.y, s.m > 0.9 ? 1.8 : 1.1, s.m > 0.9 ? 1.8 : 1.1);
}
}
function drawHorizon(p) {
actx.lineWidth = 1;
for (const el of [-20, -10, 0, 10, 20, 30, 45, 60]) {
const pts = [];
for (let d = -70; d <= 70; d += 5) {
const q = projectDir(dirFromAzEl(p.az + d, el), p, W, H, VFOV);
if (!q.behind) pts.push(q);
}
if (pts.length < 2) continue;
actx.strokeStyle =
el === 0 ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.12)';
actx.setLineDash(el === 0 ? [] : [3, 6]);
actx.beginPath();
pts.forEach((q, i) =>
i ? actx.lineTo(q.x, q.y) : actx.moveTo(q.x, q.y),
);
actx.stroke();
actx.setLineDash([]);
if (el && pts.length) {
const mid = pts[Math.floor(pts.length / 2)];
actx.fillStyle = 'rgba(255,255,255,0.35)';
actx.font = '600 10px ui-rounded, system-ui';
actx.fillText(`${el > 0 ? '+' : ''}${el}°`, mid.x + 6, mid.y - 3);
}
}
// Cardinal labels on the horizon.
const card = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
actx.font = '700 13px ui-rounded, system-ui';
actx.textAlign = 'center';
for (const [n, az] of card) {
const q = projectDir(dirFromAzEl(az, 0), p, W, H, VFOV);
if (q.behind || q.x < -20 || q.x > W + 20) continue;
actx.fillStyle = n === 'N' ? '#ff6b61' : 'rgba(255,255,255,0.8)';
actx.fillText(n, q.x, q.y + 18);
}
actx.textAlign = 'left';
}
function refreshContacts() {
const now = performance.now();
if (now - contactsAt < 1000) return;
contactsAt = now;
const o = observer();
contacts = globe.contactsNear(o.lon, o.lat, 150);
}
function rrect(x, y, w, hh, r) {
actx.beginPath();
actx.moveTo(x + r, y);
actx.arcTo(x + w, y, x + w, y + hh, r);
actx.arcTo(x + w, y + hh, x, y + hh, r);
actx.arcTo(x, y + hh, x, y, r);
actx.arcTo(x, y, x + w, y, r);
actx.closePath();
}
function drawLook(p) {
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.clearRect(0, 0, W, H);
if (!camOk) drawSky(p);
drawHorizon(p);
refreshContacts();
const o = observer();
const items = [];
const offscreen = [];
for (const c of contacts) {
const la = lookAngles(o, c);
if (la.el < -2) continue;
const q = projectDir(la.enu, p, W, H, VFOV);
const vis = !q.behind && q.x > 0 && q.x < W && q.y > 60 && q.y < H - 40;
(vis ? items : offscreen).push({ c, la, q });
}
items.sort((a, b) => a.la.range - b.la.range);
const placed = [];
let shown = 0;
for (const it of items.slice(0, 24)) {
const { c, la, q } = it;
const col = KIND_COLOR[c.kind] || '#fff';
const near = la.range < 40e3;
// Marker
actx.strokeStyle = col;
actx.lineWidth = 1.6;
actx.beginPath();
if (c.kind === 'satellite' || c.kind === 'station') {
actx.moveTo(q.x, q.y - 7);
actx.lineTo(q.x + 7, q.y);
actx.lineTo(q.x, q.y + 7);
actx.lineTo(q.x - 7, q.y);
actx.closePath();
} else if (c.kind === 'ship' || c.kind === 'vessel') {
actx.rect(q.x - 6, q.y - 6, 12, 12);
} else actx.arc(q.x, q.y, near ? 9 : 6.5, 0, Math.PI * 2);
actx.stroke();
actx.fillStyle = col;
actx.beginPath();
actx.arc(q.x, q.y, 1.8, 0, Math.PI * 2);
actx.fill();
if (shown >= 12) continue;
// Callout with simple de-cluttering.
const title = c.title || c.id;
const sub =
c.kind === 'satellite' || c.kind === 'station'
? `${Math.round(c.altM / 1000)} km · ${fmtDist(la.range)}`
: `${c.altM > 50 ? fmtAlt(c.altM) + ' · ' : ''}${fmtDist(la.range)}`;
actx.font = '700 12px ui-monospace, SFMono-Regular, Menlo, monospace';
const tw = Math.max(
actx.measureText(title).width,
((actx.font = '500 10.5px ui-rounded, system-ui'),
actx.measureText(sub).width),
);
const bw = tw + 18;
const bh = 36;
let bx = q.x + 16;
let by = q.y - bh - 12;
if (bx + bw > W - 8) bx = q.x - 16 - bw;
for (
let k = 0;
k < 6 &&
placed.some(
(r) =>
!(
bx + bw < r.x ||
bx > r.x + r.w ||
by + bh < r.y ||
by > r.y + r.h
),
);
k += 1
)
by -= bh + 4;
if (by < 64) continue;
placed.push({ x: bx, y: by, w: bw, h: bh });
shown += 1;
actx.strokeStyle = 'rgba(255,255,255,0.45)';
actx.lineWidth = 1;
actx.beginPath();
actx.moveTo(q.x + (bx > q.x ? 6 : -6), q.y - 6);
actx.lineTo(bx > q.x ? bx : bx + bw, by + bh);
actx.stroke();
rrect(bx, by, bw, bh, 10);
actx.fillStyle = 'rgba(8,11,17,0.72)';
actx.fill();
actx.strokeStyle = 'rgba(255,255,255,0.14)';
actx.stroke();
actx.fillStyle = col;
actx.fillRect(bx, by + 8, 2.5, bh - 16);
actx.fillStyle = '#fff';
actx.font = '700 12px ui-monospace, SFMono-Regular, Menlo, monospace';
actx.fillText(title, bx + 10, by + 15);
actx.fillStyle = 'rgba(235,241,250,0.65)';
actx.font = '500 10.5px ui-rounded, system-ui';
actx.fillText(sub, bx + 10, by + 29);
}
// Edge arrows for the nearest contacts outside the view.
offscreen.sort((a, b) => a.la.range - b.la.range);
for (const it of offscreen.slice(0, 5)) {
const d = ((it.la.az - p.az + 540) % 360) - 180;
const x = d < 0 ? 18 : W - 18;
const y = clamp(H / 2 - (it.la.el - p.el) * (H / VFOV), 90, H - 200);
actx.fillStyle = KIND_COLOR[it.c.kind] || '#fff';
actx.globalAlpha = 0.85;
actx.beginPath();
actx.moveTo(x + (d < 0 ? -7 : 7), y);
actx.lineTo(x + (d < 0 ? 5 : -5), y - 7);
actx.lineTo(x + (d < 0 ? 5 : -5), y + 7);
actx.closePath();
actx.fill();
actx.globalAlpha = 1;
}
const counts = {};
for (const c of contacts) counts[c.kind] = (counts[c.kind] || 0) + 1;
const legend = Object.entries(counts)
.map(
([k, n]) =>
`<span style="--c:${KIND_COLOR[k] || '#fff'}"><i></i>${n} ${(KIND_LABEL[k] || [k, `${k}s`])[n > 1 ? 1 : 0]}</span>`,
)
.join('');
const lg = $('.cap-legend', view);
const key = `${legend}|${o.simulated}|${sensors.state.hasOrientation}`;
if (lg.dataset.key !== key) {
lg.dataset.key = key;
lg.innerHTML = `${legend}<em>${o.simulated ? 'simulated position · ' : ''}${sensors.state.hasOrientation ? 'compass-aligned' : 'drag to look'}</em>`;
}
}
let hudT = 0;
function drawWitness(p) {
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.clearRect(0, 0, W, H);
if (!camOk) {
drawSky(p);
actx.fillStyle = 'rgba(255,255,255,0.5)';
actx.font = '600 12px ui-rounded, system-ui';
actx.textAlign = 'center';
actx.fillText('NO CAMERA · TEST FRAME', W / 2, H / 2 + 60);
actx.textAlign = 'left';
}
// Thirds grid
actx.strokeStyle = 'rgba(255,255,255,0.14)';
actx.lineWidth = 0.75;
actx.beginPath();
for (const k of [1 / 3, 2 / 3]) {
actx.moveTo(W * k, 0);
actx.lineTo(W * k, H);
actx.moveTo(0, H * k);
actx.lineTo(W, H * k);
}
actx.stroke();
level.style.transform = `rotate(${(-p.roll).toFixed(1)}deg)`;
level.parentElement.classList.toggle('level', Math.abs(p.roll) < 1.5);
const now = performance.now();
if (now - hudT > 100) {
hudT = now;
updateHud(p);
}
}
function updateHud(p) {
const f = sensors.state.fix;
const set = (k, v) => {
const el = view.querySelector(`[data-h="${k}"]`);
if (el && el.textContent !== v) el.textContent = v;
};
set('lat', f ? f.lat.toFixed(5) : '—');
set('lon', f ? f.lon.toFixed(5) : '—');
set(
'acc',
f ? `${Math.round(f.acc)} m` : sensors.state.geoError ? 'denied' : '…',
);
set('hdg', `${Math.round(p.az)}°`);
set('pitch', `${p.el >= 0 ? '+' : ''}${p.el.toFixed(0)}°`);
set('roll', `${p.roll >= 0 ? '+' : ''}${p.roll.toFixed(0)}°`);
const s = sensors.recording;
set(
'session',
s
? `Recording session · ${sensors.state.hasMotion ? 'IMU live' : 'no IMU'} · ${f ? 'GPS fix' : 'no GPS'}`
: 'Session idle',
);
$('.hud-rec', view).classList.toggle('on', s);
// IMU trace (sized lazily: the HUD is hidden in Look).
const hr = traceC.getBoundingClientRect();
if (Math.round(hr.width * dpr) !== traceC.width) {
traceC.width = Math.round(hr.width * dpr);
traceC.height = Math.round(hr.height * dpr);
}
const tw = traceC.width / dpr;
const th = traceC.height / dpr;
trctx.setTransform(dpr, 0, 0, dpr, 0, 0);
trctx.clearRect(0, 0, tw, th);
const tr = sensors.trace;
if (tr.length > 2) {
const vals = tr.map((x) => x.v);
const d = sparkPath(vals, tw, th, 2);
trctx.strokeStyle = '#FFD166';
trctx.lineWidth = 1.4;
trctx.stroke(new Path2D(d));
} else {
trctx.strokeStyle = 'rgba(255,255,255,0.25)';
trctx.setLineDash([2, 3]);
trctx.beginPath();
trctx.moveTo(0, th / 2);
trctx.lineTo(tw, th / 2);
trctx.stroke();
trctx.setLineDash([]);
}
}
function drawTape(p) {
const tr = tape.getBoundingClientRect();
if (
Math.round(tr.width * dpr) !== tape.width ||
Math.round(tr.height * dpr) !== tape.height
) {
tape.width = Math.round(tr.width * dpr);
tape.height = Math.round(tr.height * dpr);
}
const w = tape.width / dpr;
const hh = tape.height / dpr;
if (!w) return;
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
tctx.clearRect(0, 0, w, hh);
const pxPerDeg = w / 90;
tctx.textAlign = 'center';
for (let d = Math.floor(p.az - 50); d <= p.az + 50; d += 1) {
const deg = ((d % 360) + 360) % 360;
if (deg % 5) continue;
const x = w / 2 + (d - p.az) * pxPerDeg;
const major = deg % 15 === 0;
tctx.strokeStyle = major
? 'rgba(255,255,255,0.7)'
: 'rgba(255,255,255,0.28)';
tctx.beginPath();
tctx.moveTo(x, hh - 4);
tctx.lineTo(x, hh - (major ? 12 : 7));
tctx.stroke();
if (deg % 45 === 0) {
const n = {
0: 'N',
45: 'NE',
90: 'E',
135: 'SE',
180: 'S',
225: 'SW',
270: 'W',
315: 'NW',
}[deg];
tctx.font = '700 11px ui-rounded, system-ui';
tctx.fillStyle = deg === 0 ? '#ff6b61' : '#fff';
tctx.fillText(n, x, hh - 16);
} else if (deg % 15 === 0) {
tctx.font = '500 9px ui-rounded, system-ui';
tctx.fillStyle = 'rgba(255,255,255,0.5)';
tctx.fillText(String(deg), x, hh - 16);
}
}
const fade = (x0, x1) => {
const g = tctx.createLinearGradient(x0, 0, x1, 0);
g.addColorStop(0, 'rgba(0,0,0,1)');
g.addColorStop(1, 'rgba(0,0,0,0)');
tctx.globalCompositeOperation = 'destination-out';
tctx.fillStyle = g;
tctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), hh);
tctx.globalCompositeOperation = 'source-over';
};
fade(0, 30);
fade(w, w - 30);
$('.cap-caret', view).dataset.deg = `${Math.round(p.az)}°`;
}
/* ---------- shutter ---------- */
let hold = null;
let recording = null;
shutter.addEventListener('pointerdown', (e) => {
if (mode === 'look') return;
e.preventDefault();
hold = setTimeout(startVideo, 380);
});
const release = async () => {
if (mode === 'look') return;
if (hold) {
clearTimeout(hold);
hold = null;
if (!recording) await takePhoto();
}
if (recording) await stopVideo();
};
shutter.addEventListener('pointerup', release);
shutter.addEventListener('pointerleave', () => recording && release());
shutter.addEventListener('click', (e) => {
if (mode === 'look') {
setMode('witness');
return;
}
if (e.detail === 0) takePhoto();
});
async function startVideo() {
hold = null;
if (!camOk || !camera.canRecord()) {
showStatus('Video needs a camera — sealing a still instead');
return takePhoto();
}
try {
camera.startVideo();
} catch (err) {
showStatus(err.message);
return;
}
recording = {
t0: performance.now(),
timer: setTimeout(() => stopVideo(), 15000),
};
root.classList.add('recording');
haptic('medium');
}
async function stopVideo() {
const r = recording;
recording = null;
clearTimeout(r?.timer);
root.classList.remove('recording');
const blob = await camera.stopVideo();
if (blob?.size) await sealMedia(blob, 'video');
}
async function takePhoto() {
flash();
haptic('light');
let blob;
let test = false;
try {
blob = await camera.photo();
} catch {
test = true;
blob = await new Promise((resolve) => {
const c = document.createElement('canvas');
c.width = Math.round(W * dpr);
c.height = Math.round(H * dpr);
const x = c.getContext('2d');
x.drawImage(ar, 0, 0);
x.fillStyle = 'rgba(255,209,102,0.9)';
x.font = `${Math.round(16 * dpr)}px ui-monospace, monospace`;
x.fillText(
`TEST FRAME ${new Date().toISOString()}`,
16 * dpr,
c.height - 24 * dpr,
);
c.toBlob(resolve, 'image/jpeg', 0.9);
});
}
await sealMedia(blob, 'photo', test);
}
function flash() {
const f = h('<div class="cap-flash"></div>');
root.appendChild(f);
f.animate([{ opacity: 0.85 }, { opacity: 0 }], {
duration: reducedMotion() ? 80 : 320,
easing: 'ease-out',
}).finished.then(() => f.remove());
}
async function sealMedia(blob, kind, test = false) {
const session = sensors.endSession() || {
start: new Date().toISOString(),
end: new Date().toISOString(),
imu: [],
gps: [],
heading: null,
};
if (!session.heading) {
const p = pose();
session.heading = {
deg: Math.round(p.az * 10) / 10,
pitch: Math.round(p.el * 10) / 10,
roll: 0,
source: 'simulated',
};
}
session.mode = kind;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const context = {
app: 'gods-eye-mobile-prototype/0.1',
device:
navigator.userAgentData?.platform || navigator.platform || 'unknown',
mobile:
navigator.userAgentData?.mobile ?? /Mobi/i.test(navigator.userAgent),
ua: navigator.userAgent,
screen: {
w: screen.width,
h: screen.height,
dpr: devicePixelRatio,
orientation: screen.orientation?.type || null,
},
timezone: tz,
tzOffsetMin: new Date().getTimezoneOffset(),
networkTimeOffsetMs: netOffset,
camera: camera.info(),
sensors: {
motion: sensors.state.hasMotion,
orientation: sensors.state.hasOrientation,
absoluteHeading:
sensors.state.absolute || sensors.state.compass != null,
},
testFrame: test || undefined,
};
const url = URL.createObjectURL(blob);
showSealing(url, kind, test);
try {
const bundle = await seal({ media: blob, session, context });
lastCapture = { bundle, url };
setThumb(url, kind);
showSealed(bundle, url, kind, test);
onSealed?.(bundle);
} catch (err) {
console.error(err);
sealEl.hidden = true;
toast(`Could not seal: ${err.message}`);
}
if (active && mode === 'witness') sensors.beginSession();
}
function setThumb(url, kind) {
const t = $('.cap-thumb', view);
t.innerHTML =
kind === 'video'
? `<video src="${url}" muted playsinline></video>`
: `<img src="${url}" alt="Last sealed capture"/>`;
t.classList.add('has');
}
/* ---------- seal ceremony ---------- */
const SEAL_D = (() => {
let d = '';
for (let i = 0; i <= 96; i += 1) {
const a = (i / 96) * Math.PI * 2;
const r = 40 + 4.2 * Math.cos(a * 12);
d += `${i ? 'L' : 'M'}${(50 + r * Math.cos(a)).toFixed(2)} ${(50 + r * Math.sin(a)).toFixed(2)}`;
}
return `${d}Z`;
})();
function showSealing(url, kind, test) {
sealEl.hidden = false;
sealEl.innerHTML = `<div class="seal-card glass-strong">
<div class="seal-media">${kind === 'video' ? `<video src="${url}" muted playsinline autoplay loop></video>` : `<img src="${url}" alt="Captured frame"/>`}${test ? '<span class="badge-example">Test frame</span>' : ''}</div>
<div class="seal-glyph"><svg viewBox="0 0 100 100" aria-hidden="true"><path class="seal-ring" d="${SEAL_D}"/><path class="seal-check" d="M33 51 l11 11 l23 -24"/></svg></div>
<div class="seal-state">Sealing…</div>
<div class="seal-hash mono" aria-label="Bundle hash"></div>
</div>`;
scramble($('.seal-hash', sealEl), null);
}
function scramble(el, finalText) {
const hex = '0123456789abcdef';
const len = 64;
let n = 0;
clearInterval(scramble.t);
scramble.t = setInterval(
() => {
n += 1;
let s = '';
for (let i = 0; i < len; i += 1) {
const settled = finalText && i < (n - 6) * 4;
s += settled ? finalText[i] : hex[(Math.random() * 16) | 0];
}
el.innerHTML = s
.match(/.{16}/g)
.map((g) => `<span>${g}</span>`)
.join('');
if (finalText && (n - 6) * 4 >= len) clearInterval(scramble.t);
},
reducedMotion() ? 1 : 28,
);
}
function showSealed(bundle, url, kind, test) {
const card = $('.seal-card', sealEl);
card.classList.add('sealed');
haptic('seal');
$('.seal-state', sealEl).innerHTML =
`<b>Sealed</b> · capture #${bundle.seq}`;
scramble($('.seal-hash', sealEl), bundle.hash);
const imu = bundle.session.imu;
const mags = imu.map((s) => Math.hypot(s.ax ?? 0, s.ay ?? 0, s.az ?? 0));
const dur = imu.length > 1 ? (imu[imu.length - 1].t - imu[0].t) / 1000 : 0;
const gps = bundle.session.gps;
const f = gps[gps.length - 1];
card.insertAdjacentHTML(
'beforeend',
`<div class="seal-spark">${mags.length > 2 ? `<svg viewBox="0 0 240 34" preserveAspectRatio="none"><path d="${sparkPath(mags, 240, 34, 2)}"/></svg>` : '<div class="seal-nospark">No motion sensors on this device</div>'}<span>IMU ${imu.length} samples${dur ? ` · ${dur.toFixed(1)} s` : ''}</span></div>
<dl class="seal-facts">
<dt>Signed</dt><dd>ECDSA P-256 · key <span class="mono">${esc(bundle.keyId)}</span></dd>
<dt>Chain</dt><dd>#${bundle.seq} ← ${bundle.prev ? `<span class="mono">${esc(bundle.prev.slice(0, 12))}…</span>` : 'genesis'}</dd>
<dt>Where</dt><dd>${f ? `<span class="mono">${f.lat.toFixed(5)}, ${f.lon.toFixed(5)}</span> ±${Math.round(f.acc)} m` : 'no GPS fix'}</dd>
<dt>Heading</dt><dd>${bundle.session.heading ? `${Math.round(bundle.session.heading.deg)}° · ${esc(bundle.session.heading.source)}` : '—'}</dd>
<dt>Media</dt><dd>${esc(bundle.media.type)} · ${(bundle.media.bytes / 1024).toFixed(0)} KB</dd>
</dl>
<div class="seal-actions"><button class="pill-btn seal" type="button" data-seal="verify">${icon('seal')}Verify</button><button class="pill-btn plain" type="button" data-seal="done">Done</button></div>`,
);
}
sealEl.addEventListener('click', (e) => {
const a = e.target.closest('[data-seal]')?.dataset.seal;
if (a === 'done') sealEl.hidden = true;
if (a === 'verify' && lastCapture) onVerify?.(lastCapture.bundle);
});
$('.cap-thumb', view).addEventListener('click', () => {
if (lastCapture) onVerify?.(lastCapture.bundle);
else toast('No captures yet — switch to Witness and tap the shutter');
});
view.querySelector('[data-act="recenter"]').addEventListener('click', () => {
const s = sensors.state;
showStatus(
`${camOk ? 'Camera on' : 'No camera'} · ${s.hasOrientation ? (s.compass != null ? 'compass' : s.absolute ? 'absolute orientation' : 'relative orientation') : 'no motion sensors'} · ${s.fix ? `GPS ±${Math.round(s.fix.acc)} m` : s.geoError ? 'location denied' : 'locating…'}`,
3500,
);
});
return {
enter,
leave,
setMode,
get mode() {
return mode;
},
sensors,
};
}
+308
View File
@@ -0,0 +1,308 @@
/** Small DOM, formatting, motion and haptic helpers shared by the shell. */
export const $ = (sel, root = document) => root.querySelector(sel);
export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const ESC = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/** Escape untrusted text for HTML interpolation. */
export const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
/** Build one element from an HTML string. */
export function h(html) {
const t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
export const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
export const lerp = (a, b, t) => a + (b - a) * t;
/** "2 min ago", "just now", "3 h ago". */
export function ago(ts, now = Date.now()) {
if (!ts) return '';
const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
const s = Math.max(0, Math.round((now - t) / 1000));
if (s < 45) return 'just now';
const m = Math.round(s / 60);
if (m < 60) return `${m} min ago`;
const hrs = Math.round(m / 60);
if (hrs < 48) return `${hrs} h ago`;
return `${Math.round(hrs / 24)} d ago`;
}
export function fmtNum(v, digits = 0) {
if (v == null || v === '') return '—';
if (typeof v !== 'number') return String(v);
if (Math.abs(v) >= 10000)
return new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
}).format(v);
return v.toLocaleString(undefined, {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
export function fmtCoord(lat, lon, d = 4) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return '—';
const ns = lat >= 0 ? 'N' : 'S';
const ew = lon >= 0 ? 'E' : 'W';
return `${Math.abs(lat).toFixed(d)}° ${ns} ${Math.abs(lon).toFixed(d)}° ${ew}`;
}
export function fmtDist(m) {
if (!Number.isFinite(m)) return '—';
if (m < 1000) return `${Math.round(m)} m`;
if (m < 100000) return `${(m / 1000).toFixed(1)} km`;
return `${Math.round(m / 1000).toLocaleString()} km`;
}
export function fmtAlt(m) {
if (!Number.isFinite(m)) return '—';
if (m > 100000) return `${Math.round(m / 1000).toLocaleString()} km`;
const ft = m * 3.28084;
return `${Math.round(ft / 100) * 100 >= 1000 ? (Math.round(ft / 100) * 100).toLocaleString() : Math.round(ft)} ft`;
}
/* ---------- Settings (per-viewer conveniences only) ---------- */
const SETTINGS_KEY = 'gev.mobile.settings.v1';
const defaults = {
quality: 'auto',
reduceMotion: 'system',
alertRadiusKm: 250,
haptics: true,
followed: [],
lens: 'cinematic',
};
let settings = { ...defaults };
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (raw) settings = { ...defaults, ...JSON.parse(raw) };
} catch {
/* storage unavailable: defaults */
}
const subs = new Set();
export const store = {
get: (k) => settings[k],
set(k, v) {
settings[k] = v;
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {
/* ignore */
}
subs.forEach((fn) => fn(k, v));
},
on(fn) {
subs.add(fn);
return () => subs.delete(fn);
},
isFollowed: (id) => settings.followed.includes(id),
toggleFollow(id) {
const list = settings.followed.includes(id)
? settings.followed.filter((x) => x !== id)
: [id, ...settings.followed];
store.set('followed', list);
return list.includes(id);
},
};
/* ---------- Motion ---------- */
const rmQuery = matchMedia('(prefers-reduced-motion: reduce)');
export function reducedMotion() {
const pref = settings.reduceMotion;
if (pref === 'on') return true;
if (pref === 'off') return false;
return rmQuery.matches;
}
export function applyMotionPref() {
const pref = settings.reduceMotion;
if (pref === 'system') delete document.documentElement.dataset.reduceMotion;
else document.documentElement.dataset.reduceMotion = pref;
}
/** Spring easing string for WAAPI (falls back to cubic-bezier). */
export const SPRING = (() => {
try {
if (CSS.supports('transition-timing-function', 'linear(0, 1)'))
return 'linear(0, 0.009, 0.035 2.1%, 0.141 4.4%, 0.723 12.9%, 0.938 16.7%, 1.017 19.8%, 1.067, 1.089 25.9%, 1.085 29.1%, 1.05 35.4%, 1.016 42.4%, 0.999 51.8%, 0.996 58.3%, 1)';
} catch {
/* ignore */
}
return 'cubic-bezier(0.32, 1.25, 0.4, 1)';
})();
export const SPRING_SOFT = (() => {
try {
if (CSS.supports('transition-timing-function', 'linear(0, 1)'))
return 'linear(0, 0.06 3%, 0.25 7.5%, 0.6 15%, 0.84 22%, 0.96 30%, 1.012 38%, 1.02 46%, 1.006 60%, 1)';
} catch {
/* ignore */
}
return 'cubic-bezier(0.25, 1.1, 0.4, 1)';
})();
export const EASE_OUT = 'cubic-bezier(0.22, 1, 0.36, 1)';
export const nextFrame = () =>
new Promise((r) => requestAnimationFrame(() => r()));
/* ---------- Haptics ---------- */
const PATTERNS = {
tick: 5,
light: 8,
medium: 14,
success: [10, 40, 18],
seal: [12, 60, 12, 60, 28],
warn: [20, 50, 20],
};
export function haptic(kind = 'tick') {
if (!settings.haptics) return;
try {
if (navigator.vibrate) navigator.vibrate(PATTERNS[kind] ?? 5);
} catch {
/* ignore */
}
}
/* ---------- Toast ---------- */
let toastTimer = 0;
export function toast(text, { icon: iconHtml = '' } = {}) {
let el = document.getElementById('toast');
if (!el) {
el = h(
'<div id="toast" class="toast glass-strong" role="status" aria-live="polite"></div>',
);
document.body.appendChild(el);
}
el.innerHTML = `${iconHtml}<span>${esc(text)}</span>`;
el.classList.remove('show');
void el.offsetWidth;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2200);
}
/** Inline SVG sparkline path for a numeric series. */
export function sparkPath(values, w, h, pad = 1.5) {
const v = values.filter(Number.isFinite);
if (v.length < 2) return '';
let lo = Math.min(...v);
let hi = Math.max(...v);
if (hi - lo < 1e-9) {
hi += 1;
lo -= 1;
}
return v
.map((y, i) => {
const px = pad + (i / (v.length - 1)) * (w - pad * 2);
const py = pad + (1 - (y - lo) / (hi - lo)) * (h - pad * 2);
return `${i ? 'L' : 'M'}${px.toFixed(1)} ${py.toFixed(1)}`;
})
.join('');
}
/** Long press helper; returns a disposer. */
export function onLongPress(el, fn, ms = 480) {
let timer = 0;
let sx = 0;
let sy = 0;
let fired = false;
const down = (e) => {
fired = false;
sx = e.clientX;
sy = e.clientY;
timer = setTimeout(() => {
fired = true;
fn(e);
}, ms);
};
const move = (e) => {
if (Math.hypot(e.clientX - sx, e.clientY - sy) > 8) clearTimeout(timer);
};
const up = () => clearTimeout(timer);
el.addEventListener('pointerdown', down, { passive: true });
el.addEventListener('pointermove', move, { passive: true });
el.addEventListener('pointerup', up, { passive: true });
el.addEventListener('pointercancel', up, { passive: true });
el.addEventListener(
'click',
(e) => {
if (fired) {
e.stopImmediatePropagation();
e.preventDefault();
fired = false;
}
},
true,
);
el.addEventListener('contextmenu', (e) => e.preventDefault());
}
/** A tiny event emitter. */
export function emitter() {
const m = new Map();
return {
on(ev, fn) {
if (!m.has(ev)) m.set(ev, new Set());
m.get(ev).add(fn);
return () => m.get(ev)?.delete(fn);
},
off(ev, fn) {
m.get(ev)?.delete(fn);
},
emit(ev, data) {
m.get(ev)?.forEach((fn) => {
try {
fn(data);
} catch (err) {
console.error(err);
}
});
},
};
}
/** Esri World Imagery export URL for a lon/lat box around a point. */
export function esriThumb(lon, lat, spanDeg = 1.2, w = 480, hgt = 600) {
const aspect = hgt / w;
const dLon = spanDeg / Math.max(0.2, Math.cos((lat * Math.PI) / 180));
const dLat = spanDeg * aspect;
const west = lon - dLon / 2;
const east = lon + dLon / 2;
const south = Math.max(-85, lat - dLat / 2);
const north = Math.min(85, lat + dLat / 2);
return `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/export?bbox=${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}&bboxSR=4326&imageSR=3857&size=${w},${hgt}&format=jpg&f=image`;
}
/** Safe-area insets in px (env() read through a probe element). */
let insetProbe = null;
export function safeInsets() {
if (!insetProbe) {
insetProbe = document.createElement('div');
insetProbe.style.cssText =
'position:fixed;top:0;left:0;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)';
document.body.appendChild(insetProbe);
}
const cs = getComputedStyle(insetProbe);
return {
top: parseFloat(cs.paddingTop) || 0,
right: parseFloat(cs.paddingRight) || 0,
bottom: parseFloat(cs.paddingBottom) || 0,
left: parseFloat(cs.paddingLeft) || 0,
};
}
+350
View File
@@ -0,0 +1,350 @@
/**
* Now: a two-column, RedNote-style masonry of live situations with a large
* collapsing title, section tabs, pull-to-refresh and skeleton loading.
*/
import { icon, FAMILY_ICON, FAMILIES } from './icons.js';
import { $, $$, esc, ago, fmtNum, clamp, haptic } from './dom.js';
const SECTIONS = [
{ id: 'now', label: 'Happening now' },
{ id: 'near', label: 'Near you' },
{ id: 'recon', label: 'Reconstructions' },
{ id: 'always', label: 'Always on' },
];
const ASPECTS = [1.25, 1, 1.33, 0.82, 1.18, 0.95];
const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name]));
function hashStr(s) {
let x = 2166136261;
for (let i = 0; i < s.length; i += 1)
x = Math.imul(x ^ s.charCodeAt(i), 16777619);
return x >>> 0;
}
/** Height/width ratio for a card: from the thumbnail URL when known, else stable per id. */
function aspectFor(s) {
const m = /size=(\d+),(\d+)/.exec(s.thumbnail || '');
if (m) return clamp(Number(m[2]) / Number(m[1]), 0.72, 1.4);
if (s.kind === 'reconstruction') return 1.3;
return ASPECTS[hashStr(String(s.id)) % ASPECTS.length];
}
export function isReconstruction(s) {
return (
s.section === 'reconstructions' || s.kind === 'reconstruction' || !!s.thread
);
}
export function isAlwaysOn(s) {
return s.section === 'always' || !!s.alwaysOn;
}
function distKm(a, b) {
const R = 6371;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLon = ((b.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((b.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
}
export function createFeed(
view,
{ getSituations, onOpen, onLocate, bindScroll },
) {
view.innerHTML = `
<header class="navbar"><span class="nav-title">Now</span>
<div class="nav-trailing"><button class="icon-btn" type="button" data-act="refresh" aria-label="Refresh">${icon('refresh')}</button></div>
</header>
<div class="ptr" aria-hidden="true"><div class="ptr-spinner">${FAMILIES.map((f, i) => `<i style="--c:${f.color};--i:${i}"></i>`).join('')}</div></div>
<div class="feed-inner">
<h1 class="large-title">Now</h1>
<div class="title-sub"><span class="live-dot"></span><span class="feed-count num">Loading the planet…</span></div>
<div class="feed-tabs" role="tablist" aria-label="Feed sections" data-hscroll>
${SECTIONS.map((s, i) => `<button role="tab" type="button" data-sec="${s.id}" aria-selected="${i === 0}">${s.label}</button>`).join('')}
<span class="feed-tabs-ink" aria-hidden="true"></span>
</div>
<div class="feed-note" hidden></div>
<div class="masonry" role="feed" aria-busy="true"><div class="mcol"></div><div class="mcol"></div></div>
<p class="feed-end" hidden>You’re caught up with the planet.</p>
</div>`;
const inner = $('.feed-inner', view);
const cols = $$('.mcol', view);
const masonry = $('.masonry', view);
const tabs = $('.feed-tabs', view);
const ink = $('.feed-tabs-ink', view);
const count = $('.feed-count', view);
const note = $('.feed-note', view);
const ptr = $('.ptr', view);
let section = 'now';
let all = [];
let userLoc = null;
const cardEls = new Map();
let selectedId = null;
function skeleton() {
cols.forEach((c, ci) => {
c.innerHTML = [0, 1, 2]
.map(
(i) =>
`<div class="card skel"><div class="card-media" style="aspect-ratio:1/${ASPECTS[(i + ci * 2) % ASPECTS.length]}"></div><div class="card-body"><div class="sk-line"></div><div class="sk-line short"></div></div></div>`,
)
.join('');
});
}
function cardHtml(s) {
const m = s.metrics?.[0];
const ar = aspectFor(s);
const fam = FAM_NAME[s.family] || s.family;
return `<article class="card ${s.live ? 'is-live' : isReconstruction(s) ? 'is-recon' : s.stale || (!isAlwaysOn(s) && s.section !== 'always') ? 'is-stale' : 'is-evergreen'}" data-id="${esc(s.id)}" data-family="${esc(s.family)}" aria-labelledby="ct-${esc(s.id)}">
<button class="card-hit" type="button" aria-label="Open ${esc(s.title)}, ${esc(s.place || '')}"></button>
<div class="card-media" style="aspect-ratio:1/${ar.toFixed(3)}">
<img src="${esc(s.thumbnail || '')}" alt="" loading="lazy" decoding="async" fetchpriority="low" />
<div class="card-badges">
<span class="fam-tag glass-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(fam)}</span>
${s.live ? '<span class="live-pill"><span class="live-dot"></span>LIVE</span>' : isReconstruction(s) ? `<span class="live-pill recon">${icon('threads')}4D</span>` : ''}
</div>
${m ? `<div class="card-metric"><b class="num">${esc(fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0))}</b>${m.unit ? `<small>${esc(m.unit)}</small>` : ''}<span>${esc(m.label)}</span></div>` : ''}
</div>
<div class="card-body">
<h3 class="card-title" id="ct-${esc(s.id)}">${esc(s.title)}</h3>
<div class="card-place">${icon('pin')}<span>${esc(s.place || '')}</span></div>
<div class="card-foot"><span class="src">${esc(shortSource(s.source))}</span><span class="ago num" data-ts="${esc(s.updatedAt || '')}">${esc(ago(s.updatedAt))}</span></div>
</div>
</article>`;
}
function shortSource(src = '') {
return String(src).split(/[·(]/)[0].trim() || 'Live feed';
}
function filtered() {
if (section === 'recon') return all.filter(isReconstruction);
if (section === 'always') return all.filter(isAlwaysOn);
if (section === 'near') {
const at = userLoc || { lon: -122.42, lat: 37.77 };
return [...all]
.filter((s) => Number.isFinite(s.lon))
.sort((a, b) => distKm(at, a) - distKm(at, b))
.slice(0, 8);
}
return all
.filter((s) => !isReconstruction(s) && !isAlwaysOn(s))
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}
function render() {
const list = filtered();
cardEls.clear();
const heights = [0, 0];
const parts = [[], []];
for (const s of list) {
const col = heights[0] <= heights[1] ? 0 : 1;
parts[col].push(cardHtml(s));
heights[col] += aspectFor(s) + 0.42;
}
cols.forEach((c, i) => (c.innerHTML = parts[i].join('')));
$$('.card[data-id]', masonry).forEach((el) => {
cardEls.set(el.dataset.id, el);
el.classList.toggle('is-selected', el.dataset.id === selectedId);
});
masonry.setAttribute('aria-busy', 'false');
$('.feed-end', view).hidden = !list.length;
const live = all.filter((s) => s.live).length;
count.textContent = `${live} live · ${all.length} situations · ${new Date().toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })}`;
if (section === 'near' && !userLoc) {
note.hidden = false;
note.innerHTML = `<div class="near-prompt glass">${icon('location')}<div><b>See what’s around you</b><span>Sorted from San Francisco until you share your location. It stays on this device.</span></div><button class="pill-btn primary" type="button" data-act="locate">Use location</button></div>`;
} else note.hidden = true;
// Images fade in when decoded.
$$('img', masonry).forEach((img) => {
if (img.complete) img.classList.add('loaded');
else
img.addEventListener('load', () => img.classList.add('loaded'), {
once: true,
});
img.addEventListener('error', () => img.classList.add('failed'), {
once: true,
});
});
}
function placeInk() {
const btn = $(`[data-sec="${section}"]`, tabs);
if (!btn) return;
ink.style.width = `${btn.offsetWidth - 20}px`;
ink.style.transform = `translateX(${btn.offsetLeft + 10}px)`;
}
async function load({ quiet = false } = {}) {
if (!quiet) skeleton();
try {
const prev = new Map(all.map((s) => [s.id, JSON.stringify(s.metrics)]));
all = await getSituations();
render();
if (quiet)
for (const s of all)
if (prev.has(s.id) && prev.get(s.id) !== JSON.stringify(s.metrics))
cardEls.get(s.id)?.classList.add('pulse');
} catch (err) {
console.error(err);
count.textContent = 'Could not reach the live feeds';
}
}
tabs.addEventListener('click', (e) => {
const b = e.target.closest('[data-sec]');
if (!b || b.dataset.sec === section) return;
section = b.dataset.sec;
$$('[data-sec]', tabs).forEach((x) =>
x.setAttribute('aria-selected', String(x === b)),
);
haptic('tick');
placeInk();
b.scrollIntoView({
inline: 'nearest',
block: 'nearest',
behavior: 'smooth',
});
masonry.classList.remove('swap');
void masonry.offsetWidth;
masonry.classList.add('swap');
render();
});
view.addEventListener('click', async (e) => {
const hit = e.target.closest('.card-hit');
if (hit) {
const card = hit.closest('.card');
const s = all.find((x) => String(x.id) === card.dataset.id);
if (s) onOpen(s, card);
return;
}
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'refresh') refresh();
if (act === 'locate') {
userLoc = await onLocate();
render();
}
});
/* ---------- large title + nav bar ---------- */
const title = $('.large-title', view);
view.addEventListener(
'scroll',
() => {
const y = view.scrollTop;
view.classList.toggle('is-scrolled', y > 44);
if (y < 0)
title.style.transform = `scale(${1 + Math.min(0.08, -y / 900)})`;
else if (title.style.transform) title.style.transform = '';
},
{ passive: true },
);
bindScroll(view);
/* ---------- pull to refresh ---------- */
let pull = null;
let refreshing = false;
const THRESH = 78;
view.addEventListener(
'touchstart',
(e) => {
if (view.scrollTop > 0 || refreshing || e.touches.length !== 1) return;
pull = { y0: e.touches[0].clientY, d: 0, armed: false };
},
{ passive: true },
);
view.addEventListener(
'touchmove',
(e) => {
if (!pull) return;
const dy = e.touches[0].clientY - pull.y0;
if (dy <= 0) {
pull.d = 0;
setPull(0);
return;
}
if (view.scrollTop > 0) return;
e.preventDefault();
pull.d = dy;
const d = (1 - 1 / ((dy * 0.55) / 320 + 1)) * 320;
setPull(d);
if (!pull.armed && d > THRESH) {
pull.armed = true;
haptic('light');
} else if (pull.armed && d < THRESH) pull.armed = false;
},
{ passive: false },
);
view.addEventListener(
'touchend',
() => {
if (!pull) return;
const armed = pull.armed;
pull = null;
if (armed) refresh();
else setPull(0, true);
},
{ passive: true },
);
function setPull(d, animate = false) {
const t = animate ? 'transform 420ms var(--spring)' : 'none';
inner.style.transition = t;
inner.style.transform = d ? `translate3d(0, ${d}px, 0)` : '';
ptr.style.transition = animate ? 'opacity 200ms linear' : 'none';
ptr.style.opacity = String(clamp(d / THRESH, 0, 1));
ptr.style.setProperty('--p', clamp(d / THRESH, 0, 1).toFixed(3));
}
async function refresh() {
if (refreshing) return;
refreshing = true;
ptr.classList.add('spinning');
setPull(64, true);
const t = performance.now();
await load({ quiet: true });
const wait = Math.max(0, 700 - (performance.now() - t));
setTimeout(() => {
ptr.classList.remove('spinning');
setPull(0, true);
refreshing = false;
haptic('success');
}, wait);
}
// Keep "2 min ago" honest.
setInterval(() => {
$$('.ago[data-ts]', view).forEach((el) => {
if (el.dataset.ts)
el.textContent = ago(Number(el.dataset.ts) || el.dataset.ts);
});
}, 30000);
new ResizeObserver(placeInk).observe(tabs);
requestAnimationFrame(placeInk);
return {
load,
refresh,
cardFor: (id) => cardEls.get(String(id)) || null,
situations: () => all,
select(id) {
selectedId = id == null ? null : String(id);
cardEls.forEach((el, k) =>
el.classList.toggle('is-selected', k === selectedId),
);
},
setLocation(loc) {
userLoc = loc;
if (section === 'near') render();
},
};
}
+549
View File
@@ -0,0 +1,549 @@
/**
* Globe chrome: camera readout, lens switcher, compass/home, attribution,
* the lens dock of eight families, Control-Center-style layer grids,
* the time ribbon and the pick card.
*/
import { icon, FAMILIES, FAMILY_ICON } from './icons.js';
import {
$,
$$,
h,
esc,
fmtNum,
haptic,
toast,
store,
onLongPress,
} from './dom.js';
import { createSheet } from './sheet.js';
import { createTimeRibbon } from './timeRibbon.js';
const LENSES = [
{
id: 'cinematic',
name: 'Cinematic',
sw: 'linear-gradient(135deg,#0b2a4a,#3a77b8 55%,#f3b46b)',
},
{
id: 'clear',
name: 'Clear',
sw: 'linear-gradient(135deg,#2a6fb0,#7fb7e6 60%,#e8f1f8)',
},
{
id: 'night',
name: 'Night',
sw: 'radial-gradient(circle at 40% 60%,#ffcf6b 0,#c27a2a 8%,#0b1020 26%,#03050a)',
},
{
id: 'thermal',
name: 'Thermal',
sw: 'linear-gradient(135deg,#120428,#7a1fa2 35%,#ff5a1f 70%,#ffe26b)',
},
{
id: 'nvg',
name: 'NVG',
sw: 'radial-gradient(circle,#7dff9c 0,#1fa64a 45%,#04140a)',
},
];
const STATUS_LABEL = {
live: 'Live',
stale: 'Stale',
off: 'Off',
error: 'Error',
};
export function createGlobeChrome({
chrome,
stage,
leadView,
globe,
onOpenSituation,
onThread,
getSituations,
isTwoPane,
}) {
/* ---------- top chrome ---------- */
const top = h(`<div class="gc-top">
<div class="gc-readout glass-strong liquid"><span class="num gc-alt">—</span><span class="mono gc-ll">—</span></div>
<div class="gc-buttons">
<button class="icon-btn glass-strong liquid" type="button" data-act="lens" aria-label="Visual lens" aria-haspopup="true">${icon('lens')}</button>
<button class="icon-btn glass-strong liquid gc-compass" type="button" data-act="home" aria-label="Reset view to the whole planet"><span class="compass-n">${icon('north')}</span></button>
<button class="icon-btn glass-strong liquid" type="button" data-act="credits" aria-label="Map data attribution">${icon('info')}</button>
</div>
</div>`);
const attribution = h(
'<button class="gc-attr" type="button" data-act="credits" aria-label="Attribution">Esri · Maxar · Earthstar · NASA</button>',
);
const lensPop =
h(`<div class="lens-pop glass-strong" role="menu" aria-label="Visual lens" hidden>
${LENSES.map((l) => `<button role="menuitemradio" type="button" data-lens="${l.id}" aria-checked="false"><span class="lens-sw" style="background:${l.sw}"></span><span>${l.name}</span></button>`).join('')}
</div>`);
const dock =
h(`<div class="lens-dock" role="toolbar" aria-label="Lens families" data-hscroll>
${FAMILIES.map((f) => `<button class="chip fam-chip" type="button" data-family="${f.id}" aria-haspopup="dialog" aria-label="${f.name} layers. Long-press to solo">${icon(FAMILY_ICON[f.id])}<span>${f.name}</span><b class="num fam-count"></b></button>`).join('')}
</div>`);
const pick = h(
`<div class="pick-card glass-strong" role="dialog" aria-label="Selected contact" hidden></div>`,
);
const bottom = h('<div class="gc-bottom"></div>');
chrome.append(top, attribution, lensPop, pick, bottom);
bottom.append(dock);
const ribbon = createTimeRibbon(bottom, {
getWindow: () => globe.timeWindow(),
onTime: (d) => globe.setTime(d),
});
/* ---------- camera readout ---------- */
globe.on('camera', (c) => {
if (!c) return;
const alt =
c.height > 10000
? `${fmtNum(Math.round(c.height / 1000))} km`
: `${fmtNum(Math.round(c.height))} m`;
$('.gc-alt', top).textContent = alt;
$('.gc-ll', top).textContent =
`${Math.abs(c.lat).toFixed(2)}°${c.lat >= 0 ? 'N' : 'S'} ${Math.abs(c.lon).toFixed(2)}°${c.lon >= 0 ? 'E' : 'W'}`;
if (Number.isFinite(c.heading))
$('.compass-n', top).style.transform = `rotate(${-c.heading}deg)`;
});
/* ---------- lens switcher ---------- */
function setLens(id) {
globe.setLens(id);
store.set('lens', id);
$$('[data-lens]', lensPop).forEach((b) =>
b.setAttribute('aria-checked', String(b.dataset.lens === id)),
);
chrome.dataset.lens = id;
}
setLens(store.get('lens') || 'cinematic');
lensPop.addEventListener('click', (e) => {
const b = e.target.closest('[data-lens]');
if (!b) return;
haptic('tick');
setLens(b.dataset.lens);
setTimeout(() => (lensPop.hidden = true), 160);
});
document.addEventListener('pointerdown', (e) => {
if (!lensPop.hidden && !e.target.closest('.lens-pop, [data-act="lens"]'))
lensPop.hidden = true;
});
/* ---------- family counts + dock ---------- */
function familyState() {
const layers = globe.layers();
const st = {};
for (const f of FAMILIES)
st[f.id] = { on: 0, total: 0, count: 0, layers: [] };
for (const l of layers) {
const s = st[l.family];
if (!s) continue;
s.total += 1;
s.layers.push(l);
if (l.on) {
s.on += 1;
s.count += Number(l.count) || 0;
}
}
return st;
}
function refreshDock() {
const st = familyState();
$$('.fam-chip', dock).forEach((b) => {
const s = st[b.dataset.family];
b.classList.toggle('on', s.on > 0);
b.querySelector('.fam-count').textContent = s.on ? fmtNum(s.count) : '';
});
if (gridFamily) renderGrid(gridFamily);
if (isTwoPane()) renderLeadView();
}
globe.on('layers', () => refreshDock());
let solo = null;
dock.addEventListener('click', (e) => {
const b = e.target.closest('.fam-chip');
if (b) openGrid(b.dataset.family);
});
$$('.fam-chip', dock).forEach((b) =>
onLongPress(b, () => {
haptic('medium');
const fam = b.dataset.family;
if (solo?.family === fam) {
for (const l of globe.layers())
globe.setLayer(l.id, solo.prev.has(l.id));
solo = null;
toast('All lenses restored', { icon: icon('layers') });
} else {
const prev =
solo?.prev ||
new Set(
globe
.layers()
.filter((l) => l.on)
.map((l) => l.id),
);
for (const f of FAMILIES)
if (f.id !== fam) globe.setFamily(f.id, false);
globe.setFamily(fam, true);
solo = { family: fam, prev };
toast(
`Solo · ${FAMILIES.find((f) => f.id === fam).name}. Long-press again to restore`,
{ icon: icon(FAMILY_ICON[fam]) },
);
}
dock.dataset.solo = solo?.family || '';
refreshDock();
}),
);
/* ---------- layer grid (Control Center) ---------- */
const grid = createSheet({
label: 'Layers',
detents: ['medium', 'large'],
modal: true,
dismissible: true,
closeButton: true,
className: 'grid-sheet',
heights: {
medium: (H) => Math.min(Math.round(H * 0.62), 560),
large: (H) => Math.round(H * 0.92),
},
});
let gridFamily = null;
grid.on('close', () => (gridFamily = null));
function tileHtml(l) {
return `<button class="tile ${l.on ? 'on' : ''}" type="button" role="switch" aria-checked="${!!l.on}" data-layer="${esc(l.id)}" data-status="${esc(l.status)}">
<span class="tile-ic">${icon(FAMILY_ICON[l.family] || 'layers')}</span>
<span class="tile-name">${esc(l.name)}</span>
<span class="tile-meta"><span class="tile-status"><i></i>${esc(STATUS_LABEL[l.status] || l.status)}</span><b class="num">${l.count != null ? esc(fmtNum(l.count)) : ''}</b></span>
</button>`;
}
function renderGrid(fam) {
const st = familyState()[fam];
const f = FAMILIES.find((x) => x.id === fam);
grid.sheet.dataset.family = fam;
grid.head.innerHTML = `<div class="grid-head"><span class="grid-ic">${icon(FAMILY_ICON[fam])}</span>
<div><h2 class="sheet-title">${f.name}</h2><div class="sheet-sub num">${st.on} of ${st.total} on · ${fmtNum(st.count)} contacts</div></div>
<button class="switch" type="button" role="switch" aria-checked="${st.on > 0}" aria-label="All ${f.name} layers" data-fam-toggle></button></div>`;
grid.body.innerHTML = `<div class="tiles">${st.layers.map(tileHtml).join('')}</div>
<p class="footnote" style="padding:14px 2px 0">Long-press ${f.name} in the dock to solo it. Counts are live from each feed; dotted means stale.</p>`;
}
function openGrid(fam) {
gridFamily = fam;
renderGrid(fam);
grid.open('medium');
}
grid.root.addEventListener('click', (e) => {
const t = e.target.closest('[data-layer]');
if (t) {
const on = t.getAttribute('aria-checked') !== 'true';
haptic('tick');
t.classList.toggle('on', on);
t.setAttribute('aria-checked', String(on));
globe.setLayer(t.dataset.layer, on);
setTimeout(refreshDock, 30);
return;
}
if (e.target.closest('[data-fam-toggle]')) {
const sw = e.target.closest('[data-fam-toggle]');
const on = sw.getAttribute('aria-checked') !== 'true';
haptic('light');
globe.setFamily(gridFamily, on);
setTimeout(refreshDock, 30);
}
});
/* ---------- credits ---------- */
const credits = createSheet({
label: 'Attribution',
detents: ['large'],
modal: true,
dismissible: true,
closeButton: true,
heights: { large: (H) => Math.round(H * 0.7) },
});
function openCredits() {
const list = globe.credits() || [];
const fixed = [
{
name: 'Bhote Koshi event pack',
text: 'GeoPera reconstruction centerline; Vantor WorldView-2/3 Open Data; GeoGeorgeShadrach public geolocation map. CC BY-NC 4.0 — not covered by the app’s MIT license.',
url: 'https://creativecommons.org/licenses/by-nc/4.0/',
},
{
name: 'Esri World Imagery (feed thumbnails)',
text: 'Esri, Maxar, Earthstar Geographics, and the GIS User Community',
url: 'https://www.esri.com',
},
{
name: 'Place search',
text: '© OpenStreetMap contributors, via Nominatim',
url: 'https://www.openstreetmap.org/copyright',
},
];
const all = [
...list,
...fixed.filter((f) => !list.some((l) => l.name === f.name)),
];
credits.head.innerHTML = `<h2 class="sheet-title">Data &amp; attribution</h2><div class="sheet-sub">Imagery, feeds and the people who made them</div>`;
credits.body.innerHTML = `<div class="credit-list">${all
.map((c) => {
const text =
c.text ||
c.attribution ||
String(c.html || '').replace(/<[^>]+>/g, '');
const [head, ...rest] =
c.name || c.title
? [c.name || c.title, text]
: text.split(/:\s(.+)/s);
return `<div class="credit"><b>${esc(head)}</b>${rest.filter(Boolean).length ? `<span>${esc(rest.filter(Boolean).join(' '))}</span>` : ''}${c.url ? `<a href="${esc(c.url)}" target="_blank" rel="noopener">${esc(new URL(c.url, location.href).hostname)}</a>` : ''}</div>`;
})
.join('')}</div>`;
credits.open('large');
}
top.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (act === 'lens') {
lensPop.hidden = !lensPop.hidden;
haptic('tick');
} else if (act === 'home') {
haptic('tick');
hidePick();
globe.home();
} else if (act === 'credits') openCredits();
});
attribution.addEventListener('click', openCredits);
/* ---------- pick card ---------- */
const KIND_ICON = {
aircraft: 'sky',
helicopter: 'sky',
military: 'sky',
ship: 'sea',
vessel: 'sea',
satellite: 'orbit',
station: 'orbit',
launch: 'orbit',
quake: 'hazards',
earthquake: 'hazards',
fire: 'flame',
cyclone: 'weather',
camera: 'eyes',
cctv: 'eyes',
};
const KIND_FAM = {
aircraft: 'sky',
helicopter: 'sky',
military: 'sky',
ship: 'sea',
vessel: 'sea',
satellite: 'orbit',
station: 'orbit',
launch: 'orbit',
quake: 'hazards',
earthquake: 'hazards',
fire: 'hazards',
cyclone: 'hazards',
flood: 'hazards',
camera: 'eyes',
cctv: 'eyes',
};
let picked = null;
/** Human rows from a pick's meta: known units formatted, internals dropped. */
function metaRows(m) {
if (!m || typeof m !== 'object') return [];
const rows = [];
const num = (v) => typeof v === 'number' && Number.isFinite(v);
if (num(m.altitudeM) && m.altitudeM > 0)
rows.push([
'altitude',
m.altitudeM > 100000
? `${fmtNum(Math.round(m.altitudeM / 1000))} km`
: `${fmtNum(Math.round((m.altitudeM * 3.28084) / 100) * 100)} ft`,
]);
if (num(m.speedMps))
rows.push(['speed', `${Math.round(m.speedMps * 1.944)} kt`]);
if (num(m.headingDeg))
rows.push(['heading', `${Math.round(m.headingDeg)}°`]);
if (num(m.verticalRateMps) && Math.abs(m.verticalRateMps) > 0.5)
rows.push(['climb', `${Math.round(m.verticalRateMps * 196.85)} fpm`]);
const skip = new Set([
'layer',
'source',
'altitudeM',
'speedMps',
'headingDeg',
'verticalRateMps',
'onGround',
'class',
'situation',
]);
for (const [k, v] of Object.entries(m)) {
if (
skip.has(k) ||
v == null ||
v === '' ||
typeof v === 'object' ||
typeof v === 'boolean'
)
continue;
rows.push([
k.replace(/([A-Z])/g, ' $1').toLowerCase(),
num(v) ? fmtNum(v, v % 1 ? 1 : 0) : String(v),
]);
}
return rows;
}
function hidePick() {
pick.hidden = true;
picked = null;
chrome.classList.remove('has-pick');
}
async function showPick(p) {
if (!p) return hidePick();
if (p.kind === 'situation') {
const s = (await getSituations()).find(
(x) => String(x.id) === String(p.id),
);
if (s) onOpenSituation(s);
return;
}
picked = p;
const fam = KIND_FAM[p.kind] || 'infra';
const meta = metaRows(p.meta);
const followed = store.isFollowed(`contact:${p.id}`);
pick.dataset.family = fam;
pick.innerHTML = `<div class="pick-top"><span class="pick-ic">${icon(KIND_ICON[p.kind] || 'scope')}</span>
<div class="pick-titles"><div class="pick-title">${esc(p.title || p.id)}</div><div class="pick-sub">${esc(p.subtitle || p.kind)}</div></div>
<button class="icon-btn pick-close" type="button" aria-label="Dismiss">${icon('close')}</button></div>
${
meta.length
? `<div class="pick-meta">${meta
.slice(0, 6)
.map(
([k, v]) =>
`<div><span>${esc(k)}</span><b class="num">${esc(v)}</b></div>`,
)
.join('')}</div>`
: ''
}
<div class="pick-src">${esc(p.meta?.source || '')}${Number.isFinite(p.lat) ? ` · <span class="mono">${p.lat.toFixed(3)}, ${p.lon.toFixed(3)}</span>` : ''}</div>
<div class="pick-actions">
<button class="pill-btn tinted" type="button" data-pick="follow" aria-pressed="${followed}">${icon(followed ? 'starFill' : 'star')}${followed ? 'Following' : 'Follow'}</button>
<button class="pill-btn plain" type="button" data-pick="ride">${icon('ride')}Ride</button>
<button class="pill-btn plain" type="button" data-pick="eye">${icon('video')}Eye</button>
<button class="pill-btn plain" type="button" data-pick="thread">${icon('threads')}Thread</button>
</div>`;
pick.hidden = false;
chrome.classList.add('has-pick');
pick.animate(
[
{ transform: 'translateY(14px) scale(.97)', opacity: 0 },
{ transform: 'none', opacity: 1 },
],
{ duration: 380, easing: 'cubic-bezier(.32,1.25,.4,1)' },
);
haptic('light');
}
globe.on('pick', showPick);
pick.addEventListener('click', async (e) => {
if (e.target.closest('.pick-close')) {
globe.select(null);
return hidePick();
}
const a = e.target.closest('[data-pick]')?.dataset.pick;
if (!a || !picked) return;
const p = picked;
haptic('light');
if (a === 'follow') {
const on = store.toggleFollow(`contact:${p.id}`);
toast(on ? `Following ${p.title}` : 'Unfollowed', {
icon: icon(on ? 'starFill' : 'star'),
});
showPick(p);
} else if (a === 'ride') {
globe.select(p.id);
await globe.flyTo({
lon: p.lon,
lat: p.lat,
height: p.kind === 'satellite' ? 1200e3 : 6000,
pitch: -12,
duration: 2,
});
if (globe.isStub)
toast('Ride view needs the live engine — following from above', {
icon: icon('ride'),
});
} else if (a === 'eye') {
const cams = globe
.contactsNear(p.lon, p.lat, 60)
.filter((c) => /cam|cctv/i.test(c.kind));
if (cams.length) {
globe.flyTo({ lon: cams[0].lon, lat: cams[0].lat, height: 2500 });
toast(`Nearest eye: ${cams[0].title}`);
} else toast('No public camera within 60 km', { icon: icon('video') });
} else if (a === 'thread')
onThread?.({
id: `contact:${p.id}`,
title: p.title,
place: p.subtitle,
lon: p.lon,
lat: p.lat,
family: KIND_FAM[p.kind] || 'infra',
});
});
/* ---------- two-pane leading view (Globe tab) ---------- */
function renderLeadView() {
const st = familyState();
const active = store.get('lens') || 'cinematic';
leadView.innerHTML = `<header class="navbar"><span class="nav-title">Globe</span></header>
<h1 class="large-title">Globe</h1>
<div class="title-sub">${Object.values(st).reduce((a, s) => a + s.on, 0)} lenses on · drag the planet, pinch to descend</div>
<div class="section-h">Visual lens</div>
<div class="lens-row" data-hscroll>${LENSES.map((l) => `<button type="button" class="lens-card ${l.id === active ? 'on' : ''}" data-lens2="${l.id}"><span class="lens-sw" style="background:${l.sw}"></span>${l.name}</button>`).join('')}</div>
${FAMILIES.map((f) => {
const s = st[f.id];
return `<div class="section-h" data-family="${f.id}"><span class="lead-fam">${icon(FAMILY_ICON[f.id])}${f.name}</span><small class="num">${s.on}/${s.total} · ${fmtNum(s.count)}</small></div>
<div class="tiles lead-tiles" data-family="${f.id}">${s.layers.map(tileHtml).join('')}</div>`;
}).join('')}
<p class="footnote"><button class="link-btn" type="button" data-act2="credits">Data &amp; attribution</button></p>`;
}
leadView.addEventListener('click', (e) => {
const t = e.target.closest('[data-layer]');
if (t) {
const on = t.getAttribute('aria-checked') !== 'true';
haptic('tick');
globe.setLayer(t.dataset.layer, on);
setTimeout(refreshDock, 30);
return;
}
const l = e.target.closest('[data-lens2]');
if (l) {
setLens(l.dataset.lens2);
renderLeadView();
}
if (e.target.closest('[data-act2="credits"]')) openCredits();
});
refreshDock();
return {
ribbon,
refreshDock,
renderLeadView,
hidePick,
showPick,
openGrid,
openCredits,
setPips: (p) => ribbon.setPips(p),
};
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Mounts the one persistent globe behind the shell.
*
* Prefers the real Cesium engine (mobile/globe/index.js, DESIGN.md §10) and
* falls back to the offline stub so the shell is always demoable. Every call
* goes through a guard so a missing method or a throwing engine never takes
* the shell down; mismatches are collected in `contractReport` for the lead.
*/
const REQUIRED = [
'flyTo',
'home',
'layers',
'setLayer',
'setFamily',
'setTime',
'timeWindow',
'setLens',
'focus',
'select',
'on',
'off',
'pause',
'resume',
'contactsNear',
'situations',
];
export const contractReport = {
engine: 'none',
missing: [],
errors: [],
notes: [],
};
if (typeof window !== 'undefined') window.__gevContract = contractReport;
/** Families are lower-case ids in the shell; the engine may capitalise them. */
const fam = (f) => String(f || 'infra').toLowerCase();
const normLayer = (l) => ({ ...l, family: fam(l.family) });
function normSituation(s) {
const section = String(s.section || '').toLowerCase();
return {
...s,
family: fam(s.family),
section:
section === 'reconstruction' ? 'reconstructions' : section || undefined,
alwaysOn: s.alwaysOn ?? section === 'always',
};
}
function guard(engine, kind) {
const note = (m) => {
if (!contractReport.errors.includes(m)) contractReport.errors.push(m);
};
const call = (name, fallback, ...args) => {
const fn = engine[name];
if (typeof fn !== 'function') return fallback;
try {
return fn.apply(engine, args);
} catch (err) {
note(`${name}(): ${err?.message || err}`);
console.warn(`[globe:${kind}] ${name} failed`, err);
return fallback;
}
};
const asyncCall = async (name, fallback, ...args) => {
try {
const v = await call(name, fallback, ...args);
return v ?? fallback;
} catch (err) {
note(`${name}(): ${err?.message || err}`);
return fallback;
}
};
return {
kind,
raw: engine,
isStub: kind === 'stub',
flyTo: (o) => asyncCall('flyTo', undefined, o),
home: () => call('home', undefined),
layers: () => {
const v = call('layers', []);
return Array.isArray(v) ? v.map(normLayer) : [];
},
setLayer: (id, on) => call('setLayer', undefined, id, on),
setFamily: (f, on) => call('setFamily', undefined, f, on),
setTime: (d) => call('setTime', undefined, d),
timeWindow: () => {
const v = call('timeWindow', null);
const n = Date.now();
if (!v || !v.start || !v.end)
return {
start: new Date(n - 72 * 3600e3),
end: new Date(n + 120 * 3600e3),
now: new Date(n),
};
return {
start: new Date(v.start),
end: new Date(v.end),
now: new Date(v.now ?? n),
};
},
setLens: (id) => call('setLens', undefined, id),
focus: (s) => asyncCall('focus', undefined, s),
select: (id) => call('select', undefined, id),
on: (ev, fn) => call('on', undefined, ev, fn),
off: (ev, fn) => call('off', undefined, ev, fn),
pause: () => call('pause', undefined),
resume: () => call('resume', undefined),
contactsNear: (lon, lat, km) => {
const v = call('contactsNear', [], lon, lat, km);
return Array.isArray(v) ? v : [];
},
situations: async () => {
const v = await asyncCall('situations', []);
return Array.isArray(v) ? v.map(normSituation) : [];
},
credits: () => {
const v = call('credits', null);
return Array.isArray(v) ? v : null;
},
};
}
/**
* @param {HTMLElement} container
* @param {{ quality?: string, preferStub?: boolean }} opts
*/
export async function mountGlobe(container, opts = {}) {
const params = new URLSearchParams(location.search);
const forceStub = opts.preferStub || params.get('engine') === 'stub';
if (!forceStub) {
try {
// A glob (not a bare dynamic import) so a checkout without the engine
// still builds; Vite re-evaluates it when mobile/globe/index.js appears.
const loaders = import.meta.glob('../globe/index.js');
const load = loaders['../globe/index.js'];
if (!load) throw new Error('mobile/globe/index.js not present');
const mod = await load();
const create = mod.createGlobe || mod.default?.createGlobe || mod.default;
if (typeof create !== 'function')
throw new Error('mobile/globe/index.js exports no createGlobe');
const quality =
opts.quality === 'battery' ? 'low' : opts.quality || 'auto';
const engine = await create(container, { quality });
contractReport.engine = 'cesium';
contractReport.missing = REQUIRED.filter(
(m) => typeof engine?.[m] !== 'function',
);
if (typeof engine?.credits !== 'function')
contractReport.notes.push(
'credits() not provided (optional, used for attribution sheet)',
);
if (contractReport.missing.length)
console.warn('[globe] contract gaps', contractReport.missing);
return guard(engine, 'cesium');
} catch (err) {
contractReport.notes.push(
`real engine unavailable: ${err?.message || err}`,
);
console.info('[globe] using offline stub:', err?.message || err);
container.replaceChildren();
}
}
const { createGlobe } = await import('./globeStub.js');
const engine = await createGlobe(container, {
quality: opts.quality || 'auto',
});
contractReport.engine = 'stub';
return guard(engine, 'stub');
}
File diff suppressed because it is too large. Load diff
+122
View File
@@ -0,0 +1,122 @@
/**
* Hand-drawn SF Symbols–style glyphs on a 24-unit grid.
* Stroke 1.8, round caps/joins; `.f` parts fill when the glyph is "active".
* No icon fonts: the repo's Material Symbols font is subset.
*/
const F = 'fill="currentColor" stroke="none"';
/** Rounded seal outline (checkmark.seal) generated from a wavy circle. */
function sealPath(cx = 12, cy = 12, r = 8.6, bumps = 12, amp = 1.05) {
const steps = bumps * 8;
let d = '';
for (let i = 0; i <= steps; i += 1) {
const a = (i / steps) * Math.PI * 2;
const rr = r + amp * Math.cos(a * bumps);
const x = cx + rr * Math.cos(a);
const y = cy + rr * Math.sin(a);
d += `${i ? 'L' : 'M'}${x.toFixed(2)} ${y.toFixed(2)}`;
}
return `${d}Z`;
}
const SEAL = sealPath();
const P = {
now: `<circle cx="12" cy="12" r="2.3" ${F}/><path d="M15.4 8.6a4.8 4.8 0 0 1 0 6.8M8.6 8.6a4.8 4.8 0 0 0 0 6.8M18.3 5.7a8.9 8.9 0 0 1 0 12.6M5.7 5.7a8.9 8.9 0 0 0 0 12.6"/>`,
globe: `<circle class="f" cx="12" cy="12" r="9.2"/><circle cx="12" cy="12" r="9.2"/><ellipse cx="12" cy="12" rx="3.9" ry="9.2"/><path d="M2.8 12h18.4M4.3 7.2c2.2 1 4.8 1.5 7.7 1.5s5.5-.5 7.7-1.5M4.3 16.8c2.2-1 4.8-1.5 7.7-1.5s5.5.5 7.7 1.5"/>`,
viewfinder: `<path d="M3.8 8.6V6.6a2.8 2.8 0 0 1 2.8-2.8h2M15.4 3.8h2a2.8 2.8 0 0 1 2.8 2.8v2M20.2 15.4v2a2.8 2.8 0 0 1-2.8 2.8h-2M8.6 20.2h-2a2.8 2.8 0 0 1-2.8-2.8v-2"/><circle class="f" cx="12" cy="12" r="3.3"/><circle cx="12" cy="12" r="3.3"/>`,
threads: `<path d="M7.4 15.4 10.9 8M13.4 7.7l3.7 5.6M8.5 17.2h6.9" stroke-dasharray="0.2 2.6"/><circle class="f" cx="12" cy="5.9" r="2.3"/><circle cx="12" cy="5.9" r="2.3"/><circle class="f" cx="6.2" cy="17.4" r="2.3"/><circle cx="6.2" cy="17.4" r="2.3"/><circle class="f" cx="18.2" cy="15.6" r="2.3"/><circle cx="18.2" cy="15.6" r="2.3"/>`,
you: `<circle class="f" cx="12" cy="12" r="9.2"/><circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="10" r="3.2"/><path d="M6.1 18.4c1.3-2.2 3.4-3.3 5.9-3.3s4.6 1.1 5.9 3.3"/>`,
search: `<circle cx="10.6" cy="10.6" r="6.4"/><path d="m15.4 15.4 4.8 4.8"/>`,
chevronLeft: `<path d="M14.6 4.8 7.4 12l7.2 7.2" stroke-width="2.2"/>`,
chevronRight: `<path d="m9.4 4.8 7.2 7.2-7.2 7.2" stroke-width="2"/>`,
chevronDown: `<path d="m5.8 9.2 6.2 6.2 6.2-6.2" stroke-width="2.1"/>`,
chevronUp: `<path d="m5.8 14.8 6.2-6.2 6.2 6.2" stroke-width="2.1"/>`,
close: `<path d="m6.6 6.6 10.8 10.8M17.4 6.6 6.6 17.4" stroke-width="2.1"/>`,
info: `<circle cx="12" cy="12" r="9.2"/><path d="M12 10.9v5.8"/><circle cx="12" cy="7.6" r="1.15" ${F}/>`,
north: `<path d="M12 3.6 16.8 18 12 15.3 7.2 18Z" ${F}/>`,
home: `<path d="M3.8 11.2 12 4.2l8.2 7"/><path d="M6.3 9.4v9.4c0 .6.4 1 1 1h3.2v-5.1h3v5.1h3.2c.6 0 1-.4 1-1V9.4"/>`,
layers: `<path class="f" d="m12 3.8 8.6 4.6-8.6 4.6-8.6-4.6Z"/><path d="m12 3.8 8.6 4.6-8.6 4.6-8.6-4.6Z"/><path d="m3.4 12.4 8.6 4.6 8.6-4.6M3.4 16.4l8.6 4.6 8.6-4.6"/>`,
sky: `<path d="M12 2.6c.9 0 1.5.8 1.5 1.7v5.1l7.4 4.6v1.9l-7.4-2.3v4.9l2 1.5v1.5L12 20.6l-3.5.9V20l2-1.5v-4.9l-7.4 2.3V14l7.4-4.6V4.3c0-.9.6-1.7 1.5-1.7Z" ${F}/>`,
sea: `<path d="M2.8 15.3h18.4l-2.4 4.2a1.6 1.6 0 0 1-1.4.8H6.6a1.6 1.6 0 0 1-1.4-.8Z" ${F}/><path d="M6.4 15.3v-4.4h11.2v4.4M9.4 10.9V7.6h5.2v3.3M12 7.6V4"/>`,
orbit: `<path d="m12 8.9 3.1 3.1-3.1 3.1L8.9 12Z" ${F}/><path d="M5.4 9.6 9.6 5.4 7 2.8 2.8 7ZM18.6 14.4l-4.2 4.2 2.6 2.6 4.2-4.2ZM10.4 10.4 7.6 7.6M13.6 13.6l2.8 2.8M15.2 5.6a3.6 3.6 0 0 1 3.2 3.2"/>`,
hazards: `<path d="M10.3 4.4a2 2 0 0 1 3.4 0l7.3 12.7a2 2 0 0 1-1.7 3H4.7a2 2 0 0 1-1.7-3Z"/><path d="M12 9.4v4.4" stroke-width="2"/><circle cx="12" cy="16.9" r="1.15" ${F}/>`,
flame: `<path d="M12 21c-3.9 0-6.5-2.6-6.5-6.2 0-2.9 1.9-4.6 3-6.3.8 1.5 1.8 2.3 2.6 2.4-.3-2.9.6-5.6 3.2-7.9.2 3 1.4 4.6 2.8 6.2 1.3 1.5 2.4 3.1 2.4 5.6 0 3.6-2.6 6.2-6.5 6.2Z"/>`,
eyes: `<path d="M2.4 12c2.3-4.4 5.7-6.6 9.6-6.6s7.3 2.2 9.6 6.6c-2.3 4.4-5.7 6.6-9.6 6.6S4.7 16.4 2.4 12Z"/><circle cx="12" cy="12" r="3.3"/><circle cx="12" cy="12" r="1.3" ${F}/>`,
ground: `<path d="M3.8 16.6v-4c0-.6.2-1.1.5-1.6L6 7.7c.4-.9 1.2-1.4 2.1-1.4h7.8c.9 0 1.7.5 2.1 1.4l1.7 3.3c.3.5.5 1 .5 1.6v4ZM6 16.6v1.8c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1.8M15 16.6v1.8c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1.8M5.4 10.9h13.2"/><circle cx="7.6" cy="13.7" r="1" ${F}/><circle cx="16.4" cy="13.7" r="1" ${F}/>`,
weather: `<path d="M7.4 19.2h9.9a3.6 3.6 0 0 0 .3-7.2 5.2 5.2 0 0 0-10 1.3 3 3 0 0 0-.2 5.9Z"/><path d="M5.6 11.3a3.3 3.3 0 0 1 5.6-4M8.4 2.9v1.4M3 8.4h1.4M4.6 4.6l1 1"/>`,
infra: `<path d="M13.2 2.8 5.6 13.3h5.8l-1 7.9 7.6-10.5h-5.8Z"/>`,
star: `<path class="f" d="m12 3.6 2.5 5.3 5.8.7-4.3 4 1.1 5.8L12 16.6l-5.1 2.8L8 13.6l-4.3-4 5.8-.7Z"/><path d="m12 3.6 2.5 5.3 5.8.7-4.3 4 1.1 5.8L12 16.6l-5.1 2.8L8 13.6l-4.3-4 5.8-.7Z"/>`,
starFill: `<path d="m12 3.6 2.5 5.3 5.8.7-4.3 4 1.1 5.8L12 16.6l-5.1 2.8L8 13.6l-4.3-4 5.8-.7Z" fill="currentColor"/>`,
ride: `<circle cx="12" cy="12" r="9.2"/><circle cx="12" cy="12" r="2.4"/><path d="M3.2 10.6c2.8-.9 5.7-1.3 8.8-1.3s6 .4 8.8 1.3M10.2 14 7.4 20M13.8 14l2.8 6"/>`,
video: `<rect x="2.4" y="6.8" width="13.4" height="10.4" rx="2.4"/><path d="m15.8 10.6 5.4-3.1v9l-5.4-3.1"/>`,
share: `<path d="M12 3.4v11.2M8.2 7.2 12 3.4l3.8 3.8M8.6 10H7a2 2 0 0 0-2 2v6.6c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2V12a2 2 0 0 0-2-2h-1.6"/>`,
seal: `<path class="f" d="${SEAL}"/><path d="${SEAL}"/><path d="m8.3 12.2 2.5 2.5 4.9-5" stroke-width="2"/>`,
sealFill: `<path d="${SEAL}" fill="currentColor"/><path d="m8.3 12.2 2.5 2.5 4.9-5" stroke="var(--seal-ink, #2a1a00)" stroke-width="2.1"/>`,
play: `<path d="M8 5.2v13.6l10.6-6.8Z" fill="currentColor"/>`,
pause: `<path d="M8 5.5v13M16 5.5v13" stroke-width="3"/>`,
sparkles: `<path d="M11 3.2c.6 3.7 2.4 5.5 6.1 6.1-3.7.6-5.5 2.4-6.1 6.1-.6-3.7-2.4-5.5-6.1-6.1 3.7-.6 5.5-2.4 6.1-6.1Z" fill="currentColor"/><path d="M18 14.4c.3 1.8 1.1 2.6 2.9 2.9-1.8.3-2.6 1.1-2.9 2.9-.3-1.8-1.1-2.6-2.9-2.9 1.8-.3 2.6-1.1 2.9-2.9Z" fill="currentColor"/>`,
agree: `<circle cx="12" cy="12" r="9.2"/><path d="m8 12.3 2.8 2.8 5.2-5.4"/>`,
dispute: `<circle cx="12" cy="12" r="9.2"/><path d="M8.2 12h7.6"/>`,
sliders: `<path d="M4 6.5h9M17 6.5h3M4 12h3M11 12h9M4 17.5h11M19 17.5h1"/><circle cx="15" cy="6.5" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="17.5" r="2"/>`,
refresh: `<path d="M19.4 12.6a7.5 7.5 0 1 1-2.1-6.1"/><path d="M19.6 4.4v4.3h-4.3"/>`,
lock: `<rect x="4.6" y="10.4" width="14.8" height="10.2" rx="2.2"/><path d="M8 10.4V7.6a4 4 0 0 1 8 0v2.8"/>`,
key: `<circle cx="8" cy="15.5" r="4.2"/><path d="m11 12.5 8.6-8.6M16.4 7.1l2.4 2.4M14.2 9.3l1.8 1.8"/>`,
location: `<path d="M20.2 3.8 3.8 11l7.2 2 2 7.2Z" fill="currentColor" stroke-linejoin="round"/>`,
camera: `<path d="M4 8h3l1.5-2.3h7L17 8h3c.8 0 1.5.7 1.5 1.5V18c0 .8-.7 1.5-1.5 1.5H4c-.8 0-1.5-.7-1.5-1.5V9.5C2.5 8.7 3.2 8 4 8Z"/><circle cx="12" cy="13.4" r="3.6"/>`,
cube: `<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/>`,
download: `<path d="M12 3.4v11.2M8.2 10.8 12 14.6l3.8-3.8M5 15.5V18c0 1.1.9 2 2 2h10a2 2 0 0 0 2-2v-2.5"/>`,
lens: `<circle cx="12" cy="8.6" r="5"/><circle cx="8.6" cy="14.6" r="5"/><circle cx="15.4" cy="14.6" r="5"/>`,
checkmark: `<path d="m5 12.6 4.4 4.4L19 7.4" stroke-width="2.2"/>`,
xmark: `<path d="m7 7 10 10M17 7 7 17" stroke-width="2.2"/>`,
minus: `<path d="M6 12h12" stroke-width="2.2"/>`,
plus: `<path d="M12 5.5v13M5.5 12h13" stroke-width="2"/>`,
ellipsis: `<circle cx="6" cy="12" r="1.6" ${F}/><circle cx="12" cy="12" r="1.6" ${F}/><circle cx="18" cy="12" r="1.6" ${F}/>`,
clock: `<circle cx="12" cy="12" r="9.2"/><path d="M12 7v5.3l3.4 2.1"/>`,
pin: `<path d="M12 21s-6.4-5.6-6.4-11a6.4 6.4 0 0 1 12.8 0c0 5.4-6.4 11-6.4 11Z"/><circle cx="12" cy="10" r="2.3"/>`,
sidebar: `<rect x="3" y="4.5" width="18" height="15" rx="3"/><path d="M9.5 4.5v15M5.5 8.5h1.6M5.5 11.5h1.6"/>`,
waveform: `<path d="M3 12h2M6.5 8v8M10 4.5v15M13.5 9v6M17 6.5v11M20.5 10.5v3"/>`,
arrowUpRight: `<path d="M7 17 17 7M8.5 7H17v8.5"/>`,
scope: `<circle cx="12" cy="12" r="7.6"/><path d="M12 2.4v4M12 17.6v4M2.4 12h4M17.6 12h4"/><circle cx="12" cy="12" r="1.2" ${F}/>`,
hand: `<path d="M8.4 12.6V5.8a1.4 1.4 0 0 1 2.8 0v5.4M11.2 10.8V4.4a1.4 1.4 0 0 1 2.8 0v6.4M14 10.8V5.6a1.4 1.4 0 0 1 2.8 0v7.6M8.4 12.6l-1.3-1.7a1.5 1.5 0 0 0-2.3 1.9l3.4 5.3c1.2 1.8 3 2.9 5.2 2.9h.4c2.6 0 4.9-2.1 4.9-4.8V8.5a1.4 1.4 0 0 0-2.8 0"/>`,
};
/**
* Inline SVG markup for a glyph.
* @param {string} name
* @param {{ label?: string, cls?: string }} [opts]
*/
export function icon(name, { label = '', cls = '' } = {}) {
const body = P[name] || P.ellipsis;
const a11y = label
? `role="img" aria-label="${label}"`
: 'aria-hidden="true" focusable="false"';
return `<svg class="i ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" ${a11y}>${body}</svg>`;
}
export const FAMILY_ICON = {
sky: 'sky',
sea: 'sea',
orbit: 'orbit',
hazards: 'hazards',
eyes: 'eyes',
ground: 'ground',
weather: 'weather',
infra: 'infra',
};
export const FAMILIES = [
{ id: 'sky', name: 'Sky', color: '#7CC4FF' },
{ id: 'sea', name: 'Sea', color: '#38E1C6' },
{ id: 'orbit', name: 'Orbit', color: '#C9A7FF' },
{ id: 'hazards', name: 'Hazards', color: '#FF6A3D' },
{ id: 'eyes', name: 'Eyes', color: '#FFD166' },
{ id: 'ground', name: 'Ground', color: '#9BE15D' },
{ id: 'weather', name: 'Weather', color: '#8AB4F8' },
{ id: 'infra', name: 'Infra', color: '#B8C0CC' },
];
export const FAMILY_COLOR = Object.fromEntries(
FAMILIES.map((f) => [f.id, f.color]),
);
+114
View File
@@ -0,0 +1,114 @@
/** Verify inspector: recompute and show every check, Tufte-clean. */
import { icon } from './icons.js';
import { esc, sparkPath, esriThumb, haptic } from './dom.js';
import { createSheet } from './sheet.js';
import { verify } from '../capture/provenance.js';
import { get } from '../capture/db.js';
const GLYPH = {
pass: 'checkmark',
warn: 'hazards',
fail: 'xmark',
na: 'minus',
};
export function createInspector() {
const sheet = createSheet({
label: 'Verify capture',
detents: ['large'],
modal: true,
dismissible: true,
closeButton: true,
className: 'opaque inspector',
heights: { large: (H) => Math.round(H * 0.94) },
});
let urls = [];
function small(label, values, unit) {
const v = values.filter(Number.isFinite);
if (v.length < 3)
return `<div class="sm"><span>${label}</span><em>—</em></div>`;
const lo = Math.min(...v);
const hi = Math.max(...v);
return `<div class="sm"><span>${label}</span><svg viewBox="0 0 120 26" preserveAspectRatio="none"><path d="${sparkPath(v, 120, 26, 2)}"/></svg><em class="num">${lo.toFixed(1)}–${hi.toFixed(1)}${unit}</em></div>`;
}
async function open(bundle) {
urls.forEach((u) => URL.revokeObjectURL(u));
urls = [];
const rec = await get('media', bundle.id).catch(() => null);
const blob = rec?.blob || null;
const url = blob ? URL.createObjectURL(blob) : '';
if (url) urls.push(url);
const isVideo = /video/.test(bundle.media.type);
const when = new Date(bundle.sealedAt);
sheet.head.innerHTML = `<div class="insp-head">
<div class="insp-thumb">${url ? (isVideo ? `<video src="${url}" muted playsinline></video>` : `<img src="${url}" alt="Capture ${bundle.seq}"/>`) : ''}<span>${icon('sealFill')}</span></div>
<div><h2 class="sheet-title">Capture #${bundle.seq}</h2><div class="sheet-sub">${esc(when.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' }))}${bundle.context?.testFrame ? ' · <span class="badge-example">Test frame</span>' : ''}</div></div>
</div>`;
sheet.body.innerHTML = `<div class="insp-banner running"><span class="spinner"></span>Recomputing hashes and checking the signature…</div>`;
sheet.open('large');
const t0 = performance.now();
const res = await verify(bundle, blob);
const ms = Math.round(performance.now() - t0);
const fails = res.checks.filter((c) => c.status === 'fail').length;
const warns = res.checks.filter((c) => c.status === 'warn').length;
haptic(fails ? 'warn' : 'success');
const imu = bundle.session?.imu || [];
const gps = bundle.session?.gps || [];
const f = gps[gps.length - 1];
sheet.body.innerHTML = `
<div class="insp-banner ${fails ? 'fail' : warns ? 'warn' : 'pass'}">${icon(fails ? 'xmark' : 'seal')}<div><b>${fails ? `${fails} check${fails > 1 ? 's' : ''} failed` : warns ? `Verified with ${warns} caveat${warns > 1 ? 's' : ''}` : 'All checks passed'}</b><span>Recomputed on this device in ${ms} ms. None of this proves truth; it raises the cost of lying.</span></div></div>
<ol class="checks">${res.checks
.map(
(c) =>
`<li class="check ${c.status}"><span class="ck">${icon(GLYPH[c.status] || 'minus')}</span><div><b>${esc(c.label)}</b><span>${esc(c.detail)}</span></div></li>`,
)
.join('')}</ol>
<div class="block"><div class="block-h">Motion during capture<small>${imu.length} samples</small></div>
<div class="smalls">${small(
'α',
imu.map((s) => s.α),
'°',
)}${small(
'β',
imu.map((s) => s.β),
'°',
)}${small(
'γ',
imu.map((s) => s.γ),
'°',
)}${small(
'|a|',
imu.map((s) => Math.hypot(s.ax ?? NaN, s.ay ?? NaN, s.az ?? NaN)),
'',
)}</div></div>
${f ? `<div class="block"><div class="block-h">Where it was sealed<small>±${Math.round(f.acc)} m</small></div><div class="insp-map"><img src="${esc(esriThumb(f.lon, f.lat, 0.012, 600, 300))}" alt="" loading="lazy"/><i></i><span class="mono">${f.lat.toFixed(5)}, ${f.lon.toFixed(5)}</span></div></div>` : ''}
<div class="block"><div class="block-h">Bundle</div><dl class="facts mono-facts">
<dt>hash</dt><dd class="mono">${esc(bundle.hash)}</dd>
<dt>media</dt><dd class="mono">${esc(bundle.media.sha256)}</dd>
<dt>prev</dt><dd class="mono">${esc(bundle.prev || 'null (genesis)')}</dd>
<dt>key</dt><dd class="mono">${esc(bundle.keyId)} · P-256</dd>
<dt>sig</dt><dd class="mono">${esc(bundle.signature.slice(0, 48))}…</dd>
<dt>device</dt><dd>${esc(bundle.context?.device || '')} · ${esc(bundle.context?.timezone || '')}</dd></dl></div>
<div class="insp-actions"><button class="pill-btn plain" type="button" data-insp="export">${icon('download')}Export bundle</button><button class="pill-btn plain" type="button" data-insp="reverify">${icon('refresh')}Verify again</button></div>
<p class="footnote" style="padding:12px 0 0">In a native build the key lives in the Secure Enclave / StrongBox with App Attest / Play Integrity, and the bundle is written as a C2PA manifest.</p>`;
sheet.body.querySelector('[data-insp="export"]').onclick = () => {
const a = document.createElement('a');
const u = URL.createObjectURL(
new Blob([JSON.stringify(bundle, null, 2)], {
type: 'application/json',
}),
);
a.href = u;
a.download = `${bundle.id}.bundle.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(u), 2000);
};
sheet.body.querySelector('[data-insp="reverify"]').onclick = () =>
open(bundle);
}
return { open, sheet };
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Narrative player: flies the globe through a thread's stops while the time
* ribbon scrubs the thread's own (schematic) clock.
*/
import { icon } from './icons.js';
import { h, haptic, reducedMotion } from './dom.js';
export function createNarrative({ app, chrome, globe, ribbon, getSituation }) {
const hud =
h(`<div class="narr-hud glass-strong" role="region" aria-label="Narrative">
<div class="narr-top"><button class="narr-play" type="button" aria-label="Pause">${icon('pause')}</button>
<div class="narr-titles"><div class="narr-step"></div><div class="narr-name"></div></div>
<button class="icon-btn pick-close" type="button" aria-label="Stop narrative">${icon('close')}</button></div>
<div class="narr-prog"></div>
<div class="narr-cav"></div>
</div>`);
chrome.appendChild(hud);
const playBtn = hud.querySelector('.narr-play');
let run = null;
function fmt(base) {
return (ms) => {
const s = Math.max(0, Math.round((ms - base) / 1000));
return `T+${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};
}
function sleep(ms, r) {
return new Promise((resolve) => {
const t0 = performance.now();
const tick = () => {
if (r.stopped) return resolve();
if (r.paused) {
r.resumeAt = () => tick();
return;
}
if (performance.now() - t0 >= ms) return resolve();
requestAnimationFrame(tick);
};
tick();
});
}
async function play(thread) {
stop();
const r = { stopped: false, paused: false, thread };
run = r;
const base = Date.parse(
`${thread.observedDate || new Date().toISOString().slice(0, 10)}T00:00:00Z`,
);
const dur = (thread.elapsedSeconds || 600) * 1000;
app.classList.add('is-world', 'is-narrative');
globe.resume();
ribbon.setWindow({
start: base,
end: base + dur,
label: 'schematic clock',
format: fmt(base),
});
hud.querySelector('.narr-prog').innerHTML = thread.steps
.map(() => '<i></i>')
.join('');
hud.querySelector('.narr-cav').textContent = thread.label || '';
const sit = getSituation(thread.id);
if (sit) globe.focus(sit);
haptic('light');
let t = base;
for (let i = 0; i < thread.steps.length && !r.stopped; i += 1) {
const s = thread.steps[i];
hud.querySelector('.narr-step').textContent =
`Stop ${i + 1} of ${thread.steps.length} · ${s.phase || ''}`;
hud.querySelector('.narr-name').textContent = s.title;
hud
.querySelectorAll('.narr-prog i')
.forEach((el, k) => el.classList.toggle('done', k <= i));
const target =
base + (s.progress ?? i / Math.max(1, thread.steps.length - 1)) * dur;
const flight = globe.flyTo({
lon: s.lon,
lat: s.lat,
height: (s.elev || 1500) + 9000,
heading: 344,
pitch: -38,
duration: reducedMotion() ? 0 : 2.2,
});
// Scrub the ribbon toward this stop's schematic time.
const from = t;
const t0 = performance.now();
const scrub = () => {
if (r.stopped) return;
const k = Math.min(1, (performance.now() - t0) / 2000);
t = from + (target - from) * (1 - (1 - k) ** 3);
ribbon.seek(t);
globe.setTime(new Date(t));
if (k < 1) requestAnimationFrame(scrub);
};
scrub();
await Promise.race([flight, sleep(2600, r)]);
await sleep(reducedMotion() ? 1200 : 1600, r);
}
if (!r.stopped) {
hud.querySelector('.narr-step').textContent = 'End of narrative';
playBtn.innerHTML = icon('play');
playBtn.setAttribute('aria-label', 'Replay');
r.done = true;
}
}
function stop() {
if (!run) return;
run.stopped = true;
run = null;
app.classList.remove('is-narrative');
if (!app.classList.contains('keep-world')) app.classList.remove('is-world');
ribbon.setWindow(null);
globe.setTime(null);
playBtn.innerHTML = icon('pause');
}
playBtn.addEventListener('click', () => {
if (!run) return;
if (run.done) return play(run.thread);
run.paused = !run.paused;
playBtn.innerHTML = icon(run.paused ? 'play' : 'pause');
playBtn.setAttribute('aria-label', run.paused ? 'Play' : 'Pause');
if (!run.paused && run.resumeAt) {
const f = run.resumeAt;
run.resumeAt = null;
f();
}
});
hud.querySelector('.pick-close').addEventListener('click', stop);
return {
play,
stop,
get active() {
return !!run;
},
};
}
+139
View File
@@ -0,0 +1,139 @@
/** Search: situations, layers and places (OpenStreetMap Nominatim via /api/geocode). */
import { icon, FAMILY_ICON } from './icons.js';
import { $, esc, haptic } from './dom.js';
import { createSheet } from './sheet.js';
const PLACES = [
{ name: 'Kathmandu', sub: 'Nepal', lon: 85.324, lat: 27.7172 },
{ name: 'Strait of Hormuz', sub: 'Persian Gulf', lon: 56.4, lat: 26.5 },
{ name: 'Panama Canal', sub: 'Panama', lon: -79.68, lat: 9.08 },
{ name: 'Heathrow', sub: 'London, UK', lon: -0.4543, lat: 51.47 },
{ name: 'Cape Canaveral', sub: 'Florida, US', lon: -80.6, lat: 28.47 },
{ name: 'Mount Etna', sub: 'Sicily, Italy', lon: 14.995, lat: 37.751 },
];
export function createSearch({
getSituations,
globe,
onOpenSituation,
onPlace,
onLayer,
}) {
const sheet = createSheet({
label: 'Search',
detents: ['large'],
modal: true,
dismissible: true,
className: 'search-sheet',
heights: { large: (H) => Math.round(H * 0.92) },
});
sheet.head.innerHTML = `<form class="search-row" role="search"><label class="search-field">${icon('search')}<input type="search" placeholder="Situations, lenses, places" aria-label="Search" enterkeyhint="search" autofocus data-no-drag/></label><button class="pill-btn plain search-cancel" type="button">Cancel</button></form>`;
const input = $('input', sheet.head);
let timer = 0;
let geo = [];
function match(q, ...fields) {
const s = fields.join(' ').toLowerCase();
return q
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((w) => s.includes(w));
}
async function render() {
const q = input.value.trim();
const sits = await getSituations();
const s = q
? sits.filter((x) => match(q, x.title, x.place, x.kind, x.family))
: sits.slice(0, 5);
const layers = q
? globe
.layers()
.filter((l) => match(q, l.name, l.family))
.slice(0, 8)
: [];
const places = q
? PLACES.filter((p) => match(q, p.name, p.sub))
: PLACES.slice(0, 4);
sheet.body.innerHTML = `
<div class="results-h">${q ? 'Situations' : 'Live now'}</div>
${s.map((x) => `<button class="result" type="button" data-sit="${esc(x.id)}"><img class="result-thumb" src="${esc(x.thumbnail)}" alt="" loading="lazy"/><span><div class="result-title">${esc(x.title)}</div><div class="result-sub">${esc(x.place)}</div></span></button>`).join('') || '<p class="result-sub">No situations match.</p>'}
${layers.length ? `<div class="results-h">Lenses</div>${layers.map((l) => `<button class="result" type="button" data-layer="${esc(l.id)}" data-family="${esc(l.family)}"><span class="result-icon">${icon(FAMILY_ICON[l.family] || 'layers')}</span><span><div class="result-title">${esc(l.name)}</div><div class="result-sub">${esc(l.family)} · ${l.on ? 'on' : 'off'}${l.count != null ? ` · ${l.count}` : ''}</div></span></button>`).join('')}` : ''}
<div class="results-h">Places</div>
${[...places, ...geo].map((p, i) => `<button class="result" type="button" data-place="${i}"><span class="result-icon">${icon('pin')}</span><span><div class="result-title">${esc(p.name)}</div><div class="result-sub">${esc(p.sub || '')}</div></span></button>`).join('')}
${q.length >= 3 ? '<p class="result-sub" style="padding:10px 4px">Press search to look up places · © OpenStreetMap contributors</p>' : ''}`;
sheet.body._places = [...places, ...geo];
}
async function geocode() {
const q = input.value.trim();
if (q.length < 3) return;
try {
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
if (!r.ok) throw new Error(String(r.status));
const j = await r.json();
geo = (j.results || [])
.slice(0, 5)
.map((x) => ({
name: x.formatted_address?.split(',')[0] || x.name || q,
sub: x.formatted_address || '',
lat: Number(x.geometry?.location?.lat ?? x.lat),
lon: Number(x.geometry?.location?.lng ?? x.lon),
}))
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon));
} catch {
geo = [];
}
render();
}
input.addEventListener('input', () => {
geo = [];
clearTimeout(timer);
timer = setTimeout(render, 80);
});
sheet.head.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
geocode();
});
sheet.head
.querySelector('.search-cancel')
.addEventListener('click', () => sheet.close());
sheet.body.addEventListener('click', async (e) => {
const sit = e.target.closest('[data-sit]');
if (sit) {
const s = (await getSituations()).find(
(x) => String(x.id) === sit.dataset.sit,
);
sheet.close();
if (s) setTimeout(() => onOpenSituation(s), 200);
return;
}
const l = e.target.closest('[data-layer]');
if (l) {
haptic('tick');
sheet.close();
onLayer(l.dataset.layer);
return;
}
const p = e.target.closest('[data-place]');
if (p) {
const place = sheet.body._places[Number(p.dataset.place)];
sheet.close();
if (place) onPlace(place);
}
});
return {
open() {
input.value = '';
geo = [];
render();
sheet.open('large');
setTimeout(() => input.focus({ preventScroll: true }), 120);
},
};
}
+292
View File
@@ -0,0 +1,292 @@
/**
* Bottom sheet with detents (peek / medium / large), velocity-aware drag,
* rubber-banding and nested scroll hand-off, after UISheetPresentationController.
*/
import { h, clamp, emitter, haptic, reducedMotion } from './dom.js';
import { icon } from './icons.js';
const DEFAULT_DETENTS = {
peek: () => 108,
medium: (H) => Math.round(H * 0.46),
large: (H) => Math.round(H * 0.92),
};
/**
* @param {{
* host?: HTMLElement, label: string, detents?: string[], initial?: string,
* modal?: boolean, dismissible?: boolean, className?: string,
* heights?: Record<string,(H:number)=>number>, closeButton?: boolean,
* }} o
*/
export function createSheet(o) {
const ev = emitter();
const detents = o.detents || ['peek', 'medium', 'large'];
const heights = { ...DEFAULT_DETENTS, ...(o.heights || {}) };
const host = o.host || document.getElementById('sheets');
const root = h(`<div class="sheet-layer ${o.modal ? 'is-modal' : ''}" hidden>
${o.modal ? '<div class="sheet-scrim" aria-hidden="true"></div>' : ''}
<section class="sheet ${o.className || ''}" role="dialog" aria-modal="${o.modal ? 'true' : 'false'}" aria-label="${o.label}">
<button class="sheet-grabber" type="button" aria-label="${o.label}: resize"><span></span></button>
${o.closeButton ? `<button class="sheet-close icon-btn" type="button" aria-label="Close">${icon('close')}</button>` : ''}
<div class="sheet-head"></div>
<div class="sheet-body"></div>
</section>
</div>`);
host.appendChild(root);
const sheet = root.querySelector('.sheet');
const body = root.querySelector('.sheet-body');
const head = root.querySelector('.sheet-head');
const scrim = root.querySelector('.sheet-scrim');
let H = 0;
let current = null;
let y = 0; // translateY from the top of the large position
let isOpen = false;
const hostHeight = () => host.clientHeight || window.innerHeight;
const maxH = () => heights[detents[detents.length - 1]](H);
const offsetFor = (name) => maxH() - heights[name](H);
function measure() {
H = hostHeight();
sheet.style.height = `${maxH()}px`;
}
function apply(animate, spring = true) {
sheet.style.transition = animate
? `transform ${reducedMotion() ? 160 : 520}ms ${spring ? 'var(--spring)' : 'var(--ease-out)'}`
: 'none';
sheet.style.transform = `translate3d(0, ${y}px, 0)`;
if (scrim) {
const lowest = offsetFor(detents[0]);
const k = clamp(1 - y / Math.max(1, lowest + 120), 0, 1);
scrim.style.transition = animate ? 'opacity 300ms linear' : 'none';
scrim.style.opacity = String(isOpen ? 0.5 * k + 0.12 : 0);
}
}
function setDetent(name, { animate = true, silent = false } = {}) {
if (!detents.includes(name)) return;
const changed = name !== current;
current = name;
y = offsetFor(name);
root.dataset.detent = name;
sheet.dataset.detent = name;
body.classList.toggle('scrollable', name === detents[detents.length - 1]);
if (name !== detents[detents.length - 1]) body.scrollTop = 0;
apply(animate);
if (changed && !silent) {
haptic('tick');
ev.emit('detent', name);
}
}
function open(name = o.initial || detents[Math.min(1, detents.length - 1)]) {
measure();
root.hidden = false;
isOpen = true;
if (!current || root.dataset.closing) {
y = maxH() + 40;
apply(false);
void sheet.offsetHeight;
}
delete root.dataset.closing;
setDetent(name, { silent: false });
ev.emit('open', name);
if (o.modal)
setTimeout(
() =>
(
sheet.querySelector('[autofocus]') ||
root.querySelector('.sheet-grabber')
)?.focus({ preventScroll: true }),
60,
);
}
function close() {
if (!isOpen) return;
isOpen = false;
root.dataset.closing = '1';
y = maxH() + 40;
apply(true, false);
current = null;
ev.emit('close');
setTimeout(() => {
if (!isOpen) root.hidden = true;
}, 420);
}
/* ---------- drag ---------- */
let drag = null;
const rubber = (over, dim) => (1 - 1 / ((over * 0.55) / dim + 1)) * dim;
function bounded(raw) {
const top = 0;
const bottom = offsetFor(detents[0]);
if (raw < top) return top - rubber(top - raw, H);
if (raw > bottom && !o.dismissible) return bottom + rubber(raw - bottom, H);
return raw;
}
function start(clientY) {
drag = {
y0: clientY,
start: y,
samples: [{ t: performance.now(), y: clientY }],
};
sheet.style.transition = 'none';
root.classList.add('dragging');
}
function move(clientY) {
if (!drag) return;
const raw = drag.start + (clientY - drag.y0);
y = bounded(raw);
drag.samples.push({ t: performance.now(), y: clientY });
if (drag.samples.length > 6) drag.samples.shift();
apply(false);
}
function end() {
if (!drag) return;
root.classList.remove('dragging');
const s = drag.samples;
const a = s[0];
const b = s[s.length - 1];
const v = b.t - a.t > 0 ? ((b.y - a.y) / (b.t - a.t)) * 1000 : 0; // px/s
drag = null;
const projected = y + v * 0.2;
const lowest = offsetFor(detents[0]);
if (
o.dismissible &&
(projected > lowest + 90 || (y > lowest + 40 && v > 300))
) {
close();
return;
}
let best = detents[0];
let bd = Infinity;
for (const d of detents) {
const dd = Math.abs(offsetFor(d) - projected);
if (dd < bd) {
bd = dd;
best = d;
}
}
// A decisive flick always moves at least one detent.
const idx = detents.indexOf(current);
if (best === current && Math.abs(v) > 700) {
const next = clamp(idx + (v < 0 ? 1 : -1), 0, detents.length - 1);
best = detents[next];
}
setDetent(best);
if (best === current) apply(true);
}
// Touch: hand off between the sheet and its scrolling body.
let touchMode = null; // 'sheet' | 'scroll'
let ty0 = 0;
sheet.addEventListener(
'touchstart',
(e) => {
if (e.touches.length !== 1) return;
if (e.target.closest('input, textarea, [data-no-drag]')) {
touchMode = 'scroll';
return;
}
ty0 = e.touches[0].clientY;
touchMode = null;
},
{ passive: true },
);
sheet.addEventListener(
'touchmove',
(e) => {
if (e.touches.length !== 1 || touchMode === 'scroll') return;
const cy = e.touches[0].clientY;
if (!touchMode) {
const dy = cy - ty0;
if (Math.abs(dy) < 4) return;
const inBody = body.contains(e.target);
const atLarge = current === detents[detents.length - 1];
if (inBody && atLarge && (dy < 0 || body.scrollTop > 0)) {
touchMode = 'scroll';
return;
}
if (e.target.closest('[data-hscroll]') && Math.abs(dy) < 8) return;
touchMode = 'sheet';
start(ty0);
}
if (touchMode === 'sheet') {
e.preventDefault();
move(cy);
}
},
{ passive: false },
);
const tend = () => {
if (touchMode === 'sheet') end();
touchMode = null;
};
sheet.addEventListener('touchend', tend, { passive: true });
sheet.addEventListener('touchcancel', tend, { passive: true });
// Mouse / pen: drag from the grabber or header area.
sheet.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch' || e.button !== 0) return;
if (!e.target.closest('.sheet-grabber, .sheet-head, .sheet-drag')) return;
e.preventDefault();
start(e.clientY);
const mv = (ev2) => move(ev2.clientY);
const up = () => {
window.removeEventListener('pointermove', mv);
window.removeEventListener('pointerup', up);
end();
};
window.addEventListener('pointermove', mv, { passive: true });
window.addEventListener('pointerup', up);
});
root.querySelector('.sheet-grabber').addEventListener('click', () => {
const i = detents.indexOf(current);
setDetent(detents[(i + 1) % detents.length]);
});
root.querySelector('.sheet-close')?.addEventListener('click', () => close());
scrim?.addEventListener('click', () => close());
root.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && (o.modal || o.dismissible)) {
e.stopPropagation();
close();
}
});
new ResizeObserver(() => {
if (!isOpen) return;
measure();
if (current) {
y = offsetFor(current);
apply(false);
}
}).observe(host);
return {
root,
sheet,
head,
body,
open,
close,
setDetent,
get detent() {
return current;
},
get isOpen() {
return isOpen;
},
on: ev.on,
off: ev.off,
/** Visible height of the sheet at its current position. */
visibleHeight: () => Math.max(0, maxH() - y),
};
}
+153
View File
@@ -0,0 +1,153 @@
/**
* iOS 26-style floating glass tab bar with a tab-bar accessory
* ("now playing" for the planet) and a search button in the same row.
* Minimises to a compact pill while the feed scrolls down.
*/
import { icon, FAMILY_ICON } from './icons.js';
import { $, $$, esc, ago, fmtNum, haptic } from './dom.js';
export const TABS = [
{ id: 'now', label: 'Now', icon: 'now' },
{ id: 'globe', label: 'Globe', icon: 'globe' },
{ id: 'capture', label: 'Capture', icon: 'viewfinder' },
{ id: 'threads', label: 'Threads', icon: 'threads' },
{ id: 'you', label: 'You', icon: 'you' },
];
export function createTabBar(nav, { onSelect, onSearch, onAccessory }) {
nav.innerHTML = `
<div class="tb-full">
<button class="tb-accessory glass" type="button" hidden aria-label="Followed situation"></button>
<div class="tb-tabs glass" role="tablist" aria-label="Sections">
<span class="tb-lens" aria-hidden="true"></span>
${TABS.map(
(
t,
) => `<button class="tb-tab" role="tab" type="button" id="tab-${t.id}" data-tab="${t.id}" aria-selected="false" aria-controls="view-${t.id}">
${icon(t.icon)}<span class="tb-label">${t.label}</span></button>`,
).join('')}
</div>
<button class="tb-search glass" type="button" aria-label="Search">${icon('search')}</button>
</div>
<div class="tb-mini" aria-hidden="true">
<button class="tb-mini-tab glass" type="button" tabindex="-1" aria-label="Show tabs"></button>
<button class="tb-mini-acc glass liquid" type="button" tabindex="-1" hidden></button>
<button class="tb-search glass" type="button" tabindex="-1" aria-label="Search">${icon('search')}</button>
</div>`;
const lens = $('.tb-lens', nav);
const tabsEl = $('.tb-tabs', nav);
const acc = $('.tb-accessory', nav);
const miniTab = $('.tb-mini-tab', nav);
const miniAcc = $('.tb-mini-acc', nav);
let current = 'now';
let minimized = false;
let accSituation = null;
function placeLens() {
const idx = TABS.findIndex((t) => t.id === current);
const w = (tabsEl.clientWidth - 8) / TABS.length;
lens.style.width = `${w}px`;
lens.style.transform = `translateX(${idx * w}px)`;
}
function select(id, { silent = false } = {}) {
current = id;
$$('.tb-tab', nav).forEach((b) =>
b.setAttribute('aria-selected', String(b.dataset.tab === id)),
);
const t = TABS.find((x) => x.id === id);
miniTab.innerHTML = icon(t.icon);
placeLens();
if (!silent) setMinimized(false);
}
function setMinimized(v) {
if (v === minimized) return;
minimized = v;
nav.classList.toggle('min', v);
$('.tb-full', nav).toggleAttribute('inert', v);
$('.tb-mini', nav).setAttribute('aria-hidden', String(!v));
$$('.tb-mini button', nav).forEach((b) => (b.tabIndex = v ? 0 : -1));
}
function accessoryHtml(s) {
const m = s.metrics?.[0];
return `<span class="acc-glyph" data-family="${esc(s.family)}">${icon(FAMILY_ICON[s.family] || 'now')}</span>
<span class="acc-text"><span class="acc-title">${esc(s.title)}</span>
<span class="acc-meta">${esc(s.place || '')} · ${esc(ago(s.updatedAt))}</span></span>
${m ? `<span class="acc-metric num" data-family="${esc(s.family)}">${esc(fmtNum(m.value))}${m.unit ? ` <small>${esc(m.unit)}</small>` : ''}</span>` : ''}`;
}
function setAccessory(s) {
accSituation = s;
acc.hidden = !s;
miniAcc.hidden = !s;
nav.classList.toggle('has-accessory', !!s);
document.documentElement.classList.toggle('has-accessory', !!s);
if (!s) return;
acc.dataset.family = s.family;
miniAcc.dataset.family = s.family;
acc.innerHTML = accessoryHtml(s);
miniAcc.innerHTML = accessoryHtml(s);
acc.setAttribute('aria-label', `Following ${s.title}`);
}
nav.addEventListener('click', (e) => {
const tab = e.target.closest('.tb-tab');
if (tab) {
if (tab.dataset.tab !== current) haptic('tick');
onSelect(tab.dataset.tab, { reselect: tab.dataset.tab === current });
return;
}
if (e.target.closest('.tb-search')) return onSearch();
if (e.target.closest('.tb-accessory, .tb-mini-acc'))
return accSituation && onAccessory(accSituation);
if (e.target.closest('.tb-mini-tab')) setMinimized(false);
});
// Arrow-key roving focus across the tablist (ARIA tabs pattern).
tabsEl.addEventListener('keydown', (e) => {
if (!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
const tabs = $$('.tb-tab', nav);
const i = tabs.indexOf(document.activeElement);
const n =
tabs[(i + (e.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length];
n.focus();
});
new ResizeObserver(placeLens).observe(tabsEl);
/** Minimise while scrolling down; restore on scroll up or when scrolling stops. */
function bindScroll(scroller) {
let lastY = scroller.scrollTop;
let acc = 0;
let idle = 0;
scroller.addEventListener(
'scroll',
() => {
const y = scroller.scrollTop;
const dy = y - lastY;
lastY = y;
acc = Math.sign(dy) === Math.sign(acc) ? acc + dy : dy;
if (y < 40) setMinimized(false);
else if (acc > 28) setMinimized(true);
else if (acc < -18) setMinimized(false);
clearTimeout(idle);
idle = setTimeout(() => setMinimized(false), 1400);
},
{ passive: true },
);
}
return {
select,
setMinimized,
setAccessory,
bindScroll,
get current() {
return current;
},
};
}
+769
View File
@@ -0,0 +1,769 @@
/**
* Threads: reconstructions people build together, always in three strata —
* Observations (immutable, sourced, hashed) → Hypotheses (person / AI,
* confidence, citations, agree / dispute) → Narrative (a camera path through
* space and time). Every change lands in a hash-chained ledger.
*/
import { icon } from './icons.js';
import {
$,
$$,
esc,
ago,
haptic,
toast,
fmtCoord,
reducedMotion,
} from './dom.js';
import { canonical, sha256Hex } from '../capture/provenance.js';
import { loadEventPack } from './world.js';
const LOCAL_KEY = 'gev.mobile.threads.local.v1';
const DRAFTS_KEY = 'gev.mobile.threads.drafts.v1';
function readLocal(key, fallback) {
try {
return JSON.parse(localStorage.getItem(key) || 'null') ?? fallback;
} catch {
return fallback;
}
}
function writeLocal(key, v) {
try {
localStorage.setItem(key, JSON.stringify(v));
} catch {
/* ignore */
}
}
/* ------------------------------------------------------------------ */
/* Thread models */
/* ------------------------------------------------------------------ */
/** Example hypotheses: fictional authors, written to show the format. */
const EXAMPLE_HYPOTHESES = [
{
id: 'H1',
author: { kind: 'person', handle: '@rivergauge_example' },
at: '2026-08-28T09:12:00Z',
text: 'The outburst began at the debris-dammed lake (O2) after the second landslide (O3) loaded the dam.',
confidence: 0.7,
cites: ['O2', 'O3'],
agree: 12,
dispute: 3,
},
{
id: 'H2',
author: { kind: 'ai', handle: 'AI · example model' },
at: '2026-08-28T11:40:00Z',
text: 'Treat the Timure cluster (O5) as one timing observation: it groups five map placements, not five independent timings.',
confidence: 0.55,
cites: ['O5'],
agree: 8,
dispute: 0,
prompt: 'Which observations are not independent for timing?',
},
{
id: 'H3',
author: { kind: 'person', handle: '@northbank_example' },
at: '2026-08-29T06:05:00Z',
text: 'The Rasuwagadhi witness clip (W1) was filmed from the north bank, upstream of the border gate (O4).',
confidence: 0.4,
cites: ['W1', 'O4'],
agree: 4,
dispute: 5,
},
];
async function bhoteThread() {
const pack = await loadEventPack();
const obs = [];
obs.push({
id: 'B0',
title: 'Historical reference image',
phase: 'imagery',
lat: (pack.imagery.rectangle.north + pack.imagery.rectangle.south) / 2,
lon: (pack.imagery.rectangle.east + pack.imagery.rectangle.west) / 2,
source: pack.imagery.before.source,
via: 'Vantor Open Data Program',
time: pack.imagery.before.observedAt,
timeConf: 'acquisition date',
locConf: 'orthorectified scene',
url: '/events/bhote-koshi-2026/pre.webp',
record: pack.imagery.before,
});
obs.push({
id: 'A0',
title: 'Post-event image',
phase: 'imagery',
lat: obs[0].lat,
lon: obs[0].lon,
source: pack.imagery.after.source,
via: 'Vantor Open Data Program',
time: pack.imagery.after.observedAt,
timeConf: 'acquisition date',
locConf: 'orthorectified scene',
url: '/events/bhote-koshi-2026/post.webp',
record: pack.imagery.after,
});
for (const w of pack.witnessAnchors) {
obs.push({
id: 'W1',
title: 'Rasuwagadhi witness video',
phase: 'witness',
lat: w.lat,
lon: w.lon,
source: 'IndoWatchOSINT post (GeoConfirmed)',
via: w.provenance,
time: null,
timeConf: 'capture time unverified',
locConf: 'source geoconfirmed',
url: w.sourceUrl,
record: w,
});
}
for (const e of pack.evidenceSpine) {
obs.push({
id: `O${e.sequence}`,
seq: e.sequence,
title: e.title,
summary: e.summary,
phase: e.phase,
lat: e.lat,
lon: e.lon,
source: `${e.media?.platform || 'Source'} post`,
via: e.provenance?.via,
time: e.timing?.capturedAt,
timeConf: e.timing?.note || e.confidence?.time,
locConf: e.confidence?.location,
url: e.media?.sourceUrl,
poster: e.media?.posterUrl,
progress: e.trigger?.storyProgress,
rights: e.media?.rightsStatus,
record: e,
});
}
obs.push({
id: 'C1',
title: 'River centreline corridor',
phase: 'geometry',
lat: pack.reconstruction.corridor[0].lat,
lon: pack.reconstruction.corridor[0].lon,
source: 'GeoPera reconstruction (commit 59ee5bd)',
via: `${pack.reconstruction.corridor.length} points, every fourth published point inside the image bounds`,
time: null,
timeConf: 'not a modelled arrival time',
locConf: 'published centreline',
record: { corridor: pack.reconstruction.corridor },
});
for (const o of obs) o.hash = await sha256Hex(canonical(o.record));
const steps = pack.evidenceSpine.map((e) => ({
id: `O${e.sequence}`,
title: e.shortTitle || e.title,
phase: e.phase,
lat: e.lat,
lon: e.lon,
elev: e.fallbackElevationM,
progress: e.trigger?.storyProgress ?? 0,
}));
return {
id: pack.id,
real: true,
title: pack.title,
place: 'Rasuwa, Nepal ↔ Gyirong, Tibet',
observedDate: pack.observedDate,
created: `${pack.observedDate}T00:00:00Z`,
caveat: pack.reconstruction.caveat,
label: pack.reconstruction.label,
elapsedSeconds: pack.reconstruction.elapsedSeconds,
license:
'CC BY-NC 4.0 · GeoPera · Vantor Open Data · GeoGeorgeShadrach map',
before: '/events/bhote-koshi-2026/pre.webp',
after: '/events/bhote-koshi-2026/post.webp',
viewpoint: pack.viewpoints.corridor,
corridor: pack.reconstruction.corridor,
observations: obs,
hypotheses: EXAMPLE_HYPOTHESES.map((x) => ({ ...x, example: true })),
steps,
};
}
async function draftThread(d) {
const o = {
id: 'O1',
title: d.title,
phase: 'situation snapshot',
lat: d.lat,
lon: d.lon,
source: d.source || 'Live feed',
via: 'Snapshot when the thread was started',
time: d.createdAt,
timeConf: 'snapshot time',
locConf: 'feed position',
record: d.snapshot || d,
};
o.hash = await sha256Hex(canonical(o.record));
return {
id: d.id,
real: false,
draft: true,
title: d.title,
place: d.place,
created: d.createdAt,
observations: [o],
hypotheses: [],
steps: [{ id: 'O1', title: d.title, lat: d.lat, lon: d.lon, progress: 0 }],
};
}
/* ------------------------------------------------------------------ */
/* Ledger */
/* ------------------------------------------------------------------ */
async function buildLedger(thread, local) {
const entries = [];
const push = async (type, actor, at, payload) => {
const prev = entries.length ? entries[entries.length - 1].hash : null;
const payloadHash = await sha256Hex(canonical(payload));
const seq = entries.length;
const hash = await sha256Hex(
canonical({ seq, at, type, actor, payloadHash, prev }),
);
entries.push({ seq, at, type, actor, payload, payloadHash, prev, hash });
};
await push(
'thread.create',
thread.real ? 'event pack' : 'you (this device)',
thread.created,
{ id: thread.id, title: thread.title, license: thread.license || null },
);
for (const o of thread.observations)
await push('observation.add', o.source, o.time || thread.created, {
id: o.id,
title: o.title,
recordHash: o.hash,
});
for (const hy of [...thread.hypotheses, ...(local.proposals || [])]) {
await push(
hy.author.kind === 'ai'
? hy.simulated
? 'ai.proposal'
: 'hypothesis.propose'
: 'hypothesis.propose',
hy.author.handle,
hy.at,
{
id: hy.id,
text: hy.text,
cites: hy.cites,
confidence: hy.confidence,
example: hy.example || undefined,
...(hy.simulated
? {
prompt: hy.prompt,
model: hy.model,
inputs: hy.inputs,
simulated: true,
}
: {}),
},
);
}
for (const v of local.votes || [])
await push(`vote.${v.kind}`, 'you (this device)', v.at, {
hypothesis: v.id,
});
return entries;
}
async function verifyLedger(entries) {
let prev = null;
for (const e of entries) {
const ph = await sha256Hex(canonical(e.payload));
const hh = await sha256Hex(
canonical({
seq: e.seq,
at: e.at,
type: e.type,
actor: e.actor,
payloadHash: ph,
prev,
}),
);
if (ph !== e.payloadHash || hh !== e.hash || e.prev !== prev)
return { ok: false, at: e.seq };
prev = e.hash;
}
return { ok: true, n: entries.length, head: prev };
}
/* ------------------------------------------------------------------ */
/* Simulated AI refinement (no network) */
/* ------------------------------------------------------------------ */
function simulateRefinement(thread, prompt) {
const words = prompt.toLowerCase().match(/[a-z]{4,}/g) || [];
const scored = thread.observations
.map((o) => {
const hay = `${o.title} ${o.summary || ''} ${o.phase}`.toLowerCase();
return { o, s: words.reduce((a, w) => a + (hay.includes(w) ? 1 : 0), 0) };
})
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, 3)
.map((x) => x.o);
const cites = scored.length ? scored : thread.observations.slice(0, 2);
const anchors = cites
.map((o) => `${o.id} ${o.title} (${o.lat.toFixed(4)}, ${o.lon.toFixed(4)})`)
.join('; ');
return {
text: `Proposed refinement for “${prompt.trim()}”: candidate anchors ${anchors}. Needs a person to check the anchors against the imagery before it can be cited.`,
cites: cites.map((o) => o.id),
inputs: cites.map((o) => ({ id: o.id, recordHash: o.hash })),
};
}
/* ------------------------------------------------------------------ */
/* View */
/* ------------------------------------------------------------------ */
export function createThreads(view, { bindScroll, onPlay, onShowOnGlobe }) {
view.innerHTML = `
<div class="th-list">
<header class="navbar"><span class="nav-title">Threads</span></header>
<h1 class="large-title">Threads</h1>
<div class="title-sub">Reconstructions built in the open: observations, hypotheses, a ledger</div>
<div class="th-cards"></div>
</div>
<div class="th-detail" hidden></div>`;
const list = $('.th-list', view);
const detail = $('.th-detail', view);
const cards = $('.th-cards', view);
let bhote = null;
let open = null; // { thread, ledger }
view.addEventListener(
'scroll',
() => view.classList.toggle('is-scrolled', view.scrollTop > 44),
{ passive: true },
);
bindScroll(view);
async function renderList() {
bhote =
bhote || (await bhoteThread().catch((err) => (console.error(err), null)));
const drafts = readLocal(DRAFTS_KEY, []);
const local = readLocal(LOCAL_KEY, {})[bhote?.id] || {};
cards.innerHTML = `
${
bhote
? `<button class="th-card" type="button" data-thread="${esc(bhote.id)}">
<div class="th-ba"><img src="${bhote.before}" alt="" loading="lazy" decoding="async"/><img src="${bhote.after}" alt="" loading="lazy" decoding="async"/><span class="th-ba-l">2021</span><span class="th-ba-r">2026</span></div>
<div class="th-card-body">
<div class="th-kicker" data-family="hazards"><span class="fam-tag">${icon('hazards')}Flood</span><span>Real event pack</span></div>
<h3>${esc(bhote.title)}</h3>
<div class="th-place">${esc(bhote.place)} · ${esc(new Date(bhote.observedDate).toLocaleDateString(undefined, { dateStyle: 'medium' }))}</div>
<div class="th-stats num"><span><b>${bhote.observations.length}</b> observations</span><span><b>${bhote.hypotheses.length + (local.proposals?.length || 0)}</b> hypotheses</span><span><b>${bhote.steps.length}</b> narrative stops</span></div>
<div class="th-lic">${esc(bhote.license)}</div>
</div></button>`
: '<p class="footnote">The bundled event pack could not be loaded.</p>'
}
${drafts
.map(
(
d,
) => `<button class="th-card th-small" type="button" data-draft="${esc(d.id)}" data-family="${esc(d.family || 'infra')}">
<span class="th-small-ic">${icon('threads')}</span><div class="th-card-body"><div class="th-kicker"><span>Your draft</span><span>${esc(ago(d.createdAt))}</span></div><h3>${esc(d.title)}</h3><div class="th-place">${esc(d.place || '')}</div></div>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')}
<div class="section-h">More reconstructions <small><span class="badge-example">Example</span></small></div>
${[
{
t: 'Stadium encore, rebuilt from crowd captures',
p: '42 signed captures · 3 hypotheses',
f: 'eyes',
i: 'eyes',
},
{
t: '7700 squawk replay over the Channel',
p: 'ADS-B track · 2 witness clips',
f: 'sky',
i: 'sky',
},
{
t: 'Ferry crossing in fog, harbour pilots',
p: 'AIS tracks · radar · 1 hypothesis',
f: 'sea',
i: 'sea',
},
]
.map(
(x) =>
`<button class="th-card th-small example" type="button" data-example data-family="${x.f}"><span class="th-small-ic">${icon(x.i)}</span><div class="th-card-body"><div class="th-kicker"><span class="badge-example">Example</span><span>concept</span></div><h3>${esc(x.t)}</h3><div class="th-place">${esc(x.p)}</div></div>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join('')}
<p class="footnote">Example threads are placeholders showing the genres the format supports; they contain no real records.</p>`;
}
cards.addEventListener('click', async (e) => {
const t = e.target.closest('[data-thread]');
if (t && bhote) return openThread(bhote);
const d = e.target.closest('[data-draft]');
if (d) {
const rec = readLocal(DRAFTS_KEY, []).find(
(x) => x.id === d.dataset.draft,
);
if (rec) openThread(await draftThread(rec));
return;
}
if (e.target.closest('[data-example]'))
toast('Example thread — concept only, no records', {
icon: icon('threads'),
});
});
/* ---------- detail ---------- */
function hypHtml(hy, votes) {
const v = votes.filter((x) => x.id === hy.id);
const agree = (hy.agree || 0) + v.filter((x) => x.kind === 'agree').length;
const dispute =
(hy.dispute || 0) + v.filter((x) => x.kind === 'dispute').length;
const mine = v.at(-1)?.kind;
const total = agree + dispute || 1;
const ai = hy.author.kind === 'ai';
return `<article class="hyp ${hy.simulated ? 'proposed' : ''}" data-h="${esc(hy.id)}">
<div class="hyp-top"><span class="hyp-id">${esc(hy.id)}</span><span class="hyp-author ${ai ? 'ai' : ''}">${ai ? icon('sparkles') : ''}${esc(hy.author.handle)}</span>
${hy.example ? '<span class="badge-example">Example</span>' : ''}${hy.simulated ? '<span class="badge-example badge-sim">Proposed · local simulation</span>' : ''}<span class="hyp-ago">${esc(ago(hy.at))}</span></div>
<p class="hyp-text">${esc(hy.text)}</p>
<div class="hyp-cites">${hy.cites.map((c) => `<button type="button" class="cite" data-cite="${esc(c)}">${esc(c)}</button>`).join('')}</div>
${hy.simulated ? `<dl class="hyp-prov"><dt>prompt</dt><dd>${esc(hy.prompt)}</dd><dt>model</dt><dd class="mono">${esc(hy.model)}</dd><dt>inputs</dt><dd class="mono">${hy.inputs.map((i) => `${esc(i.id)}:${esc(i.recordHash.slice(0, 10))}`).join(' ')}</dd></dl>` : ''}
<div class="hyp-foot">
<div class="conf" title="Confidence"><span>Confidence</span><i style="--v:${hy.confidence ?? 0}"></i><b class="num">${hy.confidence == null ? 'unscored' : `${Math.round(hy.confidence * 100)}%`}</b></div>
<div class="votes"><div class="vote-bar"><i style="width:${(agree / total) * 100}%"></i></div>
<button type="button" class="vote ${mine === 'agree' ? 'on' : ''}" data-vote="agree" aria-label="Agree">${icon('agree')}<span class="num">${agree}</span></button>
<button type="button" class="vote dis ${mine === 'dispute' ? 'on' : ''}" data-vote="dispute" aria-label="Dispute">${icon('dispute')}<span class="num">${dispute}</span></button></div>
</div></article>`;
}
function obsHtml(o) {
return `<li class="obs" data-o="${esc(o.id)}">
<button class="obs-row" type="button" aria-expanded="false">
<span class="obs-id">${esc(o.id)}</span>
<span class="obs-main"><b>${esc(o.title)}</b><span>${esc(o.phase)} · ${esc(o.source)}</span></span>
<span class="obs-hash mono">${icon('lock')}${esc(o.hash.slice(0, 8))}</span>
</button>
<div class="obs-more" hidden>
${o.summary ? `<p>${esc(o.summary)}</p>` : ''}
<dl class="facts"><dt>Where</dt><dd class="mono">${esc(fmtCoord(o.lat, o.lon, 5))}</dd>
<dt>Location</dt><dd>${esc(o.locConf || '—')}</dd><dt>Time</dt><dd>${esc(o.time || '—')} · ${esc(o.timeConf || '')}</dd>
<dt>Via</dt><dd>${esc(o.via || '—')}</dd>${o.rights ? `<dt>Rights</dt><dd>${esc(o.rights)}</dd>` : ''}
<dt>sha256</dt><dd class="mono">${esc(o.hash)}</dd></dl>
<div class="obs-actions">${o.url ? `<a class="pill-btn plain" href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">${icon('arrowUpRight')}Original</a>` : ''}<button class="pill-btn tinted" type="button" data-globe="${esc(o.id)}">${icon('globe')}Show on globe</button></div>
</div></li>`;
}
async function renderDetail() {
const { thread } = open;
const local = readLocal(LOCAL_KEY, {})[thread.id] || {};
const votes = local.votes || [];
const proposals = local.proposals || [];
open.ledger = await buildLedger(thread, local);
const L = open.ledger;
detail.innerHTML = `
<header class="navbar"><div class="nav-leading"><button class="icon-btn back-btn" type="button" aria-label="Back to threads">${icon('chevronLeft')}<span>Threads</span></button></div><span class="nav-title">${esc(thread.title)}</span></header>
<div class="th-hero">
${thread.before ? `<div class="th-ba big"><figure><img src="${thread.before}" alt="2021 reference imagery" decoding="async"/><figcaption>2021 · reference</figcaption></figure><figure><img src="${thread.after}" alt="2026 post-event imagery" decoding="async"/><figcaption>27 Aug 2026 · post-event</figcaption></figure></div>` : ''}
<h1 class="th-title">${esc(thread.title)}</h1>
<div class="title-sub" style="padding:0">${esc(thread.place || '')}${thread.real ? ' · real event pack' : ' · your draft'}</div>
${thread.label ? `<div class="caveat"><b>${esc(thread.label)}</b>${esc(thread.caveat)}</div>` : ''}
</div>
<nav class="strata-nav" aria-label="Strata"><div class="segmented">
<button type="button" data-jump="obs" aria-pressed="true">Observations</button><button type="button" data-jump="hyp" aria-pressed="false">Hypotheses</button><button type="button" data-jump="nar" aria-pressed="false">Narrative</button><button type="button" data-jump="led" aria-pressed="false">Ledger</button>
<span class="seg-thumb"></span></div></nav>
<section class="stratum" id="st-obs"><div class="stratum-h"><span class="st-n">1</span><div><h2>Observations</h2><p>Immutable. Each has a source, a time and a hash. Nobody edits an observation.</p></div></div>
<ol class="obs-list">${thread.observations.map(obsHtml).join('')}</ol></section>
<section class="stratum" id="st-hyp"><div class="stratum-h"><span class="st-n">2</span><div><h2>Hypotheses</h2><p>Claims that connect observations. Cite, dispute or supersede — never edit.</p></div></div>
${thread.hypotheses.some((x) => x.example) ? '<div class="example-note"><span class="badge-example">Example content</span> These hypotheses, authors and vote counts are fictional, written to show the format. The observations above are real.</div>' : ''}
<div class="hyps">${[...thread.hypotheses, ...proposals].map((x) => hypHtml(x, votes)).join('') || '<p class="footnote" style="padding:0">No hypotheses yet.</p>'}</div>
<form class="composer" autocomplete="off">
<label for="ai-prompt">${icon('sparkles')}Ask AI to refine</label>
<textarea id="ai-prompt" rows="2" placeholder="Align the witness clip to the corridor using the bridge as a landmark" data-no-drag></textarea>
<div class="composer-row"><span>Creates a <b>proposed</b> hypothesis with its prompt, model and inputs in the ledger. Local simulation — no network model is called.</span><button class="pill-btn primary" type="submit">Propose</button></div>
</form></section>
<section class="stratum" id="st-nar"><div class="stratum-h"><span class="st-n">3</span><div><h2>Narrative</h2><p>A camera path through space and time that cites the strata below it.</p></div></div>
<div class="narr-card">
<button class="narr-play" type="button" aria-label="Play narrative">${icon('play')}</button>
<div><b>${thread.steps.length} stops${thread.elapsedSeconds ? ` · ${Math.round(thread.elapsedSeconds / 60)} min schematic` : ''}</b><span>Flies the globe down the valley, scrubbing the time ribbon.</span></div>
</div>
<ol class="narr-steps">${thread.steps.map((s, i) => `<li><span class="num">${String(i + 1).padStart(2, '0')}</span><b>${esc(s.title)}</b><em>${esc(s.phase || '')}</em></li>`).join('')}</ol></section>
<section class="stratum" id="st-led"><div class="stratum-h"><span class="st-n">#</span><div><h2>Ledger</h2><p>Every change, hash-chained with SHA-256 on this device. Anyone can export and verify it.</p></div></div>
<div class="ledger-actions"><button class="pill-btn plain" type="button" data-led="verify">${icon('checkmark')}Verify chain</button><button class="pill-btn plain" type="button" data-led="export">${icon('download')}Export ledger</button><span class="led-status num"></span></div>
<ol class="ledger">${L.map(
(
e,
) => `<li class="led ${e.type.split('.')[0]}"><button type="button" class="led-row" aria-expanded="false"><span class="led-seq num">${e.seq}</span><span class="led-main"><b>${esc(e.type)}</b><span>${esc(e.actor)} · ${esc(e.at ? String(e.at).slice(0, 10) : '')}</span></span><span class="led-hash mono">${esc(e.hash.slice(0, 10))}</span></button>
<pre class="led-json mono" hidden>${esc(JSON.stringify({ ...e, payload: e.payload }, null, 2))}</pre></li>`,
).join('')}</ol>
<p class="footnote" style="padding:8px 0 0">Head <span class="mono">${esc(L.at(-1)?.hash.slice(0, 24) || '')}…</span> · ${L.length} entries</p>
</section>`;
placeSeg();
}
function placeSeg(id) {
const seg = $('.strata-nav .segmented', detail);
if (!seg) return;
const btn = id
? $(`[data-jump="${id}"]`, seg)
: $('[aria-pressed="true"]', seg);
$$('[data-jump]', seg).forEach((b) =>
b.setAttribute('aria-pressed', String(b === btn)),
);
const th = $('.seg-thumb', seg);
th.style.width = `${btn.offsetWidth}px`;
th.style.transform = `translateX(${btn.offsetLeft}px)`;
}
async function openThread(thread) {
open = { thread };
await renderDetail();
detail.hidden = false;
view.classList.add('in-detail');
view.scrollTop = 0;
if (!reducedMotion()) {
detail.animate(
[{ transform: 'translateX(100%)' }, { transform: 'none' }],
{ duration: 480, easing: 'cubic-bezier(.32,1.1,.4,1)' },
);
list.animate(
[
{ transform: 'none', opacity: 1 },
{ transform: 'translateX(-28%)', opacity: 0.4 },
],
{ duration: 480, easing: 'cubic-bezier(.32,1.1,.4,1)' },
);
}
list.hidden = true;
requestAnimationFrame(() => placeSeg());
}
function closeThread() {
list.hidden = false;
view.classList.remove('in-detail');
if (!reducedMotion()) {
list.animate(
[
{ transform: 'translateX(-28%)', opacity: 0.4 },
{ transform: 'none', opacity: 1 },
],
{ duration: 420, easing: 'cubic-bezier(.32,1.1,.4,1)' },
);
detail
.animate([{ transform: 'none' }, { transform: 'translateX(100%)' }], {
duration: 380,
easing: 'cubic-bezier(.4,0,.6,1)',
})
.finished.then(() => (detail.hidden = true));
} else detail.hidden = true;
open = null;
renderList();
}
function saveLocal(fn) {
const all = readLocal(LOCAL_KEY, {});
const cur = all[open.thread.id] || { votes: [], proposals: [] };
fn(cur);
all[open.thread.id] = cur;
writeLocal(LOCAL_KEY, all);
}
detail.addEventListener('click', async (e) => {
if (e.target.closest('.back-btn')) return closeThread();
const jump = e.target.closest('[data-jump]');
if (jump) {
placeSeg(jump.dataset.jump);
const sec = $(`#st-${jump.dataset.jump}`, detail);
view.scrollTo({
top: sec.offsetTop - 110,
behavior: reducedMotion() ? 'auto' : 'smooth',
});
return;
}
const row = e.target.closest('.obs-row');
if (row) {
const more = row.nextElementSibling;
more.hidden = !more.hidden;
row.setAttribute('aria-expanded', String(!more.hidden));
return;
}
const g = e.target.closest('[data-globe]');
if (g) {
const o = open.thread.observations.find((x) => x.id === g.dataset.globe);
if (o) onShowOnGlobe?.(open.thread, o);
return;
}
const cite = e.target.closest('[data-cite]');
if (cite) {
const li = $(`.obs[data-o="${cite.dataset.cite}"]`, detail);
if (li) {
const more = $('.obs-more', li);
more.hidden = false;
li.scrollIntoView({
block: 'center',
behavior: reducedMotion() ? 'auto' : 'smooth',
});
li.classList.remove('flash');
void li.offsetWidth;
li.classList.add('flash');
}
return;
}
const vote = e.target.closest('[data-vote]');
if (vote) {
const id = vote.closest('[data-h]').dataset.h;
haptic('tick');
saveLocal((cur) =>
cur.votes.push({
id,
kind: vote.dataset.vote,
at: new Date().toISOString(),
}),
);
await renderDetail();
placeSeg('hyp');
toast(
`${vote.dataset.vote === 'agree' ? 'Agreed with' : 'Disputed'} ${id} · ledger +1`,
{ icon: icon('threads') },
);
return;
}
if (e.target.closest('.narr-play')) {
onPlay?.(open.thread);
return;
}
const led = e.target.closest('[data-led]')?.dataset.led;
if (led === 'verify') {
const t0 = performance.now();
const r = await verifyLedger(open.ledger);
const st = $('.led-status', detail);
st.textContent = r.ok
? `✓ ${r.n} entries linked · ${Math.round(performance.now() - t0)} ms`
: `✕ broken at #${r.at}`;
st.className = `led-status num ${r.ok ? 'ok' : 'bad'}`;
haptic(r.ok ? 'success' : 'warn');
} else if (led === 'export') {
const blob = new Blob(
[
JSON.stringify(
{
thread: open.thread.id,
title: open.thread.title,
exportedAt: new Date().toISOString(),
algorithm:
'sha256(canonical({seq,at,type,actor,payloadHash,prev}))',
entries: open.ledger,
},
null,
2,
),
],
{ type: 'application/json' },
);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${open.thread.id}.ledger.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast('Ledger exported', { icon: icon('download') });
}
const lr = e.target.closest('.led-row');
if (lr) {
const pre = lr.nextElementSibling;
pre.hidden = !pre.hidden;
lr.setAttribute('aria-expanded', String(!pre.hidden));
}
});
detail.addEventListener('submit', async (e) => {
e.preventDefault();
const ta = $('#ai-prompt', detail);
const prompt = ta.value.trim() || ta.placeholder;
const sim = simulateRefinement(open.thread, prompt);
const n =
(readLocal(LOCAL_KEY, {})[open.thread.id]?.proposals?.length || 0) +
open.thread.hypotheses.length +
1;
saveLocal((cur) =>
cur.proposals.push({
id: `H${n}`,
author: { kind: 'ai', handle: 'AI · local simulation' },
at: new Date().toISOString(),
text: sim.text,
confidence: null,
cites: sim.cites,
agree: 0,
dispute: 0,
simulated: true,
prompt,
model: 'local-simulation-v0 (no network)',
inputs: sim.inputs,
}),
);
haptic('success');
await renderDetail();
placeSeg('hyp');
$(`[data-h="H${n}"]`, detail)?.scrollIntoView({
block: 'center',
behavior: reducedMotion() ? 'auto' : 'smooth',
});
toast(`Proposed H${n} · recorded in the ledger`, {
icon: icon('sparkles'),
});
});
new ResizeObserver(() => placeSeg()).observe(view);
renderList();
return {
refresh: renderList,
async openById(id) {
if (!bhote) bhote = await bhoteThread();
if (id === bhote.id) return openThread(bhote);
const rec = readLocal(DRAFTS_KEY, []).find((x) => x.id === id);
if (rec) return openThread(await draftThread(rec));
},
/** Start a local draft thread from a situation or contact. */
startDraft(s) {
const drafts = readLocal(DRAFTS_KEY, []);
const id = `draft-${String(s.id).replace(/[^a-z0-9-]/gi, '')}`;
if (!drafts.some((d) => d.id === id))
drafts.unshift({
id,
title: s.title,
place: s.place,
lat: s.lat,
lon: s.lon,
family: s.family,
source: s.source,
createdAt: new Date().toISOString(),
snapshot: {
id: s.id,
title: s.title,
metrics: s.metrics || null,
source: s.source || null,
updatedAt: s.updatedAt || null,
lon: s.lon,
lat: s.lat,
},
});
writeLocal(DRAFTS_KEY, drafts.slice(0, 20));
return id;
},
thread: () => open?.thread || null,
bhote: async () => (bhote = bhote || (await bhoteThread())),
};
}
+412
View File
@@ -0,0 +1,412 @@
/**
* Time ribbon: −72 h ← NOW → +120 h with momentum drag, haptic hour detents,
* event pips and a LIVE pill that snaps back. Fast drags move in hours,
* slow drags in minutes. Calls `onTime(Date | null)` (null = live).
*/
import { h, clamp, haptic, reducedMotion } from './dom.js';
const HOUR = 3600e3;
export function createTimeRibbon(parent, { getWindow, onTime }) {
const el =
h(`<div class="ribbon glass-strong liquid" role="group" aria-label="Time">
<button class="rb-live is-live" type="button" aria-label="Return to live"><span class="live-dot"></span><span>LIVE</span></button>
<div class="rb-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
<canvas></canvas>
<div class="rb-head" aria-hidden="true"></div>
</div>
<div class="rb-read" aria-live="off"><b class="num">Now</b><span class="num"></span></div>
</div>`);
parent.appendChild(el);
const track = el.querySelector('.rb-track');
const canvas = el.querySelector('canvas');
const ctx = canvas.getContext('2d');
const liveBtn = el.querySelector('.rb-live');
const readB = el.querySelector('.rb-read b');
const readS = el.querySelector('.rb-read span');
let win = getWindow();
let custom = null; // { start, end, label, pips, format }
let pips = [];
let t = Date.now(); // cursor time (ms)
let live = true;
let pxPerHour = 7;
let W = 0;
let Hh = 0;
let dpr = 1;
let raf = 0;
let vel = 0; // ms per frame
let lastHour = Math.floor(t / HOUR);
let lastTick = 0;
let emitTimer = 0;
const span = () => (custom ? custom.end - custom.start : win.end - win.start);
const start = () => (custom ? custom.start : win.start.getTime());
const end = () => (custom ? custom.end : win.end.getTime());
const nowMs = () => (custom ? (custom.now ?? custom.start) : Date.now());
function resize() {
const r = track.getBoundingClientRect();
dpr = Math.min(2, window.devicePixelRatio || 1);
W = r.width;
Hh = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(Hh * dpr);
// Custom windows (minutes long) scale to fit ~1.6 track widths.
pxPerHour = custom ? (W * 1.6) / (span() / HOUR) : 7;
draw();
}
function x(ms) {
return W / 2 + ((ms - t) / HOUR) * pxPerHour;
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, Hh);
const s = start();
const e = end();
const n = nowMs();
const mid = Hh / 2 + 4;
// Window body
ctx.fillStyle = 'rgba(255,255,255,0.04)';
const xs = Math.max(0, x(s));
const xe = Math.min(W, x(e));
ctx.fillRect(xs, mid - 9, xe - xs, 18);
// Forecast region
if (!custom) {
const xn = x(n);
if (xn < W) {
const g = ctx.createLinearGradient(xn, 0, Math.min(W, xe), 0);
g.addColorStop(0, 'rgba(138,180,248,0.16)');
g.addColorStop(1, 'rgba(138,180,248,0.03)');
ctx.fillStyle = g;
ctx.fillRect(
Math.max(0, xn),
mid - 9,
Math.min(W, xe) - Math.max(0, xn),
18,
);
}
}
// Ticks
const stepMin = custom ? (span() / HOUR < 3 ? 5 : 15) : 60;
const step = stepMin * 60e3;
const first = Math.ceil((t - (W / 2 / pxPerHour) * HOUR) / step) * step;
const last = t + (W / 2 / pxPerHour) * HOUR;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (
let m = Math.max(first, s - (s % step));
m <= Math.min(last, e);
m += step
) {
const px = x(m);
const d = new Date(m);
const major = custom
? (m - s) % (stepMin * 4 * 60e3) === 0
: d.getHours() === 0;
const mid6 = !custom && d.getHours() % 6 === 0;
const future = !custom && m > n;
const len = major ? 14 : mid6 ? 9 : 5;
ctx.strokeStyle = future
? 'rgba(170,200,255,0.42)'
: major
? 'rgba(255,255,255,0.75)'
: 'rgba(255,255,255,0.32)';
ctx.lineWidth = major ? 1.3 : 1;
if (future) ctx.setLineDash([1.5, 2]);
ctx.beginPath();
ctx.moveTo(px, mid - len / 2);
ctx.lineTo(px, mid + len / 2);
ctx.stroke();
ctx.setLineDash([]);
if (major || (mid6 && pxPerHour > 4)) {
ctx.font = `${major ? 650 : 500} 9.5px ui-rounded, system-ui, sans-serif`;
ctx.fillStyle = major
? 'rgba(255,255,255,0.8)'
: 'rgba(255,255,255,0.38)';
const label = custom
? custom.format(m)
: major
? d
.toLocaleDateString(undefined, { weekday: 'short' })
.toUpperCase()
: `${String(d.getHours()).padStart(2, '0')}`;
ctx.fillText(label, px, mid - 12);
}
}
// NOW marker
if (!custom) {
const xn = x(n);
if (xn > -10 && xn < W + 10) {
ctx.fillStyle = '#FF453A';
ctx.beginPath();
ctx.arc(xn, mid + 13, 2.4, 0, Math.PI * 2);
ctx.fill();
}
}
// Event pips
for (const p of pips) {
const px = x(p.at);
if (px < -6 || px > W + 6) continue;
ctx.fillStyle = p.color || '#FFD166';
ctx.beginPath();
ctx.arc(px, mid, 3.4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(5,7,11,0.9)';
ctx.lineWidth = 1.2;
ctx.stroke();
}
// Edge fades
const fade = (x0, x1) => {
const g = ctx.createLinearGradient(x0, 0, x1, 0);
g.addColorStop(0, 'rgba(0,0,0,1)');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = g;
ctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), Hh);
ctx.restore();
};
fade(0, 26);
fade(W, W - 26);
updateReadout();
}
function fmtRel(ms) {
const d = ms / 60e3;
const sign = d < 0 ? '−' : '+';
const a = Math.abs(d);
if (a < 60) return `${sign}${Math.round(a)} min`;
const hrs = Math.floor(a / 60);
const mins = Math.round(a % 60);
return `${sign}${hrs} h${mins ? ` ${mins}` : ''}`;
}
function updateReadout() {
if (custom) {
readB.textContent = custom.format(t);
readS.textContent = custom.label || '';
track.setAttribute(
'aria-valuetext',
`${custom.format(t)} ${custom.label || ''}`,
);
return;
}
if (live) {
readB.textContent = 'Now';
readS.textContent = new Date().toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
} else {
readB.textContent = fmtRel(t - Date.now());
readS.textContent = new Date(t).toLocaleString(undefined, {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
track.setAttribute(
'aria-valuetext',
live ? 'Live' : `${readB.textContent}, ${readS.textContent}`,
);
}
function setLive(v) {
live = v;
el.classList.toggle('is-live', v);
liveBtn.classList.toggle('is-live', v);
liveBtn.querySelector('span:last-child').textContent = v
? 'LIVE'
: custom
? 'RESET'
: 'LIVE';
liveBtn.setAttribute('aria-pressed', String(v));
}
function emit(immediate = false) {
const fire = () => {
emitTimer = 0;
onTime(live && !custom ? null : new Date(t));
};
if (immediate) {
clearTimeout(emitTimer);
fire();
} else if (!emitTimer) emitTimer = setTimeout(fire, 90);
}
function setCursor(ms, { fromUser = false, quant = 0 } = {}) {
const s = start();
const e = end();
let v = ms;
if (v < s) v = s - (1 - 1 / (((s - v) / HOUR) * 0.2 + 1)) * HOUR * 2;
if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.2 + 1)) * HOUR * 2;
if (quant) v = Math.round(v / quant) * quant;
t = v;
const hr = Math.floor(t / HOUR);
if (fromUser && hr !== lastHour) {
const now = performance.now();
if (now - lastTick > 55) {
haptic('tick');
lastTick = now;
}
lastHour = hr;
}
if (fromUser) {
const near = !custom && Math.abs(t - Date.now()) < 4 * 60e3;
setLive(near);
emit();
}
draw();
}
/* ---------- drag with momentum ---------- */
let drag = null;
track.addEventListener('pointerdown', (e) => {
track.setPointerCapture(e.pointerId);
cancelAnimationFrame(raf);
drag = {
x: e.clientX,
t0: t,
samples: [{ x: e.clientX, ts: performance.now() }],
};
el.classList.add('scrubbing');
});
track.addEventListener('pointermove', (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
drag.samples.push({ x: e.clientX, ts: performance.now() });
if (drag.samples.length > 6) drag.samples.shift();
const a = drag.samples[0];
const b = drag.samples[drag.samples.length - 1];
const speed = b.ts > a.ts ? Math.abs((b.x - a.x) / (b.ts - a.ts)) : 0; // px/ms
const quant = custom ? 60e3 : speed > 0.6 ? HOUR : 5 * 60e3;
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true, quant });
});
const release = () => {
if (!drag) return;
el.classList.remove('scrubbing');
const a = drag.samples[0];
const b = drag.samples[drag.samples.length - 1];
const pxPerMs = b.ts > a.ts ? (b.x - a.x) / (b.ts - a.ts) : 0;
drag = null;
vel = (-(pxPerMs * 16.7) / pxPerHour) * HOUR;
const fast = Math.abs(pxPerMs) > 0.6;
if (reducedMotion() || Math.abs(pxPerMs) < 0.05) return settle(fast);
const stepFn = () => {
vel *= 0.93;
setCursor(t + vel, { fromUser: true });
if (Math.abs(vel) > 60e3 && t > start() && t < end())
raf = requestAnimationFrame(stepFn);
else settle(fast);
};
raf = requestAnimationFrame(stepFn);
};
track.addEventListener('pointerup', release);
track.addEventListener('pointercancel', release);
function settle(fast) {
const s = start();
const e = end();
const target = clamp(t, s, e);
const q = custom ? 60e3 : fast ? HOUR : 5 * 60e3;
animateTo(Math.round(target / q) * q, () => emit(true));
}
function animateTo(target, done) {
cancelAnimationFrame(raf);
const from = t;
const t0 = performance.now();
const dur = reducedMotion() ? 1 : 420;
const step = () => {
const k = clamp((performance.now() - t0) / dur, 0, 1);
const e = 1 - (1 - k) ** 3;
t = from + (target - from) * e;
draw();
if (k < 1) raf = requestAnimationFrame(step);
else done?.();
};
raf = requestAnimationFrame(step);
}
function goLive() {
haptic('tick');
if (custom) {
animateTo(custom.start, () => emit(true));
return;
}
setLive(true);
animateTo(Date.now(), () => emit(true));
}
liveBtn.addEventListener('click', goLive);
track.addEventListener('keydown', (e) => {
const stepMs = custom ? 60e3 : e.shiftKey ? 6 * HOUR : HOUR;
if (e.key === 'ArrowRight') setCursor(t + stepMs, { fromUser: true });
else if (e.key === 'ArrowLeft') setCursor(t - stepMs, { fromUser: true });
else if (e.key === 'Home' || e.key === 'End') goLive();
else return;
e.preventDefault();
});
// Live: keep the cursor glued to now while the planet turns.
setInterval(() => {
if (live && !custom && !drag) {
t = Date.now();
win = getWindow();
draw();
}
}, 15000);
new ResizeObserver(resize).observe(track);
resize();
setLive(true);
return {
el,
setPips(list) {
pips = list.filter((p) => Number.isFinite(p.at));
draw();
},
/** Use a custom window (e.g. a reconstruction's schematic clock). */
setWindow(w) {
custom = w
? {
...w,
start: +w.start,
end: +w.end,
format: w.format || ((ms) => new Date(ms).toLocaleTimeString()),
}
: null;
el.classList.toggle('custom', !!custom);
if (custom) {
t = custom.start;
setLive(false);
} else {
win = getWindow();
t = Date.now();
setLive(true);
}
resize();
},
/** Programmatic scrub (narrative playback). */
seek(ms) {
cancelAnimationFrame(raf);
t = ms;
draw();
},
goLive,
get time() {
return live && !custom ? null : new Date(t);
},
get isLive() {
return live && !custom;
},
get debug() {
return { t, pxPerHour, W, Hh, live, custom: !!custom };
},
};
}
+553
View File
@@ -0,0 +1,553 @@
/**
* Card → world: the hero transition into the live globe, the situation
* brief sheet (peek / medium / large) and the fold back into the card.
*/
import { icon, FAMILY_ICON, FAMILIES } from './icons.js';
import {
$,
h,
esc,
ago,
fmtNum,
fmtCoord,
haptic,
toast,
store,
reducedMotion,
SPRING,
SPRING_SOFT,
EASE_OUT,
sparkPath,
esriThumb,
emitter,
safeInsets,
} from './dom.js';
import { createSheet } from './sheet.js';
import { isReconstruction } from './feed.js';
const FAM_NAME = Object.fromEntries(FAMILIES.map((f) => [f.id, f.name]));
let eventPack = null;
export async function loadEventPack() {
if (!eventPack)
eventPack = fetch('/events/bhote-koshi-2026/event.json')
.then((r) => {
if (!r.ok) throw new Error(`event pack ${r.status}`);
return r.json();
})
.catch((err) => {
eventPack = null; // let the next caller retry
throw err;
});
return eventPack;
}
export function createWorld({
app,
stage,
globe,
getCard,
isTwoPane,
onThread,
onFollowChange,
}) {
const ev = emitter();
let current = null;
let busy = false;
let originCard = null;
/* ---------- chrome: back button ---------- */
const back = h(
`<button class="world-back icon-btn glass-strong liquid" type="button" aria-label="Back to feed">${icon('chevronLeft')}</button>`,
);
$('#globe-chrome').appendChild(back);
back.addEventListener('click', () => close());
/* ---------- the brief sheet ---------- */
const sheet = createSheet({
host: stage,
label: 'Situation brief',
detents: ['peek', 'medium', 'large'],
dismissible: true,
closeButton: true,
className: 'brief-sheet',
heights: {
peek: () => 22 + (sheet?.head?.offsetHeight || 92) + 16 + safeBottom(),
medium: (H) => Math.round(H * 0.47),
large: (H) => Math.round(H * 0.92),
},
});
sheet.on('close', () => {
if (current && !closingFromCode) close({ fromSheet: true });
});
sheet.on('detent', (d) => {
app.dataset.sheet = d;
stage.style.setProperty('--sheet-h', `${sheet.visibleHeight()}px`);
});
let closingFromCode = false;
const safeBottom = () => safeInsets().bottom;
// The world wins: start panning the globe and the sheet steps aside.
$('#globe').addEventListener(
'pointerdown',
() => {
if (current && sheet.isOpen && sheet.detent !== 'peek')
sheet.setDetent('peek');
},
{ capture: true, passive: true },
);
// Edge-swipe back (leading edge, like a navigation pop).
let edge = null;
stage.addEventListener(
'touchstart',
(e) => {
if (!current) return;
const t = e.touches[0];
if (t.clientX - stage.getBoundingClientRect().left < 22)
edge = { x: t.clientX, y: t.clientY };
},
{ passive: true },
);
stage.addEventListener(
'touchmove',
(e) => {
if (!edge) return;
const t = e.touches[0];
if (t.clientX - edge.x > 70 && Math.abs(t.clientY - edge.y) < 60) {
edge = null;
close();
}
},
{ passive: true },
);
stage.addEventListener('touchend', () => (edge = null), { passive: true });
/* ---------- brief rendering ---------- */
function metricHtml(m) {
const v =
typeof m.value === 'number'
? fmtNum(m.value, m.value % 1 ? 1 : 0)
: m.value;
return `<div class="metric ${String(v).length > 5 ? 'long' : ''}"><div class="metric-v">${esc(v)}${m.unit ? `<small>${esc(m.unit)}</small>` : ''}</div><div class="metric-l">${esc(m.label)}</div></div>`;
}
function sparkHtml(s) {
if (!Array.isArray(s.series) || s.series.length < 3) return '';
const W = 220;
const H = 42;
const d = sparkPath(s.series, W, H, 3);
const last = d.split(/[ML]/).filter(Boolean).pop().split(' ').map(Number);
const m = s.metrics?.[0];
return `<div class="block"><div class="block-h">Trend<small>${esc(m?.label || '')} · last ${s.series.length} samples</small></div>
<div class="spark-row"><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
<defs><linearGradient id="spark-grad" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0"/></linearGradient></defs>
<path class="spark-area" d="${d}L${W - 3} ${H}L3 ${H}Z"/><path class="spark-line" d="${d}"/><circle class="spark-end" cx="${last[0]}" cy="${last[1]}" r="3"/></svg>
<div class="spark-label"><b>${esc(m ? fmtNum(m.value, typeof m.value === 'number' && m.value % 1 ? 1 : 0) : '')}</b>${esc(m?.unit || '')} now</div></div></div>`;
}
function contextEvidence(s) {
const span = Math.max(0.02, (s.camera?.height || 300e3) / 111e3 / 1.6);
const frames = [
{ t: 'Region', span: span * 6 },
{ t: 'Area', span },
{ t: 'Close', span: span / 5 },
];
return frames
.map(
(
f,
i,
) => `<a class="ev" href="${esc(esriThumb(s.lon, s.lat, f.span, 900, 1100))}" target="_blank" rel="noopener">
<img class="ev-img" src="${esc(esriThumb(s.lon, s.lat, f.span, 232, 280))}" alt="" loading="lazy" decoding="async"/>
<span class="ev-seq">${i + 1}</span><div class="ev-t">${f.t} context</div><div class="ev-s">Esri World Imagery · archival</div></a>`,
)
.join('');
}
async function packEvidence() {
const pack = await loadEventPack();
const pre = `<a class="ev" href="/events/bhote-koshi-2026/pre.webp" target="_blank"><img class="ev-img" src="/events/bhote-koshi-2026/pre.webp" alt="" loading="lazy" decoding="async"/><span class="ev-seq">A</span><div class="ev-t">Before · 16 Oct 2021</div><div class="ev-s">Vantor WorldView-2</div></a>
<a class="ev" href="/events/bhote-koshi-2026/post.webp" target="_blank"><img class="ev-img" src="/events/bhote-koshi-2026/post.webp" alt="" loading="lazy" decoding="async"/><span class="ev-seq">B</span><div class="ev-t">After · 27 Aug 2026</div><div class="ev-s">Vantor WorldView-3</div></a>`;
return (
pre +
pack.evidenceSpine
.map(
(
e,
) => `<a class="ev" href="${esc(e.media?.sourceUrl || '#')}" target="_blank" rel="noopener noreferrer">
<img class="ev-img" src="${esc(e.media?.posterUrl || esriThumb(e.lon, e.lat, 0.03, 232, 280))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer"/>
<span class="ev-seq">${e.sequence}</span><div class="ev-t">${esc(e.shortTitle || e.title)}</div><div class="ev-s">${esc(e.media?.platform || 'Source')} · ${esc(e.confidence?.time || '')}</div></a>`,
)
.join('')
);
}
function render(s) {
const followed = store.isFollowed(s.id);
const recon = isReconstruction(s);
sheet.sheet.dataset.family = s.family;
const m0 = s.metrics?.[0];
const status = s.live
? `<span class="live-tag"><span class="live-dot"></span>Live</span>`
: recon
? '<span>Reconstruction</span>'
: '<span>Stale</span>';
sheet.head.innerHTML = `
<div class="brief-top"><span class="fam-tag">${icon(FAMILY_ICON[s.family] || 'now')}${esc(FAM_NAME[s.family] || s.family)}</span>${status}<span>·</span><span class="num">${esc(ago(s.updatedAt))}</span></div>
<div class="brief-title-row"><h2 class="sheet-title">${esc(s.title)}</h2>${m0 ? `<span class="peek-metric num">${esc(typeof m0.value === 'number' ? fmtNum(m0.value, m0.value % 1 ? 1 : 0) : m0.value)}<small>${esc(m0.unit || '')}</small></span>` : ''}</div>
<div class="sheet-sub">${esc(s.place || '')}${s.subtitle ? ` — ${esc(s.subtitle)}` : ''}</div>`;
const metrics = (s.metrics || []).slice(0, 3);
const time = s.time
? `${new Date(s.time.start).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })} → ${new Date(s.time.end).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}`
: 'Live';
const layers = globe.layers();
const layerChips = (s.layers || [])
.map((id) => {
const l = layers.find((x) => x.id === id);
const fam = l?.family || id.split('.')[0];
return `<span class="chip layer-chip" data-family="${esc(fam)}">${icon(FAMILY_ICON[fam] || 'layers')}${esc(l?.name || id)}</span>`;
})
.join('');
sheet.body.innerHTML = `
${metrics.length ? `<div class="metrics">${metrics.map(metricHtml).join('')}</div>` : ''}
<div class="actions" role="group" aria-label="Actions">
<button class="action" type="button" data-act="follow" aria-pressed="${followed}">${icon('star')}<span>${followed ? 'Following' : 'Follow'}</span></button>
<button class="action" type="button" data-act="ride">${icon('ride')}<span>Ride</span></button>
<button class="action" type="button" data-act="eye">${icon('video')}<span>Nearest eye</span></button>
<button class="action" type="button" data-act="thread">${icon('threads')}<span>Thread</span></button>
<button class="action" type="button" data-act="share">${icon('share')}<span>Share</span></button>
</div>
${sparkHtml(s)}
<div class="block"><div class="block-h">Evidence<small>${recon ? 'sourced observations · tap for original' : 'context imagery'}</small></div>
<div class="evidence" data-hscroll>${recon ? '<div class="ev"><div class="ev-img"></div></div>'.repeat(4) : contextEvidence(s)}</div></div>
<div class="block"><div class="block-h">Every number, its source and age</div>
<table class="metric-table"><tbody>${(s.metrics || [])
.map(
(m) =>
`<tr><td>${esc(m.label)}</td><td>${esc(typeof m.value === 'number' ? fmtNum(m.value, m.value % 1 ? 1 : 0) : m.value)} ${esc(m.unit || '')}</td><td>${esc(m.source || s.source || '')} · ${esc(ago(m.updatedAt || s.updatedAt))}</td></tr>`,
)
.join('')}</tbody></table></div>
<div class="block"><div class="block-h">Lenses this situation turns on</div><div class="layer-chips">${layerChips || '<span class="sheet-sub">None</span>'}</div></div>
<div class="block"><div class="block-h">Details</div><dl class="facts">
<dt>Where</dt><dd class="mono">${esc(fmtCoord(s.lat, s.lon, 4))}</dd>
<dt>Window</dt><dd>${esc(time)}</dd>
<dt>Kind</dt><dd>${esc(s.kind || '—')}</dd>
<dt>Source</dt><dd>${esc(s.source || '—')}</dd></dl></div>
${s.caveat ? `<div class="caveat"><b>Caveat</b>${esc(s.caveat)}</div>` : ''}`;
if (
recon &&
(s.thread === 'bhote-koshi-2026' || s.id === 'bhote-koshi-2026')
) {
packEvidence()
.then((html) => {
const strip = $('.evidence', sheet.body);
if (strip && current === s) strip.innerHTML = html;
return loadEventPack();
})
.then((pack) => {
if (current !== s || $('.caveat', sheet.body)) return;
sheet.body.insertAdjacentHTML(
'beforeend',
`<div class="caveat"><b>${esc(pack.reconstruction.label)}</b>${esc(pack.reconstruction.caveat)}</div>`,
);
})
.catch((err) => console.warn(err));
}
}
sheet.root.addEventListener('click', async (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act || !current) return;
const s = current;
haptic('light');
if (act === 'follow') {
const on = store.toggleFollow(s.id);
const b = e.target.closest('[data-act]');
b.setAttribute('aria-pressed', String(on));
b.querySelector('span').textContent = on ? 'Following' : 'Follow';
toast(on ? `Following ${s.title}` : 'Unfollowed', {
icon: icon(on ? 'starFill' : 'star'),
});
onFollowChange?.(s, on);
} else if (act === 'ride') {
sheet.setDetent('peek');
const c = s.camera || {};
await globe.flyTo({
lon: c.lon ?? s.lon,
lat: c.lat ?? s.lat,
height: Math.max(1500, (c.height || 200e3) * 0.12),
heading: (c.heading || 0) + 25,
pitch: -18,
duration: 2.4,
});
} else if (act === 'eye') {
const cams = globe
.contactsNear(s.lon, s.lat, 80)
.filter((c) => /cam|cctv|eye/i.test(c.kind));
if (cams.length) {
globe.select(cams[0].id);
globe.flyTo({ lon: cams[0].lon, lat: cams[0].lat, height: 3000 });
toast(`Nearest eye: ${cams[0].title}`);
} else toast('No public camera within 80 km', { icon: icon('video') });
} else if (act === 'thread') {
onThread?.(s);
} else if (act === 'share') {
const url = `${location.origin}${location.pathname}#s=${encodeURIComponent(s.id)}`;
try {
if (navigator.share)
await navigator.share({
title: s.title,
text: `${s.title} — ${s.place}`,
url,
});
else {
await navigator.clipboard.writeText(url);
toast('Link copied', { icon: icon('share') });
}
} catch {
/* share cancelled */
}
}
});
/* ---------- hero geometry ---------- */
function targetRect() {
return stage.getBoundingClientRect();
}
function heroFor(src, rect) {
const el = h(
`<div class="hero" aria-hidden="true"><img alt="" decoding="sync"/></div>`,
);
Object.assign(el.style, {
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
});
const img = el.querySelector('img');
img.src = src || '';
document.body.appendChild(el);
return { el, img };
}
async function imgSize(img) {
if (!img.complete || !img.naturalWidth) {
try {
await img.decode();
} catch {
/* ignore */
}
}
return { w: img.naturalWidth || 480, h: img.naturalHeight || 600 };
}
/** Place the hero image to cover `T`, and compute the transform that makes it cover card rect `C`. */
function coverMath(nat, T, C) {
const sf = Math.max(T.width / nat.w, T.height / nat.h);
const sc = Math.max(C.width / nat.w, C.height / nat.h);
const iw = nat.w * sf;
const ih = nat.h * sf;
const left = (T.width - iw) / 2;
const top = (T.height - ih) / 2;
const dx = C.left + C.width / 2 - (T.left + T.width / 2);
const dy = C.top + C.height / 2 - (T.top + T.height / 2);
return { iw, ih, left, top, k: sc / sf, dx, dy };
}
const clipFor = (T, C, r) =>
`inset(${C.top - T.top}px ${T.right - C.right}px ${T.bottom - C.bottom}px ${C.left - T.left}px round ${r}px)`;
/* ---------- open ---------- */
async function open(s, card = null) {
if (busy) return;
if (current && current.id === s.id) return;
busy = true;
const reduce = reducedMotion();
const wasOpen = !!current;
current = s;
ev.emit('open', s);
render(s);
globe.resume();
const focusing = globe.focus(s);
originCard?.classList.remove('hero-source');
originCard = card;
const media = card?.querySelector('.card-media');
const T = targetRect();
if (wasOpen || !media) {
app.classList.add('is-world');
sheet.open('medium');
busy = false;
haptic('light');
return;
}
const C = media.getBoundingClientRect();
const { el, img } = heroFor(
card.querySelector('img')?.currentSrc || s.thumbnail,
T,
);
const nat = await imgSize(img);
const g = coverMath(nat, T, C);
Object.assign(img.style, {
width: `${g.iw}px`,
height: `${g.ih}px`,
left: `${g.left}px`,
top: `${g.top}px`,
});
card.classList.add('hero-source');
haptic('light');
if (reduce) {
el.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: 160,
fill: 'forwards',
});
await wait(170);
app.classList.add('is-world');
sheet.open('medium');
await el.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 260,
fill: 'forwards',
}).finished;
el.remove();
busy = false;
return;
}
const dur = 620;
const scrim = h('<div class="hero-scrim" aria-hidden="true"></div>');
document.body.insertBefore(scrim, el);
scrim.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: dur * 0.7,
easing: EASE_OUT,
fill: 'forwards',
});
el.animate(
[
{ clipPath: clipFor(T, C, 18) },
{ clipPath: `inset(0px 0px 0px 0px round ${isTwoPane() ? 0 : 0}px)` },
],
{ duration: dur, easing: SPRING_SOFT, fill: 'forwards' },
);
const a = img.animate(
[
{ transform: `translate(${g.dx}px, ${g.dy}px) scale(${g.k})` },
{ transform: 'translate(0,0) scale(1)' },
],
{ duration: dur, easing: SPRING_SOFT, fill: 'forwards' },
);
ev.emit('progress', 'morph');
await a.finished;
card.classList.remove('hero-source');
// The world is behind the photo now: reveal it.
app.classList.add('is-world');
scrim.remove();
sheet.open('medium');
await Promise.race([focusing, wait(250)]);
const fade = el.animate(
[
{ opacity: 1, filter: 'blur(0px)' },
{ opacity: 0, filter: 'blur(6px)' },
],
{ duration: 520, easing: EASE_OUT, fill: 'forwards' },
);
await fade.finished;
el.remove();
busy = false;
}
/* ---------- close ---------- */
async function close({ fromSheet = false } = {}) {
if (!current || busy) return;
busy = true;
const s = current;
const reduce = reducedMotion();
closingFromCode = true;
if (!fromSheet) sheet.close();
closingFromCode = false;
globe.select(null);
let card =
originCard && document.contains(originCard) ? originCard : getCard(s.id);
const T = targetRect();
const twoPane = isTwoPane();
if (!card || twoPane || app.dataset.tab !== 'now') {
app.classList.remove('is-world');
card?.classList.remove('hero-source');
current = null;
originCard = null;
busy = false;
ev.emit('close', s);
return;
}
const { el, img } = heroFor(
card.querySelector('img')?.currentSrc || s.thumbnail,
T,
);
const nat = await imgSize(img);
const fadeIn = el.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: reduce ? 160 : 260,
easing: EASE_OUT,
fill: 'forwards',
});
await fadeIn.finished;
app.classList.remove('is-world');
card.scrollIntoView({ block: 'nearest' });
card.classList.add('hero-source');
const C = card.querySelector('.card-media').getBoundingClientRect();
const g = coverMath(nat, T, C);
Object.assign(img.style, {
width: `${g.iw}px`,
height: `${g.ih}px`,
left: `${g.left}px`,
top: `${g.top}px`,
});
if (reduce) {
card.classList.remove('hero-source');
await el.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 200,
fill: 'forwards',
}).finished;
} else {
const dur = 560;
el.animate(
[
{ clipPath: 'inset(0px 0px 0px 0px round 0px)' },
{ clipPath: clipFor(T, C, 18) },
],
{ duration: dur, easing: SPRING, fill: 'forwards' },
);
await img.animate(
[
{ transform: 'translate(0,0) scale(1)' },
{ transform: `translate(${g.dx}px, ${g.dy}px) scale(${g.k})` },
],
{ duration: dur, easing: SPRING, fill: 'forwards' },
).finished;
card.classList.remove('hero-source');
}
el.remove();
current = null;
originCard = null;
busy = false;
haptic('tick');
ev.emit('close', s);
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
return {
open,
close,
sheet,
get current() {
return current;
},
on: ev.on,
refresh() {
if (current) render(current);
},
};
}
+163
View File
@@ -0,0 +1,163 @@
/** You: captures and their seals, followed situations, holograms, settings. */
import { icon, FAMILY_ICON } from './icons.js';
import { $, $$, esc, ago, haptic, store, applyMotionPref } from './dom.js';
import { all, get } from '../capture/db.js';
import { deviceKey } from '../capture/provenance.js';
export function createYou(
view,
{ bindScroll, getSituations, onOpenSituation, onVerify, onCredits },
) {
let urls = [];
view.addEventListener(
'scroll',
() => view.classList.toggle('is-scrolled', view.scrollTop > 44),
{ passive: true },
);
bindScroll(view);
async function render() {
urls.forEach((u) => URL.revokeObjectURL(u));
urls = [];
const [captures, key, sits] = await Promise.all([
all('captures').catch(() => []),
deviceKey().catch(() => null),
getSituations().catch(() => []),
]);
captures.sort((a, b) => b.seq - a.seq);
const followed = store.get('followed') || [];
const fol = followed
.map((id) => sits.find((s) => String(s.id) === id))
.filter(Boolean);
const contacts = followed.filter((id) => id.startsWith('contact:'));
const thumbs = await Promise.all(
captures.slice(0, 30).map(async (c) => {
const m = await get('media', c.id).catch(() => null);
if (!m?.blob) return '';
const u = URL.createObjectURL(m.blob);
urls.push(u);
return u;
}),
);
const q = store.get('quality');
const rm = store.get('reduceMotion');
const radius = store.get('alertRadiusKm');
view.innerHTML = `
<header class="navbar"><span class="nav-title">You</span></header>
<h1 class="large-title">You</h1>
<div class="you-id"><span class="avatar">${icon('you')}</span><div><b>This device</b>
<span>${key ? `Signing key <span class="mono">${esc(key.keyId)}</span> · P-256 · ${key.extractable ? 'extractable' : 'non-extractable'}` : 'Signing key unavailable (needs a secure context)'}</span>
<span>${captures.length} sealed capture${captures.length === 1 ? '' : 's'} · ${followed.length} followed</span></div></div>
<div class="section-h">Captures <small>tap to verify</small></div>
${
captures.length
? `<div class="captures">${captures
.slice(0, 30)
.map(
(c, i) =>
`<button class="capture-tile" type="button" data-cap="${esc(c.id)}" aria-label="Capture ${c.seq}, ${esc(ago(c.sealedAt))}">${thumbs[i] ? (/video/.test(c.media.type) ? `<video src="${thumbs[i]}" muted playsinline preload="metadata"></video>` : `<img src="${thumbs[i]}" alt="" loading="lazy" decoding="async"/>`) : ''}<span class="ct-n num">#${c.seq}</span><span class="ct-seal">${icon('sealFill')}</span></button>`,
)
.join('')}</div>`
: `<div class="empty">${icon('seal')}<div>No sealed captures yet.</div><div>Open Capture → Witness and tap the shutter.</div></div>`
}
<div class="section-h">Following</div>
${
fol.length || contacts.length
? `<div class="group">${fol
.map(
(s) =>
`<button class="row" type="button" data-sit="${esc(s.id)}" data-family="${esc(s.family)}"><span class="row-icon">${icon(FAMILY_ICON[s.family] || 'now')}</span><span class="row-main"><span class="row-title">${esc(s.title)}</span><span class="row-sub">${esc(s.place)} · ${esc(ago(s.updatedAt))}</span></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
)
.join(
'',
)}${contacts.map((c) => `<div class="row"><span class="row-icon" style="--fam:var(--sky)">${icon('scope')}</span><span class="row-main"><span class="row-title mono">${esc(c.slice(8))}</span><span class="row-sub">Contact</span></span></div>`).join('')}</div>`
: '<div class="empty">Follow a situation from its brief and it appears here and in the tab-bar accessory.</div>'
}
<div class="section-h">Holograms <small><span class="badge-example">Concept</span></small></div>
<div class="holo">${icon('cube')}<div><b>Leave something in a place</b>Pin a 3D note to a spot on Earth for people who visit with Look. Concept placeholder in this prototype.</div></div>
<div class="section-h">Settings</div>
<div class="group">
<div class="row"><span class="row-icon" style="--fam:var(--sky)">${icon('globe')}</span><span class="row-main"><span class="row-title">Globe quality</span><span class="row-sub">Applies on next launch</span></span></div>
<div class="seg-row"><div class="segmented" data-set="quality">${['auto', 'high', 'battery'].map((v) => `<button type="button" data-v="${v}" aria-pressed="${q === v}">${v[0].toUpperCase() + v.slice(1)}</button>`).join('')}<span class="seg-thumb"></span></div></div>
<div class="row"><span class="row-icon" style="--fam:var(--orbit)">${icon('sparkles')}</span><span class="row-main"><span class="row-title">Reduce motion</span><span class="row-sub">Cross-fades instead of flights and morphs</span></span></div>
<div class="seg-row"><div class="segmented" data-set="reduceMotion">${['system', 'on', 'off'].map((v) => `<button type="button" data-v="${v}" aria-pressed="${rm === v}">${v[0].toUpperCase() + v.slice(1)}</button>`).join('')}<span class="seg-thumb"></span></div></div>
<div class="row"><span class="row-icon" style="--fam:var(--hazards)">${icon('pin')}</span><span class="row-main"><span class="row-title">Alert radius</span><span class="row-sub">Situations inside it ping you</span></span><span class="row-value num" data-radius>${radius.toLocaleString()} km</span></div>
<div class="slider-row"><input class="slider" type="range" min="25" max="2000" step="25" value="${radius}" aria-label="Alert radius in kilometres" style="--p:${((radius - 25) / 1975) * 100}%"/></div>
<div class="row"><span class="row-icon" style="--fam:var(--eyes)">${icon('hand')}</span><span class="row-main"><span class="row-title">Haptics</span><span class="row-sub">Ribbon detents and seals only</span></span><button class="switch" type="button" role="switch" aria-checked="${store.get('haptics')}" aria-label="Haptics" data-switch="haptics"></button></div>
</div>
<div class="section-h">About</div>
<div class="group">
<button class="row" type="button" data-act="credits"><span class="row-icon" style="--fam:var(--infra)">${icon('info')}</span><span class="row-main"><span class="row-title">Data &amp; attribution</span><span class="row-sub">Esri, NASA, Vantor, GeoPera, OpenStreetMap</span></span>${icon('chevronRight', { cls: 'chev' })}</button>
<div class="row"><span class="row-icon" style="--fam:var(--seal)">${icon('lock')}</span><span class="row-main"><span class="row-title">Provenance</span><span class="row-sub">Prototype uses WebCrypto ECDSA P-256 with a non-extractable key in IndexedDB. Native builds use the Secure Enclave and C2PA.</span></span></div>
</div>
<p class="footnote">God’s Eye mobile prototype. The window through which you see the world, and the eye through which the world sees itself.</p>`;
$$('.segmented', view).forEach(placeThumb);
}
function placeThumb(seg) {
const b = $('[aria-pressed="true"]', seg) || $('button', seg);
const th = $('.seg-thumb', seg);
if (!b || !th) return;
th.style.width = `${b.offsetWidth}px`;
th.style.transform = `translateX(${b.offsetLeft}px)`;
}
view.addEventListener('click', async (e) => {
const cap = e.target.closest('[data-cap]');
if (cap) {
const c = (await all('captures')).find((x) => x.id === cap.dataset.cap);
if (c) onVerify(c);
return;
}
const sit = e.target.closest('[data-sit]');
if (sit) {
const s = (await getSituations()).find(
(x) => String(x.id) === sit.dataset.sit,
);
if (s) onOpenSituation(s);
return;
}
const segBtn = e.target.closest('.segmented[data-set] button');
if (segBtn) {
const seg = segBtn.parentElement;
$$('button', seg).forEach((b) =>
b.setAttribute('aria-pressed', String(b === segBtn)),
);
placeThumb(seg);
store.set(seg.dataset.set, segBtn.dataset.v);
if (seg.dataset.set === 'reduceMotion') applyMotionPref();
haptic('tick');
return;
}
const sw = e.target.closest('[data-switch]');
if (sw) {
const on = sw.getAttribute('aria-checked') !== 'true';
sw.setAttribute('aria-checked', String(on));
store.set(sw.dataset.switch, on);
haptic('tick');
return;
}
if (e.target.closest('[data-act="credits"]')) onCredits();
});
view.addEventListener('input', (e) => {
if (!e.target.matches('.slider')) return;
const v = Number(e.target.value);
e.target.style.setProperty('--p', `${((v - 25) / 1975) * 100}%`);
$('[data-radius]', view).textContent = `${v.toLocaleString()} km`;
});
view.addEventListener('change', (e) => {
if (e.target.matches('.slider'))
store.set('alertRadiusKm', Number(e.target.value));
});
new ResizeObserver(() => $$('.segmented', view).forEach(placeThumb)).observe(
view,
);
return { render };
}