Files
plainleaf/plugs/object-graph/ui/components/graph_canvas.tsx
T

1006 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { editor } from "@silverbulletmd/silverbullet/syscalls";
import * as d3 from "d3-force";
import ForceGraphImpl from "force-graph";
import { Component } from "preact";
import type { Edge, ForceSettings, ObjectNode } from "../../src/model.ts";
import { STRUCTURAL_KINDS } from "../../src/model.ts";
import { colorForTag } from "../colors.ts";
// All four force parameters are now driven by sliders in the sidebar;
// see ForceSettings in src/model.ts for the defaults.
const CLICK_DELAY_MS = 220;
// Upper bound for the auto-fit camera scale. Prevents zoomToFit from
// magnifying a single isolated node to fill the entire canvas.
const MAX_AUTO_ZOOM = 4;
// Tick budgets before the simulation cools and we auto-fit the camera (via
// onEngineStop → recenter). Fitting on a fixed timer fit mid-layout and left
// nodes half off-screen; fitting on settle is reliable. A from-scratch first
// layout needs more settling than an incremental expansion.
const FIRST_FIT_COOLDOWN_TICKS = 140;
const REFIT_COOLDOWN_TICKS = 60;
type NodeStatus = "expanded" | "ghost";
type Props = {
nodes: { node: ObjectNode; status: NodeStatus }[];
edges: Edge[];
selectedRef: string | null;
onNodeClick: (ref: string) => void;
hideEdgeLabels: boolean;
forces: ForceSettings;
};
type FGNode = {
id: string;
node: ObjectNode;
status: NodeStatus;
kind: ObjectNode["kind"];
dangling: boolean;
title: string;
prefix?: string;
degree: number;
color: string;
x?: number;
y?: number;
// Pin coordinates set on drag. Kept as `number` (never null) so FGNode
// satisfies both force-graph's `NodeObject` and d3's `SimulationNodeDatum`.
fx?: number;
fy?: number;
};
// Panel-side merged edge — collapses parallel edges between the same pair
// of nodes (regardless of direction) into one record. `bidirectional` is
// set when at least one of the merged inputs ran the reverse direction.
type MergedEdge = Edge & { bidirectional: boolean };
type FGLink = {
source: string | FGNode;
target: string | FGNode;
edge: MergedEdge;
};
// force-graph 1.51 ships class-style typings (`new ForceGraph(el)`), but the
// runtime default export is still the Kapsule factory invoked as
// `ForceGraph()(element)`. Bridge the two: keep the chainable instance type
// (parameterized with our own node/link shapes so the accessor callbacks
// below type-check) while treating the import as the runtime factory.
type ForceGraphInstance = ForceGraphImpl<FGNode, FGLink>;
const ForceGraph = ForceGraphImpl as unknown as () => (
element: HTMLElement,
) => ForceGraphInstance;
type Theme = {
bg: string;
nodeDim: string;
label: string;
labelDim: string;
labelHalo: string;
link: string;
linkDim: string;
linkHot: string;
accent: string;
};
type State = {
edgeHover: { edge: MergedEdge; x: number; y: number } | null;
ghostHover: { title: string; x: number; y: number } | null;
};
type ComputedGraph = {
nodes: FGNode[];
links: FGLink[];
adjacency: Map<string, Set<string>>;
radii: Map<string, number>;
};
function readTheme(): Theme {
const cs = getComputedStyle(document.documentElement);
const v = (n: string, fallback: string) =>
cs.getPropertyValue(n).trim() || fallback;
return {
bg: v("--gv-bg", "#ffffff"),
nodeDim: v("--gv-node-dim", "#9e4705"),
label: v("--gv-label", "#333"),
labelDim: v("--gv-label-dim", "#676767"),
labelHalo: v("--gv-label-halo", "rgba(255,255,255,0.78)"),
link: v("--gv-link", "#c8c8c8"),
linkDim: v("--gv-link-dim", "#e6e6e6"),
linkHot: v("--gv-link-hot", "#464cfc"),
accent: v("--gv-accent", "#650007"),
};
}
function getId(end: string | FGNode): string {
return typeof end === "string" ? end : end.id;
}
function nodeRadius(degree: number): number {
// Modest growth with degree. Hubs go a bit larger but stay readable.
return 6 + Math.min(degree, 10) * 0.5;
}
// Strip directory prefix; show just the last path segment for graph labels.
function displayName(title: string): string {
const i = title.lastIndexOf("/");
return i === -1 ? title : title.slice(i + 1);
}
// Label visibility thresholds expressed in zoom (graph→screen) scale.
const NODE_LABEL_MIN_SCALE = 0.6;
const EDGE_LABEL_MIN_SCALE = 1.8;
function computeGraph(
inputNodes: Props["nodes"],
edges: Edge[],
): ComputedGraph {
const degree = new Map<string, number>();
const adj = new Map<string, Set<string>>();
for (const ns of inputNodes) {
degree.set(ns.node.ref, 0);
adj.set(ns.node.ref, new Set());
}
for (const e of edges) {
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
adj.get(e.source)?.add(e.target);
adj.get(e.target)?.add(e.source);
}
const fgNodes: FGNode[] = inputNodes.map(({ node, status }) => ({
id: node.ref,
node,
status,
kind: node.kind,
dangling: node.dangling,
title: node.title,
prefix: node.prefix,
degree: degree.get(node.ref) ?? 0,
color: colorForTag(node.primaryTag),
}));
// Merge all edges between the same pair of nodes (unordered) into a
// single visual edge with a comma-joined label, combined provenance,
// and a `bidirectional` flag when both directions were present.
type Accum = MergedEdge & { labels: Set<string> };
const merged = new Map<string, Accum>();
for (const e of edges) {
const [a, b] =
e.source < e.target ? [e.source, e.target] : [e.target, e.source];
const key = `${a}\x00${b}`;
const existing = merged.get(key);
if (!existing) {
merged.set(key, {
...e,
refs: [...e.refs],
labels: new Set([e.label]),
bidirectional: false,
});
continue;
}
existing.labels.add(e.label);
existing.refs.push(...e.refs);
if (e.undirected) existing.undirected = true;
if (e.source !== existing.source) existing.bidirectional = true;
// Non-mention kinds beat `mention` for styling purposes.
if (existing.kind === "mention" && e.kind !== "mention") {
existing.kind = e.kind;
}
}
const fgLinks: FGLink[] = [...merged.values()].map((acc) => {
const { labels, ...edge } = acc;
edge.label = [...labels].join(", ");
return { source: edge.source, target: edge.target, edge };
});
const rmap = new Map<string, number>();
for (const n of fgNodes) rmap.set(n.id, nodeRadius(n.degree));
return {
nodes: fgNodes,
links: fgLinks,
adjacency: adj,
radii: rmap,
};
}
export class GraphCanvas extends Component<Props, State> {
state: State = { edgeHover: null, ghostHover: null };
// DOM + force-graph handles.
private containerRef: HTMLDivElement | null = null;
private fg: ForceGraphInstance | null = null;
// Hover/adjacency state read by draw callbacks every frame.
private hoveredId: string | null = null;
private neighbors: Set<string> = new Set();
private adjacency: Map<string, Set<string>> = new Map();
private theme: Theme = readTheme();
private radiusMap: Map<string, number> = new Map();
// Mouse position relative to the canvas container; drives the edge tooltip.
private mousePos: { x: number; y: number } = { x: 0, y: 0 };
// Click-cancel timers for distinguishing single vs. double click.
private clickTimer: number | null = null;
private edgeClickTimer: number | null = null;
// Whether we've fed data once already. Both the first feed and later ones
// (expansions/collapses) arm `pendingFit` and auto-fit once the simulation
// settles (see onEngineStop); only the cooldown budget differs.
private fedOnce = false;
// Set when a data-driven feed wants the camera re-fitted after the
// simulation next settles. Gated so node drags (which also re-heat and
// fire onEngineStop) don't trigger camera jumps.
private pendingFit = false;
// Cached computation of nodes/links/adjacency/radii; recomputed in
// componentDidUpdate when props.nodes or props.edges identity changes.
private computed: ComputedGraph;
// Listeners/observers we own and must tear down.
private resizeObserver: ResizeObserver | null = null;
private mql: MediaQueryList | null = null;
constructor(props: Props) {
super(props);
this.computed = computeGraph(props.nodes, props.edges);
// Bind handlers used as listener references / bare JSX onClicks so
// `this` resolves correctly and identities stay stable across
// add/removeEventListener pairs.
this.onMouseMove = this.onMouseMove.bind(this);
this.onThemeChange = this.onThemeChange.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.zoomIn = this.zoomIn.bind(this);
this.zoomOut = this.zoomOut.bind(this);
this.pan = this.pan.bind(this);
this.recenter = this.recenter.bind(this);
}
private onMouseMove(ev: MouseEvent) {
if (!this.containerRef) return;
const rect = this.containerRef.getBoundingClientRect();
this.mousePos = {
x: ev.clientX - rect.left,
y: ev.clientY - rect.top,
};
}
private onThemeChange() {
this.theme = readTheme();
this.rerender();
}
private onKeyDown(e: KeyboardEvent) {
const tgt = e.target as HTMLElement | null;
if (
tgt &&
(tgt.tagName === "INPUT" ||
tgt.tagName === "TEXTAREA" ||
(tgt as HTMLElement).isContentEditable)
)
return;
switch (e.key) {
case "ArrowUp":
this.pan(0, -1);
break;
case "ArrowDown":
this.pan(0, 1);
break;
case "ArrowLeft":
this.pan(-1, 0);
break;
case "ArrowRight":
this.pan(1, 0);
break;
case "PageUp":
case "+":
case "=":
this.zoomIn();
break;
case "PageDown":
case "-":
case "_":
this.zoomOut();
break;
case "f":
case "F":
this.recenter();
break;
default:
return;
}
e.preventDefault();
}
// Configure the link force's strength + distance accessors. The link
// set itself is owned by force-graph (rebound from graphData on every
// update), so we only set the per-link knobs. Multi-edge pairs get a
// proportionally stronger spring for Obsidian-style cluster pull.
private configureLinkForce() {
const fg = this.fg;
if (!fg) return;
const f = this.props.forces;
const linkForce = fg.d3Force("link") as d3.ForceLink<FGNode, FGLink> | null;
if (!linkForce) return;
const mult = (l: FGLink) => Math.min(l.edge.refs.length, 6);
linkForce.strength((l: FGLink) => f.linkStrength * mult(l));
linkForce.distance(f.linkDistance);
}
private applyForces() {
const fg = this.fg;
if (!fg) return;
const f = this.props.forces;
const centerStrength = (n: FGNode) =>
n.degree === 0 ? f.centerStrength * 6 : f.centerStrength;
(fg.d3Force("x") as d3.ForceX<FGNode> | null)?.strength(centerStrength);
(fg.d3Force("y") as d3.ForceY<FGNode> | null)?.strength(centerStrength);
(fg.d3Force("charge") as d3.ForceManyBody<FGNode> | null)?.strength(
f.chargeStrength,
);
this.configureLinkForce();
// Re-heat softly so the new force field has a chance to settle.
fg.d3ReheatSimulation();
}
componentDidMount() {
if (!this.containerRef) return;
const fg = ForceGraph()(this.containerRef);
this.fg = fg;
fg.backgroundColor("transparent")
.nodeId("id")
.nodeCanvasObjectMode(() => "replace")
.nodeCanvasObject(
(node: FGNode, ctx: CanvasRenderingContext2D, scale: number) =>
this.drawNode(node, ctx, scale),
)
.nodePointerAreaPaint(
(node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = color;
ctx.beginPath();
const r = (this.radiusMap.get(node.id) ?? 12) + 4;
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
ctx.fill();
},
)
.linkCanvasObjectMode(() => "replace")
.linkCanvasObject(
(link: FGLink, ctx: CanvasRenderingContext2D, scale: number) =>
this.drawLink(link, ctx, scale),
)
.onNodeHover((node: FGNode | null) => {
const id = node?.id ?? null;
this.hoveredId = id;
this.neighbors = id ? (this.adjacency.get(id) ?? new Set()) : new Set();
if (node && node.status === "ghost") {
this.setState({
ghostHover: {
title: node.title,
x: this.mousePos.x,
y: this.mousePos.y,
},
});
} else if (this.state.ghostHover) {
this.setState({ ghostHover: null });
}
this.rerender();
})
.onNodeClick((node: FGNode) => this.handleNodeClick(node))
.onLinkHover((link: FGLink | null) => {
if (!link) {
this.setState({ edgeHover: null });
return;
}
this.setState({
edgeHover: {
edge: link.edge,
x: this.mousePos.x,
y: this.mousePos.y,
},
});
})
.onLinkClick((link: FGLink) => this.handleLinkClick(link))
.onNodeDrag((node: FGNode) => {
// Pin node where it's dragged.
node.fx = node.x;
node.fy = node.y;
})
.onNodeDragEnd((node: FGNode) => {
node.fx = node.x;
node.fy = node.y;
})
.onEngineStop(() => {
// After a data-driven feed re-heats the simulation and it settles,
// re-fit the camera so newly added/removed nodes stay in view.
// Gated by pendingFit so drag-induced settles don't move the camera.
if (this.pendingFit) {
this.pendingFit = false;
this.recenter();
}
});
const f = this.props.forces;
// Center pull, degree-aware: isolated nodes (degree 0) get a much
// stronger pull so they don't drift off into the void; well-connected
// nodes use the slider's base strength so clusters can still spread.
const centerStrength = (n: FGNode) =>
n.degree === 0 ? f.centerStrength * 6 : f.centerStrength;
fg.d3Force("x", d3.forceX<FGNode>(0).strength(centerStrength));
fg.d3Force("y", d3.forceY<FGNode>(0).strength(centerStrength));
// Generous collide padding so node labels (rendered below each node)
// have room to breathe and don't overlap with neighbors.
fg.d3Force(
"collide",
d3
.forceCollide<FGNode>((n) => (this.radiusMap.get(n.id) ?? 12) + 16)
.strength(0.85),
);
(fg.d3Force("charge") as d3.ForceManyBody<any> | null)?.strength(
f.chargeStrength,
);
// Force-graph maintains the link force itself: its `update()` runs
// after each graphData change and rebinds the link set from the
// merged links we pass in. We only override the per-link strength
// accessor (Obsidian-style cluster pull: pairs joined by multiple
// relations get a proportionally stronger spring) and the rest
// length, plus the radial center pull above.
this.configureLinkForce();
const resize = () => {
if (!this.containerRef) return;
fg.width(this.containerRef.clientWidth);
fg.height(this.containerRef.clientHeight);
};
resize();
this.resizeObserver = new ResizeObserver(resize);
this.resizeObserver.observe(this.containerRef);
this.containerRef.addEventListener("mousemove", this.onMouseMove);
this.mql = window.matchMedia("(prefers-color-scheme: dark)");
this.mql.addEventListener?.("change", this.onThemeChange);
window.addEventListener("keydown", this.onKeyDown);
// Initial data feed.
this.feedData();
}
componentDidUpdate(prevProps: Props) {
// Recompute derived graph data if either input identity changed.
const inputsChanged =
prevProps.nodes !== this.props.nodes ||
prevProps.edges !== this.props.edges;
if (inputsChanged) {
this.computed = computeGraph(this.props.nodes, this.props.edges);
this.feedData();
}
if (prevProps.hideEdgeLabels !== this.props.hideEdgeLabels) {
// Edge-label visibility flipped: force a repaint via no-op accessor swap.
const fg = this.fg;
if (fg) fg.linkCanvasObject(fg.linkCanvasObject());
}
if (prevProps.selectedRef !== this.props.selectedRef) {
// Selected-node ring change: force a repaint.
const fg = this.fg;
if (fg) fg.nodeCanvasObject(fg.nodeCanvasObject());
}
if (prevProps.forces !== this.props.forces) {
this.applyForces();
}
}
componentWillUnmount() {
this.cancelClickTimer();
this.cancelEdgeClickTimer();
if (this.containerRef) {
this.containerRef.removeEventListener("mousemove", this.onMouseMove);
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.mql?.removeEventListener?.("change", this.onThemeChange);
this.mql = null;
window.removeEventListener("keydown", this.onKeyDown);
if (this.fg) {
this.fg.pauseAnimation();
(this.fg as any)._destructor?.();
}
if (this.containerRef) this.containerRef.innerHTML = "";
this.fg = null;
}
private rerender() {
// No-op accessor swap forces force-graph to repaint immediately.
const fg = this.fg;
if (!fg) return;
fg.nodeCanvasObject(fg.nodeCanvasObject());
}
// Feed nodes/links into force-graph, preserving positions and softly
// re-heating the simulation. The first feed auto-fits the camera after a
// short delay; later feeds re-fit once the simulation settles.
private feedData() {
const fg = this.fg;
if (!fg) return;
const { nodes, links, adjacency, radii } = this.computed;
this.adjacency = adjacency;
this.radiusMap = radii;
// Carry over positions from the previous data so existing nodes stay put
// and new ghosts emerge from the position of an already-placed neighbor
// (rather than dropping in from random coordinates).
const prev = fg.graphData().nodes as FGNode[];
const prevById = new Map<string, FGNode>();
for (const p of prev) prevById.set(p.id, p);
for (const n of nodes) {
const old = prevById.get(n.id);
if (old) {
// Existing node — preserve its current physics state.
n.x = old.x;
n.y = old.y;
n.fx = old.fx;
n.fy = old.fy;
continue;
}
// New node — start at an already-placed connected neighbor.
const neighbors = adjacency.get(n.id);
if (!neighbors) continue;
for (const nbId of neighbors) {
const placed = prevById.get(nbId);
if (placed && placed.x !== undefined && placed.y !== undefined) {
// Tiny jitter avoids stacking new ghosts perfectly on top of each other.
n.x = placed.x + (Math.random() - 0.5) * 2;
n.y = placed.y + (Math.random() - 0.5) * 2;
break;
}
}
}
fg.graphData({ nodes, links });
// Re-apply our link-force tuning. force-graph's update() runs after
// graphData and rebinds the link set from the merged links, but
// doesn't touch strength/distance accessors. Re-set them now so the
// per-link multiplicity factor still applies after the rebind.
this.configureLinkForce();
this.hoveredId = null;
this.neighbors = new Set();
// Auto-fit the camera once the simulation cools (onEngineStop →
// recenter), never on a fixed timer — fitting mid-layout leaves nodes
// half off-screen. Bound the run with cooldownTicks so the first
// from-scratch layout settles in a few hundred ms instead of the
// engine's multi-second default. recenter() handles the single-node and
// MAX_AUTO_ZOOM cases.
this.pendingFit = true;
fg.cooldownTicks(
this.fedOnce ? REFIT_COOLDOWN_TICKS : FIRST_FIT_COOLDOWN_TICKS,
);
this.fedOnce = true;
}
private cancelClickTimer() {
if (this.clickTimer !== null) {
clearTimeout(this.clickTimer);
this.clickTimer = null;
}
}
private handleNodeClick(node: FGNode) {
if (this.clickTimer !== null) {
// Second click within window → treat as double-click.
this.cancelClickTimer();
void this.handleDoubleClick(node);
return;
}
this.clickTimer = window.setTimeout(() => {
this.clickTimer = null;
this.props.onNodeClick(node.id);
}, CLICK_DELAY_MS);
}
private cancelEdgeClickTimer() {
if (this.edgeClickTimer !== null) {
clearTimeout(this.edgeClickTimer);
this.edgeClickTimer = null;
}
}
private handleLinkClick(link: FGLink) {
if (this.edgeClickTimer !== null) {
this.cancelEdgeClickTimer();
void this.handleLinkDoubleClick(link);
return;
}
this.edgeClickTimer = window.setTimeout(() => {
this.edgeClickTimer = null;
// Single click on an edge: no-op for now (no edge selection model).
}, CLICK_DELAY_MS);
}
private async handleLinkDoubleClick(link: FGLink) {
const prov = link.edge.refs[0];
if (!prov) return;
try {
const ref =
prov.pos !== undefined ? `${prov.page}@${prov.pos}` : prov.page;
await editor.navigate(ref);
await editor.hidePanel("modal");
} catch (err) {
console.error("object-graph: edge navigation failed", err);
}
}
private async handleDoubleClick(node: FGNode) {
try {
if (node.kind === "url") {
await editor.openUrl(node.id);
return;
}
// An item/block node whose ref carries no `@pos` holds a bare
// `$anchor` name (e.g. an item tagged `$pete-ref`). Navigate via the
// anchor (`$name`, empty page path) so SB resolves it through the
// index to wherever the anchor currently lives, instead of treating
// the bare name as a page. Positional refs (`page@pos`), pages and
// files navigate directly.
const isAnchorRef =
(node.kind === "item" || node.kind === "block") &&
!node.id.includes("@");
const ref = isAnchorRef ? `$${node.id}` : node.id;
await editor.navigate(ref);
await editor.hidePanel("modal");
} catch (err) {
console.error("object-graph: navigation failed", err);
}
}
private isHighlighted(id: string): boolean {
const hov = this.hoveredId;
if (!hov) return true;
return id === hov || this.neighbors.has(id);
}
private linkTouchesHover(link: FGLink): boolean {
const hov = this.hoveredId;
if (!hov) return false;
return getId(link.source) === hov || getId(link.target) === hov;
}
private drawNode(node: FGNode, ctx: CanvasRenderingContext2D, scale: number) {
const t = this.theme;
const radius = this.radiusMap.get(node.id) ?? nodeRadius(node.degree);
const x = node.x ?? 0;
const y = node.y ?? 0;
const highlighted = this.isHighlighted(node.id);
const isSelected = node.id === this.props.selectedRef;
ctx.save();
let alpha = node.status === "ghost" ? 0.45 : 1;
if (!highlighted) alpha *= 0.35;
ctx.globalAlpha = alpha;
if (node.dangling) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = t.bg || "#fff";
ctx.fill();
ctx.setLineDash([3 / scale, 2 / scale]);
ctx.lineWidth = 1.5 / scale;
ctx.strokeStyle = t.nodeDim;
ctx.stroke();
ctx.setLineDash([]);
} else {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
}
if (isSelected) {
ctx.beginPath();
ctx.arc(x, y, radius + 2 / scale, 0, Math.PI * 2);
ctx.lineWidth = 2 / scale;
ctx.strokeStyle = t.accent;
ctx.stroke();
}
// Label rendered BELOW the node, scaled to constant screen pixels.
// Hide when zoomed way out OR when this node is dimmed by hover.
const showLabel =
scale >= NODE_LABEL_MIN_SCALE && (highlighted || this.hoveredId === null);
if (showLabel) {
const fontPx = isSelected ? 13 : 11;
const fontSize = fontPx / scale;
ctx.font = `${isSelected ? "600 " : ""}${fontSize}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
const name = displayName(node.title);
const label = node.prefix ? `${node.prefix}${name}` : name;
const gap = 4 / scale;
// Halo behind the label for legibility over edges.
ctx.lineWidth = 3 / scale;
ctx.strokeStyle = t.labelHalo;
ctx.strokeText(label, x, y + radius + gap);
ctx.fillStyle = node.dangling ? t.nodeDim : t.label;
ctx.fillText(label, x, y + radius + gap);
}
ctx.restore();
}
private drawLink(link: FGLink, ctx: CanvasRenderingContext2D, scale: number) {
const t = this.theme;
const source = link.source as FGNode;
const target = link.target as FGNode;
if (
typeof source !== "object" ||
typeof target !== "object" ||
source.x === undefined ||
target.x === undefined
)
return;
const sx = source.x!;
const sy = source.y!;
const tx = target.x!;
const ty = target.y!;
const dx = tx - sx;
const dy = ty - sy;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len;
const uy = dy / len;
const sR = this.radiusMap.get(source.id) ?? 12;
const tR = this.radiusMap.get(target.id) ?? 12;
const ax = sx + sR * ux;
const ay = sy + sR * uy;
const bx = tx - tR * ux;
const by = ty - tR * uy;
const kind: string = link.edge.kind;
const dimmed = STRUCTURAL_KINDS.has(kind);
const hov = this.hoveredId;
const touches = this.linkTouchesHover(link);
let strokeStyle: string;
let alpha = 1;
if (hov) {
if (touches) {
strokeStyle = t.linkHot;
} else {
strokeStyle = t.linkDim;
alpha = 0.5;
}
} else {
strokeStyle = dimmed ? t.linkDim : t.link;
}
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = (touches ? 1.8 : 1.2) / scale;
if (dimmed) ctx.setLineDash([3 / scale, 3 / scale]);
else ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
ctx.setLineDash([]);
// Arrowheads — skip on undirected co-mention collapses.
if (!link.edge.undirected) {
const headLen = 7 / scale;
const drawHead = (hx: number, hy: number, angle: number) => {
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(
hx - headLen * Math.cos(angle - Math.PI / 6),
hy - headLen * Math.sin(angle - Math.PI / 6),
);
ctx.lineTo(
hx - headLen * Math.cos(angle + Math.PI / 6),
hy - headLen * Math.sin(angle + Math.PI / 6),
);
ctx.closePath();
ctx.fillStyle = strokeStyle;
ctx.fill();
};
// Target-end arrow.
drawHead(bx, by, Math.atan2(uy, ux));
// Source-end arrow when both directions exist between this pair.
if (link.edge.bidirectional) drawHead(ax, ay, Math.atan2(-uy, -ux));
}
// Rotated edge label riding above the midpoint.
// Suppressed entirely when the global "hide labels" toggle is on,
// unless the edge is incident to the hovered node.
const label = link.edge.label;
const labelsAllowed = !this.props.hideEdgeLabels || touches;
const showLabel =
label && labelsAllowed && (touches || scale >= EDGE_LABEL_MIN_SCALE);
if (showLabel) {
const mx = (ax + bx) / 2;
const my = (ay + by) / 2;
let angle = Math.atan2(by - ay, bx - ax);
if (angle > Math.PI / 2) angle -= Math.PI;
if (angle < -Math.PI / 2) angle += Math.PI;
ctx.translate(mx, my);
ctx.rotate(angle);
const fontSize = 10 / scale;
ctx.font = `${fontSize}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.lineWidth = 3 / scale;
ctx.strokeStyle = t.labelHalo;
ctx.strokeText(label, 0, -2 / scale);
ctx.fillStyle = dimmed ? t.labelDim : t.label;
ctx.fillText(label, 0, -2 / scale);
}
ctx.restore();
}
private zoomIn() {
const fg = this.fg;
if (!fg) return;
fg.zoom(fg.zoom() * 1.4, 250);
}
private zoomOut() {
const fg = this.fg;
if (!fg) return;
fg.zoom(fg.zoom() / 1.4, 250);
}
// dx/dy in {-1, 0, 1} — step scales inversely with zoom so the visual
// pan distance stays constant.
private pan(dx: number, dy: number) {
const fg = this.fg;
if (!fg) return;
const step = 80 / fg.zoom();
const c = fg.centerAt();
if (!c) return;
fg.centerAt(c.x + dx * step, c.y + dy * step, 250);
}
private recenter() {
const fg = this.fg;
if (!fg) return;
if (this.computed.nodes.length <= 1) {
fg.centerAt(0, 0, 400);
fg.zoom(2, 400);
return;
}
fg.zoomToFit(400, 40);
if (fg.zoom() > MAX_AUTO_ZOOM) fg.zoom(MAX_AUTO_ZOOM, 400);
}
render() {
const { edgeHover, ghostHover } = this.state;
const isEmpty = this.computed.nodes.length === 0;
return (
<div class="gv-canvas-wrap">
<div
ref={(el) => {
this.containerRef = el;
}}
class="graph-canvas"
/>
{isEmpty && <div class="gv-empty">Graph is empty</div>}
{edgeHover && <EdgeTooltip {...edgeHover} />}
{ghostHover && !edgeHover && <GhostTooltip {...ghostHover} />}
<div class="graph-controls">
<div class="graph-pan">
<button
type="button"
class="graph-pan-up"
title="Pan up"
onClick={() => this.pan(0, -1)}
>
↑
</button>
<button
type="button"
class="graph-pan-left"
title="Pan left"
onClick={() => this.pan(-1, 0)}
>
←
</button>
<button
type="button"
class="graph-pan-center"
title="Fit to view"
onClick={this.recenter}
>
⊙
</button>
<button
type="button"
class="graph-pan-right"
title="Pan right"
onClick={() => this.pan(1, 0)}
>
→
</button>
<button
type="button"
class="graph-pan-down"
title="Pan down"
onClick={() => this.pan(0, 1)}
>
↓
</button>
</div>
<div class="graph-zoom">
<button type="button" title="Zoom in" onClick={this.zoomIn}>
+
</button>
<button type="button" title="Zoom out" onClick={this.zoomOut}>
−
</button>
</div>
</div>
</div>
);
}
}
function GhostTooltip({
title,
x,
y,
}: {
title: string;
x: number;
y: number;
}) {
return (
<div
class="gv-ghost-tooltip"
style={{ left: `${x + 14}px`, top: `${y + 14}px` }}
>
Click to add <strong>{title}</strong> to the graph
</div>
);
}
function EdgeTooltip({
edge,
x,
y,
}: {
edge: MergedEdge;
x: number;
y: number;
}) {
// Collect distinct, trimmed snippets across all merged provenance refs.
const snippets: string[] = [];
const seen = new Set<string>();
for (const r of edge.refs) {
const s = r.snippet?.trim();
if (!s || seen.has(s)) continue;
seen.add(s);
snippets.push(s);
}
return (
<div
class="gv-edge-tooltip"
style={{ left: `${x + 14}px`, top: `${y + 14}px` }}
>
<div class="gv-edge-tooltip-label">{edge.label}</div>
{snippets.length === 0 ? (
<div class="gv-edge-tooltip-snippet gv-edge-tooltip-empty">
(no snippet)
</div>
) : (
snippets.map((s, i) => (
<div class="gv-edge-tooltip-snippet" key={i}>
{s}
</div>
))
)}
</div>
);
}