diff --git a/src/public/index.html b/src/public/index.html index 72836d1..e67c189 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -46,6 +46,11 @@
${translate( @@ -563,7 +657,7 @@ function selectTask(taskId) { selectedTaskId = taskId; - // 添加新的選中狀態 + // 添加新的选中状态 const selectedElement = document.querySelector( `.task-item[data-id="${taskId}"]` ); @@ -571,7 +665,7 @@ function selectTask(taskId) { selectedElement.classList.add("selected"); } - // 獲取並顯示任務詳情 + // 获取并显示任务详情 const task = tasks.find((t) => t.id === taskId); if (!task) { @@ -737,8 +831,59 @@ function selectTask(taskId) { // --- 原來的 innerHTML 賦值已移除 --- - // 只調用高亮函數 - highlightNode(taskId); // 只調用 highlightNode + // 高亮节点并将其移动到中心 + highlightNode(taskId); + centerNode(taskId); +} + +// 新增:重置視圖功能 +function resetView() { + if (!svg || !simulation) return; + + // 添加重置動畫效果 + resetViewBtn.classList.add("resetting"); + + // 計算視圖中心 + const centerX = width / 2; + const centerY = height / 2; + + // 重置縮放和平移(使用 transform 過渡) + svg.transition() + .duration(750) + .call(zoom.transform, d3.zoomIdentity); + + // 重置所有節點位置到中心附近 + simulation.nodes().forEach(node => { + node.x = centerX + (Math.random() - 0.5) * 50; // 在中心點附近隨機分佈 + node.y = centerY + (Math.random() - 0.5) * 50; + node.fx = null; // 清除固定位置 + node.fy = null; + }); + + // 重置力導向模擬 + simulation + .force("center", d3.forceCenter(centerX, centerY)) + .alpha(1) // 完全重啟模擬 + .restart(); + + // 750ms 後移除動畫類 + setTimeout(() => { + resetViewBtn.classList.remove("resetting"); + }, 750); +} + +// 新增:初始化縮放行為 +function initZoom() { + zoom = d3.zoom() + .scaleExtent([0.1, 4]) // 設置縮放範圍 + .on("zoom", (event) => { + g.attr("transform", event.transform); + updateMinimap(); // 在縮放時更新縮略圖 + }); + + if (svg) { + svg.call(zoom); + } } // 渲染依賴關係圖 - 修改為全局視圖和 enter/update/exit 模式 @@ -746,22 +891,18 @@ 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 - )}
`; + dependencyGraphElement.innerHTML = `${translate("error_loading_graph_d3")}
`; } } return; } + updateDimensions(); + // 如果沒有任務,清空圖表並顯示提示 if (tasks.length === 0) { - dependencyGraphElement.innerHTML = `${translate( - "dependency_graph_placeholder_empty" - )}
`; - // 重置 SVG 和 simulation 變數,以便下次正確初始化 + dependencyGraphElement.innerHTML = `${translate("dependency_graph_placeholder_empty")}
`; svg = null; g = null; simulation = null; @@ -773,10 +914,9 @@ function renderDependencyGraph() { 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, // 保留固定位置 + fx: simulation?.nodes().find((n) => n.id === task.id)?.fx, fy: simulation?.nodes().find((n) => n.id === task.id)?.fy, })); @@ -786,44 +926,60 @@ function renderDependencyGraph() { 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,以便力導向識別 + if (nodes.some((n) => n.id === sourceId) && nodes.some((n) => n.id === targetId)) { links.push({ source: sourceId, target: targetId }); } else { - console.warn( - `Dependency link ignored: Task ${sourceId} or ${targetId} not found in task list.` - ); + 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 + dependencyGraphElement.innerHTML = ""; - svg = d3 - .select(dependencyGraphElement) + svg = d3.select(dependencyGraphElement) .append("svg") .attr("viewBox", [0, 0, width, height]) .attr("preserveAspectRatio", "xMidYMid meet"); - g = svg.append("g"); // 主要組,用於縮放和平移 + // 添加縮略圖背景 + const minimapSize = Math.min(width, height) * 0.2; // 縮略圖大小為主視圖的20% + const minimapMargin = 40; + + // 創建縮略圖容器 + const minimap = svg.append("g") + .attr("class", "minimap") + .attr("transform", `translate(${width - minimapSize - minimapMargin}, ${height - minimapSize - minimapMargin*(height/width)})`); - // 添加縮放和平移 - svg.call( - d3.zoom().on("zoom", (event) => { - g.attr("transform", event.transform); - }) - ); + // 添加縮略圖背景 + minimap.append("rect") + .attr("width", minimapSize) + .attr("height", minimapSize) + .attr("fill", "rgba(0, 0, 0, 0.2)") + .attr("stroke", "#666") + .attr("stroke-width", 1) + .attr("rx", 4) + .attr("ry", 4); + + // 創建縮略圖內容組 + minimap.append("g") + .attr("class", "minimap-content"); + + // 添加視口指示器 + minimap.append("rect") + .attr("class", "minimap-viewport") + .attr("fill", "none") + .attr("stroke", "var(--accent-color)") + .attr("stroke-width", 1.5) + .attr("pointer-events", "none"); + + g = svg.append("g"); + + // 初始化並添加縮放行為 + initZoom(); // 添加箭頭定義 g.append("defs") @@ -840,19 +996,39 @@ function renderDependencyGraph() { .attr("fill", "#999"); // 初始化力導向模擬 - simulation = d3 - .forceSimulation() // 初始化時不傳入 nodes - .force( - "link", - d3 - .forceLink() - .id((d) => d.id) - .distance(100) // 指定 id 訪問器 - ) + simulation = d3.forceSimulation() + .force("link", d3.forceLink().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)) - .on("tick", ticked); // 綁定 tick 事件處理函數 + // 新增:水平分布力 + .force("x", d3.forceX().x(d => { + // 計算節點的入度和出度 + const inDegree = links.filter(l => (l.target.id || l.target) === d.id).length; + const outDegree = links.filter(l => (l.source.id || l.source) === d.id).length; + + if (inDegree === 0) { + // 入度為0的節點(起始節點)靠左 + return width * 0.2; + } else if (outDegree === 0) { + // 出度為0的節點(終止節點)靠右 + return width * 0.8; + } else { + // 其他節點在中間 + return width * 0.5; + } + }).strength(0.2)) + // 新增:基于節點度數的垂直分布力 + .force("y", d3.forceY().y(height / 2).strength(d => { + // 計算節點的總度數(入度+出度) + const inDegree = links.filter(l => (l.target.id || l.target) === d.id).length; + const outDegree = links.filter(l => (l.source.id || l.source) === d.id).length; + const totalDegree = inDegree + outDegree; + + // 度數越大,力越大(基礎力0.05,每個連接增加0.03,最大0.3) + return Math.min(0.05 + totalDegree * 0.03, 0.3); + })) + .on("tick", ticked); // 添加用於存放連結和節點的組 g.append("g").attr("class", "links"); @@ -860,12 +1036,42 @@ function renderDependencyGraph() { } else { // --- 更新渲染 --- console.log("Updating dependency graph"); - // 更新 SVG 尺寸和中心力 (如果窗口大小改變) svg.attr("viewBox", [0, 0, width, height]); simulation.force("center", d3.forceCenter(width / 2, height / 2)); } - // 3. 更新連結 + // --- 預先運算穩定的節點位置 --- + // 複製節點和連結以進行穩定化計算 + 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") @@ -874,62 +1080,43 @@ function renderDependencyGraph() { (d) => `${d.source.id || d.source}-${d.target.id || d.target}` ); // Key function 基於 source/target ID - // Exit - 移除舊連結 - linkSelection - .exit() - .transition("exit") - .duration(300) - .attr("stroke-opacity", 0) - .remove(); + // Exit - 直接移除舊連結 + linkSelection.exit().remove(); - // Enter - 添加新連結 + // Enter - 添加新連結 (無動畫) const linkEnter = linkSelection .enter() .append("line") .attr("class", "link") .attr("stroke", "#999") .attr("marker-end", "url(#arrowhead)") - .attr("stroke-opacity", 0); // 初始透明 - - // Update + Enter - 更新所有連結的屬性 (合併 enter 和 update 選擇集) - const linkUpdate = linkSelection.merge(linkEnter); - - linkUpdate - .transition("update") - .duration(500) .attr("stroke-opacity", 0.6) .attr("stroke-width", 1.5); - // 4. 更新節點 + // 立即設置連結位置 + 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() - .transition("exit") - .duration(300) - .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0}) scale(0)`) // 從當前位置縮放消失 - .attr("opacity", 0) - .remove(); + // Exit - 直接移除舊節點 + nodeSelection.exit().remove(); - // Enter - 添加新節點組 + // Enter - 添加新節點組 (無動畫,直接在最終位置創建) const nodeEnter = nodeSelection .enter() .append("g") .attr("class", (d) => `node-item status-${getStatusClass(d.status)}`) // 使用輔助函數設置 class .attr("data-id", (d) => d.id) - // 初始位置:從模擬計算的位置(如果存在)或隨機位置出現,初始縮放為0 - .attr( - "transform", - (d) => - `translate(${d.x || Math.random() * width}, ${ - d.y || Math.random() * height - }) scale(0)` - ) - .attr("opacity", 0) + // 直接使用預計算的位置,無需縮放或透明度過渡 + .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`) .call(drag(simulation)); // 添加拖拽 // 添加圓形到 Enter 選擇集 @@ -937,8 +1124,8 @@ function renderDependencyGraph() { .append("circle") .attr("r", 10) .attr("stroke", "#fff") - .attr("stroke-width", 1.5); - // 顏色將在 merge 後通過 update 過渡設置 + .attr("stroke-width", 1.5) + .attr("fill", getNodeColor); // 直接設置顏色 // 添加文字到 Enter 選擇集 nodeEnter @@ -960,28 +1147,14 @@ function renderDependencyGraph() { event.stopPropagation(); }); - // Update + Enter - 合併並更新所有節點 - const nodeUpdate = nodeSelection.merge(nodeEnter); + // Update - 立即更新現有節點 (無動畫) + nodeSelection + .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`) + .attr("class", (d) => `node-item status-${getStatusClass(d.status)}`); - // 過渡到最終位置和狀態 - nodeUpdate - .transition("update") - .duration(500) - .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0}) scale(1)`) // 移動到模擬位置並恢復大小 - .attr("opacity", 1); - - // 更新節點顏色 (單獨過渡) - nodeUpdate + nodeSelection .select("circle") - .transition("color") - .duration(500) - .attr("fill", getNodeColor); // 使用已有的 getNodeColor 函數 - - // 更新節點狀態 Class (即時更新,無需過渡) - nodeUpdate.attr( - "class", - (d) => `node-item status-${getStatusClass(d.status)}` - ); + .attr("fill", getNodeColor); // << 新增:重新定義 drag 函數 >> function drag(simulation) { @@ -1012,10 +1185,24 @@ function renderDependencyGraph() { } // << drag 函數定義結束 >> - // 5. 更新力導向模擬 - simulation.nodes(nodes); // 在處理完 enter/exit 後更新模擬節點 + // 5. 更新力導向模擬,但不啟動 + simulation.nodes(nodes); // 更新模擬節點 simulation.force("link").links(links); // 更新模擬連結 - simulation.alpha(0.3).restart(); // 重新激活模擬 + + // 更新水平分布力的目標位置 + simulation.force("x").x(d => { + const inDegree = links.filter(l => (l.target.id || l.target) === d.id).length; + const outDegree = links.filter(l => (l.source.id || l.source) === d.id).length; + + if (inDegree === 0) { + return width * 0.2; + } else if (outDegree === 0) { + return width * 0.8; + } else { + return width * 0.5; + } + }); + // 注意:移除了 restart() 調用,防止刷新時的動畫跳變 } // Tick 函數: 更新節點和連結位置 @@ -1035,6 +1222,9 @@ function ticked() { .selectAll("g.node-item") // << 修改:添加座標後備值 >> .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`); + + // 更新縮略圖 + updateMinimap(); } // 函數:根據節點數據返回顏色 (示例) @@ -1191,5 +1381,84 @@ function getStatusClass(status) { return status ? status.replace(/_/g, "-") : "unknown"; // 替換所有下劃線 } +// 新增:更新寬高的函數 +function updateDimensions() { + if (dependencyGraphElement) { + width = dependencyGraphElement.clientWidth; + height = dependencyGraphElement.clientHeight || 400; + } +} + +// 添加縮略圖更新函數 +function updateMinimap() { + if (!svg || !simulation) return; + + const minimapSize = Math.min(width, height) * 0.2; + const nodes = simulation.nodes(); + const links = simulation.force("link").links(); + + // 計算當前圖的邊界 + const xExtent = d3.extent(nodes, d => d.x); + const yExtent = d3.extent(nodes, d => d.y); + const graphWidth = xExtent[1] - xExtent[0] || width; + const graphHeight = yExtent[1] - yExtent[0] || height; + const scale = Math.min(minimapSize / graphWidth, minimapSize / graphHeight) * 0.9; + + // 創建縮放函數 + const minimapX = d3.scaleLinear() + .domain([xExtent[0] - graphWidth * 0.05, xExtent[1] + graphWidth * 0.05]) + .range([0, minimapSize]); + const minimapY = d3.scaleLinear() + .domain([yExtent[0] - graphHeight * 0.05, yExtent[1] + graphHeight * 0.05]) + .range([0, minimapSize]); + + // 更新縮略圖中的連接 + const minimapContent = svg.select(".minimap-content"); + const minimapLinks = minimapContent.selectAll(".minimap-link") + .data(links); + + minimapLinks.enter() + .append("line") + .attr("class", "minimap-link") + .merge(minimapLinks) + .attr("x1", d => minimapX(d.source.x)) + .attr("y1", d => minimapY(d.source.y)) + .attr("x2", d => minimapX(d.target.x)) + .attr("y2", d => minimapY(d.target.y)) + .attr("stroke", "#999") + .attr("stroke-width", 0.5) + .attr("stroke-opacity", 0.6); + + minimapLinks.exit().remove(); + + // 更新縮略圖中的節點 + const minimapNodes = minimapContent.selectAll(".minimap-node") + .data(nodes); + + minimapNodes.enter() + .append("circle") + .attr("class", "minimap-node") + .attr("r", 2) + .merge(minimapNodes) + .attr("cx", d => minimapX(d.x)) + .attr("cy", d => minimapY(d.y)) + .attr("fill", getNodeColor); + + minimapNodes.exit().remove(); + + // 更新視口指示器 + const transform = d3.zoomTransform(svg.node()); + const viewportWidth = width / transform.k; + const viewportHeight = height / transform.k; + const viewportX = -transform.x / transform.k; + const viewportY = -transform.y / transform.k; + + svg.select(".minimap-viewport") + .attr("x", minimapX(viewportX)) + .attr("y", minimapY(viewportY)) + .attr("width", minimapX(viewportX + viewportWidth) - minimapX(viewportX)) + .attr("height", minimapY(viewportY + viewportHeight) - minimapY(viewportY)); +} + // 函數:啟用節點拖拽 (保持不變) // ... drag ... diff --git a/src/public/style.css b/src/public/style.css index f019803..a7681e1 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -123,6 +123,32 @@ main { border-bottom: 1px solid rgba(255, 255, 255, 0.05); } +.reset-view-btn { + background: none; + border: none; + color: var(--text-color); + cursor: pointer; + padding: 8px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +.reset-view-btn:hover { + background-color: rgba(255, 255, 255, 0.1); + transform: rotate(-30deg); +} + +.reset-view-btn svg { + transition: transform 0.3s ease; +} + +.reset-view-btn.resetting svg { + transform: rotate(-360deg); +} + h2 { font-size: 1.2rem; font-weight: 500;