generated from coulomb/repo-seed
init: first commit of chatgpt prebuild
This commit is contained in:
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT No Attribution
|
||||
|
||||
Copyright <YEAR> <COPYRIGHT HOLDER>
|
||||
Copyright 2025 @Tegwick
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
|
||||
175
engine.js
Normal file
175
engine.js
Normal file
@@ -0,0 +1,175 @@
|
||||
window.timelineEngine = {
|
||||
config: null,
|
||||
template: null,
|
||||
csvOverride: false,
|
||||
cssOverride: false,
|
||||
|
||||
async autoLoadDefaultProject() {
|
||||
// Versuche zuerst Binect-Projekt, sonst Example
|
||||
const candidates = ["binect/project.json", "example/project.json"];
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) continue;
|
||||
const cfg = await res.json();
|
||||
await this.loadProjectConfigObject(cfg);
|
||||
return;
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async loadProjectConfigObject(cfg) {
|
||||
this.config = cfg;
|
||||
const name = cfg.name || "Timeline";
|
||||
document.getElementById("projectName").innerText = name;
|
||||
document.getElementById("projectSubtitle").innerText =
|
||||
cfg.description || "Projektkonfiguration geladen.";
|
||||
|
||||
// Stylesheet
|
||||
if (cfg.stylesheet && !this.cssOverride) {
|
||||
document.getElementById("dynamicCss").href = cfg.stylesheet;
|
||||
}
|
||||
|
||||
// SVG template
|
||||
if (cfg.svgTemplate) {
|
||||
try {
|
||||
this.template = await fetch(cfg.svgTemplate).then(r => r.text());
|
||||
} catch (e) {
|
||||
console.warn("SVG template could not be loaded:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// CSV data
|
||||
if (cfg.dataSource && !this.csvOverride) {
|
||||
try {
|
||||
const csvText = await fetch(cfg.dataSource).then(r => r.text());
|
||||
this.processCsv(csvText);
|
||||
} catch (e) {
|
||||
console.warn("CSV could not be loaded:", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
processCsv(text) {
|
||||
if (!this.config || !this.config.fieldMapping) {
|
||||
console.error("No config or fieldMapping found.");
|
||||
return;
|
||||
}
|
||||
const m = this.config.fieldMapping;
|
||||
|
||||
Papa.parse(text, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (res) => {
|
||||
const rows = res.data;
|
||||
const items = rows.map((r) => {
|
||||
const dueField = (m.due || []).find(f => r[f]);
|
||||
return {
|
||||
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: this.parseDate(dueField ? r[dueField] : null)
|
||||
};
|
||||
}).filter(i => i.title && i.due);
|
||||
|
||||
if (!items.length) {
|
||||
document.getElementById("viewer").innerHTML =
|
||||
"<em style='color:#999;'>Keine gültigen Items gefunden.</em>";
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = window.timelineGenerator.generate(items, this.config, this.template);
|
||||
document.getElementById("viewer").innerHTML = svg;
|
||||
const dlBtn = document.getElementById("downloadSvg");
|
||||
dlBtn.disabled = false;
|
||||
dlBtn.style.opacity = 1;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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 ---------
|
||||
|
||||
document.getElementById("projectInput").addEventListener("change", async (ev) => {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
const cfg = JSON.parse(text);
|
||||
await window.timelineEngine.loadProjectConfigObject(cfg);
|
||||
});
|
||||
|
||||
document.getElementById("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.processCsv(text);
|
||||
});
|
||||
|
||||
document.getElementById("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);
|
||||
});
|
||||
|
||||
document.getElementById("svgInput").addEventListener("change", async (ev) => {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
window.timelineEngine.template = await file.text();
|
||||
});
|
||||
|
||||
document.getElementById("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);
|
||||
});
|
||||
|
||||
21
example/project.json
Normal file
21
example/project.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Example Timeline Project",
|
||||
"description": "Neutrales Beispielprojekt f\u00fcr die Timeline Engine.",
|
||||
"dataSource": "sample.csv",
|
||||
"stylesheet": "style.css",
|
||||
"svgTemplate": "template.svg",
|
||||
"settings": {
|
||||
"timelineMonths": 18
|
||||
},
|
||||
"fieldMapping": {
|
||||
"id": "ID",
|
||||
"title": "Title",
|
||||
"lane": "Lane",
|
||||
"due": [
|
||||
"Due"
|
||||
],
|
||||
"epic": null,
|
||||
"type": null,
|
||||
"color": null
|
||||
}
|
||||
}
|
||||
4
example/sample.csv
Normal file
4
example/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
|
||||
|
1
example/style.css
Normal file
1
example/style.css
Normal file
@@ -0,0 +1 @@
|
||||
body { background:#fafafa; }
|
||||
5
example/template.svg
Normal file
5
example/template.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||
{{MONTHS}}
|
||||
{{LANES}}
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 125 B |
108
generator.js
Normal file
108
generator.js
Normal file
@@ -0,0 +1,108 @@
|
||||
window.timelineGenerator = {
|
||||
generate(items, cfg, template) {
|
||||
const monthsRange = (cfg.settings && cfg.settings.timelineMonths) || 18;
|
||||
|
||||
// Determine time window from earliest due date
|
||||
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
|
||||
const left = 220;
|
||||
const top = 140;
|
||||
const monthWidth = 120;
|
||||
const laneHeight = 80;
|
||||
const laneGap = 16;
|
||||
|
||||
// Month grid (labels + vertical lines)
|
||||
let monthGraphics = "";
|
||||
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" });
|
||||
monthGraphics += `<line x1="${x}" y1="${gridTop}" x2="${x}" y2="${gridBottom}" stroke="#E3E8EF" />`;
|
||||
monthGraphics += `<text x="${x + 4}" y="${monthLabelY}" fill="#5C6B7A" font-size="12">${label}</text>`;
|
||||
});
|
||||
|
||||
// Helper to compute month index
|
||||
function monthIndexForDate(d) {
|
||||
return (d.getFullYear() - start.getFullYear()) * 12
|
||||
+ (d.getMonth() - start.getMonth());
|
||||
}
|
||||
|
||||
// Lane blocks
|
||||
let laneBlocks = "";
|
||||
laneNames.forEach((laneName, laneIdx) => {
|
||||
const laneY = top + laneIdx * (laneHeight + laneGap);
|
||||
|
||||
// Background band for lane
|
||||
laneBlocks += `<rect x="40" y="${laneY - 24}" width="${left + months.length * monthWidth}" height="${laneHeight}" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />`;
|
||||
// Lane label
|
||||
laneBlocks += `<text x="56" y="${laneY - 4}" fill="#0B1F3B" font-size="14" font-weight="600">${this.escapeXml(laneName)}</text>`;
|
||||
|
||||
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;
|
||||
|
||||
laneBlocks += `<circle cx="${cx}" cy="${cy}" r="5" fill="#0A4D8C" />`;
|
||||
laneBlocks += `<text x="${cx + 10}" y="${cy + 4}" font-size="12" fill="#0B1F3B">
|
||||
<tspan class="item-id">${this.escapeXml(it.id || "")}: </tspan>
|
||||
<tspan class="item-title">${this.escapeXml(it.title || "")}</tspan>
|
||||
</text>`;
|
||||
});
|
||||
});
|
||||
|
||||
if (template && template.includes("{{MONTHS}}") && template.includes("{{LANES}}")) {
|
||||
return template
|
||||
.replace("{{MONTHS}}", monthGraphics)
|
||||
.replace("{{LANES}}", laneBlocks);
|
||||
}
|
||||
|
||||
// Fallback: embed directly in simple SVG
|
||||
const height = top + laneNames.length * (laneHeight + laneGap) + 80;
|
||||
const width = left + months.length * monthWidth + 100;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
||||
<rect width="100%" height="100%" fill="#FFFFFF" />
|
||||
${monthGraphics}
|
||||
${laneBlocks}
|
||||
</svg>`;
|
||||
},
|
||||
|
||||
escapeXml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
};
|
||||
52
index.html
Normal file
52
index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Timeline Generator</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script>
|
||||
<link id="dynamicCss" rel="stylesheet" href="">
|
||||
<script defer src="engine.js"></script>
|
||||
<script defer src="generator.js"></script>
|
||||
</head>
|
||||
<body class="internal-mode" style="font-family: Inter, Arial, sans-serif; background:#f5f7fa; margin:20px;">
|
||||
|
||||
<h1 id="projectName" style="color:#0A4D8C; margin-bottom:8px;">Timeline Generator</h1>
|
||||
<p id="projectSubtitle" style="color:#5C6B7A; margin-top:0; margin-bottom:16px;">
|
||||
Wähle ein Projekt oder lade CSV/CSS/SVG-Dateien, um eine Timeline zu erzeugen.
|
||||
</p>
|
||||
|
||||
<div class="controls" style="margin-bottom:16px; display:flex; flex-wrap:wrap; gap:12px; align-items:center;">
|
||||
<label>Projekt laden:
|
||||
<input type="file" id="projectInput" accept=".json" />
|
||||
</label>
|
||||
<label>CSV laden:
|
||||
<input type="file" id="csvInput" accept=".csv" />
|
||||
</label>
|
||||
<label>CSS laden:
|
||||
<input type="file" id="cssInput" accept=".css" />
|
||||
</label>
|
||||
<label>SVG Template laden:
|
||||
<input type="file" id="svgInput" accept=".svg" />
|
||||
</label>
|
||||
|
||||
<button id="toggleView" style="padding:8px 14px; background:#0A4D8C; color:white; border:none; border-radius:6px; cursor:pointer;">
|
||||
Switch View (Internal / External)
|
||||
</button>
|
||||
<button id="downloadSvg" disabled
|
||||
style="padding:8px 14px; background:#0A4D8C; color:white; border:none; border-radius:6px; cursor:pointer; opacity:0.6;">
|
||||
Download SVG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="viewer" style="border:1px solid #ccd3db; background:white; padding:12px; border-radius:8px; overflow-x:auto; min-height:200px;">
|
||||
<em style="color:#999;">Noch keine Timeline generiert.</em>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
window.timelineEngine.autoLoadDefaultProject();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
22
my-project/project.json
Normal file
22
my-project/project.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "My Project Roadmap Timeline",
|
||||
"description": "Roadmap-Timeline for My Project",
|
||||
"dataSource": "sample.csv",
|
||||
"stylesheet": "style.css",
|
||||
"svgTemplate": "template.svg",
|
||||
"settings": {
|
||||
"timelineMonths": 18
|
||||
},
|
||||
"fieldMapping": {
|
||||
"id": "Schlüssel",
|
||||
"title": "Zusammenfassung",
|
||||
"lane": "Übergeordnet",
|
||||
"due": [
|
||||
"Abgeleitetes Fälligkeitsdatum",
|
||||
"Fälligkeitsdatum"
|
||||
],
|
||||
"epic": "Übergeordnet",
|
||||
"type": "Vorgangstyp",
|
||||
"color": "Issue color"
|
||||
}
|
||||
}
|
||||
6
my-project/sample.csv
Normal file
6
my-project/sample.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
"Schlüssel","Vorgangstyp","Übergeordnet","Zusammenfassung","Status","Zugewiesene Person","Start date","Abgeleitetes Startdatum","Fälligkeitsdatum","Abgeleitetes Fälligkeitsdatum","Issue color"
|
||||
"AN-121","Epic","","Benutzerauthentifizierung mit Entra-ID","Zu Erledigen","Axel Hörnke","2025/10/01","2025/10/01","2026/01/31","2026/01/31","purple"
|
||||
"AN-124","Epic","","Binect IDP (Smartdoc) MVP","Zu Erledigen","","2026/02/01","2026/02/01","2026/03/31","2026/03/31","green"
|
||||
"AN-123","Epic","","Multi-Tenant-Fähigkeit ONE und Dynamic ","In Arbeit","","2025/09/01","2025/09/01","2025/10/31","2025/10/31","purple"
|
||||
"AN-122","Epic","","Zusammenführen von Entwürfen","In Arbeit","","2025/09/01","2025/09/01","2025/11/30","2025/11/30","dark_blue"
|
||||
"AN-111","Epic","","👑🖊 Internet-/Paket-Marke","In Arbeit","Bernhard Hirmer","2025/11/01","2025/11/01","2026/02/27","2026/02/27","dark_blue"
|
||||
|
1
my-project/style.css
Normal file
1
my-project/style.css
Normal file
@@ -0,0 +1 @@
|
||||
body { background:#f5f7fa; }
|
||||
5
my-project/template.svg
Normal file
5
my-project/template.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
||||
{{MONTHS}}
|
||||
{{LANES}}
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 125 B |
Reference in New Issue
Block a user