Skip to content

Commit 3ae8078

Browse files
committed
Add svg export
1 parent 1ac98da commit 3ae8078

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

gantt/gantt.js

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,221 @@ function exportCSV() {
633633
dl('gantt.csv', csv, 'text/csv');
634634
}
635635

636+
function exportSVG() {
637+
const csvText = getCsvText();
638+
if (!csvText) { toast('No data to export.'); return; }
639+
try {
640+
const { tasks, threadOrder } = parseCSV(csvText);
641+
resolveNulls(tasks);
642+
const svg = buildGanttSVG(tasks, threadOrder);
643+
dl('gantt.svg', svg, 'image/svg+xml');
644+
} catch(e) { toast(e.message); }
645+
}
646+
647+
function buildGanttSVG(tasks, threadOrder, {
648+
width = 900,
649+
height = null, // null = auto from row count
650+
theme = null, // null = read from current CSS vars
651+
} = {}) {
652+
// ── Layout constants ──────────────────────────────────
653+
const LW = 150; // label column width
654+
const SH = 48; // sublayer row height
655+
const HDR_H = 36; // timeline header height
656+
const BAR_H = 30; // bar height
657+
const BAR_R = 5; // bar corner radius
658+
const PAD = 20; // outer padding
659+
const FONT = 'IBM Plex Mono, monospace';
660+
const SANS = 'DM Sans, sans-serif';
661+
662+
// ── Theme colours ─────────────────────────────────────
663+
const THEMES = {
664+
dark: {
665+
bg:'#0c0c0e', surf:'#111115', surf2:'#16161b',
666+
brd:'#22222c', brd2:'#2e2e3c',
667+
txt:'#dddde8', txt2:'#8888a0', txt3:'#50505f',
668+
},
669+
light: {
670+
bg:'#f4f4f7', surf:'#ffffff', surf2:'#f0f0f4',
671+
brd:'#dcdce6', brd2:'#c8c8d8',
672+
txt:'#1a1a2e', txt2:'#606080', txt3:'#a0a0b8',
673+
},
674+
};
675+
676+
let C;
677+
if (theme && THEMES[theme]) {
678+
C = THEMES[theme];
679+
} else {
680+
// Read from live CSS custom properties
681+
const cs = getComputedStyle(document.documentElement);
682+
const cv = k => cs.getPropertyValue(k).trim();
683+
C = {
684+
bg: cv('--bg'), surf: cv('--surf'), surf2: cv('--surf2'),
685+
brd: cv('--brd'), brd2: cv('--brd2'),
686+
txt: cv('--txt'), txt2: cv('--txt2'), txt3: cv('--txt3'),
687+
};
688+
}
689+
690+
// ── Reset colour cache so SVG matches current render ──
691+
Object.keys(colorCache).forEach(k => delete colorCache[k]);
692+
colorIdx = 0;
693+
694+
const total = Math.max(...Object.values(tasks).map(t => t.end));
695+
const vd = computeDepths(tasks, threadOrder);
696+
const threaded = new Set(Object.values(threadOrder).flat());
697+
698+
function makeSubs(list) {
699+
const rmin = Math.min(...list.map(t => vd[t.name]));
700+
const byD = {}, depths = {};
701+
for (const t of list) {
702+
const d = vd[t.name] - rmin;
703+
depths[t.name] = d;
704+
if (!byD[d]) byD[d] = [];
705+
byD[d].push(t);
706+
}
707+
return {
708+
layers: Object.keys(byD).sort((a,b) => +a - +b).map(d => byD[d]),
709+
depths,
710+
};
711+
}
712+
713+
// ── Build rows ────────────────────────────────────────
714+
const rows = [];
715+
for (const [tid, ns] of Object.entries(threadOrder)) {
716+
const list = ns.map(n => tasks[n]);
717+
const { layers, depths } = makeSubs(list);
718+
rows.push({ label: tid, tid, layers, depths, rs: Math.min(...list.map(t => t.start)) });
719+
}
720+
for (const [name, t] of Object.entries(tasks)) {
721+
if (threaded.has(name)) continue;
722+
const { layers, depths } = makeSubs([t]);
723+
rows.push({ label: name, tid: null, layers, depths, rs: t.start });
724+
}
725+
rows.sort((a,b) => a.rs - b.rs);
726+
727+
// ── Compute total height ──────────────────────────────
728+
const totalSubLayers = rows.reduce((s, r) => s + r.layers.length, 0);
729+
const autoH = HDR_H + totalSubLayers * SH;
730+
const chartH = height != null ? height - PAD * 2 : autoH;
731+
const totalW = width;
732+
const TW = totalW - LW - PAD * 2; // timeline pixel width
733+
const SVG_W = totalW + PAD * 2;
734+
const SVG_H = chartH + PAD * 2;
735+
736+
const step = pickStep(total, Math.floor(TW / 60));
737+
const x0 = PAD + LW; // timeline area left edge
738+
739+
// ── SVG helpers ───────────────────────────────────────
740+
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
741+
const parts = [];
742+
const p = s => parts.push(s);
743+
744+
// ── Gradient defs ─────────────────────────────────────
745+
const gradDefs = [];
746+
const gradId = {};
747+
for (const row of rows) {
748+
const key = row.tid || 'solo:' + row.label;
749+
const [c0, c1] = getColor(key);
750+
for (let di = 0; di < row.layers.length; di++) {
751+
const bright = BRIGHT[Math.min(di, BRIGHT.length - 1)];
752+
const id = `g_${gradDefs.length}`;
753+
gradId[key + '_' + di] = id;
754+
gradDefs.push(`<linearGradient id="${id}" x1="0%" y1="0%" x2="100%" y2="0%">
755+
<stop offset="0%" stop-color="${c0}" stop-opacity="${bright}"/>
756+
<stop offset="100%" stop-color="${c1}" stop-opacity="${bright}"/>
757+
</linearGradient>`);
758+
}
759+
}
760+
761+
// ── Header ────────────────────────────────────────────
762+
p(`<?xml version="1.0" encoding="UTF-8"?>`);
763+
p(`<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_W}" height="${SVG_H}" viewBox="0 0 ${SVG_W} ${SVG_H}">`);
764+
p(`<defs>${gradDefs.join('')}</defs>`);
765+
766+
// No background — transparent SVG;
767+
768+
// Outer border rect
769+
const cx = PAD, cy = PAD;
770+
p(`<rect x="${cx}" y="${cy}" width="${totalW}" height="${chartH}" rx="10" fill="${C.surf}" stroke="${C.brd}" stroke-width="1"/>`);
771+
772+
// Header bg
773+
p(`<rect x="${cx}" y="${cy}" width="${totalW}" height="${HDR_H}" rx="10" fill="${C.surf2}"/>`);
774+
p(`<rect x="${cx}" y="${cy + HDR_H - 2}" width="${totalW}" height="2" fill="${C.brd}"/>`);
775+
776+
// Label col header
777+
p(`<text x="${cx + 10}" y="${cy + HDR_H / 2 + 4}" font-family="${FONT}" font-size="9" fill="${C.txt2}" letter-spacing="1" text-anchor="start">THREAD / TASK</text>`);
778+
779+
// Tick marks
780+
for (let t = 0; t <= total; t += step) {
781+
const tx = x0 + (t / total) * TW;
782+
p(`<line x1="${tx}" y1="${cy}" x2="${tx}" y2="${cy + HDR_H - 2}" stroke="${C.brd}" stroke-width="1"/>`);
783+
p(`<text x="${tx + 3}" y="${cy + HDR_H - 7}" font-family="${FONT}" font-size="9" fill="${C.txt2}">${esc(fmt(t))}</text>`);
784+
}
785+
786+
// ── Rows ──────────────────────────────────────────────
787+
// Reset colour cache again so row colours match
788+
Object.keys(colorCache).forEach(k => delete colorCache[k]);
789+
colorIdx = 0;
790+
791+
let rowY = cy + HDR_H;
792+
for (const row of rows) {
793+
const key = row.tid || 'solo:' + row.label;
794+
const [c0] = getColor(key);
795+
const rowH = row.layers.length * SH;
796+
797+
// Row bg (subtle for threaded rows)
798+
if (row.tid) p(`<rect x="${cx}" y="${rowY}" width="${totalW}" height="${rowH}" fill="${C.surf}" opacity="0.5"/>`);
799+
800+
// Vertical divider between label and timeline
801+
p(`<line x1="${x0}" y1="${rowY}" x2="${x0}" y2="${rowY + rowH}" stroke="${C.brd}" stroke-width="1"/>`);
802+
803+
// Label
804+
const midY = rowY + rowH / 2;
805+
if (row.tid) {
806+
// pill
807+
p(`<rect x="${cx + 8}" y="${midY - 8}" width="16" height="16" rx="8" fill="${c0}22"/>`);
808+
p(`<text x="${cx + 16}" y="${midY + 4.5}" font-family="${SANS}" font-size="9" font-weight="700" fill="${c0}" text-anchor="middle">${esc(row.label.slice(0,1).toUpperCase())}</text>`);
809+
p(`<text x="${cx + 28}" y="${midY + 4}" font-family="${FONT}" font-size="10" fill="${C.txt}" clip-path="url(#lclip)">${esc(row.label)}</text>`);
810+
} else {
811+
p(`<text x="${cx + 8}" y="${midY + 4}" font-family="${FONT}" font-size="10" fill="${C.txt}">${esc(row.label)}</text>`);
812+
}
813+
814+
// Sublayers
815+
for (let di = 0; di < row.layers.length; di++) {
816+
const laneY = rowY + di * SH;
817+
const gid = gradId[key + '_' + di];
818+
819+
// Sublayer divider
820+
if (di > 0) p(`<line x1="${x0}" y1="${laneY}" x2="${cx + totalW}" y2="${laneY}" stroke="${C.brd}" stroke-width="1" stroke-dasharray="3,3" opacity="0.4"/>`);
821+
822+
// Grid lines
823+
for (let t = step; t < total; t += step) {
824+
const gx = x0 + (t / total) * TW;
825+
p(`<line x1="${gx}" y1="${laneY}" x2="${gx}" y2="${laneY + SH}" stroke="${C.brd}" stroke-width="1" opacity="0.3"/>`);
826+
}
827+
828+
// Bars
829+
for (const t of row.layers[di]) {
830+
const bx = x0 + (t.start / total) * TW;
831+
const bw = Math.max((t.duration / total) * TW, 3);
832+
const by = laneY + (SH - BAR_H) / 2;
833+
p(`<rect x="${bx}" y="${by}" width="${bw}" height="${BAR_H}" rx="${BAR_R}" fill="url(#${gid})"/>`);
834+
if (bw > 30) {
835+
p(`<text x="${bx + 7}" y="${by + BAR_H / 2 + 4}" font-family="${FONT}" font-size="10" font-weight="600" fill="rgba(255,255,255,0.9)" clip-path="url(#bc_${gid})">${esc(t.name)}</text>`);
836+
// Clip text to bar
837+
p(`<clipPath id="bc_${gid}"><rect x="${bx}" y="${by}" width="${bw - 7}" height="${BAR_H}"/></clipPath>`);
838+
}
839+
}
840+
}
841+
842+
// Row bottom border
843+
p(`<line x1="${cx}" y1="${rowY + rowH}" x2="${cx + totalW}" y2="${rowY + rowH}" stroke="${C.brd}" stroke-width="1"/>`);
844+
rowY += rowH;
845+
}
846+
847+
p(`</svg>`);
848+
return parts.join('\n');
849+
}
850+
636851
function dl(name, content, mime) {
637852
const a = document.createElement('a');
638853
a.href = URL.createObjectURL(new Blob([content], { type: mime }));

gantt/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
</svg>
4040
</button>
4141
<button class="tbtn" onclick="exportCSV()">↓ CSV</button>
42+
<button class="tbtn" onclick="exportSVG()">↓ SVG</button>
4243
<button class="tbtn primary" onclick="render()">▶ Render</button>
4344
</div>
4445

0 commit comments

Comments
 (0)