|
@@ -0,0 +1,456 @@
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="zh-TW">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+ <title>關鍵字關聯網路圖</title>
|
|
|
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
|
|
|
+ <style>
|
|
|
+ body {
|
|
|
+ margin: 0;
|
|
|
+ padding: 20px;
|
|
|
+ font-family: 'Microsoft JhengHei', 'SimHei', sans-serif;
|
|
|
+ background: #000;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .container {
|
|
|
+ max-width: 1600px;
|
|
|
+ margin: 0 auto;
|
|
|
+ background: #111;
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 20px;
|
|
|
+ box-shadow: 0 4px 15px rgba(255, 255, 255, 0.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ h1 {
|
|
|
+ text-align: center;
|
|
|
+ color: #fff;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ #network {
|
|
|
+ border: 1px solid #444;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #000;
|
|
|
+ }
|
|
|
+
|
|
|
+ .node {
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ }
|
|
|
+
|
|
|
+ .node:hover {
|
|
|
+ stroke: #ff6b35;
|
|
|
+ stroke-width: 3px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .link {
|
|
|
+ stroke: #fff;
|
|
|
+ stroke-opacity: 0.6;
|
|
|
+ stroke-width: 1px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .node-label {
|
|
|
+ font-family: 'Microsoft JhengHei', 'SimHei', sans-serif;
|
|
|
+ font-size: 24px;
|
|
|
+ text-anchor: middle;
|
|
|
+ pointer-events: none;
|
|
|
+ fill: #fff;
|
|
|
+ font-weight: bold;
|
|
|
+ cursor: default;
|
|
|
+ }
|
|
|
+
|
|
|
+ .node-label-bg {
|
|
|
+ fill: rgba(0, 0, 0, 0.7);
|
|
|
+ stroke: rgba(255, 255, 255, 0.3);
|
|
|
+ stroke-width: 1px;
|
|
|
+ rx: 3;
|
|
|
+ ry: 3;
|
|
|
+ }
|
|
|
+
|
|
|
+ .controls {
|
|
|
+ text-align: center;
|
|
|
+ margin-bottom: 15px;
|
|
|
+ }
|
|
|
+
|
|
|
+ button {
|
|
|
+ background: #4CAF50;
|
|
|
+ color: white;
|
|
|
+ border: none;
|
|
|
+ padding: 8px 16px;
|
|
|
+ margin: 0 5px;
|
|
|
+ border-radius: 5px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-family: 'Microsoft JhengHei', sans-serif;
|
|
|
+ }
|
|
|
+
|
|
|
+ button:hover {
|
|
|
+ background: #45a049;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tooltip {
|
|
|
+ position: absolute;
|
|
|
+ background: rgba(0, 0, 0, 0.9);
|
|
|
+ color: white;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-radius: 5px;
|
|
|
+ pointer-events: none;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.3s;
|
|
|
+ font-size: 12px;
|
|
|
+ z-index: 1000;
|
|
|
+ max-width: 200px;
|
|
|
+ word-wrap: break-word;
|
|
|
+ }
|
|
|
+
|
|
|
+ .legend {
|
|
|
+ position: absolute;
|
|
|
+ top: 80px;
|
|
|
+ right: 30px;
|
|
|
+ background: rgba(17, 17, 17, 0.9);
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #444;
|
|
|
+ }
|
|
|
+
|
|
|
+ .legend-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 5px;
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .legend-color {
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+ border-radius: 50%;
|
|
|
+ margin-right: 8px;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="container">
|
|
|
+ <h1>自行車花鼓關鍵字關聯網路圖</h1>
|
|
|
+
|
|
|
+ <div class="controls">
|
|
|
+ <button onclick="restartSimulation()">重新排列</button>
|
|
|
+ <button onclick="centerNetwork()">置中顯示</button>
|
|
|
+ <button onclick="toggleLabels()">切換標籤顯示</button>
|
|
|
+ <button onclick="adjustSpacing()">調整間距</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="legend">
|
|
|
+ <div class="legend-item">
|
|
|
+ <div class="legend-color" style="background: #ff4757;"></div>
|
|
|
+ <span>核心關鍵字 (5+連接)</span>
|
|
|
+ </div>
|
|
|
+ <div class="legend-item">
|
|
|
+ <div class="legend-color" style="background: #ff6348;"></div>
|
|
|
+ <span>重要關鍵字 (4連接)</span>
|
|
|
+ </div>
|
|
|
+ <div class="legend-item">
|
|
|
+ <div class="legend-color" style="background: #ffa502;"></div>
|
|
|
+ <span>一般關鍵字 (2-3連接)</span>
|
|
|
+ </div>
|
|
|
+ <div class="legend-item">
|
|
|
+ <div class="legend-color" style="background: #70a1ff;"></div>
|
|
|
+ <span>末端關鍵字 (1連接)</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <svg id="network" width="100%" height="1500" viewBox="-200 -200 2000 1900"></svg>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="tooltip" id="tooltip"></div>
|
|
|
+
|
|
|
+ <script>
|
|
|
+ // 關鍵字關聯資料
|
|
|
+const edgeList = [
|
|
|
+ ['微風松高頂級會所', '微風松高高級餐廳推薦'],
|
|
|
+['微風松高頂級會所', '台北頂級會員制餐廳'],
|
|
|
+['微風松高頂級會所', '預約制高級會所台北'],
|
|
|
+['微風松高頂級會所', '台北包廂餐廳微風松高'],
|
|
|
+['微風松高頂級會所', '微風松高隱密接待餐廳'],
|
|
|
+
|
|
|
+
|
|
|
+ // sealed bearing hub
|
|
|
+ ['微風松高高級餐廳推薦', '微風松高米其林餐廳'],
|
|
|
+['微風松高高級餐廳推薦', '微風松高高級火鍋'],
|
|
|
+['微風松高高級餐廳推薦', '微風松高中餐推薦'],
|
|
|
+['微風松高高級餐廳推薦', '微風松高日料推薦'],
|
|
|
+['微風松高高級餐廳推薦', '微風松高法式料理餐廳'],
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // sealed cartridge bearings 延伸
|
|
|
+['台北頂級會員制餐廳', '頂級餐飲會所推薦'],
|
|
|
+['台北頂級會員制餐廳', '會員專屬高級餐廳台北'],
|
|
|
+['台北頂級會員制餐廳', '預約制會員餐廳松高'],
|
|
|
+['台北頂級會員制餐廳', '會員制米其林餐廳推薦'],
|
|
|
+['台北頂級會員制餐廳', '台北隱藏版會員會所'],
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // mountain bike sealed hub 延伸
|
|
|
+ ['預約制高級會所台北', '台北預約困難餐廳'],
|
|
|
+['預約制高級會所台北', '預約制高級火鍋推薦'],
|
|
|
+['預約制高級會所台北', '預約制日料餐廳台北'],
|
|
|
+['預約制高級會所台北', '私廚等級餐廳推薦'],
|
|
|
+['預約制高級會所台北', '高階客戶接待會所'],
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // sealed rear hub 延伸
|
|
|
+ ['台北包廂餐廳微風松高', '微風松高家庭聚餐包廂'],
|
|
|
+['台北包廂餐廳微風松高', '微風松高高級包廂中餐'],
|
|
|
+['台北包廂餐廳微風松高', '松高適合聚餐的高級餐廳'],
|
|
|
+['台北包廂餐廳微風松高', '松高隱密會客包廂'],
|
|
|
+['台北包廂餐廳微風松高', '包廂火鍋推薦松高'],
|
|
|
+
|
|
|
+
|
|
|
+ // lightweight sealed hub 延伸
|
|
|
+ ['微風松高隱密接待餐廳', '高階接待餐廳推薦'],
|
|
|
+['微風松高隱密接待餐廳', '外賓接待餐廳台北'],
|
|
|
+['微風松高隱密接待餐廳', '商務聚餐隱密空間'],
|
|
|
+['微風松高隱密接待餐廳', '高級安靜餐廳台北'],
|
|
|
+['微風松高隱密接待餐廳', '企業高層聚餐餐廳'],
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // sealed bearing maintenance 延伸
|
|
|
+
|
|
|
+];
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ // 處理資料
|
|
|
+ function processData() {
|
|
|
+ const nodes = new Map();
|
|
|
+ const links = [];
|
|
|
+
|
|
|
+ edgeList.forEach(([source, target]) => {
|
|
|
+ if (!nodes.has(source)) {
|
|
|
+ nodes.set(source, { id: source, degree: 0 });
|
|
|
+ }
|
|
|
+ if (!nodes.has(target)) {
|
|
|
+ nodes.set(target, { id: target, degree: 0 });
|
|
|
+ }
|
|
|
+ nodes.get(source).degree++;
|
|
|
+ nodes.get(target).degree++;
|
|
|
+ links.push({ source, target });
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ nodes: Array.from(nodes.values()),
|
|
|
+ links: links
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 獲取節點顏色(依據重要性)
|
|
|
+ function getNodeColor(degree) {
|
|
|
+ if (degree >= 5) return '#ff4757'; // 紅色 - 非常重要
|
|
|
+ if (degree >= 4) return '#ff6348'; // 橙紅色 - 很重要
|
|
|
+ if (degree >= 2) return '#ffa502'; // 橙色 - 重要
|
|
|
+ return '#70a1ff'; // 藍色 - 較少連接
|
|
|
+ }
|
|
|
+
|
|
|
+ // 處理標籤文字換行
|
|
|
+ function wrapLabel(text, maxWidth = 20) {
|
|
|
+ const words = text.split(' ');
|
|
|
+ const lines = [];
|
|
|
+ let currentLine = '';
|
|
|
+
|
|
|
+ for (const word of words) {
|
|
|
+ const testLine = currentLine ? currentLine + ' ' + word : word;
|
|
|
+ if (testLine.length <= maxWidth) {
|
|
|
+ currentLine = testLine;
|
|
|
+ } else {
|
|
|
+ if (currentLine) {
|
|
|
+ lines.push(currentLine);
|
|
|
+ currentLine = word;
|
|
|
+ } else {
|
|
|
+ // 如果單個詞太長,強制換行
|
|
|
+ lines.push(word);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (currentLine) {
|
|
|
+ lines.push(currentLine);
|
|
|
+ }
|
|
|
+
|
|
|
+ return lines;
|
|
|
+ }
|
|
|
+
|
|
|
+ let simulation;
|
|
|
+ let svg, link, node, label, labelGroups;
|
|
|
+ let labelsVisible = true;
|
|
|
+ let currentSpacing = 150;
|
|
|
+
|
|
|
+ // 繪製網路圖
|
|
|
+ function drawNetwork() {
|
|
|
+ const data = processData();
|
|
|
+
|
|
|
+ svg = d3.select("#network");
|
|
|
+ const width = parseInt(svg.style("width"));
|
|
|
+ const height = 1500;
|
|
|
+
|
|
|
+ svg.selectAll("*").remove();
|
|
|
+
|
|
|
+ // 創建力導向模擬
|
|
|
+ simulation = d3.forceSimulation(data.nodes)
|
|
|
+ .force("link", d3.forceLink(data.links).id(d => d.id).distance(200)) // 原本是 currentSpacing
|
|
|
+ .force("charge", d3.forceManyBody().strength(-1000)) // 原本是 -800
|
|
|
+ .force("center", d3.forceCenter(width / 2, height / 2))
|
|
|
+ .force("collision", d3.forceCollide().radius(d => Math.max(15, Math.sqrt(d.degree) * 6) + 35)); // 提高避免重疊
|
|
|
+
|
|
|
+
|
|
|
+ // 創建連線
|
|
|
+ link = svg.append("g")
|
|
|
+ .selectAll("line")
|
|
|
+ .data(data.links)
|
|
|
+ .enter().append("line")
|
|
|
+ .attr("class", "link");
|
|
|
+
|
|
|
+ // 創建節點
|
|
|
+ node = svg.append("g")
|
|
|
+ .selectAll("circle")
|
|
|
+ .data(data.nodes)
|
|
|
+ .enter().append("circle")
|
|
|
+ .attr("class", "node")
|
|
|
+ .attr("r", d => Math.max(8, Math.sqrt(d.degree) * 4))
|
|
|
+ .attr("fill", d => getNodeColor(d.degree))
|
|
|
+ .attr("stroke", "#fff")
|
|
|
+ .attr("stroke-width", 2)
|
|
|
+ .call(d3.drag()
|
|
|
+ .on("start", dragstarted)
|
|
|
+ .on("drag", dragged)
|
|
|
+ .on("end", dragended));
|
|
|
+
|
|
|
+ // 創建標籤組
|
|
|
+ labelGroups = svg.append("g")
|
|
|
+ .selectAll("g")
|
|
|
+ .data(data.nodes)
|
|
|
+ .enter().append("g")
|
|
|
+ .attr("class", "label-group");
|
|
|
+
|
|
|
+ // 為每個標籤組添加多行文字
|
|
|
+ labelGroups.each(function(d) {
|
|
|
+ const lines = wrapLabel(d.id);
|
|
|
+ const group = d3.select(this);
|
|
|
+
|
|
|
+ lines.forEach((line, i) => {
|
|
|
+ group.append("text")
|
|
|
+ .attr("class", "node-label")
|
|
|
+ .attr("dy", 50 + i * 20) // 移到節點下方
|
|
|
+ .text(line);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ label = labelGroups.selectAll("text");
|
|
|
+
|
|
|
+ // 滑鼠事件
|
|
|
+ const tooltip = d3.select("#tooltip");
|
|
|
+
|
|
|
+ node
|
|
|
+ .on("mouseover", function(event, d) {
|
|
|
+ tooltip.transition()
|
|
|
+ .duration(200)
|
|
|
+ .style("opacity", 1);
|
|
|
+ tooltip.html(`
|
|
|
+ <strong>${d.id}</strong><br/>
|
|
|
+ 連接數: ${d.degree}<br/>
|
|
|
+ 類型: ${d.degree >= 5 ? '核心關鍵字' : d.degree >= 4 ? '重要關鍵字' : d.degree >= 2 ? '一般關鍵字' : '末端關鍵字'}
|
|
|
+ `)
|
|
|
+ .style("left", (event.pageX + 10) + "px")
|
|
|
+ .style("top", (event.pageY - 28) + "px");
|
|
|
+ })
|
|
|
+ .on("mouseout", function(d) {
|
|
|
+ tooltip.transition()
|
|
|
+ .duration(500)
|
|
|
+ .style("opacity", 0);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 模擬更新
|
|
|
+ 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)
|
|
|
+ .attr("cy", d => d.y);
|
|
|
+
|
|
|
+ labelGroups
|
|
|
+ .attr("transform", d => `translate(${d.x}, ${d.y})`);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 拖拽功能
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重新開始模擬
|
|
|
+ function restartSimulation() {
|
|
|
+ if (simulation) {
|
|
|
+ simulation.alpha(1).restart();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 置中網路
|
|
|
+ function centerNetwork() {
|
|
|
+ const width = parseInt(svg.style("width"));
|
|
|
+ const height = 1500;
|
|
|
+
|
|
|
+ if (simulation) {
|
|
|
+ simulation.force("center", d3.forceCenter(width / 2, height / 2));
|
|
|
+ simulation.alpha(0.3).restart();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 切換標籤顯示
|
|
|
+ function toggleLabels() {
|
|
|
+ labelsVisible = !labelsVisible;
|
|
|
+ const opacity = labelsVisible ? 1 : 0;
|
|
|
+
|
|
|
+ if (label) {
|
|
|
+ label.transition()
|
|
|
+ .duration(300)
|
|
|
+ .style("opacity", opacity);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 調整節點間距
|
|
|
+ function adjustSpacing() {
|
|
|
+ currentSpacing = currentSpacing === 150 ? 250 : 150;
|
|
|
+
|
|
|
+ if (simulation) {
|
|
|
+ simulation.force("link").distance(currentSpacing);
|
|
|
+ simulation.force("collision").radius(d => Math.max(15, Math.sqrt(d.degree) * 6) + (currentSpacing === 250 ? 35 : 25));
|
|
|
+ simulation.alpha(0.5).restart();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化
|
|
|
+ drawNetwork();
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>
|