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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user