// 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, "'"); } };