Skip to content

Commit

Permalink
feat: graph view with cytoscape
Browse files Browse the repository at this point in the history
  • Loading branch information
wesbillman committed Nov 25, 2024
1 parent 3adc0de commit c349771
Show file tree
Hide file tree
Showing 7 changed files with 488 additions and 3 deletions.
6 changes: 6 additions & 0 deletions frontend/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/console/src/api/modules/use-stream-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 3 additions & 2 deletions frontend/console/src/features/console/ConsolePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -33,7 +34,7 @@ export const ConsolePage = () => {
return (
<div className='flex h-full'>
<ResizablePanels
mainContent={<GraphPane onTapped={setSelectedNode} />}
mainContent={<NewGraphPane onTapped={setSelectedNode} />}
rightPanelHeader={headerForNode(selectedNode)}
rightPanelPanels={panelsForNode(modules.data.modules, selectedNode, navigate)}
bottomPanelContent={<Timeline timeSettings={{ isTailing: true, isPaused: false }} filters={[]} />}
Expand Down
270 changes: 270 additions & 0 deletions frontend/console/src/features/graph/NewGraphPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
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
}

export const NewGraphPane: React.FC<NewGraphPaneProps> = () => {
// Add state for tracking current group view
// const [currentGroup, setCurrentGroup] = useState<string | null>(null)

const modules = useStreamModules()

// Modify static data to include children
const staticData = {
modules: [
{
id: 'mod1',
title: 'Module 1',
type: 'groupNode',
children: [
{ id: 'mod1-1', title: 'Sub Module 1.1', type: 'node' },
{ id: 'mod1-2', title: 'Sub Module 1.2', type: 'node' },
],
childConnections: [{ source: 'mod1-1', target: 'mod1-2' }],
},
{
id: 'mod2',
title: 'Module 2',
type: 'groupNode',
children: [
{ id: 'mod2-1', title: 'Sub Module 2.1', type: 'node' },
{ id: 'mod2-2', title: 'Sub Module 2.2', type: 'node' },
{ id: 'mod2-3', title: 'Sub Module 2.3', type: 'node' },
],
childConnections: [{ source: 'mod2-1', target: 'mod2-2' }],
},
{
id: 'mod3',
title: 'Module 3',
type: 'groupNode',
children: [
{ id: 'mod3-1', title: 'Sub Module 3.1', type: 'node' },
{ id: 'mod3-2', title: 'Sub Module 3.2', type: 'node' },
],
childConnections: [{ source: 'mod3-1', target: 'mod3-2' }],
},
{
id: 'mod4',
title: 'Module 4',
type: 'groupNode',
children: [
{ id: 'mod4-1', title: 'Sub Module 4.1', type: 'node' },
{ id: 'mod4-2', title: 'Sub Module 4.2', type: 'node' },
],
childConnections: [{ source: 'mod4-1', target: 'mod4-2' }],
},
],
topology: {
connections: [
// { source: 'mod1', target: 'mod2' },
{ source: 'mod2', target: 'mod4' },
{ source: 'mod3', target: 'mod4' },
{ source: 'mod2-1', target: 'mod1-1' },
{ source: 'mod2-2', target: 'mod1-1' },
],
},
}

const cyRef = useRef<HTMLDivElement>(null)
const cyInstance = useRef<cytoscape.Core | null>(null)
// const [, setSelectedNode] = React.useState<FTLNode | null>(null)
const [nodePositions, setNodePositions] = useState<Record<string, { x: number; y: number }>>({})

// Initialize Cytoscape
useEffect(() => {
if (!cyRef.current) return

cyInstance.current = cytoscape({
container: cyRef.current,
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
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',
},
},
],
})

// Update zoom level event handler
cyInstance.current.on('zoom', (evt) => {
const zoom = evt.target.zoom()
const elements = evt.target.elements()

if (zoom < 1) {
// 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()
}
}, [])

// Modify the data loading effect
useEffect(() => {
if (!cyInstance.current) return

const elements = getGraphData(modules.data, nodePositions)
cyInstance.current.elements().remove()
cyInstance.current.add(elements)

// Run layout if needed
if (staticData.modules.some((module) => !nodePositions[module.id])) {
const layoutOptions: DagreLayoutOptions = {
name: 'dagre',
rankDir: 'TB',
// ranker: 'network-simplex',
nodeDimensionsIncludeLabels: true,
rankSep: 5,
nodeSep: 5,
edgeSep: 20,
padding: 5,
ranker: 'tight-tree',
spacingFactor: 2,
}

const layout = cyInstance.current.layout(layoutOptions)
layout.run()

// Save positions after layout is complete
layout.on('layoutstop', () => {
const newPositions = { ...nodePositions }
for (const node of cyInstance.current?.nodes() || []) {
newPositions[node.id()] = node.position()
}
setNodePositions(newPositions)
})
}

cyInstance.current.fit()
}, [nodePositions, modules.data])

// Modify event handlers

return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<div ref={cyRef} style={{ width: '100%', height: '100%' }} />
</div>
)
}
Loading

0 comments on commit c349771

Please sign in to comment.