UNPKG

d3-graph-controller

Version:

A TypeScript library for visualizing and simulating directed, interactive graphs.

969 lines (947 loc) 29.7 kB
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