// 全局變量 let tasks = []; let selectedTaskId = null; let searchTerm = ""; let sortOption = "date-asc"; let globalAnalysisResult = null; // 新增:儲存全局分析結果 let svg, g, simulation; // << 修改:定義 D3 相關變量 let isGraphInitialized = false; // << 新增:追蹤圖表是否已初始化 // 新增:i18n 全局變量 let currentLang = "en"; // 預設語言 let translations = {}; // 儲存加載的翻譯 // DOM元素 const taskListElement = document.getElementById("task-list"); const taskDetailsContent = document.getElementById("task-details-content"); const statusFilter = document.getElementById("status-filter"); const currentTimeElement = document.getElementById("current-time"); const progressIndicator = document.getElementById("progress-indicator"); const progressCompleted = document.getElementById("progress-completed"); const progressInProgress = document.getElementById("progress-in-progress"); const progressPending = document.getElementById("progress-pending"); const progressLabels = document.getElementById("progress-labels"); const dependencyGraphElement = document.getElementById("dependency-graph"); const globalAnalysisResultElement = document.getElementById( "global-analysis-result" ); // 假設 HTML 中有這個元素 const langSwitcher = document.getElementById("lang-switcher"); // << 新增:獲取切換器元素 // 初始化 document.addEventListener("DOMContentLoaded", () => { // fetchTasks(); // 將由 initI18n() 觸發 initI18n(); // << 新增:初始化 i18n updateCurrentTime(); setInterval(updateCurrentTime, 1000); // 事件監聽器 // statusFilter.addEventListener("change", renderTasks); // 將由 changeLanguage 觸發或在 applyTranslations 後觸發 if (statusFilter) { statusFilter.addEventListener("change", renderTasks); } // 新增:搜索和排序事件監聽 const searchInput = document.getElementById("search-input"); const sortOptions = document.getElementById("sort-options"); if (searchInput) { searchInput.addEventListener("input", (e) => { searchTerm = e.target.value.toLowerCase(); renderTasks(); }); } if (sortOptions) { sortOptions.addEventListener("change", (e) => { sortOption = e.target.value; renderTasks(); }); } // 新增:設置 SSE 連接 setupSSE(); // 新增:語言切換器事件監聽 if (langSwitcher) { langSwitcher.addEventListener("change", (e) => changeLanguage(e.target.value) ); } }); // 新增:i18n 核心函數 // 1. 語言檢測 (URL 參數 > navigator.language > 'en') function detectLanguage() { // 1. 優先從 URL 參數讀取 const urlParams = new URLSearchParams(window.location.search); const urlLang = urlParams.get("lang"); if (urlLang && ["en", "zh-TW"].includes(urlLang)) { return urlLang; } // 2. 檢查瀏覽器語言(移除 localStorage 檢查) const browserLang = navigator.language || navigator.userLanguage; if (browserLang) { if (browserLang.toLowerCase().startsWith("zh-tw")) return "zh-TW"; if (browserLang.toLowerCase().startsWith("zh")) return "zh-TW"; // 簡體也先 fallback 到繁體 if (browserLang.toLowerCase().startsWith("en")) return "en"; } // 3. 預設值 return "en"; } // 2. 異步加載翻譯文件 async function loadTranslations(lang) { try { const response = await fetch(`/locales/${lang}.json`); if (!response.ok) { throw new Error( `Failed to load ${lang}.json, status: ${response.status}` ); } translations = await response.json(); console.log(`Translations loaded for ${lang}`); } catch (error) { console.error("Error loading translations:", error); if (lang !== "en") { console.warn(`Falling back to English translations.`); await loadTranslations("en"); // Fallback to English } else { translations = {}; // Clear translations if even English fails // Maybe display a more persistent error message? alert("Critical error: Could not load language files."); } } } // 3. 翻譯函數 function translate(key, replacements = {}) { let translated = translations[key] || key; // Fallback to key itself // 簡單的佔位符替換(例如 {message}) for (const placeholder in replacements) { translated = translated.replace( `{${placeholder}}`, replacements[placeholder] ); } return translated; } // 4. 應用翻譯到 DOM (處理 textContent, placeholder, title) function applyTranslations() { console.log("Applying translations for:", currentLang); document.querySelectorAll("[data-i18n-key]").forEach((el) => { const key = el.dataset.i18nKey; const translatedText = translate(key); // 優先處理特定屬性 if (el.hasAttribute("placeholder")) { el.placeholder = translatedText; } else if (el.hasAttribute("title")) { el.title = translatedText; } else if (el.tagName === "OPTION") { el.textContent = translatedText; // 如果需要,也可以翻譯 value,但通常不需要 } else { // 對於大多數元素,設置 textContent el.textContent = translatedText; } }); // 手動更新沒有 data-key 的元素(如果有的話) // 例如,如果 footer 時間格式需要本地化,可以在這裡處理 // updateCurrentTime(); // 確保時間格式也可能更新(如果需要) } // 5. 初始化 i18n async function initI18n() { currentLang = detectLanguage(); console.log(`Initializing i18n with language: ${currentLang}`); // << 新增:設置切換器的初始值 >> if (langSwitcher) { langSwitcher.value = currentLang; } await loadTranslations(currentLang); applyTranslations(); await fetchTasks(); } // 新增:語言切換函數 function changeLanguage(lang) { if (!lang || !["en", "zh-TW"].includes(lang)) { console.warn(`Invalid language selected: ${lang}. Defaulting to English.`); lang = "en"; } currentLang = lang; console.log(`Changing language to: ${currentLang}`); loadTranslations(currentLang) .then(() => { console.log("Translations reloaded, applying..."); applyTranslations(); console.log("Re-rendering components..."); // 重新渲染需要翻譯的組件 renderTasks(); if (selectedTaskId) { const task = tasks.find((t) => t.id === selectedTaskId); if (task) { selectTask(selectedTaskId); // 確保傳遞 ID,讓 selectTask 重新查找並渲染 } else { // 如果選中的任務已不存在,清除詳情 taskDetailsContent.innerHTML = `

${translate( "task_details_placeholder" )}

`; selectedTaskId = null; highlightNode(null); } } else { // 如果沒有任務被選中,確保詳情面板顯示 placeholder taskDetailsContent.innerHTML = `

${translate( "task_details_placeholder" )}

`; } renderDependencyGraph(); // 重新渲染圖表(可能包含 placeholder) updateProgressIndicator(); // 重新渲染進度條(包含標籤) renderGlobalAnalysisResult(); // 重新渲染全局分析(標題) // 確保下拉菜單的值與當前語言一致 if (langSwitcher) langSwitcher.value = currentLang; console.log("Language change complete."); }) .catch((error) => { console.error("Error changing language:", error); // 可以添加用戶反饋,例如顯示錯誤消息 showTemporaryError("Failed to change language. Please try again."); // Need translation key }); } // --- i18n 核心函數結束 --- // 獲取任務數據 async function fetchTasks() { try { // 初始載入時顯示 loading (現在使用翻譯) if (tasks.length === 0) { taskListElement.innerHTML = `
${translate( "task_list_loading" )}
`; } const response = await fetch("/api/tasks"); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } const data = await response.json(); const newTasks = data.tasks || []; // 提取全局分析結果 (找第一個非空的) let foundAnalysisResult = null; for (const task of newTasks) { if (task.analysisResult) { foundAnalysisResult = task.analysisResult; break; // 找到一個就夠了 } } // 只有當找到的結果與當前儲存的不同時才更新 if (foundAnalysisResult !== globalAnalysisResult) { globalAnalysisResult = foundAnalysisResult; renderGlobalAnalysisResult(); // 更新顯示 } // --- 智慧更新邏輯 (初步 - 仍需改進以避免閃爍) --- // 簡單地比較任務數量或標識符來決定是否重新渲染 // 理想情況下應比較每個任務的內容並進行 DOM 更新 const tasksChanged = didTasksChange(tasks, newTasks); if (tasksChanged) { tasks = newTasks; // 更新全局任務列表 console.log("Tasks updated via fetch, re-rendering..."); renderTasks(); updateProgressIndicator(); renderDependencyGraph(); // 更新圖表 } else { console.log( "No significant task changes detected, skipping full re-render." ); // 如果不需要重新渲染列表,可能只需要更新進度條 updateProgressIndicator(); // 考慮是否需要更新圖表(如果狀態可能改變) // renderDependencyGraph(); // 暫時註釋掉,除非狀態變化很關鍵 } // *** 移除 setTimeout 輪詢 *** // setTimeout(fetchTasks, 30000); } catch (error) { console.error("Error fetching tasks:", error); // 避免覆蓋現有列表,除非是初始載入失敗 if (tasks.length === 0) { taskListElement.innerHTML = `
${translate( "error_loading_tasks", { message: error.message } )}
`; if (progressIndicator) progressIndicator.style.display = "none"; if (dependencyGraphElement) dependencyGraphElement.innerHTML = `
${translate( "error_loading_graph" )}
`; } else { showTemporaryError( translate("error_updating_tasks", { message: error.message }) ); } } } // 新增:設置 Server-Sent Events 連接 function setupSSE() { console.log("Setting up SSE connection to /api/tasks/stream"); const evtSource = new EventSource("/api/tasks/stream"); evtSource.onmessage = function (event) { console.log("SSE message received:", event.data); // 可以根據 event.data 內容做更複雜的判斷,目前只要收到消息就更新 }; evtSource.addEventListener("update", function (event) { console.log("SSE 'update' event received:", event.data); // 收到更新事件,重新獲取任務列表 fetchTasks(); }); evtSource.onerror = function (err) { console.error("EventSource failed:", err); // 可以實現重連邏輯 evtSource.close(); // 關閉錯誤的連接 // 延遲一段時間後嘗試重新連接 setTimeout(setupSSE, 5000); // 5秒後重試 }; evtSource.onopen = function () { console.log("SSE connection opened."); }; } // 新增:比較任務列表是否有變化的輔助函數 (最全面版) function didTasksChange(oldTasks, newTasks) { if (!oldTasks || !newTasks) return true; // Handle initial load or error states if (oldTasks.length !== newTasks.length) { console.log("Task length changed."); return true; // Length change definitely needs update } const oldTaskMap = new Map(oldTasks.map((task) => [task.id, task])); const newTaskIds = new Set(newTasks.map((task) => task.id)); // For checking removed tasks // Check for removed tasks first for (const oldTask of oldTasks) { if (!newTaskIds.has(oldTask.id)) { console.log(`Task removed: ${oldTask.id}`); return true; } } // Check for new or modified tasks for (const newTask of newTasks) { const oldTask = oldTaskMap.get(newTask.id); if (!oldTask) { console.log(`New task found: ${newTask.id}`); return true; // New task ID found } // Compare relevant fields const fieldsToCompare = [ "name", "description", "status", "notes", "implementationGuide", "verificationCriteria", "summary", ]; for (const field of fieldsToCompare) { if (oldTask[field] !== newTask[field]) { // Handle null/undefined comparisons carefully if needed // e.g., !(oldTask[field] == null && newTask[field] == null) checks if one is null/undefined and the other isn't if ( !(oldTask[field] === null && newTask[field] === null) && !(oldTask[field] === undefined && newTask[field] === undefined) ) { console.log(`Task ${newTask.id} changed field: ${field}`); return true; } } } // Compare dependencies (array of strings or objects) if (!compareDependencies(oldTask.dependencies, newTask.dependencies)) { console.log(`Task ${newTask.id} changed field: dependencies`); return true; } // Compare relatedFiles (array of objects) - simple length check first if (!compareRelatedFiles(oldTask.relatedFiles, newTask.relatedFiles)) { console.log(`Task ${newTask.id} changed field: relatedFiles`); return true; } // Optional: Compare updatedAt as a final check if other fields seem identical if (oldTask.updatedAt?.toString() !== newTask.updatedAt?.toString()) { console.log(`Task ${newTask.id} changed field: updatedAt (fallback)`); return true; } } return false; // No significant changes detected } // Helper function to compare dependency arrays function compareDependencies(deps1, deps2) { const arr1 = deps1 || []; const arr2 = deps2 || []; if (arr1.length !== arr2.length) return false; // Extract IDs whether they are strings or objects {taskId: string} const ids1 = new Set( arr1.map((dep) => typeof dep === "object" && dep !== null ? dep.taskId : dep ) ); const ids2 = new Set( arr2.map((dep) => typeof dep === "object" && dep !== null ? dep.taskId : dep ) ); if (ids1.size !== ids2.size) return false; // Different number of unique deps for (const id of ids1) { if (!ids2.has(id)) return false; } return true; } // Helper function to compare relatedFiles arrays (can be simple or complex) function compareRelatedFiles(files1, files2) { const arr1 = files1 || []; const arr2 = files2 || []; if (arr1.length !== arr2.length) return false; // Simple comparison: check if paths and types are the same in the same order // For a more robust check, convert to Sets of strings like `path|type` or do deep object comparison for (let i = 0; i < arr1.length; i++) { if (arr1[i].path !== arr2[i].path || arr1[i].type !== arr2[i].type) { return false; } // Add more field comparisons if needed (description, lines, etc.) // if (arr1[i].description !== arr2[i].description) return false; } return true; } // 新增:顯示臨時錯誤訊息的函數 function showTemporaryError(message) { const errorElement = document.createElement("div"); errorElement.className = "temporary-error"; errorElement.textContent = message; // 保持消息本身 document.body.appendChild(errorElement); setTimeout(() => { errorElement.remove(); }, 3000); // 顯示 3 秒 } // 渲染任務列表 - *** 需要進一步優化以實現智慧更新 *** function renderTasks() { console.log("Rendering tasks..."); // 添加日誌 const filterValue = statusFilter.value; let filteredTasks = tasks; if (filterValue !== "all") { filteredTasks = filteredTasks.filter((task) => task.status === filterValue); } if (searchTerm) { const lowerCaseSearchTerm = searchTerm.toLowerCase(); filteredTasks = filteredTasks.filter( (task) => (task.name && task.name.toLowerCase().includes(lowerCaseSearchTerm)) || (task.description && task.description.toLowerCase().includes(lowerCaseSearchTerm)) ); } filteredTasks.sort((a, b) => { switch (sortOption) { case "name-asc": return (a.name || "").localeCompare(b.name || ""); case "name-desc": return (b.name || "").localeCompare(a.name || ""); case "status": const statusOrder = { pending: 1, in_progress: 2, completed: 3 }; return (statusOrder[a.status] || 0) - (statusOrder[b.status] || 0); case "date-asc": return new Date(a.createdAt || 0) - new Date(b.createdAt || 0); case "date-desc": default: return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); } }); // --- 簡單粗暴的替換 (會導致閃爍) --- // TODO: 實現 DOM Diffing 或更智慧的更新策略 if (filteredTasks.length === 0) { taskListElement.innerHTML = `
${translate( "task_list_empty" )}
`; } else { taskListElement.innerHTML = filteredTasks .map( (task) => `

${task.name}

${getStatusText(task.status)}
` ) .join(""); } // --- 結束簡單粗暴的替換 --- // 重新應用選中狀態 if (selectedTaskId) { const taskExists = tasks.some((t) => t.id === selectedTaskId); if (taskExists) { const selectedElement = document.querySelector( `.task-item[data-id="${selectedTaskId}"]` ); if (selectedElement) { selectedElement.classList.add("selected"); } } else { // 如果選中的任務在新的列表中不存在了,清除選擇 console.log( `Selected task ${selectedTaskId} no longer exists, clearing selection.` ); selectedTaskId = null; taskDetailsContent.innerHTML = `

${translate( "task_details_placeholder" )}

`; highlightNode(null); // 清除圖表高亮 } } } // 選擇任務 function selectTask(taskId) { // 清除舊的選中狀態和高亮 if (selectedTaskId) { const previousElement = document.querySelector( `.task-item[data-id="${selectedTaskId}"]` ); if (previousElement) { previousElement.classList.remove("selected"); } } // 如果再次點擊同一個任務,則取消選中 if (selectedTaskId === taskId) { selectedTaskId = null; taskDetailsContent.innerHTML = `

${translate( "task_details_placeholder" )}

`; highlightNode(null); // 取消高亮 return; } selectedTaskId = taskId; // 添加新的選中狀態 const selectedElement = document.querySelector( `.task-item[data-id="${taskId}"]` ); if (selectedElement) { selectedElement.classList.add("selected"); } // 獲取並顯示任務詳情 const task = tasks.find((t) => t.id === taskId); if (!task) { taskDetailsContent.innerHTML = `
${translate( "error_task_not_found" )}
`; return; } // --- 安全地填充任務詳情 --- // 1. 創建基本骨架 (使用 innerHTML,但將動態內容替換為帶 ID 的空元素) taskDetailsContent.innerHTML = `

${translate( "task_detail_status_label" )}

${translate("task_detail_description_title")}

${translate("task_detail_implementation_guide_title")}


    

${translate("task_detail_verification_criteria_title")}

${translate("task_detail_dependencies_title")}

${translate("task_detail_related_files_title")}

${translate("task_detail_notes_title")}

`; // 2. 獲取對應元素並使用 textContent 安全地填充內容 const detailName = document.getElementById("detail-name"); const detailStatus = document.getElementById("detail-status"); const detailDescription = document.getElementById("detail-description"); const detailImplementationGuide = document.getElementById( "detail-implementation-guide" ); const detailVerificationCriteria = document.getElementById( "detail-verification-criteria" ); // 新增:獲取 Summary 相關元素 const detailSummarySection = document.getElementById( "detail-summary-section" ); const detailSummary = document.getElementById("detail-summary"); const detailNotes = document.getElementById("detail-notes"); const detailDependencies = document.getElementById("detail-dependencies"); const detailRelatedFiles = document.getElementById("detail-related-files"); if (detailName) detailName.textContent = task.name; if (detailStatus) { detailStatus.textContent = getStatusText(task.status); detailStatus.className = `task-status status-${task.status.replace( "_", "-" )}`; } if (detailDescription) detailDescription.textContent = task.description || translate("task_detail_no_description"); if (detailImplementationGuide) detailImplementationGuide.textContent = task.implementationGuide || translate("task_detail_no_implementation_guide"); if (detailVerificationCriteria) detailVerificationCriteria.textContent = task.verificationCriteria || translate("task_detail_no_verification_criteria"); // 新增:填充 Summary (如果存在且已完成) if (task.summary && detailSummarySection && detailSummary) { detailSummary.textContent = task.summary; detailSummarySection.style.display = "block"; // 顯示區塊 } else if (detailSummarySection) { detailSummarySection.style.display = "none"; // 隱藏區塊 } if (detailNotes) detailNotes.textContent = task.notes || translate("task_detail_no_notes"); // 3. 動態生成依賴項和相關文件 (這些可以包含安全的 HTML 結構如 span) if (detailDependencies) { const dependenciesHtml = task.dependencies && task.dependencies.length ? task.dependencies .map((dep) => { const depId = typeof dep === "object" && dep !== null && dep.taskId ? dep.taskId : dep; const depTask = tasks.find((t) => t.id === depId); // Translate the fallback text for unknown dependency const depName = depTask ? depTask.name : `${translate("task_detail_unknown_dependency")}(${depId})`; const span = document.createElement("span"); span.className = "dependency-tag"; span.dataset.id = depId; span.textContent = depName; span.onclick = () => highlightNode(depId); return span.outerHTML; }) .join("") : `${translate( "task_detail_no_dependencies" )}`; // Translate placeholder detailDependencies.innerHTML = dependenciesHtml; } if (detailRelatedFiles) { const relatedFilesHtml = task.relatedFiles && task.relatedFiles.length ? task.relatedFiles .map((file) => { const span = document.createElement("span"); span.className = "file-tag"; span.title = file.description || ""; const pathText = document.createTextNode(`${file.path} `); const small = document.createElement("small"); small.textContent = `(${file.type})`; // Type is likely technical, maybe no translation needed? span.appendChild(pathText); span.appendChild(small); return span.outerHTML; }) .join("") : `${translate( "task_detail_no_related_files" )}`; // Translate placeholder detailRelatedFiles.innerHTML = relatedFilesHtml; } // --- 原來的 innerHTML 賦值已移除 --- // 只調用高亮函數 highlightNode(taskId); // 只調用 highlightNode } // 渲染依賴關係圖 - 修改為全局視圖和 enter/update/exit 模式 function renderDependencyGraph() { if (!dependencyGraphElement || !window.d3) { console.warn("D3 or dependency graph element not found."); if (dependencyGraphElement) { // 首次或D3丟失時顯示提示,不清空已有的圖 if (!dependencyGraphElement.querySelector("svg")) { dependencyGraphElement.innerHTML = `

${translate( "error_loading_graph_d3" // Use a specific key )}

`; } } return; } // 如果沒有任務,清空圖表並顯示提示 if (tasks.length === 0) { dependencyGraphElement.innerHTML = `

${translate( "dependency_graph_placeholder_empty" )}

`; // 重置 SVG 和 simulation 變數,以便下次正確初始化 svg = null; g = null; simulation = null; isGraphInitialized = false; // << 新增:重置初始化標誌 return; } // 1. 準備節點 (Nodes) 和連結 (Links) const nodes = tasks.map((task) => ({ id: task.id, name: task.name, status: task.status, // 保留現有位置以便平滑過渡 x: simulation?.nodes().find((n) => n.id === task.id)?.x, y: simulation?.nodes().find((n) => n.id === task.id)?.y, fx: simulation?.nodes().find((n) => n.id === task.id)?.fx, // 保留固定位置 fy: simulation?.nodes().find((n) => n.id === task.id)?.fy, })); const links = []; tasks.forEach((task) => { if (task.dependencies && task.dependencies.length > 0) { task.dependencies.forEach((dep) => { const sourceId = typeof dep === "object" ? dep.taskId : dep; const targetId = task.id; if ( nodes.some((n) => n.id === sourceId) && nodes.some((n) => n.id === targetId) ) { // 確保 link 的 source/target 是 ID,以便力導向識別 links.push({ source: sourceId, target: targetId }); } else { console.warn( `Dependency link ignored: Task ${sourceId} or ${targetId} not found in task list.` ); } }); } }); // 2. D3 繪圖設置與更新 const width = dependencyGraphElement.clientWidth; const height = dependencyGraphElement.clientHeight || 400; if (!svg) { // --- 首次渲染 --- console.log("First render of dependency graph"); dependencyGraphElement.innerHTML = ""; // 清空 placeholder svg = d3 .select(dependencyGraphElement) .append("svg") .attr("viewBox", [0, 0, width, height]) .attr("preserveAspectRatio", "xMidYMid meet"); g = svg.append("g"); // 主要組,用於縮放和平移 // 添加縮放和平移 svg.call( d3.zoom().on("zoom", (event) => { g.attr("transform", event.transform); }) ); // 添加箭頭定義 g.append("defs") .append("marker") .attr("id", "arrowhead") .attr("viewBox", "-0 -5 10 10") .attr("refX", 25) .attr("refY", 0) .attr("orient", "auto") .attr("markerWidth", 8) .attr("markerHeight", 8) .append("path") .attr("d", "M0,-5L10,0L0,5") .attr("fill", "#999"); // 初始化力導向模擬 simulation = d3 .forceSimulation() // 初始化時不傳入 nodes .force( "link", d3 .forceLink() .id((d) => d.id) .distance(100) // 指定 id 訪問器 ) .force("charge", d3.forceManyBody().strength(-300)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collide", d3.forceCollide().radius(30)) .on("tick", ticked); // 綁定 tick 事件處理函數 // 添加用於存放連結和節點的組 g.append("g").attr("class", "links"); g.append("g").attr("class", "nodes"); } else { // --- 更新渲染 --- console.log("Updating dependency graph"); // 更新 SVG 尺寸和中心力 (如果窗口大小改變) svg.attr("viewBox", [0, 0, width, height]); simulation.force("center", d3.forceCenter(width / 2, height / 2)); } // --- 預先運算穩定的節點位置 --- // 複製節點和連結以進行穩定化計算 const stableNodes = [...nodes]; const stableLinks = [...links]; // 暫時創建一個模擬器來計算穩定的位置 const stableSim = d3 .forceSimulation(stableNodes) .force("link", d3.forceLink(stableLinks).id(d => d.id).distance(100)) .force("charge", d3.forceManyBody().strength(-300)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collide", d3.forceCollide().radius(30)); // 預熱模擬獲得穩定位置 for (let i = 0; i < 10; i++) { stableSim.tick(); } // 將穩定位置複製回原始節點 stableNodes.forEach((stableNode) => { const originalNode = nodes.find(n => n.id === stableNode.id); if (originalNode) { originalNode.x = stableNode.x; originalNode.y = stableNode.y; } }); // 停止臨時模擬器 stableSim.stop(); // --- 預先運算結束 --- // 3. 更新連結 (無動畫) const linkSelection = g .select(".links") // 選擇放置連結的 g 元素 .selectAll("line.link") .data( links, (d) => `${d.source.id || d.source}-${d.target.id || d.target}` ); // Key function 基於 source/target ID // Exit - 直接移除舊連結 linkSelection.exit().remove(); // Enter - 添加新連結 (無動畫) const linkEnter = linkSelection .enter() .append("line") .attr("class", "link") .attr("stroke", "#999") .attr("marker-end", "url(#arrowhead)") .attr("stroke-opacity", 0.6) .attr("stroke-width", 1.5); // 立即設置連結位置 linkEnter .attr("x1", d => d.source.x || 0) .attr("y1", d => d.source.y || 0) .attr("x2", d => d.target.x || 0) .attr("y2", d => d.target.y || 0); // 4. 更新節點 (無動畫) const nodeSelection = g .select(".nodes") // 選擇放置節點的 g 元素 .selectAll("g.node-item") .data(nodes, (d) => d.id); // 使用 ID 作為 key // Exit - 直接移除舊節點 nodeSelection.exit().remove(); // Enter - 添加新節點組 (無動畫,直接在最終位置創建) const nodeEnter = nodeSelection .enter() .append("g") .attr("class", (d) => `node-item status-${getStatusClass(d.status)}`) // 使用輔助函數設置 class .attr("data-id", (d) => d.id) // 直接使用預計算的位置,無需縮放或透明度過渡 .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`) .call(drag(simulation)); // 添加拖拽 // 添加圓形到 Enter 選擇集 nodeEnter .append("circle") .attr("r", 10) .attr("stroke", "#fff") .attr("stroke-width", 1.5) .attr("fill", getNodeColor); // 直接設置顏色 // 添加文字到 Enter 選擇集 nodeEnter .append("text") .attr("x", 15) .attr("y", 3) .text((d) => d.name) .attr("font-size", "10px") .attr("fill", "#ccc"); // 添加標題 (tooltip) 到 Enter 選擇集 nodeEnter .append("title") .text((d) => `${d.name} (${getStatusText(d.status)})`); // 添加點擊事件到 Enter 選擇集 nodeEnter.on("click", (event, d) => { selectTask(d.id); event.stopPropagation(); }); // Update - 立即更新現有節點 (無動畫) nodeSelection .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`) .attr("class", (d) => `node-item status-${getStatusClass(d.status)}`); nodeSelection .select("circle") .attr("fill", getNodeColor); // << 新增:重新定義 drag 函數 >> function drag(simulation) { function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); // 取消固定位置,讓節點可以繼續被力導引影響 (如果需要) // d.fx = null; // d.fy = null; // 或者保留固定位置直到再次拖動 } return d3 .drag() .on("start", dragstarted) .on("drag", dragged) .on("end", dragended); } // << drag 函數定義結束 >> // 5. 更新力導向模擬,但不啟動 simulation.nodes(nodes); // 更新模擬節點 simulation.force("link").links(links); // 更新模擬連結 // 注意:移除了 restart() 調用,防止刷新時的動畫跳變 } // Tick 函數: 更新節點和連結位置 function ticked() { if (!g) return; // 更新連結位置 g.select(".links") .selectAll("line.link") .attr("x1", (d) => d.source.x) .attr("y1", (d) => d.source.y) .attr("x2", (d) => d.target.x) .attr("y2", (d) => d.target.y); // 更新節點組位置 g.select(".nodes") .selectAll("g.node-item") // << 修改:添加座標後備值 >> .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`); } // 函數:根據節點數據返回顏色 (示例) function getNodeColor(nodeData) { switch (nodeData.status) { case "已完成": case "completed": return "var(--secondary-color)"; case "進行中": case "in_progress": return "var(--primary-color)"; case "待處理": case "pending": return "#f1c40f"; // 與進度條和狀態標籤一致 default: return "#7f8c8d"; // 未知狀態 } } // 輔助函數 function getStatusText(status) { switch (status) { case "pending": return translate("status_pending"); case "in_progress": return translate("status_in_progress"); case "completed": return translate("status_completed"); default: return status; } } function updateCurrentTime() { const now = new Date(); // 保留原始格式,如果需要本地化時間,可以在此處使用 translate 或其他庫 const timeString = now.toLocaleString(); // 考慮是否需要基於 currentLang 格式化 if (currentTimeElement) { // 將靜態文本和動態時間分開 const footerTextElement = currentTimeElement.parentNode.childNodes[0]; if (footerTextElement && footerTextElement.nodeType === Node.TEXT_NODE) { footerTextElement.nodeValue = translate("footer_copyright"); } currentTimeElement.textContent = timeString; } } // 更新項目進度指示器 function updateProgressIndicator() { const totalTasks = tasks.length; if (totalTasks === 0) { progressIndicator.style.display = "none"; // 沒有任務時隱藏 return; } progressIndicator.style.display = "block"; // 確保顯示 const completedTasks = tasks.filter( (task) => task.status === "completed" || task.status === "已完成" ).length; const inProgressTasks = tasks.filter( (task) => task.status === "in_progress" || task.status === "進行中" ).length; const pendingTasks = tasks.filter( (task) => task.status === "pending" || task.status === "待處理" ).length; const completedPercent = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; const inProgressPercent = totalTasks > 0 ? (inProgressTasks / totalTasks) * 100 : 0; const pendingPercent = totalTasks > 0 ? (pendingTasks / totalTasks) * 100 : 0; progressCompleted.style.width = `${completedPercent}%`; progressInProgress.style.width = `${inProgressPercent}%`; progressPending.style.width = `${pendingPercent}%`; // 更新標籤 (使用 translate) progressLabels.innerHTML = ` ${translate( "progress_completed" )}: ${completedTasks} (${completedPercent.toFixed(1)}%) ${translate( "progress_in_progress" )}: ${inProgressTasks} (${inProgressPercent.toFixed(1)}%) ${translate( "progress_pending" )}: ${pendingTasks} (${pendingPercent.toFixed(1)}%) ${translate( "progress_total" )}: ${totalTasks} `; } // 新增:渲染全局分析結果 function renderGlobalAnalysisResult() { let targetElement = document.getElementById("global-analysis-result"); // 如果元素不存在,嘗試創建並添加到合適的位置 (例如 header 或 main content 前) if (!targetElement) { targetElement = document.createElement("div"); targetElement.id = "global-analysis-result"; targetElement.className = "global-analysis-section"; // 添加樣式 class // 嘗試插入到 header 之後或 main 之前 const header = document.querySelector("header"); const mainContent = document.querySelector("main"); if (header && header.parentNode) { header.parentNode.insertBefore(targetElement, header.nextSibling); } else if (mainContent && mainContent.parentNode) { mainContent.parentNode.insertBefore(targetElement, mainContent); } else { // 作為最後手段,添加到 body 開頭 document.body.insertBefore(targetElement, document.body.firstChild); } } if (globalAnalysisResult) { targetElement.innerHTML = `

${translate( "global_analysis_title" )}

${globalAnalysisResult}
`; targetElement.style.display = "block"; } else { targetElement.style.display = "none"; // 如果沒有結果則隱藏 targetElement.innerHTML = ""; // 清空內容 } } // 新增:高亮依賴圖中的節點 function highlightNode(taskId, status = null) { if (!g || !window.d3) return; // 清除所有節點的高亮 g.select(".nodes") // 從 g 開始選擇 .selectAll("g.node-item") .classed("highlighted", false); if (!taskId) return; // 高亮選中的節點 const selectedNode = g .select(".nodes") // 從 g 開始選擇 .select(`g.node-item[data-id="${taskId}"]`); if (!selectedNode.empty()) { selectedNode.classed("highlighted", true); // 可以選擇性地將選中節點帶到最前面 // selectedNode.raise(); } } // 新增:輔助函數獲取狀態 class (應放在 ticked 函數之後,getNodeColor 之前或之後均可) function getStatusClass(status) { return status ? status.replace(/_/g, "-") : "unknown"; // 替換所有下劃線 } // 函數:啟用節點拖拽 (保持不變) // ... drag ...