Compare commits
18 Commits
v1.682.0
...
glm/fix-fl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f7b09c33 | ||
|
|
13a064e898 | ||
|
|
329b4bcdda | ||
|
|
038bc6783d | ||
|
|
7a2c0a452b | ||
|
|
c79863bb46 | ||
|
|
802cdc24d1 | ||
|
|
12d1cdf8f1 | ||
|
|
784c21ba77 | ||
|
|
6322988d45 | ||
|
|
f0ad760589 | ||
|
|
db696293e1 | ||
|
|
29624bd1c9 | ||
|
|
02595b4a30 | ||
|
|
5a3efd7d84 | ||
|
|
78371e97a2 | ||
|
|
1d08dda038 | ||
|
|
1aede4a24c |
@@ -18,10 +18,6 @@
|
||||
/** Offset so the cursor indicator icon doesn't overlap the cursor tip */
|
||||
const CURSOR_INDICATOR_OFFSET = 8
|
||||
|
||||
function nodeOffset(n: Node): number {
|
||||
return ((n.data as Record<string, unknown>)?.offset as number) ?? 0
|
||||
}
|
||||
|
||||
function getSubflowNodesAndEdges(
|
||||
moduleId: string,
|
||||
allNodes: Node[],
|
||||
@@ -68,7 +64,7 @@
|
||||
maxY = -Infinity
|
||||
for (const n of sfNodes) {
|
||||
const abs = absolutePosition(n, allNodes)
|
||||
const x = abs.x + nodeOffset(n)
|
||||
const x = abs.x
|
||||
const y = abs.y
|
||||
const w = n.measured?.width ?? NODE.width
|
||||
const h = n.measured?.height ?? NODE.height
|
||||
@@ -90,7 +86,7 @@
|
||||
let offsetY = containerHeight / 2
|
||||
if (mainNode) {
|
||||
const mainAbs = absolutePosition(mainNode, allNodes)
|
||||
const mx = mainAbs.x + nodeOffset(mainNode) - minX + PADDING
|
||||
const mx = mainAbs.x - minX + PADDING
|
||||
const my = mainAbs.y - minY + PADDING
|
||||
const mw = mainNode.measured?.width ?? NODE.width
|
||||
const mh = mainNode.measured?.height ?? NODE.height
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import {
|
||||
graphBuilder,
|
||||
isTriggerStep,
|
||||
topologicalSort,
|
||||
type InlineScript,
|
||||
type InsertKind,
|
||||
type NodeLayout,
|
||||
@@ -36,7 +35,6 @@
|
||||
import ResultNode from './renderers/nodes/ResultNode.svelte'
|
||||
import BaseEdge from './renderers/edges/BaseEdge.svelte'
|
||||
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
|
||||
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
|
||||
import { Expand, MousePointer, Hand } from 'lucide-svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
|
||||
@@ -71,6 +69,7 @@
|
||||
import type { MoveManager } from './moveManager.svelte'
|
||||
import DragCoordinator from './DragCoordinator.svelte'
|
||||
import type { ModulesTestStates } from '../modulesTest.svelte'
|
||||
import { compoundLayout } from './compoundLayout'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { AssetWithAltAccessType } from '../assets/lib'
|
||||
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
|
||||
@@ -333,7 +332,6 @@
|
||||
type NodeDep = {
|
||||
id: string
|
||||
parentIds?: string[]
|
||||
offset?: number
|
||||
data?: { assets?: AssetWithAltAccessType[] }
|
||||
}
|
||||
type NodePos = { position: { x: number; y: number } }
|
||||
@@ -354,59 +352,21 @@
|
||||
seenId.push(n.id)
|
||||
}
|
||||
|
||||
let nodeWidths: Record<string, number> = {}
|
||||
const nodes2: (NodeDep & NodePos)[] = nodes.map((n) => {
|
||||
return { ...n, position: { x: 0, y: 0 } }
|
||||
// Run recursive compound layout
|
||||
const { positions, bbox } = compoundLayout(nodes, {
|
||||
nodeWidth: NODE.width,
|
||||
nodeHeight: NODE.height,
|
||||
gapH: NODE.gap.horizontal,
|
||||
gapV: NODE.gap.vertical
|
||||
})
|
||||
for (const n of topologicalSort(nodes)) {
|
||||
const endId = n.id + '-end'
|
||||
|
||||
if (nodeWidths[endId] != undefined) {
|
||||
nodeWidths[n.id] = Math.max(nodeWidths[n.id] ?? 0, nodeWidths[endId])
|
||||
}
|
||||
if (n.parentIds && n.parentIds?.length == 1) {
|
||||
const parent = n.parentIds[0]
|
||||
const nodeWidth = nodeWidths[n.id] ?? 1
|
||||
nodeWidths[parent] = (nodeWidths[parent] ?? 0) + nodeWidth
|
||||
}
|
||||
}
|
||||
|
||||
const dag = dagStratify().id(({ id }: NodeDep & NodePos) => id)(nodes2)
|
||||
|
||||
let boxSize: any
|
||||
try {
|
||||
const layout = sugiyama()
|
||||
.decross(nodes.length > 20 ? decrossTwoLayer() : decrossOpt())
|
||||
.coord(coordCenter())
|
||||
.nodeSize((d) => {
|
||||
return [
|
||||
(nodeWidths[d?.data?.['id'] ?? ''] ?? 1) * (NODE.width + NODE.gap.horizontal * 1),
|
||||
NODE.height + NODE.gap.vertical
|
||||
] as readonly [number, number]
|
||||
})
|
||||
boxSize = layout(dag as any)
|
||||
} catch {
|
||||
const layout = sugiyama()
|
||||
.decross(decrossTwoLayer())
|
||||
.coord(coordCenter())
|
||||
.nodeSize(() => [NODE.width + NODE.gap.horizontal, NODE.height + NODE.gap.vertical])
|
||||
boxSize = layout(dag as any)
|
||||
}
|
||||
|
||||
const newNodes = dag.descendants().map((des) => ({
|
||||
id: des.data.id,
|
||||
// Center horizontally
|
||||
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
|
||||
const newNodes = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
position: {
|
||||
x: des.x
|
||||
? // @ts-ignore
|
||||
(des.data.offset ?? 0) +
|
||||
// @ts-ignore
|
||||
des.x +
|
||||
(fullSize ? fullWidth : width) / 2 -
|
||||
boxSize.width / 2 -
|
||||
NODE.width / 2 -
|
||||
(width - fullWidth) / 2
|
||||
: 0,
|
||||
y: des.y || 0
|
||||
x: (positions.get(n.id)?.x ?? 0) + xCenter - NODE.width / 2,
|
||||
y: positions.get(n.id)?.y ?? 0
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -631,7 +591,6 @@
|
||||
Object.values(graph.nodes).map((n) => ({
|
||||
id: n.id,
|
||||
parentIds: n.parentIds,
|
||||
offset: n.data.offset ?? 0,
|
||||
data: { assets: (n.data as any).assets }
|
||||
}))
|
||||
)
|
||||
@@ -640,10 +599,7 @@
|
||||
let assetNodesResult = $showAssets
|
||||
? computeAssetNodes(
|
||||
newNodes.map((n) => ({
|
||||
data: {
|
||||
assets: n.data?.assets as AssetWithAltAccessType[],
|
||||
offset: n.data?.offset as number
|
||||
},
|
||||
data: { assets: n.data?.assets as AssetWithAltAccessType[] },
|
||||
id: n.id,
|
||||
position: n.position
|
||||
}))
|
||||
@@ -674,7 +630,6 @@
|
||||
id: n.id,
|
||||
position: n.position,
|
||||
parentIds: n.parentIds,
|
||||
offset: n.data?.offset ?? 0,
|
||||
data: { assets: (n.data as any)?.assets },
|
||||
type: n.type
|
||||
})),
|
||||
@@ -1039,8 +994,8 @@
|
||||
|
||||
{#if multiSelectEnabled}
|
||||
<SelectionBoundingBox
|
||||
selectedNodes={selectionManager.selectedIds.filter(id =>
|
||||
nodesWithOffset.some(n => n.id === id)
|
||||
selectedNodes={selectionManager.selectedIds.filter((id) =>
|
||||
nodesWithOffset.some((n) => n.id === id)
|
||||
)}
|
||||
allNodes={nodesWithOffset as (Node & { type: string })[]}
|
||||
onDeleteSelected={() => onDeleteMultiple?.(resolvedModuleIds)}
|
||||
@@ -1061,7 +1016,12 @@
|
||||
{@render leftHeader()}
|
||||
</div>
|
||||
{:else}
|
||||
<Controls position="top-right" orientation="horizontal" showLock={false}>
|
||||
<Controls
|
||||
position="top-right"
|
||||
orientation="horizontal"
|
||||
showLock={false}
|
||||
fitViewOptions={{ nodes: nodes.filter((n) => n.type !== 'note') }}
|
||||
>
|
||||
{#if multiSelectEnabled}
|
||||
<div class="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
|
||||
555
frontend/src/lib/components/graph/compoundLayout.ts
Normal file
555
frontend/src/lib/components/graph/compoundLayout.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag'
|
||||
import { NODE } from './util'
|
||||
|
||||
type LayoutNode = {
|
||||
id: string
|
||||
parentIds?: string[]
|
||||
}
|
||||
|
||||
type LayoutConstants = {
|
||||
nodeWidth: number
|
||||
nodeHeight: number
|
||||
gapH: number
|
||||
gapV: number
|
||||
}
|
||||
|
||||
type CompoundGroup = {
|
||||
type: 'branch' | 'loop'
|
||||
headId: string
|
||||
endId: string
|
||||
branches: {
|
||||
labelId: string
|
||||
innerIds: string[]
|
||||
}[]
|
||||
}
|
||||
|
||||
type LayoutResult = {
|
||||
positions: Map<string, { x: number; y: number }>
|
||||
bbox: { width: number; height: number }
|
||||
contentMinX: number
|
||||
}
|
||||
|
||||
const LOOP_INDENT = 25
|
||||
|
||||
/**
|
||||
* Detect compound groups from a flat list of node IDs.
|
||||
* Uses ID naming conventions from graphBuilder:
|
||||
* - BranchAll/BranchOne: node X has children X-branch-N and X-end
|
||||
* - ForLoop/WhileLoop: node X has child X-start and X-end
|
||||
*/
|
||||
function detectGroups(
|
||||
nodeIds: Set<string>,
|
||||
allNodes: Map<string, LayoutNode>,
|
||||
childrenMap: Map<string, string[]>
|
||||
): CompoundGroup[] {
|
||||
const groups: CompoundGroup[] = []
|
||||
|
||||
for (const id of nodeIds) {
|
||||
if (!id.endsWith('-end')) continue
|
||||
|
||||
// Extract base ID (everything before -end)
|
||||
const baseId = id.slice(0, -4)
|
||||
if (!nodeIds.has(baseId)) continue
|
||||
|
||||
const baseNode = allNodes.get(baseId)
|
||||
if (!baseNode) continue
|
||||
|
||||
// Check for branch pattern: probe for baseId-branch-N nodes directly
|
||||
const branchLabelIds: string[] = []
|
||||
if (nodeIds.has(`${baseId}-branch-default`)) {
|
||||
branchLabelIds.push(`${baseId}-branch-default`)
|
||||
}
|
||||
for (let i = 0; nodeIds.has(`${baseId}-branch-${i}`); i++) {
|
||||
branchLabelIds.push(`${baseId}-branch-${i}`)
|
||||
}
|
||||
|
||||
// Check for loop pattern: baseId-start node
|
||||
const hasStart = nodeIds.has(`${baseId}-start`)
|
||||
|
||||
if (branchLabelIds.length > 0) {
|
||||
// Branches are already in correct order: default first, then 0, 1, 2...
|
||||
const branches = branchLabelIds.map((labelId) => ({
|
||||
labelId,
|
||||
innerIds: findInnerIds(labelId, id, nodeIds, childrenMap)
|
||||
}))
|
||||
|
||||
groups.push({ type: 'branch', headId: baseId, endId: id, branches })
|
||||
} else if (hasStart) {
|
||||
const innerIds = findInnerIds(`${baseId}-start`, id, nodeIds, childrenMap)
|
||||
|
||||
groups.push({
|
||||
type: 'loop',
|
||||
headId: baseId,
|
||||
endId: id,
|
||||
branches: [{ labelId: `${baseId}-start`, innerIds }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Find inner node IDs between a label/start node and an end node.
|
||||
* These are nodes that are reachable from the label node but not including
|
||||
* the label or end node themselves.
|
||||
*/
|
||||
function findInnerIds(
|
||||
labelId: string,
|
||||
endId: string,
|
||||
nodeIds: Set<string>,
|
||||
childrenMap: Map<string, string[]>
|
||||
): string[] {
|
||||
const inner: string[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
// BFS from label to find all reachable nodes before end
|
||||
const queue = [labelId]
|
||||
visited.add(labelId)
|
||||
visited.add(endId) // Don't traverse past end
|
||||
|
||||
let qi = 0
|
||||
while (qi < queue.length) {
|
||||
const current = queue[qi++]
|
||||
const kids = childrenMap.get(current) ?? []
|
||||
for (const kid of kids) {
|
||||
if (visited.has(kid)) continue
|
||||
if (!nodeIds.has(kid)) continue
|
||||
visited.add(kid)
|
||||
inner.push(kid)
|
||||
queue.push(kid)
|
||||
}
|
||||
}
|
||||
|
||||
return inner
|
||||
}
|
||||
|
||||
/**
|
||||
* Run sugiyama layout on a set of nodes with parent relationships.
|
||||
* Returns x,y positions for each node, centered at x=0.
|
||||
*/
|
||||
function runSugiyama(
|
||||
nodes: { id: string; parentIds?: string[] }[],
|
||||
constants: LayoutConstants,
|
||||
nodeSizes?: Map<string, { width: number; height: number }>
|
||||
): { positions: Map<string, { x: number; y: number }>; width: number; height: number } {
|
||||
if (nodes.length === 0) {
|
||||
return { positions: new Map(), width: 0, height: 0 }
|
||||
}
|
||||
|
||||
if (nodes.length === 1) {
|
||||
const pos = new Map<string, { x: number; y: number }>()
|
||||
pos.set(nodes[0].id, { x: 0, y: 0 })
|
||||
const w = nodeSizes?.get(nodes[0].id)?.width ?? constants.nodeWidth
|
||||
const h = nodeSizes?.get(nodes[0].id)?.height ?? constants.nodeHeight
|
||||
return { positions: pos, width: w, height: h }
|
||||
}
|
||||
|
||||
const nodeIdSet = new Set(nodes.map((n) => n.id))
|
||||
const dagNodes = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid))
|
||||
}))
|
||||
|
||||
const dag = dagStratify().id(({ id }: { id: string }) => id)(dagNodes)
|
||||
|
||||
let boxSize: { width: number; height: number }
|
||||
try {
|
||||
const layout = sugiyama()
|
||||
.decross(nodes.length > 20 ? decrossTwoLayer() : decrossOpt())
|
||||
.coord(coordCenter())
|
||||
.nodeSize((d: any) => {
|
||||
const nodeId = d?.data?.id ?? ''
|
||||
const size = nodeSizes?.get(nodeId)
|
||||
const w = size?.width ?? constants.nodeWidth
|
||||
const h = size?.height ?? constants.nodeHeight
|
||||
return [w + constants.gapH, h + constants.gapV] as readonly [number, number]
|
||||
})
|
||||
boxSize = layout(dag as any) as any
|
||||
} catch (e) {
|
||||
console.debug('[compoundLayout] decrossOpt failed, falling back to decrossTwoLayer:', e)
|
||||
const layout = sugiyama()
|
||||
.decross(decrossTwoLayer())
|
||||
.coord(coordCenter())
|
||||
.nodeSize((d: any) => {
|
||||
const nodeId = d?.data?.id ?? ''
|
||||
const size = nodeSizes?.get(nodeId)
|
||||
const h = size?.height ?? constants.nodeHeight
|
||||
return [constants.nodeWidth + constants.gapH, h + constants.gapV]
|
||||
})
|
||||
boxSize = layout(dag as any) as any
|
||||
}
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
for (const desc of dag.descendants()) {
|
||||
const nodeId = desc.data.id
|
||||
// sugiyama returns CENTER positions; convert to TOP by subtracting half the node's allocated height
|
||||
const h = nodeSizes?.get(nodeId)?.height ?? constants.nodeHeight
|
||||
const rawY = (desc as any).y ?? 0
|
||||
positions.set(nodeId, {
|
||||
x: (desc as any).x ?? 0,
|
||||
y: rawY - (h + constants.gapV) / 2
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize y so minimum = 0
|
||||
let minY = Infinity
|
||||
for (const pos of positions.values()) {
|
||||
minY = Math.min(minY, pos.y)
|
||||
}
|
||||
if (minY !== Infinity && minY !== 0) {
|
||||
for (const pos of positions.values()) {
|
||||
pos.y -= minY
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize x so center of bbox = 0 (important for nested branch placement)
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
for (const pos of positions.values()) {
|
||||
minX = Math.min(minX, pos.x)
|
||||
maxX = Math.max(maxX, pos.x)
|
||||
}
|
||||
if (minX !== Infinity) {
|
||||
const centerX = (minX + maxX) / 2
|
||||
for (const pos of positions.values()) {
|
||||
pos.x -= centerX
|
||||
}
|
||||
}
|
||||
|
||||
return { positions, width: boxSize.width, height: boxSize.height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive compound layout.
|
||||
*
|
||||
* 1. Detect compound groups at this level
|
||||
* 2. For each group, recursively lay out each branch
|
||||
* 3. Compute wrapper bbox for each group
|
||||
* 4. Replace group nodes with a single wrapper pseudo-node
|
||||
* 5. Run sugiyama on the simplified graph
|
||||
* 6. Expand wrapper positions back to absolute positions
|
||||
*/
|
||||
const MAX_RECURSION_DEPTH = 50
|
||||
|
||||
function layoutLevel(
|
||||
nodeIds: string[],
|
||||
allNodes: Map<string, LayoutNode>,
|
||||
constants: LayoutConstants,
|
||||
childrenMap: Map<string, string[]>,
|
||||
depth: number = 0
|
||||
): LayoutResult {
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
const nodeIdSet = new Set(nodeIds)
|
||||
|
||||
if (nodeIds.length === 0) {
|
||||
return {
|
||||
positions,
|
||||
bbox: { width: constants.nodeWidth, height: 0 },
|
||||
contentMinX: 0
|
||||
}
|
||||
}
|
||||
|
||||
if (depth >= MAX_RECURSION_DEPTH) {
|
||||
console.warn('[compoundLayout] Max recursion depth reached, falling back to flat layout')
|
||||
const flatNodes = nodeIds.map((id) => {
|
||||
const n = allNodes.get(id)!
|
||||
return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) }
|
||||
})
|
||||
const result = runSugiyama(flatNodes, constants)
|
||||
for (const [id, pos] of result.positions) {
|
||||
positions.set(id, pos)
|
||||
}
|
||||
return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 }
|
||||
}
|
||||
|
||||
// Step 1: detect compound groups at this level
|
||||
const groups = detectGroups(nodeIdSet, allNodes, childrenMap)
|
||||
|
||||
// First pass: quick set of ALL group-owned inner IDs (just for filtering nested heads)
|
||||
const allGroupOwnedIds = new Set<string>()
|
||||
for (const group of groups) {
|
||||
if (!nodeIdSet.has(group.headId)) continue
|
||||
for (const branch of group.branches) {
|
||||
for (const innerId of branch.innerIds) {
|
||||
allGroupOwnedIds.add(innerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to top-level groups (head not owned by another group)
|
||||
const topLevelGroups = groups.filter(
|
||||
(g) => nodeIdSet.has(g.headId) && !allGroupOwnedIds.has(g.headId)
|
||||
)
|
||||
|
||||
// Build final groupOwnedIds and groupByHeadId from top-level only
|
||||
const groupOwnedIds = new Set<string>()
|
||||
const groupByHeadId = new Map<string, CompoundGroup>()
|
||||
for (const group of topLevelGroups) {
|
||||
groupByHeadId.set(group.headId, group)
|
||||
groupOwnedIds.add(group.endId)
|
||||
for (const branch of group.branches) {
|
||||
groupOwnedIds.add(branch.labelId)
|
||||
for (const innerId of branch.innerIds) {
|
||||
groupOwnedIds.add(innerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2-3: Recursively lay out each group and compute wrapper sizes
|
||||
type GroupLayout = {
|
||||
group: CompoundGroup
|
||||
branchLayouts: {
|
||||
labelId: string
|
||||
result: LayoutResult
|
||||
bbox: { width: number; height: number }
|
||||
}[]
|
||||
branchWidths: number[]
|
||||
totalWidth: number
|
||||
wrapperWidth: number
|
||||
wrapperHeight: number
|
||||
maxBranchHeight: number
|
||||
}
|
||||
|
||||
const groupLayouts = new Map<string, GroupLayout>()
|
||||
const wrapperSizes = new Map<string, { width: number; height: number }>()
|
||||
|
||||
for (const group of topLevelGroups) {
|
||||
const branchLayouts: GroupLayout['branchLayouts'] = []
|
||||
const isBranch = group.type === 'branch'
|
||||
|
||||
for (const branch of group.branches) {
|
||||
const branchNodeIds = [branch.labelId, ...branch.innerIds]
|
||||
|
||||
// Find sub-groups within this branch
|
||||
const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1)
|
||||
|
||||
branchLayouts.push({
|
||||
labelId: branch.labelId,
|
||||
result,
|
||||
bbox: result.bbox
|
||||
})
|
||||
}
|
||||
|
||||
// Compute wrapper dimensions
|
||||
let wrapperWidth: number
|
||||
let wrapperHeight: number
|
||||
let branchWidths: number[] = []
|
||||
let totalWidth = 0
|
||||
let maxBranchHeight = 0
|
||||
const rowHeight = constants.nodeHeight + constants.gapV
|
||||
|
||||
if (isBranch) {
|
||||
// Place branches side by side horizontally
|
||||
branchWidths = branchLayouts.map((bl) => Math.max(bl.bbox.width, constants.nodeWidth))
|
||||
const gaps = Math.max(0, branchWidths.length - 1) * constants.gapH
|
||||
totalWidth = branchWidths.reduce((s, w) => s + w, 0) + gaps
|
||||
wrapperWidth = Math.max(totalWidth, constants.nodeWidth)
|
||||
|
||||
maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height))
|
||||
// head row + branch content + end row
|
||||
wrapperHeight = rowHeight + maxBranchHeight + rowHeight
|
||||
} else {
|
||||
// Loop: body is indented
|
||||
const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth
|
||||
const bodyHeight = branchLayouts[0]?.bbox.height ?? 0
|
||||
wrapperWidth = Math.max(bodyWidth + LOOP_INDENT * 2, constants.nodeWidth)
|
||||
// head row + start row + body + end row
|
||||
wrapperHeight = rowHeight + bodyHeight + rowHeight
|
||||
}
|
||||
groupLayouts.set(group.headId, {
|
||||
group,
|
||||
branchLayouts,
|
||||
branchWidths,
|
||||
totalWidth,
|
||||
wrapperWidth,
|
||||
wrapperHeight,
|
||||
maxBranchHeight
|
||||
})
|
||||
wrapperSizes.set(group.headId, { width: wrapperWidth, height: wrapperHeight })
|
||||
}
|
||||
|
||||
// Step 4: Build flattened node list for sugiyama
|
||||
// Merge into a single pass: create flatNode and compute final parentIds with end→head redirection
|
||||
const endToHead = new Map<string, string>()
|
||||
for (const group of topLevelGroups) {
|
||||
endToHead.set(group.endId, group.headId)
|
||||
}
|
||||
|
||||
const flatNodes: { id: string; parentIds?: string[] }[] = []
|
||||
for (const nid of nodeIds) {
|
||||
if (groupOwnedIds.has(nid)) continue
|
||||
|
||||
const originalNode = allNodes.get(nid)!
|
||||
const seen = new Set<string>()
|
||||
const newParents: string[] = []
|
||||
for (const pid of originalNode.parentIds ?? []) {
|
||||
if (!nodeIdSet.has(pid)) continue
|
||||
const resolved = endToHead.get(pid) ?? (groupOwnedIds.has(pid) ? undefined : pid)
|
||||
if (resolved && !seen.has(resolved)) {
|
||||
seen.add(resolved)
|
||||
newParents.push(resolved)
|
||||
}
|
||||
}
|
||||
flatNodes.push({ id: nid, parentIds: newParents })
|
||||
}
|
||||
|
||||
// Step 5: Run sugiyama on flattened nodes
|
||||
const sugResult = runSugiyama(flatNodes, constants, wrapperSizes)
|
||||
|
||||
// Step 6: Resolve absolute positions
|
||||
// First, set positions for regular (non-group) nodes
|
||||
for (const [nid, pos] of sugResult.positions) {
|
||||
if (groupByHeadId.has(nid)) continue // Handle groups separately
|
||||
positions.set(nid, { x: pos.x, y: pos.y })
|
||||
}
|
||||
|
||||
// Now expand group wrappers into absolute positions
|
||||
for (const [headId, gl] of groupLayouts) {
|
||||
const wrapperPos = sugResult.positions.get(headId)
|
||||
if (!wrapperPos) continue
|
||||
|
||||
const rowHeight = constants.nodeHeight + constants.gapV
|
||||
const isBranch = gl.group.type === 'branch'
|
||||
|
||||
// Position the head node at the top-center of the wrapper
|
||||
positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y })
|
||||
|
||||
if (isBranch) {
|
||||
// Reuse cached branchWidths and totalWidth
|
||||
let currentX = wrapperPos.x - gl.totalWidth / 2
|
||||
|
||||
for (let bi = 0; bi < gl.branchLayouts.length; bi++) {
|
||||
const bl = gl.branchLayouts[bi]
|
||||
const bw = gl.branchWidths[bi]
|
||||
const branchCenterX = currentX + bw / 2
|
||||
|
||||
// Offset all branch positions relative to the branch center
|
||||
for (const [innerNodeId, innerPos] of bl.result.positions) {
|
||||
positions.set(innerNodeId, {
|
||||
x: branchCenterX + innerPos.x,
|
||||
y: wrapperPos.y + rowHeight + innerPos.y
|
||||
})
|
||||
}
|
||||
|
||||
currentX += bw + constants.gapH
|
||||
}
|
||||
|
||||
// Position end node below all branches
|
||||
const maxBranchHeight = gl.maxBranchHeight
|
||||
positions.set(gl.group.endId, {
|
||||
x: wrapperPos.x,
|
||||
y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV
|
||||
})
|
||||
} else {
|
||||
// Loop: position start, body, and end
|
||||
const bl = gl.branchLayouts[0]
|
||||
if (bl) {
|
||||
// Position body nodes with indent
|
||||
for (const [innerNodeId, innerPos] of bl.result.positions) {
|
||||
positions.set(innerNodeId, {
|
||||
x: wrapperPos.x + LOOP_INDENT + innerPos.x,
|
||||
y: wrapperPos.y + rowHeight + innerPos.y
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Position end node below body
|
||||
const bodyHeight = bl?.bbox.height ?? 0
|
||||
positions.set(gl.group.endId, {
|
||||
x: wrapperPos.x,
|
||||
y: wrapperPos.y + rowHeight + bodyHeight + constants.gapV
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute overall bbox (nodes + group wrapper extents)
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
let minY = Infinity
|
||||
let maxY = -Infinity
|
||||
for (const pos of positions.values()) {
|
||||
minX = Math.min(minX, pos.x - constants.nodeWidth / 2)
|
||||
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2)
|
||||
minY = Math.min(minY, pos.y)
|
||||
maxY = Math.max(maxY, pos.y + constants.nodeHeight)
|
||||
}
|
||||
// Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes)
|
||||
for (const [headId, gl] of groupLayouts) {
|
||||
const pos = sugResult.positions.get(headId)
|
||||
if (!pos) continue
|
||||
minX = Math.min(minX, pos.x - gl.wrapperWidth / 2)
|
||||
maxX = Math.max(maxX, pos.x + gl.wrapperWidth / 2)
|
||||
maxY = Math.max(maxY, pos.y + gl.wrapperHeight)
|
||||
}
|
||||
|
||||
const contentMinX = minX === Infinity ? 0 : minX
|
||||
|
||||
const bboxWidth = maxX - minX
|
||||
const bboxHeight = maxY - (positions.size > 0 ? minY : 0)
|
||||
|
||||
const finalBbox = {
|
||||
width: Math.max(bboxWidth, constants.nodeWidth),
|
||||
height: Math.max(bboxHeight, 0)
|
||||
}
|
||||
return { positions, bbox: finalBbox, contentMinX }
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for compound layout.
|
||||
*
|
||||
* Takes the flat list of nodes and edges from graphBuilder and produces
|
||||
* absolute positions that account for compound structure (branches, loops).
|
||||
*/
|
||||
export function compoundLayout(
|
||||
nodes: { id: string; parentIds?: string[] }[],
|
||||
constants?: Partial<LayoutConstants>
|
||||
): LayoutResult {
|
||||
const c: LayoutConstants = {
|
||||
nodeWidth: constants?.nodeWidth ?? NODE.width,
|
||||
nodeHeight: constants?.nodeHeight ?? NODE.height,
|
||||
gapH: constants?.gapH ?? NODE.gap.horizontal,
|
||||
gapV: constants?.gapV ?? NODE.gap.vertical
|
||||
}
|
||||
|
||||
// Build node map
|
||||
const allNodes = new Map<string, LayoutNode>()
|
||||
for (const n of nodes) {
|
||||
allNodes.set(n.id, n)
|
||||
}
|
||||
|
||||
// Build children map once (reverse of parentIds), shared across all recursion levels
|
||||
const childrenMap = new Map<string, string[]>()
|
||||
for (const [nid, node] of allNodes) {
|
||||
for (const pid of node.parentIds ?? []) {
|
||||
if (!childrenMap.has(pid)) childrenMap.set(pid, [])
|
||||
childrenMap.get(pid)!.push(nid)
|
||||
}
|
||||
}
|
||||
|
||||
const nodeIds = nodes.map((n) => n.id)
|
||||
const result = layoutLevel(nodeIds, allNodes, c, childrenMap)
|
||||
|
||||
// Shift positions so minX=0 (left-aligned).
|
||||
// FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2
|
||||
// which assumes positions start at x=0.
|
||||
if (result.positions.size > 0) {
|
||||
const minX = result.contentMinX
|
||||
if (minX !== 0 && minX !== Infinity) {
|
||||
for (const pos of result.positions.values()) {
|
||||
pos.x -= minX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing nodes
|
||||
const missing = nodes.filter((n) => !result.positions.has(n.id))
|
||||
if (missing.length > 0) {
|
||||
console.warn(
|
||||
'[compoundLayout] MISSING positions for:',
|
||||
missing.map((n) => n.id)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -88,9 +88,7 @@ export function buildPrefix(prefix: string | undefined, id: string): string {
|
||||
export type NodeLayout = {
|
||||
id: string
|
||||
parentIds?: string[]
|
||||
data: {
|
||||
offset?: number
|
||||
}
|
||||
data: {}
|
||||
selectable?: boolean
|
||||
} & FlowNode
|
||||
|
||||
@@ -138,7 +136,6 @@ export type InputN = {
|
||||
export type ModuleN = {
|
||||
type: 'module'
|
||||
data: {
|
||||
offset: number
|
||||
module: FlowModule
|
||||
id: string
|
||||
parentIds: string[]
|
||||
@@ -157,7 +154,6 @@ export type ModuleN = {
|
||||
export type BranchAllStartN = {
|
||||
type: 'branchAllStart'
|
||||
data: {
|
||||
offset: number
|
||||
label: string
|
||||
id: string
|
||||
branchIndex: number
|
||||
@@ -171,7 +167,6 @@ export type BranchAllStartN = {
|
||||
export type BranchAllEndN = {
|
||||
type: 'branchAllEnd'
|
||||
data: {
|
||||
offset: number
|
||||
id: string
|
||||
eventHandlers: GraphEventHandlers
|
||||
flowModuleState: GraphModuleState | undefined
|
||||
@@ -181,7 +176,6 @@ export type BranchAllEndN = {
|
||||
export type ForLoopEndN = {
|
||||
type: 'forLoopEnd'
|
||||
data: {
|
||||
offset: number
|
||||
id: string
|
||||
eventHandlers: GraphEventHandlers
|
||||
simplifiedTriggerView: boolean
|
||||
@@ -192,7 +186,6 @@ export type ForLoopEndN = {
|
||||
export type ForLoopStartN = {
|
||||
type: 'forLoopStart'
|
||||
data: {
|
||||
offset: number
|
||||
id: string
|
||||
eventHandlers: GraphEventHandlers
|
||||
flowModuleState: GraphModuleState | undefined
|
||||
@@ -217,22 +210,18 @@ export type ResultN = {
|
||||
export type WhileLoopStartN = {
|
||||
type: 'whileLoopStart'
|
||||
data: {
|
||||
offset: number
|
||||
eventHandlers: GraphEventHandlers
|
||||
}
|
||||
}
|
||||
|
||||
export type WhileLoopEndN = {
|
||||
type: 'whileLoopEnd'
|
||||
data: {
|
||||
offset: number
|
||||
}
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type BranchOneStartN = {
|
||||
type: 'branchOneStart'
|
||||
data: {
|
||||
offset: number
|
||||
id: string
|
||||
eventHandlers: GraphEventHandlers
|
||||
flowModuleState: GraphModuleState | undefined
|
||||
@@ -248,7 +237,6 @@ export type BranchOneStartN = {
|
||||
export type BranchOneEndN = {
|
||||
type: 'branchOneEnd'
|
||||
data: {
|
||||
offset: number
|
||||
id: string
|
||||
eventHandlers: GraphEventHandlers
|
||||
flowModuleState: GraphModuleState | undefined
|
||||
@@ -258,7 +246,6 @@ export type BranchOneEndN = {
|
||||
export type SubflowBoundN = {
|
||||
type: 'subflowBound'
|
||||
data: {
|
||||
offset: number
|
||||
id: string
|
||||
eventHandlers: GraphEventHandlers
|
||||
label: string
|
||||
@@ -271,7 +258,6 @@ export type SubflowBoundN = {
|
||||
export type NoBranchN = {
|
||||
type: 'noBranch'
|
||||
data: {
|
||||
offset: number
|
||||
id: string
|
||||
eventHandlers: GraphEventHandlers
|
||||
flowModuleState: GraphModuleState | undefined
|
||||
@@ -416,7 +402,7 @@ export function graphBuilder(
|
||||
const nodes: NodeLayout[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
function addNode(module: FlowModule, offset: number) {
|
||||
function addNode(module: FlowModule) {
|
||||
const duplicated = nodes.find((n) => n.id === module.id)
|
||||
if (duplicated) {
|
||||
console.log('Duplicated node detected: ', module, duplicated)
|
||||
@@ -426,7 +412,6 @@ export function graphBuilder(
|
||||
nodes.push({
|
||||
id: module.id,
|
||||
data: {
|
||||
offset: offset,
|
||||
module: module,
|
||||
id: module.id,
|
||||
parentIds: [],
|
||||
@@ -611,7 +596,6 @@ export function graphBuilder(
|
||||
nextNode: NodeLayout | undefined,
|
||||
simplifiedTriggerView: boolean,
|
||||
prefix: string | undefined,
|
||||
currentOffset = 0,
|
||||
disableMoveIds: string[] = [],
|
||||
parentIndex?: string
|
||||
) {
|
||||
@@ -646,13 +630,12 @@ export function graphBuilder(
|
||||
|
||||
if (module.value.type === 'branchall') {
|
||||
// Start
|
||||
addNode(module, currentOffset)
|
||||
addNode(module)
|
||||
|
||||
// "Collect result of each branch" node
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
id: module.id,
|
||||
eventHandlers: eventHandlers,
|
||||
flowModuleState: extra.flowModuleStates?.[module.id]
|
||||
@@ -667,7 +650,6 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-branch-0`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
id: module.id,
|
||||
branchIndex: -1,
|
||||
eventHandlers: eventHandlers,
|
||||
@@ -693,7 +675,6 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-branch-${branchIndex}`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
label: defaultIfEmptyString(branch.summary, `Branch ${branchIndex + 1}`),
|
||||
id: module.id,
|
||||
branchIndex: branchIndex,
|
||||
@@ -724,7 +705,6 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset,
|
||||
localDisableMoveIds,
|
||||
parentIndex ? `${parentIndex}-${index}-${branchIndex}` : `${index}-${branchIndex}`
|
||||
)
|
||||
@@ -734,13 +714,12 @@ export function graphBuilder(
|
||||
previousId = endNode.id
|
||||
} else if (module.value.type === 'forloopflow') {
|
||||
if (!simplifiedTriggerView) {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module)
|
||||
}
|
||||
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-start`,
|
||||
data: {
|
||||
offset: currentOffset + 25,
|
||||
id: module.id,
|
||||
module: module,
|
||||
simplifiedTriggerView,
|
||||
@@ -765,7 +744,6 @@ export function graphBuilder(
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
id: module.id,
|
||||
eventHandlers: eventHandlers,
|
||||
simplifiedTriggerView,
|
||||
@@ -786,7 +764,6 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset + 25,
|
||||
localDisableMoveIds,
|
||||
parentIndex
|
||||
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
|
||||
@@ -795,12 +772,11 @@ export function graphBuilder(
|
||||
|
||||
previousId = endNode.id
|
||||
} else if (module.value.type === 'whileloopflow') {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module)
|
||||
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-start`,
|
||||
data: {
|
||||
offset: currentOffset + 25,
|
||||
eventHandlers: eventHandlers
|
||||
},
|
||||
type: 'whileLoopStart'
|
||||
@@ -811,7 +787,7 @@ export function graphBuilder(
|
||||
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: { offset: currentOffset, ...extra },
|
||||
data: { ...extra },
|
||||
type: 'whileLoopEnd'
|
||||
}
|
||||
|
||||
@@ -827,7 +803,6 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset + 25,
|
||||
localDisableMoveIds,
|
||||
parentIndex
|
||||
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
|
||||
@@ -836,12 +811,11 @@ export function graphBuilder(
|
||||
|
||||
previousId = endNode.id
|
||||
} else if (module.value.type === 'branchone') {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module)
|
||||
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
eventHandlers: eventHandlers,
|
||||
flowModuleState: extra.flowModuleStates?.[module.id],
|
||||
id: module.id
|
||||
@@ -854,7 +828,7 @@ export function graphBuilder(
|
||||
// const defaultBranch: NodeLayout = {
|
||||
// id: `${module.id}-default`,
|
||||
// data: {
|
||||
// offset: currentOffset,
|
||||
// offset: 0,
|
||||
// label: 'Default',
|
||||
// id: module.id,
|
||||
// branchIndex: -1,
|
||||
@@ -868,7 +842,6 @@ export function graphBuilder(
|
||||
const defaultBranch: NodeLayout = {
|
||||
id: `${module.id}-branch-default`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
label: 'Default',
|
||||
id: module.id,
|
||||
branchIndex: -1,
|
||||
@@ -895,7 +868,6 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset,
|
||||
localDisableMoveIds,
|
||||
parentIndex ? `${parentIndex}-${index}` : index.toString()
|
||||
)
|
||||
@@ -906,7 +878,6 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-branch-${branchIndex}`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
label: defaultIfEmptyString(branch.summary, 'Branch ' + (branchIndex + 1)),
|
||||
preLabel: branch.summary ? '' : branch.expr,
|
||||
id: module.id,
|
||||
@@ -933,7 +904,6 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset,
|
||||
localDisableMoveIds,
|
||||
parentIndex ? `${parentIndex}-${index}` : index.toString()
|
||||
)
|
||||
@@ -951,7 +921,6 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: startId,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
label: `Start of subflow ${idWithoutPrefix}`,
|
||||
id: startId,
|
||||
subflowId: module.id,
|
||||
@@ -980,7 +949,6 @@ export function graphBuilder(
|
||||
const endNode: NodeLayout = {
|
||||
id: endId,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
label: `End of subflow ${idWithoutPrefix}`,
|
||||
id: endId,
|
||||
subflowId: module.id,
|
||||
@@ -1000,13 +968,12 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
buildPrefix(prefix, module['oid'] ?? module.id),
|
||||
currentOffset,
|
||||
localDisableMoveIds
|
||||
)
|
||||
|
||||
previousId = endNode.id
|
||||
} else {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module)
|
||||
previousId = module.id
|
||||
}
|
||||
}
|
||||
@@ -1047,19 +1014,19 @@ export function graphBuilder(
|
||||
})
|
||||
|
||||
Object.entries(toAdd).forEach((x) => {
|
||||
addNode({ ...failureModule, id: x[1] }, 0)
|
||||
addNode({ ...failureModule, id: x[1] })
|
||||
addEdge(x[0], x[1], undefined, undefined, { type: 'empty' })
|
||||
})
|
||||
}
|
||||
|
||||
if (preprocessorModule) {
|
||||
addNode(preprocessorModule, 0)
|
||||
addNode(preprocessorModule)
|
||||
const id = JSON.parse(JSON.stringify(preprocessorModule.id))
|
||||
addEdge(id, 'Input', undefined, undefined, { type: 'empty' })
|
||||
}
|
||||
|
||||
if (failureModule && !extra.flowModuleStates) {
|
||||
addNode(failureModule, 0)
|
||||
addNode(failureModule)
|
||||
}
|
||||
|
||||
Object.keys(parents).forEach((key) => {
|
||||
|
||||
@@ -219,7 +219,7 @@ export class NoteEditor {
|
||||
/**
|
||||
* Clean up group notes using DAG path completion
|
||||
*/
|
||||
cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[]; offset?: number }[]): void {
|
||||
cleanupGroupNotes(flowNodes: { id: string; parentIds?: string[] }[]): void {
|
||||
if (!this.isAvailable()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ export type NodeDep = {
|
||||
position: { x: number; y: number }
|
||||
data?: { assets?: AssetWithAltAccessType[] }
|
||||
parentIds?: string[]
|
||||
offset?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
@@ -207,7 +206,6 @@ function calculateGroupNoteLayout(
|
||||
nodes.map((n) => ({
|
||||
id: n.id,
|
||||
position: n.position,
|
||||
data: { offset: n.offset ?? 0 },
|
||||
type: n.type ?? ''
|
||||
}))
|
||||
)
|
||||
@@ -337,7 +335,6 @@ export function computeNoteNodes(
|
||||
return {
|
||||
...n,
|
||||
data: origNode?.data,
|
||||
offset: origNode?.offset,
|
||||
type: origNode?.type
|
||||
}
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
? inputToolWidth + inputToolXGap
|
||||
: isLastRow && tools.length % 2 === 1
|
||||
? (ROW_WIDTH - inputToolWidth) / 2
|
||||
: 0) + node.data.offset,
|
||||
: 0),
|
||||
y:
|
||||
baseOffset +
|
||||
rowOffset *
|
||||
@@ -207,7 +207,7 @@
|
||||
parentId: node.id,
|
||||
width: NEW_TOOL_NODE_WIDTH,
|
||||
position: {
|
||||
x: (ROW_WIDTH - NEW_TOOL_NODE_WIDTH) / 2 + node.data.offset,
|
||||
x: (ROW_WIDTH - NEW_TOOL_NODE_WIDTH) / 2,
|
||||
y: baseOffset + rowOffset
|
||||
},
|
||||
selectable: false
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
let computeAssetNodesCache: [NodeDep[], ReturnType<typeof computeAssetNodes>] | undefined
|
||||
|
||||
type NodeDep = {
|
||||
data: object & { assets?: AssetWithAltAccessType[] | undefined; offset?: number }
|
||||
data: object & { assets?: AssetWithAltAccessType[] | undefined }
|
||||
id: string
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
@@ -78,14 +78,13 @@
|
||||
width: inputAssetWidth,
|
||||
position: {
|
||||
x:
|
||||
(node.data.offset ?? 0) +
|
||||
(displayedInputAssets.length === 1
|
||||
displayedInputAssets.length === 1
|
||||
? (NODE.width - inputAssetWidth) / 2 - 10 // Ensure we see the edge
|
||||
: (inputAssetWidth + inputAssetXGap) * (i - displayedInputAssets.length / 2) +
|
||||
(NODE.width + inputAssetXGap) / 2 +
|
||||
(overflowedInputAssets.length
|
||||
? (-ASSETS_OVERFLOWED_NODE_WIDTH - inputAssetXGap) / 2
|
||||
: 0)),
|
||||
: 0),
|
||||
y: READ_ASSET_Y_OFFSET
|
||||
},
|
||||
selectable: false
|
||||
@@ -116,14 +115,13 @@
|
||||
width: outputAssetWidth,
|
||||
position: {
|
||||
x:
|
||||
(node.data.offset ?? 0) +
|
||||
(displayedOutputAssets.length === 1
|
||||
displayedOutputAssets.length === 1
|
||||
? (NODE.width - outputAssetWidth) / 2 - 10 // Ensure we see the edge
|
||||
: (outputAssetWidth + outputAssetXGap) * (i - displayedOutputAssets.length / 2) +
|
||||
(NODE.width + outputAssetXGap) / 2 +
|
||||
(overflowedOutputAssets.length
|
||||
? (-ASSETS_OVERFLOWED_NODE_WIDTH - outputAssetXGap) / 2
|
||||
: 0)),
|
||||
: 0),
|
||||
y: WRITE_ASSET_Y_OFFSET
|
||||
},
|
||||
selectable: false
|
||||
@@ -157,7 +155,7 @@
|
||||
parentId: node.id,
|
||||
width: ASSETS_OVERFLOWED_NODE_WIDTH,
|
||||
position: {
|
||||
x: (node.data.offset ?? 0) + MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
|
||||
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
|
||||
y: READ_ASSET_Y_OFFSET
|
||||
}
|
||||
} satisfies Node & AssetsOverflowedN)
|
||||
@@ -176,7 +174,7 @@
|
||||
parentId: node.id,
|
||||
width: ASSETS_OVERFLOWED_NODE_WIDTH,
|
||||
position: {
|
||||
x: (node.data.offset ?? 0) + MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
|
||||
x: MAX_ASSET_ROW_WIDTH - ASSETS_OVERFLOWED_NODE_WIDTH - 14,
|
||||
y: WRITE_ASSET_Y_OFFSET
|
||||
}
|
||||
} satisfies Node & AssetsOverflowedN)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
|
||||
<NodeWrapper enableSourceHandle enableTargetHandle nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={'Collect result from all branches'}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
let { data, id }: Props = $props()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
{#if data.simplifiedTriggerView}
|
||||
<VirtualItem
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
let filteredInput = $derived(filterIterFromInput($pickablePropertiesFiltered?.flow_input))
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} {menuItems}>
|
||||
<NodeWrapper {menuItems}>
|
||||
{#snippet children({ darkMode })}
|
||||
<MapItem
|
||||
moduleId={data.id}
|
||||
@@ -99,7 +99,7 @@
|
||||
moduleAction={data.moduleAction}
|
||||
{menuItems}
|
||||
annotation={flowJobs &&
|
||||
(data.module.value.type === 'forloopflow' || data.module.value.type === 'whileloopflow')
|
||||
(data.module?.value?.type === 'forloopflow' || data.module?.value?.type === 'whileloopflow')
|
||||
? 'Iteration: ' +
|
||||
((state?.selectedForloopIndex ?? 0) >= 0
|
||||
? (state?.selectedForloopIndex ?? 0) + 1
|
||||
@@ -136,15 +136,18 @@
|
||||
onEditInput={data.eventHandlers.editInput}
|
||||
flowJob={data.flowJob}
|
||||
isOwner={data.isOwner}
|
||||
maximizeSubflow={data.module.value.type == 'flow' && 'path' in data.module.value
|
||||
maximizeSubflow={data.module?.value?.type == 'flow' && 'path' in data.module.value
|
||||
? () => {
|
||||
data.eventHandlers.expandSubflow(data.id, data.module.value['path'])
|
||||
const path = data.module?.value && 'path' in data.module.value ? data.module.value['path'] as string : undefined
|
||||
if (path) {
|
||||
data.eventHandlers.expandSubflow(data.id, path)
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
<div class="absolute -bottom-10 left-1/2 transform -translate-x-1/2 z-10">
|
||||
{#if (data.module.value.type === 'branchall' || data.module.value.type === 'branchone') && data.insertable}
|
||||
{#if (data.module?.value?.type === 'branchall' || data.module?.value?.type === 'branchone') && data.insertable}
|
||||
<button
|
||||
title="Add branch"
|
||||
class="rounded text-secondary border hover:bg-surface-hover bg-surface p-1"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let { data, id }: Props = $props()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
|
||||
<NodeWrapper enableSourceHandle enableTargetHandle nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label ?? 'No branches'}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
interface Props {
|
||||
enableSourceHandle?: boolean
|
||||
enableTargetHandle?: boolean
|
||||
offset?: number
|
||||
wrapperClass?: string
|
||||
contextMenuItems?: ContextMenuItem[]
|
||||
menuItems?: Item[]
|
||||
@@ -21,7 +20,6 @@
|
||||
let {
|
||||
enableSourceHandle = true,
|
||||
enableTargetHandle = true,
|
||||
offset = 0,
|
||||
wrapperClass = '',
|
||||
contextMenuItems = undefined,
|
||||
menuItems = undefined,
|
||||
@@ -58,14 +56,14 @@
|
||||
|
||||
{#if resolvedContextMenuItems && resolvedContextMenuItems.length > 0}
|
||||
<ContextMenu items={resolvedContextMenuItems}>
|
||||
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
|
||||
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)}>
|
||||
{@render children?.({ darkMode })}
|
||||
</div>
|
||||
|
||||
{@render handles()}
|
||||
</ContextMenu>
|
||||
{:else}
|
||||
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
|
||||
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)}>
|
||||
{@render children?.({ darkMode })}
|
||||
</div>
|
||||
|
||||
@@ -78,7 +76,6 @@
|
||||
type="source"
|
||||
isConnectable={false}
|
||||
position={Position.Bottom}
|
||||
style={`margin-left: ${offset / 2}px;`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -87,7 +84,6 @@
|
||||
type="target"
|
||||
isConnectable={false}
|
||||
position={Position.Top}
|
||||
style={`margin-left: ${offset / 2}px;`}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
<NodeWrapper>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
let { data, id }: Props = $props()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={'Collect result from chosen branch'}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function getNodeColorClasses(state: FlowNodeState, selected: boolean): Fl
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the bounding box for a collection of nodes, accounting for CSS offset
|
||||
* Calculate the bounding box for a collection of nodes.
|
||||
* Also includes expanded subflow nodes when calculating bounds for subflow containers
|
||||
* @param containedIds - Array of node IDs to calculate bounds for
|
||||
* @param allNodes - Array of all nodes to search for expanded subflow nodes
|
||||
@@ -226,7 +226,6 @@ export function calculateNodesBoundsWithOffset(
|
||||
allNodes: Array<{
|
||||
id: string
|
||||
position: { x: number; y: number }
|
||||
data?: { offset?: number }
|
||||
type: string
|
||||
}>
|
||||
): {
|
||||
@@ -240,14 +239,10 @@ export function calculateNodesBoundsWithOffset(
|
||||
|
||||
return nodesToCalculate.reduce(
|
||||
(acc, node) => {
|
||||
// Account for CSS offset applied by NodeWrapper
|
||||
const cssOffset = node.data?.offset ?? 0
|
||||
const visualX = node.position.x + cssOffset
|
||||
|
||||
return {
|
||||
minX: Math.min(acc.minX, visualX),
|
||||
minX: Math.min(acc.minX, node.position.x),
|
||||
minY: Math.min(acc.minY, node.position.y),
|
||||
maxX: Math.max(acc.maxX, visualX + NODE.width),
|
||||
maxX: Math.max(acc.maxX, node.position.x + NODE.width),
|
||||
maxY: Math.max(acc.maxY, node.position.y + NODE.height)
|
||||
}
|
||||
},
|
||||
@@ -271,13 +266,11 @@ function getAllRelatedSubflowNodes(
|
||||
allNodes: Array<{
|
||||
id: string
|
||||
position: { x: number; y: number }
|
||||
data?: { offset?: number }
|
||||
type: string
|
||||
}>
|
||||
): Array<{
|
||||
id: string
|
||||
position: { x: number; y: number }
|
||||
data?: { offset?: number }
|
||||
}> {
|
||||
const relatedNodeIds = new Set<string>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user