shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.
shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.
i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
986 lines
32 KiB
TypeScript
986 lines
32 KiB
TypeScript
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
|
|
|
// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
|
|
// renderer for the standalone Project Timeline / Chart page.
|
|
//
|
|
// Split into two concerns:
|
|
//
|
|
// layout(events, lanes, viewport): ChartLayout
|
|
// pure function — translates the wire shape into deterministic
|
|
// SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
|
|
// today-rule x). No DOM access. Table-driven tests pin this in
|
|
// shape-timeline-chart.test.ts.
|
|
//
|
|
// paint(layout, root): void (Slice 1, next commit)
|
|
// DOM-mutates an SVGSVGElement. Reads layout, never recomputes
|
|
// positions. Idempotent — calling twice with the same layout
|
|
// produces the same DOM.
|
|
//
|
|
// mount(host, opts): ChartHandle (Slice 1, next commit)
|
|
// End-to-end: fetches /api/projects/{id}/timeline, computes layout,
|
|
// paints, returns a handle with .refresh() / .dispose().
|
|
//
|
|
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Density = "compact" | "standard" | "spacious";
|
|
|
|
export interface ChartViewport {
|
|
width: number;
|
|
height: number;
|
|
/** Reserved on the left for lane labels (and the undated zone). */
|
|
laneLabelWidth: number;
|
|
/** Reserved on top for the date axis. */
|
|
dateAxisHeight: number;
|
|
/** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
|
|
todayISO: string;
|
|
/** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
|
|
rangeFrom: string;
|
|
/** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
|
|
rangeTo: string;
|
|
density: Density;
|
|
}
|
|
|
|
export interface AxisTick {
|
|
x: number;
|
|
label: string;
|
|
kind: "year" | "quarter" | "month";
|
|
isYearBoundary: boolean;
|
|
}
|
|
|
|
export interface LaneRow {
|
|
id: string;
|
|
label: string;
|
|
y: number;
|
|
height: number;
|
|
}
|
|
|
|
export type MarkShape =
|
|
| "dot"
|
|
| "diamond"
|
|
| "hatched-dot"
|
|
| "dashed-dot";
|
|
|
|
export interface Mark {
|
|
/** Index into the original events array — paint() reuses this for tooltips + deep-links. */
|
|
eventIndex: number;
|
|
x: number;
|
|
y: number;
|
|
/** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
|
|
radius: number;
|
|
shape: MarkShape;
|
|
kind: TimelineEvent["kind"];
|
|
status: TimelineEvent["status"];
|
|
laneId: string;
|
|
undated: boolean;
|
|
}
|
|
|
|
export interface ChartLayout {
|
|
viewport: ChartViewport;
|
|
pxPerDay: number;
|
|
chartLeft: number;
|
|
chartTop: number;
|
|
chartWidth: number;
|
|
chartHeight: number;
|
|
axisTicks: AxisTick[];
|
|
laneRows: LaneRow[];
|
|
marks: Mark[];
|
|
/** Pixel x of the today rule, or null when today is outside the range. */
|
|
todayX: number | null;
|
|
undatedCount: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Density tokens — single source of truth, used by layout() and CSS swap.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LANE_HEIGHT: Record<Density, number> = {
|
|
compact: 24,
|
|
standard: 40,
|
|
spacious: 64,
|
|
};
|
|
|
|
const MARK_RADIUS: Record<Density, number> = {
|
|
compact: 5,
|
|
standard: 7,
|
|
spacious: 10,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Date helpers — UTC throughout to avoid DST drift in day-math.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DAY_MS = 86_400_000;
|
|
|
|
function parseISODay(iso: string): number | null {
|
|
// Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
|
|
// deadline.due_date as the UTC-midnight form — see format.ts).
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
if (!m) return null;
|
|
const y = Number(m[1]);
|
|
const mo = Number(m[2]);
|
|
const d = Number(m[3]);
|
|
if (
|
|
!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
|
|
mo < 1 || mo > 12 || d < 1 || d > 31
|
|
) {
|
|
return null;
|
|
}
|
|
return Date.UTC(y, mo - 1, d);
|
|
}
|
|
|
|
function dayDelta(fromMs: number, toMs: number): number {
|
|
return Math.round((toMs - fromMs) / DAY_MS);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
|
|
if (kind === "milestone") return "diamond";
|
|
if (kind === "projected") {
|
|
if (status === "court_set") return "dashed-dot";
|
|
return "hatched-dot"; // predicted, predicted_overdue, off_script
|
|
}
|
|
// deadline + appointment + everything else → plain dot. Status drives
|
|
// colour saturation (see CSS palette tokens), not shape.
|
|
return "dot";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Axis tick generation — granularity by total span.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function generateTicks(
|
|
fromMs: number,
|
|
toMs: number,
|
|
chartLeft: number,
|
|
pxPerDay: number,
|
|
): AxisTick[] {
|
|
const totalDays = dayDelta(fromMs, toMs);
|
|
const ticks: AxisTick[] = [];
|
|
|
|
// Walk from the first day-of-month >= fromMs forward.
|
|
const start = new Date(fromMs);
|
|
const yStart = start.getUTCFullYear();
|
|
const mStart = start.getUTCMonth();
|
|
|
|
// Density rules:
|
|
// <90d → month ticks (every month-start)
|
|
// 90-730 → quarter ticks (Jan, Apr, Jul, Oct)
|
|
// >730 → year ticks (Jan only)
|
|
let kind: AxisTick["kind"];
|
|
let monthStep: number;
|
|
if (totalDays < 90) {
|
|
kind = "month";
|
|
monthStep = 1;
|
|
} else if (totalDays <= 730) {
|
|
kind = "quarter";
|
|
monthStep = 3;
|
|
} else {
|
|
kind = "year";
|
|
monthStep = 12;
|
|
}
|
|
|
|
// For quarter/year ticks, snap the starting month to the next aligned
|
|
// boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
|
|
// Feb/May/Aug/Nov).
|
|
let mCursor = mStart;
|
|
let yCursor = yStart;
|
|
if (kind === "quarter") {
|
|
const offset = mCursor % 3;
|
|
if (offset !== 0) mCursor += 3 - offset;
|
|
} else if (kind === "year") {
|
|
if (mCursor !== 0) {
|
|
mCursor = 0;
|
|
yCursor += 1;
|
|
}
|
|
}
|
|
// If the first day of fromMs is not month-1, advance by one month so we
|
|
// don't double-print the partial month at the very start.
|
|
if (kind === "month" && start.getUTCDate() !== 1) {
|
|
mCursor += 1;
|
|
}
|
|
while (mCursor >= 12) {
|
|
mCursor -= 12;
|
|
yCursor += 1;
|
|
}
|
|
|
|
// Emit ticks until past toMs.
|
|
while (true) {
|
|
const tickMs = Date.UTC(yCursor, mCursor, 1);
|
|
if (tickMs > toMs) break;
|
|
const days = dayDelta(fromMs, tickMs);
|
|
const x = chartLeft + days * pxPerDay;
|
|
const label = formatTickLabel(yCursor, mCursor, kind);
|
|
ticks.push({
|
|
x,
|
|
label,
|
|
kind,
|
|
isYearBoundary: mCursor === 0,
|
|
});
|
|
mCursor += monthStep;
|
|
while (mCursor >= 12) {
|
|
mCursor -= 12;
|
|
yCursor += 1;
|
|
}
|
|
}
|
|
return ticks;
|
|
}
|
|
|
|
const MONTH_DE = [
|
|
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
|
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
|
|
];
|
|
|
|
function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
|
|
if (kind === "year") return String(year);
|
|
if (kind === "quarter") {
|
|
const q = Math.floor(month / 3) + 1;
|
|
return `Q${q} ${year}`;
|
|
}
|
|
return MONTH_DE[month];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public: layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function layout(
|
|
events: ReadonlyArray<TimelineEvent>,
|
|
lanes: ReadonlyArray<LaneInfo>,
|
|
viewport: ChartViewport,
|
|
): ChartLayout {
|
|
// -- Canvas geometry --------------------------------------------------
|
|
const chartLeft = viewport.laneLabelWidth;
|
|
const chartTop = viewport.dateAxisHeight;
|
|
const chartWidth = Math.max(0, viewport.width - chartLeft);
|
|
// chartHeight is derived from the number of lane rows so the SVG grows
|
|
// / shrinks vertically with the data, not the supplied viewport.height
|
|
// (which the caller uses as a hint — actual height comes back in
|
|
// viewport.height after the paint pass).
|
|
const laneCount = Math.max(1, lanes.length);
|
|
const laneHeight = LANE_HEIGHT[viewport.density];
|
|
const chartHeight = laneCount * laneHeight;
|
|
|
|
// -- Date math --------------------------------------------------------
|
|
const fromMs = parseISODay(viewport.rangeFrom);
|
|
const toMsRaw = parseISODay(viewport.rangeTo);
|
|
if (fromMs === null || toMsRaw === null) {
|
|
// Degenerate input — return an empty layout rather than NaN-paint.
|
|
return {
|
|
viewport,
|
|
pxPerDay: 0,
|
|
chartLeft,
|
|
chartTop,
|
|
chartWidth,
|
|
chartHeight,
|
|
axisTicks: [],
|
|
laneRows: synthLaneRows(lanes, chartTop, laneHeight),
|
|
marks: [],
|
|
todayX: null,
|
|
undatedCount: 0,
|
|
};
|
|
}
|
|
// Guard against to < from. Clamp the inverted case to a 1-day span so
|
|
// pxPerDay stays positive and finite.
|
|
const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
|
|
const totalDays = Math.max(1, dayDelta(fromMs, toMs));
|
|
const pxPerDay = chartWidth / totalDays;
|
|
|
|
// -- Today rule -------------------------------------------------------
|
|
const todayMs = parseISODay(viewport.todayISO);
|
|
let todayX: number | null = null;
|
|
if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
|
|
todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
|
|
}
|
|
|
|
// -- Lane rows --------------------------------------------------------
|
|
const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
|
|
const laneIndex = new Map<string, LaneRow>();
|
|
for (const row of laneRows) laneIndex.set(row.id, row);
|
|
const fallbackLane = laneRows[0];
|
|
|
|
// -- Marks ------------------------------------------------------------
|
|
const marks: Mark[] = [];
|
|
let undatedCount = 0;
|
|
const radius = MARK_RADIUS[viewport.density];
|
|
|
|
for (let i = 0; i < events.length; i++) {
|
|
const event = events[i];
|
|
const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;
|
|
|
|
if (!event.date) {
|
|
// Undated rows live in a gutter to the left of the chart canvas.
|
|
// We pile them up vertically inside the lane label area so they
|
|
// remain hover-/click-targets, but they don't compete with the
|
|
// date-axis-positioned marks for screen space.
|
|
undatedCount++;
|
|
const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
|
|
marks.push({
|
|
eventIndex: i,
|
|
x: undatedX,
|
|
y: laneRow.y + laneRow.height / 2,
|
|
radius,
|
|
shape: markShape(event.kind, event.status),
|
|
kind: event.kind,
|
|
status: event.status,
|
|
laneId: laneRow.id,
|
|
undated: true,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const ms = parseISODay(event.date);
|
|
if (ms === null) continue; // unparseable date, drop defensively
|
|
if (ms < fromMs || ms > toMs) continue; // outside range — clipped
|
|
|
|
const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
|
|
const y = laneRow.y + laneRow.height / 2;
|
|
marks.push({
|
|
eventIndex: i,
|
|
x,
|
|
y,
|
|
radius,
|
|
shape: markShape(event.kind, event.status),
|
|
kind: event.kind,
|
|
status: event.status,
|
|
laneId: laneRow.id,
|
|
undated: false,
|
|
});
|
|
}
|
|
|
|
// -- Axis ticks -------------------------------------------------------
|
|
const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);
|
|
|
|
return {
|
|
viewport,
|
|
pxPerDay,
|
|
chartLeft,
|
|
chartTop,
|
|
chartWidth,
|
|
chartHeight,
|
|
axisTicks,
|
|
laneRows,
|
|
marks,
|
|
todayX,
|
|
undatedCount,
|
|
};
|
|
}
|
|
|
|
function synthLaneRows(
|
|
lanes: ReadonlyArray<LaneInfo>,
|
|
chartTop: number,
|
|
laneHeight: number,
|
|
): LaneRow[] {
|
|
if (lanes.length === 0) {
|
|
return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
|
|
}
|
|
return lanes.map((lane, idx) => ({
|
|
id: lane.id,
|
|
label: lane.label,
|
|
y: chartTop + idx * laneHeight,
|
|
height: laneHeight,
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public: paint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
|
|
const el = document.createElementNS(SVG_NS, name);
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
el.setAttribute(k, String(v));
|
|
}
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* paint mutates an existing SVGSVGElement to reflect a ChartLayout.
|
|
* Idempotent: clears prior children before painting, so calling twice
|
|
* with the same layout produces the same DOM.
|
|
*
|
|
* Events are *not* wired here — mount() attaches the delegated listeners
|
|
* after paint() returns. paint() stays pure-render so it stays cheap to
|
|
* call from a resize / palette swap.
|
|
*/
|
|
export function paint(
|
|
chart: ChartLayout,
|
|
root: SVGSVGElement,
|
|
events: ReadonlyArray<TimelineEvent>,
|
|
): void {
|
|
// Clear prior contents.
|
|
while (root.firstChild) root.removeChild(root.firstChild);
|
|
|
|
const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
|
|
root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
|
|
root.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
|
root.setAttribute("role", "img");
|
|
root.setAttribute("aria-label", "Project Timeline / Chart");
|
|
|
|
// <defs> — hatched pattern for projected marks.
|
|
const defs = svg("defs");
|
|
const pattern = svg("pattern", {
|
|
id: "chart-hatch",
|
|
patternUnits: "userSpaceOnUse",
|
|
width: 4,
|
|
height: 4,
|
|
});
|
|
pattern.appendChild(svg("path", {
|
|
d: "M0,4 L4,0",
|
|
stroke: "currentColor",
|
|
"stroke-width": 1,
|
|
fill: "none",
|
|
}));
|
|
defs.appendChild(pattern);
|
|
root.appendChild(defs);
|
|
|
|
// Layer order: grid → lane separators → today rule → marks → labels.
|
|
const gGrid = svg("g", { class: "chart-grid" });
|
|
root.appendChild(gGrid);
|
|
|
|
// Date axis ticks — vertical guidelines + labels at top.
|
|
for (const tick of chart.axisTicks) {
|
|
gGrid.appendChild(svg("line", {
|
|
class: tick.isYearBoundary
|
|
? "chart-tick chart-tick--year"
|
|
: "chart-tick",
|
|
x1: tick.x,
|
|
y1: chart.chartTop,
|
|
x2: tick.x,
|
|
y2: chart.chartTop + chart.chartHeight,
|
|
}));
|
|
const label = svg("text", {
|
|
class: "chart-tick-label",
|
|
x: tick.x + 4,
|
|
y: chart.chartTop - 8,
|
|
});
|
|
label.textContent = tick.label;
|
|
gGrid.appendChild(label);
|
|
}
|
|
|
|
// Lane separators — horizontal lines between rows + labels in the gutter.
|
|
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
|
|
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
|
|
// would bleed into the chart canvas (t-paliad-211).
|
|
const labelPadding = 8;
|
|
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
|
|
for (let i = 0; i < chart.laneRows.length; i++) {
|
|
const row = chart.laneRows[i];
|
|
if (i > 0) {
|
|
gGrid.appendChild(svg("line", {
|
|
class: "chart-lane-separator",
|
|
x1: 0,
|
|
y1: row.y,
|
|
x2: chart.viewport.width,
|
|
y2: row.y,
|
|
}));
|
|
}
|
|
if (row.label) {
|
|
const fo = svg("foreignObject", {
|
|
class: "chart-lane-label-fo",
|
|
x: labelPadding,
|
|
y: row.y,
|
|
width: labelMaxWidth,
|
|
height: row.height,
|
|
});
|
|
const div = document.createElement("div");
|
|
div.className = "chart-lane-label";
|
|
div.textContent = row.label;
|
|
div.title = row.label;
|
|
fo.appendChild(div);
|
|
gGrid.appendChild(fo);
|
|
}
|
|
}
|
|
|
|
// Today rule — vertical lime line + "Heute" label.
|
|
if (chart.todayX !== null) {
|
|
gGrid.appendChild(svg("line", {
|
|
class: "chart-today-rule",
|
|
x1: chart.todayX,
|
|
y1: chart.chartTop - 4,
|
|
x2: chart.todayX,
|
|
y2: chart.chartTop + chart.chartHeight + 4,
|
|
}));
|
|
const todayLabel = svg("text", {
|
|
class: "chart-today-label",
|
|
x: chart.todayX + 4,
|
|
y: chart.chartTop + chart.chartHeight + 18,
|
|
});
|
|
todayLabel.textContent = "Heute";
|
|
gGrid.appendChild(todayLabel);
|
|
}
|
|
|
|
// Marks.
|
|
const gMarks = svg("g", { class: "chart-marks" });
|
|
root.appendChild(gMarks);
|
|
|
|
for (const mark of chart.marks) {
|
|
const event = events[mark.eventIndex];
|
|
const markEl = paintMark(mark, event);
|
|
gMarks.appendChild(markEl);
|
|
}
|
|
}
|
|
|
|
function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
|
|
// Wrap every mark in a <g> with data-* attributes so mount() can do
|
|
// event-delegation off the top-level <svg> without per-mark listeners.
|
|
const g = svg("g", {
|
|
class: markClassName(mark),
|
|
"data-event-index": mark.eventIndex,
|
|
"data-kind": mark.kind,
|
|
"data-status": mark.status,
|
|
"data-lane": mark.laneId,
|
|
"data-undated": mark.undated ? "1" : "0",
|
|
"data-deadline-id": event.deadline_id || "",
|
|
"data-appointment-id": event.appointment_id || "",
|
|
"data-project-event-id": event.project_event_id || "",
|
|
role: "img",
|
|
tabindex: 0,
|
|
});
|
|
|
|
// ARIA label so screen-readers can read each mark (§13).
|
|
const title = svg("title");
|
|
title.textContent = markAriaLabel(mark, event);
|
|
g.appendChild(title);
|
|
|
|
// Generous invisible hit-target so dots are easy to click without
|
|
// hunting (12px hit halo around a 7px standard radius).
|
|
g.appendChild(svg("circle", {
|
|
class: "chart-mark-hit",
|
|
cx: mark.x,
|
|
cy: mark.y,
|
|
r: mark.radius + 6,
|
|
fill: "transparent",
|
|
}));
|
|
|
|
switch (mark.shape) {
|
|
case "dot": {
|
|
const c = svg("circle", {
|
|
class: "chart-mark-dot",
|
|
cx: mark.x,
|
|
cy: mark.y,
|
|
r: mark.radius,
|
|
});
|
|
g.appendChild(c);
|
|
break;
|
|
}
|
|
case "diamond": {
|
|
const r = mark.radius;
|
|
g.appendChild(svg("polygon", {
|
|
class: "chart-mark-diamond",
|
|
points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
|
|
}));
|
|
break;
|
|
}
|
|
case "hatched-dot": {
|
|
g.appendChild(svg("circle", {
|
|
class: "chart-mark-hatched",
|
|
cx: mark.x,
|
|
cy: mark.y,
|
|
r: mark.radius,
|
|
fill: "url(#chart-hatch)",
|
|
}));
|
|
break;
|
|
}
|
|
case "dashed-dot": {
|
|
g.appendChild(svg("circle", {
|
|
class: "chart-mark-dashed",
|
|
cx: mark.x,
|
|
cy: mark.y,
|
|
r: mark.radius,
|
|
}));
|
|
break;
|
|
}
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function markClassName(mark: Mark): string {
|
|
const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
|
|
if (mark.undated) parts.push("chart-mark--undated");
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function markAriaLabel(mark: Mark, event: TimelineEvent): string {
|
|
const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
|
|
return `${event.title} — ${event.kind} (${event.status}) — ${dateStr}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public: mount
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Palette presets from design §5.1. Each is a CSS-var override hung off
|
|
* `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
|
|
* reads palette state directly. */
|
|
export type Palette =
|
|
| "default"
|
|
| "kind-coded"
|
|
| "track-coded"
|
|
| "high-contrast"
|
|
| "print";
|
|
|
|
export const ALL_PALETTES: ReadonlyArray<Palette> = [
|
|
"default",
|
|
"kind-coded",
|
|
"track-coded",
|
|
"high-contrast",
|
|
"print",
|
|
];
|
|
|
|
export const ALL_DENSITIES: ReadonlyArray<Density> = [
|
|
"compact",
|
|
"standard",
|
|
"spacious",
|
|
];
|
|
|
|
/** Range presets from design §10 + faraday-Q8 default. The chart caller
|
|
* drives the active preset via setRange; "all" derives bounds from the
|
|
* loaded events at repaint time so adding / completing a row reflows. */
|
|
export type RangePreset = "1y" | "2y" | "all" | "custom";
|
|
|
|
export const ALL_RANGE_PRESETS: ReadonlyArray<RangePreset> = [
|
|
"1y",
|
|
"2y",
|
|
"all",
|
|
"custom",
|
|
];
|
|
|
|
export interface ChartMountOpts {
|
|
projectId: string;
|
|
todayISO?: string;
|
|
density?: Density;
|
|
palette?: Palette;
|
|
/** Initial range preset. Default "1y" (today-1y..today+1y) per design Q8. */
|
|
rangePreset?: RangePreset;
|
|
/** When rangePreset === "custom", these supply the bounds. Ignored for
|
|
* preset values — those derive bounds from the preset + todayISO (or,
|
|
* for "all", from the loaded events). */
|
|
rangeFrom?: string;
|
|
rangeTo?: string;
|
|
/** Optional callback fired when the user clicks a mark with a known
|
|
* deep-link target. Receives the underlying TimelineEvent. */
|
|
onMarkClick?: (event: TimelineEvent) => void;
|
|
/** Optional callback fired after every refresh() so the host can
|
|
* re-render dynamic UI (e.g. lane filter chips). */
|
|
onDataLoaded?: (data: { events: TimelineEvent[]; lanes: LaneInfo[] }) => void;
|
|
/** Initial visible-lane allowlist. null = show all (default).
|
|
* Lane ids not present in the response are silently dropped. */
|
|
visibleLanes?: string[] | null;
|
|
/** Pre-loaded data — used by Custom Views (Slice 4) where the rows
|
|
* come from ViewService not /api/projects/{id}/timeline. When set,
|
|
* mount() skips the initial fetch and paints from this data; the
|
|
* handle's refresh() still hits the project endpoint (caller can
|
|
* swap the chart back to project-mode via the standalone /chart URL). */
|
|
staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
|
|
}
|
|
|
|
export interface ChartHandle {
|
|
/** Re-fetches the timeline and re-paints. */
|
|
refresh: () => Promise<void>;
|
|
/** Removes event listeners + tears down the SVG. */
|
|
dispose: () => void;
|
|
/** Returns the last computed layout (useful for tests / debugging). */
|
|
getLayout: () => ChartLayout | null;
|
|
/** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
|
|
setPalette: (palette: Palette) => void;
|
|
/** Swap density. Re-runs layout() since lane height / mark radius change. */
|
|
setDensity: (density: Density) => void;
|
|
/** Switch range preset. "all" derives bounds from the loaded events;
|
|
* "custom" expects customFrom + customTo (otherwise it falls back to
|
|
* today-1y..today+1y). All others are time-shifted from todayISO. */
|
|
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => void;
|
|
/** Set the lane allowlist. null = show all lanes (default). Unknown
|
|
* ids in the passed array are silently dropped on repaint. */
|
|
setVisibleLanes: (lanes: string[] | null) => void;
|
|
/** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */
|
|
getSVGElement: () => SVGSVGElement;
|
|
/** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */
|
|
getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
|
|
}
|
|
|
|
interface TimelineEnvelope {
|
|
events: TimelineEvent[];
|
|
lanes: LaneInfo[];
|
|
}
|
|
|
|
/**
|
|
* mount builds a chart inside the given host element. The host's
|
|
* dimensions drive the SVG width; height grows from the lane row count.
|
|
* Returns a handle for refresh / dispose.
|
|
*/
|
|
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
|
host.classList.add("smart-timeline-chart-host");
|
|
|
|
// Empty / error placeholders.
|
|
const messageEl = document.createElement("div");
|
|
messageEl.className = "smart-timeline-chart-message";
|
|
messageEl.textContent = "";
|
|
host.appendChild(messageEl);
|
|
|
|
// The SVG root we paint into.
|
|
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
|
svgEl.classList.add("smart-timeline-chart");
|
|
svgEl.setAttribute("data-palette", opts.palette ?? "default");
|
|
svgEl.setAttribute("data-density", opts.density ?? "standard");
|
|
host.appendChild(svgEl);
|
|
|
|
let lastEvents: TimelineEvent[] = [];
|
|
let lastLayout: ChartLayout | null = null;
|
|
|
|
const todayISO = opts.todayISO ?? today();
|
|
let currentDensity: Density = opts.density ?? "standard";
|
|
let currentRangePreset: RangePreset = opts.rangePreset ?? "1y";
|
|
let customRangeFrom: string = opts.rangeFrom ?? shiftYears(todayISO, -1);
|
|
let customRangeTo: string = opts.rangeTo ?? shiftYears(todayISO, 1);
|
|
let currentVisibleLanes: Set<string> | null = opts.visibleLanes
|
|
? new Set(opts.visibleLanes)
|
|
: null;
|
|
|
|
function resolveRange(): { from: string; to: string } {
|
|
switch (currentRangePreset) {
|
|
case "1y":
|
|
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
|
|
case "2y":
|
|
return { from: shiftYears(todayISO, -2), to: shiftYears(todayISO, 2) };
|
|
case "all":
|
|
return rangeFromEvents(lastEvents, todayISO);
|
|
case "custom":
|
|
return { from: customRangeFrom, to: customRangeTo };
|
|
}
|
|
}
|
|
|
|
function repaint(): void {
|
|
const rect = host.getBoundingClientRect();
|
|
// Minimum width keeps the canvas usable when the host is hidden /
|
|
// about to be sized; resize listener will repaint on real layout.
|
|
const width = Math.max(640, rect.width || 1000);
|
|
const { from, to } = resolveRange();
|
|
const viewport: ChartViewport = {
|
|
width,
|
|
height: 400,
|
|
laneLabelWidth: 200,
|
|
dateAxisHeight: 40,
|
|
todayISO,
|
|
rangeFrom: from,
|
|
rangeTo: to,
|
|
density: currentDensity,
|
|
};
|
|
// Lane allowlist filter. null = show all; otherwise drop both the
|
|
// lane rows AND the events whose lane_id sits outside the allowlist.
|
|
// (We don't fall back to "first lane" here — that's only sensible
|
|
// when a stale id slips through; an explicit hide is a hide.)
|
|
let renderLanes = [...currentLanes];
|
|
let renderEvents: TimelineEvent[] = lastEvents;
|
|
if (currentVisibleLanes !== null) {
|
|
const allow = currentVisibleLanes;
|
|
renderLanes = currentLanes.filter((l) => allow.has(l.id));
|
|
renderEvents = lastEvents.filter((e) => {
|
|
// Empty / missing lane_id is treated as "self" — included only
|
|
// when the synthetic "self" lane is allowed.
|
|
const id = e.lane_id || "self";
|
|
return allow.has(id);
|
|
});
|
|
}
|
|
const chart = layout(renderEvents, renderLanes, viewport);
|
|
lastLayout = chart;
|
|
paint(chart, svgEl, renderEvents);
|
|
svgEl.setAttribute("width", String(width));
|
|
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
|
|
}
|
|
|
|
let currentLanes: LaneInfo[] = [];
|
|
|
|
async function refresh(): Promise<void> {
|
|
messageEl.textContent = "Lädt …";
|
|
messageEl.classList.remove("smart-timeline-chart-message--error");
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
|
|
);
|
|
if (!resp.ok) {
|
|
messageEl.textContent = "Timeline konnte nicht geladen werden.";
|
|
messageEl.classList.add("smart-timeline-chart-message--error");
|
|
return;
|
|
}
|
|
const body = await resp.json();
|
|
// Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
|
|
// even though the Slice-4 envelope is the contract today.
|
|
if (Array.isArray(body)) {
|
|
lastEvents = body as TimelineEvent[];
|
|
currentLanes = [];
|
|
} else {
|
|
const env = body as TimelineEnvelope;
|
|
lastEvents = env.events ?? [];
|
|
currentLanes = env.lanes ?? [];
|
|
}
|
|
if (lastEvents.length === 0) {
|
|
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
|
} else {
|
|
messageEl.textContent = "";
|
|
}
|
|
// Drop stale lane ids from the allowlist — a deleted CCR / child
|
|
// case shouldn't keep its lane id alive across re-fetches.
|
|
if (currentVisibleLanes !== null) {
|
|
const valid = new Set(currentLanes.map((l) => l.id));
|
|
valid.add("self"); // synthetic lane always allowed
|
|
const trimmed = new Set<string>();
|
|
for (const id of currentVisibleLanes) {
|
|
if (valid.has(id)) trimmed.add(id);
|
|
}
|
|
currentVisibleLanes = trimmed.size === 0 ? null : trimmed;
|
|
}
|
|
repaint();
|
|
if (opts.onDataLoaded) {
|
|
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
|
|
}
|
|
} catch (err) {
|
|
messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
|
|
messageEl.classList.add("smart-timeline-chart-message--error");
|
|
}
|
|
}
|
|
|
|
// Click delegation — read data-* attrs to deep-link.
|
|
function handleClick(e: Event) {
|
|
const target = e.target as Element | null;
|
|
if (!target) return;
|
|
const g = target.closest("g.chart-mark") as Element | null;
|
|
if (!g) return;
|
|
const indexAttr = g.getAttribute("data-event-index");
|
|
if (!indexAttr) return;
|
|
const idx = Number(indexAttr);
|
|
const event = lastEvents[idx];
|
|
if (!event) return;
|
|
if (opts.onMarkClick) {
|
|
opts.onMarkClick(event);
|
|
return;
|
|
}
|
|
if (event.deadline_id) {
|
|
window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
|
|
} else if (event.appointment_id) {
|
|
window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
|
|
}
|
|
// Milestones + projected rows have no detail page today — no-op.
|
|
}
|
|
|
|
// Resize handler — debounced.
|
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function handleResize() {
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => {
|
|
repaint();
|
|
}, 120);
|
|
}
|
|
|
|
svgEl.addEventListener("click", handleClick);
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
// If the caller supplied data up front (Custom Views host path), skip
|
|
// the project-timeline fetch entirely — paint from the supplied rows.
|
|
// Otherwise kick off the initial /api/projects/{id}/timeline load.
|
|
if (opts.staticData) {
|
|
lastEvents = opts.staticData.events;
|
|
currentLanes = opts.staticData.lanes;
|
|
if (lastEvents.length === 0) {
|
|
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
|
} else {
|
|
messageEl.textContent = "";
|
|
}
|
|
repaint();
|
|
if (opts.onDataLoaded) {
|
|
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
|
|
}
|
|
} else {
|
|
void refresh();
|
|
}
|
|
|
|
return {
|
|
refresh,
|
|
getLayout: () => lastLayout,
|
|
setPalette: (palette: Palette) => {
|
|
svgEl.setAttribute("data-palette", palette);
|
|
},
|
|
setDensity: (density: Density) => {
|
|
currentDensity = density;
|
|
svgEl.setAttribute("data-density", density);
|
|
repaint();
|
|
},
|
|
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => {
|
|
currentRangePreset = preset;
|
|
if (preset === "custom") {
|
|
if (customFrom) customRangeFrom = customFrom;
|
|
if (customTo) customRangeTo = customTo;
|
|
}
|
|
svgEl.setAttribute("data-range-preset", preset);
|
|
repaint();
|
|
},
|
|
setVisibleLanes: (lanes: string[] | null) => {
|
|
currentVisibleLanes = lanes ? new Set(lanes) : null;
|
|
repaint();
|
|
},
|
|
getSVGElement: () => svgEl,
|
|
getData: () => ({ events: lastEvents, lanes: currentLanes }),
|
|
dispose: () => {
|
|
svgEl.removeEventListener("click", handleClick);
|
|
window.removeEventListener("resize", handleResize);
|
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
|
|
if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Resolve the "all" preset bounds from the loaded events. Empty data
|
|
* falls back to the 1y default so the chart canvas isn't degenerate. */
|
|
function rangeFromEvents(
|
|
events: ReadonlyArray<TimelineEvent>,
|
|
todayISO: string,
|
|
): { from: string; to: string } {
|
|
let minMs: number | null = null;
|
|
let maxMs: number | null = null;
|
|
for (const ev of events) {
|
|
if (!ev.date) continue;
|
|
const ms = parseISODay(ev.date);
|
|
if (ms === null) continue;
|
|
if (minMs === null || ms < minMs) minMs = ms;
|
|
if (maxMs === null || ms > maxMs) maxMs = ms;
|
|
}
|
|
if (minMs === null || maxMs === null) {
|
|
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
|
|
}
|
|
// Pad +30d at the right so the last event isn't flush against the edge.
|
|
const fromDate = new Date(minMs);
|
|
const toDate = new Date(maxMs + 30 * 86_400_000);
|
|
return {
|
|
from: toISO(fromDate),
|
|
to: toISO(toDate),
|
|
};
|
|
}
|
|
|
|
function toISO(d: Date): string {
|
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function today(): string {
|
|
const d = new Date();
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(d.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${dd}`;
|
|
}
|
|
|
|
function shiftYears(iso: string, delta: number): string {
|
|
const ms = parseISODay(iso);
|
|
if (ms === null) return iso;
|
|
const d = new Date(ms);
|
|
return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
|
}
|