Files
timeline-svg/generator.js
tegwick b17d6a30e4 refactor: implement template-based SVG generation
Implements the refactoring plan to move SVG structure from hardcoded JavaScript
to editable template files:

- Add template-v2.svg files with template elements in defs
- Create template elements for months, lanes, and tasks with placeholders
- Refactor generator.js to extract and clone template elements
- Add hasTemplateElements, extractTemplate, and replacePlaceholders methods
- Add generateFromTemplates method for template-based rendering
- Keep generateHardcoded method for backward compatibility
- Add comprehensive tests for new template system
- Support both v1 (hardcoded) and v2 (template-based) approaches

Benefits:
- Templates can now be edited in SVG tools (Inkscape, Adobe Illustrator)
- Visual template design with actual layout visible while editing
- Separation of presentation (SVG) and logic (JavaScript)
- Easier customization without touching code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 09:10:18 +01:00

326 lines
13 KiB
JavaScript

window.timelineGenerator = {
generate(items, cfg, template) {
const monthsRange = (cfg.settings && cfg.settings.timelineMonths) || 18;
// Determine time window from earliest due date
const sorted = [...items].sort((a, b) => a.due - b.due);
const minDue = sorted[0].due;
const start = new Date(minDue.getFullYear(), minDue.getMonth(), 1);
const end = new Date(start);
end.setMonth(end.getMonth() + monthsRange);
// Build months array
const months = [];
const cursor = new Date(start);
while (cursor <= end) {
months.push(new Date(cursor));
cursor.setMonth(cursor.getMonth() + 1);
}
// Group items by lane
const laneMap = new Map();
for (const it of items) {
const laneName = it.lane || "Ohne Epic";
if (!laneMap.has(laneName)) laneMap.set(laneName, []);
laneMap.get(laneName).push(it);
}
const laneNames = Array.from(laneMap.keys()).sort((a, b) =>
a.localeCompare(b, "de")
);
// Layout constants
const left = 220;
const top = 140;
const monthWidth = 120;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
},
// 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(`<g id="${id}"[^>]*>([\\s\\S]*?)</g>`, '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(
/<svg([^>]*?)>/,
`<svg$1 width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
);
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;
const gridTop = top - 20;
const gridBottom = top + laneNames.length * (laneHeight + laneGap) + 40;
months.forEach((m, i) => {
const x = left + i * monthWidth;
const label = m.toLocaleString("de-DE", { month: "short", year: "2-digit" });
// Enhanced styling when using external template
if (template && template.includes('Enhanced')) {
// Determine color scheme based on template content
const isBlueTheme = template.includes('My Project');
const primaryColor = isBlueTheme ? '#3b82f6' : '#2d8659';
const secondaryColor = isBlueTheme ? '#60a5fa' : '#4a9b6b';
// More prominent month indicators for external template
monthGraphics += `<line x1="${x}" y1="${gridTop}" x2="${x}" y2="${gridBottom}" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />`;
monthGraphics += `<rect x="${x-30}" y="${monthLabelY-20}" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />`;
monthGraphics += `<text x="${x + 4}" y="${monthLabelY}" fill="${primaryColor}" font-size="13" font-weight="600">${label}</text>`;
// Add month separator
if (i > 0) {
monthGraphics += `<rect x="${x-1}" y="${gridTop}" width="2" height="${gridBottom-gridTop}" fill="${secondaryColor}" opacity="0.3" />`;
}
} else {
// Default styling
monthGraphics += `<line x1="${x}" y1="${gridTop}" x2="${x}" y2="${gridBottom}" stroke="#E3E8EF" />`;
monthGraphics += `<text x="${x + 4}" y="${monthLabelY}" fill="#5C6B7A" font-size="12">${label}</text>`;
}
});
// Helper to compute month index
function monthIndexForDate(d) {
return (d.getFullYear() - start.getFullYear()) * 12
+ (d.getMonth() - start.getMonth());
}
// Lane blocks
let laneBlocks = "";
laneNames.forEach((laneName, laneIdx) => {
const laneY = top + laneIdx * (laneHeight + laneGap);
// Enhanced styling when using external template
if (template && template.includes('Enhanced')) {
// Determine color scheme based on template content
const isBlueTheme = template.includes('My Project');
const primaryColor = isBlueTheme ? '#3b82f6' : '#2d8659';
const secondaryColor = isBlueTheme ? '#60a5fa' : '#4a9b6b';
// More subtle lane borders for enhanced template
laneBlocks += `<rect x="40" y="${laneY - 24}" width="${left + months.length * monthWidth}" height="${laneHeight}" fill="rgba(255,255,255,0.7)" stroke="${secondaryColor}" stroke-width="1" opacity="0.5" rx="8" />`;
// Enhanced lane label
laneBlocks += `<text x="56" y="${laneY - 4}" fill="${primaryColor}" font-size="14" font-weight="700">${this.escapeXml(laneName)}</text>`;
} else {
// Default lane styling
laneBlocks += `<rect x="40" y="${laneY - 24}" width="${left + months.length * monthWidth}" height="${laneHeight}" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />`;
// Default lane label
laneBlocks += `<text x="56" y="${laneY - 4}" fill="#0B1F3B" font-size="14" font-weight="600">${this.escapeXml(laneName)}</text>`;
}
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;
// Enhanced task item styling for external template
if (template && template.includes('Enhanced')) {
// Determine color scheme based on template content
const isBlueTheme = template.includes('My Project');
const primaryColor = isBlueTheme ? '#3b82f6' : '#2d8659';
const secondaryColor = isBlueTheme ? '#60a5fa' : '#4a9b6b';
const darkColor = isBlueTheme ? '#1e40af' : '#1e5a3d';
laneBlocks += `<circle cx="${cx}" cy="${cy}" r="6" fill="${primaryColor}" stroke="${secondaryColor}" stroke-width="2" />`;
laneBlocks += `<text x="${cx + 12}" y="${cy + 4}" font-size="12" fill="${primaryColor}" font-weight="500">
<tspan class="item-id">${this.escapeXml(it.id || "")}: </tspan>
<tspan class="item-title" fill="${darkColor}">${this.escapeXml(it.title || "")}</tspan>
</text>`;
} else {
// Default task item styling
laneBlocks += `<circle cx="${cx}" cy="${cy}" r="5" fill="#0A4D8C" />`;
laneBlocks += `<text x="${cx + 10}" y="${cy + 4}" font-size="12" fill="#0B1F3B">
<tspan class="item-id">${this.escapeXml(it.id || "")}: </tspan>
<tspan class="item-title">${this.escapeXml(it.title || "")}</tspan>
</text>`;
}
});
});
if (template && template.includes("{{MONTHS}}") && template.includes("{{LANES}}")) {
// Calculate dimensions for template
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(
/<svg([^>]*?)>/,
`<svg$1 width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
);
return processedTemplate;
}
// Fallback: embed directly in simple SVG
const height = top + laneNames.length * (laneHeight + laneGap) + 80;
const width = left + months.length * monthWidth + 100;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<rect width="100%" height="100%" fill="#FFFFFF" />
${monthGraphics}
${laneBlocks}
</svg>`;
}
};