From 5bec61740b2783c8afc63ec27af41fb49ac8b69e Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 23 Jan 2026 23:50:37 +0100 Subject: [PATCH] feat: add DOM-based prototype template system for full Inkscape editability Implements a new templating approach that allows complete visual control in Inkscape while maintaining 100% valid SVG. New Features: - DOM-based generator using DOMParser and cloneNode() - Prototype elements (month-proto, lane-proto, item-proto) instead of string templates - Full WYSIWYG editing in Inkscape - see exactly how timeline will look - Auto-detection of template type (prototype vs template-v2) - Text element mapping via IDs (e.g., id="item-title") - SVG transforms for positioning instead of placeholder replacement Implementation: - generator-dom.js: New DOM-based generator with cloning logic - engine.js: Auto-detect template type and use appropriate generator - example-proto/: Complete working example with prototype template - PROTOTYPE_TEMPLATES.md: Comprehensive guide for creating prototype templates Benefits: - No string placeholders ({{PLACEHOLDER}}) needed - Native SVG editing workflow - Better performance (DOM manipulation vs regex) - Easier maintenance and styling - Backward compatible (old template-v2 still works) Template Structure: - Prototypes with specific IDs visible in SVG (hidden after cloning) - Container groups for generated content - CSS classes for styling - Text elements with IDs matching field names All 56 tests still passing. Co-Authored-By: Claude Opus 4.5 --- Makefile | 8 +- PROTOTYPE_TEMPLATES.md | 339 +++++++++++++++++++++++++++++++ engine.js | 22 +- example-proto/project.json | 21 ++ example-proto/sample.csv | 4 + example-proto/style.css | 64 ++++++ example-proto/template-proto.svg | 60 ++++++ example/template-proto.svg | 60 ++++++ generator-dom.js | 264 ++++++++++++++++++++++++ index.html | 1 + 10 files changed, 837 insertions(+), 6 deletions(-) create mode 100644 PROTOTYPE_TEMPLATES.md create mode 100644 example-proto/project.json create mode 100644 example-proto/sample.csv create mode 100644 example-proto/style.css create mode 100644 example-proto/template-proto.svg create mode 100644 example/template-proto.svg create mode 100644 generator-dom.js 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 @@ + + + + + + + + + + + + + + + + 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/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 @@ } +