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 @@ + +
+ { canvasEl?.focus(); onPointerDown(e); }} + onpointermove={onPointerMove} + onpointerup={onPointerUp} + onpointerleave={onPointerLeave} + onwheel={onWheel} + onkeydown={onKeyDown} + onkeyup={onKeyUp} + > + + + + +
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 @@ + + + + + + + + +
+
+ +
+ Filters + +
+ + +
+ {#if followEnabled} +
+ FOLLOWING +
+ {:else} + + {/if} +
+ + +
+
+ Subjects +
+ + | + +
+
+
+ {#each SUBJECTS as subject} + {@const enabled = enabledSubjects.has(subject)} + + {/each} +
+
+
+
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} -
+

Loading...

{:else if !authStore.isAuthenticated} -
+

Redirecting to login...

{:else} -
+