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.
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
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}`;
|
||
}
|