mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
wip(mobile): checkpoint engine and experience round three in progress
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
83 files changed
+50005
-416
No files matched your search
@@ -0,0 +1,704 @@
|
||||
/**
|
||||
* 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)),
|
||||
})),
|
||||
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 `<span class="cone-thumb" style="aspect-ratio:${w}/${hgt}">
|
||||
<img src="${esc(esriThumb(c.lon, c.lat, span, w * 2, hgt * 2))}" alt="" loading="lazy" decoding="async"/>
|
||||
<svg viewBox="0 0 ${w} ${hgt}" aria-hidden="true"><defs><linearGradient id="cg-${esc(c.id)}" x1="0" y1="0" x2="1" y2="0" gradientTransform="rotate(${(c.headingDeg || 0) - 90} .5 .5)"><stop offset="0" stop-color="#ebc777" stop-opacity=".75"/><stop offset="1" stop-color="#ebc777" stop-opacity=".08"/></linearGradient></defs>
|
||||
<path d="M${cx} ${cy}L${p1[0].toFixed(1)} ${p1[1].toFixed(1)}A${len} ${len} 0 0 1 ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}Z" fill="url(#cg-${esc(c.id)})" stroke="#ebc777" stroke-opacity=".8" stroke-width="1"/>
|
||||
<circle cx="${cx}" cy="${cy}" r="3.6" fill="#ebc777" stroke="#050607" stroke-width="1.4"/>
|
||||
${m?.id === 'empire-state' ? `<path d="M${tx - 5} ${ty - 5}h3M${tx - 5} ${ty - 5}v3M${tx + 5} ${ty - 5}h-3M${tx + 5} ${ty - 5}v3M${tx - 5} ${ty + 5}h3M${tx - 5} ${ty + 5}v-3M${tx + 5} ${ty + 5}h-3M${tx + 5} ${ty + 5}v-3" stroke="#fff" stroke-width="1.1"/>` : ''}
|
||||
</svg></span>`;
|
||||
}
|
||||
|
||||
export function createMomentPlayer({
|
||||
app,
|
||||
globe,
|
||||
onExit,
|
||||
onOpenEntity,
|
||||
onOpenRoom,
|
||||
isTwoPane,
|
||||
}) {
|
||||
const ev = emitter();
|
||||
const el = h(`<section class="mp" aria-label="Moment player" hidden>
|
||||
<div class="mp-stage">
|
||||
<header class="mp-top">
|
||||
<button class="icon-btn glass-strong liquid mp-back" type="button" aria-label="Close Moment">${icon('chevronLeft')}</button>
|
||||
<div class="mp-titles"><div class="mp-kicker tele"></div><h2 class="mp-title"></h2></div>
|
||||
<button class="icon-btn glass-strong liquid mp-room" type="button" aria-label="Open the room">${icon('rooms')}</button>
|
||||
</header>
|
||||
<div class="mp-clock tele"><b class="num mp-utc">—</b><span class="mp-rel num"></span></div>
|
||||
<div class="mp-legend glass-strong" role="list" aria-label="Tracks"></div>
|
||||
<div class="mp-pops" aria-live="polite"></div>
|
||||
<div class="mp-react" role="group" aria-label="React">
|
||||
<button type="button" data-react="heart" aria-label="Heart">❤️</button><button type="button" data-react="fire" aria-label="Fire">🔥</button><button type="button" data-react="eyes" aria-label="Eyes">👀</button><button type="button" data-react="wow" aria-label="Wow">😮</button>
|
||||
</div>
|
||||
<div class="mp-bar glass-strong">
|
||||
<button class="mp-play" type="button" aria-label="Pause">${icon('pause')}</button>
|
||||
<div class="mp-scrub" role="slider" tabindex="0" aria-label="Moment time"><canvas></canvas><i class="mp-head"></i></div>
|
||||
<div class="mp-speeds" role="radiogroup" aria-label="Speed">${SPEEDS.map((s) => `<button type="button" role="radio" data-speed="${s}" aria-checked="${s === 30}">${s}×</button>`).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="mp-side glass-strong">
|
||||
<div class="mp-side-title"><div class="mp-st-k tele"></div><h3></h3><p class="mp-st-s"></p></div>
|
||||
<div class="mp-side-head">
|
||||
<div class="segmented mp-seg" role="tablist"><button role="tab" type="button" data-seg="chat" aria-selected="true">Chat<span class="mp-n" data-n="chat"></span></button><button role="tab" type="button" data-seg="record" aria-selected="false">Record<span class="mp-n" data-n="record"></span></button><button role="tab" type="button" data-seg="captures" aria-selected="false">Captures<span class="mp-n" data-n="captures"></span></button></div>
|
||||
</div>
|
||||
<div class="mp-pane" data-pane="chat"><div class="mp-chat"></div></div>
|
||||
<div class="mp-pane" data-pane="record" hidden><div class="mp-record"></div></div>
|
||||
<div class="mp-pane" data-pane="captures" hidden><div class="mp-caps"></div></div>
|
||||
<p class="mp-src tele"></p>
|
||||
</aside>
|
||||
</section>`);
|
||||
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);
|
||||
|
||||
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,
|
||||
) => `<button class="mp-tr" type="button" role="listitem" data-track="${esc(tr.id)}" style="--c:${esc(tr.color || TRACK_COL[i % TRACK_COL.length])}">
|
||||
<i class="sw"></i><span class="mp-tr-main"><b>${esc(tr.callsign || String(tr.label).replace(/\s*\(illustrative\)/i, ''))}</b><small>${esc(tr.operator || tr.type || tr.kind || '')}</small></span>
|
||||
<span class="mp-tr-v tele"><b class="num" data-alt>—</b><span data-spd>—</span></span></button>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function capCard(c, { pop = false } = {}) {
|
||||
const fe = c.firstEyes;
|
||||
return `<button class="mp-cap ${pop ? 'pop' : ''} ${fe ? 'first' : ''}" type="button" data-cap="${esc(c.id)}">
|
||||
${coneThumb(c, m)}
|
||||
<span class="mp-cap-body">
|
||||
${c.self ? `<span class="fe tele">${icon('sealFill')}YOURS${fe ? ' · FIRST EYES' : ''}</span>` : fe ? `<span class="fe tele">${icon('sealFill')}FIRST EYES</span>` : `<span class="fe tele">${icon(c.live ? 'live' : 'seal')}${c.live ? 'LIVE · SEALED' : 'SEALED'}</span>`}
|
||||
<b>${esc(c.title)}</b>
|
||||
<small>${esc(c.handle)} · ${esc(utc(c.t))} · ${esc(fmtKm((c.distanceM || 0) / 1000))}</small>
|
||||
</span></button>`;
|
||||
}
|
||||
|
||||
function chatHtml(c) {
|
||||
const b = c.badge && BADGE[c.badge];
|
||||
return `<div class="mp-msg ${c.badge ? `b-${esc(c.badge)}` : ''}"><span class="mp-who">${b ? `<span class="mp-badge" title="${esc(b[0])}">${icon(b[1])}</span>` : ''}${esc(c.handle)}</span> <span class="mp-txt">${esc(c.text)}</span></div>`;
|
||||
}
|
||||
|
||||
function postHtml(p) {
|
||||
const k = KIND[p.kind] || KIND.context;
|
||||
return `<article class="mp-post k-${esc(p.kind)}"><header><span class="mp-pk">${icon(k[0])}${esc(k[1])}</span><span class="tele">${esc(utc(p.t, { seconds: false }))}</span></header>
|
||||
<p><b>${esc(p.handle)}</b> ${esc(p.text)}</p>
|
||||
${p.kind !== 'synthesis' ? `<footer class="tele"><span>${icon('agree')}${p.votes?.corroborate ?? 0} corroborate</span><span>${icon('dispute')}${p.votes?.dispute ?? 0} dispute</span>${p.captureId ? `<button type="button" data-cap="${esc(p.captureId)}">${icon('seal')}Sealed capture</button>` : ''}</footer>` : '<footer class="tele"><span>AI synthesis · cites 5 captures and 37 messages</span></footer>'}</article>`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
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('') ||
|
||||
`<p class="mp-empty tele">${(m.chat || []).length ? 'The chat starts as the Moment does' : 'No chat was recorded in this window'}</p>`;
|
||||
} 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('') ||
|
||||
'<p class="mp-empty tele">Captures appear here at the moment they were sealed</p>';
|
||||
// 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}` : '';
|
||||
// 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 = () =>
|
||||
'<p class="mp-empty tele">Record posts appear as they were made</p>';
|
||||
|
||||
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 =
|
||||
`<span class="mp-dot"></span>MOMENT · ${esc(m.place || '')}${m.simulated ? ' <span class="tag-ill">Simulated</span>' : m.illustrative ? ' <span class="tag-ill">Illustrative</span>' : ' <span class="tag-real">Recorded</span>'}`;
|
||||
$('.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',
|
||||
`<div class="mp-more tele">+${extra} more tracks on the globe</div>`,
|
||||
);
|
||||
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 };
|
||||
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('<div class="mp-ext"></div>');
|
||||
slot.prepend(host);
|
||||
ext = sm.chatOverlay(host, { moment: raw, globe, getTime: () => t });
|
||||
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;
|
||||
globe.exitMoment();
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user