generated from coulomb/repo-seed
- Remove legacy template.svg files from example/ and my-project/ - Simplify generator.js by removing generateHardcoded method (326→210 lines, -36%) - Add strict template validation with clear error messages - Remove all fallback mechanisms - template-v2.svg format now required - Clean up tests: remove hardcoded generation tests, keep template-based tests - Add comprehensive e2e tests (large datasets, edge cases, error handling) - Update documentation: mark REFACTORING_PLAN.md complete, add TEMPLATE_V2_GUIDE.md - All 56 tests passing (16 engine + 25 generator + 15 integration) BREAKING CHANGE: Old template.svg format no longer supported. Must use template-v2.svg with <g id="*-template"> elements in defs section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
216 lines
7.2 KiB
JavaScript
216 lines
7.2 KiB
JavaScript
window.timelineGenerator = {
|
|
generate(items, cfg, template) {
|
|
// Validate template is provided
|
|
if (!template) {
|
|
throw new Error('Template is required. Please provide a template-v2.svg file with template elements.');
|
|
}
|
|
|
|
// Validate template has required elements
|
|
this.validateTemplate(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;
|
|
|
|
return this.generateFromTemplates(items, cfg, template, {
|
|
months, start, laneMap, laneNames,
|
|
left, top, monthWidth, laneHeight, laneGap
|
|
});
|
|
},
|
|
|
|
// Validate template has required elements
|
|
validateTemplate(template) {
|
|
const hasMonthTemplate = template.includes('id="month-template"');
|
|
const hasLaneTemplate = template.includes('id="lane-template"');
|
|
const hasTaskTemplate = template.includes('id="task-template"');
|
|
|
|
if (!hasMonthTemplate || !hasLaneTemplate || !hasTaskTemplate) {
|
|
const missing = [];
|
|
if (!hasMonthTemplate) missing.push('month-template');
|
|
if (!hasLaneTemplate) missing.push('lane-template');
|
|
if (!hasTaskTemplate) missing.push('task-template');
|
|
|
|
throw new Error(
|
|
`Template is missing required elements: ${missing.join(', ')}. ` +
|
|
`Please use a template-v2.svg file with proper template elements in the <defs> section.`
|
|
);
|
|
}
|
|
},
|
|
|
|
// 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);
|
|
|
|
if (!match) {
|
|
throw new Error(`Failed to extract template element: ${id}`);
|
|
}
|
|
|
|
return match[0];
|
|
},
|
|
|
|
// 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 (will throw if not found)
|
|
const monthTemplate = this.extractTemplate(template, 'month-template');
|
|
const laneTemplate = this.extractTemplate(template, 'lane-template');
|
|
const taskTemplate = this.extractTemplate(template, 'task-template');
|
|
|
|
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);
|
|
|
|
// Remove template elements from defs (they should not appear in final output)
|
|
processedTemplate = processedTemplate
|
|
.replace(/<g id="month-template"[^>]*>[\s\S]*?<\/g>/, '')
|
|
.replace(/<g id="lane-template"[^>]*>[\s\S]*?<\/g>/, '')
|
|
.replace(/<g id="task-template"[^>]*>[\s\S]*?<\/g>/, '');
|
|
|
|
// 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;
|
|
},
|
|
|
|
escapeXml(str) {
|
|
return String(str)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
};
|