iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0

▍程式碼

<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>

上一篇
DAY25 - 預測考題
系列文
打造你的數位圖書館:從雜亂檔案到個人化知識庫26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言