Skip to content

Commit 04f6442

Browse files
feat(highcharts): implement network-directed (#11530)
## Implementation: `network-directed` - javascript/highcharts Implements the **javascript/highcharts** version of `network-directed`. **File:** `plots/network-directed/implementations/javascript/highcharts.js` **Parent Issue:** #2858 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/33959013123)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 51d220b commit 04f6442

2 files changed

Lines changed: 522 additions & 0 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// anyplot.ai
2+
// network-directed: Directed Network Graph
3+
// Library: highcharts 12.6.0 | JavaScript 22.23.2
4+
// Quality: 89/100 | Created: 2026-09-05
5+
6+
const t = window.ANYPLOT_TOKENS;
7+
8+
// --- Data: a software module import graph, laid out in dependency tiers ----
9+
// Arrows point from an importing module to the module it imports. Node radius
10+
// encodes in-degree (how many other modules import it), so the shared
11+
// foundation modules read as visual hubs.
12+
const TIERS = [
13+
{
14+
name: "Entry points",
15+
color: t.palette[0],
16+
nodes: [
17+
{ id: "cli", name: "cli", y: 1, indeg: 0 },
18+
{ id: "server", name: "server", y: 2, indeg: 0 },
19+
{ id: "worker", name: "worker", y: 3, indeg: 0 },
20+
],
21+
},
22+
{
23+
name: "Orchestration",
24+
color: t.palette[1],
25+
nodes: [
26+
{ id: "router", name: "router", y: 1, indeg: 2 },
27+
{ id: "scheduler", name: "scheduler", y: 2, indeg: 1 },
28+
{ id: "api_gateway", name: "api_gateway", y: 3, indeg: 1 },
29+
],
30+
},
31+
{
32+
name: "Services",
33+
color: t.palette[2],
34+
nodes: [
35+
{ id: "auth", name: "auth", y: 0.2, indeg: 3 },
36+
{ id: "database", name: "database", y: 1.4, indeg: 3 },
37+
{ id: "cache", name: "cache", y: 2.6, indeg: 2 },
38+
{ id: "queue", name: "queue", y: 3.8, indeg: 2 },
39+
],
40+
},
41+
{
42+
name: "Foundation",
43+
color: t.palette[3],
44+
nodes: [
45+
{ id: "logger", name: "logger", y: 1, indeg: 7 },
46+
{ id: "config", name: "config", y: 2, indeg: 3 },
47+
{ id: "utils", name: "utils", y: 3, indeg: 3 },
48+
],
49+
},
50+
];
51+
52+
// Third element is the call-frequency weight (spec's optional `weight`
53+
// attribute), rendered as edge line thickness.
54+
const EDGES = [
55+
["cli", "router", 3],
56+
["cli", "config", 2],
57+
["server", "router", 3],
58+
["server", "auth", 4],
59+
["server", "api_gateway", 3],
60+
["worker", "scheduler", 3],
61+
["worker", "queue", 4],
62+
["worker", "config", 2],
63+
["router", "auth", 4],
64+
["router", "database", 3],
65+
["router", "logger", 5],
66+
["scheduler", "queue", 3],
67+
["scheduler", "database", 3],
68+
["scheduler", "logger", 5],
69+
["api_gateway", "auth", 4],
70+
["api_gateway", "cache", 3],
71+
["api_gateway", "logger", 5],
72+
["auth", "database", 4],
73+
["auth", "cache", 3],
74+
["auth", "logger", 5],
75+
["database", "logger", 5],
76+
["database", "config", 2],
77+
["cache", "logger", 4],
78+
["queue", "logger", 4],
79+
["queue", "utils", 3],
80+
["logger", "utils", 2],
81+
["config", "utils", 2],
82+
];
83+
84+
const nodeRadius = (indeg) => 14 + indeg * 3;
85+
86+
// --- Chart -------------------------------------------------------------
87+
const series = TIERS.map((tier, i) => ({
88+
name: tier.name,
89+
color: tier.color,
90+
data: tier.nodes.map((n) => ({
91+
id: n.id,
92+
x: i * 3.6,
93+
y: n.y,
94+
name: n.name,
95+
marker: { radius: nodeRadius(n.indeg), lineColor: t.pageBg, lineWidth: 2 },
96+
dataLabels: { format: n.name, y: -(nodeRadius(n.indeg) + 10) },
97+
})),
98+
}));
99+
100+
Highcharts.chart("container", {
101+
chart: {
102+
type: "scatter",
103+
backgroundColor: "transparent",
104+
animation: false,
105+
style: { fontFamily: "inherit" },
106+
events: {
107+
load() {
108+
drawEdges(this);
109+
},
110+
},
111+
},
112+
credits: { enabled: false },
113+
title: {
114+
text: "network-directed · javascript · highcharts · anyplot.ai",
115+
style: { color: t.ink, fontSize: "22px", fontWeight: "600" },
116+
},
117+
subtitle: {
118+
text: "Module import graph — arrows point from importer to dependency",
119+
style: { color: t.inkSoft, fontSize: "14px" },
120+
},
121+
xAxis: {
122+
min: -1.5,
123+
max: 12.3,
124+
lineWidth: 0,
125+
tickLength: 0,
126+
gridLineWidth: 0,
127+
labels: { enabled: false },
128+
title: { text: null },
129+
},
130+
yAxis: {
131+
min: -0.3,
132+
max: 4.3,
133+
lineWidth: 0,
134+
tickLength: 0,
135+
gridLineWidth: 0,
136+
labels: { enabled: false },
137+
title: { text: null },
138+
},
139+
legend: {
140+
itemStyle: { color: t.inkSoft, fontSize: "14px" },
141+
itemHoverStyle: { color: t.ink },
142+
},
143+
tooltip: { enabled: false },
144+
plotOptions: {
145+
series: {
146+
animation: false,
147+
dataLabels: {
148+
enabled: true,
149+
style: {
150+
color: t.ink,
151+
fontSize: "14px",
152+
fontWeight: "normal",
153+
textOutline: "none",
154+
},
155+
},
156+
},
157+
},
158+
series,
159+
});
160+
161+
// Edges are drawn once with the core SVGRenderer, behind the node markers —
162+
// the core `highcharts` bundle has no networkgraph module (add-on only), so
163+
// node-link edges with arrowheads are composed by hand from point coordinates.
164+
// Deterministic per-edge signed offset (source/target ids -> stable hash) used
165+
// both to bow the edge slightly and to fan out where it lands on a crowded
166+
// target's perimeter, so parallel/converging paths stay distinguishable.
167+
function edgeHash(fromId, toId) {
168+
const key = `${fromId}>${toId}`;
169+
let h = 0;
170+
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
171+
return h;
172+
}
173+
174+
function drawEdges(c) {
175+
const group = c.renderer.g("edges").attr({ zIndex: 1 }).add();
176+
const strokeColor = t.inkSoft;
177+
178+
const incomingTotal = {};
179+
EDGES.forEach(([, toId]) => {
180+
incomingTotal[toId] = (incomingTotal[toId] || 0) + 1;
181+
});
182+
const incomingSeen = {};
183+
184+
EDGES.forEach(([fromId, toId, weight]) => {
185+
const from = c.get(fromId);
186+
const to = c.get(toId);
187+
if (!from || !to) return;
188+
189+
const sx = c.plotLeft + from.plotX;
190+
const sy = c.plotTop + from.plotY;
191+
const tx = c.plotLeft + to.plotX;
192+
const ty = c.plotTop + to.plotY;
193+
194+
const dx = tx - sx;
195+
const dy = ty - sy;
196+
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
197+
const ux = dx / dist;
198+
const uy = dy / dist;
199+
200+
// Fan incoming arrows around the target node's perimeter instead of all
201+
// landing on the same point (VQ-03: converging edges were hard to tell
202+
// apart at hub nodes like "logger" / "database").
203+
const total = incomingTotal[toId];
204+
const seen = incomingSeen[toId] || 0;
205+
incomingSeen[toId] = seen + 1;
206+
const spread = Math.min(0.7, (total - 1) * 0.16);
207+
const fanOffset = total > 1 ? -spread / 2 + (spread / (total - 1)) * seen : 0;
208+
const baseAngleAtTarget = Math.atan2(sy - ty, sx - tx) + fanOffset;
209+
210+
const rFrom = from.marker.radius + 2;
211+
const rTo = to.marker.radius + 4;
212+
const x1 = sx + ux * rFrom;
213+
const y1 = sy + uy * rFrom;
214+
const x2 = tx + Math.cos(baseAngleAtTarget) * rTo;
215+
const y2 = ty + Math.sin(baseAngleAtTarget) * rTo;
216+
217+
// Slight deterministic curvature so overlapping straight paths through
218+
// the dense middle tiers stay traceable (DE-03), echoing the spec's own
219+
// "consider curved edges" guidance.
220+
const curve = (Math.abs(edgeHash(fromId, toId)) % 14) - 7;
221+
const midX = (x1 + x2) / 2 - uy * curve;
222+
const midY = (y1 + y2) / 2 + ux * curve;
223+
224+
const lineWidth = 1 + (weight || 1) * 0.6;
225+
drawArrow(c.renderer, group, x1, y1, midX, midY, x2, y2, strokeColor, lineWidth);
226+
});
227+
228+
c.series.forEach((s) => s.group.toFront());
229+
c.series.forEach((s) => s.dataLabelsGroup && s.dataLabelsGroup.toFront());
230+
}
231+
232+
function drawArrow(renderer, group, x1, y1, cx, cy, x2, y2, color, lineWidth) {
233+
// Tangent of the quadratic curve at its endpoint points from the control
234+
// point to the endpoint — use it to orient the arrowhead correctly.
235+
const angle = Math.atan2(y2 - cy, x2 - cx);
236+
const headLen = 14;
237+
const headAngle = Math.PI / 7;
238+
239+
const shaftEndX = x2 - Math.cos(angle) * headLen * 0.6;
240+
const shaftEndY = y2 - Math.sin(angle) * headLen * 0.6;
241+
242+
renderer
243+
.path(["M", x1, y1, "Q", cx, cy, shaftEndX, shaftEndY])
244+
.attr({
245+
stroke: color,
246+
"stroke-width": lineWidth,
247+
"stroke-linecap": "round",
248+
fill: "none",
249+
opacity: 0.7,
250+
})
251+
.add(group);
252+
253+
const p1x = x2 - Math.cos(angle - headAngle) * headLen;
254+
const p1y = y2 - Math.sin(angle - headAngle) * headLen;
255+
const p2x = x2 - Math.cos(angle + headAngle) * headLen;
256+
const p2y = y2 - Math.sin(angle + headAngle) * headLen;
257+
258+
renderer
259+
.path(["M", x2, y2, "L", p1x, p1y, "L", p2x, p2y, "Z"])
260+
.attr({ fill: color, stroke: "none", opacity: 0.9 })
261+
.add(group);
262+
}

0 commit comments

Comments
 (0)