${esc(p.handle)} ${esc(p.text)}
${p.kind !== 'synthesis' ? `` : ''}/**
* The Moments player (DESIGN.md §12.4): a cinematic, full-screen replay of a
* 4D reconstruction. The globe plays the Moment (globe.playMoment); a scrubber
* covers just the Moment's window; captures pop in at their times with their
* First-eyes byline, seal and view cone; the chat replays beside it (the
* social agent's chatOverlay when present); record posts appear as they were
* made; a legend reads each track's callsign, operator, altitude and speed.
*
* On a phone it is an overlay on the globe; on a tablet the chat, captures and
* record move to the leading pane and the globe keeps the trailing pane.
*/
import { icon } from './icons.js';
import {
$,
$$,
h,
esc,
haptic,
toast,
clamp,
esriThumb,
reducedMotion,
emitter,
} from './dom.js';
import { utc, fmtKm } from './bands.js';
import { trackAt } from './stubMoments.js';
import { social } from './socialBridge.js';
const TRACK_COL = [
'#a3d8ff',
'#ffd27a',
'#ff9db0',
'#9be15d',
'#b39dff',
'#7cf0d0',
];
const SPEEDS = [1, 10, 30, 60];
const KIND = {
witness: ['witness', 'Witness'],
context: ['context', 'Context'],
question: ['question', 'Question'],
correction: ['correction', 'Correction'],
prediction: ['prediction', 'Prediction'],
synthesis: ['sparkles', 'Clerk · synthesis'],
};
const BADGE = {
'first-eyes': ['First eyes', 'seal'],
witness: ['Was there', 'witness'],
pilot: ['Pilot', 'sky'],
local: ['Local', 'pin'],
mod: ['Mod', 'checkmark'],
};
const at = (h) =>
!h || h.startsWith('@') || h === 'Clerk' || h === 'Record' ? h : `@${h}`;
/** The engine's Moment and the shell's share one shape after this. */
export function normMoment(m) {
const subject = m.subject || { lon: m.lon, lat: m.lat };
const R = 6371008.8;
const dist = (a) => {
const dLat = ((subject.lat - a.lat) * Math.PI) / 180;
const dLon = ((subject.lon - a.lon) * Math.PI) / 180;
const x =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) *
Math.cos((subject.lat * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));
};
return {
...m,
subject,
tracks: (m.tracks || []).map((tr) => ({
...tr,
arriveAt: tr.arriveAt ?? tr.points?.find((p) => dist(p) < 900)?.t,
})),
captures: (m.captures || []).map((c) => ({
...c,
handle: at(c.handle),
where: c.where || c.place || '',
media: c.media || c.kind || 'photo',
live: c.live || c.kind === 'live',
distanceM: c.distanceM ?? c.rangeM ?? Math.round(dist(c)),
headingDeg: Number.isFinite(c.headingDeg)
? c.headingDeg
: (Math.atan2(
(subject.lon - c.lon) * Math.cos((c.lat * Math.PI) / 180),
subject.lat - c.lat,
) *
180) /
Math.PI,
})),
chat: (m.chat || []).map((c) => ({ ...c, handle: at(c.handle) })),
posts: (m.posts || []).map((p) => ({
...p,
handle: at(p.handle),
votes:
typeof p.votes === 'number'
? { corroborate: p.votes, dispute: 0 }
: p.votes || { corroborate: 0, dispute: 0 },
})),
};
}
/** A capture's thumbnail: the place, with its view cone toward the subject. */
export function coneThumb(c, m, { w = 168, hgt = 120 } = {}) {
const span = 0.006;
const mPerPx = (span * 111320 * (hgt / w)) / hgt;
const len = clamp((c.distanceM || 250) / mPerPx, 26, w * 0.7);
const a = ((c.headingDeg || 0) - 90) * (Math.PI / 180);
const f = ((c.fovDeg || 52) / 2) * (Math.PI / 180);
const cx = w / 2;
const cy = hgt / 2;
const p1 = [cx + len * Math.cos(a - f), cy + len * Math.sin(a - f)];
const p2 = [cx + len * Math.cos(a + f), cy + len * Math.sin(a + f)];
const tx = cx + len * 0.92 * Math.cos(a);
const ty = cy + len * 0.92 * Math.sin(a);
return `
`;
}
export function createMomentPlayer({
app,
globe,
onExit,
onOpenEntity,
onOpenRoom,
isTwoPane,
}) {
const ev = emitter();
const el = h(`
${esc(p.handle)} ${esc(p.text)}
${p.kind !== 'synthesis' ? `` : ''}Sealed captures and record posts appear here at the moment they were made
'; feedKeys = keys; feedEl.scrollTop = 0; } $('.mp-fn', el).textContent = items.length ? `${items.length} ITEM${items.length === 1 ? '' : 'S'}` : ''; } function drawScrub() { const r = scrub.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); const W = r.width; const H = r.height; if (canvas.width !== Math.round(W * dpr)) { canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); } sctx.setTransform(dpr, 0, 0, dpr, 0, 0); sctx.clearRect(0, 0, W, H); const s = m.time.start; const e = m.time.end; const x = (ms) => 6 + ((ms - s) / (e - s)) * (W - 12); const mid = H / 2 + 6; // Chat density (a quiet histogram). const bins = new Array(60).fill(0); for (const c of m.chat || []) bins[Math.min(59, Math.floor(((c.t - s) / (e - s)) * 60))] += 1; const peak = Math.max(1, ...bins); sctx.fillStyle = 'rgba(255,255,255,0.12)'; bins.forEach((b, i) => { const bh = (b / peak) * 12; sctx.fillRect( 6 + (i / 60) * (W - 12), mid - 6 - bh, (W - 12) / 60 - 1, bh, ); }); // Track and progress sctx.fillStyle = 'rgba(255,255,255,0.14)'; sctx.fillRect(6, mid - 1.5, W - 12, 3); sctx.fillStyle = '#fff'; sctx.fillRect(6, mid - 1.5, x(t) - 6, 3); // Arrivals, captures and posts // Beats (the story's chapters), else arrivals. const beats = m.beats?.length ? m.beats.map((b) => b.t) : (m.tracks || []).map((tr) => tr.arriveAt).filter(Boolean); for (const bt of beats) { sctx.fillStyle = bt <= t ? 'rgba(163,216,255,0.95)' : 'rgba(163,216,255,0.4)'; sctx.fillRect(x(bt) - 1, mid - 9, 2, 18); } for (const p of m.posts || []) { sctx.fillStyle = p.t <= t ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.35)'; sctx.beginPath(); sctx.arc(x(p.t), mid + 9, 2, 0, Math.PI * 2); sctx.fill(); } // First eyes stamps its byline onto the timeline as the clock passes it. for (const c of m.captures || []) { if (!c.firstEyes || c.t > t) continue; const k = clamp((t - c.t) / 6000, 0, 1); const label = `FIRST EYES · ${c.handle || ''}`; sctx.font = '700 8px "JetBrains Mono", ui-monospace, monospace'; const w = sctx.measureText(label).width + 12; const bx = clamp(x(c.t) - w / 2, 2, W - w - 2); sctx.globalAlpha = k; sctx.fillStyle = 'rgba(235,199,119,0.95)'; sctx.beginPath(); sctx.roundRect(bx, 1 + (1 - k) * 4, w, 12, 6); sctx.fill(); sctx.fillStyle = '#2a1a00'; sctx.textAlign = 'left'; sctx.fillText(label, bx + 6, 10 + (1 - k) * 4); sctx.globalAlpha = 1; } for (const c of m.captures || []) { sctx.fillStyle = c.t <= t ? '#ebc777' : 'rgba(235,199,119,0.4)'; sctx.beginPath(); sctx.arc(x(c.t), mid, c.firstEyes ? 5 : 3.6, 0, Math.PI * 2); sctx.fill(); sctx.strokeStyle = '#08090b'; sctx.lineWidth = 1.2; sctx.stroke(); } $('.mp-head', el).style.transform = `translateX(${x(t)}px)`; } /* ---------- the clock ---------- */ function sync(force = false) { const nc = (m.chat || []).filter((c) => c.t <= t).length; const np = (m.posts || []).filter((p) => p.t <= t).length; const caps = (m.captures || []).filter((c) => c.t <= t); const nk = caps.length; // Seeking back re-renders; playing forward appends with a pop. // The social overlay replays the raw chat by the clock itself. if (ext) { try { ext.setTime?.(t); } catch { /* ignore */ } } if (force || nc < shown.chat) { chatEl.innerHTML = (m.chat || []) .filter((c) => c.t <= t) .slice(-40) .map(chatHtml) .join('') || `${(m.chat || []).length ? 'The chat starts as the Moment does' : 'No chat was recorded in this window'}
`; } else for (const c of (m.chat || []).slice(shown.chat, nc)) { chatEl.querySelector('.mp-empty')?.remove(); chatEl.insertAdjacentHTML('beforeend', chatHtml(c)); const last = chatEl.lastElementChild; last.classList.add('in'); while (chatEl.children.length > 60) chatEl.firstElementChild.remove(); // Reactions in chat rise from the city on the globe. const k = /❤️|🙏|♥/.test(c.text) ? 'heart' : /🔥/.test(c.text) ? 'fire' : /👀/.test(c.text) ? 'eyes' : /😮|wild|wow/i.test(c.text) ? 'wow' : null; if (k) burst(k, 4); } if (force || np < shown.posts) recEl.innerHTML = (m.posts || []) .filter((p) => p.t <= t) .map(postHtml) .reverse() .join('') || emptyRec(); else for (const p of (m.posts || []).slice(shown.posts, np)) { recEl.querySelector('.mp-empty')?.remove(); recEl.insertAdjacentHTML('afterbegin', postHtml(p)); recEl.firstElementChild.classList.add('in'); } if (force || nk < shown.caps) { capsEl.innerHTML = caps .map((c) => capCard(c)) .reverse() .join('') || 'Captures appear here at the moment they were sealed
'; // After a seek, the latest capture stays pinned (settled) for context. pops.innerHTML = caps.length ? capCard(caps[caps.length - 1]).replace( 'class="mp-cap ', 'class="mp-cap settled ', ) : ''; } else for (const c of caps.slice(shown.caps)) { capsEl.querySelector('.mp-empty')?.remove(); capsEl.insertAdjacentHTML('afterbegin', capCard(c)); popCapture(c); } if (nc !== shown.chat && seg === 'chat') { const pane = chatEl.parentElement; pane.scrollTop = pane.scrollHeight; } shown = { chat: nc, posts: np, caps: nk }; $('[data-n="chat"]', el).textContent = nc ? ` ${nc}` : ''; $('[data-n="record"]', el).textContent = np ? ` ${np}` : ''; $('[data-n="captures"]', el).textContent = nk ? ` ${nk}` : ''; $('.mp-chn', el).textContent = nc ? `${nc} MESSAGES` : ''; if (isTwoPane()) renderFeed(); // Legend: live per-track readouts. (m.tracks || []).forEach((tr) => { const row = legend.querySelector(`[data-track="${CSS.escape(tr.id)}"]`); if (!row) return; const p = trackAt(tr, t); row.classList.toggle('absent', !p); $('[data-alt]', row).textContent = p ? `${Math.round((p.altM * 3.28084) / 10) * 10} FT` : '—'; $('[data-spd]', row).textContent = !p ? 'NOT YET' : p.speedMps ? `${Math.round(p.speedMps * 1.94384)} KT` : 'LANDED'; }); $('.mp-utc', el).textContent = utc(t); $('.mp-rel', el).textContent = `${rel(t)} / ${rel(m.time.end)}`; drawScrub(); } const emptyRec = () => 'Record posts appear as they were made
'; function popCapture(c) { const card = h(capCard(c, { pop: true })); pops.prepend(card); haptic(c.firstEyes ? 'seal' : 'light'); while (pops.children.length > (isTwoPane() ? 3 : 1)) pops.lastElementChild.remove(); setTimeout(() => card.classList.add('settle'), 4200); } function burst(kind, count = 3) { const now = performance.now(); if (now - reactCooldown < 200) return; reactCooldown = now; const jitter = () => (Math.random() - 0.5) * 0.01; globe.react({ lon: (m.lon ?? 0) + jitter(), lat: (m.lat ?? 0) + jitter(), kind, count, }); } function loop() { raf = 0; if (!m) return; const a = globe.activeMoment(); const st = globe.playback.state(); const nt = Number.isFinite(a?.t) ? a.t : Number.isFinite(st?.t) ? st.t : t; if (nt !== t) { t = clamp(nt, m.time.start, m.time.end); sync(); } setPlayingUi(!!(a?.playing ?? st?.playing)); raf = requestAnimationFrame(loop); } function setPlayingUi(p) { if (el.classList.contains('playing') === p) return; el.classList.toggle('playing', p); $('.mp-play', el).innerHTML = icon(p ? 'pause' : 'play'); $('.mp-play', el).setAttribute('aria-label', p ? 'Pause' : 'Play'); } /* ---------- open / close ---------- */ async function open( idOrMoment, { at: atT = null, speed: s = null, autoplay = true } = {}, ) { const raw = typeof idOrMoment === 'string' ? await globe.moment(idOrMoment) : idOrMoment; const mm = raw ? normMoment(raw) : null; if (!mm) { toast('That Moment is not available', { icon: icon('film') }); return null; } m = mm; speed = s ?? (SPEEDS.includes(m.recommendedSpeed) ? m.recommendedSpeed : 30); $$('[data-speed]', el).forEach((b) => b.setAttribute('aria-checked', String(Number(b.dataset.speed) === speed)), ); el.dataset.illustrative = String(!!m.illustrative); $('.mp-kicker', el).innerHTML = `MOMENT · ${esc(m.place || '')}${m.simulated ? ' Simulated' : m.illustrative ? ' Illustrative' : ' Recorded'}`; $('.mp-title', el).textContent = m.title; $('.mp-side-title h3', el).textContent = m.title; $('.mp-st-k', el).innerHTML = $('.mp-kicker', el).innerHTML; $('.mp-st-s', el).textContent = `${Math.round((m.time.end - m.time.start) / 60e3)} min · ${(m.tracks || []).length} tracks · ${(m.captures || []).length} sealed captures · ${(m.posts || []).length} posts on the record`; legend.innerHTML = legendHtml(); const extra = (m.tracks || []).length - 4; if (extra > 0) legend.insertAdjacentHTML( 'beforeend', `