diff --git a/package.json b/package.json index a485a16..b7bbaa1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "cors": "ts-node --swc ./src/cors.ts", "build": "cross-env NODE_OPTIONS=--max_old_space_size=10240 vite build", "build:worker": "esbuild src/tools/graph/forcegraph/ForceGraphWorker.ts --bundle --format=esm --outfile=src/tools/graph/forcegraph/ForceGraphWorker.bundle.js --loader:.wasm=binary", + "build:dag-worker": "esbuild src/tools/graph/dag/DagWorker.ts --bundle --format=esm --outfile=src/tools/graph/dag/DagWorker.bundle.js --loader:.wasm=binary", "start": "ts-node server.js", "format": "prettier --write \"**/*.{ts,tsx}\"", "format-scss": "stylelint \"**/*.scss\" --fix", diff --git a/src/tools/GraphPage.tsx b/src/tools/GraphPage.tsx index d01cc82..79ae0fc 100644 --- a/src/tools/GraphPage.tsx +++ b/src/tools/GraphPage.tsx @@ -23,6 +23,7 @@ import ToolView from './views/ToolView' import Tabs from './components/Tabs' import './graph/forcegraph/ForceGraph' +import './graph/dag/Dag' const tabs = [ { label: 'Pipelines', value: 'pipeline' }, diff --git a/src/tools/graph/dag/Dag.tsx b/src/tools/graph/dag/Dag.tsx new file mode 100644 index 0000000..4ad1b60 --- /dev/null +++ b/src/tools/graph/dag/Dag.tsx @@ -0,0 +1,556 @@ +/** Import all engine dependencies */ +import '@ir-engine/engine' +/** Use fly controls */ +import '@ir-engine/spatial/src/camera/systems/CameraOrbitSystem' + +import { + createEntity, + defineSystem, + ECSState, + EngineState, + Entity, + EntityTreeComponent, + getComponent, + removeEntity, + setComponent, + SimulationSystemGroup, + UndefinedEntity +} from '@ir-engine/ecs' +import { InstancingComponent } from '@ir-engine/engine/src/scene/components/InstancingComponent' +import { defineState, getMutableState, getState, useHookstate, useMutableState } from '@ir-engine/hyperflux' +import { ReferenceSpaceState, TransformComponent } from '@ir-engine/spatial' +import { CameraOrbitComponent } from '@ir-engine/spatial/src/camera/components/CameraOrbitComponent' +import { createTransitionState } from '@ir-engine/spatial/src/common/functions/createTransitionState' +import { NameComponent } from '@ir-engine/spatial/src/common/NameComponent' +import { InputComponent } from '@ir-engine/spatial/src/input/components/InputComponent' +import { InputSourceComponent } from '@ir-engine/spatial/src/input/components/InputSourceComponent' +import { MeshComponent } from '@ir-engine/spatial/src/renderer/components/MeshComponent' +import { ObjectComponent } from '@ir-engine/spatial/src/renderer/components/ObjectComponent' +import { setVisibleComponent } from '@ir-engine/spatial/src/renderer/components/VisibleComponent' +import * as dat from 'dat.gui' +import React, { useEffect } from 'react' +import { + BackSide, + BufferAttribute, + BufferGeometry, + BoxGeometry, + Color, + DataTexture, + DoubleSide, + InstancedBufferAttribute, + InstancedMesh, + Line, + Matrix4, + MeshBasicMaterial, + NormalBlending, + Quaternion, + RawShaderMaterial, + Sphere, + SRGBColorSpace, + Vector3 +} from 'three' +import { stringToColor } from '../../../utils/stringToColor' +import { JSONSchemaType } from '../../json-schema/JSONSchema' +import { TargetRegistry, TargetSchemaDefinition } from '../../registries/TargetRegistry' +import { startWebworker } from './createWorker' + +export interface Node { + id: string | number + label: string + group: string + imageSrc: string +} + +export interface Edge { + id: number + source: number + target: number + weight: number +} + +export interface NodeData { + nodes: Node[] + edges: Edge[] +} + +export const dagState = defineState({ + name: 'hxafield.conjure.dagState', + initial: { + lineEntity: null as Entity | null, + meshEntity: null as Entity | null, + atlasIndices: [] as number[], + nodes: [] as Array, + links: [] as Array + } +}) + +const iconScale = 5 +const simulationScale = 100 +const graphScale = iconScale / simulationScale + +// For Working +const m = new Matrix4() +const p = new Vector3() +const q = new Quaternion() +const s = new Vector3(1, 1, 1) + +const _vec3 = new Vector3() +const sphere = new Sphere() +sphere.radius = graphScale + +let selectedNodeIndex = -1 +let hoveredNodeIndex = -1 +let hoverAlpha = 1 +const inactiveAlpha = 0.01 +let nodeFocusTransition = createTransitionState(0.25, 'OUT') + +// buffer is 3 floats per vertex, 6 floats per line +let lastBuffer: ArrayBuffer | null = null + +const execute = () => { + const { lineEntity, links, nodes, meshEntity } = getState(dagState) + if (!meshEntity) return + + const mesh = getComponent(meshEntity, MeshComponent) as InstancedMesh + + const viewer = getState(ReferenceSpaceState).viewerEntity + + const cameraTransform = getComponent(viewer, TransformComponent) + const cameraPosition = cameraTransform.position + const cameraRotation = cameraTransform.rotation + + if (!lastBuffer) return + + nodeFocusTransition.update(getState(ECSState).deltaSeconds, (alpha) => { + hoverAlpha = 1 - alpha * inactiveAlpha + }) + + if (lineEntity) { + const colors = new Float32Array(links.length * 8) + for (let i = 0; i < links.length; i++) { + const link = links[i] + + const hoveredNodeID = hoveredNodeIndex === -1 ? null : nodes[hoveredNodeIndex].id + const selectedNodeID = selectedNodeIndex === -1 ? null : nodes[selectedNodeIndex].id + const isFocused = + link.source === hoveredNodeID || + link.target === hoveredNodeID || + link.source === selectedNodeID || + link.target === selectedNodeID + const weight = 1 //strengthFuncs[strengthFunc](links[i]) + const alpha = isFocused ? hoverAlpha : inactiveAlpha + const currentLinkNodeIndex = i * 8 + colors[currentLinkNodeIndex] = weight + colors[currentLinkNodeIndex + 1] = weight + colors[currentLinkNodeIndex + 2] = weight + colors[currentLinkNodeIndex + 3] = alpha + colors[currentLinkNodeIndex + 4] = weight + colors[currentLinkNodeIndex + 5] = weight + colors[currentLinkNodeIndex + 6] = weight + colors[currentLinkNodeIndex + 7] = alpha + } + const line = getComponent(lineEntity, ObjectComponent) as Line + line.geometry.setAttribute('color', new BufferAttribute(colors, 4, true)) + } + + // update node positions + for (let i = 0; i < nodes.length; i++) { + const x = lastBuffer[i * 3] * graphScale + const y = lastBuffer[i * 3 + 1] * graphScale + const z = lastBuffer[i * 3 + 2] * graphScale + p.set(x, y, z) + mesh.setMatrixAt(i, m.compose(p, cameraRotation, s)) + } + mesh.instanceMatrix.needsUpdate = true + + const rayhits = [] as { x: number; y: number; z: number; i: number; distance: number }[] + + const inputSources = InputComponent.getInputSourceEntities(viewer) + + if (!inputSources.length) { + if (selectedNodeIndex === -1) nodeFocusTransition.setState('OUT') + return + } + + const buttons = InputComponent.getButtons(viewer) + if (!buttons.PrimaryClick?.inputSourceEntity) return + + const inputSource = getComponent(buttons.PrimaryClick?.inputSourceEntity, InputSourceComponent) + const ray = inputSource.raycaster.ray + + for (let i = 0; i < nodes.length; i++) { + const x = lastBuffer[i * 3] * graphScale + const y = lastBuffer[i * 3 + 1] * graphScale + const z = lastBuffer[i * 3 + 2] * graphScale + p.set(x, y, z) + sphere.center.copy(p) + const hit = ray.intersectSphere(sphere, _vec3) + if (hit) rayhits.push({ x, y, z, i, distance: cameraPosition.distanceToSquared(p) }) + } + + const closestHit = rayhits.sort((a, b) => a.distance - b.distance)[0] + if (closestHit) { + hoveredNodeIndex = closestHit.i + } else { + hoveredNodeIndex = -1 + } + + if (buttons.PrimaryClick?.down) { + selectedNodeIndex = selectedNodeIndex === hoveredNodeIndex ? -1 : hoveredNodeIndex + } + + nodeFocusTransition.setState(selectedNodeIndex > -1 || hoveredNodeIndex > -1 ? 'IN' : 'OUT') +} + +const reactor = () => { + useEffect(() => { + TargetRegistry.register(DagSchema) + }, []) + + const dag = useHookstate(getMutableState(dagState)) + const { originEntity, viewerEntity } = useMutableState(ReferenceSpaceState).value + + useEffect(() => { + const state = getState(dagState) + + if (!state.nodes.length || !state.links.length || !originEntity || !viewerEntity) return + + const { worker, id, update, destroy } = startWebworker(state.nodes as Node[], state.links as Edge[], (data) => { + const linksOffset = state.nodes.length * 3 + const positions = new Float32Array(state.links.length * 6) + for (let i = 0; i < state.links.length; i++) { + const currentLinkIndex = i * 6 + positions[currentLinkIndex] = data[linksOffset + currentLinkIndex] * graphScale + positions[currentLinkIndex + 1] = data[linksOffset + currentLinkIndex + 1] * graphScale + positions[currentLinkIndex + 2] = data[linksOffset + currentLinkIndex + 2] * graphScale + positions[currentLinkIndex + 3] = data[linksOffset + currentLinkIndex + 3] * graphScale + positions[currentLinkIndex + 4] = data[linksOffset + currentLinkIndex + 4] * graphScale + positions[currentLinkIndex + 5] = data[linksOffset + currentLinkIndex + 5] * graphScale + } + const line = getComponent(lineEntity, ObjectComponent) as Line + line.geometry.setAttribute('position', new BufferAttribute(positions, 3)) + lastBuffer = data + }) + + /** UI */ + const gui = new dat.GUI() + gui.domElement.style.pointerEvents = 'all' + const folder1 = gui.addFolder('DAG Options') + folder1.closed = false + const options = { + nodeSpacing: 50, + levelSpacing: 100, + alignment: 'center', + direction: 'horizontal', + restart: () => { + update({ + restart: true, + nodeSpacing: nodeSpacingProperty.getValue(), + levelSpacing: levelSpacingProperty.getValue(), + alignment: alignmentProperty.getValue(), + direction: directionProperty.getValue() + }) + }, + reset: () => { + nodeSpacingProperty.setValue(50) + levelSpacingProperty.setValue(100) + alignmentProperty.setValue('center') + directionProperty.setValue('horizontal') + update({ nodeSpacing: 50, levelSpacing: 100, alignment: 'center', direction: 'horizontal' }) + options.restart() + } + } + const nodeSpacingProperty = folder1 + .add(options, 'nodeSpacing', 10, 200) + .onFinishChange((value) => { + update({ nodeSpacing: value }) + }) + .name('Node Spacing') + const levelSpacingProperty = folder1 + .add(options, 'levelSpacing', 50, 300) + .onFinishChange((value) => { + update({ levelSpacing: value }) + }) + .name('Level Spacing') + + const alignmentProperty = folder1 + .add(options, 'alignment', ['top', 'center', 'bottom']) + .onFinishChange((value) => { + update({ alignment: value }) + }) + .name('Alignment') + + const directionProperty = folder1 + .add(options, 'direction', ['horizontal', 'vertical']) + .onFinishChange((value) => { + update({ direction: value }) + }) + .name('Direction') + + folder1.add(options, 'restart').name('Restart Layout') + folder1.add(options, 'reset').name('Reset Parameters') + + const entity = createEntity() + setComponent(entity, NameComponent, 'DAG Node') + setComponent(entity, TransformComponent, { position: new Vector3(0, 0, 0) }) + setVisibleComponent(entity, true) + + // Use box geometry for DAG nodes to differentiate from force graph circles + const boxGeom = new BoxGeometry(graphScale * 2, graphScale * 2, graphScale * 0.5) + const material = new MeshBasicMaterial({ side: DoubleSide }) + + const mesh = new InstancedMesh(boxGeom, material, state.nodes.length) + // colors are a quick way to visualize group data + + const colors = new Map() + + // set the color for each node using mesh.setColorAt + for (let i = 0; i < state.nodes.length; i++) { + const node = state.nodes[i] + if (!node.group) continue // will be white + if (!colors.has(node.group)) colors.set(node.group, new Color(stringToColor(node.group))) + const color = colors.get(node.group)! + mesh.setColorAt(i, color) + } + + mesh.frustumCulled = false + setComponent(entity, MeshComponent, mesh) + + setComponent(entity, EntityTreeComponent, { parentEntity: originEntity }) + setComponent(entity, InstancingComponent, { instanceMatrix: mesh.instanceMatrix }) + + getMutableState(dagState).meshEntity.set(entity) + + const lineVertexShader = ` +precision mediump float; +precision mediump int; + +uniform mat4 modelViewMatrix; // optional +uniform mat4 projectionMatrix; // optional + +attribute vec3 position; +attribute vec4 color; + +varying vec3 vPosition; +varying vec4 vColor; + +void main() { + + vPosition = position; + vColor = color; + + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + +}` + + const lineFragmentShader = ` +precision mediump float; +precision mediump int; + +uniform float time; + +varying vec3 vPosition; +varying vec4 vColor; + +void main() { + vec4 color = vec4( vColor ); + + gl_FragColor = color; + +}` + + /** Create Line */ + const line = new Line( + new BufferGeometry(), + new RawShaderMaterial({ + vertexColors: true, + transparent: true, + depthTest: false, + blending: NormalBlending, + fragmentShader: lineFragmentShader, + vertexShader: lineVertexShader + }) + ) + + const lineEntity = createEntity() + setComponent(lineEntity, NameComponent, 'DAG Line') + setComponent(lineEntity, TransformComponent, { position: new Vector3(0, 0, 0) }) + setVisibleComponent(lineEntity, true) + setComponent(lineEntity, ObjectComponent, line) + setComponent(lineEntity, EntityTreeComponent, { parentEntity: originEntity }) + + getMutableState(dagState).lineEntity.set(lineEntity) + + // this is ridiculous + getMutableState(EngineState).isEditing.set(true) + setComponent(viewerEntity, CameraOrbitComponent) + + return () => { + destroy() + gui.destroy() + removeEntity(entity) + removeEntity(lineEntity) + getMutableState(dagState).meshEntity.set(UndefinedEntity) + getMutableState(dagState).lineEntity.set(UndefinedEntity) + } + }, [dag.nodes, dag.links, originEntity, viewerEntity]) + + return null +} + +const DagSystem = defineSystem({ + uuid: 'hexafield.conjure.DagSystem', + insert: { with: SimulationSystemGroup }, + execute, + reactor +}) + +export const ControlHelper = () => { + return ( +
+
+
+ {`(F) to reset view || (Left Click) to orbit || (Scroll) to zoom || (Right Click) to pan || (Right Click + WASD) to fly`} +
+
+
+ ) +} + +const dagSchema: JSONSchemaType = { + type: 'object', + required: ['nodes', 'edges'], + properties: { + nodes: { + type: 'array', + items: { + type: 'object', + required: ['id', 'label'], + properties: { + id: { type: 'string' }, + label: { type: 'string' }, + group: { type: 'string', nullable: true, default: '' }, + image: { type: 'string', nullable: true, default: '' } + } + } + }, + edges: { + type: 'array', + items: { + type: 'object', + required: ['source', 'target'], + properties: { + source: { type: 'string' }, + target: { type: 'string' }, + weight: { type: 'number', nullable: true, default: 1 } + } + } + } + } +} + +type SerializedDagShape = { + nodes: Array<{ + id: string | number + label: string + group?: string + image?: string + }> + edges: Array<{ + source: string | number + target: string | number + weight?: number + }> +} + +export const DagSchema: TargetSchemaDefinition = { + label: 'DAG Graph', + value: dagSchema, + deserialize: (data) => { + const finalData: SerializedDagShape = { nodes: [], edges: [] } + const seenLabels = new Map() + const replacedNodes = {} as Record> + let maxWeight = 0 + + for (const sourceID in data) { + const source = data[sourceID] + if (typeof source !== 'object') continue + + if (Array.isArray(source.nodes)) { + for (let i = 0; i < source.nodes.length; i++) { + const node = source.nodes[i] + const seenNode = seenLabels.get(node.label) + if (seenNode) { + if (!replacedNodes[sourceID]) { + replacedNodes[sourceID] = new Map() + } + replacedNodes[sourceID].set(node.id, seenNode.id) + } else { + seenLabels.set(node.label, { source: sourceID, id: node.id }) + finalData.nodes.push({ + ...node, + group: sourceID + }) + } + } + } + if (Array.isArray(source.edges)) { + for (const edge of source.edges) { + finalData.edges.push({ + source: replacedNodes[sourceID]?.get(edge.source) ?? edge.source, + target: replacedNodes[sourceID]?.get(edge.target) ?? edge.target, + weight: edge.weight + }) + } + } + } + + // ensure all edges have a weight + for (const edge of finalData.edges) { + edge.weight = edge.weight || 1 + maxWeight = Math.max(maxWeight, edge.weight) + } + + const minConnections = 1 + + // quick hack, remove all nodes that only have one edge + const nodeCounts = new Map() + for (const edge of finalData.edges) { + nodeCounts.set(edge.source, (nodeCounts.get(edge.source) || 0) + 1) + nodeCounts.set(edge.target, (nodeCounts.get(edge.target) || 0) + 1) + // scale all weights between 0 and 1 + edge.weight = edge.weight! / maxWeight + } + finalData.nodes = finalData.nodes.filter( + (node) => nodeCounts.get(node.id) && nodeCounts.get(node.id)! >= minConnections + ) + // and now remove all edges that don't have both nodes + finalData.edges = finalData.edges.filter( + (edge) => + finalData.nodes.find((node) => node.id === edge.source) && + finalData.nodes.find((node) => node.id === edge.target) + ) + + if (!finalData.nodes.length) return null! + + getMutableState(dagState).nodes.set(finalData.nodes as NodeData['nodes']) + getMutableState(dagState).links.set(finalData.edges as NodeData['edges']) + } +} diff --git a/src/tools/graph/dag/DagMessages.ts b/src/tools/graph/dag/DagMessages.ts new file mode 100644 index 0000000..0678712 --- /dev/null +++ b/src/tools/graph/dag/DagMessages.ts @@ -0,0 +1,26 @@ +export type ID = string | number + +export type StartMessage = { + id: number + type: 'start' + nodes: Array<{ id: ID; group: string }> + links: Array<{ source: ID; target: ID; weight: number }> +} + +export type UpdateMessage = { + id: number + type: 'update' + restart?: boolean + nodeSpacing?: number + levelSpacing?: number + alignment?: 'top' | 'center' | 'bottom' + direction?: 'horizontal' | 'vertical' + enabledGroups?: Record +} + +export type StopMessage = { + id: number + type: 'stop' +} + +export type DagMessage = StartMessage | UpdateMessage | StopMessage diff --git a/src/tools/graph/dag/DagWorker.bundle.js b/src/tools/graph/dag/DagWorker.bundle.js new file mode 100644 index 0000000..783249e --- /dev/null +++ b/src/tools/graph/dag/DagWorker.bundle.js @@ -0,0 +1,175 @@ +// src/tools/graph/dag/DagWorker.ts +var dagLayouts = /* @__PURE__ */ new Map(); +function calculateDAGLayout(nodes, links, options) { + const { nodeSpacing, levelSpacing, alignment, direction } = options; + const adjList = /* @__PURE__ */ new Map(); + const inDegree = /* @__PURE__ */ new Map(); + const nodeMap = /* @__PURE__ */ new Map(); + nodes.forEach((node) => { + nodeMap.set(node.id, node); + adjList.set(node.id, /* @__PURE__ */ new Set()); + inDegree.set(node.id, 0); + }); + links.forEach((link) => { + if (adjList.has(link.source) && inDegree.has(link.target)) { + adjList.get(link.source).add(link.target); + inDegree.set(link.target, inDegree.get(link.target) + 1); + } + }); + const levels = []; + const queue = []; + const nodeLevel = /* @__PURE__ */ new Map(); + inDegree.forEach((degree, nodeId) => { + if (degree === 0) { + queue.push(nodeId); + } + }); + let currentLevel = 0; + while (queue.length > 0) { + const levelSize = queue.length; + const currentLevelNodes = []; + for (let i = 0; i < levelSize; i++) { + const current = queue.shift(); + currentLevelNodes.push(current); + nodeLevel.set(current, currentLevel); + adjList.get(current).forEach((neighbor) => { + inDegree.set(neighbor, inDegree.get(neighbor) - 1); + if (inDegree.get(neighbor) === 0) { + queue.push(neighbor); + } + }); + } + levels.push(currentLevelNodes); + currentLevel++; + } + const layoutNodes = []; + levels.forEach((level, levelIndex) => { + const levelNodeCount = level.length; + const totalLevelWidth = (levelNodeCount - 1) * nodeSpacing; + level.forEach((nodeId, nodeIndex) => { + const node = nodeMap.get(nodeId); + let x, y, z; + if (direction === "horizontal") { + x = levelIndex * levelSpacing; + y = nodeIndex * nodeSpacing - totalLevelWidth / 2; + z = 0; + if (alignment === "top") { + y = nodeIndex * nodeSpacing; + } else if (alignment === "bottom") { + y = -(levelNodeCount - 1 - nodeIndex) * nodeSpacing; + } + } else { + x = nodeIndex * nodeSpacing - totalLevelWidth / 2; + y = -(levelIndex * levelSpacing); + z = 0; + if (alignment === "top") { + x = nodeIndex * nodeSpacing; + } else if (alignment === "bottom") { + x = -(levelNodeCount - 1 - nodeIndex) * nodeSpacing; + } + } + layoutNodes.push({ + id: nodeId, + group: node.group, + x, + y, + z + }); + }); + }); + return { + nodes: layoutNodes, + links: links.map((link) => ({ ...link })) + }; +} +function updateDAGLayout(id) { + const layout = dagLayouts.get(id); + if (!layout) + return; + const result = calculateDAGLayout( + layout.nodes.map((n) => ({ id: n.id, group: n.group })), + layout.links, + { + nodeSpacing: layout.nodeSpacing, + levelSpacing: layout.levelSpacing, + alignment: layout.alignment, + direction: layout.direction + } + ); + layout.nodes = result.nodes; + layout.links = result.links; + const nodeCount = layout.nodes.length; + const linkCount = layout.links.length; + const buffer = new Float32Array(nodeCount * 3 + linkCount * 6 + 1); + layout.nodes.forEach((node, i) => { + buffer[i * 3] = node.x; + buffer[i * 3 + 1] = node.y; + buffer[i * 3 + 2] = node.z; + }); + const nodeMap = new Map(layout.nodes.map((n) => [n.id, n])); + layout.links.forEach((link, i) => { + const sourceNode = nodeMap.get(link.source); + const targetNode = nodeMap.get(link.target); + if (sourceNode && targetNode) { + const linkOffset = nodeCount * 3 + i * 6; + buffer[linkOffset] = sourceNode.x; + buffer[linkOffset + 1] = sourceNode.y; + buffer[linkOffset + 2] = sourceNode.z; + buffer[linkOffset + 3] = targetNode.x; + buffer[linkOffset + 4] = targetNode.y; + buffer[linkOffset + 5] = targetNode.z; + } + }); + buffer[buffer.length - 1] = id; + self.postMessage(buffer.buffer, [buffer.buffer]); +} +self.onmessage = (event) => { + const message = event.data; + switch (message.type) { + case "start": { + const layout = { + nodes: message.nodes.map((n) => ({ ...n, x: 0, y: 0, z: 0 })), + links: [...message.links], + nodeSpacing: 50, + levelSpacing: 100, + alignment: "center", + direction: "horizontal", + enabledGroups: {} + }; + const groups = new Set(message.nodes.map((n) => n.group)); + groups.forEach((group) => { + layout.enabledGroups[group] = true; + }); + dagLayouts.set(message.id, layout); + updateDAGLayout(message.id); + break; + } + case "update": { + const layout = dagLayouts.get(message.id); + if (!layout) + break; + if (message.nodeSpacing !== void 0) { + layout.nodeSpacing = message.nodeSpacing; + } + if (message.levelSpacing !== void 0) { + layout.levelSpacing = message.levelSpacing; + } + if (message.alignment !== void 0) { + layout.alignment = message.alignment; + } + if (message.direction !== void 0) { + layout.direction = message.direction; + } + if (message.enabledGroups !== void 0) { + layout.enabledGroups = { ...message.enabledGroups }; + } + updateDAGLayout(message.id); + break; + } + case "stop": { + dagLayouts.delete(message.id); + break; + } + } +}; +self.postMessage("ready"); diff --git a/src/tools/graph/dag/DagWorker.ts b/src/tools/graph/dag/DagWorker.ts new file mode 100644 index 0000000..4a09f2c --- /dev/null +++ b/src/tools/graph/dag/DagWorker.ts @@ -0,0 +1,262 @@ +import { ID, DagMessage } from './DagMessages' + +interface Node { + id: ID + group: string + x: number + y: number + z: number +} + +interface Link { + source: ID + target: ID + weight: number +} + +interface DagLayout { + nodes: Node[] + links: Link[] + nodeSpacing: number + levelSpacing: number + alignment: 'top' | 'center' | 'bottom' + direction: 'horizontal' | 'vertical' + enabledGroups: Record +} + +const dagLayouts = new Map() + +// DAG layout algorithm using Kahn's algorithm for topological sorting +function calculateDAGLayout( + nodes: Array<{ id: ID; group: string }>, + links: Array<{ source: ID; target: ID; weight: number }>, + options: { + nodeSpacing: number + levelSpacing: number + alignment: 'top' | 'center' | 'bottom' + direction: 'horizontal' | 'vertical' + } +): { nodes: Node[]; links: Link[] } { + const { nodeSpacing, levelSpacing, alignment, direction } = options + + // Create adjacency list and in-degree count + const adjList = new Map>() + const inDegree = new Map() + const nodeMap = new Map() + + // Initialize all nodes + nodes.forEach(node => { + nodeMap.set(node.id, node) + adjList.set(node.id, new Set()) + inDegree.set(node.id, 0) + }) + + // Build adjacency list and calculate in-degrees + links.forEach(link => { + if (adjList.has(link.source) && inDegree.has(link.target)) { + adjList.get(link.source)!.add(link.target) + inDegree.set(link.target, inDegree.get(link.target)! + 1) + } + }) + + // Topological sort using Kahn's algorithm + const levels: ID[][] = [] + const queue: ID[] = [] + const nodeLevel = new Map() + + // Find all nodes with in-degree 0 + inDegree.forEach((degree, nodeId) => { + if (degree === 0) { + queue.push(nodeId) + } + }) + + let currentLevel = 0 + while (queue.length > 0) { + const levelSize = queue.length + const currentLevelNodes: ID[] = [] + + for (let i = 0; i < levelSize; i++) { + const current = queue.shift()! + currentLevelNodes.push(current) + nodeLevel.set(current, currentLevel) + + // Process all neighbors + adjList.get(current)!.forEach(neighbor => { + inDegree.set(neighbor, inDegree.get(neighbor)! - 1) + if (inDegree.get(neighbor) === 0) { + queue.push(neighbor) + } + }) + } + + levels.push(currentLevelNodes) + currentLevel++ + } + + // Calculate positions + const layoutNodes: Node[] = [] + + levels.forEach((level, levelIndex) => { + const levelNodeCount = level.length + const totalLevelWidth = (levelNodeCount - 1) * nodeSpacing + + level.forEach((nodeId, nodeIndex) => { + const node = nodeMap.get(nodeId)! + let x: number, y: number, z: number + + if (direction === 'horizontal') { + // Horizontal layout + x = levelIndex * levelSpacing + y = nodeIndex * nodeSpacing - totalLevelWidth / 2 + z = 0 + + // Apply alignment + if (alignment === 'top') { + y = nodeIndex * nodeSpacing + } else if (alignment === 'bottom') { + y = -(levelNodeCount - 1 - nodeIndex) * nodeSpacing + } + } else { + // Vertical layout + x = nodeIndex * nodeSpacing - totalLevelWidth / 2 + y = -(levelIndex * levelSpacing) + z = 0 + + // Apply alignment + if (alignment === 'top') { + x = nodeIndex * nodeSpacing + } else if (alignment === 'bottom') { + x = -(levelNodeCount - 1 - nodeIndex) * nodeSpacing + } + } + + layoutNodes.push({ + id: nodeId, + group: node.group, + x, + y, + z + }) + }) + }) + + return { + nodes: layoutNodes, + links: links.map(link => ({ ...link })) + } +} + +function updateDAGLayout(id: number): void { + const layout = dagLayouts.get(id) + if (!layout) return + + const result = calculateDAGLayout( + layout.nodes.map(n => ({ id: n.id, group: n.group })), + layout.links, + { + nodeSpacing: layout.nodeSpacing, + levelSpacing: layout.levelSpacing, + alignment: layout.alignment, + direction: layout.direction + } + ) + + layout.nodes = result.nodes + layout.links = result.links + + // Create output buffer: [nodes positions..., links positions...] + const nodeCount = layout.nodes.length + const linkCount = layout.links.length + + const buffer = new Float32Array(nodeCount * 3 + linkCount * 6 + 1) + + // Add node positions + layout.nodes.forEach((node, i) => { + buffer[i * 3] = node.x + buffer[i * 3 + 1] = node.y + buffer[i * 3 + 2] = node.z + }) + + // Add link positions + const nodeMap = new Map(layout.nodes.map(n => [n.id, n])) + layout.links.forEach((link, i) => { + const sourceNode = nodeMap.get(link.source) + const targetNode = nodeMap.get(link.target) + + if (sourceNode && targetNode) { + const linkOffset = nodeCount * 3 + i * 6 + buffer[linkOffset] = sourceNode.x + buffer[linkOffset + 1] = sourceNode.y + buffer[linkOffset + 2] = sourceNode.z + buffer[linkOffset + 3] = targetNode.x + buffer[linkOffset + 4] = targetNode.y + buffer[linkOffset + 5] = targetNode.z + } + }) + + // Add id at the end + buffer[buffer.length - 1] = id + + ;(self as any).postMessage(buffer.buffer, [buffer.buffer]) +} + +self.onmessage = (event) => { + const message = event.data as DagMessage + + switch (message.type) { + case 'start': { + const layout: DagLayout = { + nodes: message.nodes.map(n => ({ ...n, x: 0, y: 0, z: 0 })), + links: [...message.links], + nodeSpacing: 50, + levelSpacing: 100, + alignment: 'center', + direction: 'horizontal', + enabledGroups: {} + } + + // Initialize enabled groups + const groups = new Set(message.nodes.map(n => n.group)) + groups.forEach(group => { + layout.enabledGroups[group] = true + }) + + dagLayouts.set(message.id, layout) + updateDAGLayout(message.id) + break + } + + case 'update': { + const layout = dagLayouts.get(message.id) + if (!layout) break + + if (message.nodeSpacing !== undefined) { + layout.nodeSpacing = message.nodeSpacing + } + if (message.levelSpacing !== undefined) { + layout.levelSpacing = message.levelSpacing + } + if (message.alignment !== undefined) { + layout.alignment = message.alignment + } + if (message.direction !== undefined) { + layout.direction = message.direction + } + if (message.enabledGroups !== undefined) { + layout.enabledGroups = { ...message.enabledGroups } + } + + updateDAGLayout(message.id) + break + } + + case 'stop': { + dagLayouts.delete(message.id) + break + } + } +} + +// Send ready message +;(self as any).postMessage('ready') diff --git a/src/tools/graph/dag/createWorker.ts b/src/tools/graph/dag/createWorker.ts new file mode 100644 index 0000000..3fd02c5 --- /dev/null +++ b/src/tools/graph/dag/createWorker.ts @@ -0,0 +1,65 @@ +import { createWorkerFromCrossOriginURL } from '@ir-engine/spatial/src/common/functions/createWorkerFromCrossOriginURL' +import { ID, UpdateMessage } from './DagMessages' + +let worker: Worker + +export const createWorker = () => { + // @ts-ignore + const workerPath = new URL('./DagWorker.bundle.js', import.meta.url).href + const worker = createWorkerFromCrossOriginURL(workerPath, true, { name: 'DagWorker' }) + return worker +} + +let graphCounter = 0 + +export const startWebworker = ( + nodes: Array<{ id: ID; group: string }>, + links: Array<{ source: ID; target: ID; weight: number }>, + onData: (data: ArrayBuffer) => void +) => { + if (!worker) { + worker = createWorker() + } + + const readyPromise = new Promise((resolve, reject) => { + worker.onmessage = () => { + resolve() + } + worker.onerror = (error) => { + console.error('worker error', error) + reject(error) + } + }) + + const id = graphCounter++ + + readyPromise.then(() => { + worker.postMessage({ id, type: 'start', nodes, links }) + }) + + const update = (properties: Omit, 'id'>) => { + readyPromise.then(() => { + worker.postMessage({ id, type: 'update', ...properties }) + }) + } + const destroy = () => { + readyPromise.then(() => { + worker.postMessage({ id, type: 'stop' }) + }) + } + readyPromise.then(() => { + worker.onmessage = (e) => { + const data = e.data as ArrayBuffer + if (data.slice(-1)[0] !== id) return + onData(e.data) + } + }) + + return { + worker, + id, + readyPromise, + update, + destroy + } +} diff --git a/src/tools/graph/dag/example.ts b/src/tools/graph/dag/example.ts new file mode 100644 index 0000000..dc85460 --- /dev/null +++ b/src/tools/graph/dag/example.ts @@ -0,0 +1,38 @@ +// Example usage of the DAG visualization +import { DagSchema } from './index' + +// Example DAG data representing a simple workflow +const exampleDAGData = { + nodes: [ + { id: 'start', label: 'Start', group: 'control' }, + { id: 'task1', label: 'Process Data', group: 'processing' }, + { id: 'task2', label: 'Validate Input', group: 'processing' }, + { id: 'decision', label: 'Check Quality', group: 'decision' }, + { id: 'retry', label: 'Retry Process', group: 'processing' }, + { id: 'finalize', label: 'Finalize Results', group: 'output' }, + { id: 'end', label: 'End', group: 'control' } + ], + edges: [ + { source: 'start', target: 'task2', weight: 1 }, + { source: 'task2', target: 'task1', weight: 1 }, + { source: 'task1', target: 'decision', weight: 1 }, + { source: 'decision', target: 'finalize', weight: 1 }, + { source: 'decision', target: 'retry', weight: 0.3 }, + { source: 'retry', target: 'task1', weight: 1 }, + { source: 'finalize', target: 'end', weight: 1 } + ] +} + +// The DAG visualization will automatically: +// 1. Calculate topological ordering using Kahn's algorithm +// 2. Arrange nodes in hierarchical levels +// 3. Apply the selected layout direction and alignment +// 4. Render interactive 3D visualization with Three.js + +// Expected layout (horizontal direction): +// Level 0: start +// Level 1: task2 +// Level 2: task1, retry (retry will be positioned appropriately) +// Level 3: decision +// Level 4: finalize +// Level 5: end diff --git a/src/tools/graph/dag/index.ts b/src/tools/graph/dag/index.ts new file mode 100644 index 0000000..4b72e46 --- /dev/null +++ b/src/tools/graph/dag/index.ts @@ -0,0 +1,3 @@ +export { DagSchema } from './Dag' +export { ControlHelper } from './Dag' +export type { Node, Edge, NodeData } from './Dag'