实现功能
- 根据鼠标位置生成节点
- 根据节点位置通过鼠标拖拽生成连线
- 实现自定义线段颜色功能
- 删除节点以及连线功能
- 实现单个节点拖动功能
- 实现整条线路的拖动功能
界面如下:
主要模块介绍
绘制连线
const line = svg.selectAll(".line").data(links, d => `${d.source}-${d.target}`);
line.enter().append("line").attr("class", "line").attr("stroke", d => d.color).attr("id", d => d.objId).merge(line).attr("x1", d => nodes.find(n => n.id === d.source).x).attr("y1", d => nodes.find(n => n.id === d.source).y).attr("x2", d => nodes.find(n => n.id === d.target).x).attr("y2", d => nodes.find(n => n.id === d.target).y)});
line.exit().remove();
绘制节点
设置为四种节点类型,初始节点,杆塔,开关,电表箱
const node = svg.selectAll(".node").data(nodes, d => d.id);
node.enter()// 这里可以换成circle,加入属性r,10换成原型节点,移除xlink.append("image").attr("class", d => `node ${d.type}`).attr("width", iconSize).attr("height", iconSize).attr("xlink:href", d => {if (d.type === 'pole') return './pole.png';if (d.type === 'meterBox') return './meterBox.png';if (d.type === 'switch') return './switch.png';if (d.type === 'init') return './init.png';}).merge(node).attr("x", d => d.x - iconSize / 2).attr("y", d => d.y - iconSize / 2).call(d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended)).on("click", function(event, d) {if (isDeletingNode) {links = links.filter(link => link.source !== d.id && link.target !== d.id);nodes = nodes.filter(node => node !== d);update();// isDeletingNode = false;}else{console.log("点击了节点", d.name);}});
node.exit().remove();
添加节点
svg.on("click", function(event) {if (isAddingNode) {const coords = d3.pointer(event);const id = getUuid();const name = `Node${nodes.length + 1}`;const type = nodeType;nodes.push({ id, name, type, x: coords[0], y: coords[1] });update();}
});
完整实现如下
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Interactive Line Loss Diagram with D3.js</title><script src="https://d3js.org/d3.v6.min.js"></script> <style>.pole {fill: orange;stroke: black;stroke-width: 1px;}.meterBox {fill: green;stroke: black;stroke-width: 1px;}.gateway {fill: blue;stroke: black;stroke-width: 1px;}.switch {fill: gray;stroke: black;stroke-width: 1px;}.line {stroke-width: 2px;}.label {font-size: 12px;text-anchor: middle;}.button-container {margin-bottom: 10px;}button {margin-right: 10px;}#colorPicker {display: none;position: absolute;}</style>
</head>
<body><div class="button-container"><button id="addNodeButton" onclick="addNodeButton('pole')">新增节点</button><button id="addNodeButton" onclick="addNodeButton('meterBox')">新增表箱</button><button id="addNodeButton" onclick="addNodeButton('switch')">新增开关</button><button id="addLinkButton">连线</button><button id="deleteNodeButton">删除节点</button><button id="deleteLinkButton">删除连线</button><button onclick="logNodeButton()">打印节点</button><button onclick="selectButton()">选择节点</button><button onclick="selectAllButton()">整体拖动</button><input type="color" id="colorPicker"><buttononclick="updateColor()">更新颜色</button></div></div><svg width="800" height="600"><defs><pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse"><path d="M 20 0 L 0 0 0 20" fill="none" stroke="gray" stroke-width="0.5"/></pattern></defs><rect width="100%" height="100%" fill="url(#grid)" /></svg><script src="test.js"></script><script>const svg = d3.select("svg");// 节点以及连线的拖动行为const dragNode = d3.drag().on("start", dragNodeStarted).on("drag", draggedNode).on("end", dragNodeEnded);function dragNodeStarted(event, d) {d3.select(this).raise().classed("active", true);}function draggedNode(event, d) {d.x = event.x;d.y = event.y;d3.select(this).attr("cx", d.x).attr("cy", d.y);// 更新连线的位置svg.selectAll(".line").attr("x1", l => nodes.find(n => n.id === l.source).x).attr("y1", l => nodes.find(n => n.id === l.source).y).attr("x2", l => nodes.find(n => n.id === l.target).x).attr("y2", l => nodes.find(n => n.id === l.target).y);}function dragNodeEnded(event, d) {d3.select(this).classed("active", false);}// let nodes = [];// let links = [];let links = [];let nodes = [{"id": "ec61a8f4-73ec-46fe-adb2-4473119a006c","name": "init","type": "init","x": 119.99999237060547,"y": 43.71427917480469}];// 虚拟数据// nodes = jsonData.nodes;
// links = jsonData.links;let isAddingNode = false;let isAddingLink = false;let isDeletingNode = false;let isDeletingLink = false;let sourceNode = null;let dragLine = null;let nodeType = null;let selectedLink = null;let allDrag = false;const iconSize = 20;// 绘制节点和连线function update() {// 绘制连线const line = svg.selectAll(".line").data(links, d => `${d.source}-${d.target}`);line.enter().append("line").attr("class", "line").attr("stroke", d => d.color).attr("id", d => d.objId).merge(line).attr("x1", d => nodes.find(n => n.id === d.source).x).attr("y1", d => nodes.find(n => n.id === d.source).y).attr("x2", d => nodes.find(n => n.id === d.target).x).attr("y2", d => nodes.find(n => n.id === d.target).y).on("click", function(event, d) {if (isDeletingLink) {links = links.filter(link => link !== d);update();// isDeletingLink = false;} else{selectedLink = d;const colorPicker = document.getElementById("colorPicker");colorPicker.style.display = "block";colorPicker.style.left = `${event.pageX}px`;colorPicker.style.top = `${event.pageY}px`;colorPicker.value = d.color || "#000000";colorPicker.focus();}});line.exit().remove();// 绘制节点const node = svg.selectAll(".node").data(nodes, d => d.id);node.enter().append("image").attr("class", d => `node ${d.type}`).attr("width", iconSize).attr("height", iconSize).attr("xlink:href", d => {if (d.type === 'pole') return './pole.png';if (d.type === 'meterBox') return './meterBox.png';if (d.type === 'switch') return './switch.png';if (d.type === 'init') return './init.png';}).merge(node).attr("x", d => d.x - iconSize / 2).attr("y", d => d.y - iconSize / 2).call(d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended)).on("click", function(event, d) {if (isDeletingNode) {links = links.filter(link => link.source !== d.id && link.target !== d.id);nodes = nodes.filter(node => node !== d);update();// isDeletingNode = false;}else{console.log("点击了节点", d.name);}});node.exit().remove();// 添加标签const label = svg.selectAll(".label").data(nodes, d => d.id);label.enter().append("text").attr("class", "label").merge(label).attr("x", d => d.x).attr("y", d => d.y - iconSize/2 - 5)// .text(d => d.name);// .text(d => d.type);label.exit().remove();}// 添加新节点svg.on("click", function(event) {if (isAddingNode) {const coords = d3.pointer(event);const id = getUuid();const name = `Node${nodes.length + 1}`;// const type = nodes.length % 3 === 0 ? 'tower' : (nodes.length % 3 === 1 ? 'station' : 'gateway');const type = nodeType;nodes.push({ id, name, type, x: coords[0], y: coords[1] });update();// isAddingNode = false; // Reset the flag}});function dragstarted(event, d) {if (isAddingLink) {sourceNode = d;let id = getUuid();sourceNode.objId = id;dragLine = svg.append("line").attr("class", "dragLine").attr("stroke", "gray").attr("stroke-width", 2).attr("x1", d.x).attr("y1", d.y).attr("x2", d.x).attr("y2", d.y).attr("id", id);}if (!isAddingNode && !isAddingLink && !isDeletingNode && !isDeletingLink) {d3.select(this).raise().classed("active", true);}}function dragged(event, d) {if (dragLine) {dragLine.attr("x2", event.x).attr("y2", event.y);}if (!isAddingNode && !isAddingLink && !isDeletingNode && !isDeletingLink) {d.x = event.x;d.y = event.y;d3.select(this).attr("x", d.x - iconSize / 2).attr("y", d.y - iconSize / 2);// 更新连线的位置svg.selectAll(".line").attr("x1", l => nodes.find(n => n.id === l.source).x).attr("y1", l => nodes.find(n => n.id === l.source).y).attr("x2", l => nodes.find(n => n.id === l.target).x).attr("y2", l => nodes.find(n => n.id === l.target).y);}if (allDrag) {const dx = event.dx;const dy = event.dy;// 更新被拖动节点的位置d.x += dx;d.y += dy;d3.select(this).attr("x", d.x - iconSize / 2).attr("y", d.y - iconSize / 2);// 递归更新与当前节点相连的所有节点和连线的位置const visitedNodes = new Set();updateConnectedNodes(d, dx, dy, visitedNodes);// 更新所有连线的位置svg.selectAll(".line").attr("x1", l => nodes.find(n => n.id === l.source).x).attr("y1", l => nodes.find(n => n.id === l.source).y).attr("x2", l => nodes.find(n => n.id === l.target).x).attr("y2", l => nodes.find(n => n.id === l.target).y);// 更新所有节点的位置svg.selectAll(".node").attr("x", n => n.x - iconSize / 2).attr("y", n => n.y - iconSize / 2);}}function dragended(event, d) {if (dragLine) {dragLine.remove();dragLine = null;const targetNode = nodes.find(n => Math.hypot(n.x - event.x, n.y - event.y) < 10);if (targetNode && targetNode !== sourceNode) {const color = determineLinkColor(sourceNode, targetNode);links.push({objId:sourceNode.id, source: sourceNode.id, target: targetNode.id, color: color });update();}sourceNode = null;// isAddingLink = false; // Reset the flag}if (!isAddingNode && !isAddingLink && !isDeletingNode && !isDeletingLink) {d3.select(this).classed("active", false);}}/*** 递归更新与当前节点相连的所有节点的位置* @param {object} node - 当前节点* @param {number} dx - x方向的移动距离* @param {number} dy - y方向的移动距离* @param {Set} visitedNodes - 已访问的节点集合*/function updateConnectedNodes(node, dx, dy, visitedNodes) {visitedNodes.add(node.id);links.forEach(link => {if (link.source === node.id && !visitedNodes.has(link.target)) {const targetNode = nodes.find(n => n.id === link.target);targetNode.x += dx;targetNode.y += dy;updateConnectedNodes(targetNode, dx, dy, visitedNodes);} else if (link.target === node.id && !visitedNodes.has(link.source)) {const sourceNode = nodes.find(n => n.id === link.source);sourceNode.x += dx;sourceNode.y += dy;updateConnectedNodes(sourceNode, dx, dy, visitedNodes);}});}function determineLinkColor(sourceNode, targetNode) {if (sourceNode.type === 'pole' && targetNode.type === 'pole') {return '#1e90ff';} else if (sourceNode.type === 'station' && targetNode.type === 'station') {return 'green';} else if (sourceNode.type === 'gateway' && targetNode.type === 'gateway') {return 'blue';} else {return '#7f8c8d';}}// 按钮事件处理function addNodeButton(type) {nodeType = type;isAddingNode = true;isAddingLink = false;isDeletingNode = false;isDeletingLink = false;allDrag = false;};d3.select("#addLinkButton").on("click", function() {isAddingLink = true;isAddingNode = false;isDeletingNode = false;isDeletingLink = false;allDrag = false;});d3.select("#deleteNodeButton").on("click", function() {isDeletingNode = true;isAddingNode = false;isAddingLink = false;isDeletingLink = false;allDrag = false;});d3.select("#deleteLinkButton").on("click", function() {isDeletingLink = true;isAddingNode = false;isAddingLink = false;isDeletingNode = false;allDrag = false;});// 整体拖动function selectAllButton() {isDeletingLink = false;isAddingNode = false;isAddingLink = false;isDeletingNode = false;allDrag = true;}update();function logNodeButton() {console.log("Node type:", nodeType);console.log("Adding node:", isAddingNode);console.log("Adding link:", isAddingLink);console.log("Deleting node:", isDeletingNode);console.log("Deleting link:", isDeletingLink);console.log("Nodes:", nodes);console.log("Links:", links);}function getUuid () {if (typeof crypto === 'object') {if (typeof crypto.randomUUID === 'function') {return crypto.randomUUID();}if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {const callback = (c) => {const num = Number(c);return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16);};return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, callback);}}let timestamp = new Date().getTime();let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0;return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {let random = Math.random() * 16;if (timestamp > 0) {random = (timestamp + random) % 16 | 0;timestamp = Math.floor(timestamp / 16);} else {random = (perforNow + random) % 16 | 0;perforNow = Math.floor(perforNow / 16);}return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16);});};function selectButton() {isDeletingLink = false;isAddingNode = false;isAddingLink = false;isDeletingNode = false;allDrag = false;}/*** 更新指定线段的stroke属性* @param {string} sourceId - 连线起点节点的ID* @param {string} targetId - 连线终点节点的ID* @param {string} color - 新的颜色值*/function updateLinkStroke(sourceId, targetId, color) {// 查找指定起点和终点的连线const link = links.find(l => l.source === sourceId && l.target === targetId);if (link) {// 更新连线的颜色属性link.color = color;// 使用D3.js选择器选择指定的连线,并更新其stroke属性d3.selectAll(".line").filter(d => d.source === sourceId && d.target === targetId).attr("stroke", color);}}// 颜色选择器事件处理function updateColor() {const colorPicker = document.getElementById("colorPicker");selectedLink.color = colorPicker.value;updateLinkStroke(selectedLink.source, selectedLink.target, selectedLink.color);colorPicker.style.display = 'none';}</script>
</body>
</html>