mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 02:23:34 -06:00
feat: add timeline API with schedule-aware enrollment aggregation
Implements POST /api/timeline endpoint that aggregates enrollment by subject over 15-minute slots, filtering courses by their actual meeting times. Includes ISR-style schedule cache with hourly background refresh using stale-while-revalidate pattern, database indexes for efficient queries, and frontend refactor to dynamically discover subjects from API.
This commit is contained in:
@@ -135,6 +135,29 @@ export interface MetricsParams {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** A time range for timeline queries (ISO-8601 strings). */
|
||||
export interface TimelineRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
/** Request body for POST /api/timeline. */
|
||||
export interface TimelineRequest {
|
||||
ranges: TimelineRange[];
|
||||
}
|
||||
|
||||
/** A single 15-minute slot returned by the timeline API. */
|
||||
export interface TimelineSlot {
|
||||
time: string;
|
||||
subjects: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Response from POST /api/timeline. */
|
||||
export interface TimelineResponse {
|
||||
slots: TimelineSlot[];
|
||||
subjects: string[];
|
||||
}
|
||||
|
||||
export interface SearchParams {
|
||||
term: string;
|
||||
subjects?: string[];
|
||||
@@ -297,6 +320,13 @@ export class BannerApiClient {
|
||||
/** Stored `Last-Modified` value for audit log conditional requests. */
|
||||
private _auditLastModified: string | null = null;
|
||||
|
||||
async getTimeline(ranges: TimelineRange[]): Promise<TimelineResponse> {
|
||||
return this.request<TimelineResponse>("/timeline", {
|
||||
method: "POST",
|
||||
body: { ranges } satisfies TimelineRequest,
|
||||
});
|
||||
}
|
||||
|
||||
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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,
|
||||
@@ -125,12 +124,31 @@ let pointerOverCanvas = false;
|
||||
|
||||
// ── Drawer ──────────────────────────────────────────────────────────
|
||||
let drawerOpen = $state(false);
|
||||
let enabledSubjects: Set<Subject> = $state(new Set(SUBJECTS));
|
||||
// Start with an empty set — subjects are populated dynamically from the API.
|
||||
let enabledSubjects: Set<string> = $state(new Set());
|
||||
|
||||
// ── Data store ──────────────────────────────────────────────────────
|
||||
const store = createTimelineStore();
|
||||
let data: TimeSlot[] = $derived(store.data);
|
||||
let activeSubjects = $derived(SUBJECTS.filter((s) => enabledSubjects.has(s)));
|
||||
let allSubjects: string[] = $derived(store.subjects);
|
||||
|
||||
// Auto-enable newly discovered subjects.
|
||||
$effect(() => {
|
||||
const storeSubjects = store.subjects;
|
||||
const next = new Set(enabledSubjects);
|
||||
let changed = false;
|
||||
for (const s of storeSubjects) {
|
||||
if (!next.has(s)) {
|
||||
next.add(s);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
enabledSubjects = next;
|
||||
}
|
||||
});
|
||||
|
||||
let activeSubjects = $derived(allSubjects.filter((s) => enabledSubjects.has(s)));
|
||||
|
||||
// ── Derived layout ──────────────────────────────────────────────────
|
||||
let viewStart = $derived(viewCenter - viewSpan / 2);
|
||||
@@ -151,7 +169,7 @@ let yScale = scaleLinear()
|
||||
.range([0, 1]);
|
||||
|
||||
// ── Subject toggling ────────────────────────────────────────────────
|
||||
function toggleSubject(subject: Subject) {
|
||||
function toggleSubject(subject: string) {
|
||||
const next = new Set(enabledSubjects);
|
||||
if (next.has(subject)) next.delete(subject);
|
||||
else next.add(subject);
|
||||
@@ -159,7 +177,7 @@ function toggleSubject(subject: Subject) {
|
||||
}
|
||||
|
||||
function enableAll() {
|
||||
enabledSubjects = new Set(SUBJECTS);
|
||||
enabledSubjects = new Set(allSubjects);
|
||||
}
|
||||
|
||||
function disableAll() {
|
||||
@@ -192,7 +210,7 @@ function render() {
|
||||
};
|
||||
|
||||
const visible = getVisibleSlots(data, viewStart, viewEnd);
|
||||
const visibleStack = stackVisibleSlots(visible, enabledSubjects, animMap);
|
||||
const visibleStack = stackVisibleSlots(visible, allSubjects, enabledSubjects, animMap);
|
||||
|
||||
drawGrid(chart);
|
||||
drawHoverColumn(chart, visibleStack, hoverSlotTime);
|
||||
@@ -585,8 +603,9 @@ function tick(timestamp: number) {
|
||||
// ── Animation sync ──────────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
const slots = data;
|
||||
const subs = allSubjects;
|
||||
const enabled = enabledSubjects;
|
||||
syncAnimTargets(animMap, slots, enabled);
|
||||
syncAnimTargets(animMap, slots, subs, enabled);
|
||||
});
|
||||
|
||||
// Request data whenever the visible window changes.
|
||||
@@ -625,7 +644,7 @@ onMount(() => {
|
||||
class:cursor-grabbing={isDragging}
|
||||
style="display: block; touch-action: none;"
|
||||
tabindex="0"
|
||||
aria-label="Interactive class schedule timeline chart"
|
||||
aria-label="Interactive enrollment timeline chart"
|
||||
onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
@@ -638,6 +657,7 @@ onMount(() => {
|
||||
|
||||
<TimelineDrawer
|
||||
bind:open={drawerOpen}
|
||||
subjects={allSubjects}
|
||||
{enabledSubjects}
|
||||
{followEnabled}
|
||||
onToggleSubject={toggleSubject}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Filter, X } from "@lucide/svelte";
|
||||
import { SUBJECTS, SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
|
||||
import { getSubjectColor } from "$lib/timeline/data";
|
||||
import { DRAWER_WIDTH } from "$lib/timeline/constants";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
enabledSubjects: Set<Subject>;
|
||||
subjects: readonly string[];
|
||||
enabledSubjects: Set<string>;
|
||||
followEnabled: boolean;
|
||||
onToggleSubject: (subject: Subject) => void;
|
||||
onToggleSubject: (subject: string) => void;
|
||||
onEnableAll: () => void;
|
||||
onDisableAll: () => void;
|
||||
onResumeFollow: () => void;
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
subjects,
|
||||
enabledSubjects,
|
||||
followEnabled,
|
||||
onToggleSubject,
|
||||
@@ -109,8 +111,9 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each SUBJECTS as subject}
|
||||
{#each subjects as subject}
|
||||
{@const enabled = enabledSubjects.has(subject)}
|
||||
{@const color = getSubjectColor(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"
|
||||
@@ -118,7 +121,7 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
>
|
||||
<span
|
||||
class="inline-block w-3 h-3 rounded-sm shrink-0 transition-opacity"
|
||||
style="background: {SUBJECT_COLORS[subject]}; opacity: {enabled ? 1 : 0.2};"
|
||||
style="background: {color}; opacity: {enabled ? 1 : 0.2};"
|
||||
></span>
|
||||
<span
|
||||
class="transition-opacity {enabled
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from "d3-time-format";
|
||||
import { SUBJECT_COLORS, type Subject } from "$lib/timeline/data";
|
||||
import { getSubjectColor } from "$lib/timeline/data";
|
||||
import type { TimeSlot } from "$lib/timeline/types";
|
||||
import { enabledTotalClasses } from "$lib/timeline/viewport";
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
slot: TimeSlot | null;
|
||||
activeSubjects: readonly Subject[];
|
||||
activeSubjects: readonly string[];
|
||||
}
|
||||
|
||||
let { visible, x, y, slot, activeSubjects }: Props = $props();
|
||||
@@ -35,7 +35,7 @@ const fmtTime = timeFormat("%-I:%M %p");
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-sm"
|
||||
style="background: {SUBJECT_COLORS[subject]}"
|
||||
style="background: {getSubjectColor(subject)}"
|
||||
></span>
|
||||
<span class="text-muted-foreground">{subject}</span>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* 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";
|
||||
|
||||
@@ -20,11 +19,15 @@ export function createAnimMap(): AnimMap {
|
||||
* 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.
|
||||
*
|
||||
* @param subjects - the full list of known subject codes
|
||||
* @param enabledSubjects - subjects currently toggled on
|
||||
*/
|
||||
export function syncAnimTargets(
|
||||
animMap: AnimMap,
|
||||
slots: TimeSlot[],
|
||||
enabledSubjects: Set<Subject>
|
||||
subjects: readonly string[],
|
||||
enabledSubjects: Set<string>
|
||||
): void {
|
||||
for (const slot of slots) {
|
||||
const timeMs = slot.time.getTime();
|
||||
@@ -34,7 +37,7 @@ export function syncAnimTargets(
|
||||
animMap.set(timeMs, subjectMap);
|
||||
}
|
||||
|
||||
for (const subject of SUBJECTS) {
|
||||
for (const subject of subjects) {
|
||||
const realValue = enabledSubjects.has(subject) ? slot.subjects[subject] || 0 : 0;
|
||||
const entry = subjectMap.get(subject);
|
||||
if (entry) {
|
||||
|
||||
+59
-103
@@ -1,122 +1,78 @@
|
||||
/**
|
||||
* 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.
|
||||
* Subject color palette for the timeline chart.
|
||||
*
|
||||
* Subjects are dynamic (coming from the API), so we assign colors from
|
||||
* a fixed palette based on a deterministic hash of the subject code.
|
||||
* Known high-enrollment subjects get hand-picked colors for familiarity.
|
||||
*/
|
||||
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> = {
|
||||
/** Hand-picked colors for common UTSA subject codes. */
|
||||
const KNOWN_SUBJECT_COLORS: Record<string, string> = {
|
||||
CS: "#6366f1", // indigo
|
||||
MATH: "#f59e0b", // amber
|
||||
MAT: "#f59e0b", // amber
|
||||
BIO: "#10b981", // emerald
|
||||
ENG: "#ef4444", // red
|
||||
PHYS: "#3b82f6", // blue
|
||||
HIST: "#8b5cf6", // violet
|
||||
CHEM: "#f97316", // orange
|
||||
PHY: "#3b82f6", // blue
|
||||
HIS: "#8b5cf6", // violet
|
||||
CHE: "#f97316", // orange
|
||||
PSY: "#ec4899", // pink
|
||||
ECE: "#14b8a6", // teal
|
||||
ART: "#a855f7", // purple
|
||||
ACC: "#84cc16", // lime
|
||||
FIN: "#06b6d4", // cyan
|
||||
MUS: "#e11d48", // rose
|
||||
POL: "#d946ef", // fuchsia
|
||||
SOC: "#22d3ee", // sky
|
||||
KIN: "#4ade80", // green
|
||||
IS: "#fb923c", // light orange
|
||||
STA: "#818cf8", // light indigo
|
||||
MGT: "#fbbf24", // yellow
|
||||
MKT: "#2dd4bf", // teal-light
|
||||
};
|
||||
|
||||
/**
|
||||
* Bell-curve-like distribution centered at a given hour.
|
||||
* Returns a value 0..1 representing relative class density.
|
||||
* Extended palette for subjects that don't have a hand-picked color.
|
||||
* These are chosen to be visually distinct from each other.
|
||||
*/
|
||||
function bellCurve(hour: number, center: number, spread: number): number {
|
||||
const x = (hour - center) / spread;
|
||||
return Math.exp(-0.5 * x * x);
|
||||
}
|
||||
const FALLBACK_PALETTE = [
|
||||
"#f472b6", // pink-400
|
||||
"#60a5fa", // blue-400
|
||||
"#34d399", // emerald-400
|
||||
"#fbbf24", // amber-400
|
||||
"#a78bfa", // violet-400
|
||||
"#fb7185", // rose-400
|
||||
"#38bdf8", // sky-400
|
||||
"#4ade80", // green-400
|
||||
"#facc15", // yellow-400
|
||||
"#c084fc", // purple-400
|
||||
"#f87171", // red-400
|
||||
"#2dd4bf", // teal-400
|
||||
"#fb923c", // orange-400
|
||||
"#818cf8", // indigo-400
|
||||
"#a3e635", // lime-400
|
||||
"#22d3ee", // cyan-400
|
||||
];
|
||||
|
||||
/**
|
||||
* 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));
|
||||
/** Simple string hash for deterministic color assignment. */
|
||||
function hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
|
||||
return { time, subjects };
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** Cache of assigned colors to avoid re-computing. */
|
||||
const colorCache = new Map<string, string>();
|
||||
|
||||
const slots: TimeSlot[] = [];
|
||||
for (let t = alignedStart; t <= alignedEnd; t += SLOT_INTERVAL_MS) {
|
||||
slots.push(generateSlot(t));
|
||||
}
|
||||
return slots;
|
||||
/** Get a consistent color for any subject code. */
|
||||
export function getSubjectColor(subject: string): string {
|
||||
const cached = colorCache.get(subject);
|
||||
if (cached) return cached;
|
||||
|
||||
const color =
|
||||
KNOWN_SUBJECT_COLORS[subject] ?? FALLBACK_PALETTE[hashCode(subject) % FALLBACK_PALETTE.length];
|
||||
|
||||
colorCache.set(subject, color);
|
||||
return color;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { stack, area, curveMonotoneX, type Series } from "d3-shape";
|
||||
import { timeFormat } from "d3-time-format";
|
||||
|
||||
import { SUBJECT_COLORS, type Subject } from "./data";
|
||||
import { getSubjectColor } from "./data";
|
||||
import type { AnimMap } from "./animation";
|
||||
import { getStackSubjects } from "./viewport";
|
||||
import type { ChartContext, TimeSlot } from "./types";
|
||||
@@ -55,22 +55,31 @@ export function chooseTickCount(viewSpan: number): number {
|
||||
* 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.
|
||||
*
|
||||
* @param allSubjects - full set of known subject codes
|
||||
*/
|
||||
export function stackVisibleSlots(
|
||||
visible: TimeSlot[],
|
||||
enabledSubjects: Set<Subject>,
|
||||
allSubjects: readonly string[],
|
||||
enabledSubjects: Set<string>,
|
||||
animMap: AnimMap
|
||||
): VisibleStack {
|
||||
if (visible.length === 0) return [];
|
||||
|
||||
const stackKeys = getStackSubjects(visible, enabledSubjects, animMap, SETTLE_THRESHOLD);
|
||||
const stackKeys = getStackSubjects(
|
||||
visible,
|
||||
allSubjects,
|
||||
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>;
|
||||
const subjects: Record<string, number> = {};
|
||||
for (const subject of stackKeys) {
|
||||
const entry = subjectMap?.get(subject);
|
||||
subjects[subject] = entry ? entry.current : slot.subjects[subject] || 0;
|
||||
@@ -80,7 +89,7 @@ export function stackVisibleSlots(
|
||||
|
||||
const gen = stack<TimeSlot>()
|
||||
.keys(stackKeys)
|
||||
.value((d, key) => d.subjects[key as Subject] || 0);
|
||||
.value((d, key) => d.subjects[key] || 0);
|
||||
return gen(animatedSlots);
|
||||
}
|
||||
|
||||
@@ -187,8 +196,8 @@ export function drawStackedArea(chart: ChartContext, visibleStack: VisibleStack)
|
||||
|
||||
for (let i = visibleStack.length - 1; i >= 0; i--) {
|
||||
const layer = visibleStack[i];
|
||||
const subject = layer.key as Subject;
|
||||
const color = SUBJECT_COLORS[subject];
|
||||
const subject = layer.key;
|
||||
const color = getSubjectColor(subject);
|
||||
|
||||
ctx.beginPath();
|
||||
area<StackPoint>()
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*
|
||||
* 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.
|
||||
* Fetches are throttled so rapid panning/zooming doesn't flood the API.
|
||||
*/
|
||||
import { generateSlots } from "./data";
|
||||
import { client, type TimelineRange } from "$lib/api";
|
||||
import { SLOT_INTERVAL_MS } from "./constants";
|
||||
import type { TimeSlot } from "./types";
|
||||
|
||||
@@ -16,20 +15,6 @@ 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;
|
||||
@@ -84,6 +69,24 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch timeline data for the given gap ranges from the API.
|
||||
* Converts gap ranges into the API request format.
|
||||
*/
|
||||
async function fetchFromApi(gaps: Range[]): Promise<TimeSlot[]> {
|
||||
const ranges: TimelineRange[] = gaps.map(([start, end]) => ({
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
}));
|
||||
|
||||
const response = await client.getTimeline(ranges);
|
||||
|
||||
return response.slots.map((slot) => ({
|
||||
time: new Date(slot.time),
|
||||
subjects: slot.subjects,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive timeline store.
|
||||
*
|
||||
@@ -93,6 +96,9 @@ function mergeRange(ranges: Range[], added: Range): Range[] {
|
||||
*
|
||||
* The `data` getter returns a sorted `TimeSlot[]` that reactively
|
||||
* updates as new segments arrive.
|
||||
*
|
||||
* The `subjects` getter returns the sorted list of all subject codes
|
||||
* seen so far across all fetched data.
|
||||
*/
|
||||
export function createTimelineStore() {
|
||||
// All loaded slots keyed by aligned timestamp (ms).
|
||||
@@ -101,6 +107,9 @@ export function createTimelineStore() {
|
||||
// Sorted, non-overlapping list of fetched ranges.
|
||||
let loadedRanges: Range[] = [];
|
||||
|
||||
// All subject codes observed across all fetched data.
|
||||
let knownSubjects: Set<string> = $state(new Set());
|
||||
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingStart = 0;
|
||||
let pendingEnd = 0;
|
||||
@@ -112,18 +121,28 @@ export function createTimelineStore() {
|
||||
[...slotMap.values()].sort((a, b) => a.time.getTime() - b.time.getTime())
|
||||
);
|
||||
|
||||
// Sorted subject list derived from the known subjects set.
|
||||
const subjects: string[] = $derived([...knownSubjects].sort());
|
||||
|
||||
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)));
|
||||
let slots: TimeSlot[];
|
||||
try {
|
||||
slots = await fetchFromApi(gaps);
|
||||
} catch (err) {
|
||||
console.error("Timeline fetch failed:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
const nextSubjects = new Set(knownSubjects);
|
||||
for (const slot of slots) {
|
||||
next.set(slot.time.getTime(), slot);
|
||||
for (const subject of Object.keys(slot.subjects)) {
|
||||
nextSubjects.add(subject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +151,9 @@ export function createTimelineStore() {
|
||||
loadedRanges = mergeRange(loadedRanges, gap);
|
||||
}
|
||||
|
||||
// Single reactive assignment.
|
||||
// Single reactive assignments.
|
||||
slotMap = next;
|
||||
knownSubjects = nextSubjects;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +193,9 @@ export function createTimelineStore() {
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get subjects() {
|
||||
return subjects;
|
||||
},
|
||||
requestRange,
|
||||
dispose,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Shared types for the timeline feature.
|
||||
*
|
||||
* Subjects are dynamic strings (actual Banner subject codes like "CS",
|
||||
* "MAT", "BIO") rather than a fixed enum — the set of subjects comes
|
||||
* from the API response.
|
||||
*/
|
||||
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. */
|
||||
/** A single 15-minute time slot with per-subject enrollment totals. */
|
||||
export interface TimeSlot {
|
||||
time: Date;
|
||||
subjects: Record<Subject, number>;
|
||||
subjects: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Lerped animation entry for a single subject within a slot. */
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 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";
|
||||
|
||||
/**
|
||||
@@ -55,8 +54,8 @@ 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 {
|
||||
/** Sum of enrollment counts for enabled subjects in a slot. */
|
||||
export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly string[]): number {
|
||||
let sum = 0;
|
||||
for (const s of activeSubjects) {
|
||||
sum += slot.subjects[s] || 0;
|
||||
@@ -67,15 +66,18 @@ export function enabledTotalClasses(slot: TimeSlot, activeSubjects: readonly Sub
|
||||
/**
|
||||
* Determine which subjects to include in the stack: all enabled subjects
|
||||
* plus any disabled subjects still animating out (current > threshold).
|
||||
*
|
||||
* @param allSubjects - the full set of known subject codes
|
||||
*/
|
||||
export function getStackSubjects(
|
||||
visible: TimeSlot[],
|
||||
enabledSubjects: Set<Subject>,
|
||||
allSubjects: readonly string[],
|
||||
enabledSubjects: Set<string>,
|
||||
animMap: Map<number, Map<string, { current: number }>>,
|
||||
settleThreshold: number
|
||||
): Subject[] {
|
||||
const subjects: Subject[] = [];
|
||||
for (const subject of SUBJECTS) {
|
||||
): string[] {
|
||||
const subjects: string[] = [];
|
||||
for (const subject of allSubjects) {
|
||||
if (enabledSubjects.has(subject)) {
|
||||
subjects.push(subject);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user