@@ -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, '&' ) . replace ( / < / g, '<' ) . replace ( / > / g, '>' ) . replace ( / " / g, '"' ) ;
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+
636851function dl ( name , content , mime ) {
637852 const a = document . createElement ( 'a' ) ;
638853 a . href = URL . createObjectURL ( new Blob ( [ content ] , { type : mime } ) ) ;
0 commit comments