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 ab00082
Show file tree
Hide file tree
Showing 7 changed files with 494 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
273 changes: 273 additions & 0 deletions frontend/console/src/features/graph/NewGraphPane.tsx
Original file line number Diff line number Diff line change
@@ -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<NewGraphPaneProps> = ({ onTapped }) => {
const modules = useStreamModules()

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,
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 (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<div ref={cyRef} style={{ width: '100%', height: '100%' }} />
</div>
)
}
Loading

0 comments on commit ab00082

Please sign in to comment.