Repository navigation
Expand file tree
/
Copy pathgraph.js
More file actions
129 lines (114 loc) · 6.41 KB
/
Copy pathgraph.js
File metadata and controls
129 lines (114 loc) · 6.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { loadGraph } from "./lib/platform.js";
// Time runs left to right: the work sits at its year, what it builds on falls
// to the left, what builds on it to the right. Bands separate the three groups.
const data = await loadGraph();
const svg = document.getElementById("svg");
const tip = document.getElementById("tip");
const NS = "http://www.w3.org/2000/svg";
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const COLORS = {
extends: "--r-extends", uses_method: "--r-method", uses_data: "--r-method", background: "--r-background",
surveys: "--r-background", compares: "--r-compare", contrasts: "--r-contrast", critiques: "--r-contrast",
applies: "--r-apply", parallel: "--r-parallel",
};
const color = (rel) => getComputedStyle(document.documentElement).getPropertyValue(COLORS[rel] || "--r-background").trim();
const hidden = new Set();
if (!data) {
document.getElementById("title").textContent = "Open this from the Margin side panel.";
} else {
document.getElementById("title").textContent = data.paper.title;
document.title = `Margin · ${data.paper.title}`;
const rels = [...new Set(Object.values(data.groups).flat().map((p) => p.rel.relation))];
const legend = document.getElementById("legend");
for (const r of rels) {
const b = document.createElement("button");
b.className = `chip rel-${r}`;
b.textContent = r.replace("_", " ");
b.onclick = () => { hidden.has(r) ? hidden.delete(r) : hidden.add(r); b.classList.toggle("off"); draw(); };
legend.append(b);
}
draw();
addEventListener("resize", draw);
}
function draw() {
const W = svg.clientWidth, H = svg.clientHeight;
svg.innerHTML = "";
const pad = { l: 60, r: 60, t: 40, b: 40 };
const bands = [
["buildsOn", "Builds on"],
["builtUpon", "Built upon by"],
["conversation", "In conversation with"],
].filter(([k]) => data.groups[k].length);
const bandH = (H - pad.t - pad.b) / bands.length;
const nodes = [];
bands.forEach(([k], bi) => {
data.groups[k].slice(0, 45).forEach((p) => {
if (hidden.has(p.rel.relation)) return;
nodes.push({ p, band: bi, r: 3 + Math.min(14, Math.sqrt(p.citationCount || 0) / 5) });
});
});
// Ordinal time axis: every year present gets an equal-width column, so a
// single 2001 reference doesn't squash the busy recent years together.
const years = [...new Set([...nodes.map((n) => n.p.year), data.paper.year].filter(Boolean))].sort((a, b) => a - b);
const colW = (W - pad.l - pad.r) / Math.max(1, years.length);
const X = (yr) => pad.l + colW * ((years.indexOf(yr ?? data.paper.year) === -1 ? years.length - 1 : years.indexOf(yr ?? data.paper.year)) + 0.5);
const bandY = (bi) => pad.t + bandH * (bi + 0.5);
// The work itself sits mid-plot at its year; nodes are kept clear of it.
const cx = X(data.paper.year), cy = pad.t + (H - pad.t - pad.b) / 2;
// Anchor each node at (year, band) and relax overlaps.
// Spread each node across its year's column so same-year papers don't pile up.
const yearW = colW;
nodes.forEach((n) => { n.tx = X(n.p.year) + (Math.random() - 0.5) * yearW * 0.9; n.ty = bandY(n.band); n.x = n.tx; n.y = n.ty + (Math.random() - 0.5) * bandH * 0.6; });
for (let it = 0; it < 240; it++) {
for (const n of nodes) { n.x += (n.tx - n.x) * 0.03; n.y += (n.ty - n.y) * 0.02; }
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 0.01, min = a.r + b.r + 3;
if (d < min) { const k = (min - d) / d / 2; a.x -= dx * k * 1.4; a.y -= dy * k * 1.4; b.x += dx * k * 1.4; b.y += dy * k * 1.4; }
}
for (const n of nodes) {
const dx = n.x - cx, dy = n.y - cy, d = Math.hypot(dx, dy) || 0.01, min = n.r + 22;
if (d < min) { n.x += dx / d * (min - d); n.y += dy / d * (min - d); }
}
for (const n of nodes) {
const top = pad.t + bandH * n.band + n.r + 14, bot = pad.t + bandH * (n.band + 1) - n.r - 4;
n.y = Math.max(top, Math.min(bot, n.y));
}
}
const el = (tag, attrs, parent = svg) => { const e = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); parent.append(e); return e; };
// Year axis
const axis = el("g", { class: "axis" });
const every = Math.ceil(years.length / 14);
years.forEach((y, i) => {
el("line", { x1: X(y) - colW / 2, x2: X(y) - colW / 2, y1: pad.t, y2: H - pad.b }, axis);
if (i % every === 0) el("text", { x: X(y), y: H - pad.b + 16, "text-anchor": "middle" }, axis).textContent = y;
});
bands.forEach(([, label], bi) => {
el("text", { class: "band-label", x: pad.l, y: pad.t + bandH * bi + 12 }).textContent = label;
if (bi) el("line", { x1: pad.l, x2: W - pad.r, y1: pad.t + bandH * bi, y2: pad.t + bandH * bi, stroke: "var(--rule)", "stroke-dasharray": "3 4" });
});
const edges = el("g", {});
const edgeEls = nodes.map((n) => {
const mx = (n.x + cx) / 2;
return el("path", { class: "edge", d: `M${n.x},${n.y} C${mx},${n.y} ${mx},${cy} ${cx},${cy}` }, edges);
});
nodes.forEach((n, i) => {
const c = el("circle", { class: "node", cx: n.x, cy: n.y, r: n.r, fill: color(n.p.rel.relation) });
c.onmouseenter = (e) => {
edgeEls[i].classList.add("hot");
tip.style.display = "block";
tip.innerHTML = `<span class="chip rel-${n.p.rel.relation}">${n.p.rel.relation.replace("_", " ")}</span><b>${esc(n.p.title)}</b>
<div style="color:var(--muted)">${esc(n.p.authors?.[0] || "")}${n.p.authors?.length > 1 ? " et al." : ""} · ${n.p.year ?? "?"} · ${n.p.citationCount} cites</div>
${n.p.rel.note ? `<div style="margin-top:4px">${esc(n.p.rel.note)}</div>` : ""}`;
const rect = svg.getBoundingClientRect();
tip.style.left = Math.min(n.x + 14, rect.width - 330) + "px";
tip.style.top = (n.y + 10) + "px";
};
c.onmouseleave = () => { edgeEls[i].classList.remove("hot"); tip.style.display = "none"; };
c.onclick = () => window.open(n.p.url, "_blank");
if (n.r > 9) el("text", { class: "lbl", x: n.x + n.r + 3, y: n.y + 3 }).textContent = (n.p.title.length > 28 ? n.p.title.slice(0, 26) + "…" : n.p.title);
});
el("circle", { class: "center", cx, cy, r: 11 });
el("text", { class: "center-label", x: cx, y: cy - 18, "text-anchor": "middle" }).textContent =
data.paper.title.length > 50 ? data.paper.title.slice(0, 48) + "…" : data.paper.title;
}