diff --git a/client/src/components/PlayCanvas/PlayCanvas.css b/client/src/components/PlayCanvas/PlayCanvas.css index c0dcc93..29f910d 100644 --- a/client/src/components/PlayCanvas/PlayCanvas.css +++ b/client/src/components/PlayCanvas/PlayCanvas.css @@ -17,4 +17,80 @@ .playcanvas.grabbing { cursor: grabbing; -} \ No newline at end of file +} + +.execute-btn { + position: absolute; + top: 20px; + left: 20px; + padding: 8px 16px; + border-radius: 4px; + border: none; + background-color: var(--color-primary); + color: var(--color-bg-1); + font-size: 14px; +} + +.execute-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.last-result-summary { + position: absolute; + top: 60px; + left: 20px; + padding: 4px 8px; + background-color: rgba(0, 0, 0, 0.4); + color: var(--color-bg-1); + border-radius: 4px; + font-size: 12px; +} + +.node { + position: absolute; + width: 120px; + padding: 8px; + background-color: var(--color-bg-2); + border: 1px solid var(--color-border-grey); + border-radius: 6px; + cursor: grab; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.node-title { + font-size: 12px; + color: var(--color-text-grey); + margin-bottom: 4px; +} + +.node-ports { + display: flex; + justify-content: space-between; + align-items: center; +} + +.node-port { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--color-primary); +} + +.node-port-in { + background-color: var(--color-accent); +} + +.edges-layer { + position: absolute; + top: 0; + left: 0; + width: 2000px; + height: 2000px; + pointer-events: none; +} + +.edge-line { + stroke: var(--color-primary); + stroke-width: 2; +} diff --git a/client/src/components/PlayCanvas/PlayCanvas.tsx b/client/src/components/PlayCanvas/PlayCanvas.tsx index 40d9d57..8a7cb70 100644 --- a/client/src/components/PlayCanvas/PlayCanvas.tsx +++ b/client/src/components/PlayCanvas/PlayCanvas.tsx @@ -1,71 +1,260 @@ -import { useState } from "react" +import type React from "react" +import { useMemo, useState } from "react" + +import { + type ExecutionResult, + type DataFrame, + type WorkflowDefinition, + type NodeType, + type WorkflowNode, + WorkflowExecutionEngine +} from "../../engine/workflow" import "./PlayCanvas.css" -const PlayCanvas = () => { +interface PlayCanvasProps { + selectedNodeType: NodeType | null + onExecutionResultChange?: (result: ExecutionResult | null) => void + onNodeTypeConsumed?: () => void +} - const [ isGrabbing, setGrabbing ] = useState(false) +const PlayCanvas = ({ + selectedNodeType, + onExecutionResultChange, + onNodeTypeConsumed +}: PlayCanvasProps) => { + const [isGrabbing, setGrabbing] = useState(false) + const [lastResult, setLastResult] = useState(null) + const [isRunning, setIsRunning] = useState(false) + const [draggingNodeId, setDraggingNodeId] = useState(null) + const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>(null) + const [connectingFromNodeId, setConnectingFromNodeId] = useState(null) - const handleMouseDown = () => { - setGrabbing(true) - } + const initialDefinition: WorkflowDefinition = useMemo( + () => ({ + nodes: [], + edges: [] + }), + [] + ) - const handleMouseMove = (e) => { - if (isGrabbing) { - const board = e.target - const docWidth = document.documentElement.clientWidth - const docHeight = document.documentElement.clientHeight + const [definition, setDefinition] = useState(initialDefinition) - // A very complicated logic to prevent the board from being dragged out of the viewport - if (board.offsetLeft <= 0) { - board.style.left = `${e.movementX + board.offsetLeft}px` - } - else { - board.style.left = "0px" - } + const handleCanvasMouseDown = (e: React.MouseEvent) => { + if (selectedNodeType) { + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + const newNode: WorkflowNode = { + id: `n-${Date.now()}`, + type: selectedNodeType, + data: {}, + position: { x, y } + } + setDefinition(prev => ({ + ...prev, + nodes: [...prev.nodes, newNode] + })) + if (onNodeTypeConsumed) { + onNodeTypeConsumed() + } + return + } + setGrabbing(true) + } - if (board.offsetLeft >= docWidth - 2000) { - board.style.left = `${e.movementX + board.offsetLeft}px` - } - else { - board.style.left = `${docWidth - 2000}px` - } + const handleNodeMouseDown = ( + e: React.MouseEvent, + nodeId: string + ) => { + e.stopPropagation() + const rect = e.currentTarget.getBoundingClientRect() + const offsetX = e.clientX - rect.left + const offsetY = e.clientY - rect.top + setDraggingNodeId(nodeId) + setDragOffset({ x: offsetX, y: offsetY }) + } + const handleCanvasMouseMove = (e: React.MouseEvent) => { + if (draggingNodeId && dragOffset) { + const canvasRect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - canvasRect.left - dragOffset.x + const y = e.clientY - canvasRect.top - dragOffset.y + setDefinition(prev => ({ + ...prev, + nodes: prev.nodes.map(node => + node.id === draggingNodeId ? { ...node, position: { x, y } } : node + ) + })) + return + } - if (board.offsetTop <= 0) { - board.style.top = `${e.movementY + board.offsetTop}px` - } - else { - board.style.top = "0px" - } + if (!isGrabbing) { + return + } + const board = e.currentTarget + const docWidth = document.documentElement.clientWidth + const docHeight = document.documentElement.clientHeight - if (board.offsetTop > docHeight - 2000) { - board.style.top = `${e.movementY + board.offsetTop}px` - } - else { - board.style.top = `${docHeight - 2000}px` - } + if (board.offsetLeft <= 0) { + board.style.left = `${e.movementX + board.offsetLeft}px` + } else { + board.style.left = "0px" + } + + if (board.offsetLeft >= docWidth - 2000) { + board.style.left = `${e.movementX + board.offsetLeft}px` + } else { + board.style.left = `${docWidth - 2000}px` + } + if (board.offsetTop <= 0) { + board.style.top = `${e.movementY + board.offsetTop}px` + } else { + board.style.top = "0px" + } - } + if (board.offsetTop > docHeight - 2000) { + board.style.top = `${e.movementY + board.offsetTop}px` + } else { + board.style.top = `${docHeight - 2000}px` } + } - const handleMouseUp = () => { - setGrabbing(false) + const handleCanvasMouseUp = () => { + setGrabbing(false) + setDraggingNodeId(null) + setDragOffset(null) + } + + const handlePortMouseDown = ( + e: React.MouseEvent, + nodeId: string, + kind: "source" | "target" + ) => { + e.stopPropagation() + if (kind === "source") { + setConnectingFromNodeId(nodeId) + return + } + if (kind === "target" && connectingFromNodeId && connectingFromNodeId !== nodeId) { + const edgeId = `e-${connectingFromNodeId}-${nodeId}-${Date.now()}` + setDefinition(prev => ({ + ...prev, + edges: [...prev.edges, { id: edgeId, source: connectingFromNodeId, target: nodeId }] + })) + setConnectingFromNodeId(null) } + } - return ( -
-
+ const handleExecute = async () => { + setIsRunning(true) + try { + const engine = new WorkflowExecutionEngine() + const logs: string[] = [] + const ctx = { + log: (message: string) => { + logs.push(message) + } + } + const result = await engine.execute(definition, ctx) + const outputNodeIds = definition.nodes + .filter(n => n.type === "output") + .map(n => n.id) + const outputs: Record = {} + for (const nodeId of outputNodeIds) { + const frames = result.nodeOutputs[nodeId] + if (frames && frames.length > 0) { + outputs[nodeId] = frames[0] + } + } + const enriched: ExecutionResult = { + nodeOutputs: result.nodeOutputs + } + setLastResult(enriched) + if (onExecutionResultChange) { + onExecutionResultChange(enriched) + } + console.log("Workflow logs:", logs) + console.log("Workflow outputs:", outputs) + } catch (error) { + console.error("Workflow execution failed", error) + setLastResult(null) + if (onExecutionResultChange) { + onExecutionResultChange(null) + } + } finally { + setIsRunning(false) + } + } + return ( +
+
+ + {lastResult && ( +
+ Executed nodes: {Object.keys(lastResult.nodeOutputs).length} +
+ )} + + {definition.edges.map(edge => { + const source = definition.nodes.find(n => n.id === edge.source) + const target = definition.nodes.find(n => n.id === edge.target) + if (!source || !target) { + return null + } + const x1 = source.position.x + 100 + const y1 = source.position.y + 20 + const x2 = target.position.x + const y2 = target.position.y + 20 + return ( + + ) + })} + + {definition.nodes.map(node => ( +
handleNodeMouseDown(e, node.id)} + > +
{node.type}
+
+
handlePortMouseDown(e, node.id, "source")} + /> +
handlePortMouseDown(e, node.id, "target")} + />
-
- ) +
+ ))} +
+
+ ) } -export default PlayCanvas \ No newline at end of file +export default PlayCanvas diff --git a/client/src/components/Previewer/Previewer.css b/client/src/components/Previewer/Previewer.css index 9c83071..cdcd051 100644 --- a/client/src/components/Previewer/Previewer.css +++ b/client/src/components/Previewer/Previewer.css @@ -33,4 +33,12 @@ .previewer.close .toggle-btn { left: -40px; -} \ No newline at end of file +} + +.previewer-content { + padding: 0.8rem; + font-size: 0.8rem; + color: var(--color-text-grey); + overflow: auto; + height: calc(100vh - 60px); +} diff --git a/client/src/components/Previewer/Previewer.tsx b/client/src/components/Previewer/Previewer.tsx index 5b2f72e..2d90562 100644 --- a/client/src/components/Previewer/Previewer.tsx +++ b/client/src/components/Previewer/Previewer.tsx @@ -1,21 +1,42 @@ import { useState } from "react" import "./Previewer.css" -const Previewer = () => { - const [ open, setOpen ] = useState(true) +import { type ExecutionResult } from "../../engine/workflow" - return ( -
-
- -
-
- ) +interface PreviewerProps { + executionResult: ExecutionResult | null } -export default Previewer \ No newline at end of file +const Previewer = ({ executionResult }: PreviewerProps) => { + const [open, setOpen] = useState(true) + + const outputEntries = + executionResult?.nodeOutputs ? Object.entries(executionResult.nodeOutputs) : [] + + const firstOutputFrame = + outputEntries.length > 0 && outputEntries[outputEntries.length - 1][1][0] + ? outputEntries[outputEntries.length - 1][1][0] + : null + + return ( +
+
+ +
+
+ {firstOutputFrame ? ( +
{JSON.stringify(firstOutputFrame, null, 2)}
+ ) : ( + No execution result yet + )} +
+
+ ) +} + +export default Previewer diff --git a/client/src/components/Sidebar/Sidebar.css b/client/src/components/Sidebar/Sidebar.css index 1f9f973..786795f 100644 --- a/client/src/components/Sidebar/Sidebar.css +++ b/client/src/components/Sidebar/Sidebar.css @@ -6,15 +6,19 @@ transition: 0.3s; background: var(--color-bg-2); border-right: 1px solid var(--color-border-grey); + overflow: hidden; } .sidebar.close { - width: 0px; + width: 60px; } .sidebar .toggle-btn { position: relative; - left: 5px; +} + +.sidebar.close .toggle-btn { + right: -40px; } .sidebar-logo { diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index efd1768..cb47459 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -5,70 +5,77 @@ import "./Sidebar.css" import { LuFileInput } from "react-icons/lu" import { IoSettingsOutline } from "react-icons/io5" -import { HiLightningBolt } from "react-icons/hi"; +import { HiLightningBolt } from "react-icons/hi" +import { type NodeType } from "../../engine/workflow" -const Sidebar = () => { +interface SidebarProps { + onSelectNodeType?: (type: NodeType) => void +} - const [ open, setOpen ] = useState(true) +const Sidebar = ({ onSelectNodeType }: SidebarProps) => { + const [open] = useState(true) - return ( -
- {/*
- -
*/} -
- - NodeFlow
-
-
-
-
- Input -
-
    -
  • - -
  • -
-
+ return ( +
+
+
+ + NodeFlow
+
+
+
+
Input
+
    +
  • + +
  • +
+
-
-
- Transformation -
-
    -
  • - -
  • -
-
+
+
Transformation
+
    +
  • + +
  • +
+
-
-
- Output -
-
    -
  • - -
  • -
-
-
+
+
Output
+
    +
  • + +
  • +
- ) +
+
+ ) } -export default Sidebar \ No newline at end of file +export default Sidebar diff --git a/client/src/engine/workflow.ts b/client/src/engine/workflow.ts new file mode 100644 index 0000000..1a27685 --- /dev/null +++ b/client/src/engine/workflow.ts @@ -0,0 +1,306 @@ +export type NodeType = + | "csvInput" + | "jsonInput" + | "xmlInput" + | "filter" + | "map" + | "normalize" + | "dropColumns" + | "dropRows" + | "output" + | "visualization" + +export interface Position { + x: number + y: number +} + +export interface NodeData { + [key: string]: unknown +} + +export interface WorkflowNode { + id: string + type: NodeType + data: NodeData + position: Position +} + +export interface WorkflowEdge { + id: string + source: string + target: string +} + +export interface WorkflowDefinition { + nodes: WorkflowNode[] + edges: WorkflowEdge[] +} + +export interface Workflow { + _id: string + name: string + definition: WorkflowDefinition +} + +export type Row = Record + +export type DataFrame = Row[] + +export interface NodeExecutionContext { + log(message: string): void +} + +export abstract class BaseNodeRuntime { + readonly id: string + readonly type: NodeType + readonly config: NodeData + + constructor(id: string, type: NodeType, config: NodeData) { + this.id = id + this.type = type + this.config = config + } + + abstract execute(inputs: DataFrame[], ctx: NodeExecutionContext): Promise +} + +class CsvInputNodeRuntime extends BaseNodeRuntime { + async execute(inputs: DataFrame[], ctx: NodeExecutionContext): Promise { + ctx.log(`Executing csvInput node ${this.id}`) + const sample: DataFrame = [ + { id: 1, name: "Alice", age: 25 }, + { id: 2, name: "Bob", age: 17 }, + { id: 3, name: "Charlie", age: 32 } + ] + return [sample] + } +} + +class FilterNodeRuntime extends BaseNodeRuntime { + async execute(inputs: DataFrame[], ctx: NodeExecutionContext): Promise { + ctx.log(`Executing filter node ${this.id}`) + const input = inputs[0] ?? [] + const condition = String(this.config.condition ?? "") + const match = condition.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*>\s*(\d+)\s*$/) + if (!match) { + return [input] + } + const [, field, thresholdStr] = match + const threshold = Number(thresholdStr) + const filtered = input.filter(row => { + const value = row[field] + if (typeof value === "number") { + return value > threshold + } + if (typeof value === "string") { + const numeric = Number(value) + if (!Number.isNaN(numeric)) { + return numeric > threshold + } + } + return false + }) + return [filtered] + } +} + +class OutputNodeRuntime extends BaseNodeRuntime { + async execute(inputs: DataFrame[], ctx: NodeExecutionContext): Promise { + ctx.log(`Executing output node ${this.id}`) + const input = inputs[0] ?? [] + ctx.log(`Output rows: ${input.length}`) + return [input] + } +} + +const nodeRegistry: Record BaseNodeRuntime> = + { + csvInput: CsvInputNodeRuntime, + jsonInput: CsvInputNodeRuntime, + xmlInput: CsvInputNodeRuntime, + filter: FilterNodeRuntime, + map: FilterNodeRuntime, + normalize: FilterNodeRuntime, + dropColumns: FilterNodeRuntime, + dropRows: FilterNodeRuntime, + output: OutputNodeRuntime, + visualization: OutputNodeRuntime + } + +export interface ExecutionResult { + nodeOutputs: Record +} + +export class WorkflowExecutionEngine { + async execute(definition: WorkflowDefinition, ctx: NodeExecutionContext): Promise { + const nodesById = new Map(definition.nodes.map(n => [n.id, n])) + const runtimeNodes = new Map() + + for (const node of definition.nodes) { + const ctor = nodeRegistry[node.type] + if (!ctor) { + throw new Error(`No runtime registered for node type ${node.type}`) + } + runtimeNodes.set(node.id, new ctor(node.id, node.type, node.data)) + } + + const adjacency = new Map() + const indegree = new Map() + + for (const node of definition.nodes) { + adjacency.set(node.id, []) + indegree.set(node.id, 0) + } + + for (const edge of definition.edges) { + const list = adjacency.get(edge.source) + if (!list) { + throw new Error(`Invalid source node ${edge.source}`) + } + list.push(edge.target) + indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1) + } + + const queue: string[] = [] + for (const [nodeId, deg] of indegree.entries()) { + if (deg === 0) { + queue.push(nodeId) + } + } + + const nodeInputs = new Map() + const nodeOutputs: Record = {} + + for (const nodeId of nodesById.keys()) { + nodeInputs.set(nodeId, []) + } + + let processedCount = 0 + + while (queue.length > 0) { + const nodeId = queue.shift() as string + const runtimeNode = runtimeNodes.get(nodeId) + if (!runtimeNode) { + throw new Error(`Missing runtime node for id ${nodeId}`) + } + + const inputs = nodeInputs.get(nodeId) ?? [] + const outputs = await runtimeNode.execute(inputs, ctx) + nodeOutputs[nodeId] = outputs + + const neighbors = adjacency.get(nodeId) ?? [] + for (const neighbor of neighbors) { + const neighborInputs = nodeInputs.get(neighbor) + if (!neighborInputs) { + throw new Error(`Missing inputs map for node ${neighbor}`) + } + neighborInputs.push(...outputs) + + const updatedDegree = (indegree.get(neighbor) ?? 0) - 1 + indegree.set(neighbor, updatedDegree) + if (updatedDegree === 0) { + queue.push(neighbor) + } + } + + processedCount += 1 + } + + if (processedCount !== definition.nodes.length) { + throw new Error("Workflow contains cycles or unreachable nodes") + } + + return { nodeOutputs } + } +} + +export class WorkflowEditorState { + private workflow: WorkflowDefinition + + constructor(initial: WorkflowDefinition) { + this.workflow = structuredClone(initial) + } + + getDefinition(): WorkflowDefinition { + return structuredClone(this.workflow) + } + + addNode(node: WorkflowNode): void { + this.workflow.nodes.push(node) + } + + updateNode(id: string, partial: Partial>): void { + const node = this.workflow.nodes.find(n => n.id === id) + if (!node) { + return + } + if (partial.type !== undefined) { + node.type = partial.type + } + if (partial.data !== undefined) { + node.data = { ...node.data, ...partial.data } + } + if (partial.position !== undefined) { + node.position = partial.position + } + } + + removeNode(id: string): void { + this.workflow.nodes = this.workflow.nodes.filter(n => n.id !== id) + this.workflow.edges = this.workflow.edges.filter( + e => e.source !== id && e.target !== id + ) + } + + addEdge(edge: WorkflowEdge): void { + const exists = this.workflow.edges.some(e => e.id === edge.id) + if (!exists) { + this.workflow.edges.push(edge) + } + } + + removeEdge(id: string): void { + this.workflow.edges = this.workflow.edges.filter(e => e.id !== id) + } + + moveNode(id: string, position: Position): void { + const node = this.workflow.nodes.find(n => n.id === id) + if (!node) { + return + } + node.position = position + } +} + +export abstract class BaseNode { + readonly id: string + readonly type: NodeType + position: Position + data: NodeData + + constructor(id: string, type: NodeType, position: Position, data: NodeData) { + this.id = id + this.type = type + this.position = position + this.data = data + } + + abstract getDisplayName(): string +} + +export abstract class BaseEdge { + readonly id: string + readonly sourceId: string + readonly targetId: string + + constructor(id: string, sourceId: string, targetId: string) { + this.id = id + this.sourceId = sourceId + this.targetId = targetId + } + + abstract isDirected(): boolean +} + + diff --git a/client/src/pages/Playground/Playground.tsx b/client/src/pages/Playground/Playground.tsx index d3fadca..843e651 100644 --- a/client/src/pages/Playground/Playground.tsx +++ b/client/src/pages/Playground/Playground.tsx @@ -1,25 +1,29 @@ +import { useState } from "react" import "./Playground.css" import Sidebar from "../../components/Sidebar/Sidebar" import PlayCanvas from "../../components/PlayCanvas/PlayCanvas" import Previewer from "../../components/Previewer/Previewer" +import { type ExecutionResult, type NodeType } from "../../engine/workflow" const Playground = () => { - return ( -
-
- {/* Sidebar */} - - - {/* Canvas */} - + const [executionResult, setExecutionResult] = useState(null) + const [selectedNodeType, setSelectedNodeType] = useState(null) - {/* Previewer */} - -
+ return ( +
+
+ + setSelectedNodeType(null)} + /> + +
) } -export default Playground \ No newline at end of file +export default Playground