diff --git a/packages/arkham-shard-shell/package-lock.json b/packages/arkham-shard-shell/package-lock.json index a3310f1..f50b60d 100644 --- a/packages/arkham-shard-shell/package-lock.json +++ b/packages/arkham-shard-shell/package-lock.json @@ -12,12 +12,19 @@ "@fontsource/jetbrains-mono": "^5.0.0", "@types/d3": "^7.4.3", "@types/d3-sankey": "^0.12.5", + "cytoscape": "^3.33.1", + "cytoscape-context-menus": "^4.2.1", + "cytoscape-dagre": "^2.5.0", + "cytoscape-expand-collapse": "^4.1.1", + "cytoscape-fcose": "^2.2.0", + "cytoscape-svg": "^0.4.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dompurify": "^3.3.1", "lucide-react": "^0.294.0", "maplibre-gl": "^5.16.0", "react": "^18.2.0", + "react-cytoscapejs": "^2.0.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.0", "react-force-graph-2d": "^1.29.0", @@ -26,6 +33,7 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@types/cytoscape": "^3.21.9", "@types/dompurify": "^3.0.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -1313,6 +1321,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/cytoscape": { + "version": "3.21.9", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz", + "integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2017,12 +2032,81 @@ "dev": true, "license": "MIT" }, + "node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-context-menus": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/cytoscape-context-menus/-/cytoscape-context-menus-4.2.1.tgz", + "integrity": "sha512-h0dXq/DGIQUegC+mncawzFT+zV52I5dxME3fDLqO6cd5pYfx9U+u2YZjn29z8YfRjvayBtsxpD6JUvgiH1ZdaQ==", + "license": "MIT", + "peerDependencies": { + "cytoscape": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/cytoscape-dagre": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cytoscape-dagre/-/cytoscape-dagre-2.5.0.tgz", + "integrity": "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==", + "license": "MIT", + "dependencies": { + "dagre": "^0.8.5" + }, + "peerDependencies": { + "cytoscape": "^3.2.22" + } + }, + "node_modules/cytoscape-expand-collapse": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/cytoscape-expand-collapse/-/cytoscape-expand-collapse-4.1.1.tgz", + "integrity": "sha512-MI4/GsA6Rf6RRzNR1aCitBLSnxiIKLxvZyCzF+oti/zn/ui1jmf769VcEFAEbjjsAtwteGsTmczI+niCMWJNvA==", + "license": "MIT", + "peerDependencies": { + "cytoscape": "^3.3.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-svg": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cytoscape-svg/-/cytoscape-svg-0.4.0.tgz", + "integrity": "sha512-omqIzfPd1Vy9mk6lHTiR2wTbjxELxb9GXSQ2pE6W+GwAe/6/yvOUQ2h5ApFf2QhCBnpMwLkCTq5DZXxBCgUpDw==", + "license": "GNU GPLv3", + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", @@ -2492,6 +2576,16 @@ "node": ">=12" } }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2771,6 +2865,15 @@ "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", "license": "MIT" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -3008,6 +3111,18 @@ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", "license": "ISC" }, + "node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.17.22", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", @@ -3891,6 +4006,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-cytoscapejs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-cytoscapejs/-/react-cytoscapejs-2.0.0.tgz", + "integrity": "sha512-t3SSl1DQy7+JQjN+8QHi1anEJlM3i3aAeydHTsJwmjo/isyKK7Rs7oCvU6kZsB9NwZidzZQR21Vm2PcBLG/Tjg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "cytoscape": "^3.2.19", + "react": ">=15.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/packages/arkham-shard-shell/package.json b/packages/arkham-shard-shell/package.json index 0069cd0..cf06d09 100644 --- a/packages/arkham-shard-shell/package.json +++ b/packages/arkham-shard-shell/package.json @@ -14,12 +14,19 @@ "@fontsource/jetbrains-mono": "^5.0.0", "@types/d3": "^7.4.3", "@types/d3-sankey": "^0.12.5", + "cytoscape": "^3.33.1", + "cytoscape-context-menus": "^4.2.1", + "cytoscape-dagre": "^2.5.0", + "cytoscape-expand-collapse": "^4.1.1", + "cytoscape-fcose": "^2.2.0", + "cytoscape-svg": "^0.4.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dompurify": "^3.3.1", "lucide-react": "^0.294.0", "maplibre-gl": "^5.16.0", "react": "^18.2.0", + "react-cytoscapejs": "^2.0.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.0", "react-force-graph-2d": "^1.29.0", @@ -28,6 +35,7 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@types/cytoscape": "^3.21.9", "@types/dompurify": "^3.0.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", diff --git a/packages/arkham-shard-shell/src/pages/graph/GraphPage.css b/packages/arkham-shard-shell/src/pages/graph/GraphPage.css index 7e440c5..e3dbf99 100644 --- a/packages/arkham-shard-shell/src/pages/graph/GraphPage.css +++ b/packages/arkham-shard-shell/src/pages/graph/GraphPage.css @@ -4380,3 +4380,19 @@ padding: 1rem; overflow: auto; } + +/* Cytoscape View Container */ +.cytoscape-view-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-primary); + position: relative; +} + +.cytoscape-graph-main { + flex: 1; + width: 100%; + min-height: 0; +} diff --git a/packages/arkham-shard-shell/src/pages/graph/GraphPage.tsx b/packages/arkham-shard-shell/src/pages/graph/GraphPage.tsx index ac88aa2..1390b63 100644 --- a/packages/arkham-shard-shell/src/pages/graph/GraphPage.tsx +++ b/packages/arkham-shard-shell/src/pages/graph/GraphPage.tsx @@ -15,6 +15,9 @@ import { useFetch } from '../../hooks/useFetch'; import { useGraphSettings } from './hooks/useGraphSettings'; import { useUrlParams } from './hooks/useUrlParams'; import { GraphControls, DataSourcesPanel, LayoutModeControls, AssociationMatrix, MatrixControls, SankeyDiagram, SankeyControls, GeoGraphView, GeoGraphControls, ArgumentationView, ArgumentationControls, CausalGraphView, CausalGraphControls } from './components'; +import { CytoscapeGraph, CytoscapeControls } from './components/cytoscape'; +import type { CytoscapeGraphRef } from './components/cytoscape'; +import './components/cytoscape/CytoscapeGraph.css'; import type { FlowData } from './components'; import { EgoMetricsPanel } from './components/EgoMetricsPanel'; import { fetchScores, type EntityScore } from './api'; @@ -106,11 +109,12 @@ const ENTITY_TYPE_COLORS: Record = { unknown: '#718096', }; -type TabId = 'graph' | 'matrix' | 'sankey' | 'geo' | 'argumentation' | 'causal' | 'controls' | 'sources'; +type TabId = 'graph' | 'cytoscape' | 'matrix' | 'sankey' | 'geo' | 'argumentation' | 'causal' | 'controls' | 'sources'; export function GraphPage() { const { toast } = useToast(); const graphRef = useRef(null); + const cytoscapeRef = useRef(null); const graphSettings = useGraphSettings(); // State @@ -173,6 +177,10 @@ export function GraphPage() { const [causalShowLabels, setCausalShowLabels] = useState(true); const [causalShowStrength, setCausalShowStrength] = useState(true); + // Cytoscape view state + const [cyLayout, setCyLayout] = useState('fcose'); + const [cyShowEdgeLabels, setCyShowEdgeLabels] = useState(false); + // Container ref for responsive sizing const containerRef = useRef(null); @@ -1099,7 +1107,15 @@ export function GraphPage() { onClick={() => setActiveTab('graph')} > - Graph View + Force Graph + + + + ) : forceGraphData.nodes.length > 0 ? ( +
+ { + setSelectedNode(node as GraphNode); + }} + onNodeRightClick={(_nodeId, _position) => { + // Could show context menu here + const node = forceGraphData.nodes.find(n => n.id === _nodeId); + if (node) setSelectedNode(node); + }} + onEdgeClick={(_edgeId, edge) => { + toast.info(`Edge: ${edge.relationship_type || edge.type || 'related'}`); + }} + onBackgroundClick={() => { + setSelectedNode(null); + setHighlightedPath(new Set()); + }} + selectedNodeId={selectedNode?.id} + highlightedPath={highlightedPath} + showEdgeLabels={cyShowEdgeLabels} + className="cytoscape-graph-main" + /> +
+ ) : ( +
+ +

No Graph Data

+

Build a graph to visualize with Cytoscape

+ +
+ ) + )} + {/* Graph View */} {activeTab === 'graph' || activeTab === 'controls' || activeTab === 'sources' ? ( loading ? ( diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeControls.tsx b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeControls.tsx new file mode 100644 index 0000000..522edca --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeControls.tsx @@ -0,0 +1,155 @@ +/** + * CytoscapeControls - Layout and view controls panel for Cytoscape graph + * + * Provides controls for: + * - Layout algorithm selection (fcose, hierarchical, concentric, etc.) + * - Edge label visibility toggle + * - Cluster expand/collapse all + * - Fit to view button + */ + +import React from 'react'; +import { Icon } from '../../../../components/common/Icon'; + +export interface CytoscapeControlsProps { + layout: string; + onLayoutChange: (layout: string) => void; + showEdgeLabels: boolean; + onShowEdgeLabelsChange: (show: boolean) => void; + onZoomToFit: () => void; + onExpandAll: () => void; + onCollapseAll: () => void; +} + +// Layout options with descriptions +const LAYOUT_OPTIONS = [ + { value: 'fcose', label: 'Force-Directed (fCoSE)', description: 'General exploration' }, + { value: 'hierarchical', label: 'Hierarchical (Dagre)', description: 'Org charts, chains' }, + { value: 'concentric', label: 'Radial (Concentric)', description: 'Ego networks' }, + { value: 'circle', label: 'Circular', description: 'All nodes on ring' }, + { value: 'grid', label: 'Grid', description: 'Side-by-side' }, + { value: 'breadthfirst', label: 'Tree (Breadth-First)', description: 'Hierarchies' }, + { value: 'cose', label: 'Clustered (CoSE)', description: 'Community groups' }, +]; + +export const CytoscapeControls: React.FC = ({ + layout, + onLayoutChange, + showEdgeLabels, + onShowEdgeLabelsChange, + onZoomToFit, + onExpandAll, + onCollapseAll, +}) => { + return ( +
+ {/* Layout Selection */} +
+

+ + Layout Algorithm +

+ +

+ {LAYOUT_OPTIONS.find((o) => o.value === layout)?.description} +

+
+ + {/* Display Options */} +
+

+ + Display Options +

+ +
+ + {/* Cluster Controls */} +
+

+ + Clusters +

+
+ + +
+
+ + {/* View Controls */} +
+

+ + View +

+ +
+ + {/* Keyboard Shortcuts Help */} +
+

+ + Shortcuts +

+
+
+ Esc + Clear selection +
+
+ Ctrl+F + Fit to view +
+
+ + / - + Zoom in/out +
+
+ Del + Hide selected +
+
+
+
+ ); +}; + +export default CytoscapeControls; diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeGraph.css b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeGraph.css new file mode 100644 index 0000000..39496dd --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeGraph.css @@ -0,0 +1,699 @@ +/** + * CytoscapeGraph.css - Styles for Cytoscape graph visualization components + * + * Includes styles for: + * - Main container + * - Controls panel + * - Performance mode panel + * - Expand neighbors panel + * - Tooltip + * - Animations + */ + +/* ============================================ + MAIN CONTAINER + ============================================ */ + +.cytoscape-container { + width: 100%; + height: 100%; + min-height: 400px; + background-color: var(--bg-primary, #ffffff); + border-radius: 8px; + overflow: hidden; + position: relative; +} + +/* ============================================ + CYTOSCAPE CONTROLS + ============================================ */ + +.cytoscape-controls { + display: flex; + flex-direction: column; + gap: 16px; +} + +.cytoscape-controls .control-section { + padding: 12px; + background: var(--bg-secondary, #f7f7f7); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 8px; +} + +.cytoscape-controls .control-section h4 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 12px 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary, #1a202c); +} + +.cytoscape-controls .select { + width: 100%; + padding: 8px 12px; + font-size: 0.9rem; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 6px; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #1a202c); + cursor: pointer; + transition: border-color 0.2s; +} + +.cytoscape-controls .select:hover { + border-color: var(--primary-color, #3b82f6); +} + +.cytoscape-controls .select:focus { + outline: none; + border-color: var(--primary-color, #3b82f6); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.cytoscape-controls .control-hint { + margin: 8px 0 0 0; + font-size: 0.8rem; + color: var(--text-muted, #718096); +} + +.cytoscape-controls .checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + cursor: pointer; + color: var(--text-primary, #1a202c); +} + +.cytoscape-controls .checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--primary-color, #3b82f6); +} + +.cytoscape-controls .button-group { + display: flex; + gap: 8px; +} + +.cytoscape-controls .button-group .btn { + flex: 1; +} + +/* Shortcuts section */ +.shortcuts-section { + background: var(--bg-tertiary, #edf2f7) !important; +} + +.shortcuts-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.shortcut-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--text-secondary, #4a5568); +} + +.shortcut-item kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + padding: 2px 6px; + font-family: monospace; + font-size: 0.75rem; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 4px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* ============================================ + PERFORMANCE MODE PANEL + ============================================ */ + +.performance-mode-panel { + background: var(--bg-secondary, #f7f7f7); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 8px; + padding: 12px; + margin-bottom: 12px; +} + +.performance-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.performance-toggle { + display: flex; + align-items: center; + gap: 10px; +} + +.toggle-label { + font-weight: 500; + font-size: 0.9rem; + color: var(--text-primary, #1a202c); +} + +.auto-suggest { + color: var(--warning-color, #f59e0b); + font-size: 0.8rem; + font-weight: 400; + margin-left: 4px; +} + +.performance-indicator { + display: flex; + align-items: center; + gap: 4px; + color: var(--success-color, #10b981); + font-size: 0.85rem; + font-weight: 500; +} + +.settings-expand-btn { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 6px 8px; + margin-bottom: 8px; + font-size: 0.8rem; + color: var(--text-secondary, #4a5568); + background: transparent; + border: 1px dashed var(--border-color, #e2e8f0); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.settings-expand-btn:hover { + background: var(--bg-tertiary, #edf2f7); + border-color: var(--text-muted, #718096); +} + +.performance-options { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 0; + border-top: 1px solid var(--border-color, #e2e8f0); + border-bottom: 1px solid var(--border-color, #e2e8f0); + margin: 8px 0; +} + +.checkbox-option { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + cursor: pointer; + color: var(--text-primary, #1a202c); +} + +.checkbox-option input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: var(--primary-color, #3b82f6); +} + +.performance-stats { + display: flex; + gap: 16px; + font-size: 0.8rem; + color: var(--text-muted, #718096); + margin-top: 8px; +} + +.performance-stats .stat { + display: flex; + align-items: center; + gap: 4px; +} + +.stat-warning { + color: var(--warning-color, #f59e0b); + margin-left: 2px; +} + +.performance-tip { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px; + margin-top: 8px; + font-size: 0.8rem; + color: var(--text-secondary, #4a5568); + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 6px; +} + +/* Switch toggle styles */ +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; + flex-shrink: 0; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + inset: 0; + background-color: var(--border-color, #cbd5e0); + transition: 0.3s; + border-radius: 20px; +} + +.slider::before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + transition: 0.3s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +input:checked + .slider { + background-color: var(--primary-color, #3b82f6); +} + +input:focus + .slider { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); +} + +input:checked + .slider::before { + transform: translateX(20px); +} + +/* ============================================ + EXPAND NEIGHBORS PANEL + ============================================ */ + +.expand-neighbors-panel { + background: var(--bg-secondary, #f7f7f7); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 8px; + padding: 12px; +} + +.expand-neighbors-empty { + text-align: center; +} + +.expand-neighbors-empty .empty-message { + margin: 12px 0 0 0; + font-size: 0.85rem; + color: var(--text-muted, #718096); +} + +.expand-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color, #e2e8f0); +} + +.expand-header h4 { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #1a202c); +} + +.node-info { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.node-type-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary-color, #3b82f6); + color: white; +} + +.node-details { + display: flex; + flex-direction: column; + gap: 2px; +} + +.node-label { + font-weight: 600; + font-size: 1rem; + color: var(--text-primary, #1a202c); +} + +.node-type-name { + font-size: 0.8rem; + color: var(--text-muted, #718096); + text-transform: capitalize; +} + +.node-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.badge-seed { + background: #fef3c7; + color: #92400e; +} + +.badge-depth { + background: var(--bg-tertiary, #edf2f7); + color: var(--text-secondary, #4a5568); +} + +.badge-connections { + background: rgba(59, 130, 246, 0.1); + color: var(--primary-color, #3b82f6); +} + +.expand-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.expand-actions .btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.max-depth-notice, +.fully-expanded-notice { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: var(--bg-tertiary, #edf2f7); + border-radius: 6px; + font-size: 0.85rem; + color: var(--text-muted, #718096); +} + +.fully-expanded-notice { + color: var(--success-color, #10b981); + background: rgba(16, 185, 129, 0.1); +} + +.expansion-stats { + padding: 10px; + background: var(--bg-tertiary, #edf2f7); + border-radius: 6px; + margin-bottom: 12px; +} + +.stat-row { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + padding: 2px 0; +} + +.stat-label { + color: var(--text-muted, #718096); +} + +.stat-value { + font-weight: 600; + color: var(--text-primary, #1a202c); +} + +.depth-legend { + border-top: 1px solid var(--border-color, #e2e8f0); + padding-top: 12px; +} + +.depth-legend h5 { + font-size: 0.8rem; + margin: 0 0 8px 0; + color: var(--text-muted, #718096); + font-weight: 500; +} + +.legend-items { + display: flex; + flex-direction: column; + gap: 6px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--text-secondary, #4a5568); +} + +.legend-color { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.legend-color.depth-0 { + background: #f59e0b; +} + +.legend-color.depth-1 { + background: #3b82f6; + opacity: 1; +} + +.legend-color.depth-2 { + background: #3b82f6; + opacity: 0.75; +} + +.legend-color.depth-3 { + background: #3b82f6; + opacity: 0.5; +} + +/* ============================================ + CYTOSCAPE TOOLTIP + ============================================ */ + +.cytoscape-tooltip { + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 0; + min-width: 180px; + max-width: 280px; + pointer-events: none; + overflow: hidden; +} + +.tooltip-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-secondary, #f7f7f7); + border-bottom: 1px solid var(--border-color, #e2e8f0); +} + +.tooltip-type-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + color: white; + flex-shrink: 0; +} + +.tooltip-label { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary, #1a202c); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tooltip-content { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.tooltip-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; +} + +.tooltip-key { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted, #718096); +} + +.tooltip-value { + font-weight: 500; + color: var(--text-primary, #1a202c); +} + +.tooltip-hint { + padding: 6px 12px; + font-size: 0.75rem; + color: var(--text-muted, #718096); + background: var(--bg-tertiary, #edf2f7); + text-align: center; +} + +/* ============================================ + ANIMATIONS + ============================================ */ + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.spin { + animation: spin 1s linear infinite; +} + +/* Fade in animation for new elements */ +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.cytoscape-tooltip { + animation: fadeIn 0.15s ease-out; +} + +/* Pulse animation for expandable nodes indicator */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.pulse { + animation: pulse 2s ease-in-out infinite; +} + +/* ============================================ + DARK MODE SUPPORT + ============================================ */ + +@media (prefers-color-scheme: dark) { + .cytoscape-container { + background-color: var(--bg-primary, #1a202c); + } + + .shortcut-item kbd { + background: var(--bg-tertiary, #2d3748); + border-color: var(--border-color, #4a5568); + color: var(--text-primary, #f7fafc); + } +} + +/* ============================================ + RESPONSIVE STYLES + ============================================ */ + +@media (max-width: 768px) { + .cytoscape-controls .button-group { + flex-direction: column; + } + + .performance-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .performance-stats { + flex-direction: column; + gap: 4px; + } + + .node-badges { + gap: 4px; + } + + .badge { + font-size: 0.7rem; + padding: 2px 6px; + } +} diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeGraph.tsx b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeGraph.tsx new file mode 100644 index 0000000..2186861 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeGraph.tsx @@ -0,0 +1,575 @@ +/** + * CytoscapeGraph - Main Cytoscape.js React Component + * + * Professional OSINT-grade graph visualization with: + * - Icon-based entity rendering (Maltego-style) + * - Multiple layout algorithms + * - Context menus for node operations + * - Selection and path highlighting + * - Cluster expand/collapse + * - Performance optimizations for large graphs + * + * This is a side-by-side alternative to react-force-graph, + * not a replacement. Users can compare both engines. + */ + +import { useRef, useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react'; +import cytoscape, { Core, NodeSingular, EdgeSingular, EventObject } from 'cytoscape'; +import fcose from 'cytoscape-fcose'; +import dagre from 'cytoscape-dagre'; + +// Import local modules +import { createStylesheet, createPerformanceStylesheet } from './CytoscapeStylesheet'; +import { getLayoutConfig } from './CytoscapeLayouts'; + +// ============================================ +// TYPE DEFINITIONS +// ============================================ + +export interface GraphNode { + id: string; + label: string; + type?: string; + entity_type?: string; + degree?: number; + document_count?: number; + clusterId?: string; + properties?: Record; +} + +export interface GraphEdge { + id?: string; + source: string | { id: string }; + target: string | { id: string }; + relationship_type?: string; + type?: string; + weight?: number; + properties?: Record; +} + +export interface CytoscapeGraphProps { + /** Array of graph nodes */ + nodes: GraphNode[]; + /** Array of graph edges */ + edges: GraphEdge[]; + /** Layout algorithm to use */ + layout: string; + /** Callback when a node is clicked */ + onNodeClick?: (nodeId: string, node: GraphNode) => void; + /** Callback when a node is right-clicked */ + onNodeRightClick?: (nodeId: string, position: { x: number; y: number }) => void; + /** Callback when an edge is clicked */ + onEdgeClick?: (edgeId: string, edge: GraphEdge) => void; + /** Callback when background is clicked */ + onBackgroundClick?: () => void; + /** Currently selected node ID */ + selectedNodeId?: string; + /** Set of node IDs in the highlighted path */ + highlightedPath?: Set; + /** Whether to show node labels */ + showLabels?: boolean; + /** Whether to show edge labels */ + showEdgeLabels?: boolean; + /** Enable performance mode for large graphs */ + performanceMode?: boolean; + /** Custom CSS class for container */ + className?: string; +} + +export interface CytoscapeGraphRef { + /** Get the underlying Cytoscape instance */ + getCy: () => Core | null; + /** Fit graph to viewport */ + zoomToFit: () => void; + /** Center on a specific node */ + centerOnNode: (nodeId: string) => void; + /** Run a new layout */ + runLayout: (layoutName: string) => void; + /** Export as PNG */ + exportPng: () => Promise; + /** Highlight a path between nodes */ + highlightPath: (nodeIds: string[]) => void; + /** Clear all highlighting */ + clearHighlights: () => void; + /** Get current zoom level */ + getZoom: () => number; + /** Set zoom level */ + setZoom: (level: number) => void; +} + +// ============================================ +// REGISTER CYTOSCAPE EXTENSIONS +// ============================================ + +// Register extensions only once (check if already registered) +let extensionsRegistered = false; + +function registerExtensions() { + if (extensionsRegistered) return; + + try { + cytoscape.use(fcose); + } catch (e) { + // Extension might already be registered + } + + try { + cytoscape.use(dagre); + } catch (e) { + // Extension might already be registered + } + + extensionsRegistered = true; +} + +// ============================================ +// MAIN COMPONENT +// ============================================ + +export const CytoscapeGraph = forwardRef(({ + nodes, + edges, + layout, + onNodeClick, + onNodeRightClick, + onEdgeClick, + onBackgroundClick, + selectedNodeId, + highlightedPath, + showLabels = true, + showEdgeLabels = true, + performanceMode = false, + className = '', +}, ref) => { + // Refs + const containerRef = useRef(null); + const cyRef = useRef(null); + + // State + const [isInitialized, setIsInitialized] = useState(false); + + // Register extensions on mount + useEffect(() => { + registerExtensions(); + }, []); + + // ============================================ + // TRANSFORM DATA TO CYTOSCAPE FORMAT + // ============================================ + + const elements = useMemo(() => { + // Transform nodes + const cyNodes = nodes.map(node => ({ + data: { + id: node.id, + label: node.label || node.id, + type: (node.type || node.entity_type || 'unknown').toLowerCase(), + degree: node.degree || 0, + documentCount: node.document_count || 0, + // For compound nodes (clusters) + parent: node.clusterId || undefined, + // Store original node data + _original: node, + }, + })); + + // Transform edges with unique IDs + const edgeMap = new Map(); + const cyEdges = edges.map((edge) => { + const sourceId = typeof edge.source === 'string' ? edge.source : edge.source.id; + const targetId = typeof edge.target === 'string' ? edge.target : edge.target.id; + + // Create unique edge ID + const baseId = edge.id || `edge-${sourceId}-${targetId}`; + const count = edgeMap.get(baseId) || 0; + edgeMap.set(baseId, count + 1); + const edgeId = count > 0 ? `${baseId}-${count}` : baseId; + + // Detect self-loops + const isSelfLoop = sourceId === targetId; + + return { + data: { + id: edgeId, + source: sourceId, + target: targetId, + label: edge.relationship_type || edge.type || 'related', + weight: edge.weight || 1, + type: (edge.relationship_type || edge.type || 'co_occurrence').toLowerCase().replace(/-/g, '_'), + selfLoop: isSelfLoop, + // Store original edge data + _original: edge, + }, + }; + }); + + return [...cyNodes, ...cyEdges]; + }, [nodes, edges]); + + // ============================================ + // INITIALIZE CYTOSCAPE + // ============================================ + + useEffect(() => { + if (!containerRef.current) return; + + // Determine if we should use performance settings + const usePerformanceSettings = performanceMode || nodes.length > 500; + + // Create Cytoscape instance + const cy = cytoscape({ + container: containerRef.current, + elements: elements, + style: usePerformanceSettings + ? createPerformanceStylesheet() + : createStylesheet(showLabels, showEdgeLabels), + + // Layout will be run after init + layout: { name: 'preset', positions: undefined } as any, + + // Viewport settings + minZoom: 0.1, + maxZoom: 5, + wheelSensitivity: 0.3, + + // Performance optimizations + pixelRatio: usePerformanceSettings ? 1 : window.devicePixelRatio, + hideEdgesOnViewport: nodes.length > 500, + textureOnViewport: nodes.length > 500, + + // Selection + selectionType: 'single', + boxSelectionEnabled: false, + + // Rendering + styleEnabled: true, + }); + + cyRef.current = cy; + + // ============================================ + // EVENT HANDLERS + // ============================================ + + // Node click + cy.on('tap', 'node', (evt: EventObject) => { + const node = evt.target as NodeSingular; + const nodeData = node.data('_original') as GraphNode; + onNodeClick?.(node.id(), nodeData); + }); + + // Node right-click (context menu trigger) + cy.on('cxttap', 'node', (evt: EventObject) => { + const node = evt.target as NodeSingular; + const renderedPos = evt.renderedPosition; + onNodeRightClick?.(node.id(), { x: renderedPos.x, y: renderedPos.y }); + }); + + // Edge click + cy.on('tap', 'edge', (evt: EventObject) => { + const edge = evt.target as EdgeSingular; + const edgeData = edge.data('_original') as GraphEdge; + onEdgeClick?.(edge.id(), edgeData); + }); + + // Background click + cy.on('tap', (evt: EventObject) => { + if (evt.target === cy) { + onBackgroundClick?.(); + } + }); + + // Run initial layout + const layoutConfig = getLayoutConfig(layout); + cy.layout(layoutConfig).run(); + + setIsInitialized(true); + + // ============================================ + // CLEANUP + // ============================================ + + return () => { + cy.destroy(); + cyRef.current = null; + setIsInitialized(false); + }; + }, []); // Only run once on mount + + // ============================================ + // UPDATE ELEMENTS WHEN DATA CHANGES + // ============================================ + + useEffect(() => { + if (!cyRef.current || !isInitialized) return; + + const cy = cyRef.current; + + // Batch element updates + cy.batch(() => { + // Get existing element IDs + const existingNodeIds = new Set(cy.nodes().map(n => n.id())); + const existingEdgeIds = new Set(cy.edges().map(e => e.id())); + + // Determine new element IDs + const newNodeIds = new Set(elements.filter(e => !('source' in e.data)).map(e => e.data.id)); + const newEdgeIds = new Set(elements.filter(e => 'source' in e.data).map(e => e.data.id)); + + // Remove elements that no longer exist + existingNodeIds.forEach(id => { + if (!newNodeIds.has(id)) { + cy.$id(id).remove(); + } + }); + existingEdgeIds.forEach(id => { + if (!newEdgeIds.has(id)) { + cy.$id(id).remove(); + } + }); + + // Add/update elements + elements.forEach(element => { + const existing = cy.$id(element.data.id); + if (existing.length > 0) { + // Update existing element data + existing.data(element.data); + } else { + // Add new element + cy.add(element); + } + }); + }); + + // Run layout after data change + const layoutConfig = getLayoutConfig(layout); + cy.layout(layoutConfig).run(); + }, [elements, isInitialized]); + + // ============================================ + // UPDATE LAYOUT WHEN CHANGED + // ============================================ + + useEffect(() => { + if (!cyRef.current || !isInitialized) return; + + const layoutConfig = getLayoutConfig(layout); + cyRef.current.layout(layoutConfig).run(); + }, [layout, isInitialized]); + + // ============================================ + // UPDATE SELECTION HIGHLIGHTING + // ============================================ + + useEffect(() => { + if (!cyRef.current || !isInitialized) return; + + const cy = cyRef.current; + + // Clear previous selection + cy.nodes().removeClass('selected'); + + // Highlight selected node + if (selectedNodeId) { + const selectedNode = cy.$id(selectedNodeId); + if (selectedNode.length > 0) { + selectedNode.addClass('selected'); + } + } + }, [selectedNodeId, isInitialized]); + + // ============================================ + // UPDATE PATH HIGHLIGHTING + // ============================================ + + useEffect(() => { + if (!cyRef.current || !isInitialized) return; + + const cy = cyRef.current; + + // Clear previous path highlighting + cy.elements().removeClass('in-path highlighted'); + + // Highlight path nodes and edges + if (highlightedPath && highlightedPath.size > 0) { + const pathArray = Array.from(highlightedPath); + + // Highlight nodes in path + pathArray.forEach(nodeId => { + cy.$id(nodeId).addClass('in-path'); + }); + + // Highlight edges between consecutive path nodes + for (let i = 0; i < pathArray.length - 1; i++) { + const sourceId = pathArray[i]; + const targetId = pathArray[i + 1]; + + cy.edges().forEach(edge => { + const edgeSource = edge.source().id(); + const edgeTarget = edge.target().id(); + + if ((edgeSource === sourceId && edgeTarget === targetId) || + (edgeSource === targetId && edgeTarget === sourceId)) { + edge.addClass('highlighted'); + } + }); + } + } + }, [highlightedPath, isInitialized]); + + // ============================================ + // UPDATE STYLESHEET WHEN LABEL SETTINGS CHANGE + // ============================================ + + useEffect(() => { + if (!cyRef.current || !isInitialized) return; + + const usePerformanceSettings = performanceMode || nodes.length > 500; + const stylesheet = usePerformanceSettings + ? createPerformanceStylesheet() + : createStylesheet(showLabels, showEdgeLabels); + + cyRef.current.style(stylesheet); + }, [showLabels, showEdgeLabels, performanceMode, isInitialized, nodes.length]); + + // ============================================ + // CONTAINER RESIZE HANDLING + // ============================================ + + useEffect(() => { + if (!containerRef.current || !cyRef.current) return; + + const resizeObserver = new ResizeObserver(() => { + cyRef.current?.resize(); + }); + + resizeObserver.observe(containerRef.current); + + return () => resizeObserver.disconnect(); + }, []); + + // ============================================ + // IMPERATIVE HANDLE METHODS + // ============================================ + + const zoomToFit = useCallback(() => { + cyRef.current?.fit(undefined, 50); + }, []); + + const centerOnNode = useCallback((nodeId: string) => { + const cy = cyRef.current; + if (!cy) return; + + const node = cy.$id(nodeId); + if (node.length > 0) { + cy.center(node); + cy.zoom({ level: 2, position: node.position() }); + } + }, []); + + const runLayout = useCallback((layoutName: string) => { + if (!cyRef.current) return; + + const layoutConfig = getLayoutConfig(layoutName); + cyRef.current.layout(layoutConfig).run(); + }, []); + + const exportPng = useCallback(async (): Promise => { + if (!cyRef.current) return null; + + try { + const blob = await cyRef.current.png({ + output: 'blob-promise', + full: true, + scale: 2, + bg: '#ffffff', + }); + return blob; + } catch (error) { + console.error('Failed to export PNG:', error); + return null; + } + }, []); + + const highlightPathFn = useCallback((nodeIds: string[]) => { + if (!cyRef.current) return; + + const cy = cyRef.current; + + // Clear previous highlighting + cy.elements().removeClass('in-path highlighted'); + + // Highlight nodes + nodeIds.forEach(nodeId => { + cy.$id(nodeId).addClass('in-path'); + }); + + // Highlight edges between consecutive nodes + for (let i = 0; i < nodeIds.length - 1; i++) { + const sourceId = nodeIds[i]; + const targetId = nodeIds[i + 1]; + + cy.edges().forEach(edge => { + const edgeSource = edge.source().id(); + const edgeTarget = edge.target().id(); + + if ((edgeSource === sourceId && edgeTarget === targetId) || + (edgeSource === targetId && edgeTarget === sourceId)) { + edge.addClass('highlighted'); + } + }); + } + + // Fit view to highlighted path + const pathNodes = cy.nodes().filter(n => nodeIds.includes(n.id())); + if (pathNodes.length > 0) { + cy.fit(pathNodes, 50); + } + }, []); + + const clearHighlights = useCallback(() => { + cyRef.current?.elements().removeClass('selected in-path highlighted dimmed'); + }, []); + + const getZoom = useCallback(() => { + return cyRef.current?.zoom() ?? 1; + }, []); + + const setZoom = useCallback((level: number) => { + cyRef.current?.zoom(level); + }, []); + + // Expose methods via ref + useImperativeHandle(ref, () => ({ + getCy: () => cyRef.current, + zoomToFit, + centerOnNode, + runLayout, + exportPng, + highlightPath: highlightPathFn, + clearHighlights, + getZoom, + setZoom, + }), [zoomToFit, centerOnNode, runLayout, exportPng, highlightPathFn, clearHighlights, getZoom, setZoom]); + + // ============================================ + // RENDER + // ============================================ + + return ( +
+ ); +}); + +CytoscapeGraph.displayName = 'CytoscapeGraph'; + +export default CytoscapeGraph; diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeLayouts.ts b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeLayouts.ts new file mode 100644 index 0000000..ff3a79e --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeLayouts.ts @@ -0,0 +1,406 @@ +/** + * Cytoscape.js Layout Configurations + * + * Provides pre-configured layouts for different analysis scenarios. + * All layouts support animation for smooth transitions. + * + * Layout categories: + * - Force-directed: fcose, cose (general exploration, organic structure) + * - Hierarchical: dagre, breadthfirst (org charts, chains, trees) + * - Geometric: concentric, circle, grid (structured arrangements) + * - Custom: preset (user-defined positions) + */ + +import { LayoutOptions } from 'cytoscape'; + +/** + * Layout configuration with animation defaults + */ +export const LAYOUT_CONFIGS: Record = { + // ============================================ + // FORCE-DIRECTED LAYOUTS + // ============================================ + + /** + * fCoSE (Fast Compound Spring Embedder) + * Best for: General exploration, organic structure, unknown relationships + * Features: Fast, handles compound nodes, good edge lengths + */ + fcose: { + name: 'fcose', + quality: 'default', + randomize: true, + animate: true, + animationDuration: 800, + animationEasing: 'ease-out', + nodeDimensionsIncludeLabels: true, + + // Force simulation parameters + nodeRepulsion: 4500, + idealEdgeLength: 80, + edgeElasticity: 0.45, + nestingFactor: 0.1, + gravity: 0.25, + numIter: 2500, + + // Performance for large graphs + tile: true, + tilingPaddingVertical: 10, + tilingPaddingHorizontal: 10, + + // Fit to viewport after layout + fit: true, + padding: 50, + } as any, + + /** + * CoSE (Compound Spring Embedder) + * Best for: Clustered data, community detection visualization + * Features: Respects compound parent nodes, good for groups + */ + cose: { + name: 'cose', + animate: true, + animationDuration: 500, + animationEasing: 'ease-out', + + // Force parameters + nodeRepulsion: 400000, + nodeOverlap: 10, + idealEdgeLength: 100, + edgeElasticity: 100, + nestingFactor: 5, + gravity: 80, + + // Iteration settings + numIter: 1000, + initialTemp: 200, + coolingFactor: 0.95, + minTemp: 1.0, + + // Fit settings + fit: true, + padding: 50, + nodeDimensionsIncludeLabels: true, + randomize: false, + } as any, + + // ============================================ + // HIERARCHICAL LAYOUTS + // ============================================ + + /** + * Dagre (Directed Acyclic Graph) + * Best for: Org charts, chain of command, process flows + * Features: Clear hierarchy, customizable direction + */ + hierarchical: { + name: 'dagre', + rankDir: 'TB', // TB (top-bottom), BT, LR, RL + nodeSep: 50, // Horizontal separation between nodes + edgeSep: 10, // Separation between edges + rankSep: 80, // Vertical separation between ranks + ranker: 'network-simplex', // 'tight-tree', 'longest-path' + animate: true, + animationDuration: 500, + animationEasing: 'ease-out', + fit: true, + padding: 50, + } as any, + + /** + * Horizontal Hierarchical (Dagre LR) + * Best for: Timelines, process flows, left-to-right reading + */ + 'hierarchical-lr': { + name: 'dagre', + rankDir: 'LR', + nodeSep: 50, + edgeSep: 10, + rankSep: 100, + ranker: 'network-simplex', + animate: true, + animationDuration: 500, + fit: true, + padding: 50, + } as any, + + /** + * Breadth-First (Tree) + * Best for: Tree structures, hierarchies with single root + * Features: Layered levels, avoids crossings + */ + breadthfirst: { + name: 'breadthfirst', + directed: false, // Set to false to avoid root determination issues + animate: true, + animationDuration: 500, + animationEasing: 'ease-out', + spacingFactor: 1.5, + avoidOverlap: true, + maximal: false, // Disable maximal to avoid null node errors + fit: true, + padding: 50, + circle: false, + grid: false, + // Let cytoscape auto-determine roots based on node degree + roots: undefined, + } as any, + + // ============================================ + // GEOMETRIC LAYOUTS + // ============================================ + + /** + * Concentric (Radial) + * Best for: Ego networks, "who's connected to X", centrality visualization + * Features: High-degree nodes at center, rings by connectivity + */ + concentric: { + name: 'concentric', + animate: true, + animationDuration: 500, + animationEasing: 'ease-out', + // Nodes with higher degree go toward center + concentric: (node: any) => node.degree(), + levelWidth: () => 2, // Nodes per concentric ring + minNodeSpacing: 50, + equidistant: true, + fit: true, + padding: 50, + startAngle: 3 / 2 * Math.PI, // Start at top + sweep: 2 * Math.PI, // Full circle + clockwise: true, + } as any, + + /** + * Circle + * Best for: Equal emphasis on all nodes, small networks + * Features: All nodes on single ring + */ + circle: { + name: 'circle', + animate: true, + animationDuration: 500, + animationEasing: 'ease-out', + avoidOverlap: true, + spacingFactor: 1.5, + fit: true, + padding: 50, + startAngle: 3 / 2 * Math.PI, + sweep: 2 * Math.PI, + clockwise: true, + sort: (a: any, b: any) => b.degree() - a.degree(), // Sort by degree + } as any, + + /** + * Grid + * Best for: Comparison, orderly arrangement, sparse networks + * Features: Aligned rows and columns + */ + grid: { + name: 'grid', + animate: true, + animationDuration: 500, + animationEasing: 'ease-out', + avoidOverlap: true, + avoidOverlapPadding: 20, + condense: true, + rows: undefined, // Auto-calculate + cols: undefined, // Auto-calculate + fit: true, + padding: 50, + sort: (a: any, b: any) => { + // Sort by type, then by label + const typeA = a.data('type') || ''; + const typeB = b.data('type') || ''; + if (typeA !== typeB) return typeA.localeCompare(typeB); + const labelA = a.data('label') || ''; + const labelB = b.data('label') || ''; + return labelA.localeCompare(labelB); + }, + } as any, + + // ============================================ + // CUSTOM LAYOUTS + // ============================================ + + /** + * Preset + * Best for: Saved positions, manual arrangement, reproducible layouts + * Features: Uses pre-defined node positions + */ + preset: { + name: 'preset', + animate: true, + animationDuration: 500, + animationEasing: 'ease-out', + positions: undefined, // Set dynamically: { nodeId: { x, y } } + fit: true, + padding: 50, + } as any, + + /** + * Random + * Best for: Initial exploration, breaking out of local minima + * Features: Random positions, useful before force-directed + */ + random: { + name: 'random', + animate: true, + animationDuration: 300, + fit: true, + padding: 50, + } as any, +}; + +/** + * Analysis mode to layout mapping + * Provides recommended layouts for specific analysis scenarios + */ +export const ANALYSIS_LAYOUTS: Record = { + exploration: 'fcose', // General discovery + hierarchy: 'hierarchical', // Org structure, chains + ego: 'concentric', // Person-centric analysis + clusters: 'cose', // Community detection + timeline: 'hierarchical-lr', // Temporal flow (LR direction) + comparison: 'grid', // Side-by-side entities + tree: 'breadthfirst', // Family trees, org trees + network: 'circle', // Small network overview +}; + +/** + * Layout metadata for UI display + */ +export interface LayoutInfo { + id: string; + name: string; + description: string; + icon: string; + category: 'force' | 'hierarchical' | 'geometric' | 'custom'; + bestFor: string[]; +} + +export const LAYOUT_INFO: Record = { + fcose: { + id: 'fcose', + name: 'Force-Directed', + description: 'Organic layout using physics simulation', + icon: 'Workflow', + category: 'force', + bestFor: ['General exploration', 'Unknown relationships', 'Organic structure'], + }, + cose: { + id: 'cose', + name: 'Clustered', + description: 'Force layout respecting compound groups', + icon: 'Group', + category: 'force', + bestFor: ['Community detection', 'Grouped entities', 'Clustered data'], + }, + hierarchical: { + id: 'hierarchical', + name: 'Hierarchical', + description: 'Top-down tree structure', + icon: 'GitBranch', + category: 'hierarchical', + bestFor: ['Org charts', 'Chain of command', 'Reporting structures'], + }, + 'hierarchical-lr': { + id: 'hierarchical-lr', + name: 'Horizontal Tree', + description: 'Left-to-right hierarchical layout', + icon: 'ArrowRight', + category: 'hierarchical', + bestFor: ['Timelines', 'Process flows', 'Sequential events'], + }, + breadthfirst: { + id: 'breadthfirst', + name: 'Tree', + description: 'Layered tree from root nodes', + icon: 'Network', + category: 'hierarchical', + bestFor: ['Family trees', 'File systems', 'Single-root hierarchies'], + }, + concentric: { + id: 'concentric', + name: 'Radial', + description: 'Concentric circles by importance', + icon: 'Target', + category: 'geometric', + bestFor: ['Ego networks', 'Centrality analysis', 'Person of interest'], + }, + circle: { + id: 'circle', + name: 'Circular', + description: 'All nodes on a ring', + icon: 'Circle', + category: 'geometric', + bestFor: ['Small networks', 'Equal emphasis', 'Ring topology'], + }, + grid: { + id: 'grid', + name: 'Grid', + description: 'Organized rows and columns', + icon: 'Grid3X3', + category: 'geometric', + bestFor: ['Comparison', 'Sorted display', 'Orderly arrangement'], + }, + preset: { + id: 'preset', + name: 'Custom', + description: 'Saved or manual positions', + icon: 'Save', + category: 'custom', + bestFor: ['Saved layouts', 'Manual arrangement', 'Reproducible views'], + }, + random: { + id: 'random', + name: 'Random', + description: 'Random node positions', + icon: 'Shuffle', + category: 'custom', + bestFor: ['Reset layout', 'Break patterns', 'Fresh start'], + }, +}; + +/** + * Get layout configuration by name + */ +export function getLayoutConfig(layoutName: string): LayoutOptions { + return LAYOUT_CONFIGS[layoutName] || LAYOUT_CONFIGS.fcose; +} + +/** + * Get layout info by name + */ +export function getLayoutInfo(layoutName: string): LayoutInfo | undefined { + return LAYOUT_INFO[layoutName]; +} + +/** + * Create a modified layout config with custom options + */ +export function createLayoutConfig( + baseName: string, + overrides: Partial +): LayoutOptions { + const base = LAYOUT_CONFIGS[baseName] || LAYOUT_CONFIGS.fcose; + return { ...base, ...overrides }; +} + +/** + * Get all available layout names + */ +export function getAvailableLayouts(): string[] { + return Object.keys(LAYOUT_CONFIGS); +} + +/** + * Get layouts by category + */ +export function getLayoutsByCategory(category: LayoutInfo['category']): string[] { + return Object.entries(LAYOUT_INFO) + .filter(([_, info]) => info.category === category) + .map(([name]) => name); +} diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeStylesheet.ts b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeStylesheet.ts new file mode 100644 index 0000000..1f8f4e1 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeStylesheet.ts @@ -0,0 +1,526 @@ +/** + * Cytoscape.js Stylesheet Configuration + * + * Defines all visual styles for nodes, edges, and interactive states. + * Uses CSS-like selectors and properties specific to Cytoscape.js. + * + * Style priorities (highest to lowest): + * 1. Interactive states (.selected, .highlighted, .hidden) + * 2. Progressive disclosure states (.seed-node, .expandable, .expanded) + * 3. Dynamic sizing (degree-based) + * 4. Entity type colors + * 5. Base styles + */ + +import type { StylesheetStyle } from 'cytoscape'; +import { ENTITY_COLORS, getEntityIcon, getEntityColor } from './entityIcons'; +import { RELATIONSHIP_STYLES } from '../../constants/relationshipStyles'; + +/** + * Creates the complete Cytoscape stylesheet + * @param showLabels - Whether to display node labels + * @param showEdgeLabels - Whether to display edge labels + */ +export function createStylesheet( + showLabels: boolean = true, + showEdgeLabels: boolean = true +): StylesheetStyle[] { + return [ + // ============================================ + // BASE NODE STYLES + // ============================================ + { + selector: 'node', + style: { + // Shape and size + 'shape': 'ellipse', + 'width': 50, + 'height': 50, + + // Background color (will be overridden by entity type) + 'background-color': '#718096', + 'background-opacity': 1, + + // Icon rendering - use data URI + 'background-image': (ele: any) => { + const type = ele.data('type')?.toLowerCase() || 'unknown'; + return getEntityIcon(type); + }, + 'background-fit': 'none', + 'background-width': 24, + 'background-height': 24, + 'background-position-x': '50%', + 'background-position-y': '50%', + 'background-clip': 'none', + + // Border (color matches entity type) + 'border-width': 2, + 'border-color': (ele: any) => { + const type = ele.data('type')?.toLowerCase() || 'unknown'; + const color = getEntityColor(type); + // Darken the color slightly for border + return color; + }, + 'border-style': 'solid', + 'border-opacity': 1, + + // Label styling + 'label': showLabels ? 'data(label)' : '', + 'text-valign': 'bottom', + 'text-halign': 'center', + 'text-margin-y': 5, + 'font-size': 11, + 'font-family': 'Inter, system-ui, -apple-system, sans-serif', + 'color': '#1a202c', + 'text-background-color': '#ffffff', + 'text-background-opacity': 0.85, + 'text-background-padding': '3px', + 'text-background-shape': 'roundrectangle', + 'text-wrap': 'ellipsis', + 'text-max-width': '100px', + + // Performance: hide labels when zoomed out + 'min-zoomed-font-size': 8, + + // Transition for smooth state changes + 'transition-property': 'background-color, border-color, border-width, width, height, opacity', + 'transition-duration': '0.2s', + 'transition-timing-function': 'ease-out', + } as any, + }, + + // ============================================ + // DYNAMIC NODE SIZING BY DEGREE + // ============================================ + { + selector: 'node[degree > 5]', + style: { + 'width': 60, + 'height': 60, + 'background-width': 28, + 'background-height': 28, + }, + }, + { + selector: 'node[degree > 10]', + style: { + 'width': 70, + 'height': 70, + 'background-width': 32, + 'background-height': 32, + }, + }, + { + selector: 'node[degree > 20]', + style: { + 'width': 80, + 'height': 80, + 'background-width': 36, + 'background-height': 36, + }, + }, + + // ============================================ + // ENTITY TYPE COLORS + // ============================================ + ...Object.entries(ENTITY_COLORS).map(([type, color]) => ({ + selector: `node[type = "${type}"]`, + style: { + 'background-color': color, + }, + })), + + // ============================================ + // SELECTED NODE STATE + // ============================================ + { + selector: 'node.selected', + style: { + 'border-width': 4, + 'border-color': '#f56565', + 'overlay-color': '#f56565', + 'overlay-opacity': 0.2, + 'overlay-padding': 8, + 'z-index': 999, + }, + }, + + // ============================================ + // HIGHLIGHTED PATH NODES + // ============================================ + { + selector: 'node.in-path', + style: { + 'border-width': 4, + 'border-color': '#68d391', + 'overlay-color': '#68d391', + 'overlay-opacity': 0.2, + 'overlay-padding': 6, + 'z-index': 998, + }, + }, + + // ============================================ + // HIDDEN NODES + // ============================================ + { + selector: 'node.hidden', + style: { + 'display': 'none', + }, + }, + + // ============================================ + // COMPOUND PARENT NODES (CLUSTERS) + // ============================================ + { + selector: ':parent', + style: { + 'background-opacity': 0.15, + 'background-color': '#4299e1', + 'border-width': 2, + 'border-style': 'dashed', + 'border-color': '#2b6cb0', + 'padding': 30, + 'text-valign': 'top', + 'text-halign': 'center', + 'font-weight': 'bold', + 'font-size': 14, + 'label': 'data(label)', + 'shape': 'roundrectangle', + }, + }, + + // ============================================ + // BASE EDGE STYLES + // ============================================ + { + selector: 'edge', + style: { + 'width': 2, + 'line-color': '#a0aec0', + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'target-arrow-color': '#a0aec0', + 'arrow-scale': 0.8, + + // Edge label + 'label': showEdgeLabels ? 'data(label)' : '', + 'font-size': 9, + 'text-rotation': 'autorotate', + 'text-margin-y': -8, + 'color': '#4a5568', + 'text-background-color': '#ffffff', + 'text-background-opacity': 0.8, + 'text-background-padding': '2px', + 'text-background-shape': 'roundrectangle', + + // Performance + 'min-zoomed-font-size': 10, + + // Transitions + 'transition-property': 'line-color, width, opacity', + 'transition-duration': '0.2s', + } as any, + }, + + // ============================================ + // EDGE WEIGHT STYLING (THICKER = STRONGER) + // ============================================ + { + selector: 'edge[weight > 3]', + style: { + 'width': 3, + }, + }, + { + selector: 'edge[weight > 5]', + style: { + 'width': 4, + }, + }, + { + selector: 'edge[weight > 10]', + style: { + 'width': 5, + }, + }, + + // ============================================ + // RELATIONSHIP TYPE COLORS + // ============================================ + ...Object.entries(RELATIONSHIP_STYLES).map(([type, style]) => ({ + selector: `edge[type = "${type}"]`, + style: { + 'line-color': style.color, + 'target-arrow-color': style.color, + 'target-arrow-shape': style.directed ? 'triangle' : 'none', + 'line-style': style.dash ? 'dashed' : 'solid', + ...(style.width ? { 'width': style.width } : {}), + }, + })), + + // ============================================ + // HIGHLIGHTED PATH EDGES + // ============================================ + { + selector: 'edge.highlighted', + style: { + 'line-color': '#68d391', + 'target-arrow-color': '#68d391', + 'width': 4, + 'z-index': 999, + 'opacity': 1, + }, + }, + + // ============================================ + // EDGE BUNDLING & CURVATURE STYLES + // ============================================ + + // Multi-edge handling (multiple edges between same node pair) + // Use unbundled-bezier to curve parallel edges away from each other + { + selector: 'edge.multi-edge', + style: { + 'curve-style': 'unbundled-bezier', + 'control-point-distances': [40] as any, + 'control-point-weights': [0.5] as any, + }, + }, + + // Second parallel edge curves opposite direction + { + selector: 'edge.multi-edge-2', + style: { + 'curve-style': 'unbundled-bezier', + 'control-point-distances': [-40] as any, + 'control-point-weights': [0.5] as any, + }, + }, + + // Third parallel edge (if present) + { + selector: 'edge.multi-edge-3', + style: { + 'curve-style': 'unbundled-bezier', + 'control-point-distances': [80] as any, + 'control-point-weights': [0.5] as any, + }, + }, + + // ============================================ + // SELF-LOOP STYLES + // ============================================ + { + selector: 'edge[?selfLoop]', + style: { + 'curve-style': 'bezier', + 'loop-direction': '45deg' as any, + 'loop-sweep': '-90deg' as any, + 'control-point-step-size': 50, + }, + }, + + // ============================================ + // PERFORMANCE MODE EDGE STYLES + // ============================================ + // Haystack edges are straight lines that don't route around nodes + { + selector: 'edge.performance-mode', + style: { + 'curve-style': 'haystack', + 'haystack-radius': 0.5, + 'line-opacity': 0.6, + // Hide labels in performance mode + 'label': '', + 'target-arrow-shape': 'none', + }, + }, + + // ============================================ + // PROGRESSIVE DISCLOSURE STYLES + // ============================================ + + // Seed nodes (original query results) - distinctive double border + { + selector: 'node.seed-node', + style: { + 'border-width': 4, + 'border-color': '#f59e0b', // Amber + 'border-style': 'double', + }, + }, + + // Expandable nodes (have unexplored neighbors) + { + selector: 'node.expandable', + style: { + // Add pulsing glow to indicate expandability + 'overlay-color': '#4299e1', + 'overlay-opacity': 0.15, + 'overlay-padding': 4, + }, + }, + + // Expanded nodes (neighbors already fetched) + { + selector: 'node.expanded', + style: { + 'border-style': 'solid', + 'overlay-opacity': 0, + }, + }, + + // Fully expanded (no more neighbors to fetch) + { + selector: 'node.fully-expanded', + style: { + 'opacity': 0.8, + 'border-style': 'dotted', + }, + }, + + // Newly expanded nodes (animation state) + { + selector: 'node.newly-expanded', + style: { + 'opacity': 0.3, + }, + }, + + // Collapsed neighbors (hidden but not removed) + { + selector: 'node.collapsed-neighbor', + style: { + 'display': 'none', + }, + }, + { + selector: 'edge.collapsed-neighbor', + style: { + 'display': 'none', + }, + }, + + // Depth indicator via opacity (farther = more faded) + { + selector: 'node[depth > 1]', + style: { + 'opacity': 0.85, + }, + }, + { + selector: 'node[depth > 2]', + style: { + 'opacity': 0.7, + }, + }, + + // ============================================ + // HOVER EFFECTS + // ============================================ + { + selector: 'node:active', + style: { + 'overlay-opacity': 0.3, + }, + }, + { + selector: 'edge:active', + style: { + 'overlay-opacity': 0.2, + 'width': 4, + }, + }, + + // ============================================ + // DIMMED STATE (for focusing on subset) + // ============================================ + { + selector: 'node.dimmed', + style: { + 'opacity': 0.3, + }, + }, + { + selector: 'edge.dimmed', + style: { + 'opacity': 0.15, + }, + }, + + // ============================================ + // FADED EDGES DURING PAN/ZOOM + // ============================================ + { + selector: 'edge.viewport-hidden', + style: { + 'opacity': 0.2, + }, + }, + ]; +} + +/** + * Creates a minimal stylesheet for performance mode + * Strips unnecessary styling for maximum render speed + */ +export function createPerformanceStylesheet(): StylesheetStyle[] { + return [ + { + selector: 'node', + style: { + 'shape': 'ellipse', + 'width': 30, + 'height': 30, + 'background-color': (ele: any) => { + const type = ele.data('type')?.toLowerCase() || 'unknown'; + return getEntityColor(type); + }, + 'border-width': 1, + 'border-color': '#2d3748', + 'label': '', + } as any, + }, + { + selector: 'edge', + style: { + 'width': 1, + 'line-color': '#a0aec0', + 'curve-style': 'haystack', + 'haystack-radius': 0.5, + 'line-opacity': 0.5, + 'target-arrow-shape': 'none', + } as any, + }, + { + selector: 'node.selected', + style: { + 'border-width': 3, + 'border-color': '#f56565', + 'width': 40, + 'height': 40, + }, + }, + { + selector: 'edge.highlighted', + style: { + 'line-color': '#68d391', + 'width': 2, + 'line-opacity': 1, + }, + }, + { + selector: '.hidden', + style: { + 'display': 'none', + }, + }, + ]; +} + +/** + * Export default stylesheet + */ +export const defaultStylesheet = createStylesheet(true, true); diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeTooltip.tsx b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeTooltip.tsx new file mode 100644 index 0000000..64ea90a --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/CytoscapeTooltip.tsx @@ -0,0 +1,184 @@ +/** + * CytoscapeTooltip - Hover tooltip for graph nodes + * + * Displays: + * - Node label and type icon + * - Type name + * - Connection count + * - Document count + * + * Follows the mouse position with proper offset to avoid occlusion. + */ + +import React, { useEffect, useState, useRef } from 'react'; +import { Icon } from '../../../../components/common/Icon'; + +// GraphNode type (should match the main graph types) +interface GraphNode { + id: string; + label: string; + type: string; + degree?: number; + entity_type?: string; + document_count?: number; +} + +export interface CytoscapeTooltipProps { + node: GraphNode | null; + position: { x: number; y: number } | null; +} + +// Icon mapping for entity types +const TYPE_ICONS: Record = { + person: 'User', + organization: 'Building2', + location: 'MapPin', + gpe: 'MapPin', + event: 'Calendar', + document: 'FileText', + money: 'DollarSign', + phone: 'Phone', + email: 'Mail', + claim: 'Target', + evidence: 'CheckCircle', + hypothesis: 'Lightbulb', + artifact: 'Archive', + timeline_event: 'Clock', + unknown: 'HelpCircle', +}; + +// Entity type colors for the badge +const TYPE_COLORS: Record = { + person: '#4299e1', + organization: '#48bb78', + location: '#ed8936', + gpe: '#ed8936', + event: '#9f7aea', + document: '#f56565', + money: '#68d391', + phone: '#63b3ed', + email: '#63b3ed', + claim: '#f59e0b', + evidence: '#3b82f6', + hypothesis: '#8b5cf6', + artifact: '#10b981', + timeline_event: '#ec4899', + unknown: '#718096', +}; + +// Offset from cursor +const TOOLTIP_OFFSET_X = 15; +const TOOLTIP_OFFSET_Y = 15; + +export const CytoscapeTooltip: React.FC = ({ + node, + position, +}) => { + const tooltipRef = useRef(null); + const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null); + + // Adjust position to keep tooltip within viewport + useEffect(() => { + if (!position || !tooltipRef.current) { + setAdjustedPosition(null); + return; + } + + const tooltip = tooltipRef.current; + const rect = tooltip.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let x = position.x + TOOLTIP_OFFSET_X; + let y = position.y + TOOLTIP_OFFSET_Y; + + // Adjust if tooltip goes beyond right edge + if (x + rect.width > viewportWidth - 10) { + x = position.x - rect.width - TOOLTIP_OFFSET_X; + } + + // Adjust if tooltip goes beyond bottom edge + if (y + rect.height > viewportHeight - 10) { + y = position.y - rect.height - TOOLTIP_OFFSET_Y; + } + + // Ensure tooltip stays within left/top bounds + x = Math.max(10, x); + y = Math.max(10, y); + + setAdjustedPosition({ x, y }); + }, [position]); + + if (!node || !position) { + return null; + } + + const nodeType = (node.type || node.entity_type || 'unknown').toLowerCase(); + const iconName = TYPE_ICONS[nodeType] || TYPE_ICONS.unknown; + const typeColor = TYPE_COLORS[nodeType] || TYPE_COLORS.unknown; + + return ( +
+ {/* Header with icon and label */} +
+
+ +
+ {node.label} +
+ + {/* Content rows */} +
+
+ + + Type + + + {nodeType} + +
+ +
+ + + Connections + + + {node.degree ?? 0} + +
+ +
+ + + Documents + + + {node.document_count ?? 0} + +
+
+ + {/* Click hint */} +
+ Click to select | Right-click for options +
+
+ ); +}; + +export default CytoscapeTooltip; diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/ExpandNeighborsPanel.tsx b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/ExpandNeighborsPanel.tsx new file mode 100644 index 0000000..3d4e65a --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/ExpandNeighborsPanel.tsx @@ -0,0 +1,224 @@ +/** + * ExpandNeighborsPanel - Network exploration panel for progressive disclosure + * + * Core OSINT workflow: start with a seed entity, progressively expand its network. + * + * Features: + * - Node label and type display + * - Seed node and depth badges + * - Expand/Collapse buttons with loading state + * - Expansion statistics + * - Depth legend with color coding + */ + +import React from 'react'; +import { Icon } from '../../../../components/common/Icon'; + +// GraphNode type (should match the main graph types) +interface GraphNode { + id: string; + label: string; + type: string; + degree?: number; + entity_type?: string; + document_count?: number; +} + +export interface ExpandState { + expandedNodes: Set; + expansionDepth: Map; + seedNodes: Set; + maxDepth: number; +} + +export interface ExpandNeighborsPanelProps { + selectedNode: GraphNode | null; + expandState: ExpandState; + onExpand: (nodeId: string) => void; + onCollapse: (nodeId: string) => void; + canExpand: (nodeId: string) => boolean; + isExpanding: boolean; + maxDepth: number; +} + +// Icon mapping for entity types +const TYPE_ICONS: Record = { + person: 'User', + organization: 'Building2', + location: 'MapPin', + gpe: 'MapPin', + event: 'Calendar', + document: 'FileText', + money: 'DollarSign', + phone: 'Phone', + email: 'Mail', + claim: 'Target', + evidence: 'CheckCircle', + hypothesis: 'Lightbulb', + unknown: 'HelpCircle', +}; + +export const ExpandNeighborsPanel: React.FC = ({ + selectedNode, + expandState, + onExpand, + onCollapse, + canExpand, + isExpanding, + maxDepth, +}) => { + if (!selectedNode) { + return ( +
+
+

+ + Network Exploration +

+
+

+ Select a node to explore its network connections. +

+
+ ); + } + + const nodeDepth = expandState.expansionDepth.get(selectedNode.id) ?? 0; + const isExpanded = expandState.expandedNodes.has(selectedNode.id); + const isSeed = expandState.seedNodes.has(selectedNode.id); + const canExpandNode = canExpand(selectedNode.id); + const nodeType = (selectedNode.type || selectedNode.entity_type || 'unknown').toLowerCase(); + const iconName = TYPE_ICONS[nodeType] || TYPE_ICONS.unknown; + + return ( +
+ {/* Header */} +
+

+ + Network Exploration +

+
+ + {/* Selected node info */} +
+
+ +
+
+ {selectedNode.label} + {nodeType} +
+
+ + {/* Badges */} +
+ {isSeed && ( + + + Seed + + )} + + + Depth: {nodeDepth}/{maxDepth} + + {selectedNode.degree !== undefined && ( + + + {selectedNode.degree} connections + + )} +
+ + {/* Action buttons */} +
+ {canExpandNode && ( + + )} + + {isExpanded && ( + + )} + + {!canExpandNode && !isExpanded && nodeDepth >= maxDepth && ( +
+ + Maximum depth reached +
+ )} + + {!canExpandNode && isExpanded && ( +
+ + Fully expanded +
+ )} +
+ + {/* Expansion statistics */} +
+
+ Nodes explored: + {expandState.expandedNodes.size} +
+
+ Current depth: + {expandState.maxDepth} +
+
+ Seed nodes: + {expandState.seedNodes.size} +
+
+ + {/* Depth legend */} +
+
Depth Legend
+
+
+ + Seed (original query) +
+
+ + 1 hop away +
+
+ + 2 hops away +
+
+ + 3+ hops away +
+
+
+
+ ); +}; + +export default ExpandNeighborsPanel; diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/PerformanceModeToggle.tsx b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/PerformanceModeToggle.tsx new file mode 100644 index 0000000..c159c27 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/PerformanceModeToggle.tsx @@ -0,0 +1,171 @@ +/** + * PerformanceModeToggle - Performance mode UI for large graphs + * + * Provides: + * - Toggle switch for performance mode + * - Auto-suggest when node count exceeds threshold + * - Expandable settings for fine-grained control + * - Node/edge count statistics + */ + +import React, { useState } from 'react'; +import { Icon } from '../../../../components/common/Icon'; + +export interface PerformanceModeSettings { + hideEdgeLabels: boolean; + simplifyEdges: boolean; + hideEdgesOnPan: boolean; + lowerPixelRatio: boolean; +} + +export interface PerformanceModeToggleProps { + performanceMode: boolean; + onToggle: (enabled: boolean) => void; + onSettingsChange: (settings: Partial) => void; + settings: PerformanceModeSettings; + nodeCount: number; + edgeCount: number; + autoDetected: boolean; + nodeThreshold?: number; +} + +const DEFAULT_NODE_THRESHOLD = 500; + +export const PerformanceModeToggle: React.FC = ({ + performanceMode, + onToggle, + onSettingsChange, + settings, + nodeCount, + edgeCount, + autoDetected, + nodeThreshold = DEFAULT_NODE_THRESHOLD, +}) => { + const [showSettings, setShowSettings] = useState(false); + + return ( +
+ {/* Header with toggle */} +
+
+ + + Performance Mode + {autoDetected && !performanceMode && ( + + (Recommended for {nodeCount} nodes) + + )} + +
+ + {performanceMode && ( +
+ + Optimized +
+ )} +
+ + {/* Expandable settings when enabled */} + {performanceMode && ( + <> + + + {showSettings && ( +
+ + + + + + + +
+ )} + + )} + + {/* Stats display */} +
+ + + {nodeCount.toLocaleString()} nodes + {nodeCount > nodeThreshold && ( + + )} + + + + {edgeCount.toLocaleString()} edges + +
+ + {/* Performance tip when auto-detected but not enabled */} + {autoDetected && !performanceMode && ( +
+ + + Enable performance mode for smoother interaction with large graphs. + +
+ )} +
+ ); +}; + +export default PerformanceModeToggle; diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/edgeBundling.ts b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/edgeBundling.ts new file mode 100644 index 0000000..738d9a6 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/edgeBundling.ts @@ -0,0 +1,228 @@ +/** + * Edge Bundling Utilities for Cytoscape.js + * + * This module handles detection and styling of multi-edges (parallel edges between + * the same node pairs). Essential for dense OSINT graphs where entities may have + * multiple relationship types (e.g., "works_for" AND "communicated_with"). + */ + +import type { Core, EdgeSingular } from 'cytoscape'; + +/** + * Detect and classify multi-edges (parallel edges between same node pairs). + * Applies CSS classes to enable distinct visual styling for each parallel edge. + * + * The function groups edges by their source-target pair (undirected), then + * applies appropriate classes: + * - multi-edge: First edge in a parallel set (curves one direction) + * - multi-edge-2: Second edge (curves opposite direction) + * - multi-edge-3: Third+ edges (curves further out) + * + * @param cy - The Cytoscape instance to process + */ +export function applyEdgeBundling(cy: Core): void { + // Group edges by their source-target pair (undirected) + const edgeGroups = new Map(); + + cy.edges().forEach((edge) => { + const sourceId = edge.source().id(); + const targetId = edge.target().id(); + + // Create undirected key (smaller id first for consistency) + const key = sourceId < targetId + ? `${sourceId}|${targetId}` + : `${targetId}|${sourceId}`; + + if (!edgeGroups.has(key)) { + edgeGroups.set(key, []); + } + edgeGroups.get(key)!.push(edge); + }); + + // Apply classes to multi-edges within a batch for performance + cy.batch(() => { + edgeGroups.forEach((edges) => { + if (edges.length === 1) { + // Single edge - remove any multi-edge classes, use default bezier + edges[0].removeClass('multi-edge multi-edge-2 multi-edge-3 multi-edge-4'); + } else { + // Multiple edges - curve them away from each other + edges.forEach((edge, index) => { + // Clear existing multi-edge classes first + edge.removeClass('multi-edge multi-edge-2 multi-edge-3 multi-edge-4'); + + // Apply appropriate class based on position in the group + switch (index) { + case 0: + edge.addClass('multi-edge'); + break; + case 1: + edge.addClass('multi-edge-2'); + break; + case 2: + edge.addClass('multi-edge-3'); + break; + default: + // For 4+ edges, use the furthest curve class + edge.addClass('multi-edge-4'); + break; + } + }); + } + }); + }); +} + +/** + * Remove all edge bundling classes from the graph. + * Useful when resetting the graph or before reapplying bundling. + * + * @param cy - The Cytoscape instance to process + */ +export function clearEdgeBundling(cy: Core): void { + cy.batch(() => { + cy.edges().removeClass('multi-edge multi-edge-2 multi-edge-3 multi-edge-4'); + }); +} + +/** + * Calculate adaptive curve distance based on graph density. + * Denser graphs need tighter curves to avoid visual chaos, while sparse + * graphs can use wider curves for better readability. + * + * Graph density is calculated as: edges / (nodes * (nodes - 1) / 2) + * This represents the ratio of actual edges to maximum possible edges. + * + * @param nodeCount - Number of nodes in the graph + * @param edgeCount - Number of edges in the graph + * @returns Recommended curve control point distance in pixels + */ +export function calculateCurveDistance( + nodeCount: number, + edgeCount: number +): number { + // Avoid division by zero for tiny graphs + if (nodeCount < 2) return 40; + + // Calculate graph density (0 to 1 scale) + const maxPossibleEdges = (nodeCount * (nodeCount - 1)) / 2; + const density = maxPossibleEdges > 0 ? edgeCount / maxPossibleEdges : 0; + + // Return tighter curves for denser graphs + if (density > 0.3) return 25; // Very dense: tight curves + if (density > 0.15) return 35; // Moderately dense + if (density > 0.05) return 45; // Average density + return 55; // Sparse: wider curves for clarity +} + +/** + * Get statistics about multi-edges in the graph. + * Useful for displaying info to users or making layout decisions. + * + * @param cy - The Cytoscape instance to analyze + * @returns Object containing multi-edge statistics + */ +export function getMultiEdgeStats(cy: Core): { + totalEdges: number; + uniquePairs: number; + multiEdgePairs: number; + maxParallelEdges: number; + selfLoops: number; +} { + const edgeGroups = new Map(); + let selfLoops = 0; + + cy.edges().forEach((edge) => { + const sourceId = edge.source().id(); + const targetId = edge.target().id(); + + // Count self-loops separately + if (sourceId === targetId) { + selfLoops++; + return; + } + + // Create undirected key + const key = sourceId < targetId + ? `${sourceId}|${targetId}` + : `${targetId}|${sourceId}`; + + edgeGroups.set(key, (edgeGroups.get(key) || 0) + 1); + }); + + const counts = Array.from(edgeGroups.values()); + const multiEdgePairs = counts.filter(c => c > 1).length; + const maxParallelEdges = counts.length > 0 ? Math.max(...counts) : 0; + + return { + totalEdges: cy.edges().length, + uniquePairs: edgeGroups.size, + multiEdgePairs, + maxParallelEdges, + selfLoops, + }; +} + +/** + * Generate stylesheet entries for multi-edge styling. + * Returns an array of Cytoscape stylesheet objects that can be merged + * with the main stylesheet. + * + * @param curveDistance - Base distance for curve control points + * @returns Array of stylesheet entries for multi-edge styling + */ +export function getMultiEdgeStylesheet(curveDistance: number = 40) { + return [ + // Multi-edge handling (multiple edges between same node pair) + // Use unbundled-bezier to curve parallel edges away from each other + { + selector: 'edge.multi-edge', + style: { + 'curve-style': 'unbundled-bezier' as const, + 'control-point-distances': [curveDistance], + 'control-point-weights': [0.5], + }, + }, + + // Second parallel edge curves opposite direction + { + selector: 'edge.multi-edge-2', + style: { + 'curve-style': 'unbundled-bezier' as const, + 'control-point-distances': [-curveDistance], + 'control-point-weights': [0.5], + }, + }, + + // Third parallel edge (curves further out) + { + selector: 'edge.multi-edge-3', + style: { + 'curve-style': 'unbundled-bezier' as const, + 'control-point-distances': [curveDistance * 2], + 'control-point-weights': [0.5], + }, + }, + + // Fourth+ parallel edges + { + selector: 'edge.multi-edge-4', + style: { + 'curve-style': 'unbundled-bezier' as const, + 'control-point-distances': [-curveDistance * 2], + 'control-point-weights': [0.5], + }, + }, + + // Self-loops (entity references itself) + { + selector: 'edge[source = target]', + style: { + 'curve-style': 'bezier' as const, + 'loop-direction': '45deg', + 'loop-sweep': '-90deg', + 'control-point-step-size': 50, + }, + }, + ]; +} diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/entityIcons.ts b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/entityIcons.ts new file mode 100644 index 0000000..1d8bccc --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/entityIcons.ts @@ -0,0 +1,230 @@ +/** + * Entity type icons as SVG data URIs for Cytoscape.js + * + * These are embedded directly as data URIs - NO external requests required. + * This ensures 100% air-gap compatibility for offline OSINT deployments. + * + * Icons are based on Lucide icon paths, rendered as white strokes on + * colored backgrounds (the background color is set via stylesheet). + */ + +/** + * Creates an SVG data URI from a path definition + * @param pathD - The SVG path 'd' attribute(s) + * @param color - Stroke color (default white for visibility on colored backgrounds) + */ +const createSvgDataUri = (pathD: string | string[], color: string = '#ffffff'): string => { + const paths = Array.isArray(pathD) ? pathD : [pathD]; + const pathElements = paths.map(d => ``).join(''); + + const svg = `${pathElements}`; + + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +}; + +/** + * Entity type icons as SVG data URIs + * Keys match entity types from the backend (lowercase, normalized) + */ +export const ENTITY_ICONS: Record = { + // Person - User silhouette (Lucide: User) + person: createSvgDataUri([ + 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2', + 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z' + ]), + + // Organization - Building (Lucide: Building) + organization: createSvgDataUri([ + 'M3 21h18', + 'M9 8h1', + 'M9 12h1', + 'M9 16h1', + 'M14 8h1', + 'M14 12h1', + 'M14 16h1', + 'M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16' + ]), + + // Location - Map Pin (Lucide: MapPin) + location: createSvgDataUri([ + 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z', + 'M12 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6z' + ]), + + // GPE (Geopolitical Entity) - Same as location + gpe: createSvgDataUri([ + 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z', + 'M12 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6z' + ]), + + // Document - File (Lucide: FileText) + document: createSvgDataUri([ + 'M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z', + 'M14 2v6h6', + 'M16 13H8', + 'M16 17H8', + 'M10 9H8' + ]), + + // Event - Calendar (Lucide: Calendar) + event: createSvgDataUri([ + 'M8 2v4', + 'M16 2v4', + 'M3 10h18', + 'M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z' + ]), + + // Money/Financial - Dollar Sign (Lucide: DollarSign) + money: createSvgDataUri([ + 'M12 2v20', + 'M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' + ]), + + // Phone - Phone (Lucide: Phone) + phone: createSvgDataUri([ + 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z' + ]), + + // Email - Mail (Lucide: Mail) + email: createSvgDataUri([ + 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z', + 'M22 6l-10 7L2 6' + ]), + + // Claim - Target/Crosshairs (Lucide: Target) + claim: createSvgDataUri([ + 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z', + 'M12 6a6 6 0 1 0 0 12 6 6 0 0 0 0-12z', + 'M12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4z' + ]), + + // Evidence - Check/Verified (Lucide: CheckCircle) + evidence: createSvgDataUri([ + 'M9 11l3 3L22 4', + 'M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' + ]), + + // Hypothesis - Lightbulb (Lucide: Lightbulb) + hypothesis: createSvgDataUri([ + 'M9 18h6', + 'M10 22h4', + 'M12 2a7 7 0 0 0-4.5 12.4c.6.5 1 1.2 1.1 2 .1.8.1 1.6.1 1.6h6.6s0-.8.1-1.6c.1-.8.5-1.5 1.1-2A7 7 0 0 0 12 2z' + ]), + + // Account - User Circle (Lucide: UserCircle) + account: createSvgDataUri([ + 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z', + 'M12 8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z', + 'M6.168 18.849A4 4 0 0 1 10 16h4a4 4 0 0 1 3.834 2.855' + ]), + + // Vehicle - Car (Lucide: Car) + vehicle: createSvgDataUri([ + 'M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-3.4-5.1a2 2 0 0 0-1.7-.9H7a2 2 0 0 0-2 2v10c0 .6.4 1 1 1h2', + 'M7 17a2 2 0 1 0 4 0 2 2 0 0 0-4 0z', + 'M15 17a2 2 0 1 0 4 0 2 2 0 0 0-4 0z' + ]), + + // Property - Home (Lucide: Home) + property: createSvgDataUri([ + 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z', + 'M9 22V12h6v10' + ]), + + // Product - Package (Lucide: Package) + product: createSvgDataUri([ + 'M16.5 9.4l-9-5.19', + 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z', + 'M3.27 6.96L12 12.01l8.73-5.05', + 'M12 22.08V12' + ]), + + // Date/Time - Clock (Lucide: Clock) + date: createSvgDataUri([ + 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z', + 'M12 6v6l4 2' + ]), + + // Unknown/Fallback - Help Circle (Lucide: HelpCircle) + unknown: createSvgDataUri([ + 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z', + 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3', + 'M12 17h.01' + ]), +}; + +/** + * Entity background colors + * These should match the existing ENTITY_TYPE_COLORS in the application + */ +export const ENTITY_COLORS: Record = { + person: '#4299e1', // Blue + organization: '#48bb78', // Green + location: '#ed8936', // Orange + gpe: '#ed8936', // Orange (same as location) + event: '#9f7aea', // Purple + document: '#f56565', // Red + money: '#68d391', // Light green + phone: '#63b3ed', // Light blue + email: '#63b3ed', // Light blue + claim: '#f59e0b', // Amber + evidence: '#3b82f6', // Blue + hypothesis: '#8b5cf6', // Purple + account: '#06b6d4', // Cyan + vehicle: '#78716c', // Gray + property: '#a855f7', // Purple + product: '#14b8a6', // Teal + date: '#eab308', // Yellow + unknown: '#718096', // Gray +}; + +/** + * Get icon for entity type (with fallback) + */ +export function getEntityIcon(type: string | undefined): string { + if (!type) return ENTITY_ICONS.unknown; + const normalized = type.toLowerCase().replace(/-/g, '_'); + return ENTITY_ICONS[normalized] || ENTITY_ICONS.unknown; +} + +/** + * Get color for entity type (with fallback) + */ +export function getEntityColor(type: string | undefined): string { + if (!type) return ENTITY_COLORS.unknown; + const normalized = type.toLowerCase().replace(/-/g, '_'); + return ENTITY_COLORS[normalized] || ENTITY_COLORS.unknown; +} + +/** + * Entity type display labels + */ +export const ENTITY_LABELS: Record = { + person: 'Person', + organization: 'Organization', + location: 'Location', + gpe: 'Geopolitical Entity', + event: 'Event', + document: 'Document', + money: 'Financial', + phone: 'Phone', + email: 'Email', + claim: 'Claim', + evidence: 'Evidence', + hypothesis: 'Hypothesis', + account: 'Account', + vehicle: 'Vehicle', + property: 'Property', + product: 'Product', + date: 'Date/Time', + unknown: 'Unknown', +}; + +/** + * Get display label for entity type + */ +export function getEntityLabel(type: string | undefined): string { + if (!type) return ENTITY_LABELS.unknown; + const normalized = type.toLowerCase().replace(/-/g, '_'); + return ENTITY_LABELS[normalized] || type; +} diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/index.ts b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/index.ts new file mode 100644 index 0000000..b885dda --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/index.ts @@ -0,0 +1,90 @@ +/** + * Cytoscape components barrel export + * + * Exports all Cytoscape-related UI components for the Graph XPAC. + */ + +// ============================================ +// CORE COMPONENT +// ============================================ + +export { CytoscapeGraph, default as CytoscapeGraphDefault } from './CytoscapeGraph'; +export type { CytoscapeGraphProps, CytoscapeGraphRef, GraphNode, GraphEdge } from './CytoscapeGraph'; + +// ============================================ +// UI COMPONENTS +// ============================================ + +export { CytoscapeControls } from './CytoscapeControls'; +export type { CytoscapeControlsProps } from './CytoscapeControls'; + +export { PerformanceModeToggle } from './PerformanceModeToggle'; +export type { + PerformanceModeToggleProps, + PerformanceModeSettings, +} from './PerformanceModeToggle'; + +export { ExpandNeighborsPanel } from './ExpandNeighborsPanel'; +export type { + ExpandNeighborsPanelProps, + ExpandState, +} from './ExpandNeighborsPanel'; + +export { CytoscapeTooltip } from './CytoscapeTooltip'; +export type { CytoscapeTooltipProps } from './CytoscapeTooltip'; + +// ============================================ +// HOOKS +// ============================================ + +export { useExpandNeighbors } from './useExpandNeighbors'; +export type { + ExpandNeighborsOptions, + ExpandState as UseExpandNeighborsState, + UseExpandNeighborsReturn, +} from './useExpandNeighbors'; +export { getExpandNeighborsStylesheet } from './useExpandNeighbors'; + +export { + usePerformanceMode, + DEFAULT_PERFORMANCE_SETTINGS, + AGGRESSIVE_PERFORMANCE_SETTINGS, + getPerformanceModeStylesheet, + getRecommendedSettings, +} from './usePerformanceMode'; +export type { + PerformanceModeSettings as UsePerformanceModeSettings, + PerformanceSettings, + UsePerformanceModeReturn, +} from './usePerformanceMode'; + +// ============================================ +// STYLES & CONFIGURATION +// ============================================ + +export { createStylesheet, createPerformanceStylesheet, defaultStylesheet } from './CytoscapeStylesheet'; +export { + LAYOUT_CONFIGS, + ANALYSIS_LAYOUTS, + LAYOUT_INFO, + getLayoutConfig, + getLayoutInfo, + createLayoutConfig, + getAvailableLayouts, + getLayoutsByCategory, +} from './CytoscapeLayouts'; +export type { LayoutInfo } from './CytoscapeLayouts'; + +export { + ENTITY_ICONS, + ENTITY_COLORS, + ENTITY_LABELS, + getEntityIcon, + getEntityColor, + getEntityLabel, +} from './entityIcons'; + +export { applyEdgeBundling, calculateCurveDistance } from './edgeBundling'; + +// Styles - import this in the main CytoscapeGraph component +// import './CytoscapeGraph.css'; diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/useExpandNeighbors.ts b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/useExpandNeighbors.ts new file mode 100644 index 0000000..1c1eac3 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/useExpandNeighbors.ts @@ -0,0 +1,542 @@ +/** + * Progressive Disclosure / Expand Neighbors Hook for Cytoscape.js + * + * The core OSINT workflow: start with a single entity (or small set of seed nodes), + * then progressively expand the network to discover connections. This hook manages + * expansion state, tracks depth from seed nodes, and handles the visual animation + * of new nodes appearing in the graph. + */ + +import { useState, useCallback, useRef } from 'react'; +import type { Core, NodeSingular, EdgeSingular, Position } from 'cytoscape'; + +/** + * Represents a node in the graph + */ +export interface GraphNode { + id: string; + label: string; + type?: string; + entity_type?: string; + degree?: number; + document_count?: number; + [key: string]: unknown; +} + +/** + * Represents an edge in the graph + */ +export interface GraphEdge { + source: string | { id: string }; + target: string | { id: string }; + relationship_type?: string; + type?: string; + weight?: number; + [key: string]: unknown; +} + +/** + * State tracking for node expansion + */ +export interface ExpandState { + /** Set of node IDs that have been expanded (their neighbors fetched) */ + expandedNodes: Set; + /** Map of node ID to expansion depth (hops from nearest seed node) */ + expansionDepth: Map; + /** Set of original query/seed node IDs */ + seedNodes: Set; + /** Maximum depth reached in the current expansion */ + maxDepth: number; +} + +/** + * Options for the useExpandNeighbors hook + */ +export interface ExpandNeighborsOptions { + /** + * Callback to fetch neighbors for a given node from the backend. + * Should return the neighboring nodes and edges. + */ + onFetchNeighbors: (nodeId: string) => Promise<{ + nodes: GraphNode[]; + edges: GraphEdge[]; + }>; + /** Maximum expansion depth allowed (default: 3) */ + maxExpandDepth?: number; + /** Whether to animate new nodes appearing (default: true) */ + animateExpansion?: boolean; + /** Animation duration in milliseconds (default: 500) */ + animationDuration?: number; +} + +/** + * Return type for the useExpandNeighbors hook + */ +export interface UseExpandNeighborsReturn { + /** Current expansion state */ + expandState: ExpandState; + /** Whether an expansion operation is in progress */ + isExpanding: boolean; + /** Mark initial seed nodes (from original query) */ + setSeedNodes: (nodeIds: string[]) => void; + /** Expand a node's neighbors */ + expandNode: (nodeId: string) => Promise; + /** Collapse a node (hide expanded neighbors) */ + collapseNode: (nodeId: string) => void; + /** Check if a node can be expanded */ + canExpand: (nodeId: string) => boolean; + /** Get expansion indicator for a node */ + getExpansionIndicator: (nodeId: string) => 'expandable' | 'expanded' | 'max-depth' | 'seed'; + /** Reset all expansion state */ + resetExpansion: () => void; + /** Get all nodes at a specific depth */ + getNodesAtDepth: (depth: number) => string[]; +} + +/** + * Initial empty expansion state + */ +const INITIAL_EXPAND_STATE: ExpandState = { + expandedNodes: new Set(), + expansionDepth: new Map(), + seedNodes: new Set(), + maxDepth: 0, +}; + +/** + * Hook for managing progressive disclosure / expand neighbors workflow. + * + * @param cy - Cytoscape Core instance (can be null during initialization) + * @param options - Configuration options + * @returns Expansion state and control functions + * + * @example + * ```tsx + * const { + * expandState, + * isExpanding, + * setSeedNodes, + * expandNode, + * collapseNode, + * canExpand, + * getExpansionIndicator, + * } = useExpandNeighbors(cyRef.current, { + * onFetchNeighbors: async (nodeId) => { + * const response = await fetch(`/api/graph/neighbors/${nodeId}`); + * return response.json(); + * }, + * maxExpandDepth: 3, + * }); + * + * // Mark initial query results as seeds + * useEffect(() => { + * if (initialNodes.length > 0) { + * setSeedNodes(initialNodes.map(n => n.id)); + * } + * }, [initialNodes]); + * ``` + */ +export function useExpandNeighbors( + cy: Core | null, + options: ExpandNeighborsOptions +): UseExpandNeighborsReturn { + const { + onFetchNeighbors, + maxExpandDepth = 3, + animateExpansion = true, + animationDuration = 500, + } = options; + + const [expandState, setExpandState] = useState(INITIAL_EXPAND_STATE); + const [isExpanding, setIsExpanding] = useState(false); + + // Use ref to track the latest state in async operations + const expandStateRef = useRef(expandState); + expandStateRef.current = expandState; + + /** + * Mark initial seed nodes (from the original query). + * These are the starting points for expansion, shown with special styling. + */ + const setSeedNodes = useCallback((nodeIds: string[]) => { + const seedSet = new Set(nodeIds); + const depthMap = new Map(); + + // All seed nodes start at depth 0 + nodeIds.forEach((id) => { + depthMap.set(id, 0); + }); + + setExpandState({ + seedNodes: seedSet, + expandedNodes: new Set(), // Seeds are not yet expanded + expansionDepth: depthMap, + maxDepth: 0, + }); + + // Add visual indicator for seed nodes + if (cy) { + cy.batch(() => { + // Remove seed class from all nodes first + cy.nodes().removeClass('seed-node'); + // Add seed class to specified nodes + nodeIds.forEach((id) => { + const node = cy.$id(id); + if (node.length > 0) { + node.addClass('seed-node expandable'); + } + }); + }); + } + }, [cy]); + + /** + * Expand a single node's neighbors. + * Fetches neighbors from the backend, adds them to the graph, and animates the layout. + */ + const expandNode = useCallback(async (nodeId: string) => { + if (!cy || isExpanding) return; + + const currentState = expandStateRef.current; + const currentDepth = currentState.expansionDepth.get(nodeId) ?? 0; + + // Check if max depth reached + if (currentDepth >= maxExpandDepth) { + console.warn(`Max expansion depth (${maxExpandDepth}) reached for node ${nodeId}`); + return; + } + + // Check if already expanded + if (currentState.expandedNodes.has(nodeId)) { + console.warn(`Node ${nodeId} is already expanded`); + return; + } + + setIsExpanding(true); + + try { + // Fetch neighbors from backend + const { nodes: newNodes, edges: newEdges } = await onFetchNeighbors(nodeId); + + // Filter out already-existing nodes + const existingNodeIds = new Set(cy.nodes().map((n) => n.id())); + const nodesToAdd = newNodes.filter((n) => !existingNodeIds.has(n.id)); + + // Filter edges - only add edges where at least one endpoint will exist + const futureNodeIds = new Set([...existingNodeIds, ...nodesToAdd.map((n) => n.id)]); + const edgesToAdd = newEdges.filter((e) => { + const sourceId = typeof e.source === 'string' ? e.source : e.source.id; + const targetId = typeof e.target === 'string' ? e.target : e.target.id; + return futureNodeIds.has(sourceId) && futureNodeIds.has(targetId); + }); + + // Get source node position for animation + const sourceNode = cy.$id(nodeId); + const sourcePos: Position = sourceNode.length > 0 + ? sourceNode.position() + : { x: 0, y: 0 }; + + // Add new elements to the graph + cy.batch(() => { + // Add nodes positioned around the expanded node + nodesToAdd.forEach((node, i) => { + const angle = (2 * Math.PI * i) / Math.max(nodesToAdd.length, 1); + const radius = 80; // Initial placement radius + + cy.add({ + group: 'nodes', + data: { + id: node.id, + label: node.label, + type: node.type || node.entity_type || 'unknown', + degree: node.degree || 0, + documentCount: node.document_count || 0, + }, + position: { + x: sourcePos.x + radius * Math.cos(angle), + y: sourcePos.y + radius * Math.sin(angle), + }, + classes: 'newly-expanded expandable', + }); + }); + + // Add edges + edgesToAdd.forEach((edge, index) => { + const sourceId = typeof edge.source === 'string' ? edge.source : edge.source.id; + const targetId = typeof edge.target === 'string' ? edge.target : edge.target.id; + + cy.add({ + group: 'edges', + data: { + id: `edge-expand-${Date.now()}-${index}`, + source: sourceId, + target: targetId, + label: edge.relationship_type || edge.type || 'related', + weight: edge.weight || 1, + type: edge.relationship_type || edge.type || 'co_occurrence', + }, + classes: 'newly-expanded', + }); + }); + + // Mark source node as expanded + sourceNode.addClass('expanded').removeClass('expandable'); + }); + + // Update expansion state + const newDepth = currentDepth + 1; + setExpandState((prev) => { + const newExpanded = new Set(prev.expandedNodes); + newExpanded.add(nodeId); + + const newDepthMap = new Map(prev.expansionDepth); + nodesToAdd.forEach((n) => { + // Only set depth if not already set (preserve original depth) + if (!newDepthMap.has(n.id)) { + newDepthMap.set(n.id, newDepth); + } + }); + + return { + ...prev, + expandedNodes: newExpanded, + expansionDepth: newDepthMap, + maxDepth: Math.max(prev.maxDepth, newDepth), + }; + }); + + // Animate layout to incorporate new nodes + if (animateExpansion && nodesToAdd.length > 0) { + // Get positions of existing nodes to keep them fixed + const fixedNodes = cy.nodes().not('.newly-expanded'); + const fixedPositions = fixedNodes.map((n: NodeSingular) => ({ + nodeId: n.id(), + position: n.position(), + })); + + // Run incremental layout + const layout = cy.layout({ + name: 'fcose', + animate: true, + animationDuration, + randomize: false, + fit: false, + // Keep existing nodes in place + fixedNodeConstraint: fixedPositions, + // Layout parameters + nodeRepulsion: () => 4500, + idealEdgeLength: () => 80, + edgeElasticity: () => 0.45, + numIter: 1000, + } as any); + + layout.run(); + + // Remove temporary class after animation + setTimeout(() => { + cy.$('.newly-expanded').removeClass('newly-expanded'); + }, animationDuration + 100); + } else { + // Remove class immediately if not animating + cy.$('.newly-expanded').removeClass('newly-expanded'); + } + + // Mark as fully expanded if no new nodes were added + if (nodesToAdd.length === 0) { + cy.$id(nodeId).addClass('fully-expanded'); + } + } catch (error) { + console.error('Failed to expand node:', error); + // Re-enable expand capability on error + cy.$id(nodeId).removeClass('expanded').addClass('expandable'); + } finally { + setIsExpanding(false); + } + }, [cy, isExpanding, maxExpandDepth, onFetchNeighbors, animateExpansion, animationDuration]); + + /** + * Collapse a node (hide its expanded neighbors). + * Only hides nodes that were added as a direct result of expanding this node. + */ + const collapseNode = useCallback((nodeId: string) => { + if (!cy) return; + + const currentState = expandStateRef.current; + const nodeDepth = currentState.expansionDepth.get(nodeId) ?? 0; + + // Find nodes that should be hidden (connected to this node with higher depth) + const nodesToHide: string[] = []; + + cy.nodes().forEach((node: NodeSingular) => { + const depth = currentState.expansionDepth.get(node.id()); + if (depth !== undefined && depth > nodeDepth) { + // Check if this node is directly connected to the collapsing node + const connectedEdges = node.connectedEdges(); + let isConnected = false; + connectedEdges.forEach((edge: EdgeSingular) => { + if (edge.source().id() === nodeId || edge.target().id() === nodeId) { + isConnected = true; + } + }); + if (isConnected) { + nodesToHide.push(node.id()); + } + } + }); + + // Hide nodes and update visual state + cy.batch(() => { + nodesToHide.forEach((id) => { + cy.$id(id).addClass('collapsed-neighbor'); + }); + cy.$id(nodeId).removeClass('expanded fully-expanded').addClass('expandable'); + }); + + // Update state to mark node as no longer expanded + setExpandState((prev) => { + const newExpanded = new Set(prev.expandedNodes); + newExpanded.delete(nodeId); + + return { + ...prev, + expandedNodes: newExpanded, + }; + }); + }, [cy]); + + /** + * Check if a node can be expanded (not at max depth and not already expanded). + */ + const canExpand = useCallback((nodeId: string): boolean => { + const depth = expandState.expansionDepth.get(nodeId) ?? 0; + const isExpanded = expandState.expandedNodes.has(nodeId); + return depth < maxExpandDepth && !isExpanded; + }, [expandState, maxExpandDepth]); + + /** + * Get the expansion indicator for a node. + * Used to determine visual styling and UI state. + */ + const getExpansionIndicator = useCallback((nodeId: string): 'expandable' | 'expanded' | 'max-depth' | 'seed' => { + const isSeed = expandState.seedNodes.has(nodeId); + const isExpanded = expandState.expandedNodes.has(nodeId); + const depth = expandState.expansionDepth.get(nodeId) ?? 0; + + if (isExpanded) return 'expanded'; + if (depth >= maxExpandDepth) return 'max-depth'; + if (isSeed && depth === 0) return 'seed'; + return 'expandable'; + }, [expandState, maxExpandDepth]); + + /** + * Reset all expansion state to initial values. + */ + const resetExpansion = useCallback(() => { + setExpandState(INITIAL_EXPAND_STATE); + + if (cy) { + cy.batch(() => { + cy.nodes().removeClass('seed-node expanded expandable fully-expanded collapsed-neighbor newly-expanded'); + cy.edges().removeClass('newly-expanded'); + }); + } + }, [cy]); + + /** + * Get all node IDs at a specific depth. + * Useful for highlighting or filtering by expansion depth. + */ + const getNodesAtDepth = useCallback((depth: number): string[] => { + const result: string[] = []; + expandState.expansionDepth.forEach((d, nodeId) => { + if (d === depth) { + result.push(nodeId); + } + }); + return result; + }, [expandState]); + + return { + expandState, + isExpanding, + setSeedNodes, + expandNode, + collapseNode, + canExpand, + getExpansionIndicator, + resetExpansion, + getNodesAtDepth, + }; +} + +/** + * Generate stylesheet entries for progressive disclosure styling. + * These should be merged with the main Cytoscape stylesheet. + */ +export function getExpandNeighborsStylesheet() { + return [ + // Seed nodes (original query results) - prominent styling + { + selector: 'node.seed-node', + style: { + 'border-width': 4, + 'border-color': '#f59e0b', // Amber + 'border-style': 'double', + }, + }, + + // Expandable nodes (have unexplored neighbors) + { + selector: 'node.expandable', + style: { + // Add pulsing glow to indicate expandability + 'overlay-color': '#4299e1', + 'overlay-opacity': 0.15, + 'overlay-padding': 4, + }, + }, + + // Expanded nodes (neighbors already fetched) + { + selector: 'node.expanded', + style: { + 'border-style': 'solid', + 'overlay-opacity': 0, + }, + }, + + // Fully expanded (no more neighbors to fetch) + { + selector: 'node.fully-expanded', + style: { + 'opacity': 0.7, + 'border-style': 'dotted', + }, + }, + + // Newly expanded nodes (animation state) + { + selector: 'node.newly-expanded', + style: { + // Animation is handled via JS, but we can set initial state + 'opacity': 0.8, + }, + }, + + // Collapsed neighbors (hidden but not removed) + { + selector: 'node.collapsed-neighbor', + style: { + 'display': 'none', + }, + }, + + // Newly expanded edges + { + selector: 'edge.newly-expanded', + style: { + 'opacity': 0.8, + }, + }, + ]; +} diff --git a/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/usePerformanceMode.ts b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/usePerformanceMode.ts new file mode 100644 index 0000000..7a35950 --- /dev/null +++ b/packages/arkham-shard-shell/src/pages/graph/components/cytoscape/usePerformanceMode.ts @@ -0,0 +1,340 @@ +/** + * Performance Mode Hook for Cytoscape.js + * + * Provides adaptive rendering settings for large graphs (500+ nodes). + * Automatically detects when performance mode should be suggested and + * applies optimizations to maintain interactive frame rates. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { Core } from 'cytoscape'; + +/** + * Individual performance optimization settings + */ +export interface PerformanceSettings { + /** Hide node labels to reduce text rendering overhead */ + hideLabels: boolean; + /** Hide edge labels (relationship types) */ + hideEdgeLabels: boolean; + /** Use haystack curve style instead of bezier for faster edge rendering */ + simplifyEdges: boolean; + /** Reduce node visual complexity (overlays, shadows) */ + reduceNodeDetail: boolean; + /** Use lower pixel ratio for canvas rendering */ + lowerPixelRatio: boolean; + /** Temporarily hide edges during pan/zoom operations */ + hideEdgesOnPan: boolean; +} + +/** + * Complete performance mode configuration + */ +export interface PerformanceModeSettings { + /** Whether performance mode is currently active */ + enabled: boolean; + /** Node count threshold for auto-suggesting performance mode */ + nodeThreshold: number; + /** Individual optimization settings */ + settings: PerformanceSettings; +} + +/** + * Return type for the usePerformanceMode hook + */ +export interface UsePerformanceModeReturn { + /** Current performance mode settings */ + performanceMode: PerformanceModeSettings; + /** Update performance mode settings (partial updates supported) */ + setPerformanceMode: (settings: Partial) => void; + /** Update individual performance settings */ + updateSettings: (settings: Partial) => void; + /** Whether auto-detection suggests enabling performance mode */ + autoDetectedMode: boolean; + /** Reset to default settings */ + resetToDefaults: () => void; +} + +/** + * Default performance mode settings + * Balanced defaults that preserve some visual quality while improving performance + */ +export const DEFAULT_PERFORMANCE_SETTINGS: PerformanceModeSettings = { + enabled: false, + nodeThreshold: 500, + settings: { + hideLabels: false, + hideEdgeLabels: true, + simplifyEdges: true, + reduceNodeDetail: true, + lowerPixelRatio: true, + hideEdgesOnPan: true, + }, +}; + +/** + * Aggressive performance settings for very large graphs (1000+ nodes) + */ +export const AGGRESSIVE_PERFORMANCE_SETTINGS: PerformanceModeSettings = { + enabled: true, + nodeThreshold: 1000, + settings: { + hideLabels: true, + hideEdgeLabels: true, + simplifyEdges: true, + reduceNodeDetail: true, + lowerPixelRatio: true, + hideEdgesOnPan: true, + }, +}; + +/** + * Hook for managing performance mode in Cytoscape graphs. + * + * @param cy - Cytoscape Core instance (can be null during initialization) + * @param nodeCount - Current number of nodes in the graph + * @returns Performance mode state and control functions + * + * @example + * ```tsx + * const { performanceMode, setPerformanceMode, autoDetectedMode } = usePerformanceMode( + * cyRef.current, + * nodes.length + * ); + * + * // Show suggestion to user + * if (autoDetectedMode && !performanceMode.enabled) { + * showToast('Large graph detected. Enable performance mode?'); + * } + * ``` + */ +export function usePerformanceMode( + cy: Core | null, + nodeCount: number +): UsePerformanceModeReturn { + const [performanceMode, setPerformanceModeState] = useState( + DEFAULT_PERFORMANCE_SETTINGS + ); + + // Track event handlers for cleanup + const panZoomHandlerRef = useRef<(() => void) | null>(null); + const panZoomEndHandlerRef = useRef<(() => void) | null>(null); + + // Auto-detect when performance mode should be suggested + const autoDetectedMode = nodeCount > performanceMode.nodeThreshold; + + /** + * Apply performance optimizations to the Cytoscape instance + */ + useEffect(() => { + if (!cy) return; + + // Clean up previous event handlers + if (panZoomHandlerRef.current) { + cy.off('pan zoom', panZoomHandlerRef.current); + panZoomHandlerRef.current = null; + } + if (panZoomEndHandlerRef.current) { + cy.off('panend zoomend', panZoomEndHandlerRef.current); + panZoomEndHandlerRef.current = null; + } + + if (performanceMode.enabled) { + const { settings } = performanceMode; + + cy.batch(() => { + // Simplify edges to haystack for faster rendering + if (settings.simplifyEdges) { + cy.edges().addClass('performance-mode'); + } + + // Hide node labels + if (settings.hideLabels) { + cy.nodes().addClass('performance-no-label'); + } + + // Hide edge labels + if (settings.hideEdgeLabels) { + cy.edges().addClass('performance-no-label'); + } + + // Reduce node visual detail + if (settings.reduceNodeDetail) { + cy.nodes().addClass('performance-reduced-detail'); + } + }); + + // Enable viewport optimizations - hide edges during pan/zoom + if (settings.hideEdgesOnPan) { + const onPanZoom = () => { + cy.edges().style('opacity', 0.15); + }; + + const onPanZoomEnd = () => { + cy.edges().style('opacity', 1); + }; + + panZoomHandlerRef.current = onPanZoom; + panZoomEndHandlerRef.current = onPanZoomEnd; + + cy.on('pan zoom', onPanZoom); + cy.on('panend zoomend', onPanZoomEnd); + } + + // Lower pixel ratio for faster canvas rendering + if (settings.lowerPixelRatio) { + // Note: pixelRatio is set at initialization, but we can force a resize + // which may help with some rendering optimizations + cy.resize(); + } + } else { + // Restore full quality rendering + cy.batch(() => { + cy.edges().removeClass('performance-mode performance-no-label'); + cy.nodes().removeClass('performance-no-label performance-reduced-detail'); + // Reset any inline styles + cy.edges().removeStyle('opacity'); + }); + } + + // Cleanup function + return () => { + if (cy && panZoomHandlerRef.current) { + cy.off('pan zoom', panZoomHandlerRef.current); + } + if (cy && panZoomEndHandlerRef.current) { + cy.off('panend zoomend', panZoomEndHandlerRef.current); + } + }; + }, [cy, performanceMode]); + + /** + * Update performance mode settings (supports partial updates) + */ + const setPerformanceMode = useCallback((updates: Partial) => { + setPerformanceModeState((prev) => ({ + ...prev, + ...updates, + settings: updates.settings + ? { ...prev.settings, ...updates.settings } + : prev.settings, + })); + }, []); + + /** + * Update individual performance settings + */ + const updateSettings = useCallback((settings: Partial) => { + setPerformanceModeState((prev) => ({ + ...prev, + settings: { ...prev.settings, ...settings }, + })); + }, []); + + /** + * Reset to default settings + */ + const resetToDefaults = useCallback(() => { + setPerformanceModeState(DEFAULT_PERFORMANCE_SETTINGS); + }, []); + + return { + performanceMode, + setPerformanceMode, + updateSettings, + autoDetectedMode, + resetToDefaults, + }; +} + +/** + * Generate stylesheet entries for performance mode. + * These should be merged with the main Cytoscape stylesheet. + */ +export function getPerformanceModeStylesheet() { + return [ + // Simplified edge rendering using haystack (straight lines) + { + selector: 'edge.performance-mode', + style: { + 'curve-style': 'haystack' as const, + 'haystack-radius': 0.5, + 'line-opacity': 0.6, + // Labels already hidden via class + }, + }, + + // Hide labels on nodes + { + selector: 'node.performance-no-label', + style: { + 'label': '', + 'text-opacity': 0, + }, + }, + + // Hide labels on edges + { + selector: 'edge.performance-no-label', + style: { + 'label': '', + 'text-opacity': 0, + }, + }, + + // Reduced visual detail for nodes + { + selector: 'node.performance-reduced-detail', + style: { + 'overlay-opacity': 0, + 'border-width': 1, + // Disable background image for maximum performance + // 'background-image': 'none', + }, + }, + ]; +} + +/** + * Get recommended performance settings based on graph size + */ +export function getRecommendedSettings(nodeCount: number): PerformanceModeSettings { + if (nodeCount > 2000) { + // Very large graph - enable all optimizations + return { + ...AGGRESSIVE_PERFORMANCE_SETTINGS, + enabled: true, + }; + } else if (nodeCount > 1000) { + // Large graph - enable most optimizations + return { + enabled: true, + nodeThreshold: 500, + settings: { + hideLabels: true, + hideEdgeLabels: true, + simplifyEdges: true, + reduceNodeDetail: true, + lowerPixelRatio: true, + hideEdgesOnPan: true, + }, + }; + } else if (nodeCount > 500) { + // Medium-large graph - enable some optimizations + return { + enabled: true, + nodeThreshold: 500, + settings: { + hideLabels: false, + hideEdgeLabels: true, + simplifyEdges: true, + reduceNodeDetail: false, + lowerPixelRatio: true, + hideEdgesOnPan: true, + }, + }; + } + + // Small graph - no optimizations needed + return DEFAULT_PERFORMANCE_SETTINGS; +} diff --git a/packages/arkham-shard-shell/src/types/cytoscape-extensions.d.ts b/packages/arkham-shard-shell/src/types/cytoscape-extensions.d.ts new file mode 100644 index 0000000..e2c30fe --- /dev/null +++ b/packages/arkham-shard-shell/src/types/cytoscape-extensions.d.ts @@ -0,0 +1,139 @@ +/** + * Type declarations for Cytoscape.js extension packages + * These packages don't have @types definitions available + */ + +declare module 'cytoscape-fcose' { + import cytoscape from 'cytoscape'; + + const fcose: cytoscape.Ext; + export default fcose; + + export interface FcoseLayoutOptions extends cytoscape.BaseLayoutOptions { + name: 'fcose'; + /** Whether to fit the viewport to the graph */ + fit?: boolean; + /** Padding around the graph when fit=true */ + padding?: number; + /** Whether to animate the layout */ + animate?: boolean; + /** Duration of animation in ms */ + animationDuration?: number; + /** Easing of animation */ + animationEasing?: string; + /** Whether to randomize node positions initially */ + randomize?: boolean; + /** Node repulsion multiplier */ + nodeRepulsion?: number | ((node: any) => number); + /** Ideal edge length */ + idealEdgeLength?: number | ((edge: any) => number); + /** Edge elasticity multiplier */ + edgeElasticity?: number | ((edge: any) => number); + /** Number of iterations */ + numIter?: number; + /** Whether to use multi-level scaling */ + useMultiLevelScaling?: boolean; + /** Nesting factor */ + nestingFactor?: number; + /** Gravity force */ + gravity?: number; + /** Gravity range */ + gravityRange?: number; + /** Gravity compound */ + gravityCompound?: number; + /** Gravity range for compound nodes */ + gravityRangeCompound?: number; + /** Initial energy on incremental */ + initialEnergyOnIncremental?: number; + /** Fixed node constraint */ + fixedNodeConstraint?: Array<{ nodeId: string; position: { x: number; y: number } }>; + /** Alignment constraint */ + alignmentConstraint?: { vertical?: string[][]; horizontal?: string[][] }; + /** Relative placement constraint */ + relativePlacementConstraint?: Array<{ top?: string; left?: string; bottom?: string; right?: string; gap?: number }>; + /** Quality: 'draft' | 'default' | 'proof' */ + quality?: 'draft' | 'default' | 'proof'; + /** Tile padding */ + tile?: boolean; + /** Tile padding horizontal */ + tilingPaddingHorizontal?: number; + /** Tile padding vertical */ + tilingPaddingVertical?: number; + /** Separate connected components */ + packComponents?: boolean; + /** Component spacing */ + componentSpacing?: number; + /** Sampling type */ + samplingType?: boolean; + /** Sample size */ + sampleSize?: number; + /** Node dimensions include labels */ + nodeDimensionsIncludeLabels?: boolean; + /** Simple nodes only */ + simpleNodes?: boolean; + } +} + +declare module 'cytoscape-dagre' { + import cytoscape from 'cytoscape'; + + const dagre: cytoscape.Ext; + export default dagre; + + export interface DagreLayoutOptions extends cytoscape.BaseLayoutOptions { + name: 'dagre'; + /** Whether to fit the viewport to the graph */ + fit?: boolean; + /** Padding around the graph when fit=true */ + padding?: number; + /** Whether to animate the layout */ + animate?: boolean; + /** Duration of animation in ms */ + animationDuration?: number; + /** Easing of animation */ + animationEasing?: string; + /** Whether to use bounding box */ + boundingBox?: { x1: number; y1: number; w: number; h: number } | { x1: number; y1: number; x2: number; y2: number }; + /** Dagre layout options */ + rankDir?: 'TB' | 'BT' | 'LR' | 'RL'; + /** Alignment of nodes within ranks */ + align?: 'UL' | 'UR' | 'DL' | 'DR'; + /** Rank separation */ + rankSep?: number; + /** Node separation */ + nodeSep?: number; + /** Edge separation */ + edgeSep?: number; + /** Ranker algorithm */ + ranker?: 'network-simplex' | 'tight-tree' | 'longest-path'; + /** Minimum length of edge */ + minLen?: (edge: any) => number; + /** Edge weight */ + edgeWeight?: (edge: any) => number; + /** Node dimensions include labels */ + nodeDimensionsIncludeLabels?: boolean; + /** Spacer between components */ + spacingFactor?: number; + } +} + +declare module 'cytoscape-expand-collapse' { + import cytoscape from 'cytoscape'; + + const expandCollapse: cytoscape.Ext; + export default expandCollapse; +} + +declare module 'cytoscape-context-menus' { + import cytoscape from 'cytoscape'; + + const contextMenus: cytoscape.Ext; + export default contextMenus; +} + +declare module 'cytoscape-svg' { + import cytoscape from 'cytoscape'; + + const svg: cytoscape.Ext; + export default svg; +} diff --git a/packages/arkham-shard-shell/vite.config.ts b/packages/arkham-shard-shell/vite.config.ts index 9e62bc5..f5aa9fb 100644 --- a/packages/arkham-shard-shell/vite.config.ts +++ b/packages/arkham-shard-shell/vite.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ plugins: [react()], server: { port: 5173, + host: true, + allowedHosts: ['host.docker.internal', 'localhost'], proxy: { '/api': { target: 'http://localhost:8100',