Files
timeline-svg/engine.js
tegwick 5bec61740b 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>
2026-01-23 23:50:37 +01:00

897 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.timelineEngine = {
config: null,
template: null,
csvOverride: false,
cssOverride: false,
csvData: null, // Store current CSV text
cssData: null, // Store current CSS text
projectBasePath: '',
showTemplatePreview() {
if (!this.template) return;
console.log("Showing template preview");
// Detect template theme for appropriate colors
const isEnhanced = this.template.includes('Enhanced');
const isBlueTheme = this.template.includes('My Project');
const isGreenTheme = this.template.includes('Example');
// Color scheme selection
let primaryColor = '#0A4D8C'; // default
let secondaryColor = '#5C6B7A'; // default
if (isEnhanced) {
if (isBlueTheme) {
primaryColor = '#3b82f6';
secondaryColor = '#60a5fa';
} else if (isGreenTheme) {
primaryColor = '#2d8659';
secondaryColor = '#4a9b6b';
}
}
// Create sample month graphics matching the template style
const sampleMonths = isEnhanced ? `
<line x1="240" y1="75" x2="240" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="210" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="244" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Jan 25</text>
<line x1="360" y1="75" x2="360" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="330" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="364" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Feb 25</text>
<line x1="480" y1="75" x2="480" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="450" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="484" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Mar 25</text>
<line x1="600" y1="75" x2="600" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
<rect x="570" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
<text x="604" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Apr 25</text>
` : `
<line x1="240" y1="75" x2="240" y2="300" stroke="#E3E8EF" />
<text x="244" y="90" fill="#5C6B7A" font-size="12">Jan 25</text>
<line x1="360" y1="75" x2="360" y2="300" stroke="#E3E8EF" />
<text x="364" y="90" fill="#5C6B7A" font-size="12">Feb 25</text>
<line x1="480" y1="75" x2="480" y2="300" stroke="#E3E8EF" />
<text x="484" y="90" fill="#5C6B7A" font-size="12">Mar 25</text>
<line x1="600" y1="75" x2="600" y2="300" stroke="#E3E8EF" />
<text x="604" y="90" fill="#5C6B7A" font-size="12">Apr 25</text>
`;
// Create sample lane content matching the template style
const sampleLanes = isEnhanced ? `
<rect x="40" y="116" width="640" height="80" fill="rgba(255,255,255,0.7)" stroke="${secondaryColor}" stroke-width="1" opacity="0.5" rx="8" />
<text x="56" y="136" fill="${primaryColor}" font-size="14" font-weight="700">Example Epic</text>
<circle cx="270" cy="156" r="6" fill="${primaryColor}" stroke="${secondaryColor}" stroke-width="2" />
<text x="282" y="160" font-size="12" fill="${primaryColor}" font-weight="500">
<tspan class="item-id">EX-1: </tspan>
<tspan class="item-title" fill="${primaryColor === '#3b82f6' ? '#1e40af' : '#1e5a3d'}">Sample Task Preview</tspan>
</text>
<rect x="40" y="212" width="640" height="80" fill="rgba(255,255,255,0.7)" stroke="${secondaryColor}" stroke-width="1" opacity="0.5" rx="8" />
<text x="56" y="232" fill="${primaryColor}" font-size="14" font-weight="700">Another Epic</text>
<circle cx="390" cy="252" r="6" fill="${primaryColor}" stroke="${secondaryColor}" stroke-width="2" />
<text x="402" y="256" font-size="12" fill="${primaryColor}" font-weight="500">
<tspan class="item-id">EX-2: </tspan>
<tspan class="item-title" fill="${primaryColor === '#3b82f6' ? '#1e40af' : '#1e5a3d'}">Template Preview</tspan>
</text>
` : `
<rect x="40" y="116" width="640" height="80" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />
<text x="56" y="136" fill="#0B1F3B" font-size="14" font-weight="600">Example Epic</text>
<circle cx="270" cy="156" r="5" fill="#0A4D8C" />
<text x="282" y="160" font-size="12" fill="#0B1F3B">
<tspan class="item-id">EX-1: </tspan>
<tspan class="item-title">Sample Task Preview</tspan>
</text>
<rect x="40" y="212" width="640" height="80" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />
<text x="56" y="232" fill="#0B1F3B" font-size="14" font-weight="600">Another Epic</text>
<circle cx="390" cy="252" r="5" fill="#0A4D8C" />
<text x="402" y="256" font-size="12" fill="#0B1F3B">
<tspan class="item-id">EX-2: </tspan>
<tspan class="item-title">Template Preview</tspan>
</text>
`;
// Replace placeholders with sample data
let previewSvg = this.template
.replace("{{MONTHS}}", sampleMonths)
.replace("{{LANES}}", sampleLanes);
// Add dimensions for proper preview display (using reasonable default size)
const previewWidth = 720;
const previewHeight = 360;
previewSvg = previewSvg.replace(
/<svg([^>]*?)>/,
`<svg$1 width="${previewWidth}" height="${previewHeight}" viewBox="0 0 ${previewWidth} ${previewHeight}">`
);
document.getElementById("viewer").innerHTML = previewSvg;
// Initialize zoom functionality for the preview
if (window.svgViewer) {
window.svgViewer.resetZoom();
window.svgViewer.updateZoomControls();
}
console.log("Template preview displayed with theme:", { isEnhanced, isBlueTheme, isGreenTheme });
},
updateFileStatus(fileType, filename, status = 'loaded') {
const statusElement = document.getElementById(`${fileType}File`);
if (statusElement) {
if (filename && status === 'loaded') {
statusElement.textContent = filename;
statusElement.style.color = '#28a745';
statusElement.title = `Loaded: ${filename}`;
} else if (filename && status === 'error') {
statusElement.textContent = `Error: ${filename}`;
statusElement.style.color = '#dc3545';
statusElement.title = `Failed to load: ${filename}`;
} else {
statusElement.textContent = 'Not loaded';
statusElement.style.color = '#6c757d';
statusElement.title = 'No file loaded';
}
}
// Add visual feedback with brief highlight animation
if (statusElement && filename) {
statusElement.style.transform = 'scale(1.05)';
setTimeout(() => {
statusElement.style.transform = 'scale(1)';
}, 200);
}
},
resolveProjectPath(relativePath) {
if (!relativePath) return relativePath;
// If path is already absolute (starts with http/https) or has no base path, return as-is
if (relativePath.startsWith('http') || !this.projectBasePath) {
return relativePath;
}
const resolvedPath = this.projectBasePath + relativePath;
console.log("Resolved path:", relativePath, "->", resolvedPath);
return resolvedPath;
},
async autoLoadDefaultProject() {
console.log("Starting autoLoadDefaultProject");
// Try projects in order: binect, my-project, example
const candidates = [
{ path: "binect/project.json", basePath: "binect/" },
{ path: "my-project/project.json", basePath: "my-project/" },
{ path: "example/project.json", basePath: "example/" }
];
for (const candidate of candidates) {
try {
const res = await fetch(candidate.path);
if (!res.ok) continue;
const cfg = await res.json();
this.projectBasePath = candidate.basePath;
await this.loadProjectConfigObject(cfg);
console.log("Project loaded successfully from:", candidate.path);
return;
} catch (e) {
continue;
}
}
console.log("No project could be auto-loaded");
},
async loadProjectConfigObject(cfg) {
this.config = cfg;
const name = cfg.name || "Timeline";
document.getElementById("projectName").textContent = name;
document.getElementById("projectSubtitle").textContent =
cfg.description || "Projektkonfiguration geladen.";
// Update project status
this.updateFileStatus('project', name, 'loaded');
// Enable project edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('project');
}
// Show field mappings in debug panel
this.showFieldMappings();
// Track loading errors for user feedback
const loadingErrors = [];
// Stylesheet
if (cfg.stylesheet && !this.cssOverride) {
const stylesheetPath = this.resolveProjectPath(cfg.stylesheet);
// Load CSS via fetch to store content for editing
try {
const cssResponse = await fetch(stylesheetPath);
if (cssResponse.ok) {
const cssText = await cssResponse.text();
this.cssData = cssText; // Store for editing
// Apply CSS
const blob = new Blob([cssText], { type: "text/css" });
const linkElement = document.getElementById("dynamicCss");
linkElement.href = URL.createObjectURL(blob);
this.updateFileStatus('css', cfg.stylesheet + ' ✨', 'loaded');
// Enable CSS edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('css');
}
console.log("Stylesheet loaded successfully:", stylesheetPath);
} else {
throw new Error(`HTTP ${cssResponse.status}`);
}
} catch (e) {
console.error("Stylesheet could not be loaded:", e);
this.updateFileStatus('css', cfg.stylesheet, 'error');
loadingErrors.push(`Stylesheet: ${cfg.stylesheet}`);
}
}
// SVG template
if (cfg.svgTemplate) {
try {
const templatePath = this.resolveProjectPath(cfg.svgTemplate);
console.log("Attempting to fetch SVG template from:", templatePath);
const svgResponse = await fetch(templatePath);
console.log("SVG template fetch response:", svgResponse.status, svgResponse.statusText);
if (!svgResponse.ok) {
throw new Error(`HTTP ${svgResponse.status}: ${svgResponse.statusText}`);
}
this.template = await svgResponse.text();
console.log("SVG template loaded, length:", this.template.length);
this.updateFileStatus('svg', cfg.svgTemplate, 'loaded');
// Enable SVG edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('svg');
}
// Show template fields in debug panel
this.showTemplateFields();
// Show template preview if no CSV data is loaded yet
if (!this.csvOverride && document.querySelector("#viewer").innerHTML.includes("Keine Timeline verfügbar")) {
this.showTemplatePreview();
}
} catch (e) {
console.error("SVG template could not be loaded:", e);
console.error("Failed SVG template path was:", this.resolveProjectPath(cfg.svgTemplate));
this.updateFileStatus('svg', cfg.svgTemplate, 'error');
loadingErrors.push(`SVG template: ${cfg.svgTemplate}`);
}
}
// CSV data
if (cfg.dataSource && !this.csvOverride) {
try {
const csvPath = this.resolveProjectPath(cfg.dataSource);
console.log("Attempting to fetch CSV from:", csvPath);
const csvResponse = await fetch(csvPath);
console.log("CSV fetch response:", csvResponse.status, csvResponse.statusText);
if (!csvResponse.ok) {
throw new Error(`HTTP ${csvResponse.status}: ${csvResponse.statusText}`);
}
const csvText = await csvResponse.text();
console.log("CSV text loaded, length:", csvText.length);
console.log("CSV preview:", csvText.substring(0, 200));
this.updateFileStatus('csv', cfg.dataSource, 'loaded');
// Enable CSV edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('csv');
}
this.processCsv(csvText);
} catch (e) {
console.error("CSV could not be loaded:", e);
console.error("Failed CSV path was:", this.resolveProjectPath(cfg.dataSource));
this.updateFileStatus('csv', cfg.dataSource, 'error');
loadingErrors.push(`CSV data: ${cfg.dataSource}`);
}
}
// Show user feedback if there were loading errors
if (loadingErrors.length > 0) {
setTimeout(() => {
const viewer = document.getElementById("viewer");
if (viewer && !viewer.innerHTML.includes('<svg')) {
viewer.innerHTML = `
<div style="text-align:center; padding:40px 20px; color:#6c757d;">
<div style="font-size:48px; margin-bottom:16px;">⚠️</div>
<h4 style="margin:0 0 8px 0; color:#dc3545;">Projektdateien konnten nicht geladen werden</h4>
<p style="margin:0 0 12px 0; font-size:14px;">
<strong>Fehlgeschlagene Dateien:</strong><br>
${loadingErrors.map(err => `${err}`).join('<br>')}
</p>
<p style="margin:0; font-size:12px; color:#6c757d;">
💡 <strong>Tipp:</strong> Lade die Dateien manuell mit den Load-Buttons oben oder
verwende einen lokalen Server (<code>make serve</code>).
</p>
</div>
`;
}
}, 500); // Small delay to let other operations complete
}
},
processCsv(text) {
console.log("processCsv called with text length:", text?.length);
// Store CSV data for editing
this.csvData = text;
if (!this.config || !this.config.fieldMapping) {
console.error("No config or fieldMapping found.");
return;
}
if (typeof Papa === 'undefined') {
console.error("Papa Parse library not available");
document.getElementById("viewer").innerHTML =
"<em style='color:#dc3545;'>Fehler: CSV Parser nicht verfügbar.</em>";
return;
}
const m = this.config.fieldMapping;
const self = this; // Capture 'this' context
Papa.parse(text, {
header: true,
skipEmptyLines: true,
complete: (res) => {
console.log("Papa.parse complete, found", res.data.length, "rows");
const rows = res.data;
// Show CSV preview in debug panel
self.showCSVPreview(text, rows);
const items = rows.map((r) => {
const dueField = (m.due || []).find(f => r[f]);
const item = {
id: m.id ? r[m.id] : (r["ID"] || ""),
title: m.title ? r[m.title] : (r["Title"] || ""),
lane: m.lane ? (r[m.lane] || "Ohne Epic") : (r["Lane"] || "Default"),
due: self.parseDate(dueField ? r[dueField] : null)
};
return item;
}).filter(i => i.title && i.due);
console.log("Filtered to", items.length, "valid items");
if (!items.length) {
document.getElementById("viewer").innerHTML =
"<em style='color:#999;'>Keine gültigen Items gefunden.</em>";
return;
}
// Ensure generator is available
if (!window.timelineGenerator) {
console.error("Timeline generator not available");
document.getElementById("viewer").innerHTML =
"<em style='color:#dc3545;'>Fehler: Timeline Generator nicht verfügbar.</em>";
return;
}
try {
console.log("Generating timeline with:", items, self.config, self.template ? "template loaded" : "no 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;
dlBtn.style.opacity = 1;
// Initialize zoom functionality for the new SVG
if (window.svgViewer) {
window.svgViewer.resetZoom();
window.svgViewer.updateZoomControls();
}
console.log("Timeline generated successfully");
} catch (error) {
console.error("Error generating timeline:", error);
document.getElementById("viewer").innerHTML =
"<em style='color:#dc3545;'>Fehler beim Generieren der Timeline: " + error.message + "</em>";
}
}
});
},
parseDate(str) {
if (!str) return null;
str = String(str).trim();
let d = null;
// YYYY/MM/DD oder YYYY-MM-DD
let m = str.match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})$/);
if (m) {
const [_, yy, mm, dd] = m;
d = new Date(Number(yy), Number(mm) - 1, Number(dd));
return isNaN(d.getTime()) ? null : d;
}
// DD.MM.YYYY
m = str.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (m) {
const [_, dd, mm, yy] = m;
d = new Date(Number(yy), Number(mm) - 1, Number(dd));
return isNaN(d.getTime()) ? null : d;
}
// Fallback
d = new Date(str);
return isNaN(d.getTime()) ? null : d;
},
// --------- Debug Display Functions ---------
showFieldMappings() {
if (!this.config || !this.config.fieldMapping) return;
const debugInfo = document.getElementById("debugInfo");
const fieldMappingInfo = document.getElementById("fieldMappingInfo");
const fieldMappingDisplay = document.getElementById("fieldMappingDisplay");
if (debugInfo && fieldMappingInfo && fieldMappingDisplay) {
debugInfo.style.display = "block";
fieldMappingInfo.style.display = "block";
const mapping = this.config.fieldMapping;
let display = "CSV Column → Timeline Field\n";
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
display += `ID: ${JSON.stringify(mapping.id)}\n`;
display += `Title: ${JSON.stringify(mapping.title)}\n`;
display += `Lane: ${JSON.stringify(mapping.lane)}\n`;
display += `Due: ${JSON.stringify(mapping.due)}\n`;
if (mapping.epic) display += `Epic: ${JSON.stringify(mapping.epic)}\n`;
if (mapping.type) display += `Type: ${JSON.stringify(mapping.type)}\n`;
if (mapping.color) display += `Color: ${JSON.stringify(mapping.color)}\n`;
fieldMappingDisplay.textContent = display;
}
},
showTemplateFields() {
if (!this.template) return;
const debugInfo = document.getElementById("debugInfo");
const templateFieldsInfo = document.getElementById("templateFieldsInfo");
const templateFieldsDisplay = document.getElementById("templateFieldsDisplay");
if (debugInfo && templateFieldsInfo && templateFieldsDisplay) {
debugInfo.style.display = "block";
templateFieldsInfo.style.display = "block";
// Extract placeholders from template
const placeholders = new Set();
const placeholderRegex = /\{\{([A-Z_]+)\}\}/g;
let match;
while ((match = placeholderRegex.exec(this.template)) !== null) {
placeholders.add(match[1]);
}
let display = "Required Template Placeholders:\n";
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
// Group by category
const monthFields = Array.from(placeholders).filter(p => p.includes('MONTH'));
const laneFields = Array.from(placeholders).filter(p => p.includes('LANE'));
const taskFields = Array.from(placeholders).filter(p => p.includes('TASK') || p.includes('TEXT'));
const otherFields = Array.from(placeholders).filter(p =>
!monthFields.includes(p) && !laneFields.includes(p) && !taskFields.includes(p)
);
if (monthFields.length > 0) {
display += "\nMonth Fields:\n";
monthFields.forEach(f => display += `${f}\n`);
}
if (laneFields.length > 0) {
display += "\nLane Fields:\n";
laneFields.forEach(f => display += `${f}\n`);
}
if (taskFields.length > 0) {
display += "\nTask Fields:\n";
taskFields.forEach(f => display += `${f}\n`);
}
if (otherFields.length > 0) {
display += "\nOther Fields:\n";
otherFields.forEach(f => display += `${f}\n`);
}
templateFieldsDisplay.textContent = display;
}
},
showCSVPreview(csvText, parsedData) {
const debugInfo = document.getElementById("debugInfo");
const csvDataInfo = document.getElementById("csvDataInfo");
const csvDataDisplay = document.getElementById("csvDataDisplay");
if (debugInfo && csvDataInfo && csvDataDisplay) {
debugInfo.style.display = "block";
csvDataInfo.style.display = "block";
const lines = csvText.trim().split('\n');
const headers = lines[0] ? lines[0].split(',') : [];
const firstDataLine = lines[1] ? lines[1].split(',') : [];
let display = "CSV Structure Preview:\n";
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
display += `Headers: ${headers.join(', ')}\n\n`;
if (firstDataLine.length > 0) {
display += "First Data Row:\n";
headers.forEach((header, i) => {
display += ` ${header}: "${firstDataLine[i] || ''}"\n`;
});
}
if (parsedData) {
display += `\nParsed Rows: ${parsedData.length}\n`;
const validCount = parsedData.filter(r => {
const mapping = this.config?.fieldMapping || {};
const titleField = mapping.title;
const dueField = Array.isArray(mapping.due) ? mapping.due.find(f => r[f]) : mapping.due;
return r[titleField] && r[dueField];
}).length;
display += `Valid Items: ${validCount}\n`;
if (validCount === 0 && parsedData.length > 0) {
display += "\n⚠ WARNING: No valid items found!\n";
display += "Check that:\n";
display += " • CSV headers match field mappings\n";
display += " • Title and Due fields have values\n";
display += " • Date format is parseable (e.g., YYYY-MM-DD)\n";
}
}
csvDataDisplay.textContent = display;
}
}
};
// --------- SVG Viewer with Zoom functionality ---------
window.svgViewer = {
currentZoom: 1,
minZoom: 0.25,
maxZoom: 3,
zoomStep: 0.25,
initializeZoom() {
const viewer = document.getElementById("viewer");
const zoomControls = document.getElementById("zoomControls");
const zoomIn = document.getElementById("zoomIn");
const zoomOut = document.getElementById("zoomOut");
const zoomReset = document.getElementById("zoomReset");
if (!viewer || !zoomControls || !zoomIn || !zoomOut || !zoomReset) {
console.warn("Zoom controls not found in DOM");
return;
}
// Show zoom controls when SVG is present
this.updateZoomControls();
// Zoom in
zoomIn.addEventListener("click", () => {
this.zoomIn();
});
// Zoom out
zoomOut.addEventListener("click", () => {
this.zoomOut();
});
// Reset zoom
zoomReset.addEventListener("click", () => {
this.resetZoom();
});
// Mouse wheel zoom (with Ctrl key)
viewer.addEventListener("wheel", (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (e.deltaY < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
});
console.log("SVG zoom functionality initialized");
},
showZoomControls() {
const zoomControls = document.getElementById("zoomControls");
if (zoomControls) {
zoomControls.style.display = "block";
}
},
hideZoomControls() {
const zoomControls = document.getElementById("zoomControls");
if (zoomControls) {
zoomControls.style.display = "none";
}
},
zoomIn() {
if (this.currentZoom < this.maxZoom) {
this.currentZoom = Math.min(this.maxZoom, this.currentZoom + this.zoomStep);
this.applyZoom();
}
},
zoomOut() {
if (this.currentZoom > this.minZoom) {
this.currentZoom = Math.max(this.minZoom, this.currentZoom - this.zoomStep);
this.applyZoom();
}
},
resetZoom() {
this.currentZoom = 1;
this.applyZoom();
},
applyZoom() {
const viewer = document.getElementById("viewer");
const container = document.getElementById("viewerContainer");
if (viewer && container) {
viewer.style.transform = `scale(${this.currentZoom})`;
// Adjust container overflow behavior based on zoom level
if (this.currentZoom !== 1) {
viewer.classList.add("zoomed");
container.style.overflow = "auto";
} else {
viewer.classList.remove("zoomed");
container.style.overflow = "hidden";
}
this.updateZoomControls();
}
},
updateZoomControls() {
const zoomIn = document.getElementById("zoomIn");
const zoomOut = document.getElementById("zoomOut");
const zoomReset = document.getElementById("zoomReset");
if (zoomIn && zoomOut && zoomReset) {
// Update button states
zoomIn.disabled = this.currentZoom >= this.maxZoom;
zoomOut.disabled = this.currentZoom <= this.minZoom;
// Update zoom percentage display
const percentage = Math.round(this.currentZoom * 100);
zoomReset.textContent = `${percentage}%`;
// Show/hide controls based on whether SVG is present
const hasSvg = document.querySelector("#viewer svg") !== null;
if (hasSvg) {
this.showZoomControls();
} else {
this.hideZoomControls();
}
}
}
};
// --------- UI event handlers ---------
window.setupEventHandlers = function() {
// Handler for loading entire project folder
const folderInput = document.getElementById("folderInput");
if (folderInput) {
folderInput.addEventListener("change", async (ev) => {
const files = Array.from(ev.target.files);
if (!files.length) return;
console.log("Folder selected with", files.length, "files");
try {
// Find project.json in the uploaded files
const projectFile = files.find(f => f.name === 'project.json');
if (!projectFile) {
alert('No project.json found in selected folder. Please select a folder containing a project.json file.');
return;
}
// Parse project.json
const projectText = await projectFile.text();
const cfg = JSON.parse(projectText);
console.log("Loaded project configuration:", cfg);
// Clear projectBasePath since we're loading from uploaded files
window.timelineEngine.projectBasePath = '';
// Update project status
window.timelineEngine.updateFileStatus('project', projectFile.name, 'loaded');
// Enable project edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('project');
}
// Load referenced files from the folder
const errors = [];
// Load stylesheet
if (cfg.stylesheet) {
const cssFile = files.find(f => f.name === cfg.stylesheet || f.webkitRelativePath.endsWith(cfg.stylesheet));
if (cssFile) {
const cssText = await cssFile.text();
window.timelineEngine.cssData = cssText; // Store for editing
window.timelineEngine.cssOverride = true;
const blob = new Blob([cssText], { type: "text/css" });
document.getElementById("dynamicCss").href = URL.createObjectURL(blob);
window.timelineEngine.updateFileStatus('css', cssFile.name, 'loaded');
// Enable CSS edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('css');
}
console.log("Loaded stylesheet:", cssFile.name);
} else {
errors.push(`Stylesheet: ${cfg.stylesheet}`);
window.timelineEngine.updateFileStatus('css', cfg.stylesheet, 'error');
}
}
// Load SVG template
if (cfg.svgTemplate) {
const svgFile = files.find(f => f.name === cfg.svgTemplate || f.webkitRelativePath.endsWith(cfg.svgTemplate));
if (svgFile) {
window.timelineEngine.template = await svgFile.text();
window.timelineEngine.updateFileStatus('svg', svgFile.name, 'loaded');
window.timelineEngine.showTemplateFields();
// Enable SVG edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('svg');
}
console.log("Loaded SVG template:", svgFile.name);
} else {
errors.push(`SVG template: ${cfg.svgTemplate}`);
window.timelineEngine.updateFileStatus('svg', cfg.svgTemplate, 'error');
}
}
// Load CSV data
if (cfg.dataSource) {
const csvFile = files.find(f => f.name === cfg.dataSource || f.webkitRelativePath.endsWith(cfg.dataSource));
if (csvFile) {
const csvText = await csvFile.text();
window.timelineEngine.csvOverride = true;
window.timelineEngine.updateFileStatus('csv', csvFile.name, 'loaded');
// Enable CSV edit button
if (window.fileEditor) {
window.fileEditor.enableEditButton('csv');
}
console.log("Loaded CSV data:", csvFile.name);
// Set config and process CSV
window.timelineEngine.config = cfg;
window.timelineEngine.processCsv(csvText);
} else {
errors.push(`CSV data: ${cfg.dataSource}`);
window.timelineEngine.updateFileStatus('csv', cfg.dataSource, 'error');
}
}
// Update project name and description
const name = cfg.name || "Timeline";
document.getElementById("projectName").textContent = name;
document.getElementById("projectSubtitle").textContent = cfg.description || "Project loaded from folder.";
// Show field mappings
window.timelineEngine.showFieldMappings();
// Show any errors
if (errors.length > 0) {
setTimeout(() => {
alert(`⚠️ Some files could not be loaded:\n\n${errors.join('\n')}\n\nMake sure all referenced files are in the selected folder.`);
}, 500);
} else {
console.log("✅ All project files loaded successfully from folder");
}
} catch (error) {
console.error("Error loading project folder:", error);
alert(`Error loading project: ${error.message}`);
}
});
}
// Individual file inputs removed - use folder picker instead
const downloadSvg = document.getElementById("downloadSvg");
if (downloadSvg) {
downloadSvg.addEventListener("click", () => {
const svg = document.querySelector("#viewer svg");
if (!svg) return;
// Always external view for export: IDs ausblenden
svg.querySelectorAll(".item-id").forEach(el => el.style.display = "none");
const now = new Date();
const ts = String(now.getFullYear()).slice(2)
+ String(now.getMonth() + 1).padStart(2, "0")
+ String(now.getDate()).padStart(2, "0")
+ "T"
+ String(now.getHours()).padStart(2, "0")
+ String(now.getMinutes()).padStart(2, "0");
const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${ts}-timeline.svg`;
a.click();
URL.revokeObjectURL(url);
});
}
const toggleView = document.getElementById("toggleView");
if (toggleView) {
toggleView.addEventListener("click", () => {
const body = document.body;
const isInternal = body.classList.contains("internal-mode");
if (isInternal) {
body.classList.remove("internal-mode");
body.classList.add("external-mode");
toggleView.textContent = "Switch to Internal View";
// Hide IDs in external mode
document.querySelectorAll(".item-id").forEach(el => el.style.display = "none");
} else {
body.classList.remove("external-mode");
body.classList.add("internal-mode");
toggleView.textContent = "Switch to External View";
// Show IDs in internal mode
document.querySelectorAll(".item-id").forEach(el => el.style.display = "inline");
}
});
}
}