|
<!DOCTYPE html> |
|
<head> |
|
<meta charset="utf-8"> |
|
<script src="https://d3js.org/d3.v4.min.js"></script> |
|
<style> |
|
body { margin:0;position:fixed;top:0;right:0;bottom:0;left:0; } |
|
.node { fill: orange } |
|
g { fill: none } |
|
circle { fill: red; stroke: none } |
|
path { stroke: blue } |
|
path.arrow { fill: blue } |
|
defs { fill: blue } |
|
text { |
|
stroke: black; |
|
fill: black; |
|
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; |
|
font-size: 12px; |
|
text-anchor: middle; |
|
} |
|
</style> |
|
</head> |
|
|
|
<body> |
|
<script> |
|
const NODE_RADIUS = 20; |
|
const ARROW_LENGTH = 10; |
|
const nodes = [ |
|
{ x: Math.random() * 800 + 50, y: Math.random() * 300 + 50 }, |
|
{ x: Math.random() * 800 + 50, y: Math.random() * 300 + 50 }, |
|
{ x: Math.random() * 800 + 50, y: Math.random() * 300 + 50 } |
|
]; |
|
|
|
// const nodes = [ |
|
// { x: 300, y: 100 }, |
|
// { x: 300, y: 350 }, |
|
// { x: 700, y: 100 } |
|
// ]; |
|
const links = [ |
|
{ source: 0, target: 1, text: '0-1' }, |
|
{ source: 0, target: 1, text: '0-1' }, |
|
{ source: 1, target: 0, text: '1-0' }, |
|
|
|
{ source: 0, target: 1, text: '0-1' }, |
|
|
|
{ source: 0, target: 2, text: '0-2' }, |
|
|
|
{ source: 2, target: 1, text: '2-1' }, |
|
{ source: 2, target: 1, text: '2-1' }, |
|
|
|
{ source: 1, target: 0, text: '1-0' }, |
|
{ source: 1, target: 0, text: '1-0' }, |
|
]; |
|
|
|
const linkGroups = []; |
|
|
|
links.forEach(link => { |
|
const existingGroup = linkGroups.find(linkGroup => linkGroup[0].source === link.source && linkGroup[0].target === link.target |
|
|| linkGroup[0].source === link.target && linkGroup[0].target === link.source); |
|
if (existingGroup) { |
|
existingGroup.push(link); |
|
} |
|
else { |
|
linkGroups.push([link]); |
|
} |
|
}) |
|
|
|
const getLinksGroup = (source, target) => { |
|
return links.filter(link =>(link.source === source && link.target === target |
|
|| link.target === source && link.source === target));td |
|
} |
|
const getTranslateCoords = (animVal) => { |
|
for (let i = 0; i < animVal.numberOfItems; ++i) { |
|
if (animVal.getItem(i).type === animVal.getItem(i).SVG_TRANSFORM_TRANSLATE) { |
|
return { x: animVal.getItem(i).matrix.e, y: animVal.getItem(i).matrix.f }; |
|
} |
|
} |
|
return { x: 0, y: 0 }; |
|
}; |
|
|
|
const getScaleFactors = (animVal) => { |
|
for (let i = 0; i < animVal.numberOfItems; ++i) { |
|
if (animVal.getItem(i).type === animVal.getItem(i).SVG_TRANSFORM_SCALE) { |
|
return { x: animVal.getItem(i).matrix.a, y: animVal.getItem(i).matrix.d }; |
|
} |
|
} |
|
return { x: 1, y: 1 }; |
|
}; |
|
|
|
const getLinkCoords = (sourcePosition, targetPosition) => { |
|
const x1 = sourcePosition.x; |
|
const y1 = sourcePosition.y; |
|
const z1 = sourcePosition.z; |
|
const x2 = targetPosition.x; |
|
const y2 = targetPosition.y; |
|
const z2 = targetPosition.z; |
|
const r1 = NODE_RADIUS * z1; |
|
const r2 = NODE_RADIUS * z2; |
|
|
|
const hypotenuse = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); |
|
const angle = Math.atan2(x1 - x2, y2 - y1); |
|
const sourceX = x1 - Math.sin(angle) * r1; |
|
const sourceY = y1 + Math.cos(angle) * r1; |
|
|
|
return { x: sourceX, y: sourceY, length: hypotenuse, angle: angle * 180 / Math.PI }; |
|
}; |
|
|
|
const $svg = d3.select("body") |
|
.append("svg") |
|
.attr("width", 960) |
|
.attr("height", 500); |
|
|
|
const $zoom = $svg.append('g').attr('class', 'graph-area-g-container'); |
|
|
|
const $zoomedGroup = $zoom.append('g').attr('class', 'zoomed-group'); |
|
|
|
const zoom = d3.zoom() |
|
.scaleExtent([ 0.1, 4 ]) |
|
.on('zoom.nodes', () => $zoomedGroup.attr('transform', d3.event.transform)); |
|
$zoom.call(zoom); |
|
|
|
$zoomedGroup.selectAll('.linkGroup') |
|
.data(linkGroups) |
|
.enter() |
|
.append('g') |
|
.attr('class', 'linkGroup') |
|
.each(function(dLinkGroup, iLinkGroup) { |
|
const $linkGroup = d3.select(this); |
|
const linkGroupCoords = getLinkCoords({ x: nodes[dLinkGroup[0].source].x, y: nodes[dLinkGroup[0].source].y, z: 1}, { x: nodes[dLinkGroup[0].target].x, y: nodes[dLinkGroup[0].target].y, z: 1}); |
|
|
|
$linkGroup.attr('transform', `translate(${ nodes[dLinkGroup[0].source].x },${ nodes[dLinkGroup[0].source].y }) scale(1) rotate(${ linkGroupCoords.angle })`); |
|
|
|
|
|
$linkGroup.selectAll('.link') |
|
.data(dLinkGroup) |
|
.enter() |
|
.append('g') |
|
.attr('class', 'link') |
|
.each(function(d, i) { |
|
|
|
const $g = d3.select(this); |
|
const linkCoords = getLinkCoords({ x: nodes[d.source].x, y: nodes[d.source].y, z: 1}, { x: nodes[d.target].x, y: nodes[d.target].y, z: 1}); |
|
const middle = { |
|
x: 0, |
|
y: linkCoords.length / 2 |
|
}; |
|
const lineData = [ |
|
{ x: 0, y: d.source === dLinkGroup[0].source ? 0 : linkCoords.length }, |
|
{ x: middle.x + (NODE_RADIUS * i - NODE_RADIUS * ( dLinkGroup.length - 1) / 2) * 4 , y: middle.y }, |
|
{ x: 0, y: d.source === dLinkGroup[0].source ? linkCoords.length : 0} |
|
]; |
|
|
|
const bezierPath = d3.path() |
|
bezierPath.moveTo(lineData[0].x, lineData[0].y) |
|
bezierPath.quadraticCurveTo(lineData[1].x, lineData[1].y, lineData[2].x, lineData[2].y); |
|
|
|
const path = $g.append("path") |
|
.attr("class", "line") |
|
.attr("id", `${d.source}-${d.target}-${i}`) |
|
.attr("d", bezierPath); |
|
|
|
const pathLength = path.node().getTotalLength(); |
|
const arrowStartCoords = path.node().getPointAtLength(pathLength - NODE_RADIUS - ARROW_LENGTH); |
|
const arrowEndCoords = path.node().getPointAtLength(pathLength - NODE_RADIUS); |
|
const angleDeg = Math.atan2(arrowEndCoords.y - arrowStartCoords.y, arrowEndCoords.x - arrowStartCoords.x) * 180 / Math.PI; |
|
|
|
$g.append('path') |
|
.attr('d', 'M0,5 L10,0 L0,-5 Z') |
|
.attr('class', 'arrow') |
|
.attr('transform', `translate(${arrowStartCoords.x}, ${arrowStartCoords.y}) rotate(${angleDeg})`) |
|
|
|
$g.append('text') |
|
.attr('dy', '-4px') |
|
.attr('transform', `translate(${lineData[1].x / 2}, ${lineData[1].y}), rotate(${linkGroupCoords.angle >= 0 ? -90 : 90})`) |
|
.text(d.text) |
|
}); |
|
}); |
|
|
|
$zoomedGroup.selectAll('.node') |
|
.data(nodes) |
|
.enter() |
|
.append('g') |
|
.attr('class', 'node') |
|
.attr('transform', (d) => `translate(${d.x}, ${d.y})`) |
|
.call($g => { |
|
$g.append('circle') |
|
.attr('r', NODE_RADIUS); |
|
}); |
|
|
|
</script> |
|
</body> |