feat: add interactive timeline visualization for class times

Implements a canvas-based timeline view with D3 scales showing class
counts across subjects. Features drag-to-pan, mouse wheel zoom, subject
filtering, hover tooltips, and smooth animations. Timeline auto-follows
current time and supports keyboard navigation.
This commit is contained in:
2026-01-29 23:19:03 -06:00
parent 5a6ea1e53a
commit fa28f13a45
19 changed files with 1726 additions and 14 deletions
+36
View File
@@ -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=="],
+6
View File
@@ -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"
+8 -5
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import { page } from "$app/state";
import { Search, User } from "@lucide/svelte";
import { Search, User, Clock } from "@lucide/svelte";
import { authStore } from "$lib/auth.svelte";
import ThemeToggle from "./ThemeToggle.svelte";
const staticTabs = [{ href: "/", label: "Search", icon: Search }] as const;
const staticTabs = [
{ href: "/", label: "Search", icon: Search },
{ href: "/timeline", label: "Timeline", icon: Clock },
] as const;
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
@@ -25,7 +28,8 @@ function isActive(tabHref: string): boolean {
<nav class="w-full flex justify-center pt-5 px-5">
<div class="w-full max-w-6xl flex items-center justify-between">
<div class="flex items-center gap-1 rounded-lg bg-muted p-1">
<!-- pointer-events-auto: root layout wraps nav in pointer-events-none overlay -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1 pointer-events-auto">
{#each staticTabs as tab}
<a
href={tab.href}
@@ -48,8 +52,7 @@ function isActive(tabHref: string): boolean {
<User size={15} strokeWidth={2} />
{profileTab.label}
</a>
</div>
<ThemeToggle />
</div>
</div>
</nav>
+2 -2
View File
@@ -48,8 +48,8 @@ async function handleToggle(event: MouseEvent) {
type="button"
onclick={(e) => handleToggle(e)}
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
class="cursor-pointer border-none rounded-md flex items-center justify-center p-1.5
text-muted-foreground hover:text-foreground hover:bg-background/50 bg-transparent transition-colors"
>
<div class="relative size-[18px]">
<Sun
@@ -0,0 +1,550 @@
<script lang="ts">
import { onMount } from "svelte";
import { scaleTime, scaleLinear } from "d3-scale";
import { SUBJECTS, type Subject } from "$lib/timeline/data";
import type { TimeSlot, ChartContext } from "$lib/timeline/types";
import {
PADDING,
DEFAULT_AXIS_RATIO,
CHART_HEIGHT_RATIO,
MIN_SPAN_MS,
MAX_SPAN_MS,
DEFAULT_SPAN_MS,
ZOOM_FACTOR,
ZOOM_KEY_FACTOR,
ZOOM_EASE,
ZOOM_SETTLE_THRESHOLD,
PAN_FRICTION,
PAN_STOP_THRESHOLD,
PAN_STOP_THRESHOLD_Y,
VELOCITY_SAMPLE_WINDOW,
VELOCITY_MIN_DT,
PAN_STEP_RATIO,
PAN_STEP_CTRL_RATIO,
PAN_EASE,
PAN_SETTLE_THRESHOLD_PX,
YRATIO_STEP,
YRATIO_MIN,
YRATIO_MAX,
YRATIO_SETTLE_THRESHOLD,
FOLLOW_EASE,
MIN_MAXY,
MAX_DT,
DEFAULT_DT,
} from "$lib/timeline/constants";
import { createTimelineStore } from "$lib/timeline/store.svelte";
import {
createAnimMap,
syncAnimTargets,
stepAnimations,
pruneAnimMap,
} from "$lib/timeline/animation";
import {
getVisibleSlots,
findSlotByTime,
snapToSlot,
enabledTotalClasses,
} from "$lib/timeline/viewport";
import {
drawGrid,
drawHoverColumn,
drawStackedArea,
drawNowLine,
drawTimeAxis,
stackVisibleSlots,
} from "$lib/timeline/renderer";
import TimelineDrawer from "./TimelineDrawer.svelte";
import TimelineTooltip from "./TimelineTooltip.svelte";
// ── Reactive DOM state ──────────────────────────────────────────────
let canvasEl: HTMLCanvasElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
let width = $state(800);
let height = $state(400);
let dpr = $state(1);
// ── View window ─────────────────────────────────────────────────────
let viewCenter = $state(Date.now());
let viewSpan = $state(DEFAULT_SPAN_MS);
let viewYRatio = $state(DEFAULT_AXIS_RATIO);
// ── Interaction state ───────────────────────────────────────────────
let isDragging = $state(false);
let dragStartX = $state(0);
let dragStartY = $state(0);
let dragStartCenter = $state(0);
let dragStartYRatio = $state(0);
let followEnabled = $state(true);
let ctrlHeld = $state(false);
// ── Animation state (intentionally non-reactive — updated in rAF) ──
let panVelocity = 0;
let panVelocityY = 0;
let pointerSamples: { time: number; x: number; y: number }[] = [];
let targetSpan = DEFAULT_SPAN_MS;
let zoomAnchorTime = 0;
let zoomAnchorRatio = 0.5;
let isZoomAnimating = false;
let targetCenter = Date.now();
let isPanAnimating = false;
let targetYRatio = DEFAULT_AXIS_RATIO;
let isYPanAnimating = false;
let animationFrameId = 0;
let lastFrameTime = 0;
let animatedMaxY = MIN_MAXY;
const animMap = createAnimMap();
// ── Tooltip + hover ─────────────────────────────────────────────────
let tooltipVisible = $state(false);
let tooltipX = $state(0);
let tooltipY = $state(0);
let tooltipSlot: TimeSlot | null = $state(null);
let hoverSlotTime: number | null = $state(null);
let lastPointerClientX = 0;
let lastPointerClientY = 0;
let pointerOverCanvas = false;
// ── Drawer ──────────────────────────────────────────────────────────
let drawerOpen = $state(false);
let enabledSubjects: Set<Subject> = $state(new Set(SUBJECTS));
// ── Data store ──────────────────────────────────────────────────────
const store = createTimelineStore();
let data: TimeSlot[] = $derived(store.data);
let activeSubjects = $derived(SUBJECTS.filter((s) => enabledSubjects.has(s)));
// ── Derived layout ──────────────────────────────────────────────────
let viewStart = $derived(viewCenter - viewSpan / 2);
let viewEnd = $derived(viewCenter + viewSpan / 2);
let chartHeight = $derived(height * CHART_HEIGHT_RATIO);
let chartBottom = $derived(height * viewYRatio);
let chartTop = $derived(chartBottom - chartHeight);
let xScale = $derived(
scaleTime()
.domain([new Date(viewStart), new Date(viewEnd)])
.range([PADDING.left, width - PADDING.right])
);
// Reused across frames — domain/range updated imperatively in render().
let yScale = scaleLinear()
.domain([0, MIN_MAXY * 1.1])
.range([0, 1]);
// ── Subject toggling ────────────────────────────────────────────────
function toggleSubject(subject: Subject) {
const next = new Set(enabledSubjects);
if (next.has(subject)) next.delete(subject);
else next.add(subject);
enabledSubjects = next;
}
function enableAll() {
enabledSubjects = new Set(SUBJECTS);
}
function disableAll() {
enabledSubjects = new Set();
}
// ── Rendering ───────────────────────────────────────────────────────
function render() {
if (!canvasEl) return;
const ctx = canvasEl.getContext("2d");
if (!ctx) return;
// Update yScale in-place (no allocation per frame).
yScale.domain([0, animatedMaxY * 1.1]).range([chartBottom, chartTop]);
ctx.save();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
const chart: ChartContext = {
ctx,
xScale,
yScale,
width,
chartTop,
chartBottom,
viewSpan,
viewStart,
viewEnd,
};
const visible = getVisibleSlots(data, viewStart, viewEnd);
const visibleStack = stackVisibleSlots(visible, enabledSubjects, animMap);
drawGrid(chart);
drawHoverColumn(chart, visibleStack, hoverSlotTime);
drawStackedArea(chart, visibleStack);
drawNowLine(chart);
drawTimeAxis(chart);
ctx.restore();
}
// ── Hover logic ─────────────────────────────────────────────────────
function updateHover() {
if (!pointerOverCanvas || isDragging || !canvasEl) return;
const rect = canvasEl.getBoundingClientRect();
const x = lastPointerClientX - rect.left;
const y = lastPointerClientY - rect.top;
if (y < chartTop || y > chartBottom) {
tooltipVisible = false;
hoverSlotTime = null;
return;
}
const time = xScale.invert(x);
const snappedTime = snapToSlot(time.getTime());
const slot = findSlotByTime(data, snappedTime);
if (!slot) {
tooltipVisible = false;
hoverSlotTime = null;
return;
}
const total = enabledTotalClasses(slot, activeSubjects);
if (total <= 0) {
tooltipVisible = false;
hoverSlotTime = null;
return;
}
// Bypass area overlap check when CTRL is held.
if (!ctrlHeld) {
const stackTopY = yScale(total);
if (y < stackTopY) {
tooltipVisible = false;
hoverSlotTime = null;
return;
}
}
tooltipSlot = slot;
tooltipX = lastPointerClientX;
tooltipY = lastPointerClientY;
tooltipVisible = true;
hoverSlotTime = snappedTime;
}
// ── Interaction handlers ────────────────────────────────────────────
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragStartCenter = viewCenter;
dragStartYRatio = viewYRatio;
followEnabled = false;
panVelocity = 0;
panVelocityY = 0;
isZoomAnimating = false;
isPanAnimating = false;
isYPanAnimating = false;
targetSpan = viewSpan;
tooltipVisible = false;
hoverSlotTime = null;
pointerSamples = [{ time: performance.now(), x: e.clientX, y: e.clientY }];
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
ctrlHeld = e.ctrlKey || e.metaKey;
lastPointerClientX = e.clientX;
lastPointerClientY = e.clientY;
pointerOverCanvas = true;
if (isDragging) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const msPerPx = viewSpan / (width - PADDING.left - PADDING.right);
viewCenter = dragStartCenter - dx * msPerPx;
viewYRatio = dragStartYRatio + dy / height;
const now = performance.now();
pointerSamples.push({ time: now, x: e.clientX, y: e.clientY });
const cutoff = now - VELOCITY_SAMPLE_WINDOW;
pointerSamples = pointerSamples.filter((s) => s.time >= cutoff);
} else {
updateHover();
}
}
function onPointerUp(e: PointerEvent) {
isDragging = false;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
if (pointerSamples.length >= 2) {
const first = pointerSamples[0];
const last = pointerSamples[pointerSamples.length - 1];
const dt = last.time - first.time;
if (dt > VELOCITY_MIN_DT) {
const pxPerMsX = (last.x - first.x) / dt;
const msPerPx = viewSpan / (width - PADDING.left - PADDING.right);
panVelocity = -pxPerMsX * msPerPx;
panVelocityY = (last.y - first.y) / dt;
}
}
pointerSamples = [];
}
function onPointerLeave() {
pointerOverCanvas = false;
tooltipVisible = false;
hoverSlotTime = null;
}
function onWheel(e: WheelEvent) {
e.preventDefault();
if (!canvasEl) return;
followEnabled = false;
panVelocity = 0;
panVelocityY = 0;
const rect = canvasEl.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const chartWidth = width - PADDING.left - PADDING.right;
zoomAnchorTime = xScale.invert(mouseX).getTime();
zoomAnchorRatio = (mouseX - PADDING.left) / chartWidth;
const zoomIn = e.deltaY < 0;
const factor = zoomIn ? 1 / ZOOM_FACTOR : ZOOM_FACTOR;
targetSpan = Math.min(MAX_SPAN_MS, Math.max(MIN_SPAN_MS, targetSpan * factor));
isZoomAnimating = true;
}
function onKeyDown(e: KeyboardEvent) {
const wasCtrl = ctrlHeld;
ctrlHeld = e.ctrlKey || e.metaKey;
if (ctrlHeld !== wasCtrl) updateHover();
switch (e.key) {
case "ArrowLeft":
case "ArrowRight": {
e.preventDefault();
followEnabled = false;
panVelocity = 0;
const ratio = e.ctrlKey ? PAN_STEP_CTRL_RATIO : PAN_STEP_RATIO;
const step = viewSpan * ratio;
if (!isPanAnimating) targetCenter = viewCenter;
targetCenter += e.key === "ArrowRight" ? step : -step;
isPanAnimating = true;
break;
}
case "ArrowUp":
case "ArrowDown": {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const direction = e.key === "ArrowUp" ? -1 : 1;
if (!isYPanAnimating) targetYRatio = viewYRatio;
targetYRatio = Math.max(
YRATIO_MIN,
Math.min(YRATIO_MAX, targetYRatio + direction * YRATIO_STEP)
);
isYPanAnimating = true;
} else {
const factor = e.key === "ArrowUp" ? 1 / ZOOM_KEY_FACTOR : ZOOM_KEY_FACTOR;
followEnabled = false;
panVelocity = 0;
zoomAnchorTime = isPanAnimating ? targetCenter : viewCenter;
zoomAnchorRatio = 0.5;
targetSpan = Math.min(MAX_SPAN_MS, Math.max(MIN_SPAN_MS, targetSpan * factor));
isZoomAnimating = true;
}
break;
}
}
}
function onKeyUp(e: KeyboardEvent) {
const wasCtrl = ctrlHeld;
ctrlHeld = e.ctrlKey || e.metaKey;
if (ctrlHeld !== wasCtrl) updateHover();
}
function onWindowBlur() {
const wasCtrl = ctrlHeld;
ctrlHeld = false;
if (wasCtrl) updateHover();
}
function resumeFollow() {
panVelocity = 0;
panVelocityY = 0;
isPanAnimating = false;
isYPanAnimating = false;
targetYRatio = DEFAULT_AXIS_RATIO;
viewYRatio = DEFAULT_AXIS_RATIO;
targetSpan = DEFAULT_SPAN_MS;
isZoomAnimating = true;
zoomAnchorTime = Date.now();
zoomAnchorRatio = 0.5;
followEnabled = true;
}
// ── Resize ──────────────────────────────────────────────────────────
function updateSize() {
if (!containerEl) return;
const rect = containerEl.getBoundingClientRect();
width = rect.width;
height = rect.height;
dpr = window.devicePixelRatio || 1;
if (canvasEl) {
canvasEl.width = width * dpr;
canvasEl.height = height * dpr;
}
}
// ── Animation loop ──────────────────────────────────────────────────
function tick(timestamp: number) {
const dt = lastFrameTime > 0 ? Math.min(timestamp - lastFrameTime, MAX_DT) : DEFAULT_DT;
lastFrameTime = timestamp;
const friction = Math.pow(PAN_FRICTION, dt / 16);
// Momentum panning
if (
!isDragging &&
(Math.abs(panVelocity) > PAN_STOP_THRESHOLD || Math.abs(panVelocityY) > PAN_STOP_THRESHOLD_Y)
) {
viewCenter += panVelocity * dt;
viewYRatio += (panVelocityY * dt) / height;
panVelocity *= friction;
panVelocityY *= friction;
if (Math.abs(panVelocity) < PAN_STOP_THRESHOLD) panVelocity = 0;
if (Math.abs(panVelocityY) < PAN_STOP_THRESHOLD_Y) panVelocityY = 0;
}
// Smooth zoom
if (isZoomAnimating && !isDragging) {
const spanDiff = targetSpan - viewSpan;
if (Math.abs(spanDiff) < ZOOM_SETTLE_THRESHOLD) {
viewSpan = targetSpan;
viewCenter = zoomAnchorTime + (0.5 - zoomAnchorRatio) * viewSpan;
isZoomAnimating = false;
} else {
const zf = 1 - Math.pow(1 - ZOOM_EASE, dt / 16);
viewSpan += spanDiff * zf;
viewCenter = zoomAnchorTime + (0.5 - zoomAnchorRatio) * viewSpan;
}
}
// Keyboard pan
if (isPanAnimating && !isDragging) {
const panDiff = targetCenter - viewCenter;
const msPerPx = viewSpan / (width - PADDING.left - PADDING.right);
if (Math.abs(panDiff) < msPerPx * PAN_SETTLE_THRESHOLD_PX) {
viewCenter = targetCenter;
isPanAnimating = false;
} else {
viewCenter += panDiff * (1 - Math.pow(1 - PAN_EASE, dt / 16));
}
}
// Y-axis pan
if (isYPanAnimating && !isDragging) {
const yDiff = targetYRatio - viewYRatio;
if (Math.abs(yDiff) < YRATIO_SETTLE_THRESHOLD) {
viewYRatio = targetYRatio;
isYPanAnimating = false;
} else {
viewYRatio += yDiff * (1 - Math.pow(1 - PAN_EASE, dt / 16));
}
}
// Follow mode
if (followEnabled && !isDragging) {
const target = Date.now();
viewCenter += (target - viewCenter) * (1 - Math.pow(1 - FOLLOW_EASE, dt / 16));
}
// Step value animations & prune offscreen entries
const result = stepAnimations(animMap, dt, animatedMaxY);
animatedMaxY = result.maxY;
pruneAnimMap(animMap, viewStart, viewEnd, viewSpan);
render();
animationFrameId = requestAnimationFrame(tick);
}
// ── Animation sync ──────────────────────────────────────────────────
$effect(() => {
const slots = data;
const enabled = enabledSubjects;
syncAnimTargets(animMap, slots, enabled);
});
// Request data whenever the visible window changes.
$effect(() => {
store.requestRange(viewStart, viewEnd);
});
// ── Lifecycle ───────────────────────────────────────────────────────
onMount(() => {
updateSize();
const ro = new ResizeObserver(updateSize);
if (containerEl) ro.observe(containerEl);
window.addEventListener("blur", onWindowBlur);
viewCenter = Date.now();
targetCenter = viewCenter;
targetSpan = viewSpan;
canvasEl?.focus();
animationFrameId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(animationFrameId);
ro.disconnect();
window.removeEventListener("blur", onWindowBlur);
store.dispose();
};
});
</script>
<div class="absolute inset-0 select-none" bind:this={containerEl}>
<canvas
bind:this={canvasEl}
class="w-full h-full cursor-grab outline-none"
class:cursor-grabbing={isDragging}
style="display: block;"
tabindex="0"
aria-label="Interactive class schedule timeline chart"
onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointerleave={onPointerLeave}
onwheel={onWheel}
onkeydown={onKeyDown}
onkeyup={onKeyUp}
></canvas>
<TimelineDrawer
bind:open={drawerOpen}
{enabledSubjects}
{followEnabled}
onToggleSubject={toggleSubject}
onEnableAll={enableAll}
onDisableAll={disableAll}
onResumeFollow={resumeFollow}
/>
<TimelineTooltip
visible={tooltipVisible}
x={tooltipX}
y={tooltipY}
slot={tooltipSlot}
{activeSubjects}
/>
</div>
@@ -0,0 +1,135 @@
<script lang="ts">
import { Filter, X } from "@lucide/svelte";
import { SUBJECTS, SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
import { DRAWER_WIDTH } from "$lib/timeline/constants";
interface Props {
open: boolean;
enabledSubjects: Set<Subject>;
followEnabled: boolean;
onToggleSubject: (subject: Subject) => void;
onEnableAll: () => void;
onDisableAll: () => void;
onResumeFollow: () => void;
}
let {
open = $bindable(),
enabledSubjects,
followEnabled,
onToggleSubject,
onEnableAll,
onDisableAll,
onResumeFollow,
}: Props = $props();
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && open) {
open = false;
}
}
</script>
<svelte:window onkeydown={onKeyDown} />
<!-- Filter toggle button — slides out when drawer opens -->
<button
class="absolute right-3 z-50 p-2 rounded-md
bg-black text-white dark:bg-white dark:text-black
hover:bg-neutral-800 dark:hover:bg-neutral-200
border border-black/20 dark:border-white/20
shadow-md transition-all duration-200 ease-in-out cursor-pointer
{open ? 'opacity-0 pointer-events-none' : 'opacity-100'}"
style="top: 20%; transform: translateX({open ? '60px' : '0'});"
onclick={() => (open = true)}
aria-label="Open filters"
>
<Filter size={18} strokeWidth={2} />
</button>
<!-- Drawer panel -->
<div
class="absolute right-0 z-40 rounded-l-lg shadow-xl transition-transform duration-200 ease-in-out {open ? '' : 'pointer-events-none'}"
style="top: 20%; width: {DRAWER_WIDTH}px; height: 60%; transform: translateX({open
? 0
: DRAWER_WIDTH}px);"
>
<div
class="h-full flex flex-col bg-background/90 backdrop-blur-md border border-border/40 rounded-l-lg overflow-hidden"
style="width: {DRAWER_WIDTH}px;"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-border/40">
<span class="text-xs font-semibold text-foreground">Filters</span>
<button
class="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onclick={() => (open = false)}
aria-label="Close filters"
>
<X size={14} strokeWidth={2} />
</button>
</div>
<!-- Follow status -->
<div class="px-3 py-2 border-b border-border/40">
{#if followEnabled}
<div
class="px-2 py-1 rounded-md text-[10px] font-medium text-center
bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
>
FOLLOWING
</div>
{:else}
<button
class="w-full px-2 py-1 rounded-md text-[10px] font-medium text-center
bg-muted/80 text-muted-foreground hover:text-foreground
border border-border/50 transition-colors cursor-pointer"
onclick={onResumeFollow}
aria-label="Resume following current time"
>
FOLLOW
</button>
{/if}
</div>
<!-- Subject toggles -->
<div class="flex-1 overflow-y-auto px-3 py-2">
<div class="flex items-center justify-between mb-2 text-[10px] text-muted-foreground">
<span class="uppercase tracking-wider font-medium">Subjects</span>
<div class="flex gap-1.5">
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onEnableAll}>All</button
>
<span class="opacity-40">|</span>
<button
class="hover:text-foreground transition-colors cursor-pointer"
onclick={onDisableAll}>None</button
>
</div>
</div>
<div class="space-y-0.5">
{#each SUBJECTS as subject}
{@const enabled = enabledSubjects.has(subject)}
<button
class="flex items-center gap-2 w-full px-1.5 py-1 rounded text-xs
hover:bg-muted/50 transition-colors cursor-pointer text-left"
onclick={() => onToggleSubject(subject)}
>
<span
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
style="background: {SUBJECT_COLORS[subject]}; opacity: {enabled ? 1 : 0.2};"
></span>
<span
class="transition-opacity {enabled
? 'text-foreground'
: 'text-muted-foreground/50'}"
>
{subject}
</span>
</button>
{/each}
</div>
</div>
</div>
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import { timeFormat } from "d3-time-format";
import { SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
import type { TimeSlot } from "$lib/timeline/types";
import { enabledTotalClasses } from "$lib/timeline/viewport";
interface Props {
visible: boolean;
x: number;
y: number;
slot: TimeSlot | null;
activeSubjects: readonly Subject[];
}
let { visible, x, y, slot, activeSubjects }: Props = $props();
const fmtTime = timeFormat("%-I:%M %p");
</script>
{#if visible && slot}
{@const total = enabledTotalClasses(slot, activeSubjects)}
<div
class="pointer-events-none fixed z-50 rounded-lg border border-border/60 bg-background/95
backdrop-blur-sm shadow-lg px-3 py-2 text-xs min-w-[140px]"
style="left: {x + 12}px; top: {y - 10}px; transform: translateY(-100%);"
>
<div class="font-semibold text-foreground mb-1.5">
{fmtTime(slot.time)}
</div>
<div class="space-y-0.5">
{#each activeSubjects as subject}
{@const count = slot.subjects[subject] || 0}
{#if count > 0}
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1.5">
<span
class="inline-block w-2 h-2 rounded-sm"
style="background: {SUBJECT_COLORS[subject]}"
></span>
<span class="text-muted-foreground">{subject}</span>
</div>
<span class="font-medium tabular-nums">{count}</span>
</div>
{/if}
{/each}
</div>
<div class="mt-1.5 pt-1.5 border-t border-border/40 flex justify-between font-medium">
<span>Total</span>
<span class="tabular-nums">{total}</span>
</div>
</div>
{/if}
+108
View File
@@ -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<number, Map<string, AnimEntry>>;
/** 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<Subject>
): 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);
}
}
}
+75
View File
@@ -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";
+122
View File
@@ -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<Subject, string> = {
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<Subject, { peaks: number[]; weight: number; spread: number }> = {
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<Subject, number>;
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;
}
+291
View File
@@ -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<TimeSlot, string>[number];
export type VisibleStack = Series<TimeSlot, string>[];
// ── 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<Subject>,
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<Subject, number>;
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<TimeSlot>()
.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<StackPoint>()
.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<StackPoint>()
.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();
}
+179
View File
@@ -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<TimeSlot[]> {
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<number, TimeSlot> = $state(new Map());
// Sorted, non-overlapping list of fetched ranges.
let loadedRanges: Range[] = [];
let throttleTimer: ReturnType<typeof setTimeout> | 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<void> {
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,
};
}
+36
View File
@@ -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<Subject, number>;
}
/** 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<number, number>;
yScale: ScaleLinear<number, number>;
width: number;
chartTop: number;
chartBottom: number;
viewSpan: number;
viewStart: number;
viewEnd: number;
}
+92
View File
@@ -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<Subject>,
animMap: Map<number, Map<string, { current: number }>>,
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;
}
+3 -3
View File
@@ -70,19 +70,19 @@ function isActive(href: string): boolean {
</script>
{#if authStore.isLoading}
<div class="flex flex-col items-center p-5 pt-2">
<div class="flex flex-col items-center px-5 pb-5 pt-20">
<div class="w-full max-w-6xl">
<p class="text-muted-foreground py-12 text-center text-sm">Loading...</p>
</div>
</div>
{:else if !authStore.isAuthenticated}
<div class="flex flex-col items-center p-5 pt-2">
<div class="flex flex-col items-center px-5 pb-5 pt-20">
<div class="w-full max-w-6xl">
<p class="text-muted-foreground py-12 text-center text-sm">Redirecting to login...</p>
</div>
</div>
{:else}
<div class="flex flex-col items-center p-5 pt-2">
<div class="flex flex-col items-center px-5 pb-5 pt-20">
<div class="w-full max-w-6xl flex gap-8">
<!-- Inline sidebar -->
<aside class="w-48 shrink-0 pt-1">
+1 -1
View File
@@ -289,7 +289,7 @@ function getTimingDisplay(
tooltipLines.push(`Locked: ${formatAbsoluteDate(job.lockedAt)}`);
}
tooltipLines.push(
`${job.status === "staleLock" ? "Stale for" : "Processing"}: ${formatDuration(processingMs)}`,
`${job.status === "staleLock" ? "Stale for" : "Processing"}: ${formatDuration(processingMs)}`
);
return {
+5 -1
View File
@@ -38,8 +38,12 @@ onMount(() => {
</script>
<Tooltip.Provider>
<div class="flex min-h-screen flex-col">
<div class="relative flex min-h-screen flex-col">
<!-- pointer-events-none so the navbar doesn't block canvas interactions;
NavBar re-enables pointer-events on its own container. -->
<div class="absolute inset-x-0 top-0 z-50 pointer-events-none">
<NavBar />
</div>
<svelte:boundary onerror={(e) => console.error("[root boundary]", e)}>
<PageTransition key={transitionKey}>
+1 -1
View File
@@ -200,7 +200,7 @@ function handlePageChange(newOffset: number) {
}
</script>
<div class="min-h-screen flex flex-col items-center p-5">
<div class="min-h-screen flex flex-col items-center px-5 pb-5 pt-20">
<div class="w-full max-w-6xl flex flex-col gap-6 pt-2">
<!-- Search status + Filters -->
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from "svelte";
import TimelineCanvas from "$lib/components/TimelineCanvas.svelte";
// Prevent body scroll while this page is mounted via a CSS class
// (avoids conflicting with other components that may manage overflow).
onMount(() => {
document.documentElement.classList.add("overflow-hidden");
document.body.classList.add("overflow-hidden");
return () => {
document.documentElement.classList.remove("overflow-hidden");
document.body.classList.remove("overflow-hidden");
};
});
</script>
<svelte:head>
<title>Class Timeline</title>
</svelte:head>
<div class="fixed inset-0 z-0">
<TimelineCanvas />
</div>