Skip to content

Commit e335781

Browse files
feat(d3): implement network-bipartite (#11521)
## Implementation: `network-bipartite` - javascript/d3 Implements the **javascript/d3** version of `network-bipartite`. **File:** `plots/network-bipartite/implementations/javascript/d3.js` **Parent Issue:** #5247 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/33958514747)* --------- 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 309f342 commit e335781

2 files changed

Lines changed: 515 additions & 0 deletions

File tree

  • plots/network-bipartite
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// anyplot.ai
2+
// network-bipartite: Bipartite Network Graph
3+
// Library: d3 7.9.0 | JavaScript 22.23.2
4+
// Quality: 92/100 | Created: 2026-09-05
5+
6+
const t = window.ANYPLOT_TOKENS;
7+
const { width, height } = window.ANYPLOT_SIZE;
8+
9+
// --- Data (in-memory, deterministic) ----------------------------------------
10+
// Gene-disease association network: which genetic markers are linked to which
11+
// conditions, and how strong the evidence for each link is.
12+
const genes = [
13+
"BRCA1", "BRCA2", "TP53", "EGFR", "KRAS", "MYC", "PTEN",
14+
"APC", "VHL", "RB1", "ATM", "CDKN2A", "MLH1", "APOE",
15+
];
16+
const diseases = [
17+
"Breast Cancer", "Ovarian Cancer", "Lung Cancer", "Colorectal Cancer",
18+
"Pancreatic Cancer", "Renal Cell Carcinoma", "Retinoblastoma",
19+
"Melanoma", "Lynch Syndrome", "Alzheimer's Disease",
20+
];
21+
const links = [
22+
{ source: "BRCA1", target: "Breast Cancer", weight: 0.95 },
23+
{ source: "BRCA1", target: "Ovarian Cancer", weight: 0.85 },
24+
{ source: "BRCA2", target: "Breast Cancer", weight: 0.9 },
25+
{ source: "BRCA2", target: "Ovarian Cancer", weight: 0.75 },
26+
{ source: "BRCA2", target: "Pancreatic Cancer", weight: 0.35 },
27+
{ source: "TP53", target: "Breast Cancer", weight: 0.6 },
28+
{ source: "TP53", target: "Lung Cancer", weight: 0.7 },
29+
{ source: "TP53", target: "Colorectal Cancer", weight: 0.55 },
30+
{ source: "TP53", target: "Pancreatic Cancer", weight: 0.4 },
31+
{ source: "TP53", target: "Melanoma", weight: 0.4 },
32+
{ source: "EGFR", target: "Lung Cancer", weight: 0.9 },
33+
{ source: "EGFR", target: "Colorectal Cancer", weight: 0.35 },
34+
{ source: "KRAS", target: "Lung Cancer", weight: 0.65 },
35+
{ source: "KRAS", target: "Colorectal Cancer", weight: 0.85 },
36+
{ source: "KRAS", target: "Pancreatic Cancer", weight: 0.6 },
37+
{ source: "MYC", target: "Breast Cancer", weight: 0.5 },
38+
{ source: "MYC", target: "Lung Cancer", weight: 0.45 },
39+
{ source: "MYC", target: "Colorectal Cancer", weight: 0.4 },
40+
{ source: "PTEN", target: "Breast Cancer", weight: 0.55 },
41+
{ source: "PTEN", target: "Melanoma", weight: 0.5 },
42+
{ source: "PTEN", target: "Renal Cell Carcinoma", weight: 0.3 },
43+
{ source: "APC", target: "Colorectal Cancer", weight: 0.95 },
44+
{ source: "VHL", target: "Renal Cell Carcinoma", weight: 0.9 },
45+
{ source: "RB1", target: "Retinoblastoma", weight: 0.95 },
46+
{ source: "RB1", target: "Lung Cancer", weight: 0.3 },
47+
{ source: "ATM", target: "Breast Cancer", weight: 0.45 },
48+
{ source: "CDKN2A", target: "Melanoma", weight: 0.85 },
49+
{ source: "CDKN2A", target: "Lung Cancer", weight: 0.3 },
50+
{ source: "MLH1", target: "Lynch Syndrome", weight: 0.95 },
51+
{ source: "MLH1", target: "Colorectal Cancer", weight: 0.7 },
52+
{ source: "APOE", target: "Alzheimer's Disease", weight: 0.9 },
53+
];
54+
55+
// Degree = number of edges touching a node, drives node radius.
56+
const degree = new Map([...genes, ...diseases].map((name) => [name, 0]));
57+
for (const l of links) {
58+
degree.set(l.source, degree.get(l.source) + 1);
59+
degree.set(l.target, degree.get(l.target) + 1);
60+
}
61+
62+
// --- Reduce edge crossings: barycenter reordering within each column --------
63+
// Alternately sort each column by the mean position of its neighbors in the
64+
// opposite column, converging toward fewer crossing edges.
65+
function barycenterOrder(names, neighbors, oppositeIndex) {
66+
return [...names].sort((a, b) => {
67+
const na = neighbors.get(a);
68+
const nb = neighbors.get(b);
69+
const ba = na.length ? d3.mean(na, (n) => oppositeIndex.get(n)) : Infinity;
70+
const bb = nb.length ? d3.mean(nb, (n) => oppositeIndex.get(n)) : Infinity;
71+
return ba - bb;
72+
});
73+
}
74+
const geneNeighbors = new Map(genes.map((g) => [g, links.filter((l) => l.source === g).map((l) => l.target)]));
75+
const diseaseNeighbors = new Map(diseases.map((d) => [d, links.filter((l) => l.target === d).map((l) => l.source)]));
76+
77+
let orderedGenes = genes;
78+
let orderedDiseases = diseases;
79+
for (let i = 0; i < 4; i++) {
80+
const diseaseIndex = new Map(orderedDiseases.map((name, idx) => [name, idx]));
81+
orderedGenes = barycenterOrder(orderedGenes, geneNeighbors, diseaseIndex);
82+
const geneIndex = new Map(orderedGenes.map((name, idx) => [name, idx]));
83+
orderedDiseases = barycenterOrder(orderedDiseases, diseaseNeighbors, geneIndex);
84+
}
85+
86+
// --- Layout ------------------------------------------------------------------
87+
const margin = { top: 135, right: 230, bottom: 140, left: 130 };
88+
const iw = width - margin.left - margin.right;
89+
const ih = height - margin.top - margin.bottom;
90+
const leftX = 0;
91+
const rightX = iw;
92+
93+
function columnPositions(names) {
94+
const step = ih / (names.length + 1);
95+
return new Map(names.map((name, i) => [name, (i + 1) * step]));
96+
}
97+
const genesY = columnPositions(orderedGenes);
98+
const diseasesY = columnPositions(orderedDiseases);
99+
100+
const maxDegree = d3.max([...degree.values()]);
101+
const radius = d3.scaleSqrt().domain([1, maxDegree]).range([9, 26]);
102+
const weightExtent = d3.extent(links, (d) => d.weight);
103+
const edgeWidth = d3.scaleLinear().domain(weightExtent).range([1.25, 6]);
104+
const edgeOpacity = d3.scaleLinear().domain(weightExtent).range([0.22, 0.8]);
105+
106+
// --- SVG mount -----------------------------------------------------------
107+
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
108+
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
109+
110+
// --- Edges: d3-shape horizontal links between the two columns ---------------
111+
const linkGenerator = d3.linkHorizontal()
112+
.source((d) => [leftX, genesY.get(d.source)])
113+
.target((d) => [rightX, diseasesY.get(d.target)]);
114+
115+
g.append("g")
116+
.selectAll("path")
117+
.data(links)
118+
.join("path")
119+
.attr("d", linkGenerator)
120+
.attr("fill", "none")
121+
.attr("stroke", t.inkSoft)
122+
.attr("stroke-width", (d) => edgeWidth(d.weight))
123+
.attr("stroke-opacity", (d) => edgeOpacity(d.weight));
124+
125+
// --- Nodes: genes (left column) --------------------------------------------
126+
g.append("g")
127+
.selectAll("circle")
128+
.data(orderedGenes)
129+
.join("circle")
130+
.attr("cx", leftX)
131+
.attr("cy", (d) => genesY.get(d))
132+
.attr("r", (d) => radius(degree.get(d)))
133+
.attr("fill", t.palette[0])
134+
.attr("stroke", t.pageBg)
135+
.attr("stroke-width", 2);
136+
137+
g.append("g")
138+
.selectAll("text")
139+
.data(orderedGenes)
140+
.join("text")
141+
.attr("x", (d) => leftX - radius(degree.get(d)) - 12)
142+
.attr("y", (d) => genesY.get(d))
143+
.attr("dy", "0.35em")
144+
.attr("text-anchor", "end")
145+
.attr("fill", t.inkSoft)
146+
.style("font-size", "15px")
147+
.text((d) => d);
148+
149+
// --- Nodes: diseases (right column) -----------------------------------------
150+
g.append("g")
151+
.selectAll("circle")
152+
.data(orderedDiseases)
153+
.join("circle")
154+
.attr("cx", rightX)
155+
.attr("cy", (d) => diseasesY.get(d))
156+
.attr("r", (d) => radius(degree.get(d)))
157+
.attr("fill", t.palette[1])
158+
.attr("stroke", t.pageBg)
159+
.attr("stroke-width", 2);
160+
161+
g.append("g")
162+
.selectAll("text")
163+
.data(orderedDiseases)
164+
.join("text")
165+
.attr("x", (d) => rightX + radius(degree.get(d)) + 12)
166+
.attr("y", (d) => diseasesY.get(d))
167+
.attr("dy", "0.35em")
168+
.attr("text-anchor", "start")
169+
.attr("fill", t.inkSoft)
170+
.style("font-size", "15px")
171+
.text((d) => d);
172+
173+
// --- Column headers double as the set-membership legend ---------------------
174+
g.append("text")
175+
.attr("x", leftX)
176+
.attr("y", -30)
177+
.attr("text-anchor", "middle")
178+
.attr("fill", t.palette[0])
179+
.style("font-size", "18px")
180+
.style("font-weight", "600")
181+
.text("Genes");
182+
183+
g.append("text")
184+
.attr("x", rightX)
185+
.attr("y", -30)
186+
.attr("text-anchor", "middle")
187+
.attr("fill", t.palette[1])
188+
.style("font-size", "18px")
189+
.style("font-weight", "600")
190+
.text("Diseases");
191+
192+
// --- Legend: degree -> radius and weight -> width/opacity keys --------------
193+
const legend = g.append("g").attr("transform", `translate(0,${ih + 55})`);
194+
195+
legend.append("text")
196+
.attr("x", 0)
197+
.attr("y", -16)
198+
.attr("fill", t.inkSoft)
199+
.style("font-size", "13px")
200+
.style("font-weight", "600")
201+
.text("Node size = degree");
202+
203+
let sx = 0;
204+
for (const d of [1, maxDegree]) {
205+
const r = radius(d);
206+
legend.append("circle")
207+
.attr("cx", sx + r)
208+
.attr("cy", 10)
209+
.attr("r", r)
210+
.attr("fill", "none")
211+
.attr("stroke", t.inkSoft)
212+
.attr("stroke-width", 1.5);
213+
legend.append("text")
214+
.attr("x", sx + 2 * r + 10)
215+
.attr("y", 10)
216+
.attr("dy", "0.35em")
217+
.attr("fill", t.inkSoft)
218+
.style("font-size", "12px")
219+
.text(`degree ${d}`);
220+
sx += 2 * r + 10 + 85;
221+
}
222+
223+
const weightX = sx + 55;
224+
legend.append("text")
225+
.attr("x", weightX)
226+
.attr("y", -16)
227+
.attr("fill", t.inkSoft)
228+
.style("font-size", "13px")
229+
.style("font-weight", "600")
230+
.text("Edge width/opacity = strength");
231+
232+
let wx = weightX;
233+
for (const w of weightExtent) {
234+
legend.append("line")
235+
.attr("x1", wx)
236+
.attr("x2", wx + 40)
237+
.attr("y1", 10)
238+
.attr("y2", 10)
239+
.attr("stroke", t.inkSoft)
240+
.attr("stroke-width", edgeWidth(w))
241+
.attr("stroke-opacity", edgeOpacity(w));
242+
legend.append("text")
243+
.attr("x", wx + 50)
244+
.attr("y", 10)
245+
.attr("dy", "0.35em")
246+
.attr("fill", t.inkSoft)
247+
.style("font-size", "12px")
248+
.text(w.toFixed(2));
249+
wx += 110;
250+
}
251+
252+
// --- Title + subtitle --------------------------------------------------------
253+
svg.append("text")
254+
.attr("x", width / 2)
255+
.attr("y", 52)
256+
.attr("text-anchor", "middle")
257+
.attr("fill", t.ink)
258+
.style("font-size", "26px")
259+
.style("font-weight", "600")
260+
.text("network-bipartite · javascript · d3 · anyplot.ai");
261+
262+
svg.append("text")
263+
.attr("x", width / 2)
264+
.attr("y", 84)
265+
.attr("text-anchor", "middle")
266+
.attr("fill", t.inkSoft)
267+
.style("font-size", "16px")
268+
.text("Node size ∝ degree · edge width & opacity ∝ association strength");

0 commit comments

Comments
 (0)