feat: new graph mode setup (#234)

* feat: improve the logic for node width

* feat: use dagre to display loops

* chore: use animated dots for loops

* feat: create graph using cytoscape

* feat: use cytoscape html label

* feat: setup dynamic label height and width

* feat: set reasonable zoom levels

* feat: use theme colors for nodes

* feat: use mutation observer to change color schemes

* feat: implement dark mode with pure css

* chore: remove unused libraries

* fix: sanitize label with dompurify
This commit is contained in:
Dani Akash
2026-01-15 19:30:11 +05:30
committed by GitHub
parent 5bd45f0658
commit 9bb0686865
3 changed files with 341 additions and 180 deletions

View File

@@ -1,19 +1,8 @@
import {
Background,
BackgroundVariant,
ControlButton,
Controls,
type Edge,
MiniMap,
type Node,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import dagre from 'dagre'
import cytoscape from 'cytoscape'
import dagre from 'cytoscape-dagre'
// @ts-expect-error no types available
import nodeHtmlLabel from 'cytoscape-node-html-label'
import DOMPurify from 'dompurify'
import {
ArrowLeft,
Maximize,
@@ -24,7 +13,7 @@ import {
Save,
} from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import useDeepCompareEffect from 'use-deep-compare-effect'
import ProductLogo from '@/assets/product_logo.svg'
@@ -35,19 +24,75 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import type { GraphData } from './CreateGraph'
import { CustomNode, type NodeType } from './CustomNode'
import type { NodeType } from './CustomNode'
const nodeTypes: Record<NodeType, typeof CustomNode> = {
start: CustomNode,
end: CustomNode,
nav: CustomNode,
act: CustomNode,
extract: CustomNode,
verify: CustomNode,
decision: CustomNode,
loop: CustomNode,
fork: CustomNode,
join: CustomNode,
cytoscape.use(dagre)
nodeHtmlLabel(cytoscape)
const NODE_CONFIG: Record<
NodeType,
{ color: string; bgColor: string; icon: string; label: string }
> = {
start: {
color: '#22c55e',
bgColor: 'rgba(34, 197, 94, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>`,
label: 'START',
},
end: {
color: '#ef4444',
bgColor: 'rgba(239, 68, 68, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>`,
label: 'END',
},
nav: {
color: '#3b82f6',
bgColor: 'rgba(59, 130, 246, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>`,
label: 'NAVIGATE',
},
act: {
color: '#8b5cf6',
bgColor: 'rgba(139, 92, 246, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"></path></svg>`,
label: 'ACTION',
},
extract: {
color: '#f59e0b',
bgColor: 'rgba(245, 158, 11, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" x2="12" y1="15" y2="3"></line></svg>`,
label: 'EXTRACT',
},
verify: {
color: '#10b981',
bgColor: 'rgba(16, 185, 129, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`,
label: 'VERIFY',
},
decision: {
color: '#ec4899',
bgColor: 'rgba(236, 72, 153, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
label: 'DECISION',
},
loop: {
color: '#06b6d4',
bgColor: 'rgba(6, 182, 212, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`,
label: 'LOOP',
},
fork: {
color: '#6366f1',
bgColor: 'rgba(99, 102, 241, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"></path><path d="M8 3H3v5"></path><path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3"></path><path d="m15 9 6-6"></path></svg>`,
label: 'FORK',
},
join: {
color: '#84cc16',
bgColor: 'rgba(132, 204, 22, 0.1)',
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"></circle><circle cx="6" cy="6" r="3"></circle><path d="M6 21V9a9 9 0 0 0 9 9"></path></svg>`,
label: 'JOIN',
},
}
const initialData: GraphData = {
@@ -61,38 +106,69 @@ const initialData: GraphData = {
edges: [],
}
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
const MIN_NODE_WIDTH = 180
const MAX_NODE_WIDTH = 240
const BASE_NODE_HEIGHT = 70
const CHAR_WIDTH = 7
const ICON_AND_PADDING = 62
const MAX_ZOOM = 1.2
const nodeWidth = 180
const nodeHeight = 60
const calculateNodeDimensions = (
label: string,
): { width: number; height: number } => {
const textWidth = label.length * CHAR_WIDTH + ICON_AND_PADDING
const width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, textWidth))
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
dagreGraph.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 })
const maxCharsPerLine = Math.floor((width - ICON_AND_PADDING) / CHAR_WIDTH)
const lines = Math.ceil(label.length / maxCharsPerLine)
const extraHeight = Math.max(0, lines - 1) * 18
const height = BASE_NODE_HEIGHT + extraHeight
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight })
})
return { width, height }
}
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
nodes.forEach((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
node.position = {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
}
node.style = {
...node.style,
transition: 'transform 0.3s ease-in-out',
}
})
return { nodes, edges }
const createNodeHtml = (type: NodeType, label: string): string => {
const config = NODE_CONFIG[type] || NODE_CONFIG.start
const sanitizedLabel = DOMPurify.sanitize(label, { ALLOWED_TAGS: [] })
return `
<div class="graph-node" style="
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 160px;
max-width: 220px;
padding: 12px 16px;
background-color: var(--graph-node-bg);
border: 1px solid var(--graph-node-border);
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: system-ui, -apple-system, sans-serif;
">
<div style="
flex-shrink: 0;
color: ${config.color};
margin-top: 2px;
">
${config.icon}
</div>
<div style="flex: 1; min-width: 0;">
<div style="
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
color: ${config.color};
margin-bottom: 4px;
">${config.label}</div>
<div style="
font-size: 13px;
font-weight: 500;
color: var(--graph-node-text);
line-height: 1.4;
word-wrap: break-word;
">${sanitizedLabel}</div>
</div>
</div>
`
}
type GraphCanvasProps = {
@@ -108,7 +184,7 @@ type GraphCanvasProps = {
panelSize?: { asPercentage: number; inPixels: number }
}
const GraphCanvasInner: FC<GraphCanvasProps> = ({
export const GraphCanvas: FC<GraphCanvasProps> = ({
graphName,
onGraphNameChange,
graphData = initialData,
@@ -121,8 +197,9 @@ const GraphCanvasInner: FC<GraphCanvasProps> = ({
panelSize,
}) => {
const [isEditingName, setIsEditingName] = useState(false)
const { fitView, zoomIn, zoomOut } = useReactFlow()
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const cyRef = useRef<cytoscape.Core | null>(null)
const handleBack = () => {
if (shouldBlockNavigation) {
@@ -153,51 +230,158 @@ const GraphCanvasInner: FC<GraphCanvasProps> = ({
return isSaved ? 'Save Changes' : 'Save Workflow'
}
// Initialize nodes and edges with layout
const initialLayout = getLayoutedElements(
graphData.nodes.map((n) => ({
...n,
data: { ...n.data, type: n.type },
position: { x: 0, y: 0 },
})),
graphData.edges,
)
const zoomIn = useCallback(() => {
cyRef.current?.zoom(cyRef.current.zoom() * 1.2)
cyRef.current?.center()
}, [])
const [nodes, setNodes, onNodesChange] = useNodesState(initialLayout.nodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialLayout.edges)
const zoomOut = useCallback(() => {
cyRef.current?.zoom(cyRef.current.zoom() / 1.2)
cyRef.current?.center()
}, [])
// Handle graph updates from chat
const handleGraphUpdate = useCallback(
// biome-ignore lint/suspicious/noExplicitAny: graph data from external source
(newGraphData: { nodes: any[]; edges: any[] }) => {
const layouted = getLayoutedElements(
newGraphData.nodes.map((n) => ({
...n,
data: { ...n.data, type: n.type },
position: { x: 0, y: 0 },
})),
newGraphData.edges,
)
setNodes(layouted.nodes)
setEdges(layouted.edges)
},
[setNodes, setEdges],
)
const fitView = useCallback(() => {
cyRef.current?.fit(undefined, 50)
cyRef.current?.center()
}, [])
useEffect(() => {
if (!containerRef.current) return
const cy = cytoscape({
container: containerRef.current,
elements: [],
style: [
{
selector: 'node',
style: {
width: 'data(nodeWidth)',
height: 'data(nodeHeight)',
'background-opacity': 0,
'border-width': 0,
},
},
{
selector: 'edge',
style: {
width: 2,
'line-color': '#f97316',
'target-arrow-color': '#f97316',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'arrow-scale': 1.2,
},
},
{
selector: 'edge.back-edge',
style: {
'line-style': 'dashed',
'line-dash-pattern': [6, 3],
'curve-style': 'unbundled-bezier',
'control-point-distances': [100],
'control-point-weights': [0.5],
},
},
],
layout: { name: 'preset' },
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
selectionType: 'single',
autoungrabify: true,
autounselectify: true,
maxZoom: MAX_ZOOM,
minZoom: 0.2,
})
// @ts-expect-error nodeHtmlLabel extension
cy.nodeHtmlLabel([
{
query: 'node',
halign: 'center',
valign: 'center',
halignBox: 'center',
valignBox: 'center',
tpl: (data: { type: NodeType; label: string }) => {
return createNodeHtml(data.type, data.label)
},
},
])
cyRef.current = cy
return () => {
cy.destroy()
}
}, [])
const updateGraph = useCallback((data: GraphData) => {
const cy = cyRef.current
if (!cy) return
cy.elements().remove()
const nodes = data.nodes.map((node) => {
const dimensions = calculateNodeDimensions(node.data.label)
return {
data: {
id: node.id,
label: node.data.label,
type: node.type as NodeType,
nodeWidth: dimensions.width,
nodeHeight: dimensions.height,
},
}
})
const edges = data.edges.map((edge) => ({
data: {
id: edge.id,
source: edge.source,
target: edge.target,
},
}))
cy.add([...nodes, ...edges])
cy.layout({
name: 'dagre',
rankDir: 'TB',
nodeSep: 80,
rankSep: 100,
padding: 50,
animate: true,
animationDuration: 300,
fit: true,
} as cytoscape.LayoutOptions).run()
setTimeout(() => {
cy.edges().forEach((edge) => {
const sourceNode = edge.source()
const targetNode = edge.target()
const sourceY = sourceNode.position('y')
const targetY = targetNode.position('y')
if (sourceY > targetY) {
edge.addClass('back-edge')
}
})
}, 350)
}, [])
useDeepCompareEffect(() => {
handleGraphUpdate(graphData)
setTimeout(() => fitView({ duration: 300, maxZoom: 0.75 }), 50)
updateGraph(graphData)
}, [graphData])
// Auto fitView when panel is resized
useEffect(() => {
if (panelSize?.inPixels !== undefined) {
fitView({ duration: 200, maxZoom: 0.75 })
cyRef.current?.resize()
setTimeout(() => fitView(), 100)
}
}, [panelSize?.inPixels, fitView])
return (
<div className="flex h-full flex-col">
<div className="flex h-full flex-col [--graph-node-bg:rgba(255,255,255,1)] [--graph-node-border:rgba(228,228,231,1)] [--graph-node-text:rgba(24,24,27,1)] dark:[--graph-node-bg:rgba(24,24,27,1)] dark:[--graph-node-border:rgba(63,63,70,1)] dark:[--graph-node-text:rgba(250,250,250,1)]">
{/* Graph Header */}
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-3 backdrop-blur-md">
<div className="flex min-w-0 flex-1 items-center gap-3">
@@ -283,93 +467,48 @@ const GraphCanvasInner: FC<GraphCanvasProps> = ({
</header>
{/* Graph Canvas */}
<div className="relative flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ maxZoom: 0.75 }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
edgesFocusable={false}
nodesFocusable={false}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
style: { stroke: 'var(--accent-orange)', strokeWidth: 2 },
type: 'smoothstep',
<div className="relative flex-1 bg-[hsl(var(--background))]">
<div
ref={containerRef}
className="h-full w-full"
style={{
backgroundImage:
'radial-gradient(circle, hsl(var(--muted-foreground) / 0.2) 1px, transparent 1px)',
backgroundSize: '16px 16px',
}}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls
showZoom={false}
showFitView={false}
showInteractive={false}
style={{
backgroundColor: 'var(--card)',
border: '2px solid var(--border)',
borderRadius: '8px',
overflow: 'clip',
}}
/>
{/* Zoom Controls */}
<div className="absolute bottom-4 left-4 flex flex-col gap-1 rounded-lg border-2 border-border bg-card p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomIn}
title="Zoom in"
>
<ControlButton
onClick={() => zoomIn()}
title="Zoom in"
className="bg-card! fill-foreground! hover:bg-muted!"
>
<Plus className="h-4 w-4" />
</ControlButton>
<ControlButton
onClick={() => zoomOut()}
title="Zoom out"
className="bg-card! fill-foreground! hover:bg-muted!"
>
<Minus className="h-4 w-4" />
</ControlButton>
<ControlButton
onClick={() => fitView({ duration: 300, maxZoom: 0.75 })}
title="Fit view"
className="bg-card! fill-foreground! hover:bg-muted!"
>
<Maximize className="h-4 w-4" />
</ControlButton>
</Controls>
<MiniMap
pannable
zoomable
nodeColor={(node) => {
const colors: Record<string, string> = {
start: '#22c55e',
end: '#ef4444',
nav: '#3b82f6',
act: '#8b5cf6',
extract: '#f59e0b',
verify: '#10b981',
decision: '#ec4899',
loop: '#06b6d4',
fork: '#6366f1',
join: '#84cc16',
}
return colors[node.type || 'default'] || '#gray'
}}
style={{
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
}}
/>
</ReactFlow>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomOut}
title="Zoom out"
>
<Minus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={fitView}
title="Fit view"
>
<Maximize className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}
export const GraphCanvas: FC<GraphCanvasProps> = (props) => {
return (
<ReactFlowProvider>
<GraphCanvasInner {...props} />
</ReactFlowProvider>
)
}

View File

@@ -40,6 +40,8 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@sentry/react": "^10.31.0",
"@sentry/vite-plugin": "^4.6.1",
"@types/cytoscape": "^3.31.0",
"@types/dompurify": "^3.2.0",
"@webext-core/messaging": "^2.3.0",
"@wxt-dev/storage": "^1.2.6",
"@xyflow/react": "^12.9.3",
@@ -47,8 +49,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"cytoscape": "^3.33.1",
"cytoscape-dagre": "^2.5.0",
"cytoscape-node-html-label": "^1.2.2",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"downshift": "^9.0.10",
"embla-carousel-react": "^8.6.0",
"es-toolkit": "^1.42.0",
@@ -82,6 +87,7 @@
"@tailwindcss/vite": "^4.1.17",
"@types/bun": "^1.3.5",
"@types/chrome": "^0.1.28",
"@types/cytoscape-dagre": "^2.3.4",
"@types/dagre": "^0.7.53",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",

View File

@@ -44,6 +44,8 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@sentry/react": "^10.31.0",
"@sentry/vite-plugin": "^4.6.1",
"@types/cytoscape": "^3.31.0",
"@types/dompurify": "^3.2.0",
"@webext-core/messaging": "^2.3.0",
"@wxt-dev/storage": "^1.2.6",
"@xyflow/react": "^12.9.3",
@@ -51,8 +53,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"cytoscape": "^3.33.1",
"cytoscape-dagre": "^2.5.0",
"cytoscape-node-html-label": "^1.2.2",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"downshift": "^9.0.10",
"embla-carousel-react": "^8.6.0",
"es-toolkit": "^1.42.0",
@@ -86,6 +91,7 @@
"@tailwindcss/vite": "^4.1.17",
"@types/bun": "^1.3.5",
"@types/chrome": "^0.1.28",
"@types/cytoscape-dagre": "^2.3.4",
"@types/dagre": "^0.7.53",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
@@ -117,7 +123,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.40",
"version": "0.0.41",
"bin": {
"browseros-server": "./src/index.ts",
},
@@ -1115,6 +1121,10 @@
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cytoscape": ["@types/cytoscape@3.31.0", "", { "dependencies": { "cytoscape": "*" } }, "sha512-EXHOHxqQjGxLDEh5cP4te6J0bi7LbCzmZkzsR6f703igUac8UGMdEohMyU3GHAayCTZrLQOMnaE/lqB2Ekh8Ww=="],
"@types/cytoscape-dagre": ["@types/cytoscape-dagre@2.3.4", "", { "dependencies": { "cytoscape": "^3.31" } }, "sha512-uOGXuPfPLFoKZaegjHl9oj4tqONNJuhUl180FiJgRZ35rVijBs6J4UP1Ah6mA6S46h+7pv4ICqpgfdC3EADZlw=="],
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -1181,6 +1191,8 @@
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
@@ -1497,7 +1509,7 @@
"chroma-js": ["chroma-js@3.2.0", "", {}, "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.12.1", "", { "bin": { "chrome-devtools-mcp": "build/src/index.js" } }, "sha512-QREfGxJVVlBrjKdyis9px6UHyXix+Rre9nCkqX7CY7GsU8c6azOwwV8inQB8E3h2/QGqi4sCSF8fmjfAvmE07Q=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.13.0", "", { "bin": { "chrome-devtools-mcp": "build/src/index.js" } }, "sha512-CgotJczVYe6wG2b5cqwNFq7n4VXIM8qfvfutdzVlABPKf0b99b0TDPuRsWrviCthigSHzhpMyFQW03P5Utt1Fg=="],
"chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="],
@@ -1605,8 +1617,12 @@
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
"cytoscape-dagre": ["cytoscape-dagre@2.5.0", "", { "dependencies": { "dagre": "^0.8.5" }, "peerDependencies": { "cytoscape": "^3.2.22" } }, "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g=="],
"cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
"cytoscape-node-html-label": ["cytoscape-node-html-label@1.2.2", "", { "peerDependencies": { "@types/cytoscape": "^3.1.0", "cytoscape": "^3.0.0" }, "optionalPeers": ["@types/cytoscape"] }, "sha512-oUVwrlsIlaJJ8QrQFSMdv3uXVXPg6tMH/Tfofr8JuZIovqI4fPqBi6sQgCMcVpS6k9Td0TTjowBsNRw32CESWg=="],
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],