From dff928142b040351ac61449f02f1ca412f424dc3 Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Wed, 11 Feb 2026 21:51:35 +0100 Subject: [PATCH] feat: rework cyvest-vis - Introduced `getLevelColor`, `lightenHexColor`, and `getLevelBackgroundColor` in `colors.ts` for color handling based on security levels. - Added `truncateLabel` function in `labels.ts` to manage label length and formatting. - Removed outdated observable utility functions from `observables.ts`. - Updated tests to reflect changes in utility functions and added new tests for label and icon helpers. - Updated dependencies in `pnpm-lock.yaml` to include new packages and versions. --- README.md | 2 +- docs/index.md | 2 +- docs/js-packages.md | 19 +- js/packages/cyvest-app/README.md | 18 +- js/packages/cyvest-app/src/App.tsx | 92 ++- js/packages/cyvest-app/src/main.tsx | 5 +- js/packages/cyvest-app/vite.config.ts | 11 + js/packages/cyvest-vis/README.md | 167 ++--- js/packages/cyvest-vis/package.json | 14 +- .../src/adapters/investigationElements.ts | 268 +++++++ .../src/adapters/observablesElements.ts | 138 ++++ .../src/components/CytoscapeCanvas.tsx | 229 ++++++ .../cyvest-vis/src/components/CyvestGraph.tsx | 234 ++---- .../components/CyvestInvestigationView.tsx | 57 ++ .../src/components/CyvestObservablesView.tsx | 57 ++ .../src/components/FloatingEdge.tsx | 73 -- .../cyvest-vis/src/components/Icons.tsx | 704 ------------------ .../src/components/InvestigationGraph.tsx | 357 --------- .../src/components/InvestigationNode.tsx | 162 ---- .../src/components/ObservableNode.tsx | 154 ---- .../src/components/ObservablesGraph.tsx | 511 ------------- .../cyvest-vis/src/core/createCyInstance.ts | 31 + js/packages/cyvest-vis/src/core/styles.ts | 159 ++++ js/packages/cyvest-vis/src/core/theme.ts | 25 + .../cyvest-vis/src/hooks/useDagreLayout.ts | 102 --- .../cyvest-vis/src/hooks/useForceLayout.ts | 457 ------------ js/packages/cyvest-vis/src/icons/svg.ts | 89 +++ js/packages/cyvest-vis/src/index.ts | 65 +- js/packages/cyvest-vis/src/layout/elk.ts | 89 +++ js/packages/cyvest-vis/src/styles.css | 132 ++++ js/packages/cyvest-vis/src/types.ts | 309 ++++---- js/packages/cyvest-vis/src/utils/colors.ts | 44 ++ js/packages/cyvest-vis/src/utils/labels.ts | 17 + .../cyvest-vis/src/utils/observables.ts | 77 -- .../cyvest-vis/tests/observables.test.ts | 78 +- js/pnpm-lock.yaml | 271 ++----- 36 files changed, 1851 insertions(+), 3368 deletions(-) create mode 100644 js/packages/cyvest-vis/src/adapters/investigationElements.ts create mode 100644 js/packages/cyvest-vis/src/adapters/observablesElements.ts create mode 100644 js/packages/cyvest-vis/src/components/CytoscapeCanvas.tsx create mode 100644 js/packages/cyvest-vis/src/components/CyvestInvestigationView.tsx create mode 100644 js/packages/cyvest-vis/src/components/CyvestObservablesView.tsx delete mode 100644 js/packages/cyvest-vis/src/components/FloatingEdge.tsx delete mode 100644 js/packages/cyvest-vis/src/components/Icons.tsx delete mode 100644 js/packages/cyvest-vis/src/components/InvestigationGraph.tsx delete mode 100644 js/packages/cyvest-vis/src/components/InvestigationNode.tsx delete mode 100644 js/packages/cyvest-vis/src/components/ObservableNode.tsx delete mode 100644 js/packages/cyvest-vis/src/components/ObservablesGraph.tsx create mode 100644 js/packages/cyvest-vis/src/core/createCyInstance.ts create mode 100644 js/packages/cyvest-vis/src/core/styles.ts create mode 100644 js/packages/cyvest-vis/src/core/theme.ts delete mode 100644 js/packages/cyvest-vis/src/hooks/useDagreLayout.ts delete mode 100644 js/packages/cyvest-vis/src/hooks/useForceLayout.ts create mode 100644 js/packages/cyvest-vis/src/icons/svg.ts create mode 100644 js/packages/cyvest-vis/src/layout/elk.ts create mode 100644 js/packages/cyvest-vis/src/styles.css create mode 100644 js/packages/cyvest-vis/src/utils/colors.ts create mode 100644 js/packages/cyvest-vis/src/utils/labels.ts delete mode 100644 js/packages/cyvest-vis/src/utils/observables.ts diff --git a/README.md b/README.md index 6f43e8e..15ec7b2 100644 --- a/README.md +++ b/README.md @@ -635,7 +635,7 @@ mkdocs build The repo includes a PNPM workspace under `js/` with three packages: - `@cyvest/cyvest-js`: TypeScript types, schema validation, and helpers for Cyvest investigations. -- `@cyvest/cyvest-vis`: React components for graph visualization (depends on `@cyvest/cyvest-js`). +- `@cyvest/cyvest-vis`: React components for graph visualization (Cytoscape + ELK/Dagre, depends on `@cyvest/cyvest-js`). - `@cyvest/cyvest-app`: Vite demo that bundles the JS packages with sample investigations. The JS packages track the generated schema; serialized investigations should include fields like diff --git a/docs/index.md b/docs/index.md index 2a68bc9..27b4aea 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,7 +101,7 @@ Cyvest (facade + fluent proxies) ## JavaScript Packages - `@cyvest/cyvest-js`: TypeScript types, schema validation, and graph helpers for Cyvest investigations. -- `@cyvest/cyvest-vis`: React components (XYFlow + D3) to visualize investigations. +- `@cyvest/cyvest-vis`: React components (Cytoscape + ELK/Dagre) to visualize investigations. - `@cyvest/cyvest-app`: Vite demo bundling the JS packages with sample investigations. [See JavaScript packages guide](js-packages.md) for install and workspace commands. diff --git a/docs/js-packages.md b/docs/js-packages.md index 7ed9ec4..9dbcf1f 100644 --- a/docs/js-packages.md +++ b/docs/js-packages.md @@ -10,31 +10,32 @@ recorded as an `INVESTIGATION_STARTED` event in the `audit_log`. ## Packages - **@cyvest/cyvest-js** — Generated types, schema validation, graph builders, tag hierarchy utilities (including aggregated score/level), and helper functions for Cyvest investigation JSON. Ships ESM/CJS builds and `.d.ts` files. -- **@cyvest/cyvest-vis** — React 19+ visualization components (powered by React Flow + D3) to visualize investigations with level-aware styling. Depends on `@cyvest/cyvest-js`. +- **@cyvest/cyvest-vis** — React 19+ visualization components (powered by Cytoscape) to visualize investigations with level-aware styling. Uses ELK for observables and Dagre for investigation hierarchy. Depends on `@cyvest/cyvest-js`. - **@cyvest/cyvest-app** — Private Vite demo that bundles sample investigations and renders them via `CyvestGraph`. Useful for tweaking visuals and testing UI flows. ## @cyvest/cyvest-vis -Interactive graph visualization for Cyvest investigations. +Interactive graph visualization for Cyvest investigations with a clean v2 API. ### Features -- **Observables Graph**: Force-directed layout showing all observables and relationships -- **Investigation Graph**: Hierarchical Dagre layout showing root → tags → checks +- **Observables Graph**: ELK `stress` layout showing all observables and relationships +- **Investigation Graph**: Dagre `LR` layout showing root → tags → checks - **Professional icons**: SVG icons for all observable types (IPs, domains, emails, files, etc.) -- **Interactive controls**: Drag nodes, adjust force parameters, zoom/pan +- **Interactive controls**: Pan/zoom, fit, and re-run layout - **Level-aware colors**: Nodes styled by security level (SAFE → MALICIOUS) ### Quick Start ```tsx import { CyvestGraph } from "@cyvest/cyvest-vis"; +import "@cyvest/cyvest-vis/styles.css"; console.log(id)} + onNodeSelect={(event) => console.log(event.nodeId, event.label)} /> ``` @@ -43,10 +44,10 @@ import { CyvestGraph } from "@cyvest/cyvest-vis"; | Component | Description | |-----------|-------------| | `CyvestGraph` | Combined view with toggle between Observables and Investigation | -| `ObservablesGraph` | Force-directed graph of observables and relationships | -| `InvestigationGraph` | Hierarchical graph of root, checks, and tags | +| `CyvestObservablesView` | ELK-based graph of observables and relationships | +| `CyvestInvestigationView` | Dagre-based graph of root, checks, and tags | -See `js/packages/cyvest-vis/src/components` for advanced hooks and utilities. +See `js/packages/cyvest-vis/README.md` for full v2 API and theming details. ## Workspace commands diff --git a/js/packages/cyvest-app/README.md b/js/packages/cyvest-app/README.md index 2e99c8b..10b97bc 100644 --- a/js/packages/cyvest-app/README.md +++ b/js/packages/cyvest-app/README.md @@ -1,17 +1,17 @@ # @cyvest/cyvest-app -Vite-based demo app that ships with sample Cyvest investigations and renders them with `@cyvest/cyvest-vis`. +Vite demo application for the `@cyvest/cyvest-vis` Cytoscape visualization library (ELK for observables, Dagre for investigation view). ## What it does -- Loads bundled investigations (`src/investigations/*.json`) and validates them with `@cyvest/cyvest-js`. -- Visualizes the graph and levels via the `CyvestGraph` component. -- Serves as a quick playground for design tweaks to the visualization layer. +- Loads bundled investigations (`src/investigations/*.json`) and validates them with `@cyvest/cyvest-js` +- Renders both observables and investigation views with `CyvestGraph` +- Demonstrates node selection events and basic layout customization ## Run locally ```bash -pnpm install # from repo root +pnpm install pnpm --filter @cyvest/cyvest-app dev ``` @@ -22,9 +22,7 @@ pnpm --filter @cyvest/cyvest-app build pnpm --filter @cyvest/cyvest-app preview ``` -## Customize the demo +## Notes -- Drop new investigations under `src/investigations/` and register them in `src/api.ts`. -- Adjust layout/controls in `src/App.tsx` to try new `CyvestGraph` props or styling. - -Note: The app is marked `private` and intended for demos and development, not publishing. +- The app imports `@cyvest/cyvest-vis/styles.css` for default visual styling. +- The package is private and intended as a development/demo surface. diff --git a/js/packages/cyvest-app/src/App.tsx b/js/packages/cyvest-app/src/App.tsx index 1652b4a..b2a88fd 100644 --- a/js/packages/cyvest-app/src/App.tsx +++ b/js/packages/cyvest-app/src/App.tsx @@ -1,21 +1,24 @@ import type { CyvestInvestigation } from "@cyvest/cyvest-js"; import { getStartedAt } from "@cyvest/cyvest-js"; -import { CyvestGraph } from "@cyvest/cyvest-vis"; +import { CyvestGraph, type CyNodeSelectEvent } from "@cyvest/cyvest-vis"; import React, { useEffect, useState } from "react"; import { loadInvestigation, INVESTIGATIONS, type InvestigationKey } from "./api"; export const App: React.FC = () => { const [investigation, setInvestigation] = useState(null); - const [selectedKey, setSelectedKey] = useState("cyvest_visual"); - const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedKey, setSelectedKey] = + useState("cyvest_visual"); + const [selectedNode, setSelectedNode] = useState( + null + ); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); setError(null); - setSelectedNodeId(null); + setSelectedNode(null); loadInvestigation(selectedKey) .then(setInvestigation) .catch((err) => { @@ -25,27 +28,43 @@ export const App: React.FC = () => { .finally(() => setLoading(false)); }, [selectedKey]); - if (error) return
{error}
; + if (error) { + return
{error}
; + } return ( -
-
-

Cyvest Demo

+
+
+

Cyvest Demo

- onChange({ chargeStrength: Number(e.target.value) }) - } - style={sliderStyle} - /> -
- -
-
- Link Distance - {config.linkDistance} -
- - onChange({ linkDistance: Number(e.target.value) }) - } - style={sliderStyle} - /> -
- -
-
- Collision - {config.collisionRadius} -
- - onChange({ collisionRadius: Number(e.target.value) }) - } - style={sliderStyle} - /> -
- - -
-
- ); -}; - -/** - * Inner component that uses the force layout hook. - * Must be wrapped in ReactFlowProvider. - */ -const ObservablesGraphInner: React.FC< - ObservablesGraphProps & { - initialNodes: Node[]; - initialEdges: Edge[]; - primaryRootId?: string; - } -> = ({ - initialNodes, - initialEdges, - primaryRootId, - height, - width, - forceConfig: initialForceConfig = {}, - onNodeClick, - onNodeDoubleClick, - className, - showControls = true, -}) => { - // Force config state - const [forceConfig, setForceConfig] = useState({ - ...DEFAULT_FORCE_CONFIG, - ...initialForceConfig, - }); - - // Track if this is the first render for fitView - const initialFitDone = useRef(false); - - // React Flow state - initialized with initial nodes/edges - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - - // Set initial nodes/edges when they change - React.useEffect(() => { - setNodes(initialNodes); - setEdges(initialEdges); - initialFitDone.current = false; - }, [initialNodes, initialEdges, setNodes, setEdges]); - - // Use the iterative force layout hook - const { - onNodeDragStart, - onNodeDrag, - onNodeDragStop, - updateForceConfig, - restartSimulation, - } = useForceLayout(forceConfig, primaryRootId); - - // Handle node click - const handleNodeClick = useCallback( - (_: React.MouseEvent, node: Node) => { - onNodeClick?.(node.id); - }, - [onNodeClick] - ); - - // Handle node double click - const handleNodeDoubleClick = useCallback( - (_: React.MouseEvent, node: Node) => { - onNodeDoubleClick?.(node.id); - }, - [onNodeDoubleClick] - ); - - // Handle force config update - const handleConfigChange = useCallback( - (updates: Partial) => { - setForceConfig((prev) => ({ ...prev, ...updates })); - updateForceConfig(updates); - }, - [updateForceConfig] - ); - - // MiniMap node color based on level - const miniMapNodeColor = useCallback((node: Node) => { - const data = node.data as unknown as ObservableNodeData; - return getLevelColor(data.level); - }, []); - - // Container styles - const containerStyle = useMemo( - () => ({ - width, - height, - position: "relative" as const, - background: "linear-gradient(180deg, #fafbfc 0%, #f0f4f8 100%)", - }), - [width, height] - ); - - return ( -
- - - - - - {showControls && ( - - - - )} - -
- ); -}; - -/** - * ObservablesGraph component. - * Displays all observables from an investigation as a force-directed graph. - * Wraps the inner component with ReactFlowProvider for hook access. - */ -export const ObservablesGraph: React.FC = (props) => { - const { investigation } = props; - - const { rootKeys, primaryRootId } = useMemo(() => { - const rootType = investigation.data_extraction.root_type; - if (!rootType) { - return { rootKeys: new Set(), primaryRootId: undefined }; - } - - const normalizedRootType = rootType.toLowerCase().trim(); - const rootsByType = Object.values(investigation.observables).filter( - (obs) => obs.type.toLowerCase() === normalizedRootType - ); - - return { - rootKeys: new Set(rootsByType.map((obs) => obs.key)), - primaryRootId: rootsByType[0]?.key, - }; - }, [investigation]); - - // Create initial nodes and edges - const { initialNodes, initialEdges } = useMemo(() => { - const nodes = createObservableNodes(investigation, rootKeys); - const edges = createObservableEdges(investigation); - return { initialNodes: nodes, initialEdges: edges }; - }, [investigation, rootKeys]); - - return ( - - - - ); -}; diff --git a/js/packages/cyvest-vis/src/core/createCyInstance.ts b/js/packages/cyvest-vis/src/core/createCyInstance.ts new file mode 100644 index 0000000..d4bbc35 --- /dev/null +++ b/js/packages/cyvest-vis/src/core/createCyInstance.ts @@ -0,0 +1,31 @@ +import cytoscape, { type Core } from "cytoscape"; +import dagre from "cytoscape-dagre"; +import elk from "cytoscape-elk"; + +let pluginRegistered = false; + +function ensurePluginsRegistered(): void { + if (pluginRegistered) { + return; + } + + cytoscape.use(elk); + cytoscape.use(dagre); + pluginRegistered = true; +} + +export function createCyInstance(container: HTMLDivElement): Core { + ensurePluginsRegistered(); + + return cytoscape({ + container, + elements: [], + style: [], + autoungrabify: false, + boxSelectionEnabled: false, + selectionType: "single", + wheelSensitivity: 0.2, + minZoom: 0.1, + maxZoom: 2.4, + }); +} diff --git a/js/packages/cyvest-vis/src/core/styles.ts b/js/packages/cyvest-vis/src/core/styles.ts new file mode 100644 index 0000000..080e610 --- /dev/null +++ b/js/packages/cyvest-vis/src/core/styles.ts @@ -0,0 +1,159 @@ +import type { Stylesheet } from "cytoscape"; + +import type { CyvestThemeTokens } from "../types"; +import { resolveTheme } from "../utils/colors"; + +export function createObservablesStylesheet( + theme?: Partial +): Stylesheet[] { + const resolved = resolveTheme(theme); + + return [ + { + selector: "node", + style: { + shape: "data(shape)", + width: "data(width)", + height: "data(height)", + label: "data(labelShort)", + color: resolved.panelText, + "font-size": 11, + "font-family": resolved.fontFamily, + "font-weight": 500, + "text-wrap": "none", + "text-max-width": 210, + "text-halign": "center", + "text-valign": "bottom", + "text-margin-y": 10, + "background-color": "data(fillColor)", + "border-color": "data(borderColor)", + "border-width": "data(borderWidth)", + "background-image": "data(icon)", + "background-fit": "none", + "background-width": "13px", + "background-height": "13px", + "background-position-x": "50%", + "background-position-y": "50%", + "background-image-opacity": 1, + "background-opacity": 1, + opacity: "data(opacity)", + "overlay-opacity": 0, + }, + }, + { + selector: "node:selected", + style: { + "border-color": resolved.accent, + "border-width": 3, + }, + }, + { + selector: "edge", + style: { + width: "data(width)", + "line-color": "data(color)", + "target-arrow-color": "data(color)", + "source-arrow-color": "data(color)", + "target-arrow-shape": "data(targetArrowShape)", + "source-arrow-shape": "data(sourceArrowShape)", + "curve-style": "bezier", + "arrow-scale": 1, + opacity: 0.88, + "overlay-opacity": 0, + }, + }, + { + selector: "edge:selected", + style: { + "line-color": resolved.edgeSelectedColor, + "target-arrow-color": resolved.edgeSelectedColor, + "source-arrow-color": resolved.edgeSelectedColor, + label: "data(relationshipType)", + color: resolved.panelTextMuted, + "font-size": 10, + "font-family": resolved.fontFamily, + "text-background-color": resolved.panelBackground, + "text-background-opacity": 0.98, + "text-background-padding": 2, + }, + }, + ]; +} + +export function createInvestigationStylesheet( + theme?: Partial +): Stylesheet[] { + const resolved = resolveTheme(theme); + + return [ + { + selector: "node", + style: { + shape: "data(shape)", + width: "data(width)", + height: "data(height)", + label: "data(labelShort)", + color: resolved.panelText, + "font-size": 10, + "font-family": resolved.fontFamily, + "font-weight": 500, + "text-wrap": "none", + "text-max-width": 190, + "text-halign": "center", + "text-valign": "center", + "text-justification": "center", + "background-color": "data(fillColor)", + "border-color": "data(borderColor)", + "border-width": "data(borderWidth)", + "background-image": "data(icon)", + "background-fit": "none", + "background-width": "18px", + "background-height": "18px", + "background-position-x": "10px", + "background-position-y": "50%", + "background-image-opacity": 0.9, + "text-margin-x": 0, + "overlay-opacity": 0, + }, + }, + { + selector: "node[nodeType = 'check']", + style: { + "text-halign": "center", + "background-position-x": "10px", + }, + }, + { + selector: "node:selected", + style: { + "border-color": resolved.accent, + "border-width": 3, + }, + }, + { + selector: "edge", + style: { + width: "data(width)", + "line-color": "data(color)", + "target-arrow-color": "data(color)", + "source-arrow-color": "data(color)", + "target-arrow-shape": "data(targetArrowShape)", + "source-arrow-shape": "data(sourceArrowShape)", + "curve-style": "taxi", + "taxi-direction": "rightward", + "taxi-turn": "26px", + "arrow-scale": 0.95, + opacity: 0.9, + "overlay-opacity": 0, + }, + }, + { + selector: "edge:selected", + style: { + "line-color": resolved.edgeSelectedColor, + "target-arrow-color": resolved.edgeSelectedColor, + "source-arrow-color": resolved.edgeSelectedColor, + }, + }, + ]; +} diff --git a/js/packages/cyvest-vis/src/core/theme.ts b/js/packages/cyvest-vis/src/core/theme.ts new file mode 100644 index 0000000..a4c7c29 --- /dev/null +++ b/js/packages/cyvest-vis/src/core/theme.ts @@ -0,0 +1,25 @@ +import type React from "react"; + +import type { CyvestThemeTokens } from "../types"; +import { resolveTheme } from "../utils/colors"; + +export function createThemeStyle( + theme: Partial | undefined, + width: number | string, + height: number | string +): React.CSSProperties { + const resolved = resolveTheme(theme); + + return { + width, + height, + "--cyvest-background": resolved.background, + "--cyvest-grid-color": resolved.gridColor, + "--cyvest-panel-bg": resolved.panelBackground, + "--cyvest-panel-border": resolved.panelBorder, + "--cyvest-panel-text": resolved.panelText, + "--cyvest-panel-muted": resolved.panelTextMuted, + "--cyvest-accent": resolved.accent, + "--cyvest-font-family": resolved.fontFamily, + } as React.CSSProperties; +} diff --git a/js/packages/cyvest-vis/src/hooks/useDagreLayout.ts b/js/packages/cyvest-vis/src/hooks/useDagreLayout.ts deleted file mode 100644 index 621685b..0000000 --- a/js/packages/cyvest-vis/src/hooks/useDagreLayout.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Hook for computing Dagre layout (hierarchical). - */ - -import { useMemo } from "react"; -import Dagre from "@dagrejs/dagre"; -import type { Node, Edge } from "@xyflow/react"; - -/** - * Dagre layout options. - */ -export interface DagreLayoutOptions { - /** Direction of the layout: TB (top-bottom), BT, LR (left-right), RL */ - direction?: "TB" | "BT" | "LR" | "RL"; - /** Horizontal spacing between nodes */ - nodeSpacing?: number; - /** Vertical spacing between ranks */ - rankSpacing?: number; -} - -const DEFAULT_OPTIONS: Required = { - direction: "LR", // Horizontal layout by default - nodeSpacing: 50, - rankSpacing: 100, -}; - -/** - * Apply Dagre layout to nodes and edges. - */ -export function computeDagreLayout( - nodes: Node[], - edges: Edge[], - options: DagreLayoutOptions = {} -): { nodes: Node[]; edges: Edge[] } { - if (nodes.length === 0) { - return { nodes, edges }; - } - - const opts = { ...DEFAULT_OPTIONS, ...options }; - - // Create dagre graph - const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - - g.setGraph({ - rankdir: opts.direction, - nodesep: opts.nodeSpacing, - ranksep: opts.rankSpacing, - marginx: 20, - marginy: 20, - }); - - // Add nodes to graph - for (const node of nodes) { - // Estimate node dimensions - const width = node.measured?.width ?? 150; - const height = node.measured?.height ?? 50; - g.setNode(node.id, { width, height }); - } - - // Add edges to graph - for (const edge of edges) { - g.setEdge(edge.source, edge.target); - } - - // Compute layout - Dagre.layout(g); - - // Update node positions - const positionedNodes = nodes.map((node) => { - const dagNode = g.node(node.id); - // Dagre returns center positions, adjust for React Flow (top-left) - const width = node.measured?.width ?? 150; - const height = node.measured?.height ?? 50; - return { - ...node, - position: { - x: dagNode.x - width / 2, - y: dagNode.y - height / 2, - }, - }; - }); - - return { nodes: positionedNodes, edges }; -} - -/** - * Hook for Dagre layout computation. - */ -export function useDagreLayout( - initialNodes: Node[], - initialEdges: Edge[], - options: DagreLayoutOptions = {} -): { - nodes: Node[]; - edges: Edge[]; -} { - const { nodes, edges } = useMemo(() => { - return computeDagreLayout(initialNodes, initialEdges, options); - }, [initialNodes, initialEdges, options]); - - return { nodes, edges }; -} diff --git a/js/packages/cyvest-vis/src/hooks/useForceLayout.ts b/js/packages/cyvest-vis/src/hooks/useForceLayout.ts deleted file mode 100644 index 54fa1ec..0000000 --- a/js/packages/cyvest-vis/src/hooks/useForceLayout.ts +++ /dev/null @@ -1,457 +0,0 @@ -/** - * Hook for computing force-directed layout using d3-force. - * Uses a stable simulation that persists across renders. - */ - -import { useEffect, useRef, useCallback, useMemo } from "react"; -import { - forceSimulation, - forceLink, - forceManyBody, - forceCenter, - forceCollide, - forceX, - forceY, - type Simulation, - type SimulationNodeDatum, - type SimulationLinkDatum, -} from "d3-force"; -import { - useReactFlow, - useNodesInitialized, - useStore, - type Node, - type Edge, -} from "@xyflow/react"; -import type { ForceLayoutConfig } from "../types"; -import { DEFAULT_FORCE_CONFIG } from "../types"; - -/** - * D3 simulation node with position. - */ -interface SimNode extends SimulationNodeDatum { - id: string; - x: number; - y: number; - fx?: number | null; - fy?: number | null; -} - -/** - * D3 simulation link. - */ -interface SimLink extends SimulationLinkDatum { - source: string | SimNode; - target: string | SimNode; -} - -/** - * Selector to get node IDs for change detection - */ -const nodeIdsSelector = (state: { nodeLookup: Map }) => { - const ids = Array.from(state.nodeLookup.keys()).sort(); - return ids.join(","); -}; - -/** - * Hook that applies iterative force-directed layout to React Flow nodes. - * The simulation runs continuously and updates node positions on each tick. - */ -export function useForceLayout( - config: Partial = {}, - rootNodeId?: string -) { - const { getNodes, getEdges, setNodes } = useReactFlow(); - const nodesInitialized = useNodesInitialized(); - const nodeIds = useStore(nodeIdsSelector); - - // Merge config with defaults - const forceConfig = useMemo( - () => ({ ...DEFAULT_FORCE_CONFIG, ...config }), - [config] - ); - - // Store the simulation reference - const simulationRef = useRef | null>(null); - - // Track dragging state with ref for immediate access - const draggingRef = useRef<{ - nodeId: string | null; - active: boolean; - }>({ nodeId: null, active: false }); - - // Store node positions in a ref to avoid React state conflicts during drag - const nodePositionsRef = useRef>( - new Map() - ); - - // Animation frame ref for smooth updates - const rafRef = useRef(null); - - // Initialize and run the simulation - useEffect(() => { - if (!nodesInitialized || !nodeIds) { - return; - } - - const nodes = getNodes(); - const edges = getEdges(); - - if (nodes.length === 0) { - return; - } - - // Create simulation nodes from React Flow nodes - const simNodes: SimNode[] = nodes.map((node) => { - // Check if this node already exists in the simulation - const existingNode = simulationRef.current - ?.nodes() - .find((n) => n.id === node.id); - - // Use existing position if available, otherwise use node position - const x = - existingNode?.x ?? - nodePositionsRef.current.get(node.id)?.x ?? - node.position.x ?? - Math.random() * 500 - 250; - const y = - existingNode?.y ?? - nodePositionsRef.current.get(node.id)?.y ?? - node.position.y ?? - Math.random() * 500 - 250; - - return { - id: node.id, - x, - y, - // Preserve fixed positions for dragged nodes or root - fx: existingNode?.fx ?? null, - fy: existingNode?.fy ?? null, - }; - }); - - // Fix root node at center - if (rootNodeId) { - const rootNode = simNodes.find((n) => n.id === rootNodeId); - if (rootNode) { - rootNode.x = 0; - rootNode.y = 0; - rootNode.fx = 0; - rootNode.fy = 0; - } - } - - // Create simulation links from React Flow edges - const simLinks: SimLink[] = edges.map((edge) => ({ - source: edge.source, - target: edge.target, - })); - - // Stop existing simulation - if (simulationRef.current) { - simulationRef.current.stop(); - } - - // Cancel any pending animation frame - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - - // Create the force simulation - const simulation = forceSimulation(simNodes) - .force( - "link", - forceLink(simLinks) - .id((d) => d.id) - .distance(forceConfig.linkDistance) - .strength(0.4) - ) - .force( - "charge", - forceManyBody().strength(forceConfig.chargeStrength) - ) - .force("center", forceCenter(0, 0).strength(forceConfig.centerStrength)) - .force("collision", forceCollide(forceConfig.collisionRadius)) - .force("x", forceX(0).strength(0.008)) - .force("y", forceY(0).strength(0.008)) - .alphaDecay(0.02) - .velocityDecay(0.35); - - // Batch updates using requestAnimationFrame for smoother rendering - const updateNodes = () => { - if (draggingRef.current.active) { - // Don't update node positions while actively dragging - rafRef.current = requestAnimationFrame(updateNodes); - return; - } - - const simNodes = simulation.nodes(); - - // Update position cache - for (const simNode of simNodes) { - nodePositionsRef.current.set(simNode.id, { x: simNode.x, y: simNode.y }); - } - - // Batch update React Flow nodes - setNodes((currentNodes) => - currentNodes.map((node) => { - const simNode = simNodes.find((n) => n.id === node.id); - if (!simNode) return node; - - // Skip update if position hasn't changed significantly - const dx = Math.abs(node.position.x - simNode.x); - const dy = Math.abs(node.position.y - simNode.y); - if (dx < 0.1 && dy < 0.1) return node; - - return { - ...node, - position: { - x: simNode.x, - y: simNode.y, - }, - }; - }) - ); - - if (simulation.alpha() > 0.001) { - rafRef.current = requestAnimationFrame(updateNodes); - } - }; - - simulation.on("tick", () => { - if (rafRef.current === null && simulation.alpha() > 0.001) { - rafRef.current = requestAnimationFrame(updateNodes); - } - }); - - simulationRef.current = simulation; - - // Cleanup: stop simulation when unmounting or dependencies change - return () => { - simulation.stop(); - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - }; - }, [ - nodesInitialized, - nodeIds, - getNodes, - getEdges, - setNodes, - forceConfig, - rootNodeId, - ]); - - /** - * Handle drag start - fix the node position - */ - const onNodeDragStart = useCallback( - (_: React.MouseEvent, node: Node) => { - const simulation = simulationRef.current; - if (!simulation) return; - - // Mark as dragging immediately - draggingRef.current = { nodeId: node.id, active: true }; - - // Find and fix the simulation node - const simNode = simulation.nodes().find((n) => n.id === node.id); - if (simNode) { - simNode.fx = node.position.x; - simNode.fy = node.position.y; - } - - // Gently reheat the simulation - simulation.alphaTarget(0.1).restart(); - }, - [] - ); - - /** - * Handle drag - update the fixed position without triggering React updates - */ - const onNodeDrag = useCallback((_: React.MouseEvent, node: Node) => { - const simulation = simulationRef.current; - if (!simulation) return; - - const simNode = simulation.nodes().find((n) => n.id === node.id); - if (simNode) { - simNode.fx = node.position.x; - simNode.fy = node.position.y; - // Update cache - nodePositionsRef.current.set(node.id, { - x: node.position.x, - y: node.position.y, - }); - } - }, []); - - /** - * Handle drag end - unfix the node and let simulation cool down - */ - const onNodeDragStop = useCallback( - (_: React.MouseEvent, node: Node) => { - const simulation = simulationRef.current; - - // Clear dragging state first - draggingRef.current = { nodeId: null, active: false }; - - if (!simulation) return; - - // Let simulation cool down gradually - simulation.alphaTarget(0); - - // Unfix the node (unless it's the root) - if (node.id !== rootNodeId) { - const simNode = simulation.nodes().find((n) => n.id === node.id); - if (simNode) { - simNode.fx = null; - simNode.fy = null; - } - } - - // Schedule a gentle restart to let the graph settle - setTimeout(() => { - if (simulationRef.current && !draggingRef.current.active) { - simulationRef.current.alpha(0.1).restart(); - } - }, 50); - }, - [rootNodeId] - ); - - /** - * Update force configuration dynamically - */ - const updateForceConfig = useCallback( - (updates: Partial) => { - const simulation = simulationRef.current; - if (!simulation) return; - - if (updates.chargeStrength !== undefined) { - simulation.force( - "charge", - forceManyBody().strength(updates.chargeStrength) - ); - } - - if (updates.linkDistance !== undefined) { - const linkForce = simulation.force("link") as - | ReturnType> - | undefined; - if (linkForce) { - linkForce.distance(updates.linkDistance); - } - } - - if (updates.collisionRadius !== undefined) { - simulation.force( - "collision", - forceCollide(updates.collisionRadius) - ); - } - - // Reheat simulation to apply changes - simulation.alpha(0.3).restart(); - }, - [] - ); - - /** - * Manually restart the simulation - */ - const restartSimulation = useCallback(() => { - const simulation = simulationRef.current; - if (!simulation) return; - - simulation.alpha(1).restart(); - }, []); - - return { - onNodeDragStart, - onNodeDrag, - onNodeDragStop, - updateForceConfig, - restartSimulation, - }; -} - -/** - * One-time force layout computation (for static layouts). - * Use this when you don't need continuous simulation. - */ -export function computeForceLayout( - nodes: Node[], - edges: Edge[], - config: ForceLayoutConfig, - centerX: number = 0, - centerY: number = 0, - rootNodeId?: string -): { nodes: Node[]; edges: Edge[] } { - if (nodes.length === 0) { - return { nodes, edges }; - } - - // Create simulation nodes - const simNodes: SimNode[] = nodes.map((node) => ({ - id: node.id, - x: node.position.x || Math.random() * 400 - 200, - y: node.position.y || Math.random() * 400 - 200, - fx: null, - fy: null, - })); - - // Find root node and fix it at center - if (rootNodeId) { - const rootNode = simNodes.find((n) => n.id === rootNodeId); - if (rootNode) { - rootNode.x = centerX; - rootNode.y = centerY; - rootNode.fx = centerX; - rootNode.fy = centerY; - } - } - - // Create simulation links - const simLinks: SimLink[] = edges.map((edge) => ({ - source: edge.source, - target: edge.target, - })); - - // Create and run simulation - const simulation = forceSimulation(simNodes) - .force( - "link", - forceLink(simLinks) - .id((d) => d.id) - .distance(config.linkDistance) - ) - .force("charge", forceManyBody().strength(config.chargeStrength)) - .force( - "center", - forceCenter(centerX, centerY).strength(config.centerStrength) - ) - .force("collision", forceCollide(config.collisionRadius)) - .stop(); - - // Run simulation ticks - for (let i = 0; i < config.iterations; i++) { - simulation.tick(); - } - - // Create positioned nodes - const positionedNodes = nodes.map((node) => { - const simNode = simNodes.find((n) => n.id === node.id); - return { - ...node, - position: { - x: simNode?.x ?? node.position.x, - y: simNode?.y ?? node.position.y, - }, - }; - }); - - return { nodes: positionedNodes, edges }; -} diff --git a/js/packages/cyvest-vis/src/icons/svg.ts b/js/packages/cyvest-vis/src/icons/svg.ts new file mode 100644 index 0000000..6a33814 --- /dev/null +++ b/js/packages/cyvest-vis/src/icons/svg.ts @@ -0,0 +1,89 @@ +import type { InvestigationCyNodeType } from "../types"; + +export interface IconRenderOptions { + color?: string; +} + +type IconName = + | "globe" + | "domain" + | "link" + | "mail" + | "file" + | "hash" + | "flask" + | "question" + | "crosshair" + | "check" + | "tag"; + +const ICON_PATHS: Record = { + globe: + '', + domain: + '', + link: + '', + mail: + '', + file: + '', + hash: + '', + flask: + '', + question: + '', + crosshair: + '', + check: + '', + tag: + '', +}; + +export const OBSERVABLE_ICON_NAME_MAP: Record = { + ipv4: "globe", + ipv6: "globe", + domain: "domain", + url: "link", + email: "mail", + hash: "hash", + file: "file", + artifact: "flask", +}; + +export const INVESTIGATION_ICON_NAME_MAP: Record = { + root: "crosshair", + check: "check", + tag: "tag", +}; + +function toSvgDataUri(iconName: IconName, color: string): string { + const body = ICON_PATHS[iconName] ?? ICON_PATHS.question; + const svg = [ + '`, + body, + "", + ].join(""); + + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +export function getObservableIconSvg( + observableType: string, + options?: IconRenderOptions +): string { + const normalizedType = observableType.toLowerCase().trim(); + const icon = OBSERVABLE_ICON_NAME_MAP[normalizedType] ?? "question"; + return toSvgDataUri(icon, options?.color ?? "#314264"); +} + +export function getInvestigationIconSvg( + nodeType: InvestigationCyNodeType, + options?: IconRenderOptions +): string { + const icon = INVESTIGATION_ICON_NAME_MAP[nodeType] ?? "question"; + return toSvgDataUri(icon, options?.color ?? "#314264"); +} diff --git a/js/packages/cyvest-vis/src/index.ts b/js/packages/cyvest-vis/src/index.ts index 027b824..7dca46e 100644 --- a/js/packages/cyvest-vis/src/index.ts +++ b/js/packages/cyvest-vis/src/index.ts @@ -1,36 +1,39 @@ -/** - * Cyvest Visualization Library - * - * React components for visualizing Cyvest investigations using React Flow. - * - * @packageDocumentation - */ - -// Main component exports export { CyvestGraph } from "./components/CyvestGraph"; -export { ObservablesGraph } from "./components/ObservablesGraph"; -export { InvestigationGraph } from "./components/InvestigationGraph"; +export { CyvestObservablesView } from "./components/CyvestObservablesView"; +export { CyvestInvestigationView } from "./components/CyvestInvestigationView"; -// Icon exports for customization export { - getObservableIcon, - getInvestigationIcon, - OBSERVABLE_ICON_MAP, - INVESTIGATION_ICON_MAP, - type IconProps, -} from "./components/Icons"; + getObservableIconSvg, + getInvestigationIconSvg, + OBSERVABLE_ICON_NAME_MAP, + INVESTIGATION_ICON_NAME_MAP, + type IconRenderOptions, +} from "./icons/svg"; -// Re-export types for consumers -export type { - CyvestGraphProps, - ObservablesGraphProps, - InvestigationGraphProps, - ForceLayoutConfig, - ObservableNodeData, - ObservableEdgeData, - InvestigationNodeData, - InvestigationNodeType, - ObservableShape, -} from "./types"; +export { truncateLabel } from "./utils/labels"; +export { + getLevelColor, + getLevelBackgroundColor, + lightenHexColor, +} from "./utils/colors"; -export { DEFAULT_FORCE_CONFIG } from "./types"; +export { createElkLayout, getDefaultElkOptions } from "./layout/elk"; + +export { + DEFAULT_CYVEST_THEME, + type CyvestThemeTokens, + type CyvestViewMode, + type CyvestElkDirection, + type CyvestElkOptions, + type CyNodeSelectEvent, + type CyEdgeSelectEvent, + type CyvestBaseViewProps, + type CyvestGraphProps, + type CyvestObservablesViewProps, + type CyvestInvestigationViewProps, + type ObservableCyNodeData, + type ObservableCyEdgeData, + type InvestigationCyNodeType, + type InvestigationCyNodeData, + type InvestigationCyEdgeData, +} from "./types"; diff --git a/js/packages/cyvest-vis/src/layout/elk.ts b/js/packages/cyvest-vis/src/layout/elk.ts new file mode 100644 index 0000000..d6a2db5 --- /dev/null +++ b/js/packages/cyvest-vis/src/layout/elk.ts @@ -0,0 +1,89 @@ +import type { LayoutOptions } from "cytoscape"; + +import type { CyvestElkOptions, CyvestViewMode } from "../types"; + +const DEFAULT_OBSERVABLES_LAYOUT: CyvestElkOptions = { + algorithm: "stress", + spacingNodeNode: 70, + spacingEdgeNode: 40, + padding: 56, + fit: true, + animate: false, +}; + +const DEFAULT_INVESTIGATION_LAYOUT: CyvestElkOptions = { + algorithm: "dagre", + direction: "RIGHT", + spacingNodeNode: 50, + spacingEdgeNode: 30, + spacingBetweenLayers: 120, + padding: 56, + fit: true, + animate: false, +}; + +export function getDefaultElkOptions(view: CyvestViewMode): CyvestElkOptions { + return view === "observables" + ? { ...DEFAULT_OBSERVABLES_LAYOUT } + : { ...DEFAULT_INVESTIGATION_LAYOUT }; +} + +function mergeElkOptions( + view: CyvestViewMode, + overrides?: CyvestElkOptions +): CyvestElkOptions { + return { + ...getDefaultElkOptions(view), + ...overrides, + extra: { + ...(getDefaultElkOptions(view).extra ?? {}), + ...(overrides?.extra ?? {}), + }, + }; +} + +export function createElkLayout( + view: CyvestViewMode, + overrides?: CyvestElkOptions +): LayoutOptions { + const merged = mergeElkOptions(view, overrides); + + if (view === "investigation") { + return { + name: "dagre", + fit: merged.fit ?? true, + padding: merged.padding ?? 56, + animate: merged.animate ?? false, + // Force horizontal left-to-right investigation flow. + rankDir: "LR", + rankSep: merged.spacingBetweenLayers ?? 120, + nodeSep: merged.spacingNodeNode ?? 50, + edgeSep: merged.spacingEdgeNode ?? 30, + ...(merged.extra ?? {}), + } as LayoutOptions; + } + + const elkOptions: Record = { + "elk.algorithm": merged.algorithm ?? "stress", + "elk.spacing.nodeNode": merged.spacingNodeNode ?? 60, + "elk.spacing.edgeNode": merged.spacingEdgeNode ?? 30, + ...(merged.direction ? { "elk.direction": merged.direction } : {}), + ...(merged.spacingBetweenLayers + ? { + "elk.layered.spacing.nodeNodeBetweenLayers": + merged.spacingBetweenLayers, + } + : {}), + ...(merged.extra ?? {}), + }; + + return { + name: "elk", + fit: merged.fit ?? true, + padding: merged.padding ?? 56, + animate: merged.animate ?? false, + nodeDimensionsIncludeLabels: true, + // cytoscape-elk reads these options and forwards them to elkjs + elk: elkOptions, + } as LayoutOptions; +} diff --git a/js/packages/cyvest-vis/src/styles.css b/js/packages/cyvest-vis/src/styles.css new file mode 100644 index 0000000..1b8b91d --- /dev/null +++ b/js/packages/cyvest-vis/src/styles.css @@ -0,0 +1,132 @@ +.cyvest-canvas { + position: relative; + overflow: hidden; + border: 1px solid var(--cyvest-panel-border); + border-radius: 14px; + background: + radial-gradient(circle at 8% 12%, rgba(255, 255, 255, 0.9), transparent 52%), + radial-gradient(circle at 90% 84%, rgba(31, 111, 235, 0.12), transparent 50%), + var(--cyvest-background); + font-family: var(--cyvest-font-family); +} + +.cyvest-canvas::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-image: linear-gradient( + to right, + rgba(120, 138, 170, 0.2) 1px, + transparent 1px + ), + linear-gradient( + to bottom, + rgba(120, 138, 170, 0.2) 1px, + transparent 1px + ); + background-size: 28px 28px; + opacity: 0.34; + z-index: 0; +} + +.cyvest-canvas__surface { + position: absolute; + inset: 0; + z-index: 1; +} + +.cyvest-toolbar { + position: absolute; + top: 10px; + right: 10px; + z-index: 12; + display: flex; + gap: 6px; + padding: 6px; + border-radius: 10px; + border: 1px solid var(--cyvest-panel-border); + background: var(--cyvest-panel-bg); + box-shadow: 0 8px 18px rgba(13, 27, 52, 0.16); +} + +.cyvest-toolbar__button { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 8px; + color: var(--cyvest-panel-text); + background: transparent; + cursor: pointer; + transition: background-color 0.14s ease, border-color 0.14s ease, + color 0.14s ease; +} + +.cyvest-toolbar__button:hover { + background: rgba(31, 111, 235, 0.12); + border-color: rgba(31, 111, 235, 0.24); + color: var(--cyvest-accent); +} + +.cyvest-toolbar__button svg { + width: 17px; + height: 17px; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.cyvest-view-toggle { + position: absolute; + top: 12px; + left: 12px; + z-index: 22; + display: flex; + gap: 4px; + padding: 4px; + border-radius: 11px; + border: 1px solid var(--cyvest-panel-border, #d3dae6); + background: var(--cyvest-panel-bg, rgba(255, 255, 255, 0.96)); + box-shadow: 0 8px 18px rgba(13, 27, 52, 0.16); + font-family: var(--cyvest-font-family, "IBM Plex Sans", "Segoe UI", sans-serif); +} + +.cyvest-view-toggle__button { + border: none; + border-radius: 8px; + padding: 8px 12px; + color: var(--cyvest-panel-muted, #556079); + background: transparent; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.01em; + cursor: pointer; + transition: background-color 0.14s ease, color 0.14s ease; +} + +.cyvest-view-toggle__button:hover { + background: rgba(31, 111, 235, 0.12); + color: var(--cyvest-accent, #1f6feb); +} + +.cyvest-view-toggle__button--active, +.cyvest-view-toggle__button--active:hover { + background: var(--cyvest-accent, #1f6feb); + color: #ffffff; +} + +@media (max-width: 768px) { + .cyvest-view-toggle { + top: 8px; + left: 8px; + } + + .cyvest-toolbar { + top: 8px; + right: 8px; + } +} diff --git a/js/packages/cyvest-vis/src/types.ts b/js/packages/cyvest-vis/src/types.ts index 1afdc6d..5cf6194 100644 --- a/js/packages/cyvest-vis/src/types.ts +++ b/js/packages/cyvest-vis/src/types.ts @@ -1,191 +1,156 @@ -/** - * Type definitions for cyvest-vis visualization library. - */ - -import type { Node, Edge } from "@xyflow/react"; -import type { Level } from "@cyvest/cyvest-js"; - -// ============================================================================ -// Observable Graph Types -// ============================================================================ - -/** - * Shape types for observable nodes. - * In the current design, all non-root nodes are circles. - */ -export type ObservableShape = "circle" | "rectangle"; - -/** - * Data attached to observable graph nodes. - */ -export interface ObservableNodeData extends Record { - /** Display label (may be truncated) */ - label: string; - /** Full observable value */ - fullValue: string; - /** Observable type (e.g., "domain", "ipv4") */ - observableType: string; - /** Security level */ - level: Level; - /** Numeric score */ - score: number; - /** Shape for this node type */ - shape: ObservableShape; - /** Whether this is the root observable */ - isRoot: boolean; - /** Whether the observable is whitelisted */ - whitelisted: boolean; - /** Whether the observable is internal */ - internal: boolean; +import type { CyvestInvestigation, Level, RelationshipDirection } from "@cyvest/cyvest-js"; +import type { Core, EdgeSingular, NodeSingular } from "cytoscape"; + +export type CyvestViewMode = "observables" | "investigation"; + +export interface CyvestThemeTokens { + background: string; + gridColor: string; + panelBackground: string; + panelBorder: string; + panelText: string; + panelTextMuted: string; + accent: string; + edgeColor: string; + edgeSelectedColor: string; + fontFamily: string; } -/** - * Observable graph node type. - */ -export type ObservableNode = Node; +export const DEFAULT_CYVEST_THEME: CyvestThemeTokens = { + background: "#f4f7fb", + gridColor: "#d7dfeb", + panelBackground: "rgba(255, 255, 255, 0.96)", + panelBorder: "#d3dae6", + panelText: "#172033", + panelTextMuted: "#556079", + accent: "#1f6feb", + edgeColor: "#8a95aa", + edgeSelectedColor: "#1f6feb", + fontFamily: + "'IBM Plex Sans', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif", +}; -/** - * Data attached to observable graph edges. - */ -export interface ObservableEdgeData extends Record { - /** Relationship type (e.g., "related-to") */ - relationshipType: string; - /** Whether this is a bidirectional relationship */ - bidirectional: boolean; +export type CyvestElkDirection = "RIGHT" | "LEFT" | "UP" | "DOWN"; + +export interface CyvestElkOptions { + algorithm?: string; + direction?: CyvestElkDirection; + spacingNodeNode?: number; + spacingEdgeNode?: number; + spacingBetweenLayers?: number; + padding?: number; + fit?: boolean; + animate?: boolean; + extra?: Record; } -/** - * Observable graph edge type. - */ -export type ObservableEdge = Edge; - -// ============================================================================ -// Investigation Graph Types (Dagre Layout) -// ============================================================================ - -/** - * Node types for the investigation graph view. - */ -export type InvestigationNodeType = "root" | "check" | "tag"; - -/** - * Data attached to investigation graph nodes. - */ -export interface InvestigationNodeData extends Record { - /** Display label */ +export interface CyNodeSelectEvent { + view: CyvestViewMode; + nodeId: string; + nodeType: string; label: string; - /** Node type (root, check, or tag) */ - nodeType: InvestigationNodeType; - /** Security level */ - level: Level; - /** Numeric score */ - score: number; - /** Description (for checks) */ - description?: string; - /** Name (for tags) */ - name?: string; + data: Record; + element: NodeSingular; } -/** - * Investigation graph node type. - */ -export type InvestigationNode = Node; - -/** - * Investigation graph edge type. - */ -export type InvestigationEdge = Edge; - -// ============================================================================ -// Force Layout Configuration -// ============================================================================ - -/** - * Configuration options for d3-force layout. - */ -export interface ForceLayoutConfig { - /** Strength of the charge force (repulsion). Default: -200 */ - chargeStrength: number; - /** Target distance between linked nodes. Default: 80 */ - linkDistance: number; - /** Strength of the centering force. Default: 0.05 */ - centerStrength: number; - /** Radius for collision detection. Default: 40 */ - collisionRadius: number; - /** Number of simulation iterations (for static layout). Default: 300 */ - iterations: number; +export interface CyEdgeSelectEvent { + view: CyvestViewMode; + edgeId: string; + sourceId: string; + targetId: string; + relationshipType?: string; + data: Record; + element: EdgeSingular; } -/** - * Default force layout configuration. - * Tuned for good visual separation and smooth animations. - */ -export const DEFAULT_FORCE_CONFIG: ForceLayoutConfig = { - chargeStrength: -200, - linkDistance: 80, - centerStrength: 0.05, - collisionRadius: 45, - iterations: 300, -}; - -// ============================================================================ -// Component Props -// ============================================================================ - -/** - * Props for the ObservablesGraph component. - */ -export interface ObservablesGraphProps { - /** The Cyvest investigation to visualize */ - investigation: import("@cyvest/cyvest-js").CyvestInvestigation; - /** Height of the graph container */ +export interface CyvestBaseViewProps { + investigation: CyvestInvestigation; height?: number | string; - /** Width of the graph container */ width?: number | string; - /** Force layout configuration */ - forceConfig?: Partial; - /** Callback when a node is clicked */ - onNodeClick?: (nodeId: string) => void; - /** Callback when a node is double-clicked */ - onNodeDoubleClick?: (nodeId: string) => void; - /** Custom class name for the container */ className?: string; - /** Whether to show the force controls panel */ - showControls?: boolean; + theme?: Partial; + onCyReady?: (cy: Core) => void; + onNodeSelect?: (event: CyNodeSelectEvent) => void; + onEdgeSelect?: (event: CyEdgeSelectEvent) => void; } -/** - * Props for the InvestigationGraph component. - */ -export interface InvestigationGraphProps { - /** The Cyvest investigation to visualize */ - investigation: import("@cyvest/cyvest-js").CyvestInvestigation; - /** Height of the graph container */ - height?: number | string; - /** Width of the graph container */ - width?: number | string; - /** Callback when a node is clicked */ - onNodeClick?: (nodeId: string, nodeType: InvestigationNodeType) => void; - /** Custom class name for the container */ - className?: string; +export interface CyvestObservablesViewProps extends CyvestBaseViewProps { + layout?: CyvestElkOptions; + showToolbar?: boolean; + maxLabelLength?: number; } -/** - * Props for the CyvestGraph component (combined view). - */ -export interface CyvestGraphProps { - /** The Cyvest investigation to visualize */ - investigation: import("@cyvest/cyvest-js").CyvestInvestigation; - /** Height of the graph container */ - height?: number | string; - /** Width of the graph container */ - width?: number | string; - /** Initial view to display */ - initialView?: "observables" | "investigation"; - /** Callback when a node is clicked */ - onNodeClick?: (nodeId: string) => void; - /** Custom class name for the container */ - className?: string; - /** Whether to show view toggle */ +export interface CyvestInvestigationViewProps extends CyvestBaseViewProps { + layout?: CyvestElkOptions; + showToolbar?: boolean; + maxLabelLength?: number; +} + +export interface CyvestGraphProps extends CyvestBaseViewProps { + initialView?: CyvestViewMode; showViewToggle?: boolean; + onViewChange?: (view: CyvestViewMode) => void; + showToolbar?: boolean; + observablesLayout?: CyvestElkOptions; + investigationLayout?: CyvestElkOptions; + maxObservableLabelLength?: number; + maxInvestigationLabelLength?: number; +} + +export interface ObservableCyNodeData extends Record { + id: string; + nodeType: "observable"; + labelShort: string; + labelFull: string; + observableType: string; + level: Level; + score: number; + isRoot: boolean; + whitelisted: boolean; + internal: boolean; + shape: "ellipse" | "round-rectangle" | "rectangle"; + width: number; + height: number; + borderWidth: number; + borderColor: string; + fillColor: string; + icon: string; + opacity: number; +} + +export interface ObservableCyEdgeData extends Record { + id: string; + relationshipType: string; + direction: RelationshipDirection; + color: string; + width: number; + sourceArrowShape: "none" | "triangle"; + targetArrowShape: "none" | "triangle"; +} + +export type InvestigationCyNodeType = "root" | "tag" | "check"; + +export interface InvestigationCyNodeData extends Record { + id: string; + nodeType: InvestigationCyNodeType; + labelShort: string; + labelFull: string; + level: Level; + score: number; + borderColor: string; + fillColor: string; + icon: string; + width: number; + height: number; + shape: "round-rectangle"; + borderWidth: number; +} + +export interface InvestigationCyEdgeData extends Record { + id: string; + relationshipType: string; + color: string; + width: number; + sourceArrowShape: "none" | "triangle"; + targetArrowShape: "none" | "triangle"; } diff --git a/js/packages/cyvest-vis/src/utils/colors.ts b/js/packages/cyvest-vis/src/utils/colors.ts new file mode 100644 index 0000000..a8a8ba7 --- /dev/null +++ b/js/packages/cyvest-vis/src/utils/colors.ts @@ -0,0 +1,44 @@ +import { getColorForLevel, type Level } from "@cyvest/cyvest-js"; +import { + DEFAULT_CYVEST_THEME, + type CyvestThemeTokens, +} from "../types"; + +export function getLevelColor(level: Level): string { + return getColorForLevel(level); +} + +function clampChannel(channel: number): number { + return Math.max(0, Math.min(255, Math.round(channel))); +} + +export function lightenHexColor(hex: string, ratio: number): string { + const normalized = hex.startsWith("#") ? hex.slice(1) : hex; + if (!/^[0-9a-fA-F]{6}$/.test(normalized)) { + return hex; + } + + const red = Number.parseInt(normalized.slice(0, 2), 16); + const green = Number.parseInt(normalized.slice(2, 4), 16); + const blue = Number.parseInt(normalized.slice(4, 6), 16); + + const mix = (channel: number) => + clampChannel(channel + (255 - channel) * ratio) + .toString(16) + .padStart(2, "0"); + + return `#${mix(red)}${mix(green)}${mix(blue)}`; +} + +export function getLevelBackgroundColor(level: Level): string { + return lightenHexColor(getLevelColor(level), 0.9); +} + +export function resolveTheme( + theme?: Partial +): CyvestThemeTokens { + return { + ...DEFAULT_CYVEST_THEME, + ...theme, + }; +} diff --git a/js/packages/cyvest-vis/src/utils/labels.ts b/js/packages/cyvest-vis/src/utils/labels.ts new file mode 100644 index 0000000..ee18427 --- /dev/null +++ b/js/packages/cyvest-vis/src/utils/labels.ts @@ -0,0 +1,17 @@ +export function truncateLabel( + value: string, + maxLength: number = 28, + truncateMiddle: boolean = true +): string { + if (maxLength < 4 || value.length <= maxLength) { + return value; + } + + if (!truncateMiddle) { + return `${value.slice(0, maxLength - 1)}…`; + } + + const leftLength = Math.ceil((maxLength - 1) / 2); + const rightLength = Math.floor((maxLength - 1) / 2); + return `${value.slice(0, leftLength)}…${value.slice(-rightLength)}`; +} diff --git a/js/packages/cyvest-vis/src/utils/observables.ts b/js/packages/cyvest-vis/src/utils/observables.ts deleted file mode 100644 index c9af3ae..0000000 --- a/js/packages/cyvest-vis/src/utils/observables.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Utility functions for observable visualization. - */ - -import { getColorForLevel, type Level } from "@cyvest/cyvest-js"; -import type { ObservableShape } from "../types"; - -/** - * Get the shape for an observable type. - * All non-root nodes are circles for a cleaner look. - */ -export function getObservableShape( - _observableType: string, - isRoot: boolean -): ObservableShape { - // Root nodes get a rounded rectangle (pill shape) - if (isRoot) { - return "rectangle"; - } - // All other nodes are circles - return "circle"; -} - -/** - * Truncate a label, optionally in the middle for long strings. - */ -export function truncateLabel( - value: string, - maxLength: number = 20, - truncateMiddle: boolean = true -): string { - if (value.length <= maxLength) { - return value; - } - - if (truncateMiddle) { - const halfLen = Math.floor((maxLength - 3) / 2); - return `${value.slice(0, halfLen)}…${value.slice(-halfLen)}`; - } - - return `${value.slice(0, maxLength - 1)}…`; -} - -/** - * Get color for security level. - */ -export function getLevelColor(level: Level): string { - return getColorForLevel(level); -} - -/** - * Lighten a hex color by a percentage. - */ -function lightenHexColor(hex: string, amount: number): string { - const normalized = hex.startsWith("#") ? hex.slice(1) : hex; - if (normalized.length !== 6) { - return hex; - } - - const r = parseInt(normalized.slice(0, 2), 16); - const g = parseInt(normalized.slice(2, 4), 16); - const b = parseInt(normalized.slice(4, 6), 16); - - const mix = (channel: number) => - Math.max(0, Math.min(255, Math.round(channel + (255 - channel) * amount))); - - const toHex = (channel: number) => channel.toString(16).padStart(2, "0"); - - return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`; -} - -/** - * Get background color for security level (lighter version). - */ -export function getLevelBackgroundColor(level: Level): string { - return lightenHexColor(getLevelColor(level), 0.88); -} diff --git a/js/packages/cyvest-vis/tests/observables.test.ts b/js/packages/cyvest-vis/tests/observables.test.ts index 8e6ac2f..30294e0 100644 --- a/js/packages/cyvest-vis/tests/observables.test.ts +++ b/js/packages/cyvest-vis/tests/observables.test.ts @@ -1,35 +1,65 @@ import { describe, expect, it } from "vitest"; -import { LEVEL_COLORS } from "@cyvest/cyvest-js"; +import { parseCyvest } from "@cyvest/cyvest-js"; + +import { buildInvestigationElements } from "../src/adapters/investigationElements"; +import { buildObservablesElements } from "../src/adapters/observablesElements"; import { - getLevelBackgroundColor, - getLevelColor, - getObservableShape, - truncateLabel, -} from "../src/utils/observables"; - -describe("observables utils", () => { - it("returns shapes based on root flag (all non-root nodes are circles)", () => { - // All non-root nodes are now circles for a cleaner design - expect(getObservableShape("domain", false)).toBe("circle"); - expect(getObservableShape("ipv6", false)).toBe("circle"); - expect(getObservableShape("anything-else", false)).toBe("circle"); - // Root nodes get a rectangle (pill shape) - expect(getObservableShape("anything-else", true)).toBe("rectangle"); - expect(getObservableShape("domain", true)).toBe("rectangle"); - }); + getInvestigationIconSvg, + getObservableIconSvg, +} from "../src/icons/svg"; +import { truncateLabel } from "../src/utils/labels"; + +import cyvestVisualData from "../../cyvest-app/src/investigations/cyvest_visual.json"; +const investigation = parseCyvest(cyvestVisualData); + +describe("label utilities", () => { it("truncates long labels in the middle by default", () => { expect(truncateLabel("short", 10)).toBe("short"); - expect(truncateLabel("averyverylongvalue", 10)).toBe("ave…lue"); + expect(truncateLabel("averyverylongvalue", 10)).toBe("avery…alue"); expect(truncateLabel("averyverylongvalue", 10, false)).toBe("averyvery…"); }); +}); + +describe("icon helpers", () => { + it("creates data URI icons for observables and investigation nodes", () => { + const observableIcon = getObservableIconSvg("domain"); + const investigationIcon = getInvestigationIconSvg("tag"); + + expect(observableIcon.startsWith("data:image/svg+xml;utf8,")).toBe(true); + expect(investigationIcon.startsWith("data:image/svg+xml;utf8,")).toBe(true); + }); +}); + +describe("cytoscape adapters", () => { + it("builds observable nodes and edges", () => { + const elements = buildObservablesElements(investigation); + + const nodes = elements.filter((item) => item.group === "nodes"); + const edges = elements.filter((item) => item.group === "edges"); + + expect(nodes.length).toBeGreaterThan(0); + expect(edges.length).toBeGreaterThan(0); + + const firstNodeData = nodes[0]?.data as Record; + expect(typeof firstNodeData.labelShort).toBe("string"); + expect(typeof firstNodeData.icon).toBe("string"); + }); + + it("builds investigation hierarchy nodes and edges", () => { + const elements = buildInvestigationElements(investigation); + + const nodes = elements.filter((item) => item.group === "nodes"); + const edges = elements.filter((item) => item.group === "edges"); + + expect(nodes.length).toBeGreaterThan(0); + expect(edges.length).toBeGreaterThan(0); + + const rootNode = nodes.find( + (node) => (node.data as Record).nodeType === "root" + ); - it("maps levels to colors", () => { - expect(getLevelColor("SUSPICIOUS")).toBe(LEVEL_COLORS.SUSPICIOUS); - // Background color is the level color lightened by 88% - const bgColor = getLevelBackgroundColor("SUSPICIOUS"); - // Just verify it's a valid hex color that's lighter than the original - expect(bgColor).toMatch(/^#[0-9a-f]{6}$/i); + expect(rootNode).toBeDefined(); }); }); diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 14ffb00..f3f654e 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -73,15 +73,21 @@ importers: '@cyvest/cyvest-js': specifier: workspace:* version: link:../cyvest-js - '@dagrejs/dagre': - specifier: ^1.1.8 - version: 1.1.8 - '@xyflow/react': - specifier: ^12.10.0 - version: 12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - d3-force: - specifier: ^3.0.0 - version: 3.0.0 + cytoscape: + specifier: ^3.31.2 + version: 3.33.1 + cytoscape-dagre: + specifier: ^2.5.0 + version: 2.5.0(cytoscape@3.33.1) + cytoscape-elk: + specifier: ^2.3.0 + version: 2.3.0(cytoscape@3.33.1) + dagre: + specifier: ^0.8.5 + version: 0.8.5 + elkjs: + specifier: ^0.11.0 + version: 0.11.0 react: specifier: ^19.0.0 version: 19.2.3 @@ -89,9 +95,6 @@ importers: specifier: ^19.0.0 version: 19.2.3(react@19.2.3) devDependencies: - '@types/d3-force': - specifier: ^3.0.10 - version: 3.0.10 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -114,13 +117,6 @@ packages: resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} - '@dagrejs/dagre@1.1.8': - resolution: {integrity: sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==} - - '@dagrejs/graphlib@2.2.4': - resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} - engines: {node: '>17.0.0'} - '@esbuild/aix-ppc64@0.27.0': resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} engines: {node: '>=18'} @@ -487,27 +483,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-drag@3.0.7': - resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} - - '@types/d3-force@3.0.10': - resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-selection@3.0.11': - resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} - - '@types/d3-transition@3.0.9': - resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} - - '@types/d3-zoom@3.0.8': - resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -566,15 +541,6 @@ packages: '@vitest/utils@4.0.16': resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} - '@xyflow/react@12.10.0': - resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==} - peerDependencies: - react: '>=17' - react-dom: '>=17' - - '@xyflow/system@0.0.74': - resolution: {integrity: sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==} - acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -619,9 +585,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - classcat@5.0.5: - resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -636,51 +599,22 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-dispatch@3.0.1: - resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} - engines: {node: '>=12'} - - d3-drag@3.0.0: - resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} - engines: {node: '>=12'} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-force@3.0.0: - resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-quadtree@3.0.1: - resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} - engines: {node: '>=12'} - - d3-selection@3.0.0: - resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} + cytoscape-dagre@2.5.0: + resolution: {integrity: sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==} + peerDependencies: + cytoscape: ^3.2.22 - d3-transition@3.0.1: - resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} - engines: {node: '>=12'} + cytoscape-elk@2.3.0: + resolution: {integrity: sha512-1h2ZmPOy5HD2+mrfF3P2ICxfnDyPCWg/xLVs7fIjTOzdQu51ydrMtm6Sb7KnhFwLBzhGIVYI2Gbns0njggBarQ==} peerDependencies: - d3-selection: 2 - 3 + cytoscape: ^3.2.0 - d3-zoom@3.0.0: - resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} - engines: {node: '>=12'} + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -691,6 +625,12 @@ packages: supports-color: optional: true + elkjs@0.11.0: + resolution: {integrity: sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw==} + + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -729,6 +669,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -951,11 +894,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1035,21 +973,6 @@ packages: engines: {node: '>=8'} hasBin: true - zustand@4.5.7: - resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} - engines: {node: '>=12.7.0'} - peerDependencies: - '@types/react': '>=16.8' - immer: '>=9.0.6' - react: '>=16.8' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - snapshots: '@apidevtools/json-schema-ref-parser@11.9.3': @@ -1058,12 +981,6 @@ snapshots: '@types/json-schema': 7.0.15 js-yaml: 4.1.1 - '@dagrejs/dagre@1.1.8': - dependencies: - '@dagrejs/graphlib': 2.2.4 - - '@dagrejs/graphlib@2.2.4': {} - '@esbuild/aix-ppc64@0.27.0': optional: true @@ -1285,29 +1202,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/d3-color@3.1.3': {} - - '@types/d3-drag@3.0.7': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-force@3.0.10': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-selection@3.0.11': {} - - '@types/d3-transition@3.0.9': - dependencies: - '@types/d3-selection': 3.0.11 - - '@types/d3-zoom@3.0.8': - dependencies: - '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 - '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -1376,29 +1270,6 @@ snapshots: '@vitest/pretty-format': 4.0.16 tinyrainbow: 3.0.3 - '@xyflow/react@12.10.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@xyflow/system': 0.0.74 - classcat: 5.0.5 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - zustand: 4.5.7(@types/react@19.2.7)(react@19.2.3) - transitivePeerDependencies: - - '@types/react' - - immer - - '@xyflow/system@0.0.74': - dependencies: - '@types/d3-drag': 3.0.7 - '@types/d3-interpolate': 3.0.4 - '@types/d3-selection': 3.0.11 - '@types/d3-transition': 3.0.9 - '@types/d3-zoom': 3.0.8 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-zoom: 3.0.0 - acorn@8.15.0: {} ajv-formats@3.0.1(ajv@8.17.1): @@ -1431,8 +1302,6 @@ snapshots: dependencies: readdirp: 4.1.2 - classcat@5.0.5: {} - commander@4.1.1: {} confbox@0.1.8: {} @@ -1441,54 +1310,31 @@ snapshots: csstype@3.2.3: {} - d3-color@3.1.0: {} - - d3-dispatch@3.0.1: {} - - d3-drag@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-selection: 3.0.0 - - d3-ease@3.0.1: {} - - d3-force@3.0.0: + cytoscape-dagre@2.5.0(cytoscape@3.33.1): dependencies: - d3-dispatch: 3.0.1 - d3-quadtree: 3.0.1 - d3-timer: 3.0.1 + cytoscape: 3.33.1 + dagre: 0.8.5 - d3-interpolate@3.0.1: + cytoscape-elk@2.3.0(cytoscape@3.33.1): dependencies: - d3-color: 3.1.0 - - d3-quadtree@3.0.1: {} + cytoscape: 3.33.1 + elkjs: 0.9.3 - d3-selection@3.0.0: {} + cytoscape@3.33.1: {} - d3-timer@3.0.1: {} - - d3-transition@3.0.1(d3-selection@3.0.0): - dependencies: - d3-color: 3.1.0 - d3-dispatch: 3.0.1 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-timer: 3.0.1 - - d3-zoom@3.0.0: + dagre@0.8.5: dependencies: - d3-dispatch: 3.0.1 - d3-drag: 3.0.0 - d3-interpolate: 3.0.1 - d3-selection: 3.0.0 - d3-transition: 3.0.1(d3-selection@3.0.0) + graphlib: 2.1.8 + lodash: 4.17.21 debug@4.4.3: dependencies: ms: 2.1.3 + elkjs@0.11.0: {} + + elkjs@0.9.3: {} + es-module-lexer@1.7.0: {} esbuild@0.27.0: @@ -1543,6 +1389,10 @@ snapshots: fsevents@2.3.3: optional: true + graphlib@2.1.8: + dependencies: + lodash: 4.17.21 + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1756,10 +1606,6 @@ snapshots: undici-types@7.16.0: optional: true - use-sync-external-store@1.6.0(react@19.2.3): - dependencies: - react: 19.2.3 - vite@7.3.0(@types/node@24.10.1): dependencies: esbuild: 0.27.0 @@ -1813,10 +1659,3 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - - zustand@4.5.7(@types/react@19.2.7)(react@19.2.3): - dependencies: - use-sync-external-store: 1.6.0(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.7 - react: 19.2.3