/* Situation sheet: detents (peek / medium / large), drag with velocity snapping, the world always
wins (touching the globe collapses to peek). Tufte-grade brief: direct-labelled sparklines with
uncertainty, small multiples rendered from the globe, evidence strip, every number sourced + aged. */
const SH = (GE.sheet = { id: null, detent: 'medium', seg: 'room' });
const sheetEl = () => $('#sheet');
SH.heights = function () {
const H = $('#app').clientHeight; const sab = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--sab')) || 0;
return { peek: 112 + sab, medium: Math.round(H * 0.45), large: Math.round(H * 0.92), H };
};
SH.setDetent = function (d, animate = true) {
SH.detent = d; const el = sheetEl(); if (APP.isWide()) { el.classList.add('wopen'); return; }
const hs = SH.heights(); const full = el.offsetHeight || hs.H; const vis = hs[d];
el.classList.toggle('drag', !animate);
el.style.transform = `translateY(${Math.max(0, full - vis)}px)`;
const sc = $('#sheetC'); sc.style.overflowY = d === 'large' ? 'auto' : 'hidden'; sc.style.touchAction = d === 'large' ? 'pan-y' : 'none';
if (d !== 'large') sc.scrollTop = 0;
SH.targetShift = d === 'large' ? 0.75 : clamp(vis / hs.H, 0, 0.8);
};
SH.open = function (id, o = {}) {
const s = GE.SIT.byId[id]; if (!s) return;
const W = GE.world;
SH.fromTab = APP.mode === 'situation' ? SH.fromTab : APP.tab; SH.id = id; SH.sit = s; SH.opts = o; SH.seg = o.seg || 'room';
if (APP.tab !== 'globe' && !APP.isWide()) { APP.tab = 'globe'; $('#app').dataset.tab = 'globe'; $$('.tab').forEach((b) => b.classList.toggle('on', b.dataset.tab === 'globe')); $$('.pane .view').forEach((v) => v.classList.remove('on')); APP.moveLens(); }
APP.setMode('situation');
// layers & time window
SH._savedLayers = SH._savedLayers || W.layers.map((l) => l.on);
for (const lid of s.layers || []) if (W.layerById[lid]) W.layerById[lid].on = true;
bus.emit('layers', W.layers);
if (s.time) { W.setWindow({ start: s.time.start ?? s.time.at - 6 * 3600000, end: s.time.end ?? s.time.at + 6 * 3600000 }); W.setTime(s.time.at); }
W.auroraK = s.auroraK ?? 0.85;
GE.setExtras && GE.setExtras(s.id); GE.setLabels3d(s.thread ? GE.threads.data[s.thread].labels : s.labels3d); GE.roomUI.labels(s, o.post);
// camera
const cam = o.cam || GE.SIT.cameraFor(s);
const hs = SH.heights();
const shiftY = APP.isWide() ? 0 : hs.medium / hs.H;
SH.targetShift = shiftY;
Cam.autoSpin = 0;
if (o.morph) { Cam.set({ ...cam, shiftY, shiftX: 0 }); Cam.flyTo({ range: cam.range * 0.82, heading: cam.heading + 0.12 }, { duration: 2.6 }); }
else Cam.flyTo({ ...cam, shiftY, shiftX: 0 });
W.focus = s.kind === 'recon' ? null : { lat: s.lat, lon: s.lon, k: 0.35 };
SH.render(s);
const el = sheetEl();
if (APP.isWide()) { el.classList.add('wopen'); }
else { el.classList.add('drag'); el.style.transform = 'translateY(100%)'; requestAnimationFrame(() => requestAnimationFrame(() => SH.setDetent(o.detent || 'medium'))); }
if (o.ride && s.ride) setTimeout(() => GE.ride.start({ kind: 'heli', id: 0 }), 650);
APP.updateChrome();
};
SH.close = function (silent, keepTime) {
if (!SH.id) return; const id = SH.id; const W = GE.world;
const el = sheetEl(); el.classList.remove('drag', 'wopen'); el.style.transform = APP.isWide() ? '' : 'translateY(100%)';
SH.id = null; W.focus = null; GE.setExtras && GE.setExtras(null); GE.setLabels3d(null); GE.roomUI.composer(false);
if (SH._savedLayers) { W.layers.forEach((l, i) => (l.on = SH._savedLayers[i])); SH._savedLayers = null; bus.emit('layers', W.layers); }
if (!keepTime) { W.setWindow(null); W.setTime(null); } W.auroraK = 0.85;
APP.setMode('');
if (silent) return;
const fromNow = !APP.isWide() && GE.now && $(`#v-now .card[data-id="${id}"]`);
if (SH.fromTab === 'threads' || SH.fromTab === 'you') APP.setTab(SH.fromTab, { force: true });
else if (fromNow) { APP.setTab('now', { force: true }); requestAnimationFrame(() => GE.now.foldBack(id)); }
else { Cam.flyTo({ range: Math.max(Cam.range * 3, 0.6), shiftY: APP.isWide() ? 0 : 0.06, tilt: 0 }); }
};
/* drag */
SH.initDrag = function () {
const el = sheetEl(); let st = null;
const begin = (e) => {
if (APP.isWide()) return;
const onGrab = e.target.closest('#grab'); const sc = $('#sheetC');
if (!onGrab && SH.detent === 'large') return;
if (e.target.closest('button, a, input, textarea, .evid, .multi')) return;
st = { y: e.clientY, y0: e.clientY, t: performance.now(), base: new DOMMatrix(getComputedStyle(el).transform).m42, moved: 0, id: e.pointerId, samples: [] };
el.setPointerCapture?.(e.pointerId);
};
el.addEventListener('pointerdown', begin);
el.addEventListener('pointermove', (e) => {
if (!st || e.pointerId !== st.id) return;
const dy = e.clientY - st.y0; st.moved = Math.max(st.moved, Math.abs(dy));
if (st.moved < 4) return;
el.classList.add('drag');
const hs = SH.heights(); const full = el.offsetHeight; const minY = full - hs.large, maxY = full - hs.peek + 40;
let y = st.base + dy; if (y < minY) y = minY - Math.sqrt(minY - y) * 3; if (y > maxY) y = maxY + Math.sqrt(y - maxY) * 3;
el.style.transform = `translateY(${y}px)`;
st.samples.push({ t: performance.now(), y: e.clientY }); if (st.samples.length > 6) st.samples.shift();
SH.targetShift = clamp((full - y) / hs.H, 0, 0.8);
});
const end = (e) => {
if (!st || e.pointerId !== st.id) return;
const s0 = st.samples[0], s1 = st.samples[st.samples.length - 1];
const v = s0 && s1 && s1.t > s0.t ? (s1.y - s0.y) / (s1.t - s0.t) : 0; // px/ms
const hs = SH.heights(); const full = el.offsetHeight;
const cur = new DOMMatrix(getComputedStyle(el).transform).m42; const visNow = full - cur + -v * 220;
if (st.moved >= 4) {
if (visNow < hs.peek * 0.6 && v > 0.6) { SH.close(); st = null; return; }
const ds = ['peek', 'medium', 'large']; let best = 'medium', bd = 1e9; for (const d of ds) { const dd = Math.abs(hs[d] - visNow); if (dd < bd) { bd = dd; best = d; } }
SH.setDetent(best); haptic(5);
} else if (e.target.closest('#grab')) { SH.setDetent(SH.detent === 'peek' ? 'medium' : SH.detent === 'medium' ? 'large' : 'medium'); }
st = null;
};
el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end);
bus.on('globe-touch', () => { if (SH.id && SH.detent !== 'peek' && !APP.isWide()) SH.setDetent('peek'); });
$('#back').addEventListener('click', () => { if (APP.mode === 'situation') SH.close(); });
$('#sitFollow').addEventListener('click', () => { if (!SH.id) return; APP.following = SH.id === 'cyclone' || !SH.sit.accessory ? 'cyclone' : SH.id; APP.toast(`${ic('bell', 'sm')} Following · alerts within your radius`); haptic(10); });
$('#sitShare').addEventListener('click', () => APP.toast(`${ic('link', 'sm')} Link copied · opens at this place & time`));
};
/* ---------- content ---------- */
function sparkSVG(sr, w) {
const hgt = 46, pad = 3; const d = sr.data; const n = d.length;
const band = sr.band || 0;
let mn = Math.min(...d) - band, mx = Math.max(...d) + band; if (mx - mn < 1e-6) { mx += 1; mn -= 1; }
const X = (i) => pad + (i / (n - 1)) * (w - pad * 2 - 34), Y = (v) => pad + 6 + (1 - (v - mn) / (mx - mn)) * (hgt - pad * 2 - 12);
const split = sr.split ?? n - 1;
const path = (a, b) => d.slice(a, b + 1).map((v, k) => `${k ? 'L' : 'M'}${X(a + k).toFixed(1)} ${Y(v).toFixed(1)}`).join('');
let s = '';
if (band) { const up = d.map((v, i) => `${i ? 'L' : 'M'}${X(i).toFixed(1)} ${Y(v + band * (i > split ? 1 + (i - split) * 0.35 : 1)).toFixed(1)}`).join(''); const lo = d.map((v, i) => [X(i), Y(v - band * (i > split ? 1 + (i - split) * 0.35 : 1))]).reverse().map(([x, y]) => `L${x.toFixed(1)} ${y.toFixed(1)}`).join(''); s += `
${esc(b.lede)}
` : ''} ${b.series?.length ? `Concept · illustrative data. People, handles, posts and claims shown here are fictional.