diff --git a/web/bun.lock b/web/bun.lock
index e60a506..2e1bc6d 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -5,6 +5,9 @@
"": {
"name": "banner-web",
"dependencies": {
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.2.0",
+ "d3-time-format": "^4.1.0",
"date-fns": "^4.1.0",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-svelte": "^0.5.5",
@@ -18,6 +21,9 @@
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/table-core": "^8.21.3",
+ "@types/d3-scale": "^4.0.9",
+ "@types/d3-shape": "^3.1.8",
+ "@types/d3-time-format": "^4.0.3",
"@types/node": "^25.1.0",
"bits-ui": "^1.3.7",
"clsx": "^2.1.1",
@@ -239,6 +245,16 @@
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
+ "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
+
+ "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
+
+ "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
+
+ "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
+
+ "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
+
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -287,6 +303,24 @@
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
+ "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
+
+ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
+
+ "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
+
+ "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
+
+ "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
+
+ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
+
+ "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
+
+ "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
+
+ "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
+
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
@@ -335,6 +369,8 @@
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
+
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
diff --git a/web/package.json b/web/package.json
index 38dc971..7ccb9ce 100644
--- a/web/package.json
+++ b/web/package.json
@@ -20,6 +20,9 @@
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/table-core": "^8.21.3",
+ "@types/d3-scale": "^4.0.9",
+ "@types/d3-shape": "^3.1.8",
+ "@types/d3-time-format": "^4.0.3",
"@types/node": "^25.1.0",
"bits-ui": "^1.3.7",
"clsx": "^2.1.1",
@@ -33,6 +36,9 @@
"vitest": "^3.0.5"
},
"dependencies": {
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.2.0",
+ "d3-time-format": "^4.1.0",
"date-fns": "^4.1.0",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-svelte": "^0.5.5"
diff --git a/web/src/lib/components/NavBar.svelte b/web/src/lib/components/NavBar.svelte
index c6e2a42..cef164d 100644
--- a/web/src/lib/components/NavBar.svelte
+++ b/web/src/lib/components/NavBar.svelte
@@ -1,10 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/TimelineDrawer.svelte b/web/src/lib/components/TimelineDrawer.svelte
new file mode 100644
index 0000000..c6cd892
--- /dev/null
+++ b/web/src/lib/components/TimelineDrawer.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/TimelineTooltip.svelte b/web/src/lib/components/TimelineTooltip.svelte
new file mode 100644
index 0000000..af889c8
--- /dev/null
+++ b/web/src/lib/components/TimelineTooltip.svelte
@@ -0,0 +1,52 @@
+
+
+{#if visible && slot}
+ {@const total = enabledTotalClasses(slot, activeSubjects)}
+
+
+ {fmtTime(slot.time)}
+
+
+ {#each activeSubjects as subject}
+ {@const count = slot.subjects[subject] || 0}
+ {#if count > 0}
+
+
+
+ {subject}
+
+
{count}
+
+ {/if}
+ {/each}
+
+
+ Total
+ {total}
+
+
+{/if}
diff --git a/web/src/lib/timeline/animation.ts b/web/src/lib/timeline/animation.ts
new file mode 100644
index 0000000..6200141
--- /dev/null
+++ b/web/src/lib/timeline/animation.ts
@@ -0,0 +1,108 @@
+/**
+ * Animation-map management for the timeline's stacked-area transitions.
+ *
+ * Each visible slot has per-subject animated values that lerp toward
+ * targets. This module owns the AnimMap lifecycle: syncing targets,
+ * stepping current values, and pruning offscreen entries.
+ */
+import { SUBJECTS, type Subject } from "./data";
+import { VALUE_EASE, MAXY_EASE, SETTLE_THRESHOLD, MIN_MAXY } from "./constants";
+import type { AnimEntry, TimeSlot } from "./types";
+
+export type AnimMap = Map>;
+
+/** Create a fresh, empty animation map. */
+export function createAnimMap(): AnimMap {
+ return new Map();
+}
+
+/**
+ * Sync animMap targets from data + filter state.
+ * New slots start at current=0 so they animate in from the baseline.
+ * Disabled subjects get target=0 so they animate out.
+ */
+export function syncAnimTargets(
+ animMap: AnimMap,
+ slots: TimeSlot[],
+ enabledSubjects: Set
+): void {
+ for (const slot of slots) {
+ const timeMs = slot.time.getTime();
+ let subjectMap = animMap.get(timeMs);
+ if (!subjectMap) {
+ subjectMap = new Map();
+ animMap.set(timeMs, subjectMap);
+ }
+
+ for (const subject of SUBJECTS) {
+ const realValue = enabledSubjects.has(subject) ? slot.subjects[subject] || 0 : 0;
+ const entry = subjectMap.get(subject);
+ if (entry) {
+ entry.target = realValue;
+ } else {
+ subjectMap.set(subject, { current: 0, target: realValue });
+ }
+ }
+ }
+}
+
+/**
+ * Advance all animated values toward their targets.
+ * Returns the new animatedMaxY value and whether any entry is still moving.
+ */
+export function stepAnimations(
+ animMap: AnimMap,
+ dt: number,
+ prevMaxY: number
+): { animating: boolean; maxY: number } {
+ const ease = 1 - Math.pow(1 - VALUE_EASE, dt / 16);
+ let animating = false;
+ let computedMaxY = MIN_MAXY;
+
+ for (const subjectMap of animMap.values()) {
+ let slotSum = 0;
+ for (const entry of subjectMap.values()) {
+ const diff = entry.target - entry.current;
+ if (Math.abs(diff) < SETTLE_THRESHOLD) {
+ if (entry.current !== entry.target) {
+ entry.current = entry.target;
+ }
+ } else {
+ entry.current += diff * ease;
+ animating = true;
+ }
+ slotSum += entry.current;
+ }
+ if (slotSum > computedMaxY) computedMaxY = slotSum;
+ }
+
+ const maxYEase = 1 - Math.pow(1 - MAXY_EASE, dt / 16);
+ const maxDiff = computedMaxY - prevMaxY;
+ let maxY: number;
+ if (Math.abs(maxDiff) < SETTLE_THRESHOLD) {
+ maxY = computedMaxY;
+ } else {
+ maxY = prevMaxY + maxDiff * maxYEase;
+ animating = true;
+ }
+
+ return { animating, maxY };
+}
+
+/**
+ * Remove animMap entries whose timestamps fall outside the viewport
+ * (with margin), preventing unbounded memory growth during long sessions.
+ */
+export function pruneAnimMap(
+ animMap: AnimMap,
+ viewStart: number,
+ viewEnd: number,
+ viewSpan: number
+): void {
+ const margin = viewSpan;
+ for (const timeMs of animMap.keys()) {
+ if (timeMs < viewStart - margin || timeMs > viewEnd + margin) {
+ animMap.delete(timeMs);
+ }
+ }
+}
diff --git a/web/src/lib/timeline/constants.ts b/web/src/lib/timeline/constants.ts
new file mode 100644
index 0000000..81aa051
--- /dev/null
+++ b/web/src/lib/timeline/constants.ts
@@ -0,0 +1,75 @@
+/** Layout & padding */
+export const PADDING = { top: 20, right: 0, bottom: 40, left: 0 } as const;
+export const DEFAULT_AXIS_RATIO = 0.80;
+export const CHART_HEIGHT_RATIO = 0.6;
+
+/** Viewport span limits (ms) */
+export const MIN_SPAN_MS = 5 * 60 * 1000;
+export const MAX_SPAN_MS = 72 * 60 * 60 * 1000;
+export const DEFAULT_SPAN_MS = 36 * 60 * 60 * 1000;
+
+/** Slot interval (15 minutes) */
+export const SLOT_INTERVAL_MS = 15 * 60 * 1000;
+
+/** Zoom */
+export const ZOOM_FACTOR = 1.15;
+export const ZOOM_KEY_FACTOR = 1.25;
+export const ZOOM_EASE = 0.12;
+export const ZOOM_SETTLE_THRESHOLD = 100;
+
+/** Grid rendering */
+export const GRID_ALPHA = 0.15;
+export const HOUR_GRID_ALPHA = 0.25;
+
+/** Now line */
+export const NOW_LINE_WIDTH = 2;
+export const NOW_LINE_COLOR = "#ef4444";
+export const NOW_TRIANGLE_HEIGHT = 6;
+export const NOW_TRIANGLE_HALF_WIDTH = 5;
+export const NOW_LABEL_FONT = "bold 10px Inter, system-ui, sans-serif";
+
+/** Hover highlight */
+export const HOVER_HIGHLIGHT_ALPHA = 0.25;
+
+/** Stacked area */
+export const AREA_FILL_ALPHA = 0.82;
+export const AREA_STROKE_ALPHA = 0.4;
+
+/** Physics / momentum */
+export const PAN_FRICTION = 0.94;
+export const PAN_STOP_THRESHOLD = 0.5;
+export const PAN_STOP_THRESHOLD_Y = 0.01;
+export const VELOCITY_SAMPLE_WINDOW = 80;
+export const VELOCITY_MIN_DT = 5;
+
+/** Keyboard panning */
+export const PAN_STEP_RATIO = 0.1;
+export const PAN_STEP_CTRL_RATIO = 0.35;
+export const PAN_EASE = 0.12;
+export const PAN_SETTLE_THRESHOLD_PX = 1;
+
+/** Y-axis panning */
+export const YRATIO_STEP = 0.03;
+export const YRATIO_MIN = 0.3;
+export const YRATIO_MAX = 1.2;
+export const YRATIO_SETTLE_THRESHOLD = 0.001;
+
+/** Follow mode */
+export const FOLLOW_EASE = 0.03;
+
+/** Animation */
+export const VALUE_EASE = 0.12;
+export const MAXY_EASE = 0.4;
+export const SETTLE_THRESHOLD = 0.5;
+export const MIN_MAXY = 60;
+export const MAX_DT = 50;
+export const DEFAULT_DT = 16;
+
+/** Visible-slot margin for smooth curve edges at viewport boundaries */
+export const RENDER_MARGIN_SLOTS = 3;
+
+/** Drawer */
+export const DRAWER_WIDTH = 220;
+
+/** Axis text */
+export const AXIS_FONT = "11px Inter, system-ui, sans-serif";
diff --git a/web/src/lib/timeline/data.ts b/web/src/lib/timeline/data.ts
new file mode 100644
index 0000000..63fbc2c
--- /dev/null
+++ b/web/src/lib/timeline/data.ts
@@ -0,0 +1,122 @@
+/**
+ * Data types, constants, and deterministic slot generation for the class timeline.
+ * Each 15-minute slot is seeded by its timestamp, so the same slot always produces
+ * identical data regardless of when or in what order it's fetched.
+ */
+import { SLOT_INTERVAL_MS } from "./constants";
+import type { TimeSlot } from "./types";
+
+export type { TimeSlot };
+
+export const SUBJECTS = [
+ "CS",
+ "MATH",
+ "BIO",
+ "ENG",
+ "PHYS",
+ "HIST",
+ "CHEM",
+ "PSY",
+ "ECE",
+ "ART",
+] as const;
+
+export type Subject = (typeof SUBJECTS)[number];
+
+/** Subject colors — distinct, accessible palette */
+export const SUBJECT_COLORS: Record = {
+ CS: "#6366f1", // indigo
+ MATH: "#f59e0b", // amber
+ BIO: "#10b981", // emerald
+ ENG: "#ef4444", // red
+ PHYS: "#3b82f6", // blue
+ HIST: "#8b5cf6", // violet
+ CHEM: "#f97316", // orange
+ PSY: "#ec4899", // pink
+ ECE: "#14b8a6", // teal
+ ART: "#a855f7", // purple
+};
+
+/**
+ * Bell-curve-like distribution centered at a given hour.
+ * Returns a value 0..1 representing relative class density.
+ */
+function bellCurve(hour: number, center: number, spread: number): number {
+ const x = (hour - center) / spread;
+ return Math.exp(-0.5 * x * x);
+}
+
+/**
+ * Each subject has characteristic scheduling patterns:
+ * peak hours, relative popularity, and spread.
+ */
+const SUBJECT_PROFILES: Record = {
+ CS: { peaks: [10, 14, 16], weight: 12, spread: 2.0 },
+ MATH: { peaks: [8, 10, 13], weight: 10, spread: 1.8 },
+ BIO: { peaks: [9, 11, 14], weight: 8, spread: 1.5 },
+ ENG: { peaks: [9, 11, 14, 16], weight: 7, spread: 2.2 },
+ PHYS: { peaks: [8, 13, 15], weight: 6, spread: 1.6 },
+ HIST: { peaks: [10, 13, 15], weight: 5, spread: 2.0 },
+ CHEM: { peaks: [8, 10, 14], weight: 6, spread: 1.5 },
+ PSY: { peaks: [11, 14, 16], weight: 7, spread: 2.0 },
+ ECE: { peaks: [9, 13, 15], weight: 5, spread: 1.8 },
+ ART: { peaks: [10, 14, 17], weight: 4, spread: 2.5 },
+};
+
+/**
+ * Seeded pseudo-random number generator (LCG) for reproducible data.
+ */
+function seededRandom(seed: number): () => number {
+ let s = seed;
+ return () => {
+ s = (s * 1664525 + 1013904223) & 0xffffffff;
+ return (s >>> 0) / 0xffffffff;
+ };
+}
+
+/**
+ * Integer hash so adjacent slot timestamps produce very different seeds.
+ */
+function hashTimestamp(ms: number): number {
+ let h = ms | 0;
+ h = ((h >> 16) ^ h) * 0x45d9f3b;
+ h = ((h >> 16) ^ h) * 0x45d9f3b;
+ h = (h >> 16) ^ h;
+ return h >>> 0;
+}
+
+/** Generate a single TimeSlot for the given aligned timestamp. */
+function generateSlot(timeMs: number): TimeSlot {
+ const rand = seededRandom(hashTimestamp(timeMs));
+ const time = new Date(timeMs);
+ const hour = time.getHours() + time.getMinutes() / 60;
+
+ const subjects = {} as Record;
+ for (const subject of SUBJECTS) {
+ const profile = SUBJECT_PROFILES[subject];
+ let density = 0;
+ for (const peak of profile.peaks) {
+ density += bellCurve(hour, peak, profile.spread);
+ }
+ const base = density * profile.weight;
+ const noise = (rand() - 0.5) * 2;
+ subjects[subject] = Math.max(0, Math.round(base + noise));
+ }
+
+ return { time, subjects };
+}
+
+/**
+ * Generate TimeSlots covering [startMs, endMs], aligned to 15-minute boundaries.
+ * Each slot is deterministically seeded by its timestamp.
+ */
+export function generateSlots(startMs: number, endMs: number): TimeSlot[] {
+ const alignedStart = Math.floor(startMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
+ const alignedEnd = Math.ceil(endMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
+
+ const slots: TimeSlot[] = [];
+ for (let t = alignedStart; t <= alignedEnd; t += SLOT_INTERVAL_MS) {
+ slots.push(generateSlot(t));
+ }
+ return slots;
+}
diff --git a/web/src/lib/timeline/renderer.ts b/web/src/lib/timeline/renderer.ts
new file mode 100644
index 0000000..2aca007
--- /dev/null
+++ b/web/src/lib/timeline/renderer.ts
@@ -0,0 +1,291 @@
+/**
+ * Pure canvas rendering functions for the timeline chart.
+ *
+ * Every function takes a {@link ChartContext} plus any data it needs.
+ * No Svelte reactivity, no side-effects beyond drawing on the context.
+ */
+import { stack, area, curveMonotoneX, type Series } from "d3-shape";
+import { timeFormat } from "d3-time-format";
+
+import { SUBJECT_COLORS, type Subject } from "./data";
+import type { AnimMap } from "./animation";
+import { getStackSubjects } from "./viewport";
+import type { ChartContext, TimeSlot } from "./types";
+import {
+ GRID_ALPHA,
+ HOUR_GRID_ALPHA,
+ NOW_LINE_WIDTH,
+ NOW_LINE_COLOR,
+ NOW_TRIANGLE_HEIGHT,
+ NOW_TRIANGLE_HALF_WIDTH,
+ NOW_LABEL_FONT,
+ HOVER_HIGHLIGHT_ALPHA,
+ AREA_FILL_ALPHA,
+ AREA_STROKE_ALPHA,
+ SLOT_INTERVAL_MS,
+ SETTLE_THRESHOLD,
+ AXIS_FONT,
+} from "./constants";
+
+// ── Formatters (allocated once) ─────────────────────────────────────
+const fmtHour = timeFormat("%-I %p");
+const fmtAxisDetailed = timeFormat("%-I:%M %p");
+const fmtAxisCoarse = timeFormat("%-I %p");
+const fmtNow = timeFormat("%-I:%M %p");
+
+// ── Stacked-area types ──────────────────────────────────────────────
+type StackPoint = Series[number];
+export type VisibleStack = Series[];
+
+// ── Tick count heuristic ────────────────────────────────────────────
+
+/** Choose the number of x-axis ticks based on the viewport span. */
+export function chooseTickCount(viewSpan: number): number {
+ const spanHours = viewSpan / (60 * 60 * 1000);
+ if (spanHours <= 1) return 12;
+ if (spanHours <= 3) return 12;
+ if (spanHours <= 8) return 16;
+ if (spanHours <= 14) return 14;
+ return 10;
+}
+
+// ── Stack computation ───────────────────────────────────────────────
+
+/**
+ * Stack only the visible slice using *animated* values so transitions
+ * between filter/data states are smooth. Includes subjects that are
+ * still animating out so removal is gradual.
+ */
+export function stackVisibleSlots(
+ visible: TimeSlot[],
+ enabledSubjects: Set,
+ animMap: AnimMap
+): VisibleStack {
+ if (visible.length === 0) return [];
+
+ const stackKeys = getStackSubjects(visible, enabledSubjects, animMap, SETTLE_THRESHOLD);
+ if (stackKeys.length === 0) return [];
+
+ // Build synthetic slots with animated current values.
+ const animatedSlots: TimeSlot[] = visible.map((slot) => {
+ const timeMs = slot.time.getTime();
+ const subjectMap = animMap.get(timeMs);
+ const subjects = {} as Record;
+ for (const subject of stackKeys) {
+ const entry = subjectMap?.get(subject);
+ subjects[subject] = entry ? entry.current : slot.subjects[subject] || 0;
+ }
+ return { time: slot.time, subjects };
+ });
+
+ const gen = stack()
+ .keys(stackKeys)
+ .value((d, key) => d.subjects[key as Subject] || 0);
+ return gen(animatedSlots);
+}
+
+// ── Drawing functions ───────────────────────────────────────────────
+
+export function drawGrid(chart: ChartContext): void {
+ const { ctx, xScale, chartTop, chartBottom, width, viewSpan, viewStart, viewEnd } = chart;
+ const tickCount = chooseTickCount(viewSpan);
+
+ ctx.save();
+ ctx.lineWidth = 1;
+
+ // Minor tick grid lines
+ const ticks = xScale.ticks(tickCount);
+ ctx.strokeStyle = `rgba(128, 128, 128, ${GRID_ALPHA})`;
+ for (const tick of ticks) {
+ if (tick.getMinutes() === 0) continue;
+ const x = Math.round(xScale(tick)) + 0.5;
+ if (x < 0 || x > width) continue;
+ ctx.beginPath();
+ ctx.moveTo(x, chartTop);
+ ctx.lineTo(x, chartBottom);
+ ctx.stroke();
+ }
+
+ // Hourly grid lines with labels
+ const spanHours = viewSpan / (60 * 60 * 1000);
+ let hourStep = 1;
+ if (spanHours > 48) hourStep = 6;
+ else if (spanHours > 24) hourStep = 4;
+ else if (spanHours > 12) hourStep = 2;
+
+ const startHour = new Date(viewStart);
+ startHour.setMinutes(0, 0, 0);
+ if (hourStep > 1) {
+ const h = startHour.getHours();
+ startHour.setHours(Math.ceil(h / hourStep) * hourStep);
+ } else if (startHour.getTime() < viewStart) {
+ startHour.setHours(startHour.getHours() + 1);
+ }
+
+ const hourStepMs = hourStep * 60 * 60 * 1000;
+ for (let t = startHour.getTime(); t <= viewEnd; t += hourStepMs) {
+ const d = new Date(t);
+ const x = Math.round(xScale(d)) + 0.5;
+ if (x < 0 || x > width) continue;
+
+ ctx.strokeStyle = `rgba(128, 128, 128, ${HOUR_GRID_ALPHA})`;
+ ctx.beginPath();
+ ctx.moveTo(x, chartTop);
+ ctx.lineTo(x, chartBottom);
+ ctx.stroke();
+
+ ctx.fillText(fmtHour(d), x + 5, chartTop + 6);
+ }
+
+ ctx.restore();
+}
+
+/**
+ * Trace the top outline of the stacked area onto `ctx` as a clip path.
+ */
+function traceStackOutline(chart: ChartContext, visibleStack: VisibleStack): void {
+ if (visibleStack.length === 0) return;
+ const { ctx, xScale, yScale } = chart;
+ const topLayer = visibleStack[visibleStack.length - 1];
+
+ ctx.beginPath();
+ area()
+ .x((d) => xScale(d.data.time))
+ .y0(() => yScale(0))
+ .y1((d) => yScale(d[1]))
+ .curve(curveMonotoneX)
+ .context(ctx)(topLayer as unknown as StackPoint[]);
+}
+
+export function drawHoverColumn(
+ chart: ChartContext,
+ visibleStack: VisibleStack,
+ hoverSlotTime: number | null
+): void {
+ if (hoverSlotTime == null || visibleStack.length === 0) return;
+ const { ctx, xScale, chartTop, chartBottom } = chart;
+
+ const x0 = xScale(new Date(hoverSlotTime));
+ const x1 = xScale(new Date(hoverSlotTime + SLOT_INTERVAL_MS));
+
+ ctx.save();
+ traceStackOutline(chart, visibleStack);
+ ctx.clip();
+
+ ctx.fillStyle = `rgba(255, 255, 255, ${HOVER_HIGHLIGHT_ALPHA})`;
+ ctx.fillRect(x0, chartTop, x1 - x0, chartBottom - chartTop);
+ ctx.restore();
+}
+
+export function drawStackedArea(chart: ChartContext, visibleStack: VisibleStack): void {
+ const { ctx, xScale, yScale, width, chartTop, chartBottom } = chart;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(0, chartTop, width, chartBottom - chartTop);
+ ctx.clip();
+
+ for (let i = visibleStack.length - 1; i >= 0; i--) {
+ const layer = visibleStack[i];
+ const subject = layer.key as Subject;
+ const color = SUBJECT_COLORS[subject];
+
+ ctx.beginPath();
+ area()
+ .x((d) => xScale(d.data.time))
+ .y0((d) => yScale(d[0]))
+ .y1((d) => yScale(d[1]))
+ .curve(curveMonotoneX)
+ .context(ctx)(layer as unknown as StackPoint[]);
+
+ ctx.globalAlpha = AREA_FILL_ALPHA;
+ ctx.fillStyle = color;
+ ctx.fill();
+
+ ctx.globalAlpha = AREA_STROKE_ALPHA;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ }
+
+ ctx.restore();
+}
+
+export function drawNowLine(chart: ChartContext): void {
+ const { ctx, xScale, chartTop, chartBottom } = chart;
+ const now = new Date();
+ const x = xScale(now);
+ if (x < 0 || x > chart.width) return;
+
+ ctx.save();
+
+ ctx.shadowColor = "rgba(239, 68, 68, 0.5)";
+ ctx.shadowBlur = 8;
+ ctx.strokeStyle = NOW_LINE_COLOR;
+ ctx.lineWidth = NOW_LINE_WIDTH;
+ ctx.setLineDash([]);
+
+ ctx.beginPath();
+ ctx.moveTo(x, chartTop);
+ ctx.lineTo(x, chartBottom);
+ ctx.stroke();
+
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = NOW_LINE_COLOR;
+
+ // Triangle marker at chart top
+ ctx.beginPath();
+ ctx.moveTo(x - NOW_TRIANGLE_HALF_WIDTH, chartTop);
+ ctx.lineTo(x + NOW_TRIANGLE_HALF_WIDTH, chartTop);
+ ctx.lineTo(x, chartTop + NOW_TRIANGLE_HEIGHT);
+ ctx.closePath();
+ ctx.fill();
+
+ // Time label
+ ctx.font = NOW_LABEL_FONT;
+ ctx.textAlign = "left";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(fmtNow(now), x + 6, chartTop - 1);
+
+ ctx.restore();
+}
+
+export function drawTimeAxis(chart: ChartContext): void {
+ const { ctx, xScale, width, chartBottom, viewSpan } = chart;
+ const tickCount = chooseTickCount(viewSpan);
+ const y = chartBottom;
+ const ticks = xScale.ticks(tickCount);
+
+ ctx.save();
+
+ // Axis baseline
+ ctx.strokeStyle = "rgba(128, 128, 128, 0.15)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(0, y + 0.5);
+ ctx.lineTo(width, y + 0.5);
+ ctx.stroke();
+
+ const spanHours = viewSpan / (60 * 60 * 1000);
+ const fmt = spanHours <= 3 ? fmtAxisDetailed : fmtAxisCoarse;
+
+ ctx.fillStyle = "rgba(128, 128, 128, 0.6)";
+ ctx.font = AXIS_FONT;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "top";
+
+ for (const tick of ticks) {
+ const x = xScale(tick);
+ if (x < 20 || x > width - 20) continue;
+
+ ctx.strokeStyle = "rgba(128, 128, 128, 0.2)";
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x, y + 4);
+ ctx.stroke();
+
+ ctx.fillText(fmt(tick), x, y + 6);
+ }
+
+ ctx.restore();
+}
diff --git a/web/src/lib/timeline/store.svelte.ts b/web/src/lib/timeline/store.svelte.ts
new file mode 100644
index 0000000..3e1ab3c
--- /dev/null
+++ b/web/src/lib/timeline/store.svelte.ts
@@ -0,0 +1,179 @@
+/**
+ * Reactive timeline data store with gap-aware on-demand loading.
+ *
+ * Tracks which time ranges have already been fetched and only requests
+ * the missing segments when the view expands into unloaded territory.
+ * Fetches are throttled so rapid panning/zooming doesn't flood the
+ * (currently mock) API.
+ */
+import { generateSlots } from "./data";
+import { SLOT_INTERVAL_MS } from "./constants";
+import type { TimeSlot } from "./types";
+
+/** Inclusive range of aligned slot timestamps [start, end]. */
+type Range = [start: number, end: number];
+
+const FETCH_THROTTLE_MS = 500;
+const BUFFER_RATIO = 0.15;
+
+// Mock network latency bounds (ms).
+const MOCK_DELAY_MIN = 40;
+const MOCK_DELAY_MAX = 120;
+
+/**
+ * Simulate an API call that returns slots for an arbitrary time range.
+ * The delay makes loading behaviour visible during development.
+ */
+async function mockFetch(startMs: number, endMs: number): Promise {
+ const delay = MOCK_DELAY_MIN + Math.random() * (MOCK_DELAY_MAX - MOCK_DELAY_MIN);
+ await new Promise((r) => setTimeout(r, delay));
+ return generateSlots(startMs, endMs);
+}
+
+/** Align a timestamp down to the nearest slot boundary. */
+function alignFloor(ms: number): number {
+ return Math.floor(ms / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
+}
+
+/** Align a timestamp up to the nearest slot boundary. */
+function alignCeil(ms: number): number {
+ return Math.ceil(ms / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
+}
+
+/**
+ * Given a requested range and a sorted list of already-loaded ranges,
+ * return the sub-ranges that still need fetching.
+ */
+function findGaps(start: number, end: number, loaded: Range[]): Range[] {
+ const s = alignFloor(start);
+ const e = alignCeil(end);
+
+ if (loaded.length === 0) return [[s, e]];
+
+ const gaps: Range[] = [];
+ let cursor = s;
+
+ for (const [ls, le] of loaded) {
+ if (le < cursor) continue; // entirely before cursor
+ if (ls > e) break; // entirely past our range
+
+ if (ls > cursor) {
+ gaps.push([cursor, Math.min(ls, e)]);
+ }
+ cursor = Math.max(cursor, le);
+ }
+
+ if (cursor < e) {
+ gaps.push([cursor, e]);
+ }
+
+ return gaps;
+}
+
+/** Merge a new range into a sorted, non-overlapping range list (mutates nothing). */
+function mergeRange(ranges: Range[], added: Range): Range[] {
+ const all = [...ranges, added].sort((a, b) => a[0] - b[0]);
+ const merged: Range[] = [];
+ for (const r of all) {
+ if (merged.length === 0 || merged[merged.length - 1][1] < r[0]) {
+ merged.push([r[0], r[1]]);
+ } else {
+ merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], r[1]);
+ }
+ }
+ return merged;
+}
+
+/**
+ * Create a reactive timeline store.
+ *
+ * Call `requestRange(viewStart, viewEnd)` whenever the visible window
+ * changes. The store applies a 15 % buffer, computes which sub-ranges
+ * are missing, and fetches them (throttled to 500 ms).
+ *
+ * The `data` getter returns a sorted `TimeSlot[]` that reactively
+ * updates as new segments arrive.
+ */
+export function createTimelineStore() {
+ // All loaded slots keyed by aligned timestamp (ms).
+ let slotMap: Map = $state(new Map());
+
+ // Sorted, non-overlapping list of fetched ranges.
+ let loadedRanges: Range[] = [];
+
+ let throttleTimer: ReturnType | undefined;
+ let pendingStart = 0;
+ let pendingEnd = 0;
+ let hasFetchedOnce = false;
+
+ // Sorted array derived from the map. The O(n log n) sort only runs when
+ // slotMap is reassigned, which happens on fetch completion — not per frame.
+ const data: TimeSlot[] = $derived(
+ [...slotMap.values()].sort((a, b) => a.time.getTime() - b.time.getTime())
+ );
+
+ async function fetchGaps(start: number, end: number): Promise {
+ const gaps = findGaps(start, end, loadedRanges);
+ if (gaps.length === 0) return;
+
+ // Fetch all gap segments in parallel.
+ const results = await Promise.all(gaps.map(([gs, ge]) => mockFetch(gs, ge)));
+
+ // Merge results into the slot map.
+ const next = new Map(slotMap);
+ for (const slots of results) {
+ for (const slot of slots) {
+ next.set(slot.time.getTime(), slot);
+ }
+ }
+
+ // Update loaded-range bookkeeping.
+ for (const gap of gaps) {
+ loadedRanges = mergeRange(loadedRanges, gap);
+ }
+
+ // Single reactive assignment.
+ slotMap = next;
+ }
+
+ /**
+ * Notify the store that the viewport now covers [viewStart, viewEnd] (ms).
+ * Automatically buffers by 15 % each side and throttles fetches.
+ *
+ * The first call fetches immediately. Subsequent calls update the pending
+ * range but don't reset an existing timer, so continuous view changes
+ * (follow mode, momentum pan) don't starve the fetch.
+ */
+ function requestRange(viewStart: number, viewEnd: number): void {
+ const span = viewEnd - viewStart;
+ const buffer = span * BUFFER_RATIO;
+ pendingStart = viewStart - buffer;
+ pendingEnd = viewEnd + buffer;
+
+ if (!hasFetchedOnce) {
+ hasFetchedOnce = true;
+ fetchGaps(pendingStart, pendingEnd);
+ return;
+ }
+
+ if (throttleTimer === undefined) {
+ throttleTimer = setTimeout(() => {
+ throttleTimer = undefined;
+ fetchGaps(pendingStart, pendingEnd);
+ }, FETCH_THROTTLE_MS);
+ }
+ }
+
+ /** Clean up the throttle timer (call on component destroy). */
+ function dispose(): void {
+ clearTimeout(throttleTimer);
+ }
+
+ return {
+ get data() {
+ return data;
+ },
+ requestRange,
+ dispose,
+ };
+}
diff --git a/web/src/lib/timeline/types.ts b/web/src/lib/timeline/types.ts
new file mode 100644
index 0000000..634db5b
--- /dev/null
+++ b/web/src/lib/timeline/types.ts
@@ -0,0 +1,36 @@
+/**
+ * Shared types for the timeline feature.
+ */
+import type { ScaleLinear, ScaleTime } from "d3-scale";
+
+import type { Subject } from "./data";
+
+export type { Subject };
+
+/** A single 15-minute time slot with per-subject class counts. */
+export interface TimeSlot {
+ time: Date;
+ subjects: Record;
+}
+
+/** Lerped animation entry for a single subject within a slot. */
+export interface AnimEntry {
+ current: number;
+ target: number;
+}
+
+/**
+ * Shared context passed to all canvas rendering functions.
+ * Computed once per frame in the orchestrator and threaded through.
+ */
+export interface ChartContext {
+ ctx: CanvasRenderingContext2D;
+ xScale: ScaleTime;
+ yScale: ScaleLinear;
+ width: number;
+ chartTop: number;
+ chartBottom: number;
+ viewSpan: number;
+ viewStart: number;
+ viewEnd: number;
+}
diff --git a/web/src/lib/timeline/viewport.ts b/web/src/lib/timeline/viewport.ts
new file mode 100644
index 0000000..81d689c
--- /dev/null
+++ b/web/src/lib/timeline/viewport.ts
@@ -0,0 +1,92 @@
+/**
+ * Pure viewport utility functions: binary search, visible-slot slicing,
+ * hit-testing, and snapping for the timeline canvas.
+ */
+import { SLOT_INTERVAL_MS, RENDER_MARGIN_SLOTS } from "./constants";
+import { SUBJECTS, type Subject } from "./data";
+import type { TimeSlot } from "./types";
+
+/**
+ * Binary-search for the index of the first slot whose time >= target.
+ * `slots` must be sorted ascending by time.
+ */
+export function lowerBound(slots: TimeSlot[], targetMs: number): number {
+ let lo = 0;
+ let hi = slots.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >>> 1;
+ if (slots[mid].time.getTime() < targetMs) lo = mid + 1;
+ else hi = mid;
+ }
+ return lo;
+}
+
+/**
+ * Return the sub-array of `data` covering the viewport [viewStart, viewEnd]
+ * plus a small margin for smooth curve edges.
+ */
+export function getVisibleSlots(data: TimeSlot[], viewStart: number, viewEnd: number): TimeSlot[] {
+ if (data.length === 0) return data;
+ const lo = Math.max(0, lowerBound(data, viewStart) - RENDER_MARGIN_SLOTS);
+ const hi = Math.min(data.length, lowerBound(data, viewEnd) + RENDER_MARGIN_SLOTS);
+ return data.slice(lo, hi);
+}
+
+/** Find the slot closest to `timeMs`, or null if none within one interval. */
+export function findSlotByTime(data: TimeSlot[], timeMs: number): TimeSlot | null {
+ if (data.length === 0) return null;
+ const idx = lowerBound(data, timeMs);
+ let best: TimeSlot | null = null;
+ let bestDist = Infinity;
+ for (const i of [idx - 1, idx]) {
+ if (i < 0 || i >= data.length) continue;
+ const dist = Math.abs(data[i].time.getTime() - timeMs);
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = data[i];
+ }
+ }
+ if (best && bestDist < SLOT_INTERVAL_MS) return best;
+ return null;
+}
+
+/** Snap a timestamp down to the nearest 15-minute slot boundary. */
+export function snapToSlot(timeMs: number): number {
+ return Math.floor(timeMs / SLOT_INTERVAL_MS) * SLOT_INTERVAL_MS;
+}
+
+/** Sum of class counts for enabled subjects in a slot. */
+export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly Subject[]): number {
+ let sum = 0;
+ for (const s of activeSubjects) {
+ sum += slot.subjects[s] || 0;
+ }
+ return sum;
+}
+
+/**
+ * Determine which subjects to include in the stack: all enabled subjects
+ * plus any disabled subjects still animating out (current > threshold).
+ */
+export function getStackSubjects(
+ visible: TimeSlot[],
+ enabledSubjects: Set,
+ animMap: Map>,
+ settleThreshold: number
+): Subject[] {
+ const subjects: Subject[] = [];
+ for (const subject of SUBJECTS) {
+ if (enabledSubjects.has(subject)) {
+ subjects.push(subject);
+ continue;
+ }
+ for (const slot of visible) {
+ const entry = animMap.get(slot.time.getTime())?.get(subject);
+ if (entry && Math.abs(entry.current) > settleThreshold) {
+ subjects.push(subject);
+ break;
+ }
+ }
+ }
+ return subjects;
+}
diff --git a/web/src/routes/(app)/+layout.svelte b/web/src/routes/(app)/+layout.svelte
index 02d61a9..092b4e1 100644
--- a/web/src/routes/(app)/+layout.svelte
+++ b/web/src/routes/(app)/+layout.svelte
@@ -70,19 +70,19 @@ function isActive(href: string): boolean {
{#if authStore.isLoading}
-
+
{:else if !authStore.isAuthenticated}
-
+
{:else}
-
+