diff --git a/example/template-v2.svg b/example/template-v2.svg new file mode 100644 index 0000000..cc4a6c2 --- /dev/null +++ b/example/template-v2.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 📊 Template V2 Active + + + Template-based rendering with cloned elements ✨ + + + + + + {{MONTHS}} + + + + + {{LANES}} + + + + + diff --git a/generator.js b/generator.js index 08dd92e..b730b6b 100644 --- a/generator.js +++ b/generator.js @@ -36,6 +36,171 @@ window.timelineGenerator = { const laneHeight = 80; const laneGap = 16; + // Check if template uses new template-based approach + if (template && this.hasTemplateElements(template)) { + return this.generateFromTemplates(items, cfg, template, { + months, start, laneMap, laneNames, + left, top, monthWidth, laneHeight, laneGap + }); + } + + // Use hardcoded generation for backward compatibility + return this.generateHardcoded(items, cfg, template, { + months, start, laneMap, laneNames, + left, top, monthWidth, laneHeight, laneGap + }); + }, + + escapeXml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }, + + // Check if template has template elements + hasTemplateElements(template) { + return template.includes('id="month-template"') && + template.includes('id="lane-template"') && + template.includes('id="task-template"'); + }, + + // Extract a template element from SVG + extractTemplate(template, id) { + const regex = new RegExp(`]*>([\\s\\S]*?)`, 'i'); + const match = template.match(regex); + return match ? match[0] : null; + }, + + // Replace placeholders in template string + replacePlaceholders(template, values) { + let result = template; + for (const [key, value] of Object.entries(values)) { + result = result.replace(new RegExp(`{{${key}}}`, 'g'), value); + } + return result; + }, + + // Generate SVG using template-based approach + generateFromTemplates(items, cfg, template, layout) { + const { months, start, laneMap, laneNames, left, top, monthWidth, laneHeight, laneGap } = layout; + + // Extract template elements + const monthTemplate = this.extractTemplate(template, 'month-template'); + const laneTemplate = this.extractTemplate(template, 'lane-template'); + const taskTemplate = this.extractTemplate(template, 'task-template'); + + if (!monthTemplate || !laneTemplate || !taskTemplate) { + console.warn('Template elements not found, falling back to hardcoded generation'); + return this.generateHardcoded(items, cfg, template, layout); + } + + const monthLabelY = 90; + const gridTop = top - 20; + const gridBottom = top + laneNames.length * (laneHeight + laneGap) + 40; + + // Generate months using template + let monthGraphics = ""; + months.forEach((m, i) => { + const x = left + i * monthWidth; + const label = m.toLocaleString("de-DE", { month: "short", year: "2-digit" }); + + const monthValues = { + MONTH_X: x, + GRID_TOP: gridTop, + GRID_BOTTOM: gridBottom, + MONTH_X_OFFSET: x - 30, + MONTH_LABEL_Y_OFFSET: monthLabelY - 20, + MONTH_TEXT_X: x + 4, + MONTH_LABEL_Y: monthLabelY, + MONTH_LABEL: label, + MONTH_SEP_X: x - 1, + GRID_HEIGHT: gridBottom - gridTop + }; + + let monthElement = this.replacePlaceholders(monthTemplate, monthValues); + // Remove display:none and id to make element visible + monthElement = monthElement.replace(/style="display:\s*none;?"/, ''); + monthElement = monthElement.replace(/id="month-template"/, ''); + monthGraphics += monthElement; + }); + + // Helper to compute month index + function monthIndexForDate(d) { + return (d.getFullYear() - start.getFullYear()) * 12 + + (d.getMonth() - start.getMonth()); + } + + // Generate lanes using template + let laneBlocks = ""; + laneNames.forEach((laneName, laneIdx) => { + const laneY = top + laneIdx * (laneHeight + laneGap); + const laneWidth = left + months.length * monthWidth; + + const laneValues = { + LANE_X: 40, + LANE_Y: laneY - 24, + LANE_WIDTH: laneWidth, + LANE_HEIGHT: laneHeight, + LABEL_X: 56, + LABEL_Y: laneY - 4, + LANE_NAME: this.escapeXml(laneName) + }; + + let laneElement = this.replacePlaceholders(laneTemplate, laneValues); + laneElement = laneElement.replace(/style="display:\s*none;?"/, ''); + laneElement = laneElement.replace(/id="lane-template"/, ''); + laneBlocks += laneElement; + + // Generate tasks for this lane + const laneItems = laneMap.get(laneName).sort((a, b) => a.due - b.due); + laneItems.forEach((it, idx) => { + const mi = monthIndexForDate(it.due); + const clampedMi = Math.max(0, Math.min(months.length - 1, mi)); + const cx = left + clampedMi * monthWidth + monthWidth * 0.5; + const cy = laneY + 10 + idx * 18; + + const taskValues = { + TASK_X: cx, + TASK_Y: cy, + TEXT_X: cx + 12, + TEXT_Y: cy + 4, + TASK_ID: this.escapeXml(it.id || ""), + TASK_TITLE: this.escapeXml(it.title || "") + }; + + let taskElement = this.replacePlaceholders(taskTemplate, taskValues); + taskElement = taskElement.replace(/style="display:\s*none;?"/, ''); + taskElement = taskElement.replace(/id="task-template"/, ''); + laneBlocks += taskElement; + }); + }); + + // Calculate dimensions + const height = top + laneNames.length * (laneHeight + laneGap) + 80; + const width = left + months.length * monthWidth + 100; + + // Replace placeholders and inject calculated dimensions + let processedTemplate = template + .replace("{{MONTHS}}", monthGraphics) + .replace("{{LANES}}", laneBlocks); + + // Add width and height attributes to the SVG element + processedTemplate = processedTemplate.replace( + /]*?)>/, + `` + ); + + return processedTemplate; + }, + + // Original hardcoded generation (for backward compatibility) + generateHardcoded(items, cfg, template, layout) { + const { months, start, laneMap, laneNames, left, top, monthWidth, laneHeight, laneGap } = layout; + + // This is the original hardcoded generation that was in the generate() method // Month grid (labels + vertical lines) let monthGraphics = ""; const monthLabelY = 90; @@ -156,14 +321,5 @@ window.timelineGenerator = { ${monthGraphics} ${laneBlocks} `; - }, - - escapeXml(str) { - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); } }; diff --git a/my-project/template-v2.svg b/my-project/template-v2.svg new file mode 100644 index 0000000..a05b2ed --- /dev/null +++ b/my-project/template-v2.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 📅 Template V2 Active + + + Template-based rendering with cloned elements ✨ + + + + + + {{MONTHS}} + + + + + {{LANES}} + + + + + diff --git a/test/generator.test.js b/test/generator.test.js index 1391290..1ccc058 100644 --- a/test/generator.test.js +++ b/test/generator.test.js @@ -164,4 +164,141 @@ describe('Timeline Generator', () => { expect(result).toContain('T-2') }) }) + + describe('Template-based rendering', () => { + let items, config + + beforeEach(() => { + items = createSampleItems() + config = createSampleProject() + }) + + describe('hasTemplateElements', () => { + it('should detect template elements', () => { + const templateWithElements = ` + + + + + + + + ` + expect(timelineGenerator.hasTemplateElements(templateWithElements)).toBe(true) + }) + + it('should return false when template elements are missing', () => { + const templateWithoutElements = '{{MONTHS}}' + expect(timelineGenerator.hasTemplateElements(templateWithoutElements)).toBe(false) + }) + + it('should return false when only some template elements exist', () => { + const partialTemplate = '' + expect(timelineGenerator.hasTemplateElements(partialTemplate)).toBe(false) + }) + }) + + describe('extractTemplate', () => { + it('should extract template element by id', () => { + const svg = ` + + + + {{LABEL}} + + + ` + const result = timelineGenerator.extractTemplate(svg, 'month-template') + expect(result).toContain('id="month-template"') + expect(result).toContain('{{X}}') + expect(result).toContain('{{LABEL}}') + }) + + it('should return null when template not found', () => { + const svg = '' + const result = timelineGenerator.extractTemplate(svg, 'month-template') + expect(result).toBeNull() + }) + }) + + describe('replacePlaceholders', () => { + it('should replace all placeholders with values', () => { + const template = '{{LABEL}}' + const values = { X: 100, Y: 200, LABEL: 'Test' } + const result = timelineGenerator.replacePlaceholders(template, values) + expect(result).toBe('Test') + }) + + it('should handle multiple occurrences of same placeholder', () => { + const template = '' + const values = { X: 50 } + const result = timelineGenerator.replacePlaceholders(template, values) + expect(result).toBe('') + }) + + it('should leave unmatched placeholders unchanged', () => { + const template = '{{LABEL}} {{OTHER}}' + const values = { LABEL: 'Test' } + const result = timelineGenerator.replacePlaceholders(template, values) + expect(result).toContain('Test') + expect(result).toContain('{{OTHER}}') + }) + }) + + describe('generateFromTemplates', () => { + it('should generate SVG using template elements', async () => { + // Read actual template-v2.svg + const fs = await import('fs/promises') + const templateV2 = await fs.readFile('./example/template-v2.svg', 'utf-8') + + const result = timelineGenerator.generate(items, config, templateV2) + + // Should contain SVG structure + expect(result).toContain(' { + const incompleteTemplate = ` + + + + + {{MONTHS}} + {{LANES}} + + ` + + const result = timelineGenerator.generate(items, config, incompleteTemplate) + + // Should still generate valid SVG + expect(result).toContain(' { + const fs = await import('fs/promises') + const templateV2 = await fs.readFile('./example/template-v2.svg', 'utf-8') + + const result = timelineGenerator.generate(items, config, templateV2) + + // Should preserve gradient and filter definitions + expect(result).toContain('monthHeaderGrad') + expect(result).toContain('textShadow') + expect(result).toContain('bgGrid') + }) + }) + }) }) \ No newline at end of file