265 lines
8.7 KiB
JavaScript
265 lines
8.7 KiB
JavaScript
|
|
// DOM-based Timeline Generator
|
||
|
|
// Uses proper DOM manipulation instead of string templates
|
||
|
|
// SVG remains 100% valid and Inkscape-editable
|
||
|
|
|
||
|
|
window.timelineGeneratorDOM = {
|
||
|
|
generate(items, cfg, templateString) {
|
||
|
|
if (!templateString) {
|
||
|
|
throw new Error('Template is required. Please provide an SVG file with prototype elements.');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse SVG as DOM
|
||
|
|
const parser = new DOMParser();
|
||
|
|
const doc = parser.parseFromString(templateString, 'image/svg+xml');
|
||
|
|
|
||
|
|
// Check for parse errors
|
||
|
|
const parserError = doc.querySelector('parsererror');
|
||
|
|
if (parserError) {
|
||
|
|
throw new Error('Failed to parse SVG template: ' + parserError.textContent);
|
||
|
|
}
|
||
|
|
|
||
|
|
const svg = doc.documentElement;
|
||
|
|
|
||
|
|
// Validate required prototypes exist
|
||
|
|
this.validatePrototypes(svg);
|
||
|
|
|
||
|
|
// Calculate layout
|
||
|
|
const layout = this.calculateLayout(items, cfg);
|
||
|
|
|
||
|
|
// Generate timeline using DOM cloning
|
||
|
|
this.generateFromPrototypes(svg, items, cfg, layout);
|
||
|
|
|
||
|
|
// Set SVG dimensions
|
||
|
|
svg.setAttribute('width', layout.width);
|
||
|
|
svg.setAttribute('height', layout.height);
|
||
|
|
svg.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`);
|
||
|
|
|
||
|
|
// Serialize back to string
|
||
|
|
const serializer = new XMLSerializer();
|
||
|
|
return serializer.serializeToString(svg);
|
||
|
|
},
|
||
|
|
|
||
|
|
validatePrototypes(svg) {
|
||
|
|
const requiredIds = ['month-proto', 'lane-proto', 'item-proto'];
|
||
|
|
const missing = [];
|
||
|
|
|
||
|
|
for (const id of requiredIds) {
|
||
|
|
if (!svg.querySelector(`#${id}`)) {
|
||
|
|
missing.push(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (missing.length > 0) {
|
||
|
|
throw new Error(
|
||
|
|
`Template is missing required prototype elements: ${missing.join(', ')}. ` +
|
||
|
|
`Please create prototype groups with these IDs in your SVG.`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for target containers (create if missing)
|
||
|
|
const containers = ['months-container', 'lanes-container', 'items-container'];
|
||
|
|
for (const id of containers) {
|
||
|
|
if (!svg.querySelector(`#${id}`)) {
|
||
|
|
console.warn(`Container #${id} not found, will create it`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
calculateLayout(items, cfg) {
|
||
|
|
const monthsRange = (cfg.settings && cfg.settings.timelineMonths) || 18;
|
||
|
|
|
||
|
|
// Determine time window
|
||
|
|
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 (can be overridden in cfg.settings)
|
||
|
|
const left = (cfg.settings && cfg.settings.marginLeft) || 220;
|
||
|
|
const top = (cfg.settings && cfg.settings.marginTop) || 140;
|
||
|
|
const monthWidth = (cfg.settings && cfg.settings.monthWidth) || 120;
|
||
|
|
const laneHeight = (cfg.settings && cfg.settings.laneHeight) || 80;
|
||
|
|
const laneGap = (cfg.settings && cfg.settings.laneGap) || 16;
|
||
|
|
|
||
|
|
const height = top + laneNames.length * (laneHeight + laneGap) + 80;
|
||
|
|
const width = left + months.length * monthWidth + 100;
|
||
|
|
|
||
|
|
return {
|
||
|
|
months, start, laneMap, laneNames,
|
||
|
|
left, top, monthWidth, laneHeight, laneGap,
|
||
|
|
width, height
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
generateFromPrototypes(svg, items, cfg, layout) {
|
||
|
|
const { months, start, laneMap, laneNames, left, top, monthWidth, laneHeight, laneGap } = layout;
|
||
|
|
|
||
|
|
// Find prototypes
|
||
|
|
const monthProto = svg.querySelector('#month-proto');
|
||
|
|
const laneProto = svg.querySelector('#lane-proto');
|
||
|
|
const itemProto = svg.querySelector('#item-proto');
|
||
|
|
|
||
|
|
// Find or create containers
|
||
|
|
let monthsContainer = svg.querySelector('#months-container');
|
||
|
|
let lanesContainer = svg.querySelector('#lanes-container');
|
||
|
|
let itemsContainer = svg.querySelector('#items-container');
|
||
|
|
|
||
|
|
if (!monthsContainer) {
|
||
|
|
monthsContainer = svg.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||
|
|
monthsContainer.setAttribute('id', 'months-container');
|
||
|
|
svg.appendChild(monthsContainer);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!lanesContainer) {
|
||
|
|
lanesContainer = svg.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||
|
|
lanesContainer.setAttribute('id', 'lanes-container');
|
||
|
|
svg.appendChild(lanesContainer);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!itemsContainer) {
|
||
|
|
itemsContainer = svg.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||
|
|
itemsContainer.setAttribute('id', 'items-container');
|
||
|
|
svg.appendChild(itemsContainer);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate months
|
||
|
|
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" });
|
||
|
|
|
||
|
|
const clone = monthProto.cloneNode(true);
|
||
|
|
clone.removeAttribute('id'); // Remove id to avoid duplicates
|
||
|
|
clone.setAttribute('transform', `translate(${x}, 0)`);
|
||
|
|
|
||
|
|
// Update text content
|
||
|
|
const labelElement = clone.querySelector('[id*="label"], text');
|
||
|
|
if (labelElement) {
|
||
|
|
labelElement.textContent = label;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update grid line positions (if present)
|
||
|
|
const gridLine = clone.querySelector('line');
|
||
|
|
if (gridLine) {
|
||
|
|
gridLine.setAttribute('y1', gridTop);
|
||
|
|
gridLine.setAttribute('y2', gridBottom);
|
||
|
|
}
|
||
|
|
|
||
|
|
monthsContainer.appendChild(clone);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Helper function
|
||
|
|
function monthIndexForDate(d) {
|
||
|
|
return (d.getFullYear() - start.getFullYear()) * 12
|
||
|
|
+ (d.getMonth() - start.getMonth());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate lanes
|
||
|
|
laneNames.forEach((laneName, laneIdx) => {
|
||
|
|
const laneY = top + laneIdx * (laneHeight + laneGap);
|
||
|
|
|
||
|
|
const clone = laneProto.cloneNode(true);
|
||
|
|
clone.removeAttribute('id');
|
||
|
|
clone.setAttribute('transform', `translate(0, ${laneY})`);
|
||
|
|
|
||
|
|
// Update lane label
|
||
|
|
const labelElement = clone.querySelector('[id*="label"], text');
|
||
|
|
if (labelElement) {
|
||
|
|
labelElement.textContent = laneName;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update lane background width (if rect present)
|
||
|
|
const bgRect = clone.querySelector('rect');
|
||
|
|
if (bgRect) {
|
||
|
|
const laneWidth = left + months.length * monthWidth;
|
||
|
|
bgRect.setAttribute('width', laneWidth);
|
||
|
|
bgRect.setAttribute('height', laneHeight);
|
||
|
|
}
|
||
|
|
|
||
|
|
lanesContainer.appendChild(clone);
|
||
|
|
|
||
|
|
// Generate items 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 itemClone = itemProto.cloneNode(true);
|
||
|
|
itemClone.removeAttribute('id');
|
||
|
|
itemClone.setAttribute('transform', `translate(${cx}, ${cy})`);
|
||
|
|
|
||
|
|
// Update text elements based on field mapping
|
||
|
|
const fieldMapping = cfg.fieldMapping || {
|
||
|
|
id: 'ID',
|
||
|
|
title: 'Title',
|
||
|
|
lane: 'Lane',
|
||
|
|
due: 'Due'
|
||
|
|
};
|
||
|
|
|
||
|
|
// Map data to text elements
|
||
|
|
for (const [fieldName, csvColumn] of Object.entries(fieldMapping)) {
|
||
|
|
if (fieldName === 'due') continue; // Skip due, used for positioning
|
||
|
|
|
||
|
|
const value = it[fieldName] || '';
|
||
|
|
// Try to find text element with matching id
|
||
|
|
const textElement = itemClone.querySelector(`#item-${fieldName}, [id*="${fieldName}"]`);
|
||
|
|
if (textElement) {
|
||
|
|
textElement.textContent = value;
|
||
|
|
textElement.removeAttribute('id'); // Avoid duplicate IDs
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Also check for generic text element if no specific mapping found
|
||
|
|
if (!itemClone.querySelector('text[id]')) {
|
||
|
|
const anyText = itemClone.querySelector('text');
|
||
|
|
if (anyText) {
|
||
|
|
anyText.textContent = it.title || it.id || '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
itemsContainer.appendChild(itemClone);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Hide or remove prototypes
|
||
|
|
monthProto.style.display = 'none';
|
||
|
|
laneProto.style.display = 'none';
|
||
|
|
itemProto.style.display = 'none';
|
||
|
|
},
|
||
|
|
|
||
|
|
escapeXml(str) {
|
||
|
|
return String(str)
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.replace(/</g, "<")
|
||
|
|
.replace(/>/g, ">")
|
||
|
|
.replace(/"/g, """)
|
||
|
|
.replace(/'/g, "'");
|
||
|
|
}
|
||
|
|
};
|