feat: comprehensive SVG viewer enhancements with zoom and template preview

- Add interactive zoom functionality (25%-300% with Ctrl+scroll wheel)
- Fix SVG width constraints by injecting calculated dimensions into templates
- Implement template preview with theme-aware sample data
- Enhance UI layout with file reordering (Project → Template → CSS → Data)
- Add blue theme support for my-project alongside existing green theme
- Fix my-project field mapping from empty 'Übergeordnet' to 'Status'
- Improve SVG viewport handling with proper scrolling and container management
- Add visual zoom controls with percentage display and smart visibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 00:46:58 +01:00
parent 02bcf69096
commit bc756fa0cd
6 changed files with 524 additions and 33 deletions

262
engine.js
View File

@@ -6,6 +6,117 @@ window.timelineEngine = {
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`);
@@ -47,9 +158,10 @@ window.timelineEngine = {
async autoLoadDefaultProject() {
console.log("Starting autoLoadDefaultProject");
// Versuche zuerst Binect-Projekt, sonst Example
// 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) {
@@ -122,6 +234,11 @@ window.timelineEngine = {
this.template = await response.text();
console.log("SVG template loaded, length:", this.template.length);
this.updateFileStatus('svg', cfg.svgTemplate, 'loaded');
// 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));
@@ -238,6 +355,13 @@ window.timelineEngine = {
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);
@@ -275,6 +399,136 @@ window.timelineEngine = {
}
};
// --------- 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 ---------
function setupEventHandlers() {
@@ -295,6 +549,9 @@ function setupEventHandlers() {
if (cfg.name && cfg.name.includes('Example')) {
window.timelineEngine.projectBasePath = 'example/';
console.log("Detected example project, setting base path to example/");
} else if (cfg.name && (cfg.name.includes('My Project') || cfg.name.includes('Roadmap'))) {
window.timelineEngine.projectBasePath = 'my-project/';
console.log("Detected my-project, setting base path to my-project/");
} else {
window.timelineEngine.projectBasePath = '';
console.log("Unknown project, clearing base path");
@@ -352,6 +609,9 @@ function setupEventHandlers() {
if (!file) return;
window.timelineEngine.template = await file.text();
window.timelineEngine.updateFileStatus('svg', file.name, 'loaded');
// Show template preview immediately when manually loaded
window.timelineEngine.showTemplatePreview();
});
}