working on the visualiser
This commit is contained in:
parent
5fb218908d
commit
40e931c842
@ -5,7 +5,7 @@
|
||||
}
|
||||
|
||||
.visualiser-flow {
|
||||
width: 320px;
|
||||
width: min(100%, 760px);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba($blue, 0.12),
|
||||
@ -21,6 +21,20 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.visualiser-level {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.visualiser-level-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.visualiser-node {
|
||||
width: 220px;
|
||||
padding: 12px 14px;
|
||||
@ -73,6 +87,21 @@
|
||||
background: rgba($blue, 0.2);
|
||||
}
|
||||
|
||||
.visualiser-connector-branch {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.visualiser-connector-branch-line {
|
||||
stroke: rgba($blue, 0.55);
|
||||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.visualiser-connector-branch-arrow {
|
||||
fill: rgba($blue, 0.55);
|
||||
}
|
||||
|
||||
@include color-mode(dark) {
|
||||
.visualiser-flow {
|
||||
background: linear-gradient(
|
||||
@ -106,4 +135,12 @@
|
||||
.visualiser-connector-tail {
|
||||
background: rgba($blue, 0.3);
|
||||
}
|
||||
|
||||
.visualiser-connector-branch-line {
|
||||
stroke: rgba($blue, 0.8);
|
||||
}
|
||||
|
||||
.visualiser-connector-branch-arrow {
|
||||
fill: rgba($blue, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService";
|
||||
import ValidationErrorIcon from "../../../../components/validationErrorIcon";
|
||||
|
||||
@ -7,12 +7,37 @@ interface VisualiserTabProps {
|
||||
taskValidation: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface ArrowPositions {
|
||||
levelIndex: number;
|
||||
positions: number[];
|
||||
}
|
||||
|
||||
const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
||||
data,
|
||||
taskValidation,
|
||||
}) => {
|
||||
const tasks = data.tasks;
|
||||
const orderedTasks: CreateWorkflowTemplateVersion["tasks"] = [];
|
||||
const levels: CreateWorkflowTemplateVersion["tasks"][] = [];
|
||||
const levelRowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [arrowPositions, setArrowPositions] = useState<ArrowPositions[]>([]);
|
||||
const taskColumnMap = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Calculate column range directly from taskColumnMap in render
|
||||
const allColumns = Array.from(taskColumnMap.current.values());
|
||||
const columnRange = {
|
||||
min: allColumns.length > 0 ? Math.min(...allColumns) : 0,
|
||||
max: allColumns.length > 0 ? Math.max(...allColumns) : 0,
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
if (allColumns.length > 0 && process.env.NODE_ENV === "development") {
|
||||
const columnMap: Record<string, number> = {};
|
||||
tasks.forEach((task) => {
|
||||
columnMap[(task.config.name as string) || task.type] =
|
||||
taskColumnMap.current.get(task.config.guid as string) ?? 0;
|
||||
});
|
||||
console.log("Column assignments:", columnMap, "Range:", columnRange);
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
const byGuid = new Map<string, CreateWorkflowTemplateVersion["tasks"][0]>();
|
||||
@ -20,73 +45,323 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
||||
byGuid.set(task.config.guid as string, task);
|
||||
});
|
||||
|
||||
const startTask = tasks.find(
|
||||
(task) =>
|
||||
!task.config.predecessors ||
|
||||
(task.config.predecessors as string[]).length === 0,
|
||||
);
|
||||
const levelByGuid = new Map<string, number>();
|
||||
const columnByGuid = new Map<string, number>();
|
||||
|
||||
if (startTask) {
|
||||
const visited = new Set<string>();
|
||||
let current: CreateWorkflowTemplateVersion["tasks"][0] | undefined =
|
||||
startTask;
|
||||
|
||||
while (current && !visited.has(current.config.guid as string)) {
|
||||
orderedTasks.push(current);
|
||||
visited.add(current.config.guid as string);
|
||||
|
||||
const currentGuid = current.config.guid as string;
|
||||
const nextTask = tasks.find((task) => {
|
||||
const predecessors = task.config.predecessors as string[] | undefined;
|
||||
return predecessors?.length === 1 && predecessors[0] === currentGuid;
|
||||
});
|
||||
|
||||
current = nextTask;
|
||||
const getLevel = (task: CreateWorkflowTemplateVersion["tasks"][0]) => {
|
||||
const guid = task.config.guid as string;
|
||||
const cached = levelByGuid.get(guid);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const predecessors = task.config.predecessors as string[] | undefined;
|
||||
if (!predecessors || predecessors.length === 0) {
|
||||
levelByGuid.set(guid, 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const predecessorLevels = predecessors.map((pred) => {
|
||||
const predTask = byGuid.get(pred);
|
||||
if (!predTask) {
|
||||
return 1;
|
||||
}
|
||||
return getLevel(predTask);
|
||||
});
|
||||
|
||||
const nextLevel = Math.max(...predecessorLevels, 0) + 1;
|
||||
levelByGuid.set(guid, nextLevel);
|
||||
return nextLevel;
|
||||
};
|
||||
|
||||
// First pass: calculate levels for all tasks
|
||||
tasks.forEach((task) => {
|
||||
if (!orderedTasks.includes(task)) {
|
||||
orderedTasks.push(task);
|
||||
}
|
||||
getLevel(task);
|
||||
});
|
||||
|
||||
// Second pass: assign columns based on predecessor-child relationships
|
||||
const getColumn = (
|
||||
task: CreateWorkflowTemplateVersion["tasks"][0],
|
||||
): number => {
|
||||
const guid = task.config.guid as string;
|
||||
const cached = columnByGuid.get(guid);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const predecessors = task.config.predecessors as string[] | undefined;
|
||||
if (!predecessors || predecessors.length === 0) {
|
||||
columnByGuid.set(guid, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get predecessor column and find which child index this is
|
||||
const predTask = byGuid.get(predecessors[0]);
|
||||
if (!predTask) {
|
||||
columnByGuid.set(guid, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const predColumn = getColumn(predTask);
|
||||
|
||||
// Find all siblings (tasks with same predecessor)
|
||||
const siblings = tasks.filter((t) => {
|
||||
const preds = t.config.predecessors as string[] | undefined;
|
||||
return preds && preds[0] === predecessors[0];
|
||||
});
|
||||
|
||||
// Sort siblings by name for consistent ordering
|
||||
siblings.sort((a, b) => {
|
||||
const nameA = (a.config.name as string) || a.type || "";
|
||||
const nameB = (b.config.name as string) || b.type || "";
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
const siblingIndex = siblings.findIndex(
|
||||
(s) => (s.config.guid as string) === guid,
|
||||
);
|
||||
|
||||
// Assign columns: spread siblings around parent column
|
||||
const startCol = Math.max(
|
||||
0,
|
||||
predColumn - Math.floor(siblings.length / 2),
|
||||
);
|
||||
const column = startCol + siblingIndex;
|
||||
|
||||
columnByGuid.set(guid, column);
|
||||
return column;
|
||||
};
|
||||
|
||||
// Calculate columns for all tasks
|
||||
tasks.forEach((task) => {
|
||||
const col = getColumn(task);
|
||||
taskColumnMap.current.set(task.config.guid as string, col);
|
||||
});
|
||||
|
||||
const levelsMap = new Map<number, CreateWorkflowTemplateVersion["tasks"]>();
|
||||
tasks.forEach((task) => {
|
||||
const level = getLevel(task);
|
||||
const list = levelsMap.get(level) ?? [];
|
||||
list.push(task);
|
||||
levelsMap.set(level, list);
|
||||
});
|
||||
|
||||
// Sort tasks within each level by column
|
||||
[...levelsMap.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.forEach((entry) => {
|
||||
entry[1].sort((a, b) => {
|
||||
const colA = taskColumnMap.current.get(a.config.guid as string) ?? 0;
|
||||
const colB = taskColumnMap.current.get(b.config.guid as string) ?? 0;
|
||||
return colA - colB;
|
||||
});
|
||||
levels.push(entry[1]);
|
||||
});
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
{ key: "start", label: "Start", isTask: false },
|
||||
...orderedTasks.map((task) => ({
|
||||
key: task.config.guid as string,
|
||||
guid: task.config.guid as string,
|
||||
label: (task.config.name as string) || task.type,
|
||||
isTask: true,
|
||||
})),
|
||||
{ key: "end", label: "End", isTask: false },
|
||||
];
|
||||
useEffect(() => {
|
||||
// Measure actual positions of task nodes and calculate arrow positions
|
||||
const positions: ArrowPositions[] = [];
|
||||
|
||||
levelRowRefs.current.forEach((levelRow, levelIndex) => {
|
||||
if (!levelRow) return;
|
||||
|
||||
const levelRect = levelRow.getBoundingClientRect();
|
||||
const nodes = levelRow.querySelectorAll(".visualiser-node");
|
||||
const nodePositions: number[] = [];
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
// Calculate center of node relative to level row
|
||||
const relativeCenter =
|
||||
(nodeRect.left - levelRect.left + nodeRect.width / 2) /
|
||||
levelRect.width;
|
||||
// Convert to SVG coordinates (0-100)
|
||||
nodePositions.push(relativeCenter * 100);
|
||||
});
|
||||
|
||||
if (nodePositions.length > 0) {
|
||||
positions.push({ levelIndex, positions: nodePositions });
|
||||
}
|
||||
});
|
||||
|
||||
setArrowPositions(positions);
|
||||
}, [tasks]);
|
||||
|
||||
const getArrowPositionsForLevel = (levelIndex: number): number[] => {
|
||||
const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex);
|
||||
return found?.positions ?? [];
|
||||
};
|
||||
|
||||
const renderConnector = (count: number, positions?: number[]) => {
|
||||
if (count <= 1) {
|
||||
return (
|
||||
<div aria-hidden="true" className="visualiser-connector">
|
||||
<div className="visualiser-connector-line" />
|
||||
<div className="visualiser-connector-arrow" />
|
||||
<div className="visualiser-connector-tail" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use measured positions if available and valid, otherwise calculate evenly distributed
|
||||
const displayPositions =
|
||||
positions && positions.length > 0
|
||||
? positions
|
||||
: Array.from(
|
||||
{ length: count },
|
||||
(_, index) => ((index + 1) / (count + 1)) * 100,
|
||||
);
|
||||
|
||||
// Guard against empty or invalid positions
|
||||
if (!displayPositions || displayPositions.length === 0) {
|
||||
return (
|
||||
<div aria-hidden="true" className="visualiser-connector">
|
||||
<div className="visualiser-connector-line" />
|
||||
<div className="visualiser-connector-arrow" />
|
||||
<div className="visualiser-connector-tail" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const minX = Math.min(...displayPositions);
|
||||
const maxX = Math.max(...displayPositions);
|
||||
|
||||
// Final safety check for Infinity
|
||||
if (!isFinite(minX) || !isFinite(maxX)) {
|
||||
return (
|
||||
<div aria-hidden="true" className="visualiser-connector">
|
||||
<div className="visualiser-connector-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="visualiser-connector-branch"
|
||||
viewBox="0 0 100 40"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
className="visualiser-connector-branch-line"
|
||||
x1="50"
|
||||
y1="0"
|
||||
x2="50"
|
||||
y2="14"
|
||||
/>
|
||||
<line
|
||||
className="visualiser-connector-branch-line"
|
||||
x1={minX}
|
||||
y1="14"
|
||||
x2={maxX}
|
||||
y2="14"
|
||||
/>
|
||||
{displayPositions.map((x) => (
|
||||
<g key={x}>
|
||||
<line
|
||||
className="visualiser-connector-branch-line"
|
||||
x1={x}
|
||||
y1="14"
|
||||
x2={x}
|
||||
y2="28"
|
||||
/>
|
||||
<polygon
|
||||
className="visualiser-connector-branch-arrow"
|
||||
points={`${x - 1.5},28 ${x + 1.5},28 ${x},34`}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="visualiser-root">
|
||||
<div className="visualiser-flow">
|
||||
{nodes.map((node, index) => (
|
||||
<React.Fragment key={node.key}>
|
||||
<div className="visualiser-node">
|
||||
<div className="visualiser-node-content">
|
||||
{node.isTask && (
|
||||
<ValidationErrorIcon
|
||||
visible={taskValidation[node.guid as string] === false}
|
||||
/>
|
||||
)}
|
||||
<span>{node.label}</span>
|
||||
<div className="visualiser-node">
|
||||
<div className="visualiser-node-content">
|
||||
<span>Start</span>
|
||||
</div>
|
||||
</div>
|
||||
{levels.length > 0 &&
|
||||
renderConnector(levels[0].length, getArrowPositionsForLevel(0))}
|
||||
{levels.map((level, index) => (
|
||||
<React.Fragment key={`level-${index}`}>
|
||||
<div className="visualiser-level">
|
||||
<div
|
||||
className="visualiser-level-row"
|
||||
ref={(el) => {
|
||||
levelRowRefs.current[index] = el;
|
||||
}}
|
||||
style={
|
||||
{
|
||||
gridTemplateColumns:
|
||||
columnRange.max > columnRange.min
|
||||
? `repeat(${
|
||||
columnRange.max - columnRange.min + 1
|
||||
}, minmax(220px, 1fr))`
|
||||
: "220px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{level.map((task) => {
|
||||
const taskColumn =
|
||||
taskColumnMap.current.get(task.config.guid as string) ?? 0;
|
||||
const isRootTask = !(
|
||||
task.config.predecessors as string[] | undefined
|
||||
)?.length;
|
||||
const numColumns = columnRange.max - columnRange.min + 1;
|
||||
|
||||
let styleObj: React.CSSProperties = {};
|
||||
|
||||
if (isRootTask && numColumns > 1) {
|
||||
// Root tasks span all columns and center their content
|
||||
styleObj = {
|
||||
gridColumn: `1 / -1`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
};
|
||||
} else {
|
||||
// Child tasks positioned in their specific column
|
||||
const gridColumn = taskColumn - columnRange.min + 1;
|
||||
styleObj = { gridColumn };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.config.guid as string}
|
||||
className="visualiser-node"
|
||||
data-column={taskColumn}
|
||||
style={styleObj}
|
||||
>
|
||||
<div className="visualiser-node-content">
|
||||
<ValidationErrorIcon
|
||||
visible={
|
||||
taskValidation[task.config.guid as string] === false
|
||||
}
|
||||
/>
|
||||
<span>{(task.config.name as string) || task.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{index < nodes.length - 1 && (
|
||||
<div aria-hidden="true" className="visualiser-connector">
|
||||
<div className="visualiser-connector-line" />
|
||||
<div className="visualiser-connector-arrow" />
|
||||
<div className="visualiser-connector-tail" />
|
||||
</div>
|
||||
)}
|
||||
{index < levels.length - 1
|
||||
? renderConnector(
|
||||
levels[index + 1].length,
|
||||
getArrowPositionsForLevel(index + 1),
|
||||
)
|
||||
: renderConnector(1)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{levels.length === 0 && renderConnector(1)}
|
||||
<div className="visualiser-node">
|
||||
<div className="visualiser-node-content">
|
||||
<span>End</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user