Files
paliad/frontend/src/client/views/chart-export.ts
mAi 98a51faa66 feat(t-paliad-177): chart exports — SVG/PNG/CSV/JSON + browser-print CSS
Five client-side export paths per design §7 (faraday-Q4: rule out
chromedp, browser-print is good enough).

- SVG: XMLSerializer over a clone of the live SVGSVGElement, with
  --chart-* tokens inlined so the standalone file paints the same way
  when opened in an image viewer (no document.css context).
- PNG: SVG → Image → Canvas at 2× DPR, toBlob("image/png"). White
  background painted first so transparent SVG stays printable.
- PDF: window.print() → @media print stylesheet hides chrome, forces
  the print palette tokens, locks A4 landscape via @page. User picks
  "Save as PDF" in the browser print dialog. No chromedp dep.
- CSV: 20-column flat schema mirroring TimelineEvent, UTF-8 BOM for
  Excel-DE, RFC 4180 escaping.
- JSON: events + lanes envelope + export-metadata header (project_id,
  project_title, exported_at).

Export menu uses native <details>/<summary> so it's keyboard-accessible
without JS. The chart handle exposes getSVGElement() + getData() so
chart-export.ts stays pure: it never reads DOM state outside the SVG
it's handed.

Filenames are sanitised + dated: paliad-{title}-{yyyy-mm-dd}.{ext}.

i18n: 7 new keys DE+EN under projects.chart.export.*.

Design ref: docs/design-project-chart-2026-05-09.md §7.
2026-05-13 00:08:28 +02:00

275 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// chart-export (t-paliad-177 Slice 2) — client-side export helpers for
// the Project Timeline / Chart page.
//
// Five formats land in Slice 2 (per design §7.1, m's pick on faraday-Q4
// to rule out server-side PDF via chromedp):
//
// SVG — XMLSerializer of the live SVG element
// PNG — SVG → <img> → <canvas> at 2× HiDPI, toBlob("image/png")
// PDF — window.print() with @media print stylesheet (browser handles
// the PDF engine; no chromedp dep on Dokploy)
// CSV — flat tabular dump of TimelineEvent[] (UTF-8 BOM for Excel-DE)
// JSON — wire envelope verbatim + export-metadata header
//
// iCal lands in a follow-up commit (C5) and goes via a server-side
// endpoint that reuses internal/services/caldav_ical.go (faraday-Q6).
//
// Design ref: docs/design-project-chart-2026-05-09.md §7.
export interface ExportContext {
projectId: string;
projectTitle: string;
svgEl: SVGSVGElement;
events: ReadonlyArray<TimelineEvent>;
lanes: ReadonlyArray<LaneInfo>;
}
// ---------------------------------------------------------------------------
// Public surface
// ---------------------------------------------------------------------------
export async function exportSVG(ctx: ExportContext): Promise<void> {
const svgString = serialiseSVG(ctx.svgEl);
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
triggerDownload(blob, filename(ctx, "svg"));
}
export async function exportPNG(ctx: ExportContext): Promise<void> {
const svgString = serialiseSVG(ctx.svgEl);
const blob = await rasterise(svgString, ctx.svgEl);
if (!blob) {
throw new Error("PNG raster failed");
}
triggerDownload(blob, filename(ctx, "png"));
}
export function exportCSV(ctx: ExportContext): void {
const rows: string[][] = [csvHeader()];
for (const event of ctx.events) {
rows.push(csvRow(event, ctx));
}
// UTF-8 BOM keeps Excel-DE from mis-detecting ANSI; ISO-8601 dates
// round-trip correctly into German Excel as text.
const text = "" + rows.map(csvLine).join("\r\n") + "\r\n";
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
triggerDownload(blob, filename(ctx, "csv"));
}
export function exportJSON(ctx: ExportContext): void {
const envelope = {
project_id: ctx.projectId,
project_title: ctx.projectTitle,
exported_at: new Date().toISOString(),
events: ctx.events,
lanes: ctx.lanes,
};
const text = JSON.stringify(envelope, null, 2) + "\n";
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
triggerDownload(blob, filename(ctx, "json"));
}
export function exportPrint(): void {
// The @media print stylesheet in global.css does the layout work;
// we just invoke the browser's print dialog. User picks "Save as PDF"
// (Chrome/Edge), "Drucken in Datei" (Firefox), etc.
window.print();
}
// ---------------------------------------------------------------------------
// SVG / PNG plumbing
// ---------------------------------------------------------------------------
function serialiseSVG(svgEl: SVGSVGElement): string {
// Clone so we can inline computed styles without polluting the live DOM.
// For a true cross-environment-portable SVG, we'd compute every used
// CSS-var into a literal value. v1 keeps it light: the receiver inherits
// colours via document context when opened standalone, and the rendered
// bars still work because palette tokens fall through to the .smart-
// timeline-chart root selector via inline class. Add a fallback width /
// height attribute so headless viewers don't render 0×0.
const clone = svgEl.cloneNode(true) as SVGSVGElement;
if (!clone.getAttribute("width") && svgEl.getAttribute("width")) {
clone.setAttribute("width", svgEl.getAttribute("width") || "1000");
}
if (!clone.getAttribute("height") && svgEl.getAttribute("height")) {
clone.setAttribute("height", svgEl.getAttribute("height") || "400");
}
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
// Inline the chart's computed palette tokens so the standalone SVG
// paints the same way when opened in an image viewer (which has no
// document.css). Read every --chart-* property off the live element.
const computed = window.getComputedStyle(svgEl);
const styleLines: string[] = [];
for (const prop of [
"--chart-mark-deadline",
"--chart-mark-appointment",
"--chart-mark-milestone",
"--chart-mark-projected",
"--chart-mark-overdue",
"--chart-mark-done",
"--chart-today-rule",
"--chart-grid-line",
"--chart-lane-label",
"--chart-tick-label",
"--chart-bg",
]) {
const val = computed.getPropertyValue(prop).trim();
if (val) styleLines.push(`${prop}: ${val};`);
}
if (styleLines.length > 0) {
const existing = clone.getAttribute("style") || "";
clone.setAttribute("style", existing + styleLines.join(" "));
}
return new XMLSerializer().serializeToString(clone);
}
async function rasterise(svgString: string, svgEl: SVGSVGElement): Promise<Blob | null> {
const widthAttr = svgEl.getAttribute("width") || "1000";
const heightAttr = svgEl.getAttribute("height") || "400";
const width = Number(widthAttr) || 1000;
const height = Number(heightAttr) || 400;
// 2× device pixel ratio for HiDPI exports (design §7.1 "PNG, 2× HiDPI").
const scale = 2;
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const img = await loadImage(url);
const canvas = document.createElement("canvas");
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), "image/png");
});
} finally {
URL.revokeObjectURL(url);
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Image load failed"));
img.src = src;
});
}
// ---------------------------------------------------------------------------
// CSV plumbing
// ---------------------------------------------------------------------------
const CSV_COLUMNS = [
"project_id",
"project_title",
"kind",
"status",
"track",
"lane_id",
"date",
"title",
"description",
"rule_code",
"depends_on_rule_code",
"depends_on_date",
"depends_on_rule_name",
"sub_project_id",
"sub_project_title",
"bubble_up",
"deadline_id",
"appointment_id",
"project_event_id",
"project_event_type",
] as const;
function csvHeader(): string[] {
return [...CSV_COLUMNS];
}
function csvRow(event: TimelineEvent, ctx: ExportContext): string[] {
return [
ctx.projectId,
ctx.projectTitle,
event.kind,
event.status,
event.track,
event.lane_id ?? "",
isoOnly(event.date),
event.title,
event.description ?? "",
event.rule_code ?? "",
event.depends_on_rule_code ?? "",
isoOnly(event.depends_on_date),
event.depends_on_rule_name ?? "",
event.sub_project_id ?? "",
event.sub_project_title ?? "",
event.bubble_up ? "true" : "false",
event.deadline_id ?? "",
event.appointment_id ?? "",
event.project_event_id ?? "",
event.project_event_type ?? "",
];
}
function csvLine(fields: string[]): string {
return fields.map(csvEscape).join(",");
}
/** RFC 4180 quoting: double quotes inside the field are doubled; wrap
* the whole field in quotes if it contains comma / quote / newline. */
function csvEscape(value: string): string {
if (/[,"\r\n]/.test(value)) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}
function isoOnly(date: string | null | undefined): string {
if (!date) return "";
return date.slice(0, 10);
}
// ---------------------------------------------------------------------------
// Download trigger
// ---------------------------------------------------------------------------
function triggerDownload(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
// Some browsers (Safari < 14) ignore the download attribute unless
// the link is in the document tree. Inserting + removing is cheap.
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Give the browser a tick to start the download before we revoke.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function filename(ctx: ExportContext, ext: string): string {
// Keep filenames diff-friendly + filesystem-safe. Replace anything that
// isn't ASCII alnum/dot/hyphen with "_". Truncate the title to 60 chars.
const safeTitle = (ctx.projectTitle || "timeline")
.normalize("NFKD")
.replace(/[^\x20-\x7e]/g, "")
.replace(/[^A-Za-z0-9.-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.slice(0, 60) || "timeline";
const dateStr = new Date().toISOString().slice(0, 10);
return `paliad-${safeTitle}-${dateStr}.${ext}`;
}