reagraph
Version:
WebGL Node-based Graph for React
1,476 lines • 171 kB
JavaScript
(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