diff --git a/Makefile b/Makefile
index 2598ac1..e29fff0 100644
--- a/Makefile
+++ b/Makefile
@@ -152,12 +152,12 @@ dist:
@echo "📦 Created dist/ directory"
@# Copy core application files
- @cp index.html engine.js generator.js file-editor.js dist/
- @echo "✅ Copied core files (index.html, engine.js, generator.js, file-editor.js)"
+ @cp index.html engine.js generator.js generator-dom.js file-editor.js dist/
+ @echo "✅ Copied core files (index.html, engine.js, generator.js, generator-dom.js, file-editor.js)"
@# Copy project directories
- @cp -r example example-1 my-project dist/
- @echo "✅ Copied project directories (example, example-1, my-project)"
+ @cp -r example example-1 example-proto my-project dist/
+ @echo "✅ Copied project directories (example, example-1, example-proto, my-project)"
@# Copy documentation
@cp README.md LICENSE dist/
diff --git a/PROTOTYPE_TEMPLATES.md b/PROTOTYPE_TEMPLATES.md
new file mode 100644
index 0000000..63f02c4
--- /dev/null
+++ b/PROTOTYPE_TEMPLATES.md
@@ -0,0 +1,339 @@
+# Prototype-Based Templates Guide
+
+## Overview
+
+The timeline generator now supports **prototype-based templates** using DOM cloning. This is the recommended approach for creating custom timeline designs because:
+
+- ✅ **100% Valid SVG** - Edit directly in Inkscape with full visual control
+- ✅ **WYSIWYG** - See exactly how your timeline will look
+- ✅ **No String Placeholders** - No need for `{{PLACEHOLDER}}` syntax
+- ✅ **Better Performance** - Uses native DOM manipulation
+- ✅ **Easier Maintenance** - Visual editing instead of code
+
+## How It Works
+
+1. **Create Prototypes in Inkscape**
+ - Design visual elements (month headers, lanes, items) with specific IDs
+ - Style them exactly as you want them to appear
+ - The generator will clone these elements for each data item
+
+2. **Generator Clones Prototypes**
+ - Each prototype is deep-cloned using DOM
+ - Positioned using SVG transforms
+ - Text content updated from your data
+ - Appended to target containers
+
+3. **Prototypes Hidden**
+ - Original prototypes are hidden (`display:none`) after cloning
+ - Only the cloned, data-filled elements remain visible
+
+## Template Structure
+
+### Required Prototype Elements
+
+Your SVG must contain these three prototype groups:
+
+```xml
+
+
+
+ Jan 25
+
+
+
+
+
+ Lane Name
+
+
+
+
+
+ Task Title
+ T-123
+
+```
+
+### Required Container Elements
+
+The generator will place clones in these containers (created automatically if missing):
+
+```xml
+
+
+
+```
+
+### Text Element Mapping
+
+Text elements in the item prototype are matched to your data fields:
+
+- Elements with `id="item-{fieldname}"` (e.g., `id="item-title"`, `id="item-id"`)
+- Or elements with IDs containing the field name
+- Or the first `` element if no specific match found
+
+Example:
+```xml
+
+ Placeholder Title
+ T-000
+ TODO
+
+```
+
+Will map to CSV columns based on your `fieldMapping` in project.json:
+```json
+{
+ "fieldMapping": {
+ "title": "Title",
+ "id": "ID",
+ "status": "Status"
+ }
+}
+```
+
+## Creating a Prototype Template in Inkscape
+
+### Step 1: Start with a Base SVG
+
+1. Open Inkscape
+2. Create new document (File → New)
+3. Set document size (recommended: 1200x800px)
+
+### Step 2: Design Your Month Prototype
+
+1. Create a group (Ctrl+G) and name it `month-proto` in the Object Properties panel
+2. Add elements:
+ - Vertical line for grid separator
+ - Text label for month name
+3. Style using Inkscape's fill, stroke, font tools
+4. Position at x=220 (will be translated for each month)
+
+### Step 3: Design Your Lane Prototype
+
+1. Create group named `lane-proto`
+2. Add elements:
+ - Rectangle for background
+ - Text label for lane name
+3. Style as desired (gradients, rounded corners, etc.)
+4. Position at y=140 (will be translated for each lane)
+
+### Step 4: Design Your Item Prototype
+
+1. Create group named `item-proto`
+2. Add elements:
+ - Shape (circle, rect, path) for marker
+ - Text elements for title, ID, or other fields
+3. Give text elements IDs like `item-title`, `item-id`
+4. Position at x=280, y=150
+
+### Step 5: Add Containers
+
+1. Create three empty groups:
+ - `months-container`
+ - `lanes-container`
+ - `items-container`
+2. These can be anywhere - they're just containers
+
+### Step 6: Add Styles
+
+Use Inkscape's built-in styles or add a `
+
+```
+
+### Step 7: Save and Use
+
+1. Save as Plain SVG (not Inkscape SVG)
+2. Reference in your project.json:
+ ```json
+ {
+ "svgTemplate": "template-proto.svg"
+ }
+ ```
+
+## Configuration Options
+
+### Layout Settings
+
+Control spacing and positioning in your project.json:
+
+```json
+{
+ "settings": {
+ "timelineMonths": 18,
+ "marginLeft": 220,
+ "marginTop": 140,
+ "monthWidth": 120,
+ "laneHeight": 80,
+ "laneGap": 16
+ }
+}
+```
+
+- `marginLeft`: Left margin before first month
+- `marginTop`: Top margin before first lane
+- `monthWidth`: Horizontal space for each month
+- `laneHeight`: Vertical space for each lane
+- `laneGap`: Space between lanes
+
+## Examples
+
+### Example 1: Minimal Prototype Template
+
+See `example-proto/template-proto.svg` for a complete working example.
+
+### Example 2: Custom Styling
+
+```xml
+
+
+
+ Task Title
+
+
+```
+
+### Example 3: With Gradients
+
+```xml
+
+
+
+
+
+
+
+
+
+ Lane Name
+
+```
+
+## Migration from Template-v2
+
+If you have existing template-v2.svg files, you can continue using them. The generator automatically detects which template type you're using:
+
+- **Prototype-based** (new): Detected by `id="*-proto"` elements → uses DOM generator
+- **Template-v2** (old): Detected by `id="*-template"` elements → uses string generator
+
+To migrate:
+
+1. Open your template-v2.svg in Inkscape
+2. Rename template elements:
+ - `month-template` → `month-proto`
+ - `lane-template` → `lane-proto`
+ - `item-template` → `item-proto`
+3. Replace `{{PLACEHOLDERS}}` with actual sample text
+4. Add IDs to text elements (e.g., `id="item-title"`)
+5. Create container groups
+6. Save and test
+
+## Troubleshooting
+
+### Prototypes Not Found
+
+**Error:** `Template is missing required prototype elements`
+
+**Solution:** Ensure your SVG has groups with exact IDs:
+- `month-proto`
+- `lane-proto`
+- `item-proto`
+
+Check Object Properties panel in Inkscape (Ctrl+Shift+O).
+
+### Text Not Updating
+
+**Problem:** Cloned items show placeholder text instead of data
+
+**Solution:** Add IDs to text elements matching your field names:
+```xml
+Placeholder
+T-000
+```
+
+### Wrong Positioning
+
+**Problem:** Elements appear in wrong locations
+
+**Solution:** Prototypes should be positioned at a base location (e.g., x=220 for months). The generator adds transforms for each clone. Check that your `marginLeft`, `marginTop`, `monthWidth`, etc. settings match your prototype positions.
+
+### Styling Not Applied
+
+**Problem:** Generated timeline doesn't have styles from Inkscape
+
+**Solution:**
+1. Use CSS classes in your prototypes, define in `
+
+
+
+
+
+
+
+
+
+
+ Timeline Prototype
+
+
+ Edit this SVG in Inkscape - prototypes will be cloned for each data item
+
+
+
+
+
+ PROTOTYPES (will be cloned):
+
+
+
+
+
+ Jan 25
+
+
+
+
+
+ Lane Name
+
+
+
+
+
+ Task Title
+ T-123
+
+
+
+
+
+
+
+
diff --git a/example/template-proto.svg b/example/template-proto.svg
new file mode 100644
index 0000000..27f070c
--- /dev/null
+++ b/example/template-proto.svg
@@ -0,0 +1,60 @@
+
+
diff --git a/generator-dom.js b/generator-dom.js
new file mode 100644
index 0000000..02682fe
--- /dev/null
+++ b/generator-dom.js
@@ -0,0 +1,264 @@
+// 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, "'");
+ }
+};
diff --git a/index.html b/index.html
index 588e6bd..bee036a 100644
--- a/index.html
+++ b/index.html
@@ -315,6 +315,7 @@
}
+