d3.js で円型ノードの中心同士を繋ぐノードグラフのサンプルです。ノードはドラッグ移動できます。
ソースコード。無駄にclass化されてますが、その辺は目をつむってください。
<svg width="400" height="400">
<defs>
<filter id="solid">
<feFlood flood-color="#eeeeee" result="bg" flood-opacity="0.8" />
<feMerge>
<feMergeNode in="bg" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
</svg>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"
integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
class Node {
constructor(
index,
label,
x, y, radius, width, height, href,
imageOffsetX, imageOffsetY,
forceRateY = 1.0
) {
this.index = index;
this.label = label;
this.x = x;
this.y = y;
this.radius = radius;
this.width = width;
this.height = height;
this.href = href;
this.imageOffsetX = imageOffsetX;
this.imageOffsetY = imageOffsetY;
this.forceRateY = forceRateY;
}
}
class NodeEdge {
constructor(label, source, target, distance) {
this.label = label;
this.labelOffset = 6;
this.source = source;
this.target = target;
this.distance = distance;
}
}
class RelationshipChart {
constructor() {
this.simulation = null;
this.nodesData = [];
this.linksData = [];
this.link = null;
this.edgeLabels = null;
this.node = null;
this.imageFrame = null;
this.nodeLabels = null;
this.clipPaths = null;
}
initMain() {
const imageWidth = 100;
const imageHeight = 100;
const imageFrameInnerWidth = 3;
const imageFrameOuterWidth = 2;
const imageFrameColor = "#ffdd99";
const imageFrameOuterColor = "#ffaa00";
const fontSize = 10;
const width = document.querySelector("svg").clientWidth;
const height = document.querySelector("svg").clientHeight;
this.nodesData.push(new Node(0,
"アルフレッド",
width * 0.5 - imageWidth, height * 0.5,
1,
imageWidth, imageHeight,
"/blog/images/FehCylPortraits/CYL_Alfred_Engage.png",
imageWidth / 2, imageHeight / 2,
0
));
this.nodesData.push(new Node(1,
"セリーヌ",
width * 0.5 + imageWidth, height * 0.5,
1,
imageWidth, imageHeight,
"/blog/images/FehCylPortraits/CYL_Celine_Engage.png",
imageWidth / 2, imageHeight / 2,
0
));
this.linksData.push(new NodeEdge(
"兄妹",
0, 1,
300)
);
const svg = d3.select("svg");
this.link = svg.append("g").attr("id", "edges")
.selectAll("line")
.data(this.linksData)
.enter()
.append("line")
.attr("stroke-width", 2)
.attr("stroke", "gray");
this.edgeLabels = svg.append("g").attr("id", "edgeLabels")
.selectAll("text")
.data(this.linksData)
.enter()
.append("text")
.attr("font-size", fontSize)
.style("text-anchor", "middle")
.attr("stroke", "black")
.text(x => x.label);
this.node = svg.append("g").attr("id", "edgePoints")
.selectAll("circle")
.data(this.nodesData)
.enter()
.append("circle")
.attr("r", d => d.radius)
.attr("fill", "black")
.attr("stroke", "black");
this.imageFrame = svg.append("g").attr("id", "imageFrames")
.selectAll("circle")
.data(this.nodesData)
.enter()
.append("circle")
.attr("r", d => d.width / 2)
.attr("fill", imageFrameColor)
.attr("stroke-width", imageFrameOuterWidth)
.attr("stroke", imageFrameOuterColor);
this.images = svg.append("g").attr("id", "images")
.selectAll("image")
.data(this.nodesData)
.enter()
.append("image")
.attr("href", d => d.href)
.attr("height", d => d.height)
.attr("width", d => d.width)
.attr("clip-path", d => `url(#circle-clip${d.index})`)
.call(d3.drag()
.on("start", (e, d) => {
if (!e.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (e, d) => {
d.fx = e.x;
d.fy = e.y;
})
.on("end", (e, d) => {
d.fx = e.x;
d.fy = e.y;
}));
this.clipPaths = svg
.select("defs")
.selectAll("clipPath")
.data(this.nodesData)
.enter()
.append("clipPath")
.attr("id", d => "circle-clip" + d.index)
.append("circle")
.attr("r", d => d.width / 2 - (imageFrameInnerWidth + imageFrameOuterWidth));
svg.select("defs")
.selectAll("marker")
.data(["arrow"])
.enter()
.append("marker")
.attr("id", String)
.attr("viewBox", "0 -5 10 10")
.attr("refX", imageWidth / 2)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto-start-reverse")
.append("svg:path")
.attr("d", "M0,-5L10,0L0,5");
this.nodeLabels = svg.append("g")
.selectAll("text")
.data(this.nodesData)
.enter()
.append("text")
.attr("font-size", fontSize)
.style("text-anchor", "middle")
.attr("stroke", "black")
.attr("filter", "url(#solid)")
.text(x => x.label);
this.simulation = d3.forceSimulation(this.nodesData)
.on("tick", () => {
const fontHeight = 20;
this.link
.attr("x1", d => this.nodesData[d.source].x)
.attr("y1", d => this.nodesData[d.source].y)
.attr("x2", d => this.nodesData[d.target].x)
.attr("y2", d => this.nodesData[d.target].y)
.attr("marker-start", "url(#arrow)")
.attr("marker-end", "url(#arrow)");
this.edgeLabels
.attr("x", d => (this.nodesData[d.source].x + this.nodesData[d.target].x) / 2)
.attr("y", d => (this.nodesData[d.source].y + this.nodesData[d.target].y) / 2 - d.labelOffset);
this.node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
this.images
.attr("x", d => d.x - d.imageOffsetX)
.attr("y", d => d.y - d.imageOffsetY);
this.imageFrame
.attr("cx", d => d.x)
.attr("cy", d => d.y);
this.nodeLabels
.attr("x", d => d.x - d.imageOffsetX + d.width / 2)
.attr("y", d => d.y + d.height / 2 - 5);
this.clipPaths
.attr("cx", d => d.x - d.imageOffsetX + d.width / 2)
.attr("cy", d => d.y - d.imageOffsetY + d.height / 2);
});
}
}
const appData = new RelationshipChart();
appData.initMain();
</script>