使用solidJS实现力导向图
力导向图参考:https://observablehq.com/@d3/force-directed-graph/2
原始代码与分析
chart = {
// Specify the dimensions of the chart.
const width = 928;
const height = 600;
// Specify the color scale.
const color = d3.scaleOrdinal(d3.schemeCategory10);
// The force simulation mutates links and nodes, so create a copy
// so that re-evaluating this cell produces the same result.
const links = data.links.map(d => ({...d}));
const nodes = data.nodes.map(d => ({...d}));
// Create a simulation with several forces.
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(width / 2, height / 2))
.on("tick", ticked);
// Create the SVG container.
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
// Add a line for each link, and a circle for each node.
const link = svg.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll()
.data(links)
.join("line")
.attr("stroke-width", d => Math.sqrt(d.value));
const node = svg.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll()
.data(nodes)
.join("circle")
.attr("r", 5)
.attr("fill", d => color(d.group));
node.append("title")
.text(d => d.id);
// Add a drag behavior.
node.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
// Set the position attributes of links and nodes each time the simulation ticks.
function ticked() {
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);
}
// Reheat the simulation when drag starts, and fix the subject position.
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
// Update the subject (dragged node) position during drag.
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
// Restore the target alpha so the simulation cools after dragging ends.
// Unfix the subject position now that it’s no longer being dragged.
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
// When this cell is re-run, stop the previous simulation. (This doesn’t
// really matter since the target alpha is zero and the simulation will
// stop naturally, but it’s a good practice.)
invalidation.then(() => simulation.stop());
return svg.node();
}
GPT分析
这段代码使用D3.js库创建了一个力导向图(force-directed graph)。力导向图是一种通过模拟物理力的方式来自动布局图形节点的图表。下面是对这段代码的详细分析:
-
设置图表的尺寸:
const width = 928; const height = 600;
图表的宽度被设置为928像素,高度设置为600像素。
-
设置颜色比例尺:
const color = d3.scaleOrdinal(d3.schemeCategory10);
使用D3内置的颜色比例尺
d3.schemeCategory10
来为不同的节点分组设置颜色。 -
复制链接和节点的数据:
const links = data.links.map(d => ({...d})); const nodes = data.nodes.map(d => ({...d}));
为了防止原始数据被
d3.forceSimulation
改变,创建链接和节点数据的副本。 -
创建力导向仿真:
const simulation = d3.forceSimulation(nodes) .force("link", d3.forceLink(links).id(d => d.id)) .force("charge", d3.forceManyBody()) .force("center", d3.forceCenter(width / 2, height / 2)) .on("tick", ticked);
创建一个力导向仿真,包括以下几个力:
forceLink
:链接力,用于连接节点。forceManyBody
:多体力,用于节点之间的相互作用。forceCenter
:中心力,用于将图形居中。
每次仿真“tick”时调用ticked
函数。
-
创建SVG容器:
const svg = d3.create("svg") .attr("width", width) .attr("height", height) .attr("viewBox", [0, 0, width, height]) .attr("style", "max-width: 100%; height: auto;");
创建一个SVG元素并设置其属性。
-
添加链接线和节点圆圈:
const link = svg.append("g") .attr("stroke", "#999") .attr("stroke-opacity", 0.6) .selectAll() .data(links) .join("line") .attr("stroke-width", d => Math.sqrt(d.value)); const node = svg.append("g") .attr("stroke", "#fff") .attr("stroke-width", 1.5) .selectAll() .data(nodes) .join("circle") .attr("r", 5) .attr("fill", d => color(d.group));
添加链接线,设置其颜色、透明度和线宽。然后添加节点圆圈,设置其半径、填充颜色和边框。
-
添加节点拖拽行为:
node.call(d3.drag() .on("start", dragstarted) .on("drag", dragged) .on("end", dragended));
为节点添加拖拽行为,定义拖拽开始、进行中和结束时的回调函数。
-
每次仿真“tick”时更新链接和节点的位置:
function ticked() { 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); }
每次仿真“tick”时,更新链接线和节点圆圈的位置。
-
拖拽行为的回调函数:
function dragstarted(event) { if (!event.active) simulation.alphaTarget(0.3).restart(); event.subject.fx = event.subject.x; event.subject.fy = event.subject.y; } function dragged(event) { event.subject.fx = event.x; event.subject.fy = event.y; } function dragended(event) { if (!event.active) simulation.alphaTarget(0); event.subject.fx = null; event.subject.fy = null; }
dragstarted
函数在拖拽开始时重新加热仿真并固定节点位置,dragged
函数在拖拽过程中更新节点位置,dragended
函数在拖拽结束时恢复仿真目标alpha并解除节点位置的固定。 -
停止之前的仿真:
invalidation.then(() => simulation.stop());
当该单元格重新运行时,停止之前的仿真。
-
返回SVG节点:
return svg.node();
总的来说,这段代码定义了一个D3.js力导向图,其中包括节点和链接的绘制、力仿真的设置以及拖拽行为的实现。
使用SolidJS和TS实现以上功能(GPT)
要使用SolidJS和TypeScript实现力导向图,我们需要以下步骤:
- 设置项目:初始化SolidJS项目并配置TypeScript支持。
- 定义力导向图的算法:由于不使用额外的库,我们需要手动实现力导向仿真。
- 绘制SVG:使用SolidJS的模板语法创建SVG元素并渲染节点和连线。
- 实现节点拖拽功能:在SVG中实现节点的拖拽行为。
以下是一个完整的示例,展示了如何在SolidJS和TypeScript中实现上述功能:
1. 设置项目
确保你已经安装了Node.js和npm。然后,创建一个新的SolidJS项目并安装必要的依赖项。
npm init solid my-solid-project
cd my-solid-project
npm install
npm install typescript @types/node --save-dev
配置TypeScript支持:
在tsconfig.json
文件中添加以下内容:
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
2. 定义力导向图的算法
首先,我们需要定义一些接口来描述图的数据结构:
interface Node {
id: string;
group: number;
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number | null;
fy?: number | null;
}
interface Link {
source: string;
target: string;
value: number;
}
interface GraphData {
nodes: Node[];
links: Link[];
}
3. 实现力导向仿真
实现简单的力导向仿真算法:
const width = 928;
const height = 600;
function forceSimulation(nodes: Node[], links: Link[]) {
// Initialize positions if they are not already set
nodes.forEach((node) => {
node.x = node.x ?? Math.random() * width;
node.y = node.y ?? Math.random() * height;
node.vx = 0;
node.vy = 0;
});
// Simplified force-directed algorithm
const alpha = 0.1;
const alphaDecay = 0.99;
const repulsionStrength = -30;
const attractionStrength = 0.1;
function tick() {
// Apply repulsion between nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x! - nodes[i].x!;
const dy = nodes[j].y! - nodes[i].y!;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsionStrength / (distance * distance);
nodes[i].vx! -= force * dx;
nodes[i].vy! -= force * dy;
nodes[j].vx! += force * dx;
nodes[j].vy! += force * dy;
}
}
// Apply attraction along links
links.forEach((link) => {
const source = nodes.find((node) => node.id === link.source)!;
const target = nodes.find((node) => node.id === link.target)!;
const dx = target.x! - source.x!;
const dy = target.y! - source.y!;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (distance - 30) * attractionStrength;
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
source.vx! += fx;
source.vy! += fy;
target.vx! -= fx;
target.vy! -= fy;
});
// Update positions
nodes.forEach((node) => {
if (!node.fx) node.x! += node.vx! * alpha;
if (!node.fy) node.y! += node.vy! * alpha;
node.vx! *= alphaDecay;
node.vy! *= alphaDecay;
});
}
return { tick };
}
4. 绘制SVG并实现拖拽
在SolidJS组件中实现SVG绘制和拖拽行为:
import { createSignal, onCleanup, onMount } from "solid-js";
import { render } from "solid-js/web";
const Graph = (props: { data: GraphData }) => {
const [nodes, setNodes] = createSignal<Node[]>(props.data.nodes);
const [links] = createSignal<Link[]>(props.data.links);
let svgRef: SVGSVGElement | undefined;
onMount(() => {
const simulation = forceSimulation(nodes(), links());
const interval = setInterval(() => {
simulation.tick();
setNodes([...nodes()]);
}, 100);
onCleanup(() => clearInterval(interval));
});
const drag = (event: MouseEvent, node: Node) => {
node.fx = event.clientX - (svgRef?.getBoundingClientRect().left || 0);
node.fy = event.clientY - (svgRef?.getBoundingClientRect().top || 0);
setNodes([...nodes()]);
};
const endDrag = (node: Node) => {
node.fx = null;
node.fy = null;
setNodes([...nodes()]);
};
return (
<svg
ref={svgRef}
width={width}
height={height}
style={{ border: "1px solid black", "max-width": "100%", height: "auto" }}
>
{links().map((link) => {
const source = nodes().find((node) => node.id === link.source)!;
const target = nodes().find((node) => node.id === link.target)!;
return (
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#999"
stroke-opacity="0.6"
stroke-width={Math.sqrt(link.value)}
/>
);
})}
{nodes().map((node) => (
<circle
cx={node.x}
cy={node.y}
r="5"
fill="#69b3a2"
stroke="#fff"
stroke-width="1.5"
onm ousedown={(e) => drag(e, node)}
onm ouseup={() => endDrag(node)}
>
<title>{node.id}</title>
</circle>
))}
</svg>
);
};
const data: GraphData = {
nodes: [
{ id: "1", group: 1 },
{ id: "2", group: 2 },
{ id: "3", group: 2 },
{ id: "4", group: 1 },
],
links: [
{ source: "1", target: "2", value: 1 },
{ source: "2", target: "3", value: 1 },
{ source: "3", target: "4", value: 1 },
],
};
render(() => <Graph data={data} />, document.getElementById("root"));
这个示例展示了如何在SolidJS中实现一个简单的力导向图。我们手动实现了力导向仿真算法,并使用SVG绘制图形,同时实现了节点的拖拽功能。通过这种方式,可以更好地理解力导向图的原理和实现细节。
将代码分为多个文件有助于代码的可维护性和模块化。可以按照功能划分不同的文件,如下所示:
types.ts
:定义图的数据结构。forceSimulation.ts
:实现力导向仿真算法。Graph.tsx
:使用SolidJS创建图表组件。
下面是如何组织这些文件:
1. types.ts
export interface Node {
id: string;
group: number;
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number | null;
fy?: number | null;
}
export interface Link {
source: string;
target: string;
value: number;
}
export interface GraphData {
nodes: Node[];
links: Link[];
}
2. forceSimulation.ts
import { Node, Link } from "./types";
const width = 928;
const height = 600;
export function forceSimulation(nodes: Node[], links: Link[]) {
nodes.forEach((node) => {
node.x = node.x ?? Math.random() * width;
node.y = node.y ?? Math.random() * height;
node.vx = 0;
node.vy = 0;
});
const alpha = 0.1;
const alphaDecay = 0.99;
const repulsionStrength = -30;
const attractionStrength = 0.1;
function tick() {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x! - nodes[i].x!;
const dy = nodes[j].y! - nodes[i].y!;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsionStrength / (distance * distance);
nodes[i].vx! -= force * dx;
nodes[i].vy! -= force * dy;
nodes[j].vx! += force * dx;
nodes[j].vy! += force * dy;
}
}
links.forEach((link) => {
const source = nodes.find((node) => node.id === link.source)!;
const target = nodes.find((node) => node.id === link.target)!;
const dx = target.x! - source.x!;
const dy = target.y! - source.y!;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (distance - 30) * attractionStrength;
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
source.vx! += fx;
source.vy! += fy;
target.vx! -= fx;
target.vy! -= fy;
});
nodes.forEach((node) => {
if (!node.fx) node.x! += node.vx! * alpha;
if (!node.fy) node.y! += node.vy! * alpha;
node.vx! *= alphaDecay;
node.vy! *= alphaDecay;
});
}
return { tick };
}
3. Graph.tsx
import { createSignal, onCleanup, onMount } from "solid-js";
import { Node, Link, GraphData } from "./types";
import { forceSimulation } from "./forceSimulation";
const width = 928;
const height = 600;
const Graph = (props: { data: GraphData }) => {
const [nodes, setNodes] = createSignal<Node[]>(props.data.nodes);
const [links] = createSignal<Link[]>(props.data.links);
let svgRef: SVGSVGElement | undefined;
onMount(() => {
const simulation = forceSimulation(nodes(), links());
const interval = setInterval(() => {
simulation.tick();
setNodes([...nodes()]);
}, 100);
onCleanup(() => clearInterval(interval));
});
const drag = (event: MouseEvent, node: Node) => {
node.fx = event.clientX - (svgRef?.getBoundingClientRect().left || 0);
node.fy = event.clientY - (svgRef?.getBoundingClientRect().top || 0);
setNodes([...nodes()]);
};
const endDrag = (node: Node) => {
node.fx = null;
node.fy = null;
setNodes([...nodes()]);
};
return (
<svg
ref={svgRef}
width={width}
height={height}
style={{ border: "1px solid black", "max-width": "100%", height: "auto" }}
>
{links().map((link) => {
const source = nodes().find((node) => node.id === link.source)!;
const target = nodes().find((node) => node.id === link.target)!;
return (
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#999"
stroke-opacity="0.6"
stroke-width={Math.sqrt(link.value)}
/>
);
})}
{nodes().map((node) => (
<circle
cx={node.x}
cy={node.y}
r="5"
fill="#69b3a2"
stroke="#fff"
stroke-width="1.5"
onm ousedown={(e) => drag(e, node)}
onm ouseup={() => endDrag(node)}
>
<title>{node.id}</title>
</circle>
))}
</svg>
);
};
export default Graph;
4. index.tsx
最后,在入口文件中渲染Graph
组件:
import { render } from "solid-js/web";
import Graph from "./Graph";
import { GraphData } from "./types";
const data: GraphData = {
nodes: [
{ id: "1", group: 1 },
{ id: "2", group: 2 },
{ id: "3", group: 2 },
{ id: "4", group: 1 },
],
links: [
{ source: "1", target: "2", value: 1 },
{ source: "2", target: "3", value: 1 },
{ source: "3", target: "4", value: 1 },
],
};
render(() => <Graph data={data} />, document.getElementById("root"));
总结
通过这种方式,我们将代码分为多个文件,使得每个文件专注于一个功能。这样做不仅提高了代码的可读性和可维护性,还便于重用和测试。
停止仿真(待解决)
在力导向图中,节点持续运动是由于力仿真算法在不断地调整节点和连线的位置,以达到一个相对稳定的状态。这种运动是由仿真中的力(如引力、排斥力和连线力)驱动的。仿真会在每个时间步(tick)调整节点的位置,直到系统达到平衡。
然而,仿真不应该无限期地运行。通常,当仿真达到平衡或经过一定的时间后,会停止仿真,使节点保持静止状态。
在你的实现中,我们可以添加一个停止仿真的机制,当仿真达到一定的稳定性时停止。
修改 forceSimulation
以支持停止仿真
我们可以添加一个alpha阈值,当alpha值低于这个阈值时,停止仿真。这样,当系统达到平衡状态时,仿真就会停止。
在 forceSimulation.ts
中,修改 forceSimulation
函数:
import { Node, Link } from "./types";
const width = 928;
const height = 600;
export function forceSimulation(nodes: Node[], links: Link[]) {
nodes.forEach((node) => {
node.x = node.x ?? Math.random() * width;
node.y = node.y ?? Math.random() * height;
node.vx = 0;
node.vy = 0;
});
const alphaDecay = 0.99;
const repulsionStrength = -30;
const attractionStrength = 0.1;
let alpha = 0.1;
const alphaMin = 0.001; // Minimum alpha value to stop the simulation
function tick() {
if (alpha < alphaMin) return false; // Stop simulation when alpha is low
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x! - nodes[i].x!;
const dy = nodes[j].y! - nodes[i].y!;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = repulsionStrength / (distance * distance);
nodes[i].vx! -= force * dx;
nodes[i].vy! -= force * dy;
nodes[j].vx! += force * dx;
nodes[j].vy! += force * dy;
}
}
links.forEach((link) => {
const source = nodes.find((node) => node.id === link.source)!;
const target = nodes.find((node) => node.id === link.target)!;
const dx = target.x! - source.x!;
const dy = target.y! - source.y!;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (distance - 30) * attractionStrength;
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
source.vx! += fx;
source.vy! += fy;
target.vx! -= fx;
target.vy! -= fy;
});
nodes.forEach((node) => {
if (!node.fx) node.x! += node.vx! * alpha;
if (!node.fy) node.y! += node.vy! * alpha;
node.vx! *= alphaDecay;
node.vy! *= alphaDecay;
});
alpha *= alphaDecay; // Decrease alpha
return true; // Continue simulation
}
return { tick };
}
修改 Graph.tsx
以停止仿真
在 Graph.tsx
中,修改 onMount
函数,以停止仿真:
import { createSignal, onCleanup, onMount } from "solid-js";
import { Node, Link, GraphData } from "./types";
import { forceSimulation } from "./forceSimulation";
const width = 928;
const height = 600;
const Graph = (props: { data: GraphData }) => {
const [nodes, setNodes] = createSignal<Node[]>(props.data.nodes);
const [links] = createSignal<Link[]>(props.data.links);
let svgRef: SVGSVGElement | undefined;
onMount(() => {
const simulation = forceSimulation(nodes(), links());
const interval = setInterval(() => {
const running = simulation.tick();
setNodes([...nodes()]);
if (!running) {
clearInterval(interval); // Stop the interval if simulation is not running
}
}, 100);
onCleanup(() => clearInterval(interval));
});
const drag = (event: MouseEvent, node: Node) => {
node.fx = event.clientX - (svgRef?.getBoundingClientRect().left || 0);
node.fy = event.clientY - (svgRef?.getBoundingClientRect().top || 0);
setNodes([...nodes()]);
};
const endDrag = (node: Node) => {
node.fx = null;
node.fy = null;
setNodes([...nodes()]);
};
return (
<svg
ref={svgRef}
width={width}
height={height}
style={{ border: "1px solid black", "max-width": "100%", height: "auto" }}
>
{links().map((link) => {
const source = nodes().find((node) => node.id === link.source)!;
const target = nodes().find((node) => node.id === link.target)!;
return (
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#999"
stroke-opacity="0.6"
stroke-width={Math.sqrt(link.value)}
/>
);
})}
{nodes().map((node) => (
<circle
cx={node.x}
cy={node.y}
r="5"
fill="#69b3a2"
stroke="#fff"
stroke-width="1.5"
onm ousedown={(e) => drag(e, node)}
onm ouseup={() => endDrag(node)}
>
<title>{node.id}</title>
</circle>
))}
</svg>
);
};
export default Graph;
通过这些修改,当仿真达到稳定状态(即alpha值低于某个阈值)时,仿真将自动停止,从而使节点不再继续运动。
现存问题
- 点的初始状态不是静止的。加入仿真停止阈值后,点由运动状态逐渐转为静止
- 没有拖拽功能
- 初始点和link数量太少(有无数据集?)