UNPKG

reagraph

Version:

WebGL Node-based Graph for React

1,476 lines 171 kB
(function() { try { if (typeof document != "undefined") { var elementStyle = document.createElement("style"); elementStyle.appendChild(document.createTextNode("._canvas_670zp_1 {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n._container_x9hyx_1 {\n border-radius: 50%;\n z-index: 9;\n position: relative;\n height: 175px;\n width: 175px;\n border: solid 5px var(--radial-menu-border);\n overflow: hidden;\n background: var(--radial-menu-background);\n}\n\n ._container_x9hyx_1:before {\n content: ' ';\n background: var(--radial-menu-border);\n border-radius: 50%;\n height: 25px;\n width: 25px;\n position: absolute;\n z-index: 9;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n._container_155l7_1 {\n transform-origin: bottom right;\n overflow: hidden;\n position: absolute;\n border: solid 1px var(--radial-menu-border);\n}\n\n ._container_155l7_1._disabled_155l7_7 {\n opacity: 0.6;\n }\n\n ._container_155l7_1._disabled_155l7_7 ._contentContainer_155l7_10 {\n cursor: not-allowed;\n }\n\n ._container_155l7_1:not(._disabled_155l7_7) ._contentContainer_155l7_10 {\n cursor: pointer;\n }\n\n ._container_155l7_1:not(._disabled_155l7_7) ._contentContainer_155l7_10:hover {\n color: var(--radial-menu-active-color);\n background: var(--radial-menu-active-background);\n }\n\n ._container_155l7_1 ._contentContainer_155l7_10 {\n width: 200%;\n height: 200%;\n transform-origin: 50% 50%;\n border-radius: 50%;\n outline: none;\n transition: background 150ms ease-in-out;\n color: var(--radial-menu-color);\n }\n\n ._container_155l7_1 ._contentContainer_155l7_10 ._contentInner_155l7_35 {\n position: absolute;\n width: 100%;\n text-align: center;\n }\n\n ._container_155l7_1 ._contentContainer_155l7_10 ._contentInner_155l7_35 ._content_155l7_10 {\n display: inline-block;\n }\n\n ._container_155l7_1 svg {\n margin: 0 auto;\n fill: var(--radial-menu-active-color);\n height: 25px;\n width: 25px;\n display: block;\n }\n/*$vite$:1*/")); document.head.appendChild(elementStyle); } } catch (e) { console.error("vite-plugin-css-injected-by-js", e); }})(); import { Canvas, extend, useFrame, useThree } from "@react-three/fiber"; import ThreeCameraControls from "camera-controls"; import * as holdEvent from "hold-event"; import React, { Fragment, Suspense, createContext, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Box3, BoxGeometry, BufferAttribute, BufferGeometry, CatmullRomCurve3, Color, CylinderGeometry, DoubleSide, Euler, Float32BufferAttribute, LineCurve3, LinearFilter, MOUSE, MathUtils, Matrix4, Mesh, Plane, QuadraticBezierCurve3, Quaternion, Raycaster, RingGeometry, Scene, ShaderMaterial, Sphere as Sphere$1, SphereGeometry, Spherical, TextureLoader, TubeGeometry, Vector2, Vector3, Vector4 } from "three"; import Graph from "graphology"; import { create, useStore } from "zustand"; import { useShallow } from "zustand/shallow"; import { SelectionBox, mergeBufferGeometries } from "three-stdlib"; import { degreeCentrality } from "graphology-metrics/centrality/degree.js"; import { scaleLinear } from "d3-scale"; import pagerank from "graphology-metrics/centrality/pagerank.js"; import { bidirectional } from "graphology-shortest-path"; import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime"; import { a, useSpring } from "@react-spring/three"; import { Billboard, Html, Image, RoundedBox, Svg as Svg$1, Text, useCursor } from "@react-three/drei"; import { useGesture } from "@use-gesture/react"; import ellipsize from "ellipsize"; import circular from "graphology-layout/circular.js"; import random from "graphology-layout/random.js"; import forceAtlas2Layout from "graphology-layout-forceatlas2"; import { forceCenter, forceCollide, forceLink, forceManyBody, forceRadial as forceRadial$1, forceSimulation, forceX, forceY, forceZ } from "d3-force-3d"; import { hierarchy, stratify, tree, treemap } from "d3-hierarchy"; import noverlapLayout from "graphology-layout-noverlap"; import classNames from "classnames"; //#region src/utils/animation.ts var animationConfig = { mass: 10, tension: 1e3, friction: 300, precision: .1 }; //#endregion //#region src/utils/arrow.ts function getArrowVectors(placement, curve, arrowLength) { const curveLength = curve.getLength(); const u = ((placement === "end" ? curveLength : curveLength / 2) - (placement === "end" ? arrowLength / 2 : 0)) / curveLength; return [curve.getPointAt(u), curve.getTangentAt(u)]; } function getArrowSize(size) { return [size + 6, 2 + size / 1.5]; } //#endregion //#region src/utils/layout.ts /** * Given a collection of nodes, get the center point. */ function getLayoutCenter(nodes) { let minX = Number.POSITIVE_INFINITY; let maxX = Number.NEGATIVE_INFINITY; let minY = Number.POSITIVE_INFINITY; let maxY = Number.NEGATIVE_INFINITY; let minZ = Number.POSITIVE_INFINITY; let maxZ = Number.NEGATIVE_INFINITY; for (const node of nodes) { minX = Math.min(minX, node.position.x); maxX = Math.max(maxX, node.position.x); minY = Math.min(minY, node.position.y); maxY = Math.max(maxY, node.position.y); minZ = Math.min(minZ, node.position.z); maxZ = Math.max(maxZ, node.position.z); } return { height: maxY - minY, width: maxX - minX, minX, maxX, minY, maxY, minZ, maxZ, x: (maxX + minX) / 2, y: (maxY + minY) / 2, z: (maxZ + minZ) / 2 }; } //#endregion //#region src/utils/cluster.ts /** * Given nodes and a attribute, find all the cluster groups. */ function buildClusterGroups(nodes, clusterAttribute) { if (!clusterAttribute) return /* @__PURE__ */ new Map(); return nodes.reduce((entryMap, e) => { const val = e.data[clusterAttribute]; if (val) entryMap.set(val, [...entryMap.get(val) || [], e]); return entryMap; }, /* @__PURE__ */ new Map()); } /** * Builds the cluster map. * * This function: * - Builds the cluster groups * - Calculates the center position of each cluster group * - Creates a cluster object for each group */ function calculateClusters({ nodes, clusterAttribute }) { const result = /* @__PURE__ */ new Map(); if (clusterAttribute) { const groups = buildClusterGroups(nodes, clusterAttribute); for (const [key, nodes] of groups) { const position = getLayoutCenter(nodes); result.set(key, { label: key, nodes, position }); } } return result; } //#endregion //#region src/utils/dom.ts /** * Check if an element is not editable (input, select, textarea, contentEditable). * @param element - The element to check * @returns True if the element is not editable, false otherwise */ var isNotEditableElement = (element) => { return element.tagName !== "INPUT" && element.tagName !== "SELECT" && element.tagName !== "TEXTAREA" && !element.isContentEditable; }; //#endregion //#region src/utils/geometry.ts /** * Create a null geometry with consistent attributes * @returns A BufferGeometry with a null appearance */ var createNullGeometry = () => { const nullGeom = new BoxGeometry(0, 0, 0); const vertexCount = nullGeom.attributes.position.count; const colorArray = new Float32Array(vertexCount * 3); for (let i = 0; i < vertexCount; i++) { colorArray[i * 3] = 1; colorArray[i * 3 + 1] = 1; colorArray[i * 3 + 2] = 1; } nullGeom.setAttribute("color", new Float32BufferAttribute(colorArray, 3)); return nullGeom; }; /** * Add a color attribute to a geometry * @param geometry - The geometry to add the color attribute to * @param color - The color to add to the geometry */ var addColorAttribute = (geometry, color) => { const vertexCount = geometry.attributes.position.count; const colorArray = new Float32Array(vertexCount * 3); for (let i = 0; i < vertexCount; i++) { colorArray[i * 3] = color.r; colorArray[i * 3 + 1] = color.g; colorArray[i * 3 + 2] = color.b; } geometry.setAttribute("color", new Float32BufferAttribute(colorArray, 3)); }; /** * Create actual dashed geometry with gaps by making multiple small tube segments * @param curve - The curve to create a dashed geometry from * @param radius - The radius of the tube * @param color - The color of the tube * @param dashArray - The dash array [dashSize, gapSize] * @returns A BufferGeometry with a dashed appearance */ var createDashedGeometry = (curve, radius, color, dashArray = [3, 1]) => { const [dashSize, gapSize] = dashArray; const totalSize = dashSize + gapSize; const curveLength = curve.getLength(); const numDashes = Math.max(3, Math.floor(curveLength / totalSize)); const segments = []; for (let i = 0; i < numDashes; i++) { const startT = i / numDashes; const endT = startT + dashSize / totalSize / numDashes; if (endT > startT && startT < 1) { const points = []; const segmentSteps = Math.max(3, Math.floor(8 * (endT - startT))); for (let j = 0; j <= segmentSteps; j++) { const t = startT + (endT - startT) * (j / segmentSteps); if (t <= 1) points.push(curve.getPointAt(t)); } if (points.length >= 2) { const segmentGeometry = new TubeGeometry(new CatmullRomCurve3(points), Math.max(2, points.length - 1), radius, 5, false); addColorAttribute(segmentGeometry, color); segments.push(segmentGeometry); } } } return segments.length > 0 ? mergeBufferGeometries(segments) : new BufferGeometry(); }; //#endregion //#region src/sizing/attribute.ts function attributeSizing({ graph, attribute, defaultSize }) { const map = /* @__PURE__ */ new Map(); if (attribute) graph.forEachNode((id, node) => { const size = node.data?.[attribute]; if (isNaN(size)) console.warn(`Attribute ${size} is not a number for node ${node.id}`); map.set(id, size || 0); }); else console.warn("Attribute sizing configured but no attribute provided"); return { getSizeForNode: (nodeId) => { if (!attribute || !map) return defaultSize; return map.get(nodeId); } }; } //#endregion //#region src/sizing/centrality.ts function centralitySizing({ graph }) { const ranks = degreeCentrality(graph); return { ranks, getSizeForNode: (nodeID) => ranks[nodeID] * 20 }; } //#endregion //#region src/sizing/pageRank.ts function pageRankSizing({ graph }) { const ranks = pagerank(graph); return { ranks, getSizeForNode: (nodeID) => ranks[nodeID] * 80 }; } //#endregion //#region src/sizing/nodeSizeProvider.ts var providers = { pagerank: pageRankSizing, centrality: centralitySizing, attribute: attributeSizing, none: ({ defaultSize }) => ({ getSizeForNode: (_id) => defaultSize }) }; function nodeSizeProvider({ type, ...rest }) { const provider = providers[type]?.(rest); if (!provider && type !== "default") throw new Error(`Unknown sizing strategy: ${type}`); const { graph, minSize, maxSize } = rest; const sizes = /* @__PURE__ */ new Map(); let min; let max; graph.forEachNode((id, node) => { let size; if (type === "default") size = node.size || rest.defaultSize; else size = provider.getSizeForNode(id); if (min === void 0 || size < min) min = size; if (max === void 0 || size > max) max = size; sizes.set(id, size); }); if (type !== "none") { const scale = scaleLinear().domain([min, max]).rangeRound([minSize, maxSize]); for (const [nodeId, size] of sizes) sizes.set(nodeId, scale(size)); } return sizes; } //#endregion //#region src/utils/visibility.ts /** * Whether label visibility for a given mode depends on the camera * distance/zoom. Only 'auto' re-evaluates as the camera moves; every other * mode is always-on, always-off, or shape-gated and therefore camera * independent. Callers use this to skip per-camera-frame recomputation. */ function isLabelVisibilityCameraDependent(labelType) { return labelType === "auto"; } function calcLabelVisibility({ nodePosition, labelType, camera }) { return (shape, size, nodePositionOverride) => { const nodePos = nodePositionOverride ?? nodePosition; const isAlwaysVisible = labelType === "all" || labelType === "nodes" && shape === "node" || labelType === "edges" && shape === "edge"; if (!isAlwaysVisible && camera && nodePos && camera?.position?.z / camera?.zoom - nodePos?.z > 6e3) return false; if (isAlwaysVisible) return true; else if (labelType === "auto" && shape === "node") { if (size > 7) return true; else if (camera && nodePos && camera.position.z / camera.zoom - nodePos.z < 3e3) return true; } return false; }; } function getLabelOffsetByType(offset, position) { switch (position) { case "above": return offset; case "below": return -offset; default: return 0; } } var isServerRender = typeof window === "undefined"; //#endregion //#region src/utils/graph.ts /** * Initialize the graph with the nodes/edges. */ function buildGraph(graph, nodes, edges) { graph.clear(); const addedNodes = /* @__PURE__ */ new Set(); for (const node of nodes) try { if (!addedNodes.has(node.id)) { graph.addNode(node.id, node); addedNodes.add(node.id); } } catch (e) { console.error(`[Graph] Error adding node '${node.id}`, e); } for (const edge of edges) { if (!addedNodes.has(edge.source) || !addedNodes.has(edge.target)) continue; try { graph.addEdge(edge.source, edge.target, edge); } catch (e) { console.error(`[Graph] Error adding edge '${edge.source} -> ${edge.target}`, e); } } return graph; } /** * Transform the graph into a format that is easier to work with. */ function transformGraph({ graph, layout, sizingType, labelType, sizingAttribute, defaultNodeSize, minNodeSize, maxNodeSize, clusterAttribute }) { const nodes = []; const edges = []; const map = /* @__PURE__ */ new Map(); const sizes = nodeSizeProvider({ graph, type: sizingType, attribute: sizingAttribute, minSize: minNodeSize, maxSize: maxNodeSize, defaultSize: defaultNodeSize }); const nodeCount = graph.nodes().length; const checkVisibility = calcLabelVisibility({ nodeCount, labelType }); graph.forEachNode((id, node) => { const position = layout.getNodePosition(id); const { data, fill, icon, label, size, ...rest } = node; const nodeSize = sizes.get(node.id); const labelVisible = checkVisibility("node", nodeSize); const parents = (graph.inboundNeighbors(node.id) || []).map((n) => graph.getNodeAttributes(n)); const n = { ...node, size: nodeSize, labelVisible, label, icon, fill, cluster: clusterAttribute ? data[clusterAttribute] : void 0, parents, data: { ...rest, ...data ?? {} }, position: { ...position, x: position.x || 0, y: position.y || 0, z: position.z || 1 } }; map.set(node.id, n); nodes.push(n); }); graph.forEachEdge((_id, link) => { const from = map.get(link.source); const to = map.get(link.target); if (from && to) { const { data, id, label, size, ...rest } = link; const labelVisible = checkVisibility("edge", size); edges.push({ ...link, id, label, labelVisible, size, data: { ...rest, id, ...data || {} } }); } }); return { nodes, edges }; } //#endregion //#region src/utils/paths.ts function findPath(graph, source, target) { return bidirectional(graph, source, target); } //#endregion //#region src/utils/position.ts var MULTI_EDGE_OFFSET_FACTOR = .7; /** * Get the midpoint given two points. */ function getMidPoint(from, to, offset = 0) { const fromVector = new Vector3(from.x, from.y || 0, from.z || 0); const toVector = new Vector3(to.x, to.y || 0, to.z || 0); const midVector = new Vector3().addVectors(fromVector, toVector).divideScalar(2); return midVector.setLength(midVector.length() + offset); } /** * Calculate the center for a quadratic bezier curve. * * 1) Find the point halfway between the start and end points of the desired curve * 2) Find the vector pependicular to that point * 3) Find the point 1/4 the distance between start and end along that vector. */ function getCurvePoints(from, to, offset = -1) { const fromVector = from.clone(); const toVector = to.clone(); const v = new Vector3().subVectors(toVector, fromVector); const vlen = v.length(); const vn = v.clone().normalize(); const vv = new Vector3().subVectors(toVector, fromVector).divideScalar(2); const k = Math.abs(vn.x) % 1; const b = new Vector3(-vn.y, vn.x - k * vn.z, k * vn.y).normalize(); return [ from, new Vector3().add(fromVector).add(vv).add(b.multiplyScalar(vlen / 4).multiplyScalar(offset)), to ]; } /** * Get the curve given two points. */ function getCurve(from, fromOffset, to, toOffset, curved, curveOffset) { const offsetFrom = getPointBetween(from, to, fromOffset); const offsetTo = getPointBetween(to, from, toOffset); return curved ? new QuadraticBezierCurve3(...getCurvePoints(offsetFrom, offsetTo, curveOffset)) : new LineCurve3(offsetFrom, offsetTo); } /** * Get the curve for a self-loop. */ function getSelfLoopCurve(from) { const nodePosition = getVector(from); const loopRadius = from.size; const angle = Math.PI / 2; const loopCenter = nodePosition.clone().add(new Vector3(loopRadius * Math.cos(angle), loopRadius * 1.3 * Math.sin(angle), 0)); const numPoints = 10; const points = []; for (let i = 0; i < numPoints; i++) { const theta = i / numPoints * 2 * Math.PI; points.push(loopCenter.clone().add(new Vector3(loopRadius * Math.cos(theta), loopRadius * Math.sin(theta), 0))); } return new CatmullRomCurve3(points, true); } /** * Create a threejs vector for a node. */ function getVector(node) { return new Vector3(node.position.x, node.position.y, node.position.z || 0); } /** * Get the point between two vectors. */ function getPointBetween(from, to, offset) { const distance = from.distanceTo(to); return from.clone().add(to.clone().sub(from).multiplyScalar(offset / distance)); } /** * Given a node and a new vector set, update the node model. */ function updateNodePosition(node, offset) { return { ...node, position: { ...node.position, x: node.position.x + offset.x, y: node.position.y + offset.y, z: node.position.z + offset.z } }; } /** * Calculate the curve offset for an edge. * This is used to offset edges that are parallel to each other (same source and same target). * This will return a curveOffset of null if the edge is not parallel to any other edges. */ function calculateEdgeCurveOffset({ edge, edges, curved }) { let updatedCurved = curved; let curveOffset; const parallelEdges = edges.filter((e) => e.target === edge.target && e.source === edge.source).map((e) => e.id); if (parallelEdges.length > 1) { updatedCurved = true; const edgeIndex = parallelEdges.indexOf(edge.id); const offsetMultiplier = edgeIndex === 0 ? 1 : 1 + edgeIndex * .8; curveOffset = (edgeIndex % 2 === 0 ? 1 : -1) * (MULTI_EDGE_OFFSET_FACTOR * offsetMultiplier); } if (edge.data?.isAggregated && edges.length > 1) return { curved: true, curveOffset: parallelEdges.indexOf(edge.id) === 0 ? MULTI_EDGE_OFFSET_FACTOR : -.7 }; return { curved: updatedCurved, curveOffset }; } /** * Calculate the offset position for a subLabel based on edge orientation and placement preference * * @param fromPosition - Position of the source node * @param toPosition - Position of the target node * @param subLabelPlacement - Whether to place the subLabel 'above' or 'below' the edge * @returns Object with x, y offset values for positioning the subLabel perpendicular to the edge * * The function calculates a perpendicular offset from the edge line, with the direction * determined by both the subLabelPlacement ('above' or 'below') and the edge direction. * The perpendicular angle is calculated differently based on whether the edge is going * left-to-right or right-to-left to maintain consistent 'above'/'below' positioning. */ function calculateSubLabelOffset(fromPosition, toPosition, subLabelPlacement) { const dx = toPosition.x - fromPosition.x; const dy = toPosition.y - fromPosition.y; const angle = Math.atan2(dy, dx); const perpAngle = subLabelPlacement === "above" ? dx >= 0 ? angle + Math.PI / 2 : angle - Math.PI / 2 : dx >= 0 ? angle - Math.PI / 2 : angle + Math.PI / 2; const offsetDistance = 7; return { x: Math.cos(perpAngle) * offsetDistance, y: Math.sin(perpAngle) * offsetDistance, z: 0 }; } //#endregion //#region src/utils/textMeasurement.ts var measurementCache = /* @__PURE__ */ new Map(); var canvasContext = null; /** * Get or create a canvas context for text measurements. */ function getCanvasContext() { if (!canvasContext) { canvasContext = document.createElement("canvas").getContext("2d"); if (!canvasContext) throw new Error("Failed to create canvas context for text measurement"); } return canvasContext; } /** * Generate a cache key from measurement options. */ function getCacheKey(options) { const { text, fontSize, fontWeight = 400, fontFamily = "sans-serif" } = options; return `${text}|${fontSize}|${fontWeight}|${fontFamily}`; } /** * Measure text dimensions using Canvas API. * Results are cached for performance. * * @param options - Text measurement options * @returns Text dimensions (width and height) */ function measureText(options) { const cacheKey = getCacheKey(options); const cached = measurementCache.get(cacheKey); if (cached) return cached; const { text, fontSize, fontWeight = 400, fontFamily = "sans-serif" } = options; try { const context = getCanvasContext(); context.font = `${fontWeight} ${fontSize}px ${fontFamily}`; const metrics = context.measureText(text); const dimensions = { width: metrics.width, height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent || fontSize * 1.2 }; measurementCache.set(cacheKey, dimensions); return dimensions; } catch (error) { console.warn("Failed to measure text, falling back to estimation:", error); return { width: text.length * fontSize * .6, height: fontSize * 1.2 }; } } /** * Clear the measurement cache. * Useful for testing or memory management. */ function clearMeasurementCache() { measurementCache.clear(); } /** * Get the current cache size. */ function getMeasurementCacheSize() { return measurementCache.size; } //#endregion //#region src/store.ts /** * Build the id -> node lookup map used for O(1) node access. */ var buildNodeMap = (nodes) => { const map = /* @__PURE__ */ new Map(); for (const node of nodes) map.set(node.id, node); return map; }; /** * Build the set of node ids that have at least one outbound edge. */ var buildOutboundSet = (edges) => { const set = /* @__PURE__ */ new Set(); for (const edge of edges) set.add(edge.source); return set; }; /** * Build the id -> edge lookup map used for O(1) edge access. */ var buildEdgeMap = (edges) => { const map = /* @__PURE__ */ new Map(); for (const edge of edges) map.set(edge.id, edge); return map; }; var createStore = ({ actives = [], selections = [], collapsedNodeIds = [], theme }) => create((set) => ({ theme: { ...theme, edge: { ...theme?.edge, label: { ...theme?.edge?.label, fontSize: theme?.edge?.label?.fontSize ?? 6 } } }, edges: [], nodes: [], nodeMap: /* @__PURE__ */ new Map(), nodesWithOutboundEdges: /* @__PURE__ */ new Set(), edgeMap: /* @__PURE__ */ new Map(), collapsedNodeIds, clusters: /* @__PURE__ */ new Map(), panning: false, draggingIds: [], actives, hoveredEdgeIds: [], edgeContextMenus: /* @__PURE__ */ new Set(), edgeMeshes: [], selections, hoveredNodeId: null, drags: {}, graph: new Graph({ multi: true }), setTheme: (theme) => set((state) => ({ ...state, theme })), setClusters: (clusters) => set((state) => ({ ...state, clusters })), setEdgeContextMenus: (edgeContextMenus) => set((state) => ({ ...state, edgeContextMenus })), setEdgeMeshes: (edgeMeshes) => set((state) => ({ ...state, edgeMeshes })), setPanning: (panning) => set((state) => ({ ...state, panning })), setDrags: (drags) => set((state) => ({ ...state, drags })), addDraggingId: (id) => set((state) => ({ ...state, draggingIds: [...state.draggingIds, id] })), removeDraggingId: (id) => set((state) => ({ ...state, draggingIds: state.draggingIds.filter((drag) => drag !== id) })), setActives: (actives) => set((state) => ({ ...state, actives })), setSelections: (selections) => set((state) => ({ ...state, selections })), setHoveredNodeId: (hoveredNodeId) => set((state) => ({ ...state, hoveredNodeId })), setHoveredEdgeIds: (hoveredEdgeIds) => set((state) => ({ ...state, hoveredEdgeIds })), setNodes: (nodes) => set((state) => ({ ...state, nodes, nodeMap: buildNodeMap(nodes), centerPosition: getLayoutCenter(nodes) })), setEdges: (edges) => set((state) => ({ ...state, edges, edgeMap: buildEdgeMap(edges), nodesWithOutboundEdges: buildOutboundSet(edges) })), setNodePosition: (id, position) => set((state) => { const node = state.nodeMap.get(id); if (!node) return state; const originalVector = getVector(node); const offset = new Vector3(position.x, position.y, position.z).sub(originalVector); const idsToMove = state.selections?.includes(id) ? state.selections : [id]; const moving = new Set(idsToMove); const nodeMap = new Map(state.nodeMap); const nodes = state.nodes.map((n) => { if (moving.has(n.id)) { const updated = updateNodePosition(n, offset); nodeMap.set(n.id, updated); return updated; } return n; }); return { ...state, drags: { ...state.drags, [id]: node }, nodes, nodeMap }; }), setCollapsedNodeIds: (nodeIds = []) => set((state) => ({ ...state, collapsedNodeIds: nodeIds })), setClusterPosition: (id, position) => set((state) => { const clusters = new Map(state.clusters); const cluster = clusters.get(id); if (cluster) { const oldPos = cluster.position; const offset = new Vector3(position.x - oldPos.x, position.y - oldPos.y, position.z - (oldPos.z ?? 0)); const nodes = [...state.nodes]; const nodeMap = new Map(state.nodeMap); const drags = { ...state.drags }; nodes.forEach((node, index) => { if (node.cluster === id) { nodes[index] = { ...node, position: { ...node.position, x: node.position.x + offset.x, y: node.position.y + offset.y, z: node.position.z + (offset.z ?? 0) } }; nodeMap.set(node.id, nodes[index]); drags[node.id] = node; } }); const newClusterPosition = getLayoutCenter(nodes.filter((node) => node.cluster === id)); clusters.set(id, { ...cluster, position: newClusterPosition }); return { ...state, drags: { ...drags, [id]: cluster }, clusters, nodes, nodeMap }; } return state; }) })); var defaultStore = createStore({}); var StoreContext = isServerRender ? null : createContext(defaultStore); var Provider = ({ children, store = defaultStore }) => { if (isServerRender) return children; return React.createElement(StoreContext.Provider, { value: store }, children); }; var useStore$1 = (selector) => { return useStore(useContext(StoreContext), useShallow(selector)); }; //#endregion //#region src/CameraControls/useCameraControls.ts var CameraControlsContext = createContext({ controls: null, resetControls: () => void 0, zoomIn: () => void 0, zoomOut: () => void 0, dollyIn: () => void 0, dollyOut: () => void 0, panLeft: () => void 0, panRight: () => void 0, panUp: () => void 0, panDown: () => void 0, freeze: () => void 0, unFreeze: () => void 0 }); var useCameraControls = () => { const context = useContext(CameraControlsContext); if (context === void 0) throw new Error("`useCameraControls` hook must be used within a `ControlsProvider` component"); return context; }; //#endregion //#region src/CameraControls/CameraControls.tsx ThreeCameraControls.install({ THREE: { MOUSE, Vector2, Vector3, Vector4, Quaternion, Matrix4, Spherical, Box3, Sphere: Sphere$1, Raycaster, MathUtils: { DEG2RAD: MathUtils?.DEG2RAD, clamp: MathUtils?.clamp } } }); extend({ ThreeCameraControls }); var CameraControls = forwardRef(({ mode = "rotate", children, animated, disabled, minDistance = 1e3, maxDistance = 5e4, minZoom = 1, maxZoom = 100 }, ref) => { const cameraRef = useRef(null); const camera = useThree((state) => state.camera); const gl = useThree((state) => state.gl); const isOrbiting = mode === "orbit"; const setPanning = useStore$1((state) => state.setPanning); const isDragging = useStore$1((state) => state.draggingIds.length > 0); const cameraSpeedRef = useRef(0); const [controlMounted, setControlMounted] = useState(false); useFrame((_state, delta) => { if (cameraRef.current?.enabled) cameraRef.current?.update(delta); if (isOrbiting) cameraRef.current.azimuthAngle += 20 * delta * MathUtils.DEG2RAD; }, -1); useEffect(() => () => cameraRef.current?.dispose(), []); const zoomIn = useCallback(() => { cameraRef.current?.zoom(camera.zoom / 2, animated); }, [animated, camera.zoom]); const zoomOut = useCallback(() => { cameraRef.current?.zoom(-camera.zoom / 2, animated); }, [animated, camera.zoom]); const dollyIn = useCallback((distance) => { cameraRef.current?.dolly(distance, animated); }, [animated]); const dollyOut = useCallback((distance) => { cameraRef.current?.dolly(distance, animated); }, [animated]); const panRight = useCallback((event) => { if (!isOrbiting) cameraRef.current?.truck(-.03 * event.deltaTime, 0, animated); }, [animated, isOrbiting]); const panLeft = useCallback((event) => { if (!isOrbiting) cameraRef.current?.truck(.03 * event.deltaTime, 0, animated); }, [animated, isOrbiting]); const panUp = useCallback((event) => { if (!isOrbiting) cameraRef.current?.truck(0, .03 * event.deltaTime, animated); }, [animated, isOrbiting]); const panDown = useCallback((event) => { if (!isOrbiting) cameraRef.current?.truck(0, -.03 * event.deltaTime, animated); }, [animated, isOrbiting]); const onKeyDown = useCallback((event) => { if (event.code === "Space") if (mode === "rotate") cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.TRUCK; else cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.ROTATE; }, [mode]); const onKeyUp = useCallback((event) => { if (event.code === "Space") if (mode === "rotate") cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.ROTATE; else cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.TRUCK; }, [mode]); const [keyControls, setKeyControls] = useState(null); useEffect(() => { if (!isServerRender) setKeyControls({ leftKey: new holdEvent.KeyboardKeyHold("ArrowLeft", 100), rightKey: new holdEvent.KeyboardKeyHold("ArrowRight", 100), upKey: new holdEvent.KeyboardKeyHold("ArrowUp", 100), downKey: new holdEvent.KeyboardKeyHold("ArrowDown", 100) }); }, []); useEffect(() => { if (!disabled && keyControls) { keyControls.leftKey.addEventListener("holding", panLeft); keyControls.rightKey.addEventListener("holding", panRight); keyControls.upKey.addEventListener("holding", panUp); keyControls.downKey.addEventListener("holding", panDown); window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); } return () => { if (keyControls) { keyControls.leftKey.removeEventListener("holding", panLeft); keyControls.rightKey.removeEventListener("holding", panRight); keyControls.upKey.removeEventListener("holding", panUp); keyControls.downKey.removeEventListener("holding", panDown); window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); } }; }, [ disabled, onKeyDown, onKeyUp, panDown, panLeft, panRight, panUp, keyControls ]); useEffect(() => { const isOrthographic = mode === "orthographic"; if (disabled) { cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.NONE; cameraRef.current.mouseButtons.middle = ThreeCameraControls.ACTION.NONE; cameraRef.current.mouseButtons.wheel = ThreeCameraControls.ACTION.NONE; } else { cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.TRUCK; cameraRef.current.mouseButtons.middle = ThreeCameraControls.ACTION.TRUCK; cameraRef.current.mouseButtons.wheel = isOrthographic ? ThreeCameraControls.ACTION.ZOOM : ThreeCameraControls.ACTION.DOLLY; } if (isOrthographic && cameraRef.current) { cameraRef.current.maxZoom = maxZoom; cameraRef.current.minZoom = minZoom; } }, [ disabled, mode, minZoom, maxZoom ]); useEffect(() => { const onControl = () => setPanning(true); const onControlEnd = () => setPanning(false); const ref = cameraRef.current; if (ref) { ref.addEventListener("control", onControl); ref.addEventListener("controlend", onControlEnd); } return () => { if (ref) { ref.removeEventListener("control", onControl); ref.removeEventListener("controlend", onControlEnd); } }; }, [cameraRef, setPanning]); useEffect(() => { if (isDragging) { cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.NONE; cameraRef.current.touches.one = ThreeCameraControls.ACTION.NONE; } else if (mode === "rotate") { cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.ROTATE; cameraRef.current.touches.one = ThreeCameraControls.ACTION.TOUCH_ROTATE; } else { cameraRef.current.touches.one = ThreeCameraControls.ACTION.TOUCH_TRUCK; cameraRef.current.mouseButtons.left = ThreeCameraControls.ACTION.TRUCK; } }, [isDragging, mode]); const values = useMemo(() => ({ controls: cameraRef.current, zoomIn: () => zoomIn(), zoomOut: () => zoomOut(), dollyIn: (distance = 1e3) => dollyIn(distance), dollyOut: (distance = -1e3) => dollyOut(distance), panLeft: (deltaTime = 100) => panLeft({ deltaTime }), panRight: (deltaTime = 100) => panRight({ deltaTime }), panDown: (deltaTime = 100) => panDown({ deltaTime }), panUp: (deltaTime = 100) => panUp({ deltaTime }), resetControls: (animated) => cameraRef.current?.normalizeRotations().reset(animated), freeze: () => { if (cameraRef.current.truckSpeed) cameraSpeedRef.current = cameraRef.current.truckSpeed; cameraRef.current.truckSpeed = 0; }, unFreeze: () => cameraRef.current.truckSpeed = cameraSpeedRef.current }), [ zoomIn, zoomOut, panLeft, panRight, panDown, panUp, cameraRef.current ]); useImperativeHandle(ref, () => values); return /* @__PURE__ */ jsxs(CameraControlsContext.Provider, { value: values, children: [/* @__PURE__ */ jsx("threeCameraControls", { ref: (controls) => { cameraRef.current = controls; if (!controlMounted) setControlMounted(true); }, args: [camera, gl.domElement], smoothTime: .1, minDistance, dollyToCursor: true, maxDistance }), children] }); }); //#endregion //#region src/CameraControls/utils.ts /** * Get the visible height at the z depth. * Ref: https://discourse.threejs.org/t/functions-to-calculate-the-visible-width-height-at-a-given-z-depth-from-a-perspective-camera/269 */ function visibleHeightAtZDepth(depth, camera) { const cameraOffset = camera.position.z; if (depth < cameraOffset) depth -= cameraOffset; else depth += cameraOffset; const vFOV = camera.fov / camera.zoom * Math.PI / 180; return 2 * Math.tan(vFOV / 2) * Math.abs(depth); } /** * Get the visible width at the z depth. */ function visibleWidthAtZDepth(depth, camera) { return visibleHeightAtZDepth(depth, camera) * camera.aspect; } /** * Returns whether the node is in view of the camera. */ function isNodeInView(camera, nodePosition) { const visibleWidth = visibleWidthAtZDepth(1, camera); const visibleHeight = visibleHeightAtZDepth(1, camera); const visibleArea = { x0: camera?.position?.x - visibleWidth / 2, x1: camera?.position?.x + visibleWidth / 2, y0: camera?.position?.y - visibleHeight / 2, y1: camera?.position?.y + visibleHeight / 2 }; return nodePosition?.x > visibleArea.x0 && nodePosition?.x < visibleArea.x1 && nodePosition?.y > visibleArea.y0 && nodePosition?.y < visibleArea.y1; } /** * Get the closest axis to a given angle. */ function getClosestAxis(angle, axes) { return axes.reduce((prev, curr) => Math.abs(curr - angle % Math.PI) < Math.abs(prev - angle % Math.PI) ? curr : prev); } /** * Get how far an angle is from the closest 2D axis in radians. */ function getDegreesToClosest2dAxis(horizontalAngle, verticalAngle) { const closestHorizontalAxis = getClosestAxis(horizontalAngle, [0, Math.PI]); const closestVerticalAxis = getClosestAxis(verticalAngle, [Math.PI / 2, 3 * Math.PI / 2]); return { horizontalRotation: closestHorizontalAxis - horizontalAngle % Math.PI, verticalRotation: closestVerticalAxis - verticalAngle % Math.PI }; } //#endregion //#region src/collapse/utils.ts /** * Get a node's outbound edges in O(degree) via graphology's adjacency index. */ var getOutboundEdges = (graph, nodeId) => graph.hasNode(nodeId) ? graph.mapOutEdges(nodeId, (_key, attributes) => attributes) : []; /** * Get a node's inbound edges in O(degree) via graphology's adjacency index. */ var getInboundEdges = (graph, nodeId) => graph.hasNode(nodeId) ? graph.mapInEdges(nodeId, (_key, attributes) => attributes) : []; /** * Get the children of a node id that is hidden. */ function getHiddenChildren({ nodeId, graph, currentHiddenNodes, currentHiddenEdges }) { const hiddenNodes = []; const hiddenEdges = []; const curHiddenNodeIds = new Set(currentHiddenNodes.map((n) => n.id)); const curHiddenEdgeIds = new Set(currentHiddenEdges.map((e) => e.id)); const outboundEdges = getOutboundEdges(graph, nodeId); const outboundEdgeNodeIds = outboundEdges.map((l) => l.target); hiddenEdges.push(...outboundEdges); for (const outboundEdgeNodeId of outboundEdgeNodeIds) { const incomingEdges = getInboundEdges(graph, outboundEdgeNodeId).filter((l) => l.source !== nodeId); let hideNode = false; if (incomingEdges.length === 0) hideNode = true; else if (incomingEdges.length > 0 && !curHiddenNodeIds.has(outboundEdgeNodeId)) { if (incomingEdges.every((l) => curHiddenEdgeIds.has(l.id))) hideNode = true; } if (hideNode) { if (graph.hasNode(outboundEdgeNodeId)) hiddenNodes.push(graph.getNodeAttributes(outboundEdgeNodeId)); const nested = getHiddenChildren({ nodeId: outboundEdgeNodeId, graph, currentHiddenEdges: hiddenEdges, currentHiddenNodes: hiddenNodes }); hiddenEdges.push(...nested.hiddenEdges); hiddenNodes.push(...nested.hiddenNodes); } } return { hiddenEdges: Object.values(hiddenEdges.reduce((acc, next) => ({ ...acc, [next.id]: next }), {})), hiddenNodes: Object.values(hiddenNodes.reduce((acc, next) => ({ ...acc, [next.id]: next }), {})) }; } /** * Get the visible nodes and edges given a collapsed set of ids. */ var getVisibleEntities = ({ collapsedIds, nodes, edges, graph: providedGraph }) => { if (!collapsedIds?.length) return { visibleNodes: nodes, visibleEdges: edges }; const graph = providedGraph ?? buildGraph(new Graph({ multi: true }), nodes, edges); const curHiddenNodes = []; const curHiddenEdges = []; for (const collapsedId of collapsedIds) { const { hiddenEdges, hiddenNodes } = getHiddenChildren({ nodeId: collapsedId, graph, currentHiddenEdges: curHiddenEdges, currentHiddenNodes: curHiddenNodes }); curHiddenNodes.push(...hiddenNodes); curHiddenEdges.push(...hiddenEdges); } const hiddenNodeIds = new Set(curHiddenNodes.map((n) => n.id)); const hiddenEdgeIds = new Set(curHiddenEdges.map((e) => e.id)); return { visibleNodes: nodes.filter((n) => !hiddenNodeIds.has(n.id)), visibleEdges: edges.filter((e) => !hiddenEdgeIds.has(e.id)) }; }; /** * Get the path to expand a node. */ var getExpandPath = ({ nodeId, edges, visibleEdgeIds }) => { const graph = new Graph({ multi: true }); for (const edge of edges) { graph.mergeNode(edge.source); graph.mergeNode(edge.target); graph.addEdge(edge.source, edge.target, edge); } const visibleEdgeIdSet = new Set(visibleEdgeIds); const walk = (id) => { const parentIds = []; const inboundEdges = getInboundEdges(graph, id); if (inboundEdges.some((edge) => visibleEdgeIdSet.has(edge.id))) return parentIds; let addedParent = false; for (const edge of inboundEdges) if (!addedParent) { parentIds.push(...[edge.source, ...walk(edge.source)]); addedParent = true; } return parentIds; }; return walk(nodeId); }; //#endregion //#region src/collapse/useCollapse.ts var useCollapse = ({ collapsedNodeIds = [], nodes = [], edges = [] }) => { const { visibleNodes, visibleEdges } = useMemo(() => getVisibleEntities({ nodes, edges, collapsedIds: collapsedNodeIds }), [ collapsedNodeIds, edges, nodes ]); const visibleNodeIds = useMemo(() => new Set(visibleNodes.map((n) => n.id)), [visibleNodes]); return { getIsCollapsed: useCallback((nodeId) => !visibleNodeIds.has(nodeId), [visibleNodeIds]), getExpandPathIds: useCallback((nodeId) => { return getExpandPath({ nodeId, edges, visibleEdgeIds: visibleEdges.map((e) => e.id) }); }, [visibleEdges, edges]) }; }; //#endregion //#region src/CameraControls/useCenterGraph.ts var PADDING = 50; var useCenterGraph = ({ animated, disabled, layoutType }) => { const nodes = useStore$1((state) => state.nodes); const nodeMap = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); const [isCentered, setIsCentered] = useState(false); const invalidate = useThree((state) => state.invalidate); const { controls } = useCameraControls(); const camera = useThree((state) => state.camera); const mounted = useRef(false); const centerNodes = useCallback(async (nodes, opts) => { const animated = opts?.animated !== void 0 ? opts?.animated : true; const centerOnlyIfNodesNotInView = opts?.centerOnlyIfNodesNotInView !== void 0 ? opts?.centerOnlyIfNodesNotInView : false; if (!mounted.current || !centerOnlyIfNodesNotInView || centerOnlyIfNodesNotInView && nodes?.some((node) => !isNodeInView(camera, node.position))) { const { x, y, z } = getLayoutCenter(nodes); await controls.normalizeRotations().setTarget(x, y, z, animated); if (!isCentered) setIsCentered(true); invalidate(); } }, [ invalidate, controls, nodes ]); const fitNodesInView = useCallback(async (nodes, opts = { animated: true, fitOnlyIfNodesNotInView: false }) => { const { fitOnlyIfNodesNotInView } = opts; if (!fitOnlyIfNodesNotInView || fitOnlyIfNodesNotInView && nodes?.some((node) => !isNodeInView(camera, node.position))) { const { minX, maxX, minY, maxY, minZ, maxZ } = getLayoutCenter(nodes); if (!layoutType.includes("3d")) { const { horizontalRotation, verticalRotation } = getDegreesToClosest2dAxis(controls?.azimuthAngle, controls?.polarAngle); controls?.rotate(horizontalRotation, verticalRotation, true); } await controls?.zoomTo(1, opts?.animated); await controls?.fitToBox(new Box3(new Vector3(minX, minY, minZ), new Vector3(maxX, maxY, maxZ)), opts?.animated, { cover: false, paddingLeft: PADDING, paddingRight: PADDING, paddingBottom: PADDING, paddingTop: PADDING }); } }, [ camera, controls, layoutType ]); const getNodesById = useCallback((nodeIds) => { let mappedNodes = null; if (nodeIds?.length) mappedNodes = nodeIds.reduce((acc, id) => { const node = nodeMap.get(id); if (node) acc.push(node); else throw new Error(`Attempted to center ${id} but it was not found in the nodes`); return acc; }, []); return mappedNodes; }, [nodeMap]); const centerNodesById = useCallback((nodeIds, opts) => { centerNodes(getNodesById(nodeIds) || nodes, { animated, centerOnlyIfNodesNotInView: opts?.centerOnlyIfNodesNotInView }); }, [ animated, centerNodes, getNodesById, nodes ]); const fitNodesInViewById = useCallback(async (nodeIds, opts) => { await fitNodesInView(getNodesById(nodeIds) || nodes, { animated, ...opts }); }, [ animated, fitNodesInView, getNodesById, nodes ]); useLayoutEffect(() => { async function load() { if (controls && nodes?.length) { if (!mounted.current) { await centerNodes(nodes, { animated: false }); await fitNodesInView(nodes, { animated: false }); mounted.current = true; } } } load(); }, [ controls, centerNodes, nodes, animated, camera, fitNodesInView ]); return { centerNodes, centerNodesById, fitNodesInViewById, isCentered }; }; //#endregion //#region src/symbols/Arrow.tsx var Arrow = ({ animated, color = "#D8E6EA", length, opacity = .5, position, rotation, size = 1, onActive, onContextMenu }) => { const normalizedColor = useMemo(() => new Color(color), [color]); const meshRef = useRef(null); const isDragging = useStore$1((state) => state.draggingIds.length > 0); const center = useStore$1((state) => state.centerPosition); const [{ pos, arrowOpacity }] = useSpring(() => ({ from: { pos: center ? [ center.x, center.y, center.z ] : [ 0, 0, 0 ], arrowOpacity: 0 }, to: { pos: [ position.x, position.y, position.z ], arrowOpacity: opacity }, config: { ...animationConfig, duration: animated && !isDragging ? void 0 : 0 } }), [ animated, isDragging, opacity, position ]); const setQuaternion = useCallback(() => { const axis = new Vector3(0, 1, 0); meshRef.current?.quaternion.setFromUnitVectors(axis, rotation); }, [rotation, meshRef]); useEffect(() => setQuaternion(), [setQuaternion]); return /* @__PURE__ */ jsxs(a.mesh, { position: pos, ref: meshRef, scale: [ 1, 1, 1 ], onPointerOver: () => onActive(true), onPointerOut: () => onActive(false), onPointerDown: (event) => { if (event.nativeEvent.buttons === 2) event.stopPropagation(); }, onContextMenu: (event) => { event.nativeEvent.preventDefault(); event.stopPropagation(); onContextMenu(); }, children: [/* @__PURE__ */ jsx("cylinderGeometry", { args: [ 0, size, length, 20, 1, true ], attach: "geometry" }), /* @__PURE__ */ jsx(a.meshBasicMaterial, { attach: "material", color: normalizedColor, depthTest: false, opacity: arrowOpacity, transparent: true, side: DoubleSide, fog: true })] }); }; //#endregion //#region src/utils/useDrag.ts var useDrag = ({ draggable, set, position, bounds, onDragStart, onDragEnd }) => { const camera = useThree((state) => state.camera); const raycaster = useThree((state) => state.raycaster); const size = useThree((state) => state.size); const gl = useThree((state) => state.gl); const { mouse2D, mouse3D, offset, normal, plane } = useMemo(() => ({ mouse2D: new Vector2(), mouse3D: new Vector3(), offset: new Vector3(), normal: new Vector3(), plane: new Plane() }), []); const clientRect = useMemo(() => gl.domElement.getBoundingClientRect(), [gl.domElement, size]); return useGesture({ onDragStart: ({ event }) => { const { eventObject, point } = event; offset.setFromMatrixPosition(eventObject.matrixWorld).sub(point); mouse3D.copy(point); onDragStart(); }, onDrag: ({ xy, buttons, cancel }) => { if (buttons !== 1) { cancel(); return; } const nx = (xy[0] - (clientRect?.left ?? 0)) / size.width * 2 - 1; const ny = -((xy[1] - (clientRect?.top ?? 0)) / size.height) * 2 + 1; mouse2D.set(nx, ny); raycaster.setFromCamera(mouse2D, camera); camera.getWorldDirection(normal).negate(); plane.setFromNormalAndCoplanarPoint(normal, mouse3D); raycaster.ray.intersectPlane(plane, mouse3D); const updated = new Vector3(position.x, position.y, position.z).copy(mouse3D).add(offset); if (bounds) { const center = new Vector3((bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2, (bounds.minZ + bounds.maxZ) / 2); const radius = (bounds.maxX - bounds.minX) / 2; const direction = updated.clone().sub(center); if (direction.length() > radius) { direction.normalize().multiplyScalar(radius); updated.copy(center).add(direction); } } return set(updated); }, onDragEnd }, { drag: { enabled: draggable, threshold: 10 } }); }; //#endregion //#region src/utils/useHoverIntent.ts /** * Hover intent identifies if the user actually is * intending to over by measuring the position of the mouse * once a pointer enters and determining if in a duration if * the mouse moved inside a certain threshold and fires the events. */ var useHoverIntent = ({ sensitivity = 7, interval = 50, timeout = 0, disabled, onPointerOver, onPointerOut }) => { const mouseOver = useRef(false); const timer = useRef(null); const state = useRef(0); const coords = useRef({ x: null, y: null, px: null, py: null }); const onMouseMove = useCallback((event) => { coords.current.x = event.clientX; coords.current.y = event.clientY; }, []); const comparePosition = useCallback((event) => { timer.current = clearTimeout(timer.current); const { px, x, py, y } = coords.current; if (Math.abs(px - x) + Math.abs(py - y) < sensitivity) { state.current = 1; onPointerOver(event); } else { coords.current.px = x; coords.current.py = y; timer.current = setTimeout(() => comparePosition(event), interval); } }, [ interval, onPointerOver, sensitivity ]); const cleanup = useCallback(() => { clearTimeout(timer.current); if (typeof window !== "undefined") document.removeEventListener("mousemove", onMouseMove, false); }, [onMouseMove]); const pointerOver = useCallback((event) => { if (!disabled) { mouseOver.current = true; cleanup(); if (state.current !== 1) { coords.current.px = event.pointer.x; coords.current.py = event.pointer.y; if (typeof window !== "undefined") document.addEventListener("mousemove", onMouseMove, false); timer.current = setTimeout(() => comparePosition(event), timeout); } } }, [ cleanup, comparePosition, disabled, onMouseMove, timeout ]); const delay = useCallback((event) => { timer.curr