generated from coulomb/repo-seed
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 <noreply@anthropic.com>
This commit is contained in:
8
Makefile
8
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/
|
||||
|
||||
339
PROTOTYPE_TEMPLATES.md
Normal file
339
PROTOTYPE_TEMPLATES.md
Normal file
@@ -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
|
||||
<g id="month-proto">
|
||||
<!-- Month header design -->
|
||||
<line x1="0" y1="120" x2="0" y2="600" class="month-grid" />
|
||||
<text x="4" y="90" class="month-label">Jan 25</text>
|
||||
</g>
|
||||
|
||||
<g id="lane-proto">
|
||||
<!-- Lane/swimlane design -->
|
||||
<rect x="40" y="-24" width="1000" height="80" class="lane-bg" />
|
||||
<text x="56" y="-4" class="lane-label">Lane Name</text>
|
||||
</g>
|
||||
|
||||
<g id="item-proto">
|
||||
<!-- Task/item marker design -->
|
||||
<circle cx="0" cy="0" r="5" class="item-marker" />
|
||||
<text x="12" y="4" class="item-title">Task Title</text>
|
||||
<text x="12" y="-8" class="item-id">T-123</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
### Required Container Elements
|
||||
|
||||
The generator will place clones in these containers (created automatically if missing):
|
||||
|
||||
```xml
|
||||
<g id="months-container"></g>
|
||||
<g id="lanes-container"></g>
|
||||
<g id="items-container"></g>
|
||||
```
|
||||
|
||||
### 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 `<text>` element if no specific match found
|
||||
|
||||
Example:
|
||||
```xml
|
||||
<g id="item-proto">
|
||||
<text id="item-title">Placeholder Title</text>
|
||||
<text id="item-id">T-000</text>
|
||||
<text id="item-status">TODO</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
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 `<style>` section:
|
||||
|
||||
```xml
|
||||
<defs>
|
||||
<style>
|
||||
.month-label { font-family: Arial; font-size: 11px; fill: #666; }
|
||||
.lane-bg { fill: #f8f9fa; stroke: #e9ecef; }
|
||||
.item-marker { fill: #007bff; }
|
||||
</style>
|
||||
</defs>
|
||||
```
|
||||
|
||||
### 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
|
||||
<g id="item-proto">
|
||||
<rect x="-8" y="-8" width="16" height="16"
|
||||
fill="#007bff" rx="3" opacity="0.8"/>
|
||||
<text x="12" y="4"
|
||||
font-family="Arial" font-size="10px" font-weight="bold">
|
||||
Task Title
|
||||
</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
### Example 3: With Gradients
|
||||
|
||||
```xml
|
||||
<defs>
|
||||
<linearGradient id="laneGradient">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#f0f0f0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g id="lane-proto">
|
||||
<rect fill="url(#laneGradient)" width="1000" height="80"/>
|
||||
<text>Lane Name</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
## 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
|
||||
<text id="item-title">Placeholder</text>
|
||||
<text id="item-id">T-000</text>
|
||||
```
|
||||
|
||||
### 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 `<style>` section
|
||||
2. Or use inline SVG attributes (fill, stroke, etc.)
|
||||
3. Avoid Inkscape-specific attributes
|
||||
|
||||
### Parse Errors
|
||||
|
||||
**Error:** `Failed to parse SVG template`
|
||||
|
||||
**Solution:**
|
||||
1. Save as "Plain SVG" not "Inkscape SVG"
|
||||
2. Check SVG is valid XML (balanced tags, proper nesting)
|
||||
3. Remove Inkscape-specific elements if present
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep Prototypes Simple** - Complex nested structures may be harder to position
|
||||
2. **Use CSS Classes** - Easier to style consistently than inline attributes
|
||||
3. **Test Incrementally** - Start with basic shapes, add detail gradually
|
||||
4. **Use Inkscape Layers** - Keep prototypes on separate layer for organization
|
||||
5. **Set Prototype Opacity** - Makes them visible but distinguishable from final output
|
||||
6. **Document Your Template** - Add comments or text notes explaining custom elements
|
||||
|
||||
## Advanced: Custom Field Mapping
|
||||
|
||||
You can map CSV columns to any text element IDs:
|
||||
|
||||
```json
|
||||
{
|
||||
"fieldMapping": {
|
||||
"id": "ID",
|
||||
"title": "Task Name",
|
||||
"assignee": "Owner",
|
||||
"status": "State"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then in your prototype:
|
||||
```xml
|
||||
<g id="item-proto">
|
||||
<text id="item-id">T-000</text>
|
||||
<text id="item-title">Task placeholder</text>
|
||||
<text id="item-assignee">Person</text>
|
||||
<text id="item-status">Open</text>
|
||||
</g>
|
||||
```
|
||||
|
||||
## Further Resources
|
||||
|
||||
- See `example-proto/` for a complete working example
|
||||
- Read `CLAUDE.md` for generator implementation details
|
||||
- Check `README.md` for general usage instructions
|
||||
|
||||
## Comparison: Prototype vs Template-v2
|
||||
|
||||
| Feature | Prototype (New) | Template-v2 (Old) |
|
||||
|---------|----------------|-------------------|
|
||||
| Inkscape Editing | ✅ Full WYSIWYG | ⚠️ Limited (placeholders) |
|
||||
| Valid SVG | ✅ Yes | ✅ Yes |
|
||||
| Visual Preview | ✅ See actual design | ⚠️ See placeholders |
|
||||
| Syntax | Simple IDs | `{{PLACEHOLDER}}` |
|
||||
| Performance | ✅ DOM cloning | String manipulation |
|
||||
| Styling | Full Inkscape tools | CSS + inline |
|
||||
| Learning Curve | Lower (visual) | Higher (syntax) |
|
||||
| Recommended | ✅ Yes | For legacy only |
|
||||
22
engine.js
22
engine.js
@@ -389,7 +389,25 @@ window.timelineEngine = {
|
||||
|
||||
try {
|
||||
console.log("Generating timeline with:", items, self.config, self.template ? "template loaded" : "no template");
|
||||
const svg = window.timelineGenerator.generate(items, self.config, self.template);
|
||||
|
||||
// Auto-detect template type: prototype-based (new) or template-based (old)
|
||||
const isPrototypeBased = self.template && (
|
||||
self.template.includes('id="month-proto"') ||
|
||||
self.template.includes('id="lane-proto"') ||
|
||||
self.template.includes('id="item-proto"')
|
||||
);
|
||||
|
||||
let svg;
|
||||
if (isPrototypeBased && window.timelineGeneratorDOM) {
|
||||
console.log("Using DOM-based generator (prototype templates)");
|
||||
svg = window.timelineGeneratorDOM.generate(items, self.config, self.template);
|
||||
} else if (window.timelineGenerator) {
|
||||
console.log("Using string-based generator (template-v2)");
|
||||
svg = window.timelineGenerator.generate(items, self.config, self.template);
|
||||
} else {
|
||||
throw new Error("No timeline generator available");
|
||||
}
|
||||
|
||||
document.getElementById("viewer").innerHTML = svg;
|
||||
const dlBtn = document.getElementById("downloadSvg");
|
||||
dlBtn.disabled = false;
|
||||
@@ -405,7 +423,7 @@ window.timelineEngine = {
|
||||
} catch (error) {
|
||||
console.error("Error generating timeline:", error);
|
||||
document.getElementById("viewer").innerHTML =
|
||||
"<em style='color:#dc3545;'>Fehler beim Generieren der Timeline.</em>";
|
||||
"<em style='color:#dc3545;'>Fehler beim Generieren der Timeline: " + error.message + "</em>";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
21
example-proto/project.json
Normal file
21
example-proto/project.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Prototype Template Example",
|
||||
"description": "Example using DOM-based prototype templates - fully editable in Inkscape!",
|
||||
"dataSource": "sample.csv",
|
||||
"stylesheet": "style.css",
|
||||
"svgTemplate": "template-proto.svg",
|
||||
"settings": {
|
||||
"timelineMonths": 18,
|
||||
"marginLeft": 220,
|
||||
"marginTop": 140,
|
||||
"monthWidth": 120,
|
||||
"laneHeight": 80,
|
||||
"laneGap": 16
|
||||
},
|
||||
"fieldMapping": {
|
||||
"id": "ID",
|
||||
"title": "Title",
|
||||
"lane": "Lane",
|
||||
"due": ["Due"]
|
||||
}
|
||||
}
|
||||
4
example-proto/sample.csv
Normal file
4
example-proto/sample.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
ID,Title,Due,Lane
|
||||
1,Example Task A,2025-12-01,Team Alpha
|
||||
2,Example Task B,2026-02-15,Team Beta
|
||||
3,Example Task C,2026-03-10,Team Alpha
|
||||
|
64
example-proto/style.css
Normal file
64
example-proto/style.css
Normal file
@@ -0,0 +1,64 @@
|
||||
/* Example Project Dark Green Theme */
|
||||
/* This CSS demonstrates successful external stylesheet loading */
|
||||
|
||||
body {
|
||||
background: #1e3a2f !important;
|
||||
}
|
||||
|
||||
#projectName {
|
||||
color: #2d8659 !important;
|
||||
border-bottom: 2px solid #2d8659;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
#projectSubtitle {
|
||||
color: #4a9b6b !important;
|
||||
}
|
||||
|
||||
/* File Manager Override */
|
||||
#fileManager {
|
||||
background: #243329 !important;
|
||||
border-color: #2d8659 !important;
|
||||
}
|
||||
|
||||
#fileManager h3 {
|
||||
color: #4a9b6b !important;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
background: #2a3f32 !important;
|
||||
border-color: #2d8659 !important;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
border-color: #4a9b6b !important;
|
||||
box-shadow: 0 2px 8px rgba(45, 134, 89, 0.2) !important;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
color: #4a9b6b !important;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
background: #2d8659 !important;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
background: #1e5a3d !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background: #2d8659 !important;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #4a9b6b !important;
|
||||
}
|
||||
|
||||
/* Viewer */
|
||||
#viewer {
|
||||
background: #2a3f32 !important;
|
||||
border-color: #2d8659 !important;
|
||||
color: #e8f5e8 !important;
|
||||
}
|
||||
60
example-proto/template-proto.svg
Normal file
60
example-proto/template-proto.svg
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<!-- Styles that can be edited in Inkscape -->
|
||||
<style>
|
||||
.month-label { font-family: Arial, sans-serif; font-size: 11px; fill: #666; font-weight: 600; }
|
||||
.month-grid { stroke: #ddd; stroke-width: 1; }
|
||||
.lane-bg { fill: #f8f9fa; stroke: #e9ecef; stroke-width: 1; }
|
||||
.lane-label { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; font-weight: 600; }
|
||||
.item-marker { fill: #007bff; }
|
||||
.item-title { font-family: Arial, sans-serif; font-size: 10px; fill: #212529; }
|
||||
.item-id { font-family: monospace; font-size: 9px; fill: #6c757d; }
|
||||
</style>
|
||||
|
||||
<!-- Gradients, filters, etc. can be added here and edited in Inkscape -->
|
||||
<linearGradient id="laneGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f8f9fa;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Title and metadata (visible in Inkscape) -->
|
||||
<text x="20" y="30" style="font-family:Arial; font-size:20px; font-weight:bold; fill:#212529;">
|
||||
Timeline Prototype
|
||||
</text>
|
||||
<text x="20" y="50" style="font-family:Arial; font-size:12px; fill:#6c757d;">
|
||||
Edit this SVG in Inkscape - prototypes will be cloned for each data item
|
||||
</text>
|
||||
|
||||
<!-- Prototypes layer (will be hidden after cloning) -->
|
||||
<g id="prototypes" style="opacity:0.5">
|
||||
<text x="20" y="80" style="font-family:Arial; font-size:11px; fill:#fd7e14; font-weight:bold;">
|
||||
PROTOTYPES (will be cloned):
|
||||
</text>
|
||||
|
||||
<!-- Month prototype -->
|
||||
<g id="month-proto" transform="translate(220, 0)">
|
||||
<line x1="0" y1="120" x2="0" y2="600" class="month-grid" />
|
||||
<text x="4" y="90" class="month-label">Jan 25</text>
|
||||
</g>
|
||||
|
||||
<!-- Lane prototype -->
|
||||
<g id="lane-proto" transform="translate(0, 140)">
|
||||
<rect x="40" y="-24" width="1000" height="80" class="lane-bg" rx="4" ry="4" style="fill:url(#laneGradient)" />
|
||||
<text x="56" y="-4" class="lane-label">Lane Name</text>
|
||||
</g>
|
||||
|
||||
<!-- Item prototype -->
|
||||
<g id="item-proto" transform="translate(280, 150)">
|
||||
<circle cx="0" cy="0" r="5" class="item-marker" />
|
||||
<text x="12" y="4" class="item-title">Task Title</text>
|
||||
<text x="12" y="-8" class="item-id">T-123</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Containers where clones will be placed -->
|
||||
<g id="months-container"></g>
|
||||
<g id="lanes-container"></g>
|
||||
<g id="items-container"></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
60
example/template-proto.svg
Normal file
60
example/template-proto.svg
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs>
|
||||
<!-- Styles that can be edited in Inkscape -->
|
||||
<style>
|
||||
.month-label { font-family: Arial, sans-serif; font-size: 11px; fill: #666; font-weight: 600; }
|
||||
.month-grid { stroke: #ddd; stroke-width: 1; }
|
||||
.lane-bg { fill: #f8f9fa; stroke: #e9ecef; stroke-width: 1; }
|
||||
.lane-label { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; font-weight: 600; }
|
||||
.item-marker { fill: #007bff; }
|
||||
.item-title { font-family: Arial, sans-serif; font-size: 10px; fill: #212529; }
|
||||
.item-id { font-family: monospace; font-size: 9px; fill: #6c757d; }
|
||||
</style>
|
||||
|
||||
<!-- Gradients, filters, etc. can be added here and edited in Inkscape -->
|
||||
<linearGradient id="laneGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f8f9fa;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Title and metadata (visible in Inkscape) -->
|
||||
<text x="20" y="30" style="font-family:Arial; font-size:20px; font-weight:bold; fill:#212529;">
|
||||
Timeline Prototype
|
||||
</text>
|
||||
<text x="20" y="50" style="font-family:Arial; font-size:12px; fill:#6c757d;">
|
||||
Edit this SVG in Inkscape - prototypes will be cloned for each data item
|
||||
</text>
|
||||
|
||||
<!-- Prototypes layer (will be hidden after cloning) -->
|
||||
<g id="prototypes" style="opacity:0.5">
|
||||
<text x="20" y="80" style="font-family:Arial; font-size:11px; fill:#fd7e14; font-weight:bold;">
|
||||
PROTOTYPES (will be cloned):
|
||||
</text>
|
||||
|
||||
<!-- Month prototype -->
|
||||
<g id="month-proto" transform="translate(220, 0)">
|
||||
<line x1="0" y1="120" x2="0" y2="600" class="month-grid" />
|
||||
<text x="4" y="90" class="month-label">Jan 25</text>
|
||||
</g>
|
||||
|
||||
<!-- Lane prototype -->
|
||||
<g id="lane-proto" transform="translate(0, 140)">
|
||||
<rect x="40" y="-24" width="1000" height="80" class="lane-bg" rx="4" ry="4" style="fill:url(#laneGradient)" />
|
||||
<text x="56" y="-4" class="lane-label">Lane Name</text>
|
||||
</g>
|
||||
|
||||
<!-- Item prototype -->
|
||||
<g id="item-proto" transform="translate(280, 150)">
|
||||
<circle cx="0" cy="0" r="5" class="item-marker" />
|
||||
<text x="12" y="4" class="item-title">Task Title</text>
|
||||
<text x="12" y="-8" class="item-id">T-123</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Containers where clones will be placed -->
|
||||
<g id="months-container"></g>
|
||||
<g id="lanes-container"></g>
|
||||
<g id="items-container"></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
264
generator-dom.js
Normal file
264
generator-dom.js
Normal file
@@ -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, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
};
|
||||
@@ -315,6 +315,7 @@
|
||||
}
|
||||
</style>
|
||||
<script src="generator.js"></script>
|
||||
<script src="generator-dom.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="file-editor.js"></script>
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user