mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 06:23:37 -06:00
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:
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user