Skip to content

Commit

Permalink
feat: improve ui
Browse files Browse the repository at this point in the history
  • Loading branch information
DerYeger committed Aug 12, 2023
1 parent 530aed4 commit 9098195
Show file tree
Hide file tree
Showing 16 changed files with 769 additions and 252 deletions.
1 change: 1 addition & 0 deletions packages/tsconfig/cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"compilerOptions": {
"declaration": false,
"module": "CommonJS",
"noEmit": false,
"sourceMap": false
}
}
1 change: 1 addition & 0 deletions packages/tsconfig/next.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"extends": "./web.json",
"plugins": [{ "name": "next" }],
"compilerOptions": {
"noEmit": true,
"incremental": true
Expand Down
100 changes: 100 additions & 0 deletions packages/turbo-graph-ui/app/converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Stream } from '@yeger/streams'
import { graphStratify, sugiyama } from 'd3-dag'
import { type Edge, type Node } from 'reactflow'

import type { TurboEdge, TurboGraph, TurboNode } from './data'

export type FlowGraph = ReturnType<typeof convertGraph>

export interface SizeConfig {
width: number
height: number
horizontalSpacing: number
verticalSpacing: number
}

export function convertGraph(graph: TurboGraph) {
const hierarchy = createHierarchy(graph)
const longestLine = getLongestLineLength(graph)
const sizeConfig = createSizeConfig(longestLine)
return createFlowGraph(hierarchy, graph.edges, sizeConfig)
}

function createHierarchy(graph: TurboGraph) {
const stratify = graphStratify()
return stratify([
...graph.nodes.map((node) => ({
...node,
id: node.id,
parentIds: graph.edges
.filter((edge) => edge.target === node.id)
.map(({ source }) => source),
})),
])
}

function getLongestLineLength({ nodes }: TurboGraph) {
return Math.max(
...Stream.from(nodes).flatMap(({ task, workspace }) => [
task.length,
workspace.length,
]),
)
}

function createSizeConfig(longestLine: number): SizeConfig {
return {
width: longestLine * 10,
height: 64,
horizontalSpacing: 128,
verticalSpacing: 128,
}
}

export interface FlowNode extends TurboNode {
isOrigin: boolean
isTerminal: boolean
}

function createFlowGraph(
hierarchy: ReturnType<typeof createHierarchy>,
turboEdges: TurboEdge[],
sizeConfig: SizeConfig,
) {
const { width, height, horizontalSpacing, verticalSpacing } = sizeConfig
const layout = sugiyama().nodeSize([
width + horizontalSpacing,
height + verticalSpacing,
])
layout(hierarchy)
const nodes = Stream.from(hierarchy.nodes())
.map<Node<FlowNode>>(
(node) =>
({
id: node.data.id,
data: {
...node.data,
isTerminal: node.nchildren() === 0,
isOrigin: node.nparents() === 0,
},
position: { x: node.x, y: node.y },
type: 'task',
draggable: false,
selectable: false,
connectable: false,
deletable: false,
focusable: false,
}) as const,
)
.toArray()
const edges = turboEdges.map<Edge<TurboEdge>>((edge) => ({
id: `edge-${edge.source}-${edge.target}`,
source: edge.source,
target: edge.target,
animated: true,
deletable: false,
focusable: false,
updatable: false,
}))
return { nodes, edges, sizeConfig }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import fs from 'node:fs/promises'
import path from 'node:path'

import { execa } from 'execa'
import type { NextApiRequest, NextApiResponse } from 'next'

interface Data {
dir: string
Expand All @@ -14,40 +13,24 @@ interface Config {
pipeline: Record<string, unknown>
}

export default async function handler(
_: NextApiRequest,
res: NextApiResponse<TurboGraph>,
) {
const { dir, config } = await findTurboConfig()
const tasks = getTask(config)
export async function getGraph(tasks: string[]) {
const { dir } = await findTurboConfig()
const stdout = await executeCommand(tasks, dir)
const graph = await processResult(stdout)
res.status(200).json(graph)
return processResult(stdout)
}

async function executeCommand(tasks: string[], dir: string): Promise<string> {
// TODO: Get .bin dir location from package manager
const { stdout } = await execa(
`node_modules${path.sep}.bin${path.sep}turbo`,
['run', ...tasks, '--concurrency=100%', '--graph'],
['run', ...tasks, '--concurrency=100%', '--graph', '--filter=formi...'],
{
cwd: dir,
},
)
return stdout
}

function getTask(config: Config): string[] {
const pipeline = Object.keys(config.pipeline).map((entry) => {
if (!entry.includes('#')) {
return entry
}
return entry.substring(entry.indexOf('#') + 1)
})

return [...new Set(pipeline)]
}

async function findTurboConfig(currentPath = '.'): Promise<Data> {
const files = await fs.readdir(currentPath)
const turboConfig = files.find((file) => file === 'turbo.json')
Expand Down Expand Up @@ -82,7 +65,7 @@ async function processResult(input: string): Promise<TurboGraph> {
.filter((line) => line.includes('->') && !line.includes('___ROOT___'))
.map((line) => line.substring(line.indexOf('"') + 1, line.lastIndexOf('"')))
.map((line) => {
const [source, target] = line.split('" -> "', 2)
const [target, source] = line.split('" -> "', 2)
return { source: source!, target: target! }
})
const nodes: TurboNode[] = [...new Set(edges.flatMap(Object.values))].map(
Expand Down
73 changes: 73 additions & 0 deletions packages/turbo-graph-ui/app/flow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client'

import { scaleOrdinal } from 'd3-scale'
import { schemeSet3 } from 'd3-scale-chromatic'
import { useMemo } from 'react'
import { Background, Controls, Handle, Position, ReactFlow } from 'reactflow'

import 'reactflow/dist/style.css'
import type { FlowGraph, FlowNode } from './converter'

export interface Props {
graph: FlowGraph
uniqueTasks: Set<string>
}

interface TaskProps {
data: FlowNode
}

// TODO: Omit source and target handles if not needed
export function Flow({ graph, uniqueTasks }: Props) {
const { sizeConfig } = graph
const nodeTypes = useMemo(() => {
const getColor = scaleOrdinal(schemeSet3).domain(uniqueTasks)
return {
task: function Task({ data }: TaskProps) {
const { task, workspace, isTerminal, isOrigin } = data
return (
<div className="flex flex-col">
{isOrigin ? null : (
<Handle
type="target"
position={Position.Top}
isConnectable={false}
/>
)}
<div
className="flex flex-col rounded border-2 bg-neutral-50 p-2 outline outline-2 outline-neutral-500"
style={{
width: `${sizeConfig.width}px`,
height: `${sizeConfig.height}px`,
borderColor: getColor(task),
}}
>
<div className="font-bold">{task}</div>
<div className="text-right text-neutral-600">{workspace}</div>
</div>
{isTerminal ? null : (
<Handle
type="source"
position={Position.Bottom}
isConnectable={false}
/>
)}
</div>
)
},
}
}, [sizeConfig, uniqueTasks])
return (
<div style={{ width: '100%', height: '100%' }}>
<ReactFlow
nodes={graph.nodes}
edges={graph.edges}
nodeTypes={nodeTypes}
minZoom={0.1}
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
</div>
)
}
File renamed without changes.
112 changes: 112 additions & 0 deletions packages/turbo-graph-ui/app/graph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client'

import {
GraphController,
Markers,
PositionInitializers,
defineGraph,
defineGraphConfig,
defineLink,
defineNodeWithDefaults,
} from 'd3-graph-controller'
import { scaleOrdinal } from 'd3-scale'
import { schemeSet3 } from 'd3-scale-chromatic'
import { useEffect, useMemo, useRef } from 'react'
import 'd3-graph-controller/default.css'

import type { TurboGraph } from './data'

export interface Props {
graph: TurboGraph
}

export function Graph({ graph }: Props) {
const graphRef = useRef<HTMLDivElement>(null)

const colors = useMemo(() => {
const tasks = [
...new Set(graph?.nodes.map(({ task }) => task) ?? []),
].sort()
return scaleOrdinal(schemeSet3).domain(tasks)
}, [graph])

const graphController = useMemo(() => {
const container = graphRef.current
if (!container || !graph) {
return undefined
}
const nodes = graph.nodes.map((node) =>
defineNodeWithDefaults({
id: node.id,
type: node.task,
color: colors(node.task),
label: { text: node.workspace, color: 'black', fontSize: '0.875rem' },
}),
)
const links = graph.edges.map((edge) => {
const source = nodes.find((node) => node.id === edge.source)!
const target = nodes.find((node) => node.id === edge.target)!
return defineLink({
source,
target,
color: '#aaa',
label: false,
})
})

return new GraphController(
container,
defineGraph({ nodes, links }),
defineGraphConfig({
autoResize: true,
hooks: {
afterZoom(scale: number, xOffset: number, yOffset: number) {
container.style.setProperty('--offset-x', `${xOffset}px`)
container.style.setProperty('--offset-y', `${yOffset}px`)
container.style.setProperty('--dot-size', `${scale}rem`)
},
},
marker: Markers.Arrow(4),
positionInitializer:
nodes.length > 1
? PositionInitializers.Randomized
: PositionInitializers.Centered,
simulation: {
forces: {
link: { length: 200 },
charge: {
strength: 200,
},
collision: {
radiusMultiplier: 10,
strength: 300,
},
},
},
zoom: {
min: 0.3,
max: 2,
},
}),
)
}, [colors, graphRef, graph])

useEffect(() => {
return () => {
graphController?.shutdown()
}
}, [graphController])

// const tasks = graphController?.nodeTypes.sort() ?? []

return (
<div className="relative flex-1">
{!graphController ? (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<span className="text-gray-700">Loading</span>
</div>
) : null}
<div ref={graphRef} className="bg-dotted h-full w-full bg-gray-50 " />
</div>
)
}
11 changes: 11 additions & 0 deletions packages/turbo-graph-ui/app/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client'

import { useRouter } from 'next/navigation'
import type { ChangeEventHandler } from 'react'

export function TaskInput() {
const router = useRouter()
const onChange: ChangeEventHandler<HTMLInputElement> = (event) =>
router.push(`/?tasks=${event.target.value}`)
return <input type="text" onChange={onChange} />
}
15 changes: 15 additions & 0 deletions packages/turbo-graph-ui/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import './globals.css'

export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className="h-full w-full">
<body className="m-0 h-full w-full">{children}</body>
</html>
)
}
Loading

0 comments on commit 9098195

Please sign in to comment.