▍程式碼
<div>
<h1>知識圖譜視覺化</h1>
<p>本圖譜顯示題目、標籤及來源書籍之間的關聯。節點大小代表重要性 (與錯誤次數相關)。</p>
<!-- 知識圖譜 -->
<div class="graph-container">
</div>
</div>
<!-- 引入 D3.js -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
// 知識圖譜腳本
async function loadKnowledgeGraph() {
const spinner = document.getElementById('loading-spinner');
const container = document.querySelector('.graph-container');
container.innerHTML = '';
spinner.style.display = 'block';
// 響應式
const width = container.clientWidth;
const height = container.clientHeight;
if (width === 0 || height === 0) {
console.warn("Graph container is not visible or has zero dimensions. Retrying in 500ms.");
setTimeout(loadKnowledgeGraph, 500);
return;
}
try {
// 呼叫路由是
const response = await fetch('/api/knowledge_graph');
let graph;
if (!response.ok) {
const errorText = await response.text();
throw new Error(`伺服器錯誤 ${response.status}: ${errorText.substring(0, 50)}...`);
}
graph = await response.json();
if (!graph.nodes || graph.nodes.length === 0) {
container.innerHTML = '<p class="text-center p-5 text-muted">無知識圖譜數據可顯示。</p>';
return;
}
// 繪圖
drawGraph(graph, width, height);
} catch (error) {
console.error("載入知識圖譜失敗:", error);
container.innerHTML = `<p class="text-center p-5 text-danger">錯誤:${error.message}</p>`;
} finally {
spinner.style.display = 'none';
}
}
function drawGraph(graph, width, height) {
d3.select(".graph-container svg").remove();
const svg = d3.select(".graph-container")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", width)
.attr("height", height);
const g = svg.append("g");
svg.call(d3.zoom()
.scaleExtent([0.1, 4])
.on("zoom", function ({ transform }) {
g.attr("transform", transform);
}))
.on("dblclick.zoom", null);
const color = d3.scaleOrdinal()
.domain(["question", "tag", "book"])
.range(["#4E79A7", "#F28E2B", "#59A14F"]);
const simulation = d3.forceSimulation(graph.nodes)
.force("link", d3.forceLink(graph.links).id(d => d.id).distance(100).strength(d => d.value * 0.1))
.force("charge", d3.forceManyBody().strength(-1000)) // 節點間排斥力
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(d => d.size * 2)); // 避免重疊
// 繪製連結
const link = g.append("g")
.attr("class", "links")
.attr("stroke", "#999")
.selectAll("line")
.data(graph.links)
.enter().append("line")
.attr("class", "link")
.attr("stroke-width", d => Math.sqrt(d.value) * 1.5);
// 繪製節點
const node = g.append("g")
.attr("class", "nodes")
.selectAll("circle")
.data(graph.nodes)
.enter().append("circle")
.attr("class", d => `node group-${d.group}`)
.attr("r", d => d.size)
.attr("fill", d => color(d.type))
.call(drag(simulation));
// 繪製標籤
const label = g.append("g")
.attr("class", "labels")
.selectAll("text")
.data(graph.nodes)
.enter().append("text")
.attr("x", d => d.size + 2) // 稍微偏移圓圈
.attr("y", 3)
.text(d => d.name)
.style("font-size", d => d.type === 'book' ? '14px' : '10px')
.style("fill", "#333")
.style("pointer-events", "none"); // 確保標籤不會阻擋點擊
// 繪製浮動提示
const tooltip = d3.select("body").append("div")
.attr("class", "tooltip");
// 添加互動事件
node.on("mouseover", function (event, d) {
tooltip.transition()
.duration(200)
.style("opacity", .9);
let tooltipContent = `<strong>${d.name}</strong>`;
if (d.type === 'question' && d.error_count) {
tooltipContent += `<br>錯誤次數: ${d.error_count}`;
}
tooltip.html(tooltipContent)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 28) + "px");
const connectedNodes = getConnectedNodes(d, graph.links);
node.style("opacity", n => connectedNodes.includes(n.id) || n.id === d.id ? 1 : 0.1);
link.style("stroke-opacity", l => l.source.id === d.id || l.target.id === d.id ? 1 : 0.1)
.style("stroke", l => l.source.id === d.id || l.target.id === d.id ? "#333" : "#999");
label.style("opacity", n => connectedNodes.includes(n.id) || n.id === d.id ? 1 : 0.1);
}).on("mouseout", function (d) {
tooltip.transition()
.duration(500)
.style("opacity", 0);
node.style("opacity", 1);
link.style("stroke-opacity", 0.6).style("stroke", "#999");
label.style("opacity", 1);
});
simulation.on("tick", () => {
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);
// 限制邊界
node
.attr("cx", d => d.x = Math.max(d.size, Math.min(width - d.size, d.x)))
.attr("cy", d => d.y = Math.max(d.size, Math.min(height - d.size, d.y)));
label
.attr("x", d => d.x + d.size + 2)
.attr("y", d => d.y + 3);
});
}
// 拖曳
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);
}
function getConnectedNodes(d, links) {
const connected = new Set();
links.forEach(link => {
if (link.source.id === d.id) {
connected.add(link.target.id);
} else if (link.target.id === d.id) {
connected.add(link.source.id);
}
});
return Array.from(connected);
}
document.addEventListener('DOMContentLoaded', loadKnowledgeGraph);
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(loadKnowledgeGraph, 250); // 延遲重繪以優化性能
});
</script>