diff --git a/frontend/console/package.json b/frontend/console/package.json
index 1168379cef..b903928156 100644
--- a/frontend/console/package.json
+++ b/frontend/console/package.json
@@ -43,11 +43,17 @@
"@tailwindcss/forms": "^0.5.6",
"@tanstack/react-query": "^5.51.23",
"@tanstack/react-query-devtools": "^5.51.23",
+ "@types/cytoscape": "^3.21.8",
+ "@types/cytoscape-dagre": "^2.3.3",
+ "@types/dagre": "^0.7.52",
"@uiw/codemirror-theme-atomone": "^4.22.0",
"@uiw/codemirror-theme-github": "^4.22.0",
"@vitejs/plugin-react": "^4.0.4",
"codemirror-json-schema": "0.7.0",
"codemirror-json5": "^1.0.3",
+ "cytoscape": "^3.30.3",
+ "cytoscape-dagre": "^2.5.0",
+ "dagre": "^0.8.5",
"fnv1a": "^1.1.1",
"fuse.js": "^7.0.0",
"highlight.js": "^11.8.0",
diff --git a/frontend/console/src/api/modules/use-stream-modules.ts b/frontend/console/src/api/modules/use-stream-modules.ts
index 6bfd535038..8582d1c7a7 100644
--- a/frontend/console/src/api/modules/use-stream-modules.ts
+++ b/frontend/console/src/api/modules/use-stream-modules.ts
@@ -7,7 +7,7 @@ import type { Module, Topology } from '../../protos/xyz/block/ftl/v1/console/con
const streamModulesKey = 'streamModules'
-type StreamModulesResult = {
+export type StreamModulesResult = {
modules: Module[]
topology: Topology
}
diff --git a/frontend/console/src/features/console/ConsolePage.tsx b/frontend/console/src/features/console/ConsolePage.tsx
index f0684022be..66443c6679 100644
--- a/frontend/console/src/features/console/ConsolePage.tsx
+++ b/frontend/console/src/features/console/ConsolePage.tsx
@@ -4,7 +4,8 @@ import { useModules } from '../../api/modules/use-modules'
import { Loader } from '../../components/Loader'
import { ResizablePanels } from '../../components/ResizablePanels'
import { Config, Data, Database, Enum, Module, Secret, Verb } from '../../protos/xyz/block/ftl/v1/console/console_pb'
-import { type FTLNode, GraphPane } from '../graph/GraphPane'
+import type { FTLNode } from '../graph/GraphPane'
+import { NewGraphPane } from '../graph/NewGraphPane'
import { configPanels } from '../modules/decls/config/ConfigRightPanels'
import { dataPanels } from '../modules/decls/data/DataRightPanels'
import { databasePanels } from '../modules/decls/database/DatabaseRightPanels'
@@ -33,7 +34,7 @@ export const ConsolePage = () => {
return (
}
+ mainContent={
}
rightPanelHeader={headerForNode(selectedNode)}
rightPanelPanels={panelsForNode(modules.data.modules, selectedNode, navigate)}
bottomPanelContent={
}
diff --git a/frontend/console/src/features/graph/NewGraphPane.tsx b/frontend/console/src/features/graph/NewGraphPane.tsx
new file mode 100644
index 0000000000..39363f78d2
--- /dev/null
+++ b/frontend/console/src/features/graph/NewGraphPane.tsx
@@ -0,0 +1,273 @@
+import cytoscape from 'cytoscape'
+import dagre, { type DagreLayoutOptions } from 'cytoscape-dagre'
+import { useEffect, useRef, useState } from 'react'
+import type React from 'react'
+import { useStreamModules } from '../../api/modules/use-stream-modules'
+import type { FTLNode } from './GraphPane'
+import { getGraphData } from './graph-utils'
+
+cytoscape.use(dagre)
+
+interface NewGraphPaneProps {
+ onTapped?: (item: FTLNode | null) => void
+}
+
+const ZOOM_THRESHOLD = 1
+
+export const NewGraphPane: React.FC
= ({ onTapped }) => {
+ const modules = useStreamModules()
+
+ const cyRef = useRef(null)
+ const cyInstance = useRef(null)
+ // const [, setSelectedNode] = React.useState(null)
+ const [nodePositions, setNodePositions] = useState>({})
+
+ // Initialize Cytoscape
+ useEffect(() => {
+ if (!cyRef.current) return
+
+ cyInstance.current = cytoscape({
+ container: cyRef.current,
+ userZoomingEnabled: true,
+ userPanningEnabled: true,
+ boxSelectionEnabled: false,
+ autoungrabify: true,
+ style: [
+ {
+ selector: 'node',
+ style: {
+ 'background-color': '#64748b',
+ label: 'data(label)',
+ 'text-valign': 'center',
+ 'text-halign': 'center',
+ shape: 'round-rectangle',
+ width: '120px',
+ height: '40px',
+ 'text-wrap': 'wrap',
+ 'text-max-width': '100px',
+ 'text-overflow-wrap': 'anywhere',
+ 'font-size': '12px',
+ },
+ },
+ {
+ selector: 'edge',
+ style: {
+ width: 2,
+ 'line-color': '#6366f1',
+ 'curve-style': 'bezier',
+ 'target-arrow-shape': 'triangle',
+ 'target-arrow-color': '#6366f1',
+ 'arrow-scale': 1,
+ },
+ },
+ {
+ selector: '$node > node',
+ style: {
+ 'padding-top': '10px',
+ 'padding-left': '10px',
+ 'padding-bottom': '10px',
+ 'padding-right': '10px',
+ 'text-valign': 'top',
+ 'text-halign': 'center',
+ 'background-color': '#94a3b8',
+ },
+ },
+ {
+ selector: 'node[type="groupNode"]',
+ style: {
+ 'background-color': '#6366f1',
+ 'background-opacity': 0.8,
+ shape: 'round-rectangle',
+ width: '120px',
+ height: '120px',
+ 'text-valign': 'top', // Default position at top
+ 'text-halign': 'center',
+ 'text-wrap': 'wrap',
+ 'text-max-width': '100px',
+ 'text-overflow-wrap': 'anywhere',
+ 'font-size': '14px',
+ },
+ },
+ {
+ selector: ':parent',
+ style: {
+ 'text-valign': 'top',
+ 'text-halign': 'center',
+ 'background-opacity': 0.3,
+ },
+ },
+ {
+ selector: '.selected',
+ style: {
+ 'background-color': '#3b82f6',
+ 'border-width': 2,
+ 'border-color': '#60a5fa',
+ },
+ },
+ {
+ selector: 'node[type="node"]',
+ style: {
+ 'background-color': 'data(backgroundColor)',
+ shape: 'round-rectangle',
+ width: '100px',
+ height: '30px',
+ 'border-width': '1px',
+ 'border-color': '#475569',
+ 'text-wrap': 'wrap',
+ 'text-max-width': '80px',
+ 'text-overflow-wrap': 'anywhere',
+ 'font-size': '11px',
+ },
+ },
+ ],
+ })
+
+ // Add click handlers
+ cyInstance.current.on('tap', 'node', (evt) => {
+ const node = evt.target
+ const nodeType = node.data('type')
+ const item = node.data('item')
+ const zoom = evt.cy.zoom()
+
+ if (zoom < ZOOM_THRESHOLD) {
+ if (nodeType === 'node') {
+ const parent = node.parent()
+ if (parent.length) {
+ onTapped?.(parent.data('item'))
+ return
+ }
+ }
+ }
+
+ if (nodeType === 'groupNode' || (nodeType === 'node' && zoom >= ZOOM_THRESHOLD)) {
+ onTapped?.(item)
+ }
+ })
+
+ cyInstance.current.on('tap', (evt) => {
+ if (evt.target === cyInstance.current) {
+ onTapped?.(null)
+ }
+ })
+
+ // Update zoom level event handler
+ cyInstance.current.on('zoom', (evt) => {
+ const zoom = evt.target.zoom()
+ const elements = evt.target.elements()
+
+ if (zoom < ZOOM_THRESHOLD) {
+ // Hide child nodes
+ elements.nodes('node[type != "groupNode"]').style('opacity', 0)
+
+ // Show only module-level edges (type="moduleConnection")
+ elements.edges('[type = "moduleConnection"]').style('opacity', 1)
+ elements.edges('[type = "childConnection"]').style('opacity', 0)
+
+ // Move text inside and make it larger when zoomed out
+ elements.nodes('node[type = "groupNode"]').style({
+ 'text-valign': 'center',
+ 'text-halign': 'center',
+ 'font-size': '18px',
+ 'text-max-width': '100px',
+ })
+ } else {
+ // Show all nodes
+ elements.nodes().style('opacity', 1)
+
+ // Show only verb-level edges (type="childConnection")
+ elements.edges('[type = "moduleConnection"]').style('opacity', 0)
+ elements.edges('[type = "childConnection"]').style('opacity', 1)
+
+ // Move text to top when zoomed in
+ elements.nodes('node[type = "groupNode"]').style({
+ 'text-valign': 'top', // Move text to top
+ 'text-halign': 'center', // Keep text centered horizontally
+ 'font-size': '14px', // Original font size
+ 'text-max-width': '100px',
+ })
+ }
+ })
+
+ return () => {
+ cyInstance.current?.destroy()
+ }
+ }, [onTapped])
+
+ // Modify the data loading effect
+ useEffect(() => {
+ if (!cyInstance.current) return
+
+ const elements = getGraphData(modules.data, nodePositions)
+ const cy = cyInstance.current
+
+ // Update existing elements and add new ones
+ for (const element of elements) {
+ const id = element.data?.id
+ if (!id) continue // Skip elements without an id
+
+ const existingElement = cy.getElementById(id)
+
+ if (existingElement.length) {
+ // Update existing element data
+ existingElement.data(element.data)
+
+ // If it's a node and doesn't have saved position, update position
+ if (element.group === 'nodes' && !nodePositions[id]) {
+ existingElement.position(element.position || { x: 0, y: 0 })
+ }
+ } else {
+ // Add new element
+ cy.add(element)
+ }
+ }
+
+ // Remove elements that no longer exist in the data
+ for (const element of cy.elements()) {
+ const elementId = element.data('id')
+ const stillExists = elements.some((e) => e.data?.id === elementId)
+ if (!stillExists) {
+ element.remove()
+ }
+ }
+
+ // Only run layout for new nodes without positions
+ const hasNewNodesWithoutPositions = cy.nodes().some((node) => {
+ const nodeId = node.data('id')
+ return node.data('type') === 'groupNode' && !nodePositions[nodeId]
+ })
+
+ if (hasNewNodesWithoutPositions) {
+ const layoutOptions: DagreLayoutOptions = {
+ name: 'dagre',
+ rankDir: 'LR',
+ nodeDimensionsIncludeLabels: true,
+ rankSep: 5,
+ nodeSep: 5,
+ edgeSep: 5,
+ padding: 5,
+ ranker: 'network-simplex',
+ spacingFactor: 2,
+ }
+
+ const layout = cy.layout(layoutOptions)
+ layout.run()
+
+ layout.on('layoutstop', () => {
+ const newPositions = { ...nodePositions }
+ for (const node of cy.nodes()) {
+ const nodeId = node.data('id')
+ newPositions[nodeId] = node.position()
+ }
+ setNodePositions(newPositions)
+ })
+ }
+
+ cy.fit()
+ }, [nodePositions, modules.data])
+
+ return (
+
+ )
+}
diff --git a/frontend/console/src/features/graph/graph-utils.ts b/frontend/console/src/features/graph/graph-utils.ts
new file mode 100644
index 0000000000..9a4a03b5f6
--- /dev/null
+++ b/frontend/console/src/features/graph/graph-utils.ts
@@ -0,0 +1,142 @@
+import type { EdgeDefinition, ElementDefinition } from 'cytoscape'
+import type { StreamModulesResult } from '../../api/modules/use-stream-modules'
+import type { Config, Module, Secret, Subscription, Verb } from '../../protos/xyz/block/ftl/v1/console/console_pb'
+import type { FTLNode } from './GraphPane'
+
+const createParentNode = (module: Module, nodePositions: Record) => ({
+ group: 'nodes' as const,
+ data: {
+ id: module.name,
+ label: module.name,
+ type: 'groupNode',
+ item: module,
+ },
+ ...(nodePositions[module.name] && {
+ position: nodePositions[module.name],
+ }),
+})
+
+const createChildNode = (
+ parentName: string,
+ childId: string,
+ childLabel: string,
+ childType: string,
+ nodePositions: Record,
+ item: FTLNode,
+) => ({
+ group: 'nodes' as const,
+ data: {
+ id: childId,
+ label: childLabel,
+ type: 'node',
+ nodeType: childType,
+ parent: parentName,
+ item,
+ backgroundColor: childType === 'verb' ? '#e3f2fd' : childType === 'config' ? '#e8f5e9' : childType === 'secret' ? '#fce4ec' : '#ffffff',
+ },
+ ...(nodePositions[childId] && {
+ position: nodePositions[childId],
+ }),
+})
+
+const createModuleChildren = (module: Module, nodePositions: Record) => {
+ const children = [
+ // Create nodes for verbs
+ ...(module.verbs || []).map((verb: Verb) =>
+ createChildNode(module.name, `${module.name}-verb-${verb.verb?.name}`, verb.verb?.name || '', 'verb', nodePositions, verb),
+ ),
+ // Create nodes for configs
+ ...(module.configs || []).map((config: Config) =>
+ createChildNode(module.name, `${module.name}-config-${config.config?.name}`, config.config?.name || '', 'config', nodePositions, config),
+ ),
+ // Create nodes for secrets
+ ...(module.secrets || []).map((secret: Secret) =>
+ createChildNode(module.name, `${module.name}-secret-${secret.secret?.name}`, secret.secret?.name || '', 'secret', nodePositions, secret),
+ ),
+ // Create nodes for subscriptions
+ ...(module.subscriptions || []).map((subscription: Subscription) =>
+ createChildNode(
+ module.name,
+ `${module.name}-subscription-${subscription.subscription?.name}`,
+ subscription.subscription?.name || '',
+ 'subscription',
+ nodePositions,
+ subscription,
+ ),
+ ),
+ ]
+ return children
+}
+
+const createChildEdge = (sourceModule: string, sourceVerb: string, targetModule: string, targetVerb: string) => ({
+ group: 'edges' as const,
+ data: {
+ id: `${sourceModule}-${sourceVerb}-to-${targetModule}-${targetVerb}`,
+ source: `${sourceModule}-verb-${sourceVerb}`,
+ target: `${targetModule}-verb-${targetVerb}`,
+ type: 'childConnection',
+ },
+})
+
+const createModuleEdge = (sourceModule: string, targetModule: string) => ({
+ group: 'edges' as const,
+ data: {
+ id: `module-${sourceModule}-to-${targetModule}`,
+ source: sourceModule,
+ target: targetModule,
+ type: 'moduleConnection',
+ },
+})
+
+const createVerbEdges = (modules: Module[]) => {
+ const edges: EdgeDefinition[] = []
+ const moduleConnections = new Set() // Track unique module connections
+
+ for (const module of modules) {
+ // For each verb in the module
+ for (const verb of module.verbs || []) {
+ // For each reference in the verb
+ for (const ref of verb.references || []) {
+ // Create verb-to-verb edge
+ edges.push(createChildEdge(ref.module, ref.name, module.name, verb.verb?.name || ''))
+
+ // Track module-to-module connection
+ // Sort module names to ensure consistent edge IDs
+ const [sourceModule, targetModule] = [module.name, ref.module].sort()
+ moduleConnections.add(`${sourceModule}-${targetModule}`)
+ }
+ }
+
+ for (const config of module.configs || []) {
+ for (const ref of config.references || []) {
+ edges.push(createChildEdge(ref.module, ref.name, module.name, config.config?.name || ''))
+
+ // Track module-to-module connection
+ // Sort module names to ensure consistent edge IDs
+ const [sourceModule, targetModule] = [module.name, ref.module].sort()
+ moduleConnections.add(`${sourceModule}-${targetModule}`)
+ }
+ }
+ }
+
+ // Create module-level edges for each unique module connection
+ for (const connection of moduleConnections) {
+ const [sourceModule, targetModule] = connection.split('-')
+ edges.push(createModuleEdge(sourceModule, targetModule))
+ }
+
+ return edges
+}
+
+export const getGraphData = (modules: StreamModulesResult | undefined, nodePositions: Record = {}): ElementDefinition[] => {
+ if (!modules) return []
+
+ return [
+ // Add parent nodes (modules)
+ ...modules.modules.map((module) => createParentNode(module, nodePositions)),
+ // Add all child nodes
+ ...modules.modules.flatMap((module) => createModuleChildren(module, nodePositions)),
+ // Add both verb-level and module-level edges
+ ...createVerbEdges(modules.modules),
+ ]
+}
diff --git a/ftl-project.toml b/ftl-project.toml
index f4967c7d0e..260bff6439 100644
--- a/ftl-project.toml
+++ b/ftl-project.toml
@@ -18,6 +18,9 @@ disable-ide-integration = true
[modules.mysql]
[modules.mysql.secrets]
FTL_DSN_MYSQL_TESTDB = "inline://InJvb3Q6c2VjcmV0QHRjcCgxMjcuMC4wLjE6MTMzMDYpL215c3FsX3Rlc3RkYj9hbGxvd05hdGl2ZVBhc3N3b3Jkcz1UcnVlIg"
+ [modules.postgres]
+ [modules.postgres.secrets]
+ FTL_DSN_POSTGRES_TESTDB = "inline://InBvc3RncmVzOi8vMTI3LjAuMC4xOjE1NDMyL3Bvc3RncmVzX3Rlc3RkYj9zc2xtb2RlPWRpc2FibGVcdTAwMjZ1c2VyPXBvc3RncmVzXHUwMDI2cGFzc3dvcmQ9c2VjcmV0Ig"
[modules.test]
[modules.test.configuration]
[modules.test.secrets]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c779e6855..9dde6bc30c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,15 @@ importers:
'@tanstack/react-query-devtools':
specifier: ^5.51.23
version: 5.61.3(@tanstack/react-query@5.61.3(react@18.3.1))(react@18.3.1)
+ '@types/cytoscape':
+ specifier: ^3.21.8
+ version: 3.21.8
+ '@types/cytoscape-dagre':
+ specifier: ^2.3.3
+ version: 2.3.3
+ '@types/dagre':
+ specifier: ^0.7.52
+ version: 0.7.52
'@uiw/codemirror-theme-atomone':
specifier: ^4.22.0
version: 4.23.6(@codemirror/language@6.10.4)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0)
@@ -65,6 +74,15 @@ importers:
codemirror-json5:
specifier: ^1.0.3
version: 1.0.3
+ cytoscape:
+ specifier: ^3.30.3
+ version: 3.30.3
+ cytoscape-dagre:
+ specifier: ^2.5.0
+ version: 2.5.0(cytoscape@3.30.3)
+ dagre:
+ specifier: ^0.8.5
+ version: 0.8.5
fnv1a:
specifier: ^1.1.1
version: 1.1.1
@@ -2046,6 +2064,12 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+ '@types/cytoscape-dagre@2.3.3':
+ resolution: {integrity: sha512-FJBsNMbBZpqNwT6rp5leVYMevWUjnyD1QS8erNMAMWoBifvaVUklXIjE+bllLDSowjM3abXuRvljliSXUU+d1A==}
+
+ '@types/cytoscape@3.21.8':
+ resolution: {integrity: sha512-6Bo9ZDrv0vfwe8Sg/ERc5VL0yU0gYvP4dgZi0fAXYkKHfyHaNqWRMcwYm3mu4sLsXbB8ZuXE75sR7qnaOL5JgQ==}
+
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -2139,6 +2163,9 @@ packages:
'@types/d3@7.4.3':
resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+ '@types/dagre@0.7.52':
+ resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==}
+
'@types/doctrine@0.0.9':
resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
@@ -2904,6 +2931,15 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+ cytoscape-dagre@2.5.0:
+ resolution: {integrity: sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==}
+ peerDependencies:
+ cytoscape: ^3.2.22
+
+ cytoscape@3.30.3:
+ resolution: {integrity: sha512-HncJ9gGJbVtw7YXtIs3+6YAFSSiKsom0amWc33Z7QbylbY2JGMrA0yz4EwrdTScZxnwclXeEZHzO5pxoy0ZE4g==}
+ engines: {node: '>=0.10'}
+
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
@@ -2942,6 +2978,9 @@ packages:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
+ dagre@0.8.5:
+ resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
+
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -3442,6 +3481,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ graphlib@2.1.8:
+ resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
+
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
@@ -7422,6 +7464,12 @@ snapshots:
dependencies:
'@types/node': 22.9.3
+ '@types/cytoscape-dagre@2.3.3':
+ dependencies:
+ '@types/cytoscape': 3.21.8
+
+ '@types/cytoscape@3.21.8': {}
+
'@types/d3-array@3.2.1': {}
'@types/d3-axis@3.0.6':
@@ -7539,6 +7587,8 @@ snapshots:
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
+ '@types/dagre@0.7.52': {}
+
'@types/doctrine@0.0.9': {}
'@types/eslint-scope@3.7.7':
@@ -8433,6 +8483,13 @@ snapshots:
csstype@3.1.3: {}
+ cytoscape-dagre@2.5.0(cytoscape@3.30.3):
+ dependencies:
+ cytoscape: 3.30.3
+ dagre: 0.8.5
+
+ cytoscape@3.30.3: {}
+
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
@@ -8469,6 +8526,11 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
+ dagre@0.8.5:
+ dependencies:
+ graphlib: 2.1.8
+ lodash: 4.17.21
+
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -9023,6 +9085,10 @@ snapshots:
graceful-fs@4.2.11: {}
+ graphlib@2.1.8:
+ dependencies:
+ lodash: 4.17.21
+
handlebars@4.7.8:
dependencies:
minimist: 1.2.8