asl-viewer
Version:
React library for viewing AWS Step Functions workflows in the browser
1,404 lines (1,391 loc) • 132 kB
JavaScript
'use strict';
var jsxRuntime = require('react/jsx-runtime');
var React = require('react');
var yaml = require('js-yaml');
var ReactFlow = require('reactflow');
var controls = require('@reactflow/controls');
var background = require('@reactflow/background');
var minimap = require('@reactflow/minimap');
var iconsReact = require('@tabler/icons-react');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var yaml__namespace = /*#__PURE__*/_interopNamespaceDefault(yaml);
/**
* Validates an ASL definition for syntax and semantic errors
*/
function validateASLDefinition(definition) {
const errors = [];
// Early validation for required fields
if (!definition.StartAt) {
errors.push({
message: "StartAt field is required",
path: "StartAt",
severity: "error",
});
}
// Check States existence and cache state names for performance
const hasStates = definition.States && typeof definition.States === "object";
const stateNames = hasStates ? Object.keys(definition.States) : [];
if (!hasStates || stateNames.length === 0) {
errors.push({
message: "States field is required and must contain at least one state",
path: "States",
severity: "error",
});
return errors; // Early return if no states
}
// Create state name lookup set for O(1) existence checks
const stateNameSet = new Set(stateNames);
// Validate StartAt references an existing state
if (definition.StartAt && !stateNameSet.has(definition.StartAt)) {
errors.push({
message: `StartAt references non-existent state: ${definition.StartAt}`,
path: "StartAt",
severity: "error",
});
}
// Validate each state with pre-computed state name set
for (const [stateName, state] of Object.entries(definition.States)) {
const stateErrors = validateState(stateName, state, stateNameSet);
errors.push(...stateErrors);
}
// Check for unreachable states only if basic validation passes
if (definition.StartAt && stateNameSet.has(definition.StartAt)) {
const reachableStates = findReachableStates(definition);
for (const stateName of stateNames) {
if (!reachableStates.has(stateName)) {
errors.push({
message: `State "${stateName}" is unreachable`,
path: `States.${stateName}`,
severity: "warning",
});
}
}
}
return errors;
}
function validateState(stateName, state, stateNameSet) {
const errors = [];
const basePath = `States.${stateName}`;
// Check required Type field
if (!state.Type) {
errors.push({
message: "Type field is required for all states",
path: `${basePath}.Type`,
severity: "error",
});
return errors;
}
// Pre-compute validation conditions to avoid repeated checks
const hasResource = "Resource" in state && state.Resource;
const hasChoices = state.Choices && Array.isArray(state.Choices);
const hasNext = "Next" in state && state.Next;
const hasEnd = "End" in state && state.End;
// Validate state type-specific requirements
switch (state.Type) {
case "Pass":
// Pass states can have Result but not Resource
if (hasResource) {
errors.push({
message: "Pass states cannot have Resource field",
path: `${basePath}.Resource`,
severity: "error",
});
}
break;
case "Task":
// Task states must have Resource
if (!hasResource) {
errors.push({
message: "Task states must have Resource field",
path: `${basePath}.Resource`,
severity: "error",
});
}
break;
case "Choice":
// Choice states must have Choices array and cannot have End or Next
if (!hasChoices || state.Choices.length === 0) {
errors.push({
message: "Choice states must have non-empty Choices array",
path: `${basePath}.Choices`,
severity: "error",
});
}
if (hasEnd) {
errors.push({
message: "Choice states cannot have End field",
path: `${basePath}.End`,
severity: "error",
});
}
if (hasNext) {
errors.push({
message: "Choice states cannot have Next field",
path: `${basePath}.Next`,
severity: "error",
});
}
break;
case "Wait": {
// Wait states must have exactly one time specification
const timeFields = [
"Seconds",
"Timestamp",
"SecondsPath",
"TimestampPath",
];
const presentTimeFields = timeFields.filter((field) => field in state && state[field] !== undefined);
if (presentTimeFields.length === 0) {
errors.push({
message: "Wait states must have one of: Seconds, Timestamp, SecondsPath, or TimestampPath",
path: basePath,
severity: "error",
});
}
else if (presentTimeFields.length > 1) {
errors.push({
message: "Wait states can only have one time specification field",
path: basePath,
severity: "error",
});
}
break;
}
case "Parallel": {
// Parallel states must have Branches
if (!state.Branches ||
!Array.isArray(state.Branches) ||
state.Branches.length === 0) {
errors.push({
message: "Parallel states must have non-empty Branches array",
path: `${basePath}.Branches`,
severity: "error",
});
}
break;
}
case "Map": {
// Map states must have Iterator
if (!state.Iterator) {
errors.push({
message: "Map states must have Iterator field",
path: `${basePath}.Iterator`,
severity: "error",
});
}
break;
}
case "Fail": {
// Fail states cannot have Next and automatically end
if (hasNext) {
errors.push({
message: "Fail states cannot have Next field",
path: `${basePath}.Next`,
severity: "error",
});
}
break;
}
case "Succeed": {
// Succeed states cannot have Next and automatically end
if (hasNext) {
errors.push({
message: "Succeed states cannot have Next field",
path: `${basePath}.Next`,
severity: "error",
});
}
break;
}
}
// Validate Next references with O(1) lookup
if (hasNext && !stateNameSet.has(state.Next)) {
errors.push({
message: `Next references non-existent state: ${state.Next}`,
path: `${basePath}.Next`,
severity: "error",
});
}
// Validate Choice references with batch processing
if (hasChoices) {
for (let i = 0; i < state.Choices.length; i++) {
const choice = state.Choices[i];
if (choice.Next && !stateNameSet.has(choice.Next)) {
errors.push({
message: `Choice rule references non-existent state: ${choice.Next}`,
path: `${basePath}.Choices[${i}].Next`,
severity: "error",
});
}
}
}
// Validate Default reference for Choice states
if (state.Default && !stateNameSet.has(state.Default)) {
errors.push({
message: `Default references non-existent state: ${state.Default}`,
path: `${basePath}.Default`,
severity: "error",
});
}
// Validate Catch references with batch processing
if (state.Catch && Array.isArray(state.Catch)) {
for (let i = 0; i < state.Catch.length; i++) {
const catchDef = state.Catch[i];
if (!stateNameSet.has(catchDef.Next)) {
errors.push({
message: `Catch references non-existent state: ${catchDef.Next}`,
path: `${basePath}.Catch[${i}].Next`,
severity: "error",
});
}
}
}
return errors;
}
function findReachableStates(definition) {
const reachable = new Set();
// Early return if States is not defined or StartAt is missing
if (!definition.States || !definition.StartAt) {
return reachable;
}
// Use iterative approach with stack for better performance
const toVisit = [definition.StartAt];
const states = definition.States;
while (toVisit.length > 0) {
const current = toVisit.pop();
// Skip if already visited or state doesn't exist
if (reachable.has(current) || !states[current]) {
continue;
}
reachable.add(current);
const state = states[current];
// Batch collect next states to minimize array operations
const nextStates = [];
// Add direct next state
if (state.Next) {
nextStates.push(state.Next);
}
// Add choice targets
if (state.Choices) {
for (const choice of state.Choices) {
if (choice.Next) {
nextStates.push(choice.Next);
}
}
}
// Add default choice target
if (state.Default) {
nextStates.push(state.Default);
}
// Add catch targets
if (state.Catch) {
for (const catchDef of state.Catch) {
nextStates.push(catchDef.Next);
}
}
// Add all collected states at once
toVisit.push(...nextStates);
// Handle parallel branches recursively (less common, kept separate)
if (state.Branches) {
for (const branch of state.Branches) {
const branchReachable = findReachableStates(branch);
for (const stateName of branchReachable) {
reachable.add(stateName);
}
}
}
// Handle map iterator recursively (less common, kept separate)
if (state.Iterator) {
const iteratorReachable = findReachableStates(state.Iterator);
for (const stateName of iteratorReachable) {
reachable.add(stateName);
}
}
}
return reachable;
}
/**
* Parses ASL definition from string to object
*/
function parseASLDefinition(definition) {
if (typeof definition === "string") {
try {
return JSON.parse(definition);
}
catch (error) {
throw new Error(`Invalid JSON: ${error}`);
}
}
return definition;
}
/**
* Determines the size of a state node based on its type and properties
*/
function getStateSize(type, isStartState = false, isEndState = false) {
// All regular state nodes are now rectangular (no more circular end states)
switch (type) {
case "Choice":
return { width: 240, height: 60 };
case "Parallel":
case "Map":
return { width: 260, height: 60 };
case "Task":
return { width: 230, height: 60 };
case "Wait":
return { width: 220, height: 60 };
default:
return { width: 220, height: 60 };
}
}
/**
* Calculates the bounds needed for a group container with improved spacing
*/
function calculateGroupBounds(type, children) {
if (children.length === 0) {
return { width: 350, height: 250 }; // Increased default size
}
if (type === "Parallel") {
// Group children by branch
const branches = new Map();
children.forEach((child) => {
const branchIndex = child.branchIndex ?? 0;
if (!branches.has(branchIndex)) {
branches.set(branchIndex, []);
}
branches.get(branchIndex).push(child);
});
const branchCount = branches.size;
const maxBranchHeight = Math.max(...Array.from(branches.values()).map((branchChildren) => branchChildren.length * 90 + (branchChildren.length - 1) * 20));
return {
width: Math.max(450, branchCount * 200 + (branchCount - 1) * 40 + 60), // Increased width
height: Math.max(250, maxBranchHeight + 150), // Increased padding and height
};
}
else if (type === "Map") {
// Map iterator children are arranged vertically with better spacing
const totalHeight = children.length * 90 + (children.length - 1) * 20; // Increased spacing
return {
width: 350, // Increased width
height: Math.max(250, totalHeight + 150), // Increased padding
};
}
return { width: 350, height: 250 }; // Increased default size
}
/**
* Creates a regular state node
*/
function createStateNode(stateName, state, startAt) {
const isStartState = stateName === startAt;
const isEndState = state.End === true || state.Type === "Succeed" || state.Type === "Fail";
const size = getStateSize(state.Type, isStartState, isEndState);
return {
id: stateName,
name: stateName,
type: state.Type,
definition: state,
position: { x: 0, y: 0 }, // Will be set by layout algorithm
size,
connections: [],
isStartState,
isEndState,
};
}
/**
* Creates a group node that contains parent and child nodes
*/
function createGroupNode(stateName, state, startAt, children) {
const isStartState = stateName === startAt;
const isEndState = state.End === true || state.Type === "Succeed" || state.Type === "Fail";
// Calculate group bounds based on children (for expanded state)
const groupBounds = calculateGroupBounds(state.Type, children);
// Start with collapsed size (same as regular node)
const collapsedSize = getStateSize(state.Type, isStartState, isEndState);
return {
id: stateName,
name: stateName,
type: state.Type,
definition: state,
position: { x: 0, y: 0 }, // Will be set by layout algorithm
size: collapsedSize, // Start collapsed
connections: [],
isStartState,
isEndState,
isGroup: true,
groupBounds,
children,
isExpanded: false, // Start collapsed
};
}
/**
* Creates artificial start and end nodes for the graph
*/
function createArtificialNodes() {
const startNode = {
id: "__start__",
name: "START",
type: "Pass", // Using Pass as a placeholder type
definition: { Type: "Pass" },
position: { x: 0, y: 0 },
size: { width: 80, height: 80 }, // Circular node
connections: [],
isStartState: false,
isEndState: false,
};
const endNode = {
id: "__end__",
name: "END",
type: "Pass", // Using Pass as a placeholder type
definition: { Type: "Pass" },
position: { x: 0, y: 0 },
size: { width: 80, height: 80 }, // Circular node
connections: [],
isStartState: false,
isEndState: false,
};
return { start: startNode, end: endNode };
}
/**
* Creates connections between states based on state definition
*/
function createConnections(stateName, state) {
const connections = [];
// Next connection
if (state.Next) {
connections.push({
from: stateName,
to: state.Next,
type: "next",
});
}
// Choice connections
if (state.Choices) {
state.Choices.forEach((choice, index) => {
connections.push({
from: stateName,
to: choice.Next,
type: "choice",
label: `Choice ${index + 1}`,
condition: formatChoiceCondition(choice),
});
});
}
// Default connection for Choice states
if (state.Default) {
connections.push({
from: stateName,
to: state.Default,
type: "default",
label: "Default",
});
}
// Catch connections
if (state.Catch) {
state.Catch.forEach((catchDef, index) => {
connections.push({
from: stateName,
to: catchDef.Next,
type: "error",
label: `Catch ${index + 1}`,
condition: catchDef.ErrorEquals.join(", "),
});
});
}
return connections;
}
/**
* Formats choice condition into human-readable string
*/
function formatChoiceCondition(choice) {
// Create a human-readable condition string
if (choice.Variable) {
const variable = choice.Variable;
if (choice.StringEquals !== undefined)
return `${variable} == "${choice.StringEquals}"`;
if (choice.StringLessThan !== undefined)
return `${variable} < "${choice.StringLessThan}"`;
if (choice.StringGreaterThan !== undefined)
return `${variable} > "${choice.StringGreaterThan}"`;
if (choice.NumericEquals !== undefined)
return `${variable} == ${choice.NumericEquals}`;
if (choice.NumericLessThan !== undefined)
return `${variable} < ${choice.NumericLessThan}`;
if (choice.NumericGreaterThan !== undefined)
return `${variable} > ${choice.NumericGreaterThan}`;
if (choice.BooleanEquals !== undefined)
return `${variable} == ${choice.BooleanEquals}`;
}
return "condition";
}
/**
* Creates child nodes for Parallel state branches
*/
function createParallelChildNodes(parentId, branches) {
const childNodes = [];
branches.forEach((branch, branchIndex) => {
// Create nodes for each state in the branch
Object.entries(branch.States).forEach(([stateName, state]) => {
const childId = `${parentId}_branch${branchIndex}_${stateName}`;
const childNode = {
id: childId,
name: stateName,
type: state.Type,
definition: state,
position: { x: 0, y: 0 }, // Will be set by layout algorithm
size: getStateSize(state.Type),
connections: [],
isStartState: stateName === branch.StartAt,
isEndState: state.End === true ||
state.Type === "Succeed" ||
state.Type === "Fail",
parentId,
branchIndex,
};
childNodes.push(childNode);
});
// Create connections within the branch
Object.entries(branch.States).forEach(([stateName, state]) => {
if (state.Next) {
const fromId = `${parentId}_branch${branchIndex}_${stateName}`;
const toId = `${parentId}_branch${branchIndex}_${state.Next}`;
// Only add if the target state exists in this branch
const targetExists = childNodes.some((node) => node.id === toId);
if (targetExists) {
const fromNode = childNodes.find((node) => node.id === fromId);
if (fromNode) {
fromNode.connections.push({
from: fromId,
to: toId,
type: "next",
});
}
}
}
});
});
return childNodes;
}
/**
* Creates child nodes for Map state iterator
*/
function createMapChildNodes(parentId, iterator) {
const childNodes = [];
// Create nodes for each state in the iterator
Object.entries(iterator.States).forEach(([stateName, state]) => {
const childId = `${parentId}_iterator_${stateName}`;
const childNode = {
id: childId,
name: stateName,
type: state.Type,
definition: state,
position: { x: 0, y: 0 }, // Will be set by layout algorithm
size: getStateSize(state.Type),
connections: [],
isStartState: stateName === iterator.StartAt,
isEndState: state.End === true || state.Type === "Succeed" || state.Type === "Fail",
parentId,
branchIndex: 0, // Map has only one iterator
};
childNodes.push(childNode);
});
// Create connections within the iterator
Object.entries(iterator.States).forEach(([stateName, state]) => {
if (state.Next) {
const fromId = `${parentId}_iterator_${stateName}`;
const toId = `${parentId}_iterator_${state.Next}`;
// Only add if the target state exists in the iterator
const targetExists = childNodes.some((node) => node.id === toId);
if (targetExists) {
const fromNode = childNodes.find((node) => node.id === fromId);
if (fromNode) {
fromNode.connections.push({
from: fromId,
to: toId,
type: "next",
});
}
}
}
});
return childNodes;
}
/**
* Positions child nodes around their parent node with improved spacing
*/
function positionChildNodes(parentNode, children, nodeSpacing) {
if (children.length === 0)
return;
const childSpacing = 180; // Increased spacing between branches
const verticalOffset = 100; // Increased vertical offset from parent
const verticalSpacing = 90; // Increased spacing between nodes in same branch
if (parentNode.type === "Parallel") {
// Group children by branch
const branches = new Map();
children.forEach((child) => {
const branchIndex = child.branchIndex ?? 0;
if (!branches.has(branchIndex)) {
branches.set(branchIndex, []);
}
branches.get(branchIndex).push(child);
});
// Position each branch with improved spacing
let branchOffset = 0;
const totalBranchWidth = (branches.size - 1) * childSpacing;
const startOffset = -totalBranchWidth / 2;
branches.forEach((branchChildren, branchIndex) => {
const branchStartX = parentNode.position.x + startOffset + branchOffset;
branchChildren.forEach((child, childIndex) => {
child.position = {
x: branchStartX,
y: parentNode.position.y +
verticalOffset +
childIndex * verticalSpacing,
};
});
branchOffset += childSpacing;
});
}
else if (parentNode.type === "Map") {
// Position iterator children in a vertical line with improved spacing
const startX = parentNode.position.x;
children.forEach((child, index) => {
child.position = {
x: startX,
y: parentNode.position.y + verticalOffset + index * verticalSpacing,
};
});
}
}
/**
* Calculates reactive layout adjustments when group nodes are expanded/collapsed
*/
function calculateReactiveLayout(nodes, edges, expandedNodeIds, layoutCache) {
// Make a copy of nodes to avoid mutation
const updatedNodes = nodes.map((node) => ({ ...node }));
// Store original positions if not already cached
if (layoutCache.originalPositions.size === 0) {
updatedNodes.forEach((node) => {
layoutCache.originalPositions.set(node.id, { ...node.position });
});
}
// Check if expansion state has changed
const hasChanges = !setsEqual(expandedNodeIds, layoutCache.expandedNodes);
if (!hasChanges) {
return updatedNodes;
}
// Update cache
layoutCache.expandedNodes = new Set(expandedNodeIds);
// Separate group nodes and regular nodes
const groupNodes = updatedNodes.filter((node) => node.isGroup);
const regularNodes = updatedNodes.filter((node) => !node.isGroup && !node.parentId);
// Sort nodes by vertical position for proper adjustment calculation
const sortedNodes = [...groupNodes, ...regularNodes].sort((a, b) => a.position.y - b.position.y);
// Calculate space requirements for each expanded group
const spaceRequirements = new Map();
groupNodes.forEach((groupNode) => {
if (expandedNodeIds.has(groupNode.id)) {
const requiredSpace = calculateExpandedGroupSpace(groupNode);
spaceRequirements.set(groupNode.id, requiredSpace);
}
});
// Adjust positions based on expanded groups
adjustNodePositions(sortedNodes, spaceRequirements, layoutCache.originalPositions, expandedNodeIds);
// Position child nodes for expanded groups
groupNodes.forEach((groupNode) => {
if (expandedNodeIds.has(groupNode.id) && groupNode.children) {
// Update group size for expanded state
if (groupNode.groupBounds) {
groupNode.size = { ...groupNode.groupBounds };
}
// Position child nodes relative to the group
positionChildNodes(groupNode, groupNode.children);
// Update existing child nodes in the result instead of adding duplicates
groupNode.children.forEach((child) => {
const existingIndex = updatedNodes.findIndex((n) => n.id === child.id);
if (existingIndex >= 0) {
// Update existing child node with new position
updatedNodes[existingIndex] = {
...updatedNodes[existingIndex],
...child,
};
}
// Don't add new children nodes to updatedNodes as they should only be rendered inside the group
});
}
else {
// Restore original collapsed size
const originalSize = getCollapsedSize(groupNode.type);
groupNode.size = originalSize;
}
});
return updatedNodes;
}
/**
* Adjusts node positions to accommodate expanded groups
*/
function adjustNodePositions(sortedNodes, spaceRequirements, originalPositions, expandedNodeIds) {
let cumulativeOffset = 0;
const processedNodes = new Set();
sortedNodes.forEach((node) => {
if (processedNodes.has(node.id))
return;
const originalPos = originalPositions.get(node.id);
if (!originalPos)
return;
// Apply cumulative offset to vertical position
node.position = {
x: originalPos.x,
y: originalPos.y + cumulativeOffset,
};
processedNodes.add(node.id);
// If this is an expanded group, add its space requirement to the offset
if (node.isGroup && expandedNodeIds.has(node.id)) {
const requiredSpace = spaceRequirements.get(node.id) || 0;
cumulativeOffset += requiredSpace;
}
});
}
/**
* Calculates the additional space needed when a group is expanded
*/
function calculateExpandedGroupSpace(groupNode) {
if (!groupNode.groupBounds)
return 0;
const collapsedSize = getCollapsedSize(groupNode.type);
const expandedHeight = groupNode.groupBounds.height;
// Return the additional space needed (expanded height - collapsed height)
return Math.max(0, expandedHeight - collapsedSize.height + 50); // Extra 50px for spacing
}
/**
* Gets the collapsed size for a group node type
*/
function getCollapsedSize(type) {
switch (type) {
case "Parallel":
case "Map":
return { width: 260, height: 60 };
default:
return { width: 220, height: 60 };
}
}
/**
* Utility function to check if two sets are equal
*/
function setsEqual(setA, setB) {
if (setA.size !== setB.size)
return false;
for (const item of setA) {
if (!setB.has(item))
return false;
}
return true;
}
/**
* Calculates improved spacing between levels and nodes
*/
function calculateImprovedSpacing(nodes, edges) {
// Analyze edge complexity to determine spacing
const hasLabeledEdges = edges.some((edge) => edge.label && edge.label.trim().length > 0);
const hasMultipleChoices = edges.filter((edge) => edge.type === "choice").length > 1;
const hasErrorHandling = edges.some((edge) => edge.type === "error");
// Base spacing values
let nodeSpacing = 300; // Horizontal spacing between nodes
let levelSpacing = 120; // Vertical spacing between levels
// Adjust spacing based on complexity
if (hasLabeledEdges) {
nodeSpacing += 40;
levelSpacing += 30;
}
if (hasMultipleChoices) {
nodeSpacing += 60;
levelSpacing += 20;
}
if (hasErrorHandling) {
nodeSpacing += 30;
levelSpacing += 25;
}
// Check for group nodes that might need extra space
const hasGroupNodes = nodes.some((node) => node.isGroup);
if (hasGroupNodes) {
nodeSpacing += 50;
levelSpacing += 40;
}
return {
nodeSpacing: Math.min(nodeSpacing, 500), // Cap at reasonable maximum
levelSpacing: Math.min(levelSpacing, 200),
};
}
/**
* Calculates hierarchical layout for nodes using BFS algorithm
*/
function calculateHierarchicalLayout(nodes, edges, startAt) {
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
const visited = new Set();
const levels = [];
// Separate parent nodes from child nodes
const parentNodes = nodes.filter((node) => !node.parentId);
nodes.filter((node) => node.parentId);
// Build adjacency list (only for parent nodes initially)
const adjacency = new Map();
edges.forEach((edge) => {
// Only process edges between parent nodes for the main layout
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (fromNode && toNode && !fromNode.parentId && !toNode.parentId) {
if (!adjacency.has(edge.from)) {
adjacency.set(edge.from, []);
}
adjacency.get(edge.from).push(edge.to);
}
});
// BFS to determine levels (only for parent nodes)
const queue = [{ id: startAt, level: 0 }];
visited.add(startAt);
while (queue.length > 0) {
const { id, level } = queue.shift();
if (!levels[level]) {
levels[level] = [];
}
levels[level].push(id);
const neighbors = adjacency.get(id) || [];
neighbors.forEach((neighbor) => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push({ id: neighbor, level: level + 1 });
}
});
}
// Add any remaining parent nodes (unreachable) to the last level
parentNodes.forEach((node) => {
if (!visited.has(node.id)) {
if (levels.length === 0) {
levels.push([]);
}
levels[levels.length - 1].push(node.id);
}
});
// Calculate improved spacing based on graph complexity
const spacingConfig = calculateImprovedSpacing(parentNodes, edges);
// Position nodes with dynamic spacing based on labeled edges
const layout = positionNodesInLevels(levels, nodeMap, edges, spacingConfig);
return {
nodes: Array.from(nodeMap.values()),
width: Math.max(layout.width, 600),
height: Math.max(layout.height, 400),
};
}
/**
* Positions nodes in their calculated levels with proper spacing
*/
function positionNodesInLevels(levels, nodeMap, edges, spacingConfig) {
// Use provided spacing or fall back to defaults
const nodeSpacing = spacingConfig?.nodeSpacing || 340;
const baseLevelSpacing = spacingConfig?.levelSpacing || 150;
const labeledEdgeSpacing = 50; // Extra spacing for labeled edges
const startX = 100;
const startY = 100;
let maxWidth = 0;
let currentY = startY;
// Check if there are edges with labels between levels
const hasLabeledEdgesBetweenLevels = (fromLevel, toLevel) => {
if (fromLevel >= levels.length || toLevel >= levels.length)
return false;
for (const fromNode of levels[fromLevel]) {
for (const toNode of levels[toLevel]) {
const edge = edges.find((e) => e.from === fromNode && e.to === toNode);
if (edge && edge.label && edge.label.trim().length > 0) {
return true;
}
}
}
return false;
};
levels.forEach((level, levelIndex) => {
const levelWidth = level.length * nodeSpacing;
maxWidth = Math.max(maxWidth, levelWidth);
const startXForLevel = startX + (maxWidth - levelWidth) / 2;
level.forEach((nodeId, nodeIndex) => {
const node = nodeMap.get(nodeId);
if (node) {
node.position = {
x: startXForLevel + nodeIndex * nodeSpacing,
y: currentY,
};
}
});
// Calculate spacing for next level
if (levelIndex < levels.length - 1) {
let spacingToNext = baseLevelSpacing;
// Check if there are labeled edges between this level and the next
if (hasLabeledEdgesBetweenLevels(levelIndex, levelIndex + 1)) {
spacingToNext += labeledEdgeSpacing;
}
// Add extra space if current level has group nodes
const hasGroupNodes = level.some((nodeId) => {
const node = nodeMap.get(nodeId);
return node && node.isGroup;
});
if (hasGroupNodes) {
spacingToNext += 50; // Extra space for group nodes
}
currentY += spacingToNext;
}
});
const totalWidth = maxWidth + 200;
const totalHeight = currentY + 100;
return { width: totalWidth, height: totalHeight };
}
/**
* Converts ASL definition to graph layout using a hierarchical approach optimized for React Flow
*/
function createGraphLayout(definition) {
const nodes = [];
const edges = [];
// Create artificial start and end nodes
const { start: startNode, end: endNode } = createArtificialNodes();
nodes.push(startNode, endNode);
// Create connection from artificial start to actual start state
edges.push({
from: "__start__",
to: definition.StartAt,
type: "next",
});
// Find end states and create connections to artificial end node
const endStates = findEndStates(definition);
// Create nodes for all states
Object.entries(definition.States).forEach(([stateName, state]) => {
if (state.Type === "Parallel" && state.Branches) {
// Create a group node for Parallel state
const childNodes = createParallelChildNodes(stateName, state.Branches);
const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes);
nodes.push(groupNode);
}
else if (state.Type === "Map" && state.Iterator) {
// Create a group node for Map state
const childNodes = createMapChildNodes(stateName, state.Iterator);
const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes);
nodes.push(groupNode);
}
else {
// Regular state node
const node = createStateNode(stateName, state, definition.StartAt);
nodes.push(node);
}
});
// Create edges between states
Object.entries(definition.States).forEach(([stateName, state]) => {
const connections = createConnections(stateName, state);
edges.push(...connections);
});
// Add connections from end states to artificial end node
endStates.forEach((endState) => {
edges.push({
from: endState,
to: "__end__",
type: "next",
});
});
// Calculate hierarchical layout
const layout = calculateHierarchicalLayout(nodes, edges, "__start__");
return {
nodes: layout.nodes,
edges,
width: layout.width,
height: layout.height,
};
}
/**
* Creates a simplified layout for basic use cases without complex dependencies
*/
function createSimpleLayout(definition) {
const nodes = [];
const edges = [];
// Calculate improved spacing first
const tempNodes = Object.entries(definition.States).map(([stateName, state]) => createStateNode(stateName, state, definition.StartAt));
// Create temporary edges to analyze spacing needs
const tempEdges = [];
Object.entries(definition.States).forEach(([stateName, state]) => {
const connections = createConnections(stateName, state);
tempEdges.push(...connections);
});
const spacingConfig = calculateImprovedSpacing(tempNodes, tempEdges);
let currentY = 50;
const baseSpacing = spacingConfig.levelSpacing;
const labeledEdgeSpacing = 50; // Extra spacing for labeled edges
const centerX = 200;
// Create artificial start node
const { start: startNode, end: endNode } = createArtificialNodes();
startNode.position = { x: centerX - 40, y: currentY };
startNode.size = { width: 40, height: 40 };
nodes.push(startNode);
// Create connection from artificial start to actual start state
edges.push({
from: "__start__",
to: definition.StartAt,
type: "next",
});
// Find end states
const endStates = findEndStates(definition);
// Create edges between states first to check for labels
Object.entries(definition.States).forEach(([stateName, state]) => {
const connections = createConnections(stateName, state);
edges.push(...connections);
});
// Add connections from end states to artificial end node
endStates.forEach((endState) => {
edges.push({
from: endState,
to: "__end__",
type: "next",
});
});
// Helper function to check if there are labeled edges from a specific node
const hasLabeledEdgesFrom = (nodeId) => {
return edges.some((edge) => edge.from === nodeId && edge.label && edge.label.trim().length > 0);
};
// Position start node
currentY += baseSpacing;
if (hasLabeledEdgesFrom("__start__")) {
currentY += labeledEdgeSpacing;
}
// Create nodes in simple vertical layout with dynamic spacing
const stateEntries = Object.entries(definition.States);
stateEntries.forEach(([stateName, state], index) => {
if (state.Type === "Parallel" && state.Branches) {
// Create a group node for Parallel state
const childNodes = createParallelChildNodes(stateName, state.Branches);
const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes);
groupNode.position = {
x: centerX - groupNode.size.width / 2,
y: currentY,
};
nodes.push(groupNode);
}
else if (state.Type === "Map" && state.Iterator) {
// Create a group node for Map state
const childNodes = createMapChildNodes(stateName, state.Iterator);
const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes);
groupNode.position = {
x: centerX - groupNode.size.width / 2,
y: currentY,
};
nodes.push(groupNode);
}
else {
// Regular state node
const node = createStateNode(stateName, state, definition.StartAt);
node.position = { x: centerX - node.size.width / 2, y: currentY };
nodes.push(node);
}
// Calculate spacing for next node
if (index < stateEntries.length - 1) {
let spacingToNext = baseSpacing;
if (hasLabeledEdgesFrom(stateName)) {
spacingToNext += labeledEdgeSpacing;
}
// Add extra space for group nodes
if ((state.Type === "Parallel" && state.Branches) ||
(state.Type === "Map" && state.Iterator)) {
spacingToNext += 50;
}
currentY += spacingToNext;
}
else {
// For the last state, check if it has labeled edges to end
let spacingToEnd = baseSpacing;
if (hasLabeledEdgesFrom(stateName)) {
spacingToEnd += labeledEdgeSpacing;
}
// Add extra space for group nodes
if ((state.Type === "Parallel" && state.Branches) ||
(state.Type === "Map" && state.Iterator)) {
spacingToEnd += 50;
}
currentY += spacingToEnd;
}
});
// Create artificial end node
endNode.position = { x: centerX - 40, y: currentY };
endNode.size = { width: 40, height: 40 };
nodes.push(endNode);
return {
nodes,
edges,
width: 400,
height: currentY + 100,
};
}
/**
* Finds all end states in the ASL definition
*/
function findEndStates(definition) {
const endStates = [];
Object.entries(definition.States).forEach(([stateName, state]) => {
if (state.End === true ||
state.Type === "Succeed" ||
state.Type === "Fail") {
endStates.push(stateName);
}
});
return endStates;
}
/**
* CSS transition styles for smooth node movement
*/
const nodeTransitionStyles = {
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
transitionProperty: "transform, opacity, width, height",
};
/**
* Animation timing constants
*/
const ANIMATION_DURATION = 300; // milliseconds
/**
* Easing functions for smooth animations
*/
const easingFunctions = {
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
easeOutBack: (t) => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
};
/**
* Manages animation state for node transitions
*/
class NodeAnimationManager {
constructor() {
this.animationState = null;
this.animationFrame = null;
/**
* Animation loop
*/
this.animate = () => {
if (!this.animationState)
return;
const now = performance.now();
const elapsed = now - this.animationState.startTime;
const progress = Math.min(elapsed / this.animationState.duration, 1);
// Apply easing
const easedProgress = easingFunctions.easeOutCubic(progress);
// Calculate current positions
const currentPositions = new Map();
this.animationState.fromPositions.forEach((fromPos, nodeId) => {
const toPos = this.animationState.toPositions.get(nodeId);
if (toPos) {
currentPositions.set(nodeId, {
x: fromPos.x + (toPos.x - fromPos.x) * easedProgress,
y: fromPos.y + (toPos.y - fromPos.y) * easedProgress,
});
}
});
// Call update callback
if (this.onUpdate) {
this.onUpdate(easedProgress, currentPositions);
}
// Continue animation or finish
if (progress < 1) {
this.animationFrame = requestAnimationFrame(this.animate);
}
else {
this.stopAnimation();
}
};
}
/**
* Starts a new animation between node positions
*/
startAnimation(fromNodes, toNodes, duration = ANIMATION_DURATION, onUpdate) {
// Stop any existing animation
this.stopAnimation();
// Create position maps
const fromPositions = new Map();
const toPositions = new Map();
fromNodes.forEach((node) => {
fromPositions.set(node.id, { ...node.position });
});
toNodes.forEach((node) => {
toPositions.set(node.id, { ...node.position });
});
// Initialize animation state
this.animationState = {
isAnimating: true,
startTime: performance.now(),
duration,
fromPositions,
toPositions,
};
this.onUpdate = onUpdate;
// Start animation loop
this.animate();
}
/**
* Stops the current animation
*/
stopAnimation() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
this.animationState = null;
this.onUpdate = undefined;
}
/**
* Checks if animation is currently running
*/
isAnimating() {
return this.animationState?.isAnimating ?? false;
}
}
/**
* Modern Light Theme - Clean and professional with subtle shadows
*/
const lightTheme = {
name: "light",
background: "#fafbfc",
surfaceColor: "#ffffff",
overlayColor: "rgba(255, 255, 255, 0.95)",
nodeColors: {
pass: "#e8f4fd",
task: "#f0f9f0",
choice: "#fff8e1",
wait: "#f8f4ff",
succeed: "#e8f5e8",
fail: "#ffebee",
parallel: "#e0f7fa",
map: "#f1f8e9",
},
nodeBorderColors: {
pass: "#2196f3",
task: "#4caf50",
choice: "#ff9800",
wait: "#9c27b0",
succeed: "#4caf50",
fail: "#f44336",
parallel: "#00bcd4",
map: "#8bc34a",
},
nodeHoverColors: {
pass: "#d4ecfc",
task: "#e6f5e6",
choice: "#ffecb3",
wait: "#ede7f6",
succeed: "#d4edda",
fail: "#f5c6cb",
parallel: "#b2ebf2",
map: "#dcedc8",
},
textColor: "#1a1a1a",
textColorSecondary: "#4a4a4a",
textColorMuted: "#757575",
borderColor: "#e1e4e8",
borderColorHover: "#c6cbd1",
connectionColor: "#6a737d",
connectionHoverColor: "#24292e",
connectionLabelColor: "#586069",
startNodeColor: "#28a745",
endNodeColor: "#dc3545",
selectedNodeColor: "#0366d6",
shadowColor: "rgba(27, 31, 35, 0.15)",
errorColor: "#d73a49",
warningColor: "#f66a0a",
infoColor: "#0366d6",
successColor: "#28a745",
gridColor: "#f0f0f0",
miniMapBackground: "#ffffff",
controlsBackground: "#ffffff",
tooltipBackground: "#24292e",
tooltipTextColor: "#ffffff",
};
/**
* Modern Dark Theme - Sleek and elegant with high contrast
*/
const darkTheme = {
name: "dark",
background: "#0d1117",
surfaceColor: "#161b22",
overlayColor: "rgba(22, 27, 34, 0.95)",
nodeColors: {
pass: "#1f2937",
task: "#064e3b",
choice: "#451a03",
wait: "#581c87",
succeed: "#064e3b",
fail: "#7f1d1d",
parallel: "#164e63",
map: "#365314",
},
nodeBorderColors: {
pass: "#3b82f6",
task: "#10b981",
choice: "#f59e0b",
wait: "#a855f7",
succeed: "#10b981",
fail: "#ef4444",
parallel: "#06b6d4",
map: "#84cc16",
},
nodeHoverColors: {
pass: "#374151",
task: "#065f46",
choice: "#78350f",
wait: "#6b21a8",
succeed: "#065f46",
fail: "#991b1b",
parallel: "#0e7490",
map: "#4d7c0f",
},
textColor: "#f0f6fc",
textColorSecondary: "#c9d1d9",
textColorMuted: "#8b949e",
borderColor: "#30363d",
borderColorHover: "#484f58",
connectionColor: "#8b949e",
connectionHoverColor: "#f0f6fc",
connectionLabelColor: "#c9d1d9",
startNodeColor: "#22c55e",
endNodeColor: "#ef4444",
selectedNodeColor: "#3b82f6",
shadowColor: "rgba(0, 0, 0, 0.5)",
errorColor: "#f85149",
warningColor: "#ff8700",
infoColor: "#58a6ff",
successColor: "#3fb950",
gridColor: "#21262d",
miniMapBackground: "#161b22",
controlsBackground: "#21262d",
tooltipBackground: "#484f58",
tooltipTextColor: "#f0f6fc",
};
/**
* High Contrast Theme - Optimized for accessibility
*/
const highContrastTheme = {
name: "highContrast",
background: "#000000",
surfaceColor: "#1a1a1a",
overlayColor: "rgba(26, 26, 26, 0.95)",
nodeColors: {
pass: "#000080",
task: "#008000",
choice: "#ff8c00",
wait: "#8b008b",
succeed: "#228b22",
fail: "#dc143c",
para