/** * 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(``); app.appendChild(el); const legend = $('.mp-legend', el); const pops = $('.mp-pops', el); const scrub = $('.mp-scrub', el); const canvas = $('canvas', scrub); const sctx = canvas.getContext('2d'); const chatEl = $('.mp-chat', el); const recEl = $('.mp-record', el); const capsEl = $('.mp-caps', el); const feedEl = $('.mp-feed-list', el); let feedKeys = new Set(); let m = null; let raf = 0; let t = 0; let speed = 30; let shown = { chat: 0, posts: 0, caps: 0 }; let ext = null; // social chatOverlay let seg = 'chat'; let wasPlaying = false; let reactCooldown = 0; let offOwn = null; /* ---------- rendering pieces ---------- */ const rel = (ms) => { const s = Math.max(0, Math.round((ms - m.time.start) / 1000)); return `+${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }; function legendHtml() { return (m.tracks || []) .map( ( tr, i, ) => ``, ) .join(''); } function capCard(c, { pop = false } = {}) { const fe = c.firstEyes; return ``; } function chatHtml(c) { const b = c.badge && BADGE[c.badge]; return `
${b ? `${icon(b[1])}` : ''}${esc(c.handle)} ${esc(c.text)}
`; } function postHtml(p) { const k = KIND[p.kind] || KIND.context; return `
${icon(k[0])}${esc(k[1])}${esc(utc(p.t, { seconds: false }))}

${esc(p.handle)} ${esc(p.text)}

${p.kind !== 'synthesis' ? `` : ''}
`; } /** A capture as a compact row for the leading pane's feed. */ function capRow(c) { const fe = c.firstEyes; return ``; } /** * The leading pane's feed (tablet): captures and record posts stacked * newest first as the clock passes them. */ function renderFeed() { const items = [ ...(m.captures || []) .filter((c) => c.t <= t) .map((c) => ({ key: `c:${c.id}`, t: c.t, html: capRow(c) })), ...(m.posts || []) .filter((p) => p.t <= t) .map((p) => ({ key: `p:${p.id || p.t}`, t: p.t, html: postHtml(p).replace('class="mp-post', 'class="mp-fi mp-post'), })), ].sort((a, b) => b.t - a.t); const keys = new Set(items.map((x) => x.key)); const same = keys.size === feedKeys.size && [...keys].every((k) => feedKeys.has(k)); if (!same) { const fresh = feedKeys.size > 0; feedEl.innerHTML = items .slice(0, 12) .map((x) => fresh && !feedKeys.has(x.key) ? x.html.replace('class="mp-fi', 'class="mp-fi in') : x.html, ) .join('') || '

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', `
+${extra} more tracks on the globe
`, ); legend.hidden = !(m.tracks || []).length; $('.mp-src', el).textContent = (m.sources || []) .map((x) => `${x.name}${x.detail ? ` — ${x.detail}` : ''}`) .join(' · '); shown = { chat: 0, posts: 0, caps: 0 }; feedKeys = new Set(); feedEl.innerHTML = ''; chatEl.innerHTML = ''; recEl.innerHTML = emptyRec(); capsEl.innerHTML = ''; pops.innerHTML = ''; // The social agent's chat replay, when it exists. ext = null; const sm = await social(); // Your own sealed captures posted into this Moment join its timeline. const addOwn = (list) => { for (const c of list || []) { const id = String(c.id).split(':').pop(); if (m.captures.some((x) => x.id === id)) continue; m.captures.push( normMoment({ ...m, captures: [ { ...c, id, t: clamp(+c.t || m.time.end, m.time.start, m.time.end), self: true, title: c.title || 'Your sealed capture', }, ], }).captures[0], ); } m.captures.sort((a, b) => a.t - b.t); }; try { addOwn(await sm?.momentCaptures?.(m.id)); } catch { /* optional */ } offOwn?.(); offOwn = sm?.onSocial?.('moment-capture', (e) => { if (!m || e?.momentId !== m.id) return; addOwn([e.capture]); sync(true); }) || null; const slot = $('.mp-pane[data-pane="chat"]', el); slot.classList.remove('has-ext'); el.classList.remove('has-ext'); $('.mp-ext', slot)?.remove(); // Clips carry no chat: the overlay would only invent one. if (typeof sm?.chatOverlay === 'function' && (m.chat || []).length) { try { const host = h('
'); slot.prepend(host); ext = sm.chatOverlay(host, { moment: raw, globe, getTime: () => t, // A tablet's leading pane has the height for a real chat column. maxLines: isTwoPane() ? 60 : 9, }); ext?.setMoment?.({ ...raw, chat: [] }); slot.classList.add('has-ext'); el.classList.add('has-ext'); } catch (err) { console.warn( '[moment] chatOverlay failed; using the built-in replay', err, ); ext = null; } } el.hidden = false; app.classList.add('is-moment'); void el.offsetWidth; el.classList.add('in'); t = atT ?? m.time.start; await globe.playMoment(raw, { speed, camera: true, autoplay: false }); if (atT != null) globe.seekMoment(atT); sync(true); if (autoplay) globe.playback.play({ speed }); cancelAnimationFrame(raf); raf = requestAnimationFrame(loop); ev.emit('open', m); return m; } function close() { if (!m) return; cancelAnimationFrame(raf); raf = 0; // Stop the clock and let a frame render before the engine tears the // Moment's primitives down (exiting mid-frame can stop Cesium's render). globe.playback.stop(); const leaving = m; requestAnimationFrame(() => requestAnimationFrame(() => { if (m === null || m === leaving) globe.exitMoment(); }), ); try { ext?.destroy?.(); } catch { /* ignore */ } ext = null; offOwn?.(); offOwn = null; const was = m; m = null; el.classList.remove('in'); app.classList.remove('is-moment'); setTimeout( () => { if (!m) el.hidden = true; }, reducedMotion() ? 0 : 320, ); ev.emit('close', was); onExit?.(was); } /* ---------- input ---------- */ $('.mp-back', el).addEventListener('click', () => { haptic('tick'); close(); }); $('.mp-room', el).addEventListener('click', () => m && onOpenRoom?.(m)); $('.mp-play', el).addEventListener('click', () => { haptic('tick'); const st = globe.playback.state(); if (st.playing) globe.playback.stop(); else globe.playback.play({ speed }); }); $('.mp-speeds', el).addEventListener('click', (e) => { const b = e.target.closest('[data-speed]'); if (!b) return; haptic('tick'); speed = Number(b.dataset.speed); $$('[data-speed]', el).forEach((x) => x.setAttribute('aria-checked', String(x === b)), ); if (globe.playback.state().playing) globe.playback.play({ speed }); }); $('.mp-react', el).addEventListener('click', (e) => { const b = e.target.closest('[data-react]'); if (!b || !m) return; haptic('light'); reactCooldown = 0; burst(b.dataset.react, 3); b.animate( [ { transform: 'scale(1)' }, { transform: 'scale(1.35)' }, { transform: 'scale(1)' }, ], { duration: 320, easing: 'cubic-bezier(.32,1.25,.4,1)' }, ); }); const seekTo = (clientX) => { const r = scrub.getBoundingClientRect(); const k = clamp((clientX - r.left - 6) / (r.width - 12), 0, 1); const ms = m.time.start + k * (m.time.end - m.time.start); globe.seekMoment(ms); t = ms; sync(true); }; let dragging = false; scrub.addEventListener('pointerdown', (e) => { if (!m) return; scrub.setPointerCapture(e.pointerId); dragging = true; wasPlaying = globe.playback.state().playing; globe.playback.stop(); el.classList.add('scrubbing'); seekTo(e.clientX); }); scrub.addEventListener('pointermove', (e) => dragging && seekTo(e.clientX)); const up = () => { if (!dragging) return; dragging = false; el.classList.remove('scrubbing'); if (wasPlaying) globe.playback.play({ speed }); }; scrub.addEventListener('pointerup', up); scrub.addEventListener('pointercancel', up); scrub.addEventListener('keydown', (e) => { if (!m) return; const step = e.shiftKey ? 60e3 : 10e3; if (e.key === 'ArrowRight') globe.seekMoment(Math.min(m.time.end, t + step)); else if (e.key === 'ArrowLeft') globe.seekMoment(Math.max(m.time.start, t - step)); else if (e.key === ' ') $('.mp-play', el).click(); else return; e.preventDefault(); t = globe.activeMoment()?.t ?? t; sync(true); }); $('.mp-seg', el).addEventListener('click', (e) => { const b = e.target.closest('[data-seg]'); if (!b) return; haptic('tick'); seg = b.dataset.seg; $$('[data-seg]', el).forEach((x) => x.setAttribute('aria-selected', String(x === b)), ); $$('.mp-pane', el).forEach((p) => (p.hidden = p.dataset.pane !== seg)); }); el.addEventListener('click', (e) => { const tr = e.target.closest('[data-track]'); if (tr && m) return onOpenEntity?.(`moment:${m.id}:${tr.dataset.track}`); const c = e.target.closest('[data-cap]'); if (c && m) return onOpenEntity?.(`capture:${m.id}:${c.dataset.cap}`); }); document.addEventListener('keydown', (e) => { if (!m || e.target.closest?.('input, textarea')) return; if ( e.key === 'Escape' && !document.querySelector( '.sheet-layer:not([hidden]) .sheet[aria-modal="true"]', ) ) close(); }); new ResizeObserver(() => m && drawScrub()).observe(scrub); return { open, close, on: ev.on, get current() { return m; }, get time() { return t; }, /** Deterministic setup for the director. */ seek(ms, { play = false } = {}) { if (!m) return; globe.seekMoment(ms); t = ms; sync(true); if (play) globe.playback.play({ speed }); else globe.playback.stop(); }, setSeg(s) { $(`[data-seg="${s}"]`, el)?.click(); }, }; }