Skip to content

Commit 2d6d85a

Browse files
committed
feat(render): GraphChart interactivity — zoom/pan, drag, hover, fit, tooltips (Slice 4a)
Built-in interactivity (on by default) so consumers don't wire it per page: - viewport transform: wheel-zoom (about the cursor, clamped), drag-to-pan; - drag a node → pins it to the layout; release → unpins; - hover-highlight: emphasise a node + its neighbours + incident links, dim the rest; - fitToView(padding), centerOnNode(id, scale), setZoom/panBy, screenToWorld, hitTestNode; - setTooltipRenderer((node) => html) → a positioned tooltip div. Event wiring is skipped when there's no real DOM, so the logic stays headless-testable; 7 new tests (transform inversion, zoom-anchor, clamp, fitToView containment, centering, hit-test, highlight). GraphJS now 88. The graph-chart demo drops its hand-rolled drag and uses the built-in interaction + a tooltip + a Fit button. (No version bump yet — Slice 4b, the Canvas backend, is the remaining piece.)
1 parent 66f7c84 commit 2d6d85a

5 files changed

Lines changed: 515 additions & 27 deletions

File tree

examples/graph-chart.html

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
font-size:10.5px;letter-spacing:.06em;color:var(--muted);background:color-mix(in srgb,var(--card) 80%,transparent);
5353
border:1px solid var(--border);padding:5px 12px;border-radius:20px;white-space:nowrap}
5454
.hint b{color:var(--accent)}
55+
/* built-in tooltip (GraphChart appends .gjs-tooltip to the container) */
56+
#stage{position:relative}
57+
.gjs-tooltip{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:6px 10px;
58+
font-size:12px;color:var(--ink);box-shadow:0 4px 16px rgba(31,35,40,.14);z-index:6;white-space:nowrap}
59+
.gjs-tooltip b{color:var(--accent)}
5560
</style>
5661
</head>
5762
<body>
@@ -69,9 +74,10 @@
6974
</header>
7075

7176
<div id="stage"></div>
72-
<div class="hint"><b>Drag</b> a node · toggle the node/link renderers below — the chart draws the SVG for you</div>
77+
<div class="hint"><b>Drag</b> nodes · <b>scroll</b> to zoom · <b>hover</b> to highlight — all built into GraphChart</div>
7378
<div class="dock">
7479
<button id="toggle">View: Template cards</button>
80+
<button id="fit">Fit</button>
7581
<button id="reheat">Reheat</button>
7682
</div>
7783

@@ -150,32 +156,15 @@
150156

151157
resize();
152158
applyRenderers();
159+
// Drag, wheel-zoom, pan, and hover-highlight are built into GraphChart — add a tooltip too.
160+
chart.setTooltipRenderer((node) => `<b>${node.data.name}</b> · ${node.data.group}`);
153161
chart.render(graph, layout); // subscribes to ticks; the chart redraws itself
154162
layout.start();
155163

156-
// ---- drag (pin the node under the cursor to the mouse) --------------------
157-
const svg = stage.querySelector("svg");
158-
const pt = (e) => { const r = svg.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
159-
function nodeAt(x, y) {
160-
for (const n of graph.getNodes()) { const dx = x - n.x, dy = y - n.y; if (dx * dx + dy * dy <= 20 * 20) return n; }
161-
return null;
162-
}
163-
let dragging = null;
164-
svg.addEventListener("mousedown", (e) => {
165-
const p = pt(e); const n = nodeAt(p.x, p.y);
166-
if (n) { dragging = n; svg.classList.add("dragging"); layout.pinNode(n.id, p.x, p.y); layout.restart(0.5); }
167-
});
168-
addEventListener("mousemove", (e) => {
169-
if (!dragging) return;
170-
const p = pt(e); layout.pinNode(dragging.id, p.x, p.y); layout.restart(0.35);
171-
});
172-
addEventListener("mouseup", () => {
173-
if (dragging) { layout.unpinNode(dragging.id); dragging = null; svg.classList.remove("dragging"); layout.restart(0.2); }
174-
});
175-
176164
// ---- controls -------------------------------------------------------------
177165
// cycle Template cards → Custom circles → Default (all live via set*Renderer)
178166
toggleBtn.onclick = () => { mode = (mode + 1) % MODES.length; applyRenderers(); };
167+
document.getElementById("fit").onclick = () => chart.fitToView(60);
179168
document.getElementById("reheat").onclick = () => layout.restart(1);
180169
</script>
181170
</body>

examples/graphjs.esm.js

Lines changed: 213 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,11 +1468,24 @@ class GraphChart {
14681468
this.curveParallel = options.curveParallel !== false;
14691469
this.curveStep = options.curveStep ?? 22;
14701470
this.endGap = options.endGap ?? this.nodeRadius + 7;
1471+
this.interactive = options.interactive !== false;
1472+
this.minZoom = options.minZoom ?? 0.2;
1473+
this.maxZoom = options.maxZoom ?? 4;
1474+
this.tooltipRenderer = options.tooltipRenderer || null;
14711475
this.graph = null;
14721476
this.layout = null;
14731477
this._nodeEls = new Map;
14741478
this._linkEls = [];
14751479
this._onTick = () => this._position();
1480+
this._tx = 0;
1481+
this._ty = 0;
1482+
this._scale = 1;
1483+
this._adj = new Map;
1484+
this._hover = null;
1485+
this._drag = null;
1486+
this._pan = null;
1487+
this._tooltip = null;
1488+
this._listeners = [];
14761489
if (this.doc && container)
14771490
this._mount();
14781491
}
@@ -1489,10 +1502,15 @@ class GraphChart {
14891502
this.linkLabelLayer.setAttribute("class", "gjs-link-labels");
14901503
this.nodeLayer = this._el("g");
14911504
this.nodeLayer.setAttribute("class", "gjs-nodes");
1492-
this.svg.appendChild(this.linkLayer);
1493-
this.svg.appendChild(this.linkLabelLayer);
1494-
this.svg.appendChild(this.nodeLayer);
1505+
this.scene = this._el("g");
1506+
this.scene.setAttribute("class", "gjs-viewport");
1507+
this.scene.appendChild(this.linkLayer);
1508+
this.scene.appendChild(this.linkLabelLayer);
1509+
this.scene.appendChild(this.nodeLayer);
1510+
this.svg.appendChild(this.scene);
14951511
this.container.appendChild(this.svg);
1512+
if (this.interactive)
1513+
this._bindInteraction();
14961514
}
14971515
_defs() {
14981516
const defs = this._el("defs");
@@ -1624,6 +1642,18 @@ class GraphChart {
16241642
this.nodeLayer.appendChild(g);
16251643
this._nodeEls.set(node.id, g);
16261644
}
1645+
this._buildAdjacency();
1646+
}
1647+
_buildAdjacency() {
1648+
this._adj = new Map;
1649+
for (const l of this.graph && this.graph.linkList || []) {
1650+
if (!this._adj.has(l.source.id))
1651+
this._adj.set(l.source.id, new Set);
1652+
if (!this._adj.has(l.target.id))
1653+
this._adj.set(l.target.id, new Set);
1654+
this._adj.get(l.source.id).add(l.target.id);
1655+
this._adj.get(l.target.id).add(l.source.id);
1656+
}
16271657
}
16281658
_buildTemplateNode(node, g, spec) {
16291659
if (spec.cssClass)
@@ -1773,11 +1803,191 @@ class GraphChart {
17731803
nodeElement(id) {
17741804
return this._nodeEls.get(id);
17751805
}
1806+
_applyTransform() {
1807+
if (this.scene)
1808+
this.scene.setAttribute("transform", `translate(${this._tx}, ${this._ty}) scale(${this._scale})`);
1809+
}
1810+
screenToWorld(px, py) {
1811+
return { x: (px - this._tx) / this._scale, y: (py - this._ty) / this._scale };
1812+
}
1813+
setZoom(scale, cx, cy) {
1814+
const s = Math.max(this.minZoom, Math.min(this.maxZoom, scale));
1815+
if (cx != null && cy != null) {
1816+
const w = this.screenToWorld(cx, cy);
1817+
this._scale = s;
1818+
this._tx = cx - w.x * s;
1819+
this._ty = cy - w.y * s;
1820+
} else {
1821+
this._scale = s;
1822+
}
1823+
this._applyTransform();
1824+
return this;
1825+
}
1826+
panBy(dx, dy) {
1827+
this._tx += dx;
1828+
this._ty += dy;
1829+
this._applyTransform();
1830+
return this;
1831+
}
1832+
fitToView(padding = 40) {
1833+
const nodes = this.graph ? this.graph.getNodes() : [];
1834+
if (!nodes.length)
1835+
return this;
1836+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1837+
for (const n of nodes) {
1838+
if (n.x < minX)
1839+
minX = n.x;
1840+
if (n.y < minY)
1841+
minY = n.y;
1842+
if (n.x > maxX)
1843+
maxX = n.x;
1844+
if (n.y > maxY)
1845+
maxY = n.y;
1846+
}
1847+
const spanX = maxX - minX || 1, spanY = maxY - minY || 1;
1848+
this._scale = Math.max(this.minZoom, Math.min(this.maxZoom, Math.min((this.width - 2 * padding) / spanX, (this.height - 2 * padding) / spanY)));
1849+
this._tx = (this.width - (minX + maxX) * this._scale) / 2;
1850+
this._ty = (this.height - (minY + maxY) * this._scale) / 2;
1851+
this._applyTransform();
1852+
return this;
1853+
}
1854+
centerOnNode(id, scale) {
1855+
const n = this.graph && this.graph.getNode(id);
1856+
if (!n)
1857+
return this;
1858+
if (scale != null)
1859+
this._scale = Math.max(this.minZoom, Math.min(this.maxZoom, scale));
1860+
this._tx = this.width / 2 - n.x * this._scale;
1861+
this._ty = this.height / 2 - n.y * this._scale;
1862+
this._applyTransform();
1863+
return this;
1864+
}
1865+
hitTestNode(px, py) {
1866+
const w = this.screenToWorld(px, py);
1867+
const r = this.nodeRadius + 6;
1868+
let best = null, bestD = r * r;
1869+
for (const n of this.graph ? this.graph.getNodes() : []) {
1870+
const dx = w.x - n.x, dy = w.y - n.y, d2 = dx * dx + dy * dy;
1871+
if (d2 <= bestD) {
1872+
bestD = d2;
1873+
best = n;
1874+
}
1875+
}
1876+
return best;
1877+
}
1878+
setHighlight(nodeId) {
1879+
this._hover = nodeId;
1880+
const lit = nodeId != null ? new Set([nodeId, ...this._adj.get(nodeId) || []]) : null;
1881+
for (const [id, g] of this._nodeEls)
1882+
g.setAttribute("opacity", lit && !lit.has(id) ? "0.25" : "1");
1883+
for (const { link, el, label } of this._linkEls) {
1884+
const incident = nodeId == null || link.source.id === nodeId || link.target.id === nodeId;
1885+
const op = nodeId != null && !incident ? "0.12" : "1";
1886+
el.setAttribute("opacity", op);
1887+
if (label)
1888+
label.setAttribute("opacity", op);
1889+
}
1890+
return this;
1891+
}
1892+
setTooltipRenderer(fn) {
1893+
this.tooltipRenderer = fn;
1894+
return this;
1895+
}
1896+
_showTooltip(node, px, py) {
1897+
if (!this.tooltipRenderer || !this.doc || typeof this.doc.createElement !== "function")
1898+
return;
1899+
if (!this._tooltip) {
1900+
this._tooltip = this.doc.createElement("div");
1901+
this._tooltip.setAttribute("class", "gjs-tooltip");
1902+
this._tooltip.style.position = "absolute";
1903+
this._tooltip.style.pointerEvents = "none";
1904+
this.container.appendChild(this._tooltip);
1905+
}
1906+
this._tooltip.innerHTML = this.tooltipRenderer(node);
1907+
this._tooltip.style.display = "block";
1908+
this._tooltip.style.left = px + 14 + "px";
1909+
this._tooltip.style.top = py + 14 + "px";
1910+
}
1911+
_hideTooltip() {
1912+
if (this._tooltip)
1913+
this._tooltip.style.display = "none";
1914+
}
1915+
_bindInteraction() {
1916+
const svg = this.svg;
1917+
if (!svg || typeof svg.addEventListener !== "function")
1918+
return;
1919+
const add = (t, ev, fn, opts) => {
1920+
t.addEventListener(ev, fn, opts);
1921+
this._listeners.push([t, ev, fn]);
1922+
};
1923+
const pos = (e) => {
1924+
const r = svg.getBoundingClientRect ? svg.getBoundingClientRect() : { left: 0, top: 0 };
1925+
return { x: e.clientX - r.left, y: e.clientY - r.top };
1926+
};
1927+
add(svg, "mousedown", (e) => {
1928+
const p = pos(e);
1929+
const n = this.hitTestNode(p.x, p.y);
1930+
if (n && this.layout && this.layout.pinNode) {
1931+
const w = this.screenToWorld(p.x, p.y);
1932+
this._drag = n;
1933+
this.layout.pinNode(n.id, w.x, w.y);
1934+
if (this.layout.restart)
1935+
this.layout.restart(0.5);
1936+
} else {
1937+
this._pan = p;
1938+
}
1939+
});
1940+
add(svg, "mousemove", (e) => {
1941+
const p = pos(e);
1942+
if (this._drag) {
1943+
const w = this.screenToWorld(p.x, p.y);
1944+
this.layout.pinNode(this._drag.id, w.x, w.y);
1945+
if (this.layout.restart)
1946+
this.layout.restart(0.35);
1947+
} else if (this._pan) {
1948+
this.panBy(p.x - this._pan.x, p.y - this._pan.y);
1949+
this._pan = p;
1950+
} else {
1951+
const n = this.hitTestNode(p.x, p.y);
1952+
const id = n ? n.id : null;
1953+
if (id !== this._hover)
1954+
this.setHighlight(id);
1955+
if (n)
1956+
this._showTooltip(n, p.x, p.y);
1957+
else
1958+
this._hideTooltip();
1959+
if (svg.style)
1960+
svg.style.cursor = n ? "pointer" : "grab";
1961+
}
1962+
});
1963+
add(this.doc, "mouseup", () => {
1964+
if (this._drag && this.layout && this.layout.unpinNode) {
1965+
this.layout.unpinNode(this._drag.id);
1966+
if (this.layout.restart)
1967+
this.layout.restart(0.2);
1968+
}
1969+
this._drag = null;
1970+
this._pan = null;
1971+
});
1972+
add(svg, "wheel", (e) => {
1973+
if (e.preventDefault)
1974+
e.preventDefault();
1975+
const p = pos(e);
1976+
this.setZoom(this._scale * Math.exp(-(e.deltaY || 0) * 0.0015), p.x, p.y);
1977+
}, { passive: false });
1978+
}
17761979
destroy() {
17771980
if (this.layout && this.layout.off) {
17781981
this.layout.off("tick", this._onTick);
17791982
this.layout.off("end", this._onTick);
17801983
}
1984+
for (const [t, ev, fn] of this._listeners)
1985+
if (t.removeEventListener)
1986+
t.removeEventListener(ev, fn);
1987+
this._listeners = [];
1988+
if (this._tooltip && this._tooltip.parentNode)
1989+
this._tooltip.parentNode.removeChild(this._tooltip);
1990+
this._tooltip = null;
17811991
if (this.svg && this.svg.parentNode)
17821992
this.svg.parentNode.removeChild(this.svg);
17831993
this._nodeEls.clear();

0 commit comments

Comments
 (0)