mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
56 files changed
+23645
No files matched your search
@@ -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 & 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,
|
||||
};
|
||||
}
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 & 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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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]),
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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())),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 };
|
||||
}
|
||||
Reference in new issue
Block a user