Files
timeline-svg/engine.js
tegwick 02bcf69096 enhance: integrated UI with visual loading feedback
- Integrate file upload controls with status display for cleaner UX
- Add dark grey internal styling vs. dark green external theme
- Create enhanced SVG template with prominent month indicators
- Improve file loading error detection and user guidance
- Add visual confirmation system for external resource loading
- Update generator to support enhanced template styling
- Fix CSV/template loading context binding issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 00:20:22 +01:00

408 lines
15 KiB
JavaScript

window.timelineEngine = {
config: null,
template: null,
csvOverride: false,
cssOverride: false,
projectBasePath: '',
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");
// Versuche zuerst Binect-Projekt, sonst Example
const candidates = [
{ path: "binect/project.json", basePath: "binect/" },
{ 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").innerText = name;
document.getElementById("projectSubtitle").innerText =
cfg.description || "Projektkonfiguration geladen.";
// Update project status
this.updateFileStatus('project', name, 'loaded');
// Track loading errors for user feedback
const loadingErrors = [];
// Stylesheet
if (cfg.stylesheet && !this.cssOverride) {
const stylesheetPath = this.resolveProjectPath(cfg.stylesheet);
const linkElement = document.getElementById("dynamicCss");
// Set up load/error event handlers before setting href
const handleLoad = () => {
console.log("Stylesheet loaded successfully:", stylesheetPath);
this.updateFileStatus('css', cfg.stylesheet + ' ✨', 'loaded');
linkElement.removeEventListener('load', handleLoad);
linkElement.removeEventListener('error', handleError);
};
const handleError = () => {
console.error("Stylesheet could not be loaded:", stylesheetPath);
this.updateFileStatus('css', cfg.stylesheet, 'error');
loadingErrors.push(`Stylesheet: ${cfg.stylesheet}`);
linkElement.removeEventListener('load', handleLoad);
linkElement.removeEventListener('error', handleError);
};
linkElement.addEventListener('load', handleLoad);
linkElement.addEventListener('error', handleError);
linkElement.href = stylesheetPath;
}
// SVG template
if (cfg.svgTemplate) {
try {
const templatePath = this.resolveProjectPath(cfg.svgTemplate);
console.log("Attempting to fetch SVG template from:", templatePath);
const response = await fetch(templatePath);
console.log("SVG template fetch response:", response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.template = await response.text();
console.log("SVG template loaded, length:", this.template.length);
this.updateFileStatus('svg', cfg.svgTemplate, 'loaded');
} 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 response = await fetch(csvPath);
console.log("CSV fetch response:", response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const csvText = await response.text();
console.log("CSV text loaded, length:", csvText.length);
console.log("CSV preview:", csvText.substring(0, 200));
this.updateFileStatus('csv', cfg.dataSource, 'loaded');
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);
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;
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");
const svg = window.timelineGenerator.generate(items, self.config, self.template);
document.getElementById("viewer").innerHTML = svg;
const dlBtn = document.getElementById("downloadSvg");
dlBtn.disabled = false;
dlBtn.style.opacity = 1;
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.</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;
}
};
// --------- UI event handlers ---------
function setupEventHandlers() {
const projectInput = document.getElementById("projectInput");
if (projectInput) {
projectInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
try {
const text = await file.text();
const cfg = JSON.parse(text);
// For manually loaded projects, try to infer base path from filename
// If it's example/project.json or binect/project.json, set appropriate base path
const filename = file.name;
if (filename === 'project.json') {
// Try to detect if this is a known project by checking the config
if (cfg.name && cfg.name.includes('Example')) {
window.timelineEngine.projectBasePath = 'example/';
console.log("Detected example project, setting base path to example/");
} else {
window.timelineEngine.projectBasePath = '';
console.log("Unknown project, clearing base path");
}
} else {
window.timelineEngine.projectBasePath = '';
}
window.timelineEngine.updateFileStatus('project', file.name, 'loaded');
await window.timelineEngine.loadProjectConfigObject(cfg);
// Show message about relative paths if project has data sources
if (cfg.dataSource || cfg.stylesheet || cfg.svgTemplate) {
const viewer = document.getElementById("viewer");
if (viewer && (viewer.innerHTML.includes("could not be loaded") || viewer.innerHTML.includes("Keine gültigen Items"))) {
viewer.innerHTML += "<br><br><em style='color:#6c757d; font-size:12px;'>💡 Hinweis: Stelle sicher, dass sich die referenzierten Dateien (CSV, CSS, SVG) im gleichen Verzeichnis wie die HTML-Datei befinden.</em>";
}
}
} catch (error) {
console.error("Error loading project:", error);
window.timelineEngine.updateFileStatus('project', file.name, 'error');
}
});
}
const csvInput = document.getElementById("csvInput");
if (csvInput) {
csvInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
const text = await file.text();
window.timelineEngine.csvOverride = true;
window.timelineEngine.updateFileStatus('csv', file.name, 'loaded');
window.timelineEngine.processCsv(text);
});
}
const cssInput = document.getElementById("cssInput");
if (cssInput) {
cssInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
const cssText = await file.text();
window.timelineEngine.cssOverride = true;
const blob = new Blob([cssText], { type: "text/css" });
document.getElementById("dynamicCss").href = URL.createObjectURL(blob);
window.timelineEngine.updateFileStatus('css', file.name, 'loaded');
});
}
const svgInput = document.getElementById("svgInput");
if (svgInput) {
svgInput.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
if (!file) return;
window.timelineEngine.template = await file.text();
window.timelineEngine.updateFileStatus('svg', file.name, 'loaded');
});
}
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");
}
});
}
}