// ----------------------------- // Projektplan — GANTT-Rendering & Zoom // ----------------------------- // Dieses Modul rendert das GANTT-Diagramm der Projektplan-Seite inklusive // der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js // liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben. // Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des // Datumsbereichs (fitPlanGanttRange) sowie die Anzeige der Ressourcen- // Auslastung. renderPlanGantt ist die zentrale Neu-Render-Funktion und wird // nach Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js // erneut aufgerufen. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts // neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den // Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und // Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die // Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert. window.renderPlanGantt = function(){ const container = document.getElementById('planGanttContainer'); if(!app.project || !container) return; const prevScroll = container.scrollLeft; let start = parseDateISO(document.getElementById('planGanttStart').value); let end = parseDateISO(document.getElementById('planGanttEnd').value); if(!start || !end){ const range = ganttRangeFromProjects([app.project], 7); start = range.start; end = range.end; document.getElementById('planGanttStart').value = formatDateISO(start); document.getElementById('planGanttEnd').value = formatDateISO(end); } const showVacation = document.getElementById('planShowVacation').checked; const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id); const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : []; container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath }); container.scrollLeft = prevScroll; requestAnimationFrame(() => renderGanttDependencies(container)); renderResourceLoad(); }; // renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die // Abhängigkeits-Pfade als SVG-Kurven mit Pfeilspitzen in das Diagramm. // Bereits vorhandene Linien werden zuvor entfernt; Konflikt- und // erledigte-Kanten erhalten eigene Farben. Parameter: container (optional, // GANTT-Container). Rückgabe: keine. window.renderGanttDependencies = function(container){ const c = container || document.getElementById('planGanttContainer'); if(!c) return; const chart = c.querySelector('.gantt-chart'); if(!chart) return; let svg = chart.querySelector('.gantt-dependencies-svg'); if(svg) svg.remove(); const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]')); if(nodeBars.length === 0) return; const chartRect = chart.getBoundingClientRect(); const barMap = new Map(); nodeBars.forEach(b => { if(b.dataset.todoId) barMap.set(b.dataset.todoId, b); }); const paths = []; nodeBars.forEach(targetBar => { const dependsOnStr = targetBar.dataset.todoDepends; if(!dependsOnStr) return; const depIds = dependsOnStr.split(',').map(s => s.trim()).filter(Boolean); depIds.forEach(depId => { const sourceBar = barMap.get(depId); if(!sourceBar) return; const sourceRect = sourceBar.getBoundingClientRect(); const targetRect = targetBar.getBoundingClientRect(); const x1 = sourceRect.right - chartRect.left; const y1 = sourceRect.top + sourceRect.height / 2 - chartRect.top; const x2 = targetRect.left - chartRect.left; const y2 = targetRect.top + targetRect.height / 2 - chartRect.top; const isConflict = x2 < x1; const isDone = targetBar.classList.contains('done') && sourceBar.classList.contains('done'); let d = ''; if(x2 >= x1 + 10){ const midX = x1 + (x2 - x1) / 2; d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; } else { const r = 16; d = `M ${x1} ${y1} C ${x1 + r} ${y1}, ${x1 + r} ${(y1 + y2)/2}, ${(x1 + x2)/2} ${(y1 + y2)/2} C ${x2 - r} ${(y1 + y2)/2}, ${x2 - r} ${y2}, ${x2} ${y2}`; } let pathClass = 'gantt-dep-path'; let markerId = 'gantt-arrow-normal'; if(isConflict){ pathClass += ' conflict'; markerId = 'gantt-arrow-conflict'; } else if(isDone){ pathClass += ' done'; markerId = 'gantt-arrow-done'; } paths.push(``); }); }); if(paths.length === 0) return; svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'gantt-dependencies-svg'); svg.innerHTML = ` ${paths.join('')} `; chart.appendChild(svg); }; // fitPlanGanttRange: setzt die Datumsfelder der Seite auf den Projektzeitraum // (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach // Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt. window.fitPlanGanttRange = function(){ const range = ganttRangeFromProjects([app.project], 7); document.getElementById('planGanttStart').value = formatDateISO(range.start); document.getElementById('planGanttEnd').value = formatDateISO(range.end); renderPlanGantt(); }; // zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms. // Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit). window.zoomPlanGantt = function(dir){ const container = document.getElementById('planGanttContainer'); let start = parseDateISO(document.getElementById('planGanttStart').value); let end = parseDateISO(document.getElementById('planGanttEnd').value); if(!start || !end) return; const totalDays = Math.max(1, daysBetween(start, end) + 1); const cw = container ? Math.max(200, container.clientWidth - 250) : 900; const fitPx = Math.max(3, cw / totalDays); const base = app.pxPerDay || fitPx; let next; if(dir > 0){ next = Math.min(120, Math.round(base * 1.35)); } else { next = Math.round(base / 1.35); if(next <= fitPx * 0.7) next = null; } app.pxPerDay = next; renderPlanGantt(); }; // ---------- Ressourcen-Auslastung ---------- // renderResourceLoad: berechnet die Auslastung der Projektbeteiligten in // Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs und // rendert pro Person eine Auslastungskarte mit Balken und Abwesenheits- // Hinweis. Ein Klick auf eine Karte öffnet das Abwesenheits-Modal aus // plan-modals.js. window.renderResourceLoad = function(){ const container = document.getElementById('resourceLoadContainer'); if(!container || !app.project) return; const plan = app.ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : []; const assignees = projectAssignees(app.project, app.week); if(assignees.length === 0){ container.innerHTML = ''; return; } const loads = {}; assignees.forEach(a => { if(!loads[a.person.id]){ loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] }; } if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role); }); todos.forEach(t => { if(!Array.isArray(t.assignees) || t.assignees.length === 0) return; const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end)); t.assignees.forEach(pid => { if(!loads[pid]) return; loads[pid].days += days; loads[pid].todos.push(t.title); }); }); const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0)); let html = '
Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)
'; html += '
'; Object.values(loads).forEach(l => { const pct = Math.round(l.days / maxDays * 100); const absCount = app.countPersonAbsences(l.person.id); const absBadge = absCount > 0 ? `
🏖 ${absCount} Abwesenheit(en)
` : ''; let todoListHtml; if(l.todos.length === 0){ todoListHtml = '
keine TODOs
'; } else { todoListHtml = l.todos.slice(0,3).map(t => `
${esc(t)}
`).join(''); if(l.todos.length > 3){ todoListHtml += '
+' + (l.todos.length - 3) + ' weitere
'; } } html += `

${esc(l.person.name)} (${esc(l.roles.join(' / '))})

${l.days}
${todoListHtml}
${absBadge}
`; }); html += '
'; container.innerHTML = html; }; // Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen // Debounce, damit während des Ziehens nicht unnötig neu gerendert wird. window.addEventListener('resize', () => { if(app.resizeTimer) clearTimeout(app.resizeTimer); app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150); }); })();