Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
519 changes: 437 additions & 82 deletions web/client/package-lock.json

Large diffs are not rendered by default.

8 changes: 3 additions & 5 deletions web/client/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
{
"name": "tobiko",
"version": "0.0.0",
"engines": {
"node": "18",
"npm": "8"
},
"scripts": {
"dev": "npm run generate:api && vite",
"build": "npm run generate:api && tsc && vite build",
Expand All @@ -29,19 +25,21 @@
"@tanstack/react-table": "^8.7.9",
"@uiw/react-codemirror": "^4.19.7",
"clsx": "^1.2.1",
"elkjs": "^0.8.2",
"pluralize": "^8.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.6.2",
"react-split": "^2.0.14",
"reactflow": "^11.5.6",
"zustand": "^4.3.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tanstack/react-query-devtools": "^4.22.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/dagre": "^0.7.48",
"@types/pluralize": "^0.0.29",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
Expand Down
9 changes: 9 additions & 0 deletions web/client/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
getPlanApiPlanGet,
getApiContextApiContextGet,
ContextEnvironment,
DagApiDagGet200,
dagApiDagGet,
} from './client'
import type { File, Directory, Context } from './client'

Expand All @@ -36,6 +38,13 @@ export function useApiFiles(): UseQueryResult<Directory> {
})
}

export function useApiDag(): UseQueryResult<DagApiDagGet200> {
return useQuery({
queryKey: ['/api/dag'],
queryFn: dagApiDagGet,
})
}

export function useApiContext(): UseQueryResult<Context> {
return useQuery({
queryKey: ['/api/context'],
Expand Down
132 changes: 132 additions & 0 deletions web/client/src/library/components/graph/Graph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { MouseEvent, useEffect, useMemo, useState } from 'react'
import ReactFlow, {
Controls,
Background,
useNodesState,
useEdgesState,
Panel,
Handle,
Position,
BackgroundVariant,
} from 'reactflow'
import { Button } from '../button/Button'
import { useApiDag } from '../../../api'
import 'reactflow/dist/base.css'
import { getNodesAndEdges } from './help'
import { isFalse, isNil } from '../../../utils'

export default function Graph({ closeGraph }: any): JSX.Element {
const { data } = useApiDag()
const [graph, setGraph] = useState<{ nodes: any[]; edges: any[] }>()
const [algorithm, setAlgorithm] = useState('layered')
const nodeTypes = useMemo(() => ({ model: ModelNode }), [])
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])

useEffect(() => {
if (isNil(data)) return

let active = true

void load()

return () => {
active = false
}

async function load(): Promise<void> {
console.log('start')

setGraph(undefined)

const graph = await getNodesAndEdges({ data, algorithm })

if (isFalse(active)) return

setGraph(graph)
}
}, [data, algorithm])

useEffect(() => {
if (graph == null) return

setNodes(graph.nodes)
setEdges(graph.edges)
}, [graph])

return (
<div className="px-2 py-1 w-full h-[90vh]">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeOrigin={[0.5, 0.5]}
nodeTypes={nodeTypes}
fitView
>
<Panel
position="top-right"
className="flex"
>
<select
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
e.stopPropagation()

setAlgorithm(e.target.value)
}}
value={algorithm}
>
<option value="layered">Layered</option>
<option value="stress">Stress</option>
<option value="mrtree">Mr. Tree</option>
<option value="radial">Radial</option>
<option value="force">Force</option>
</select>
<Button
size="sm"
variant="alternative"
className="mx-0 ml-4"
onClick={(e: MouseEvent) => {
e.stopPropagation()

closeGraph()
}}
>
Close
</Button>
</Panel>
<Controls className="bg-secondary-100" />
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={2}
/>
</ReactFlow>
</div>
)
}

function ModelNode({ data, sourcePosition, targetPosition }: any): JSX.Element {
return (
<div className="bg-secondary-100 border-2 border-secondary-500 px-3 py-1 rounded-full text-xs font-semibold text-secondary-500">
{targetPosition === Position.Left && (
<Handle
type="target"
position={Position.Left}
isConnectable={false}
className="bg-secondary-500 w-2 h-2 rounded-full"
/>
)}
<div>{data.label}</div>
{sourcePosition === Position.Right && (
<Handle
type="source"
position={Position.Right}
className="bg-secondary-500 w-2 h-2 rounded-full"
isConnectable={false}
/>
)}
</div>
)
}
139 changes: 139 additions & 0 deletions web/client/src/library/components/graph/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import ELK from 'elkjs/lib/elk-api'
import { isArrayNotEmpty } from '../../../utils'

interface GraphNodeData {
label: string
[key: string]: any
}

interface GraphNodePosition {
x: number
y: number
}

interface GraphNode {
id: string
type: string
position: GraphNodePosition
data: GraphNodeData
connectable: boolean
selectable: boolean
deletable: boolean
focusable: boolean
sourcePosition?: 'left' | 'right'
targetPosition?: 'left' | 'right'
}

interface GraphEdge {
id: string
source: string
target: string
style: {
strokeWidth: number
stroke: string
}
}

interface GraphOptions {
data?: Record<string, string[]>
nodeWidth?: number
nodeHeight?: number
algorithm?: string
}

const elk = new ELK({
workerUrl: '/node_modules/elkjs/lib/elk-worker.min.js',
})

export async function getNodesAndEdges({
data,
nodeWidth = 172,
nodeHeight = 32,
algorithm = 'layered',
}: GraphOptions): Promise<{ nodes: GraphNode[]; edges: GraphEdge[] }> {
if (data == null) return await Promise.resolve({ nodes: [], edges: [] })

const targets = new Set(Object.values(data).flat())
const models = Object.keys(data)
const nodesMap: Record<string, GraphNode> = models.reduce(
(acc, label) => Object.assign(acc, { [label]: toGraphNode({ label }) }),
{},
)
const edges = models.map(source => getNodeEdges(data[source], source)).flat()

const graph = {
id: 'root',
layoutOptions: { algorithm },
children: Object.values(nodesMap).map(node => ({
id: node.id,
width: nodeWidth,
height: nodeHeight,
})),
edges: edges.map(edge => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
})),
}

const layout = await elk.layout(graph)
const nodes: GraphNode[] = []

layout.children?.forEach((node, idx: number) => {
const output = nodesMap[node.id]

if (output == null) return

if (isArrayNotEmpty(data[node.id])) {
output.sourcePosition = 'right'
}

if (targets.has(node.id)) {
output.targetPosition = 'left'
}

output.position = {
x: node.x ?? 0,
y: node.y ?? 0,
}

nodes.push(output)
})

return { nodes, edges }
}

function getNodeEdges(targets: string[] = [], source: string): GraphEdge[] {
return targets.map(target => toGraphEdge(source, target))
}

function toGraphNode(
data: GraphNodeData,
type: string = 'model',
position: GraphNodePosition = { x: 0, y: 0 },
): GraphNode {
return {
id: data.label,
type,
position,
data,
connectable: false,
selectable: false,
deletable: false,
focusable: false,
}
}

function toGraphEdge(source: string, target: string): GraphEdge {
const id = `${source}_${target}`

return {
id,
source,
target,
style: {
strokeWidth: 2,
stroke: 'hsl(260, 100%, 80%)',
},
}
}
Loading