// Main app — owns endDate, rows, zoom/pan, filters, hover, playback state. const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA, useCallback: useCallbackA } = React; const DAYS = 60; const SHIFT = 14; const MIN_ZOOM = 1; const MAX_ZOOM = 60; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "mood": "nocturnal", "blockStyle": "bars", "density": 28 }/*EDITMODE-END*/; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); // Apply mood + density to root via CSS class + variable useEffectA(() => { document.body.dataset.mood = t.mood; document.body.dataset.blockStyle = t.blockStyle; document.documentElement.style.setProperty('--row-h', t.density + 'px'); }, [t.mood, t.blockStyle, t.density]); // endDate = the most-recent day shown (its row covers noon→noon). // Skip the in-progress window: the most recent row must be one whose // noon→noon span has fully elapsed. const [endDate, setEndDate] = useStateA(() => { const d = new Date(); d.setHours(12, 0, 0, 0); if (new Date().getHours() < 12) d.setDate(d.getDate() - 1); d.setDate(d.getDate() - 1); return d; }); // Rows are loaded asynchronously from the API when endDate changes. const [rows, setRows] = useStateA([]); const [loading, setLoading] = useStateA(true); useEffectA(() => { let cancelled = false; setLoading(true); MeowData.generateRange(endDate, DAYS) .then((r) => { if (!cancelled) { setRows(r); setLoading(false); } }) .catch((err) => { console.error('Failed to load rows', err); if (!cancelled) { setRows([]); setLoading(false); } }); return () => { cancelled = true; }; }, [endDate]); // Zoom + pan (shared across all rows). Pan is in pixels, in the // un-transformed track space *after* it has been scaled by zoom. // i.e. we render track at width = zoom*100% then translateX(-pan). const [zoom, setZoom] = useStateA(1); const [pan, setPan] = useStateA(0); // Filters const [scoreThreshold, setScoreThreshold] = useStateA(0.25); const [enabledLabels, setEnabledLabels] = useStateA(() => new Set(MeowData.LABELS)); // Hover & playback const [hover, setHover] = useStateA(null); // { meow, row, x, y } const [player, setPlayer] = useStateA(null); // { meow, row, dur, x, y } // Refs for measuring the scrollable track area const gridRef = useRefA(null); // axis-header element — track viewport bounds const wrapRef = useRefA(null); // grid-wrap — wheel/drag listener target (covers axis + rows) function trackWidthPx() { const el = gridRef.current; if (!el) return 0; return el.clientWidth; } // Clamp pan so we never reveal beyond the track edges. function clampPan(p, z) { const w = trackWidthPx(); const max = Math.max(0, w * (z - 1)); return Math.max(0, Math.min(max, p)); } // Compute the smallest fractional [start, end] window that covers every // recording period across all loaded rows. Returns null if no periods. function recordingExtent(rowsArg) { let lo = Infinity, hi = -Infinity; for (const row of rowsArg) { for (const p of row.periods || []) { if (p.start < lo) lo = p.start; if (p.end > hi) hi = p.end; } } if (!isFinite(lo) || !isFinite(hi) || hi <= lo) return null; return { lo, hi }; } // Set zoom + pan so the viewport spans only the recording extent. // Falls back to a full 1× view if no periods are available. function fitToRecordings(rowsArg) { const ext = recordingExtent(rowsArg); if (!ext) { setZoom(1); setPan(0); return; } // Tiny padding so the very first/last recordings aren't flush to the edges. const PAD = 0.01; const fStart = Math.max(0, ext.lo - PAD); const fEnd = Math.min(1, ext.hi + PAD); const range = Math.max(1e-6, fEnd - fStart); const z = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, 1 / range)); // Pan needs the track viewport width, which only exists after layout. // Retry on the next frame if the ref isn't measurable yet (initial mount). function apply() { const w = trackWidthPx(); if (!w) { requestAnimationFrame(apply); return; } setZoom(z); setPan(fStart * w * z); } apply(); } useEffectA(() => { setPan((p) => clampPan(p, zoom)); // re-clamp on resize too function onResize() { setPan((p) => clampPan(p, zoom)); } window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [zoom]); // Whenever the loaded rows change (initial load or date navigation), // default the viewport to the span of recording periods. useEffectA(() => { if (rows.length === 0) return; fitToRecordings(rows); }, [rows]); // ----- Wheel zoom (centered on cursor) ----- useEffectA(() => { function onWheel(e) { // Zoom only when Ctrl (or Cmd on mac) is held; otherwise let the // page scroll vertically as normal. if (!e.ctrlKey && !e.metaKey) return; // Suppress the browser's native Ctrl+wheel page-zoom site-wide. // Must call preventDefault BEFORE any other work — and the listener // must be passive:false (we register it that way below). e.preventDefault(); e.stopPropagation(); const trackEl = gridRef.current; if (!trackEl) return; const rect = trackEl.getBoundingClientRect(); // Cursor X within the track viewport, clamped so zoom anchors // behave sensibly when the wheel happens over the row-header gutter. const w = trackEl.clientWidth; const cursorX = Math.max(0, Math.min(w, e.clientX - rect.left)); // Fraction along the *content* under the cursor: // visible offset = pan + cursorX, content width = w * zoom setZoom((z) => { const dy = e.deltaY; // exponential zoom; faster with bigger deltas const factor = Math.exp(-dy * 0.0015); let z2 = z * factor; z2 = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z2)); // Adjust pan so the point under cursor stays put. // Let frac = (pan + cursorX) / (w * z). We want // (pan2 + cursorX) / (w * z2) === frac setPan((p) => { const contentW = w * z; const frac = (p + cursorX) / contentW; const p2 = frac * (w * z2) - cursorX; const max = Math.max(0, w * (z2 - 1)); return Math.max(0, Math.min(max, p2)); }); return z2; }); } // Attach at document level with capture+non-passive so we win the race // against the browser's native Ctrl+wheel page-zoom behaviour regardless // of which descendant element the wheel event actually targets. document.addEventListener('wheel', onWheel, { passive: false, capture: true }); return () => document.removeEventListener('wheel', onWheel, { capture: true }); }, []); // ----- Drag to pan ----- useEffectA(() => { const el = wrapRef.current; if (!el) return; let dragging = false; let startX = 0; let startPan = 0; function onDown(e) { // Don't start drag on a block (let click pass through) if (e.target.closest('.block')) return; // Only left button if (e.button !== 0) return; dragging = true; startX = e.clientX; startPan = panRef.current; el.classList.add('dragging'); e.preventDefault(); } function onMove(e) { if (!dragging) return; const dx = e.clientX - startX; setPan(() => clampPan(startPan - dx, zoomRef.current)); } function onUp() { if (dragging) { dragging = false; el.classList.remove('dragging'); } } el.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { el.removeEventListener('mousedown', onDown); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, []); // Refs for current zoom/pan so the drag handler isn't stale const zoomRef = useRefA(zoom); useEffectA(() => { zoomRef.current = zoom; }, [zoom]); const panRef = useRefA(pan); useEffectA(() => { panRef.current = pan; }, [pan]); // ----- Toolbar handlers ----- function shiftDays(delta) { setEndDate((d) => { const n = new Date(d); n.setDate(n.getDate() + delta); // Don't let user navigate into the future past today const today = new Date(); today.setHours(12, 0, 0, 0); if (n > today) return today; return n; }); setHover(null); } function jumpToDate(d) { const target = new Date(d); target.setHours(12, 0, 0, 0); const today = new Date(); today.setHours(12, 0, 0, 0); if (target > today) target.setTime(today.getTime()); setEndDate(target); setHover(null); } function toggleLabel(label) { setEnabledLabels((s) => { const n = new Set(s); if (n.has(label)) n.delete(label); else n.add(label); return n; }); } // ----- Block hover / click ----- const onBlockHover = useCallbackA((meow, row, e) => { setHover({ meow, row, x: e.clientX, y: e.clientY }); }, []); const onBlockLeave = useCallbackA(() => setHover(null), []); const onBlockClick = useCallbackA((meow, row, e) => { const dur = MeowAudio.play(meow, () => { setPlayer((p) => (p && p.meow.id === meow.id ? null : p)); }); setPlayer({ meow, row, dur, startedAt: performance.now() }); }, []); function closePlayer() { MeowAudio.stopAll(); setPlayer(null); } // Counts const totalMeows = useMemoA(() => rows.reduce((acc, r) => acc + r.meows.length, 0), [rows]); const visibleMeows = useMemoA(() => rows.reduce((acc, r) => acc + r.meows.filter(m => m.score >= scoreThreshold && enabledLabels.has(m.label)).length, 0), [rows, scoreThreshold, enabledLabels]); const today = new Date(); return (
fitToRecordings(rows)} player={player} onClosePlayer={closePlayer} />
activity
{loading && rows.length === 0 ? (
Loading…
) : rows.length === 0 ? (
No data
) : ( rows.map((row) => ( )) )}
setTweak('mood', v)} /> setTweak('blockStyle', v)} /> setTweak('density', v)} />
ctrl+scroll zoom drag pan click play meow
); } ReactDOM.createRoot(document.getElementById('root')).render();