d3-graph-controller
Version:
A TypeScript library for visualizing and simulating directed, interactive graphs.
969 lines (947 loc) • 29.7 kB
JavaScript
import { debounce } from "@yeger/debounce";
import { select } from "d3-selection";
import { Vector } from "vecti";
import { zoom, zoomIdentity } from "d3-zoom";
import { drag } from "d3-drag";
import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from "d3-force";
//#region src/config/alpha.ts
/**
* Create the default alpha configuration.
*/
function createDefaultAlphaConfig() {
return {
drag: {
end: 0,
start: .1
},
filter: {
link: 1,
type: .1,
unlinked: {
include: .1,
exclude: .1
}
},
focus: {
acquire: () => .1,
release: () => .1
},
initialize: 1,
labels: {
links: {
hide: 0,
show: 0
},
nodes: {
hide: 0,
show: 0
}
},
resize: .5
};
}
//#endregion
//#region ../deepmerge/dist/index.mjs
function isObject(obj) {
if (typeof obj === "object" && obj !== null) {
if (typeof Object.getPrototypeOf === "function") {
const prototype = Object.getPrototypeOf(obj);
return prototype === Object.prototype || prototype === null;
}
return Object.prototype.toString.call(obj) === "[object Object]";
}
return false;
}
function merge(...objects) {
return objects.reduce((result, current) => {
if (Array.isArray(current)) throw new TypeError("Arguments provided to deepmerge must be objects, not arrays.");
Object.keys(current).forEach((key) => {
if ([
"__proto__",
"constructor",
"prototype"
].includes(key)) return;
if (Array.isArray(result[key]) && Array.isArray(current[key])) result[key] = merge.options.mergeArrays ? Array.from(new Set(result[key].concat(current[key]))) : current[key];
else if (isObject(result[key]) && isObject(current[key])) result[key] = merge(result[key], current[key]);
else result[key] = current[key];
});
return result;
}, {});
}
const defaultOptions = { mergeArrays: true };
merge.options = defaultOptions;
merge.withOptions = (options, ...objects) => {
merge.options = {
mergeArrays: true,
...options
};
const result = merge(...objects);
merge.options = defaultOptions;
return result;
};
var src_default = merge;
//#endregion
//#region src/config/forces.ts
/**
* Create the default force configuration.
*/
function createDefaultForceConfig() {
return {
centering: {
enabled: true,
strength: .1
},
charge: {
enabled: true,
strength: -1
},
collision: {
enabled: true,
strength: 1,
radiusMultiplier: 2
},
link: {
enabled: true,
strength: 1,
length: 128
}
};
}
//#endregion
//#region src/config/initial.ts
/**
* Create default initial settings.
*/
function createDefaultInitialGraphSettings() {
return {
includeUnlinked: true,
linkFilter: () => true,
nodeTypeFilter: void 0,
showLinkLabels: true,
showNodeLabels: true
};
}
//#endregion
//#region src/lib/utils.ts
function terminateEvent(event) {
event.preventDefault();
event.stopPropagation();
}
function isNumber(value) {
return typeof value === "number";
}
function getNodeRadius(config, node) {
return isNumber(config.nodeRadius) ? config.nodeRadius : config.nodeRadius(node);
}
/**
* Get the id of a link.
* @param link - The link.
*/
function getLinkId(link) {
return `${link.source.id}-${link.target.id}`;
}
/**
* Get the ID of a marker.
* @param color - The color of the link.
*/
function getMarkerId(color) {
return `link-arrow-${color}`.replace(/[()]/g, "~");
}
/**
* Get the URL of a marker.
* @param link - The link of the marker.
*/
function getMarkerUrl(link) {
return `url(#${getMarkerId(link.color)})`;
}
//#endregion
//#region src/config/marker.ts
function defaultMarkerConfig(size) {
return {
size,
padding: (node, config) => getNodeRadius(config, node) + 2 * size,
ref: [size / 2, size / 2],
path: [
[0, 0],
[0, size],
[size, size / 2]
],
viewBox: [
0,
0,
size,
size
].join(",")
};
}
/**
* Collection of built-in markers.
*/
const Markers = { Arrow: (size) => defaultMarkerConfig(size) };
//#endregion
//#region src/config/position.ts
const Centered = (_, width, height) => [width / 2, height / 2];
const Randomized = (_, width, height) => [randomInRange(0, width), randomInRange(0, height)];
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
function Stable(previousGraph) {
const positions = Object.fromEntries(previousGraph.nodes.map((node) => [node.id, [node.x, node.y]]));
return (node, width, height) => {
const [x, y] = positions[node.id] ?? [];
if (!x || !y) return Randomized(node, width, height);
return [x, y];
};
}
/**
* Collection of built-in position initializers.
*/
const PositionInitializers = {
Centered,
Randomized,
Stable
};
//#endregion
//#region src/config/config.ts
function defaultGraphConfig() {
return {
autoResize: false,
callbacks: {},
hooks: {},
initial: createDefaultInitialGraphSettings(),
nodeRadius: 16,
marker: Markers.Arrow(4),
modifiers: {},
positionInitializer: PositionInitializers.Centered,
simulation: {
alphas: createDefaultAlphaConfig(),
forces: createDefaultForceConfig()
},
zoom: {
initial: 1,
min: .1,
max: 2
}
};
}
/**
* Define the configuration of a controller.
* Will be merged with the default configuration.
* @param config - The partial configuration.
* @returns The merged configuration.
*/
function defineGraphConfig(config = {}) {
return src_default.withOptions({ mergeArrays: false }, defaultGraphConfig(), config);
}
//#endregion
//#region src/lib/canvas.ts
function defineCanvas({ applyZoom, container, onDoubleClick, onPointerMoved, onPointerUp, offset: [xOffset, yOffset], scale, zoom: zoom$1 }) {
const svg = container.classed("graph", true).append("svg").attr("height", "100%").attr("width", "100%").call(zoom$1).on("contextmenu", (event) => terminateEvent(event)).on("dblclick", (event) => onDoubleClick?.(event)).on("dblclick.zoom", null).on("pointermove", (event) => onPointerMoved?.(event)).on("pointerup", (event) => onPointerUp?.(event)).style("cursor", "grab");
if (applyZoom) svg.call(zoom$1.transform, zoomIdentity.translate(xOffset, yOffset).scale(scale));
return svg.append("g");
}
function updateCanvasTransform({ canvas, scale, xOffset, yOffset }) {
canvas?.attr("transform", `translate(${xOffset},${yOffset})scale(${scale})`);
}
//#endregion
//#region src/lib/drag.ts
function defineDrag({ config, onDragStart, onDragEnd }) {
const drg = drag().filter((event) => {
if (event.type === "mousedown") return event.button === 0;
else if (event.type === "touchstart") return event.touches.length === 1;
return false;
}).on("start", (event, d) => {
if (event.active === 0) onDragStart(event, d);
select(event.sourceEvent.target).classed("grabbed", true);
d.fx = d.x;
d.fy = d.y;
}).on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
}).on("end", (event, d) => {
if (event.active === 0) onDragEnd(event, d);
select(event.sourceEvent.target).classed("grabbed", false);
d.fx = void 0;
d.fy = void 0;
});
config.modifiers.drag?.(drg);
return drg;
}
//#endregion
//#region src/lib/filter.ts
function filterGraph({ graph, filter, focusedNode, includeUnlinked, linkFilter }) {
const links = graph.links.filter((d) => filter.includes(d.source.type) && filter.includes(d.target.type) && linkFilter(d));
const isLinked = (node) => links.find((link) => link.source.id === node.id || link.target.id === node.id) !== void 0;
const nodes = graph.nodes.filter((d) => filter.includes(d.type) && (includeUnlinked || isLinked(d)));
if (focusedNode === void 0 || !filter.includes(focusedNode.type)) return {
nodes,
links
};
return getFocusedSubgraph({
nodes,
links
}, focusedNode);
}
function getFocusedSubgraph(graph, source) {
const links = [...getIncomingLinksTransitively(graph, source), ...getOutgoingLinksTransitively(graph, source)];
const nodes = links.flatMap((link) => [link.source, link.target]);
return {
nodes: [...new Set([...nodes, source])],
links: [...new Set(links)]
};
}
function getIncomingLinksTransitively(graph, source) {
return getLinksInDirectionTransitively(graph, source, (link, node) => link.target.id === node.id);
}
function getOutgoingLinksTransitively(graph, source) {
return getLinksInDirectionTransitively(graph, source, (link, node) => link.source.id === node.id);
}
function getLinksInDirectionTransitively(graph, source, directionPredicate) {
const remainingLinks = new Set(graph.links);
const foundNodes = new Set([source]);
const foundLinks = [];
while (remainingLinks.size > 0) {
const newLinks = [...remainingLinks].filter((link) => [...foundNodes].some((node) => directionPredicate(link, node)));
if (newLinks.length === 0) return foundLinks;
newLinks.forEach((link) => {
foundNodes.add(link.source);
foundNodes.add(link.target);
foundLinks.push(link);
remainingLinks.delete(link);
});
}
return foundLinks;
}
//#endregion
//#region src/lib/paths.ts
function getX(node) {
return node.x ?? 0;
}
function getY(node) {
return node.y ?? 0;
}
function calculateVectorData({ source, target }) {
const s = new Vector(getX(source), getY(source));
const t = new Vector(getX(target), getY(target));
const diff = t.subtract(s);
const dist = diff.length();
const norm = diff.normalize();
return {
s,
t,
dist,
norm,
endNorm: norm.multiply(-1)
};
}
function calculateCenter({ center, node }) {
const n = new Vector(getX(node), getY(node));
let c = center;
if (n.x === c.x && n.y === c.y) c = c.add(new Vector(0, 1));
return {
n,
c
};
}
function calculateSourceAndTarget({ config, source, target }) {
const { s, t, norm } = calculateVectorData({
config,
source,
target
});
return {
start: s.add(norm.multiply(getNodeRadius(config, source) - 1)),
end: t.subtract(norm.multiply(config.marker.padding(target, config)))
};
}
function paddedLinePath(params) {
const { start, end } = calculateSourceAndTarget(params);
return `M${start.x},${start.y}
L${end.x},${end.y}`;
}
function lineLinkTextTransform(params) {
const { start, end } = calculateSourceAndTarget(params);
const midpoint = end.subtract(start).multiply(.5);
const result = start.add(midpoint);
return `translate(${result.x - 8},${result.y - 4})`;
}
function paddedArcPath({ config, source, target }) {
const { s, t, dist, norm, endNorm } = calculateVectorData({
config,
source,
target
});
const rotation = 10;
const start = norm.rotateByDegrees(-rotation).multiply(getNodeRadius(config, source) - 1).add(s);
const end = endNorm.rotateByDegrees(rotation).multiply(getNodeRadius(config, target)).add(t).add(endNorm.rotateByDegrees(rotation).multiply(2 * config.marker.size));
const arcRadius = 1.2 * dist;
return `M${start.x},${start.y}
A${arcRadius},${arcRadius},0,0,1,${end.x},${end.y}`;
}
function paddedReflexivePath({ center, config, node }) {
const { n, c } = calculateCenter({
center,
config,
node
});
const radius = getNodeRadius(config, node);
const diff = n.subtract(c);
const norm = diff.multiply(1 / diff.length());
const rotation = 40;
const start = norm.rotateByDegrees(rotation).multiply(radius - 1).add(n);
const end = norm.rotateByDegrees(-rotation).multiply(radius).add(n).add(norm.rotateByDegrees(-rotation).multiply(2 * config.marker.size));
return `M${start.x},${start.y}
A${radius},${radius},0,1,0,${end.x},${end.y}`;
}
function bidirectionalLinkTextTransform({ config, source, target }) {
const { t, dist, endNorm } = calculateVectorData({
config,
source,
target
});
const end = endNorm.rotateByDegrees(10).multiply(.5 * dist).add(t);
return `translate(${end.x},${end.y})`;
}
function reflexiveLinkTextTransform({ center, config, node }) {
const { n, c } = calculateCenter({
center,
config,
node
});
const diff = n.subtract(c);
const offset = diff.multiply(1 / diff.length()).multiply(3 * getNodeRadius(config, node) + 8).add(n);
return `translate(${offset.x},${offset.y})`;
}
const Paths = {
line: {
labelTransform: lineLinkTextTransform,
path: paddedLinePath
},
arc: {
labelTransform: bidirectionalLinkTextTransform,
path: paddedArcPath
},
reflexive: {
labelTransform: reflexiveLinkTextTransform,
path: paddedReflexivePath
}
};
//#endregion
//#region src/lib/link.ts
function defineLinkSelection(canvas) {
return canvas.append("g").classed("links", true).selectAll("path");
}
function createLinks({ config, graph, selection, showLabels }) {
const result = selection?.data(graph.links, (d) => getLinkId(d)).join((enter) => {
const linkGroup = enter.append("g");
const linkPath = linkGroup.append("path").classed("link", true).style("marker-end", (d) => getMarkerUrl(d)).style("stroke", (d) => d.color);
config.modifiers.link?.(linkPath);
const linkLabel = linkGroup.append("text").classed("link__label", true).style("fill", (d) => d.label ? d.label.color : null).style("font-size", (d) => d.label ? d.label.fontSize : null).text((d) => d.label ? d.label.text : null);
config.modifiers.linkLabel?.(linkLabel);
return linkGroup;
});
result?.select(".link__label").attr("opacity", (d) => d.label && showLabels ? 1 : 0);
return result;
}
function updateLinks(params) {
updateLinkPaths(params);
updateLinkLabels(params);
}
function updateLinkPaths({ center, config, graph, selection }) {
selection?.selectAll("path").attr("d", (d) => {
if (d.source.x === void 0 || d.source.y === void 0 || d.target.x === void 0 || d.target.y === void 0) return "";
if (d.source.id === d.target.id) return Paths.reflexive.path({
config,
node: d.source,
center
});
else if (areBidirectionallyConnected(graph, d.source, d.target)) return Paths.arc.path({
config,
source: d.source,
target: d.target
});
else return Paths.line.path({
config,
source: d.source,
target: d.target
});
});
}
function updateLinkLabels({ config, center, graph, selection }) {
selection?.select(".link__label").attr("transform", (d) => {
if (d.source.x === void 0 || d.source.y === void 0 || d.target.x === void 0 || d.target.y === void 0) return "translate(0, 0)";
if (d.source.id === d.target.id) return Paths.reflexive.labelTransform({
config,
node: d.source,
center
});
else if (areBidirectionallyConnected(graph, d.source, d.target)) return Paths.arc.labelTransform({
config,
source: d.source,
target: d.target
});
else return Paths.line.labelTransform({
config,
source: d.source,
target: d.target
});
});
}
function areBidirectionallyConnected(graph, source, target) {
return source.id !== target.id && graph.links.some((l) => l.target.id === source.id && l.source.id === target.id) && graph.links.some((l) => l.target.id === target.id && l.source.id === source.id);
}
//#endregion
//#region src/lib/marker.ts
function defineMarkerSelection(canvas) {
return canvas.append("defs").selectAll("marker");
}
function createMarkers({ config, graph, selection }) {
return selection?.data(getUniqueColors(graph), (d) => d).join((enter) => {
const marker = enter.append("marker").attr("id", (d) => getMarkerId(d)).attr("markerHeight", 4 * config.marker.size).attr("markerWidth", 4 * config.marker.size).attr("markerUnits", "userSpaceOnUse").attr("orient", "auto").attr("refX", config.marker.ref[0]).attr("refY", config.marker.ref[1]).attr("viewBox", config.marker.viewBox).style("fill", (d) => d);
marker.append("path").attr("d", makeLine(config.marker.path));
return marker;
});
}
function getUniqueColors(graph) {
return [...new Set(graph.links.map((link) => link.color))];
}
function makeLine(points) {
const [start, ...rest] = points;
if (!start) return "M0,0";
const [startX, startY] = start;
return rest.reduce((line, [x, y]) => `${line}L${x},${y}`, `M${startX},${startY}`);
}
//#endregion
//#region src/lib/node.ts
function defineNodeSelection(canvas) {
return canvas.append("g").classed("nodes", true).selectAll("circle");
}
function createNodes({ config, drag: drag$1, graph, onNodeContext, onNodeSelected, selection, showLabels }) {
const result = selection?.data(graph.nodes, (d) => d.id).join((enter) => {
const nodeGroup = enter.append("g");
if (drag$1 !== void 0) nodeGroup.call(drag$1);
const nodeCircle = nodeGroup.append("circle").classed("node", true).attr("r", (d) => getNodeRadius(config, d)).on("contextmenu", (event, d) => {
terminateEvent(event);
onNodeContext(d);
}).on("pointerdown", (event, d) => onPointerDown(event, d, onNodeSelected ?? onNodeContext)).style("fill", (d) => d.color);
config.modifiers.node?.(nodeCircle);
const nodeLabel = nodeGroup.append("text").classed("node__label", true).attr("dy", `0.33em`).style("fill", (d) => d.label ? d.label.color : null).style("font-size", (d) => d.label ? d.label.fontSize : null).style("stroke", "none").text((d) => d.label ? d.label.text : null);
config.modifiers.nodeLabel?.(nodeLabel);
return nodeGroup;
});
result?.select(".node").classed("focused", (d) => d.isFocused);
result?.select(".node__label").attr("opacity", showLabels ? 1 : 0);
return result;
}
const DOUBLE_CLICK_INTERVAL_MS = 500;
function onPointerDown(event, node, onNodeSelected) {
if (event.button !== void 0 && event.button !== 0) return;
const lastInteractionTimestamp = node.lastInteractionTimestamp;
const now = Date.now();
if (lastInteractionTimestamp === void 0 || now - lastInteractionTimestamp > DOUBLE_CLICK_INTERVAL_MS) {
node.lastInteractionTimestamp = now;
return;
}
node.lastInteractionTimestamp = void 0;
onNodeSelected(node);
}
function updateNodes(selection) {
selection?.attr("transform", (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
}
//#endregion
//#region src/lib/simulation.ts
function defineSimulation({ center, config, graph, onTick }) {
const simulation = forceSimulation(graph.nodes);
const centeringForce = config.simulation.forces.centering;
if (centeringForce && centeringForce.enabled) {
const strength = centeringForce.strength;
simulation.force("x", forceX(() => center().x).strength(strength)).force("y", forceY(() => center().y).strength(strength));
}
const chargeForce = config.simulation.forces.charge;
if (chargeForce && chargeForce.enabled) simulation.force("charge", forceManyBody().strength(chargeForce.strength));
const collisionForce = config.simulation.forces.collision;
if (collisionForce && collisionForce.enabled) simulation.force("collision", forceCollide().radius((d) => collisionForce.radiusMultiplier * getNodeRadius(config, d)));
const linkForce = config.simulation.forces.link;
if (linkForce && linkForce.enabled) simulation.force("link", forceLink(graph.links).id((d) => d.id).distance(config.simulation.forces.link.length).strength(linkForce.strength));
simulation.on("tick", () => onTick());
config.modifiers.simulation?.(simulation);
return simulation;
}
//#endregion
//#region src/lib/zoom.ts
function defineZoom({ canvasContainer, config, min, max, onZoom }) {
const z = zoom().scaleExtent([min, max]).filter((event) => event.button === 0 || event.touches?.length >= 2).on("start", () => canvasContainer().classed("grabbed", true)).on("zoom", (event) => onZoom(event)).on("end", () => canvasContainer().classed("grabbed", false));
config.modifiers.zoom?.(z);
return z;
}
//#endregion
//#region src/controller.ts
/**
* Controller for a graph view.
*/
var GraphController = class {
/**
* Array of all node types included in the controller's graph.
*/
nodeTypes;
_nodeTypeFilter;
_includeUnlinked = true;
_linkFilter = () => true;
_showLinkLabels = true;
_showNodeLabels = true;
filteredGraph;
width = 0;
height = 0;
simulation;
canvas;
linkSelection;
nodeSelection;
markerSelection;
zoom;
drag;
xOffset = 0;
yOffset = 0;
scale;
focusedNode = void 0;
resizeObserver;
container;
graph;
config;
/**
* Create a new controller and initialize the view.
* @param container - The container the graph will be placed in.
* @param graph - The graph of the controller.
* @param config - The config of the controller.
*/
constructor(container, graph, config) {
this.container = container;
this.graph = graph;
this.config = config;
this.scale = config.zoom.initial;
this.resetView();
this.graph.nodes.forEach((node) => {
const [x, y] = config.positionInitializer(node, this.effectiveWidth, this.effectiveHeight);
node.x = node.x ?? x;
node.y = node.y ?? y;
});
this.nodeTypes = [...new Set(graph.nodes.map((d) => d.type))];
this._nodeTypeFilter = [...this.nodeTypes];
if (config.initial) {
const { includeUnlinked, nodeTypeFilter, linkFilter, showLinkLabels, showNodeLabels } = config.initial;
this._includeUnlinked = includeUnlinked ?? this._includeUnlinked;
this._showLinkLabels = showLinkLabels ?? this._showLinkLabels;
this._showNodeLabels = showNodeLabels ?? this._showNodeLabels;
this._nodeTypeFilter = nodeTypeFilter ?? this._nodeTypeFilter;
this._linkFilter = linkFilter ?? this._linkFilter;
}
this.filterGraph(void 0);
this.initGraph();
this.restart(config.simulation.alphas.initialize);
if (config.autoResize) {
this.resizeObserver = new ResizeObserver(debounce(() => this.resize()));
this.resizeObserver.observe(this.container);
}
}
/**
* Get the current node type filter.
* Only nodes whose type is included will be shown.
*/
get nodeTypeFilter() {
return this._nodeTypeFilter;
}
/**
* Get whether nodes without incoming or outgoing links will be shown or not.
*/
get includeUnlinked() {
return this._includeUnlinked;
}
/**
* Set whether nodes without incoming or outgoing links will be shown or not.
* @param value - The value.
*/
set includeUnlinked(value) {
this._includeUnlinked = value;
this.filterGraph(this.focusedNode);
const { include, exclude } = this.config.simulation.alphas.filter.unlinked;
const alpha = value ? include : exclude;
this.restart(alpha);
}
/**
* Set a new link filter and update the controller's state.
* @param value - The new link filter.
*/
set linkFilter(value) {
this._linkFilter = value;
this.filterGraph(this.focusedNode);
this.restart(this.config.simulation.alphas.filter.link);
}
/**
* Get the current link filter.
* @returns - The current link filter.
*/
get linkFilter() {
return this._linkFilter;
}
/**
* Get whether node labels are shown or not.
*/
get showNodeLabels() {
return this._showNodeLabels;
}
/**
* Set whether node labels will be shown or not.
* @param value - The value.
*/
set showNodeLabels(value) {
this._showNodeLabels = value;
const { hide, show } = this.config.simulation.alphas.labels.nodes;
const alpha = value ? show : hide;
this.restart(alpha);
}
/**
* Get whether link labels are shown or not.
*/
get showLinkLabels() {
return this._showLinkLabels;
}
/**
* Set whether link labels will be shown or not.
* @param value - The value.
*/
set showLinkLabels(value) {
this._showLinkLabels = value;
const { hide, show } = this.config.simulation.alphas.labels.links;
const alpha = value ? show : hide;
this.restart(alpha);
}
get effectiveWidth() {
return this.width / this.scale;
}
get effectiveHeight() {
return this.height / this.scale;
}
get effectiveCenter() {
return Vector.of([this.width, this.height]).divide(2).subtract(Vector.of([this.xOffset, this.yOffset])).divide(this.scale);
}
/**
* Resize the graph to fit its container.
*/
resize() {
const oldWidth = this.width;
const oldHeight = this.height;
const newWidth = this.container.getBoundingClientRect().width;
const newHeight = this.container.getBoundingClientRect().height;
const widthDiffers = oldWidth.toFixed() !== newWidth.toFixed();
const heightDiffers = oldHeight.toFixed() !== newHeight.toFixed();
if (!widthDiffers && !heightDiffers) return;
this.width = this.container.getBoundingClientRect().width;
this.height = this.container.getBoundingClientRect().height;
const alpha = this.config.simulation.alphas.resize;
this.restart(isNumber(alpha) ? alpha : alpha({
oldWidth,
oldHeight,
newWidth,
newHeight
}));
}
/**
* Restart the controller.
* @param alpha - The alpha value of the controller's simulation after the restart.
*/
restart(alpha) {
this.markerSelection = createMarkers({
config: this.config,
graph: this.filteredGraph,
selection: this.markerSelection
});
this.linkSelection = createLinks({
config: this.config,
graph: this.filteredGraph,
selection: this.linkSelection,
showLabels: this._showLinkLabels
});
this.nodeSelection = createNodes({
config: this.config,
drag: this.drag,
graph: this.filteredGraph,
onNodeContext: (d) => this.toggleNodeFocus(d),
onNodeSelected: this.config.callbacks.nodeClicked,
selection: this.nodeSelection,
showLabels: this._showNodeLabels
});
this.simulation?.stop();
this.simulation = defineSimulation({
center: () => this.effectiveCenter,
config: this.config,
graph: this.filteredGraph,
onTick: () => this.onTick()
}).alpha(alpha).restart();
}
/**
* Update the node type filter by either including or removing the specified type from the filter.
* @param include - Whether the type will be included or removed from the filter.
* @param nodeType - The type to be added or removed from the filter.
*/
filterNodesByType(include, nodeType) {
if (include) this._nodeTypeFilter.push(nodeType);
else this._nodeTypeFilter = this._nodeTypeFilter.filter((type) => type !== nodeType);
this.filterGraph(this.focusedNode);
this.restart(this.config.simulation.alphas.filter.type);
}
/**
* Shut down the controller's simulation and (optional) automatic resizing.
*/
shutdown() {
if (this.focusedNode !== void 0) {
this.focusedNode.isFocused = false;
this.focusedNode = void 0;
}
this.resizeObserver?.unobserve(this.container);
this.simulation?.stop();
}
initGraph() {
this.zoom = defineZoom({
config: this.config,
canvasContainer: () => select(this.container).select("svg"),
min: this.config.zoom.min,
max: this.config.zoom.max,
onZoom: (event) => this.onZoom(event)
});
this.canvas = defineCanvas({
applyZoom: this.scale !== 1,
container: select(this.container),
offset: [this.xOffset, this.yOffset],
scale: this.scale,
zoom: this.zoom
});
this.applyZoom();
this.linkSelection = defineLinkSelection(this.canvas);
this.nodeSelection = defineNodeSelection(this.canvas);
this.markerSelection = defineMarkerSelection(this.canvas);
this.drag = defineDrag({
config: this.config,
onDragStart: () => this.simulation?.alphaTarget(this.config.simulation.alphas.drag.start).restart(),
onDragEnd: () => this.simulation?.alphaTarget(this.config.simulation.alphas.drag.end).restart()
});
}
onTick() {
updateNodes(this.nodeSelection);
updateLinks({
config: this.config,
center: this.effectiveCenter,
graph: this.filteredGraph,
selection: this.linkSelection
});
}
resetView() {
this.simulation?.stop();
select(this.container).selectChildren().remove();
this.zoom = void 0;
this.canvas = void 0;
this.linkSelection = void 0;
this.nodeSelection = void 0;
this.markerSelection = void 0;
this.simulation = void 0;
this.width = this.container.getBoundingClientRect().width;
this.height = this.container.getBoundingClientRect().height;
}
onZoom(event) {
this.xOffset = event.transform.x;
this.yOffset = event.transform.y;
this.scale = event.transform.k;
this.applyZoom();
this.config.hooks.afterZoom?.(this.scale, this.xOffset, this.yOffset);
this.simulation?.restart();
}
applyZoom() {
updateCanvasTransform({
canvas: this.canvas,
scale: this.scale,
xOffset: this.xOffset,
yOffset: this.yOffset
});
}
toggleNodeFocus(node) {
if (node.isFocused) {
this.filterGraph(void 0);
this.restart(this.config.simulation.alphas.focus.release(node));
} else this.focusNode(node);
}
focusNode(node) {
this.filterGraph(node);
this.restart(this.config.simulation.alphas.focus.acquire(node));
}
filterGraph(nodeToFocus) {
if (this.focusedNode !== void 0) {
this.focusedNode.isFocused = false;
this.focusedNode = void 0;
}
if (nodeToFocus !== void 0 && this._nodeTypeFilter.includes(nodeToFocus.type)) {
nodeToFocus.isFocused = true;
this.focusedNode = nodeToFocus;
}
this.filteredGraph = filterGraph({
graph: this.graph,
filter: this._nodeTypeFilter,
focusedNode: this.focusedNode,
includeUnlinked: this._includeUnlinked,
linkFilter: this._linkFilter
});
}
};
//#endregion
//#region src/model/graph.ts
/**
* Define a graph with type inference.
* @param data - The nodes and links of the graph. If either are omitted, they default to an empty array.
*/
function defineGraph({ nodes, links }) {
return {
nodes: nodes ?? [],
links: links ?? []
};
}
//#endregion
//#region src/model/link.ts
/**
* Define a link with type inference.
* @param data - The data of the link.
*/
function defineLink(data) {
return { ...data };
}
//#endregion
//#region src/model/node.ts
/**
* Define a node with type inference.
* @param data - The data of the node.
*/
function defineNode(data) {
return {
...data,
isFocused: false,
lastInteractionTimestamp: void 0
};
}
const nodeDefaults = {
color: "lightgray",
label: {
color: "black",
fontSize: "1rem",
text: ""
},
isFocused: false
};
/**
* Define a node with type inference and some default values.
* @param data - The data of the node.
*/
function defineNodeWithDefaults(data) {
return defineNode({
...nodeDefaults,
...data
});
}
//#endregion
export { GraphController, Markers, PositionInitializers, createDefaultAlphaConfig, createDefaultForceConfig, createDefaultInitialGraphSettings, defineGraph, defineGraphConfig, defineLink, defineNode, defineNodeWithDefaults };
//# sourceMappingURL=index.mjs.map