generated from coulomb/repo-seed
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>
This commit is contained in:
174
generator.js
174
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, """)
|
||||
.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(`<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;
|
||||
@@ -156,14 +321,5 @@ window.timelineGenerator = {
|
||||
${monthGraphics}
|
||||
${laneBlocks}
|
||||
</svg>`;
|
||||
},
|
||||
|
||||
escapeXml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user