feat(frontend): replace flat sugiyama with recursive compound layout for flow graph
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,6 @@
|
||||
import {
|
||||
graphBuilder,
|
||||
isTriggerStep,
|
||||
topologicalSort,
|
||||
type InlineScript,
|
||||
type InsertKind,
|
||||
type NodeLayout,
|
||||
@@ -36,7 +35,7 @@
|
||||
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'
|
||||
// d3-dag imports now used inside compoundLayout.ts
|
||||
import { Expand, MousePointer, Hand } from 'lucide-svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
|
||||
@@ -71,6 +70,8 @@
|
||||
import type { MoveManager } from './moveManager.svelte'
|
||||
import DragCoordinator from './DragCoordinator.svelte'
|
||||
import type { ModulesTestStates } from '../modulesTest.svelte'
|
||||
import { compoundLayout, type WrapperInfo } from './compoundLayout'
|
||||
import DebugWrapperNode from './renderers/nodes/DebugWrapperNode.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { AssetWithAltAccessType } from '../assets/lib'
|
||||
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
|
||||
@@ -323,6 +324,8 @@
|
||||
}
|
||||
type NodePos = { position: { x: number; y: number } }
|
||||
let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined
|
||||
let lastWrappers: WrapperInfo[] = []
|
||||
let lastXCenter = 0
|
||||
|
||||
function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] {
|
||||
let lastResult = lastNodes?.[1]
|
||||
@@ -339,59 +342,31 @@
|
||||
seenId.push(n.id)
|
||||
}
|
||||
|
||||
let nodeWidths: Record<string, number> = {}
|
||||
const nodes2: (NodeDep & NodePos)[] = nodes.map((n) => {
|
||||
return { ...n, position: { x: 0, y: 0 } }
|
||||
// Build edges from parentIds
|
||||
const edges = nodes.flatMap((n) =>
|
||||
(n.parentIds ?? []).map((pid) => ({ source: pid, target: n.id }))
|
||||
)
|
||||
|
||||
// Run recursive compound layout
|
||||
const { positions, bbox, wrappers } = compoundLayout(nodes, edges, {
|
||||
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
|
||||
}
|
||||
}
|
||||
lastWrappers = wrappers
|
||||
|
||||
const dag = dagStratify().id(({ id }: NodeDep & NodePos) => id)(nodes2)
|
||||
// Center horizontally
|
||||
const xCenter =
|
||||
(fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
|
||||
lastXCenter = xCenter
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -642,8 +617,35 @@
|
||||
}))
|
||||
}
|
||||
|
||||
// Build debug wrapper nodes from compound layout
|
||||
// compoundLayout positions use x as CENTER of each node.
|
||||
// layoutNodes transforms: screenX = compoundX + lastXCenter - NODE.width/2
|
||||
// For wrappers: screenX = w.x + lastXCenter - w.width/2
|
||||
const wrapperNodes: Node[] = lastWrappers.map((w) => {
|
||||
return {
|
||||
id: w.id,
|
||||
type: 'debugWrapper',
|
||||
position: {
|
||||
x: w.x + lastXCenter - w.width / 2,
|
||||
y: w.y
|
||||
},
|
||||
data: {
|
||||
headId: w.headId,
|
||||
type: w.type,
|
||||
level: w.level,
|
||||
label: w.label,
|
||||
wrapperWidth: w.width,
|
||||
wrapperHeight: w.height
|
||||
},
|
||||
selectable: false,
|
||||
draggable: false,
|
||||
zIndex: w.level === 'group' ? -10 : -5,
|
||||
style: `width: ${w.width}px; height: ${w.height}px;`
|
||||
} satisfies Node
|
||||
})
|
||||
|
||||
// update nodes
|
||||
nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])]
|
||||
nodes = [...finalNodes, ...wrapperNodes, ...(noteNodesResult?.noteNodes ?? [])]
|
||||
|
||||
edges = [
|
||||
...(assetNodesResult?.newAssetEdges ?? []),
|
||||
@@ -689,7 +691,8 @@
|
||||
assetsOverflowed: AssetsOverflowedNode,
|
||||
aiTool: AiToolNode,
|
||||
newAiTool: NewAiToolNode,
|
||||
note: NoteNode
|
||||
note: NoteNode,
|
||||
debugWrapper: DebugWrapperNode
|
||||
} as any
|
||||
|
||||
const edgeTypes = {
|
||||
|
||||
738
frontend/src/lib/components/graph/compoundLayout.ts
Normal file
738
frontend/src/lib/components/graph/compoundLayout.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
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: 'branchall' | 'branchone' | 'forloop' | 'whileloop'
|
||||
headId: string
|
||||
endId: string
|
||||
branches: {
|
||||
labelId: string
|
||||
innerIds: string[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export type WrapperInfo = {
|
||||
id: string
|
||||
headId: string
|
||||
type: CompoundGroup['type']
|
||||
level: 'group' | 'branch' | 'loop-body'
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type LayoutResult = {
|
||||
positions: Map<string, { x: number; y: number }>
|
||||
bbox: { width: number; height: number }
|
||||
wrappers: WrapperInfo[]
|
||||
}
|
||||
|
||||
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>): 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: baseId-branch-N nodes
|
||||
const branchLabelIds: string[] = []
|
||||
for (const nid of nodeIds) {
|
||||
const branchMatch = nid.match(new RegExp(`^${escapeRegExp(baseId)}-branch-(\\d+|default)$`))
|
||||
if (branchMatch) {
|
||||
branchLabelIds.push(nid)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for loop pattern: baseId-start node
|
||||
const hasStart = nodeIds.has(`${baseId}-start`)
|
||||
|
||||
if (branchLabelIds.length > 0) {
|
||||
// This is a branch group (branchall or branchone)
|
||||
const isBranchOne = branchLabelIds.some((lid) => lid.endsWith('-branch-default'))
|
||||
const type = isBranchOne ? 'branchone' : 'branchall'
|
||||
|
||||
// Sort branch labels: default first for branchone, then numeric
|
||||
branchLabelIds.sort((a, b) => {
|
||||
if (a.endsWith('-default')) return -1
|
||||
if (b.endsWith('-default')) return 1
|
||||
const aNum = parseInt(a.split('-branch-').pop()!)
|
||||
const bNum = parseInt(b.split('-branch-').pop()!)
|
||||
return aNum - bNum
|
||||
})
|
||||
|
||||
const branches = branchLabelIds.map((labelId) => ({
|
||||
labelId,
|
||||
innerIds: findInnerIds(labelId, id, nodeIds, allNodes)
|
||||
}))
|
||||
|
||||
groups.push({ type, headId: baseId, endId: id, branches })
|
||||
} else if (hasStart) {
|
||||
// Both forloop and whileloop have identical structure for layout purposes
|
||||
const type = determineLoopType(baseId, allNodes)
|
||||
|
||||
const innerIds = findInnerIds(`${baseId}-start`, id, nodeIds, allNodes)
|
||||
|
||||
groups.push({
|
||||
type,
|
||||
headId: baseId,
|
||||
endId: id,
|
||||
branches: [{ labelId: `${baseId}-start`, innerIds }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a loop is a forloop or whileloop.
|
||||
* We look at children of the start node - whileloop start nodes have type 'whileLoopStart'.
|
||||
* Since we don't have type info in LayoutNode, we check the ID patterns.
|
||||
* Both are laid out identically, so this is mainly for documentation.
|
||||
*/
|
||||
function determineLoopType(
|
||||
_baseId: string,
|
||||
_allNodes: Map<string, LayoutNode>
|
||||
): 'forloop' | 'whileloop' {
|
||||
// Both loop types have identical structure for layout purposes
|
||||
// The distinction doesn't affect layout, so we default to 'forloop'
|
||||
return 'forloop'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
allNodes: Map<string, LayoutNode>
|
||||
): string[] {
|
||||
const inner: string[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
// Build children map (reverse of parentIds)
|
||||
const children = new Map<string, string[]>()
|
||||
for (const [nid, node] of allNodes) {
|
||||
if (!nodeIds.has(nid)) continue
|
||||
for (const pid of node.parentIds ?? []) {
|
||||
if (!nodeIds.has(pid)) continue
|
||||
if (!children.has(pid)) children.set(pid, [])
|
||||
children.get(pid)!.push(nid)
|
||||
}
|
||||
}
|
||||
|
||||
// BFS from label to find all reachable nodes before end
|
||||
const queue = [labelId]
|
||||
visited.add(labelId)
|
||||
visited.add(endId) // Don't traverse past end
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
const kids = children.get(current) ?? []
|
||||
for (const kid of kids) {
|
||||
if (visited.has(kid)) continue
|
||||
visited.add(kid)
|
||||
inner.push(kid)
|
||||
queue.push(kid)
|
||||
}
|
||||
}
|
||||
|
||||
return inner
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 dagNodes = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
parentIds: (n.parentIds ?? []).filter((pid) => nodes.some((nn) => nn.id === 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 {
|
||||
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
|
||||
*/
|
||||
function layoutLevel(
|
||||
nodeIds: string[],
|
||||
allNodes: Map<string, LayoutNode>,
|
||||
constants: LayoutConstants,
|
||||
depth = 0
|
||||
): LayoutResult {
|
||||
const prefix = ' '.repeat(depth)
|
||||
console.log(`${prefix}[layoutLevel] depth=${depth} nodeIds=`, nodeIds)
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>()
|
||||
const nodeIdSet = new Set(nodeIds)
|
||||
|
||||
if (nodeIds.length === 0) {
|
||||
console.log(`${prefix}[layoutLevel] empty → returning`)
|
||||
return { positions, bbox: { width: constants.nodeWidth, height: 0 }, wrappers: [] }
|
||||
}
|
||||
|
||||
// Step 1: detect compound groups at this level
|
||||
const groups = detectGroups(nodeIdSet, allNodes)
|
||||
console.log(
|
||||
`${prefix}[layoutLevel] detected groups:`,
|
||||
groups.map((g) => ({
|
||||
type: g.type,
|
||||
headId: g.headId,
|
||||
endId: g.endId,
|
||||
branches: g.branches.map((b) => ({ labelId: b.labelId, innerIds: b.innerIds }))
|
||||
}))
|
||||
)
|
||||
|
||||
// Build a set of all IDs that belong to groups (to exclude from top-level layout)
|
||||
const groupOwnedIds = new Set<string>()
|
||||
const groupByHeadId = new Map<string, CompoundGroup>()
|
||||
for (const group of groups) {
|
||||
// Only process groups whose head is at this level (not nested)
|
||||
if (!nodeIdSet.has(group.headId)) continue
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to only top-level groups (head is at this level, not owned by another group)
|
||||
const topLevelGroups = groups.filter(
|
||||
(g) => nodeIdSet.has(g.headId) && !groupOwnedIds.has(g.headId)
|
||||
)
|
||||
|
||||
// Rebuild groupOwnedIds for only top-level groups
|
||||
groupOwnedIds.clear()
|
||||
groupByHeadId.clear()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${prefix}[layoutLevel] topLevelGroups:`,
|
||||
topLevelGroups.map((g) => g.headId)
|
||||
)
|
||||
console.log(`${prefix}[layoutLevel] groupOwnedIds:`, [...groupOwnedIds])
|
||||
|
||||
// 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 }
|
||||
}[]
|
||||
wrapperWidth: number
|
||||
wrapperHeight: 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 === 'branchall' || group.type === 'branchone'
|
||||
|
||||
for (const branch of group.branches) {
|
||||
const branchNodeIds = [branch.labelId, ...branch.innerIds]
|
||||
|
||||
console.log(
|
||||
`${prefix} [group ${group.headId}] laying out branch labelId=${branch.labelId} nodes=`,
|
||||
branchNodeIds
|
||||
)
|
||||
// Find sub-groups within this branch
|
||||
const result = layoutLevel(branchNodeIds, allNodes, constants, depth + 1)
|
||||
console.log(
|
||||
`${prefix} [group ${group.headId}] branch result bbox=`,
|
||||
result.bbox,
|
||||
'positions=',
|
||||
Object.fromEntries(result.positions)
|
||||
)
|
||||
|
||||
branchLayouts.push({
|
||||
labelId: branch.labelId,
|
||||
result,
|
||||
bbox: result.bbox
|
||||
})
|
||||
}
|
||||
|
||||
// Compute wrapper dimensions
|
||||
let wrapperWidth: number
|
||||
let wrapperHeight: number
|
||||
const rowHeight = constants.nodeHeight + constants.gapV
|
||||
|
||||
if (isBranch) {
|
||||
// Place branches side by side horizontally
|
||||
const totalBranchWidth = branchLayouts.reduce(
|
||||
(sum, bl) => sum + Math.max(bl.bbox.width, constants.nodeWidth),
|
||||
0
|
||||
)
|
||||
const gaps = Math.max(0, branchLayouts.length - 1) * constants.gapH
|
||||
wrapperWidth = Math.max(totalBranchWidth + gaps, constants.nodeWidth)
|
||||
|
||||
const maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height))
|
||||
// head row + branch content + end row
|
||||
wrapperHeight = rowHeight + maxBranchHeight + rowHeight
|
||||
console.log(
|
||||
`${prefix} [group ${group.headId}] BRANCH wrapper: totalBranchWidth=${totalBranchWidth} gaps=${gaps} maxBranchHeight=${maxBranchHeight} → ${wrapperWidth}x${wrapperHeight}`
|
||||
)
|
||||
} 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, constants.nodeWidth)
|
||||
// head row + start row + body + end row
|
||||
wrapperHeight = rowHeight + bodyHeight + rowHeight
|
||||
console.log(
|
||||
`${prefix} [group ${group.headId}] LOOP wrapper: bodyWidth=${bodyWidth} bodyHeight=${bodyHeight} rowHeight=${rowHeight} → ${wrapperWidth}x${wrapperHeight}`
|
||||
)
|
||||
}
|
||||
|
||||
groupLayouts.set(group.headId, {
|
||||
group,
|
||||
branchLayouts,
|
||||
wrapperWidth,
|
||||
wrapperHeight
|
||||
})
|
||||
wrapperSizes.set(group.headId, { width: wrapperWidth, height: wrapperHeight })
|
||||
}
|
||||
|
||||
// Step 4: Build flattened node list for sugiyama
|
||||
// Regular nodes + wrapper pseudo-nodes (replacing groups)
|
||||
const flatNodes: { id: string; parentIds?: string[] }[] = []
|
||||
|
||||
for (const nid of nodeIds) {
|
||||
if (groupOwnedIds.has(nid)) continue // Skip group internals
|
||||
|
||||
if (groupByHeadId.has(nid)) {
|
||||
// This is a group head — acts as the wrapper node
|
||||
const headNode = allNodes.get(nid)!
|
||||
flatNodes.push({
|
||||
id: nid,
|
||||
parentIds: (headNode.parentIds ?? []).filter(
|
||||
(pid) => nodeIdSet.has(pid) && !groupOwnedIds.has(pid)
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// Regular node
|
||||
const node = allNodes.get(nid)!
|
||||
flatNodes.push({
|
||||
id: nid,
|
||||
parentIds: (node.parentIds ?? []).filter((pid) => {
|
||||
if (!nodeIdSet.has(pid)) return false
|
||||
if (groupOwnedIds.has(pid)) {
|
||||
// This node's parent is inside a group — it should connect to the group head instead
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fix parent references: if a node's parent is a group end node, redirect to the group head
|
||||
const endToHead = new Map<string, string>()
|
||||
for (const group of topLevelGroups) {
|
||||
endToHead.set(group.endId, group.headId)
|
||||
}
|
||||
|
||||
for (const fn of flatNodes) {
|
||||
if (!fn.parentIds) continue
|
||||
// Check original parents before filtering
|
||||
const originalNode = allNodes.get(fn.id)!
|
||||
const newParents: string[] = []
|
||||
for (const pid of originalNode.parentIds ?? []) {
|
||||
if (!nodeIdSet.has(pid)) continue
|
||||
if (endToHead.has(pid)) {
|
||||
const headId = endToHead.get(pid)!
|
||||
if (!newParents.includes(headId)) newParents.push(headId)
|
||||
} else if (!groupOwnedIds.has(pid)) {
|
||||
if (!newParents.includes(pid)) newParents.push(pid)
|
||||
}
|
||||
}
|
||||
fn.parentIds = newParents
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${prefix}[layoutLevel] flatNodes for sugiyama:`,
|
||||
flatNodes.map((n) => ({ id: n.id, parentIds: n.parentIds }))
|
||||
)
|
||||
console.log(`${prefix}[layoutLevel] wrapperSizes:`, Object.fromEntries(wrapperSizes))
|
||||
|
||||
// Step 5: Run sugiyama on flattened nodes
|
||||
const sugResult = runSugiyama(flatNodes, constants, wrapperSizes)
|
||||
|
||||
console.log(
|
||||
`${prefix}[layoutLevel] sugiyama result: box=${sugResult.width}x${sugResult.height}`,
|
||||
'positions=',
|
||||
Object.fromEntries(sugResult.positions)
|
||||
)
|
||||
|
||||
// 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 === 'branchall' || gl.group.type === 'branchone'
|
||||
|
||||
// Position the head node at the top-center of the wrapper
|
||||
positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y })
|
||||
|
||||
if (isBranch) {
|
||||
// Place branches side by side
|
||||
const branchWidths = gl.branchLayouts.map((bl) =>
|
||||
Math.max(bl.bbox.width, constants.nodeWidth)
|
||||
)
|
||||
const totalWidth =
|
||||
branchWidths.reduce((s, w) => s + w, 0) +
|
||||
Math.max(0, branchWidths.length - 1) * constants.gapH
|
||||
|
||||
let currentX = wrapperPos.x - totalWidth / 2
|
||||
|
||||
for (let bi = 0; bi < gl.branchLayouts.length; bi++) {
|
||||
const bl = gl.branchLayouts[bi]
|
||||
const bw = 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 = Math.max(0, ...gl.branchLayouts.map((bl) => bl.bbox.height))
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Collect wrappers: group-level + per-branch + recursive child wrappers
|
||||
const wrappers: WrapperInfo[] = []
|
||||
for (const [headId, gl] of groupLayouts) {
|
||||
const wrapperPos = sugResult.positions.get(headId)
|
||||
if (!wrapperPos) continue
|
||||
|
||||
// Overall group wrapper
|
||||
wrappers.push({
|
||||
id: `__wrapper__${headId}`,
|
||||
headId,
|
||||
type: gl.group.type,
|
||||
level: 'group',
|
||||
label: `${gl.group.type} (${headId})`,
|
||||
x: wrapperPos.x,
|
||||
y: wrapperPos.y,
|
||||
width: gl.wrapperWidth,
|
||||
height: gl.wrapperHeight
|
||||
})
|
||||
|
||||
const rowHeight = constants.nodeHeight + constants.gapV
|
||||
const isBranch = gl.group.type === 'branchall' || gl.group.type === 'branchone'
|
||||
if (isBranch) {
|
||||
const branchWidths = gl.branchLayouts.map((bl) =>
|
||||
Math.max(bl.bbox.width, constants.nodeWidth)
|
||||
)
|
||||
const totalWidth =
|
||||
branchWidths.reduce((s, w) => s + w, 0) +
|
||||
Math.max(0, branchWidths.length - 1) * constants.gapH
|
||||
let cx = wrapperPos.x - totalWidth / 2
|
||||
for (let bi = 0; bi < gl.branchLayouts.length; bi++) {
|
||||
const bl = gl.branchLayouts[bi]
|
||||
const bw = branchWidths[bi]
|
||||
const branchCenterX = cx + bw / 2
|
||||
|
||||
// Per-branch wrapper
|
||||
wrappers.push({
|
||||
id: `__wrapper__${headId}__branch_${bi}`,
|
||||
headId,
|
||||
type: gl.group.type,
|
||||
level: 'branch',
|
||||
label: `branch ${bi} (${bl.labelId})`,
|
||||
x: branchCenterX,
|
||||
y: wrapperPos.y + rowHeight,
|
||||
width: bw,
|
||||
height: bl.bbox.height
|
||||
})
|
||||
|
||||
// Child wrappers from recursive layout
|
||||
for (const cw of bl.result.wrappers) {
|
||||
wrappers.push({ ...cw, x: branchCenterX + cw.x, y: wrapperPos.y + rowHeight + cw.y })
|
||||
}
|
||||
cx += bw + constants.gapH
|
||||
}
|
||||
} else {
|
||||
const bl = gl.branchLayouts[0]
|
||||
if (bl) {
|
||||
// Loop body wrapper
|
||||
wrappers.push({
|
||||
id: `__wrapper__${headId}__body`,
|
||||
headId,
|
||||
type: gl.group.type,
|
||||
level: 'loop-body',
|
||||
label: `loop body (${bl.labelId})`,
|
||||
x: wrapperPos.x + LOOP_INDENT,
|
||||
y: wrapperPos.y + rowHeight,
|
||||
width: bl.bbox.width,
|
||||
height: bl.bbox.height
|
||||
})
|
||||
|
||||
// Child wrappers from recursive layout
|
||||
for (const cw of bl.result.wrappers) {
|
||||
wrappers.push({
|
||||
...cw,
|
||||
x: wrapperPos.x + LOOP_INDENT + cw.x,
|
||||
y: wrapperPos.y + rowHeight + cw.y
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${prefix}[layoutLevel] final positions:`, Object.fromEntries(positions))
|
||||
console.log(
|
||||
`${prefix}[layoutLevel] wrappers:`,
|
||||
wrappers.map((w) => ({ id: w.id, type: w.type, x: w.x, y: w.y, w: w.width, h: w.height }))
|
||||
)
|
||||
|
||||
// Compute overall bbox
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = 0
|
||||
for (const pos of positions.values()) {
|
||||
minX = Math.min(minX, pos.x - constants.nodeWidth / 2)
|
||||
maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2)
|
||||
maxY = Math.max(maxY, pos.y + constants.nodeHeight)
|
||||
}
|
||||
|
||||
const bboxWidth = maxX - minX
|
||||
const bboxHeight =
|
||||
maxY - (positions.size > 0 ? Math.min(...Array.from(positions.values()).map((p) => p.y)) : 0)
|
||||
|
||||
const finalBbox = {
|
||||
width: Math.max(bboxWidth, constants.nodeWidth),
|
||||
height: Math.max(bboxHeight, 0)
|
||||
}
|
||||
console.log(
|
||||
`${prefix}[layoutLevel] returning bbox=`,
|
||||
finalBbox,
|
||||
`(minX=${minX} maxX=${maxX} maxY=${maxY} bboxWidth=${bboxWidth} bboxHeight=${bboxHeight})`
|
||||
)
|
||||
|
||||
return { positions, bbox: finalBbox, wrappers }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] }[],
|
||||
_edges: { source: string; target: 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
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[compoundLayout] input nodes:',
|
||||
nodes.map((n) => ({ id: n.id, parentIds: n.parentIds }))
|
||||
)
|
||||
|
||||
// Build node map
|
||||
const allNodes = new Map<string, LayoutNode>()
|
||||
for (const n of nodes) {
|
||||
allNodes.set(n.id, n)
|
||||
}
|
||||
|
||||
const nodeIds = nodes.map((n) => n.id)
|
||||
const result = layoutLevel(nodeIds, allNodes, c)
|
||||
|
||||
console.log('[compoundLayout] FINAL positions:', Object.fromEntries(result.positions))
|
||||
console.log('[compoundLayout] FINAL bbox:', result.bbox)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -645,13 +645,13 @@ export function graphBuilder(
|
||||
|
||||
if (module.value.type === 'branchall') {
|
||||
// Start
|
||||
addNode(module, currentOffset)
|
||||
addNode(module, 0)
|
||||
|
||||
// "Collect result of each branch" node
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
id: module.id,
|
||||
eventHandlers: eventHandlers,
|
||||
flowModuleState: extra.flowModuleStates?.[module.id]
|
||||
@@ -666,7 +666,7 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-branch-0`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
id: module.id,
|
||||
branchIndex: -1,
|
||||
eventHandlers: eventHandlers,
|
||||
@@ -692,7 +692,7 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-branch-${branchIndex}`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
label: defaultIfEmptyString(branch.summary, `Branch ${branchIndex + 1}`),
|
||||
id: module.id,
|
||||
branchIndex: branchIndex,
|
||||
@@ -723,7 +723,7 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset,
|
||||
0,
|
||||
localDisableMoveIds,
|
||||
parentIndex ? `${parentIndex}-${index}-${branchIndex}` : `${index}-${branchIndex}`
|
||||
)
|
||||
@@ -733,13 +733,13 @@ export function graphBuilder(
|
||||
previousId = endNode.id
|
||||
} else if (module.value.type === 'forloopflow') {
|
||||
if (!simplifiedTriggerView) {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module, 0)
|
||||
}
|
||||
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-start`,
|
||||
data: {
|
||||
offset: currentOffset + 25,
|
||||
offset: 0,
|
||||
id: module.id,
|
||||
module: module,
|
||||
simplifiedTriggerView,
|
||||
@@ -764,7 +764,7 @@ export function graphBuilder(
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
id: module.id,
|
||||
eventHandlers: eventHandlers,
|
||||
simplifiedTriggerView,
|
||||
@@ -785,7 +785,7 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset + 25,
|
||||
0,
|
||||
localDisableMoveIds,
|
||||
parentIndex
|
||||
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
|
||||
@@ -794,12 +794,12 @@ export function graphBuilder(
|
||||
|
||||
previousId = endNode.id
|
||||
} else if (module.value.type === 'whileloopflow') {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module, 0)
|
||||
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-start`,
|
||||
data: {
|
||||
offset: currentOffset + 25,
|
||||
offset: 0,
|
||||
eventHandlers: eventHandlers
|
||||
},
|
||||
type: 'whileLoopStart'
|
||||
@@ -810,7 +810,7 @@ export function graphBuilder(
|
||||
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: { offset: currentOffset, ...extra },
|
||||
data: { offset: 0, ...extra },
|
||||
type: 'whileLoopEnd'
|
||||
}
|
||||
|
||||
@@ -826,7 +826,7 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset + 25,
|
||||
0,
|
||||
localDisableMoveIds,
|
||||
parentIndex
|
||||
? `${parentIndex}-${index}-${selectedIterIndex ?? '?'}`
|
||||
@@ -835,12 +835,12 @@ export function graphBuilder(
|
||||
|
||||
previousId = endNode.id
|
||||
} else if (module.value.type === 'branchone') {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module, 0)
|
||||
|
||||
const endNode: NodeLayout = {
|
||||
id: `${module.id}-end`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
eventHandlers: eventHandlers,
|
||||
flowModuleState: extra.flowModuleStates?.[module.id],
|
||||
id: module.id
|
||||
@@ -853,7 +853,7 @@ export function graphBuilder(
|
||||
// const defaultBranch: NodeLayout = {
|
||||
// id: `${module.id}-default`,
|
||||
// data: {
|
||||
// offset: currentOffset,
|
||||
// offset: 0,
|
||||
// label: 'Default',
|
||||
// id: module.id,
|
||||
// branchIndex: -1,
|
||||
@@ -867,7 +867,7 @@ export function graphBuilder(
|
||||
const defaultBranch: NodeLayout = {
|
||||
id: `${module.id}-branch-default`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
label: 'Default',
|
||||
id: module.id,
|
||||
branchIndex: -1,
|
||||
@@ -894,7 +894,7 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset,
|
||||
0,
|
||||
localDisableMoveIds,
|
||||
parentIndex ? `${parentIndex}-${index}` : index.toString()
|
||||
)
|
||||
@@ -905,7 +905,7 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: `${module.id}-branch-${branchIndex}`,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
label: defaultIfEmptyString(branch.summary, 'Branch ' + (branchIndex + 1)),
|
||||
preLabel: branch.summary ? '' : branch.expr,
|
||||
id: module.id,
|
||||
@@ -932,7 +932,7 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
prefix,
|
||||
currentOffset,
|
||||
0,
|
||||
localDisableMoveIds,
|
||||
parentIndex ? `${parentIndex}-${index}` : index.toString()
|
||||
)
|
||||
@@ -950,7 +950,7 @@ export function graphBuilder(
|
||||
const startNode: NodeLayout = {
|
||||
id: startId,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
label: `Start of subflow ${idWithoutPrefix}`,
|
||||
id: startId,
|
||||
subflowId: module.id,
|
||||
@@ -979,7 +979,7 @@ export function graphBuilder(
|
||||
const endNode: NodeLayout = {
|
||||
id: endId,
|
||||
data: {
|
||||
offset: currentOffset,
|
||||
offset: 0,
|
||||
label: `End of subflow ${idWithoutPrefix}`,
|
||||
id: endId,
|
||||
subflowId: module.id,
|
||||
@@ -999,13 +999,13 @@ export function graphBuilder(
|
||||
endNode,
|
||||
false,
|
||||
buildPrefix(prefix, module['oid'] ?? module.id),
|
||||
currentOffset,
|
||||
0,
|
||||
localDisableMoveIds
|
||||
)
|
||||
|
||||
previousId = endNode.id
|
||||
} else {
|
||||
addNode(module, currentOffset)
|
||||
addNode(module, 0)
|
||||
previousId = module.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
data: {
|
||||
headId: string
|
||||
type: string
|
||||
level: 'group' | 'branch' | 'loop-body'
|
||||
label: string
|
||||
wrapperWidth: number
|
||||
wrapperHeight: number
|
||||
}
|
||||
}
|
||||
let { data }: Props = $props()
|
||||
|
||||
// Group wrappers: solid bg, thicker border
|
||||
// Branch wrappers: lighter bg, thinner border
|
||||
const groupColors: Record<string, { bg: string; border: string }> = {
|
||||
branchall: { bg: 'rgba(59, 130, 246, 0.08)', border: 'rgba(59, 130, 246, 0.5)' },
|
||||
branchone: { bg: 'rgba(168, 85, 247, 0.08)', border: 'rgba(168, 85, 247, 0.5)' },
|
||||
forloop: { bg: 'rgba(34, 197, 94, 0.08)', border: 'rgba(34, 197, 94, 0.5)' },
|
||||
whileloop: { bg: 'rgba(234, 179, 8, 0.08)', border: 'rgba(234, 179, 8, 0.5)' }
|
||||
}
|
||||
const branchColors: Record<string, { bg: string; border: string }> = {
|
||||
branchall: { bg: 'rgba(59, 130, 246, 0.12)', border: 'rgba(59, 130, 246, 0.7)' },
|
||||
branchone: { bg: 'rgba(168, 85, 247, 0.12)', border: 'rgba(168, 85, 247, 0.7)' },
|
||||
forloop: { bg: 'rgba(34, 197, 94, 0.12)', border: 'rgba(34, 197, 94, 0.7)' },
|
||||
whileloop: { bg: 'rgba(234, 179, 8, 0.12)', border: 'rgba(234, 179, 8, 0.7)' }
|
||||
}
|
||||
|
||||
const fallback = { bg: 'rgba(100,100,100,0.06)', border: 'rgba(100,100,100,0.4)' }
|
||||
const colors = data.level === 'group'
|
||||
? (groupColors[data.type] ?? fallback)
|
||||
: (branchColors[data.type] ?? fallback)
|
||||
const borderWidth = data.level === 'group' ? 2 : 1.5
|
||||
const borderStyle = data.level === 'group' ? 'dashed' : 'dotted'
|
||||
</script>
|
||||
|
||||
<div
|
||||
style="
|
||||
width: {data.wrapperWidth}px;
|
||||
height: {data.wrapperHeight}px;
|
||||
background: {colors.bg};
|
||||
border: {borderWidth}px {borderStyle} {colors.border};
|
||||
border-radius: {data.level === 'group' ? 10 : 6}px;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
position: absolute;
|
||||
{data.level === 'group' ? 'top: 2px; left: 6px;' : 'bottom: 2px; right: 6px;'}
|
||||
font-size: 9px;
|
||||
color: {colors.border};
|
||||
font-family: monospace;
|
||||
opacity: 0.85;
|
||||
"
|
||||
>
|
||||
{data.label} {data.wrapperWidth}×{data.wrapperHeight}
|
||||
</span>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
let {
|
||||
enableSourceHandle = true,
|
||||
enableTargetHandle = true,
|
||||
offset = 0,
|
||||
offset: _offset = 0,
|
||||
wrapperClass = '',
|
||||
contextMenuItems = undefined,
|
||||
nodeId = undefined,
|
||||
@@ -39,14 +39,14 @@
|
||||
|
||||
{#if contextMenuItems && contextMenuItems.length > 0}
|
||||
<ContextMenu items={contextMenuItems}>
|
||||
<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>
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
type="source"
|
||||
isConnectable={false}
|
||||
position={Position.Bottom}
|
||||
style={`margin-left: ${offset / 2}px;`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -68,7 +67,6 @@
|
||||
type="target"
|
||||
isConnectable={false}
|
||||
position={Position.Top}
|
||||
style={`margin-left: ${offset / 2}px;`}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -240,14 +240,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)
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user