From 53e1673ec3f4647d9cfccf8bc3f3a363f002c97f Mon Sep 17 00:00:00 2001 From: aaronstrachardt Date: Sun, 19 Apr 2026 16:32:29 +0200 Subject: [PATCH 01/28] feat: add /generate-alternatives route --- .../generate_alternatives/GenerateAlternatives.tsx | 10 ++++++++++ src/index.tsx | 4 ++++ 2 files changed, 14 insertions(+) create mode 100644 src/components/generate_alternatives/GenerateAlternatives.tsx diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx new file mode 100644 index 00000000..50c11ced --- /dev/null +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; + + +export function GenerateAlternatives() { + return ( +
+ Generate Alternatives Placeholder +
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index bd846a12..7637456b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,7 @@ import { WorkflowConstraints } from './components/explore/WorkflowConstraints'; import { GenerationConfig } from './components/explore/GenerationConfig'; import { GenerationResults } from './components/explore/GenerationResults'; import { VisualizeBenchmark } from './components/benchmark/VisualizeBenchmarks'; +import { GenerateAlternatives} from "./components/generate_alternatives/GenerateAlternatives"; const router = createBrowserRouter([ { @@ -31,6 +32,9 @@ const router = createBrowserRouter([ { path: "results", element: } ] }, + { + path: "generate-alternatives", element: + }, { path: "benchmark", children: [ From 7aa0c07a921a49efb0d448c69fd37572847480cf Mon Sep 17 00:00:00 2001 From: aaronstrachardt Date: Sun, 19 Apr 2026 17:22:15 +0200 Subject: [PATCH 02/28] feat: add custom edge and custom node design for reactflow --- .../generate_alternatives/flow/CustomEdge.tsx | 71 ++++++++ .../flow/WorkflowNode.tsx | 95 +++++++++++ .../generate_alternatives/ui/Icons.tsx | 155 ++++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 src/components/generate_alternatives/flow/CustomEdge.tsx create mode 100644 src/components/generate_alternatives/flow/WorkflowNode.tsx create mode 100644 src/components/generate_alternatives/ui/Icons.tsx diff --git a/src/components/generate_alternatives/flow/CustomEdge.tsx b/src/components/generate_alternatives/flow/CustomEdge.tsx new file mode 100644 index 00000000..6a218441 --- /dev/null +++ b/src/components/generate_alternatives/flow/CustomEdge.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { BaseEdge, EdgeLabelRenderer, getBezierPath, EdgeProps } from "reactflow"; +import { Icons } from "../ui/Icons"; + + +const CustomEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEnd, + data, + }: EdgeProps) => { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + const status = data?.status; + const isInteractable = data?.isInteractable; + + let LabelIcon = null; + let labelClass = ""; + + if (status === "chain") { + LabelIcon = Icons.Link; + labelClass = "bg-teal-100 border-teal-300 text-teal-700"; + } else if (status === "break") { + LabelIcon = Icons.Scissors; + labelClass = "bg-rose-100 border-rose-300 text-rose-700"; + } + + const hitStrokeWidth = isInteractable ? 20 : 0; + const cursorClass = isInteractable ? "cursor-pointer" : "cursor-default"; + + return ( + <> + + + {LabelIcon && ( + +
+ +
+
+ )} + + ); +}; + +export default CustomEdge; \ No newline at end of file diff --git a/src/components/generate_alternatives/flow/WorkflowNode.tsx b/src/components/generate_alternatives/flow/WorkflowNode.tsx new file mode 100644 index 00000000..1e20f0c1 --- /dev/null +++ b/src/components/generate_alternatives/flow/WorkflowNode.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Icons } from "../ui/Icons"; + +const WorkflowNode = ({ data }: NodeProps) => { + const { label, type, status } = data; + const isData = type !== "tool"; + + let containerStyle = "bg-white border-slate-200"; + let icon = ; + let labelStyle = "text-slate-600"; + let statusIndicator = null; + + if (isData) { + containerStyle = + "bg-slate-50 border-slate-200 border-l-4 border-l-slate-300"; + labelStyle = "text-slate-500 font-medium italic"; + icon = ; + } else { + if (status === "Fixed") { + containerStyle = + "bg-white border-slate-300 shadow-sm border-l-4 border-l-slate-600"; + icon = ; + labelStyle = "text-slate-800 font-bold"; + statusIndicator = ( + + KEEP + + ); + } else if (status === "Vary") { + containerStyle = + "bg-[#fff7ed] border-[#f06455] shadow-md border-l-4 border-l-[#f06455]"; + icon = ; + labelStyle = "text-slate-900 font-bold"; + statusIndicator = ( + + VARY + + ); + } else if (status === "Ban") { + containerStyle = + "bg-slate-50 border-slate-200 opacity-60 border-l-4 border-l-rose-400"; + icon = ; + labelStyle = "text-slate-400 line-through decoration-slate-400"; + statusIndicator = ( + + BAN + + ); + } + } + + const stripedBg = + status === "Ban" + ? { + backgroundImage: + "linear-gradient(135deg, #f1f5f9 25%, #ffffff 25%, #ffffff 50%, #f1f5f9 50%, #f1f5f9 75%, #ffffff 75%, #ffffff 100%)", + backgroundSize: "20px 20px", + } + : {}; + + return ( +
+ +
{icon}
+
+ + {type} + +
+ + {label} + +
+
+ {statusIndicator && ( +
{statusIndicator}
+ )} + +
+ ); +}; + +export default WorkflowNode; \ No newline at end of file diff --git a/src/components/generate_alternatives/ui/Icons.tsx b/src/components/generate_alternatives/ui/Icons.tsx new file mode 100644 index 00000000..4a87b1f4 --- /dev/null +++ b/src/components/generate_alternatives/ui/Icons.tsx @@ -0,0 +1,155 @@ +import React, { SVGProps } from "react"; +type IconProps = SVGProps; + +export const Icons = { + Play: (props: IconProps) => ( + + + + ), + Gear: (props: IconProps) => ( + + + + + ), + Sparkles: (props: IconProps) => ( + + + + ), + Ban: (props: IconProps) => ( + + + + ), + Data: (props: IconProps) => ( + + + + ), + Code: (props: IconProps) => ( + + + + ), + Link: (props: IconProps) => ( + + + + ), + Scissors: (props: IconProps) => ( + + + + ), + Close: (props: IconProps) => ( + + + + ), + Pin: (props: IconProps) => ( + + + + ), + List: (props: IconProps) => ( + + + + ), +}; \ No newline at end of file From 8fe7d8cebb47a7f4e5fb31f1519868fa715140a5 Mon Sep 17 00:00:00 2001 From: aaronstrachardt Date: Sun, 19 Apr 2026 17:22:52 +0200 Subject: [PATCH 03/28] feat: bring the basic graph together --- package-lock.json | 192 ++++++++++++++++++ package.json | 3 + .../GenerateAlternatives.tsx | 85 +++++++- .../utils/layoutHelper.tsx | 32 +++ src/index.tsx | 2 +- 5 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 src/components/generate_alternatives/utils/layoutHelper.tsx diff --git a/package-lock.json b/package-lock.json index 3263cac3..164efa1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/react-dom": "^18.0.10", "antd": "^5.23.1", "d3": "^7.8.5", + "dagre": "^0.8.5", "daisyui": "^2.49.0", "dotenv": "^16.0.3", "http-proxy-middleware": "^3.0.3", @@ -30,10 +31,12 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.8.1", "react-scripts": "5.0.1", + "reactflow": "^11.11.4", "typescript": "^4.9.4", "web-vitals": "^2.1.4" }, "devDependencies": { + "@types/dagre": "^0.7.54", "tailwindcss": "^3.2.4" } }, @@ -3326,6 +3329,108 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/@remix-run/router": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.2.tgz", @@ -4162,6 +4267,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.4.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.10.tgz", @@ -5944,6 +6056,12 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -7065,6 +7183,16 @@ "node": ">=12" } }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/daisyui": { "version": "2.51.5", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-2.51.5.tgz", @@ -9359,6 +9487,15 @@ "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -15891,6 +16028,24 @@ "node": ">=10" } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -17905,6 +18060,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -18927,6 +19091,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 60248ae2..f2a15975 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/react-dom": "^18.0.10", "antd": "^5.23.1", "d3": "^7.8.5", + "dagre": "^0.8.5", "daisyui": "^2.49.0", "dotenv": "^16.0.3", "http-proxy-middleware": "^3.0.3", @@ -25,6 +26,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.8.1", "react-scripts": "5.0.1", + "reactflow": "^11.11.4", "typescript": "^4.9.4", "web-vitals": "^2.1.4" }, @@ -53,6 +55,7 @@ ] }, "devDependencies": { + "@types/dagre": "^0.7.54", "tailwindcss": "^3.2.4" } } diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx index 50c11ced..17e4e6bd 100644 --- a/src/components/generate_alternatives/GenerateAlternatives.tsx +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -1,10 +1,85 @@ -import * as React from 'react'; +import React, { useState, useMemo, useEffect } from "react"; +import ReactFlow, { + Background, + useNodesState, + useEdgesState, + MarkerType, +} from "reactflow"; +import "reactflow/dist/style.css"; +import WorkflowNode from "./flow/WorkflowNode"; +import CustomEdge from "./flow/CustomEdge"; +import { getLayoutedElements } from "./utils/layoutHelper"; + +const initialData = [ + { id: "fasta", label: "Protein sequence", type: "input", inputs: [] }, + { id: "mzml", label: "Mass spectrum", type: "input", inputs: [] }, + { id: "comet", label: "Comet", type: "tool", inputs: ["fasta", "mzml"] }, + { id: "pepprop", label: "PeptideProphet", type: "tool", inputs: ["comet", "fasta", "mzml"] }, + { id: "protprop", label: "ProteinProphet", type: "tool", inputs: ["pepprop", "fasta"] }, + { id: "out1", label: "Over-representation data", type: "output", inputs: ["protprop"] }, +]; + +export default function GenerateAlternatives() { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // Wichtig: Die Registrierung der Custom Types + const nodeTypes = useMemo(() => ({ custom: WorkflowNode }), []); + const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []); + + // 3. Graph initialisieren (aus initialData) + useEffect(() => { + const rfNodes = initialData.map((item) => ({ + id: item.id, + type: "custom", + data: { + label: item.label, + type: item.type, + status: "Fixed", // Standardstatus + }, + position: { x: 0, y: 0 }, + })); + + const rfEdges = initialData.flatMap((item) => + (item.inputs || []).map((parentId) => { + const sourceNode = initialData.find((d) => d.id === parentId); + const targetNode = initialData.find((d) => d.id === item.id); + return { + id: `e-${parentId}-${item.id}`, + source: parentId, + target: item.id, + type: "custom", + data: { + status: null, + isInteractable: sourceNode?.type === "tool" && targetNode?.type === "tool", + }, + markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" }, + style: { stroke: "#94a3b8", strokeWidth: 2 }, + }; + }) + ); + + // Layout berechnen und setzen + const layouted = getLayoutedElements(rfNodes, rfEdges); + setNodes(layouted.nodes); + setEdges(layouted.edges); + }, [setNodes, setEdges]); -export function GenerateAlternatives() { return ( -
- Generate Alternatives Placeholder + // Ein Full-Screen Container für den Test +
+ + +
); -} +} \ No newline at end of file diff --git a/src/components/generate_alternatives/utils/layoutHelper.tsx b/src/components/generate_alternatives/utils/layoutHelper.tsx new file mode 100644 index 00000000..f6c33a6e --- /dev/null +++ b/src/components/generate_alternatives/utils/layoutHelper.tsx @@ -0,0 +1,32 @@ +import dagre from "dagre"; +import { Node, Edge } from "reactflow"; + +export const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + // TB = Top to Bottom + dagreGraph.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 }); + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { width: 190, height: 60 }); + }); + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + + dagre.layout(dagreGraph); + + const layoutedNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - 190 / 2, + y: nodeWithPosition.y - 60 / 2, + }, + }; + }); + + return { nodes: layoutedNodes, edges }; +}; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 7637456b..d83d9aba 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,7 +11,7 @@ import { WorkflowConstraints } from './components/explore/WorkflowConstraints'; import { GenerationConfig } from './components/explore/GenerationConfig'; import { GenerationResults } from './components/explore/GenerationResults'; import { VisualizeBenchmark } from './components/benchmark/VisualizeBenchmarks'; -import { GenerateAlternatives} from "./components/generate_alternatives/GenerateAlternatives"; +import GenerateAlternatives from "./components/generate_alternatives/GenerateAlternatives"; const router = createBrowserRouter([ { From cbda196d5158bd5fe346749f8f9266c13c95b2cf Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:40:45 +0200 Subject: [PATCH 04/28] feat: add ParsedWorkflow and NodeStatus types --- src/components/generate_alternatives/types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/components/generate_alternatives/types.ts diff --git a/src/components/generate_alternatives/types.ts b/src/components/generate_alternatives/types.ts new file mode 100644 index 00000000..4d2d7d30 --- /dev/null +++ b/src/components/generate_alternatives/types.ts @@ -0,0 +1,14 @@ +export type NodeStatus = "Keep" | "Vary" | "Ban"; + +export interface WorkflowNodeData { + label: string; + type: "tool" | "input" | "output"; + status: NodeStatus; +} + +export interface ParsedWorkflow { + nodes: { id: string; label: string; type: "tool" | "input" | "output" }[]; + edges: { source: string; target: string }[]; + inputs: { id: string; label: string }[]; + outputs: { id: string; label: string }[]; +} From 0a88e04afa5e3b600647cc8d4fcc0de6d63549ca Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:40:53 +0200 Subject: [PATCH 05/28] feat: implement APE config builder from graph constraints Translates node status (Keep/Ban/Vary) and edge status (chain/break) into APE constraint objects and assembles the full synthesis config JSON including I/O EDAM tuples, tool taxonomy paths, and generation parameters. --- .../utils/apeConfigBuilder.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/components/generate_alternatives/utils/apeConfigBuilder.tsx diff --git a/src/components/generate_alternatives/utils/apeConfigBuilder.tsx b/src/components/generate_alternatives/utils/apeConfigBuilder.tsx new file mode 100644 index 00000000..306b75c0 --- /dev/null +++ b/src/components/generate_alternatives/utils/apeConfigBuilder.tsx @@ -0,0 +1,83 @@ +import { NodeStatus, ParsedWorkflow } from "../types"; + +export const generateApeConfig = ( + stepStatus: Record, + edgeStatus: Record, + edgeEndpoints: Record, + configParams: { timeout: number; minLength: number; maxLength: number; solutions: number }, + parsedWorkflow: ParsedWorkflow +) => { + const config: any = { + ontology_path: "https://raw.githubusercontent.com/Workflomics/tools-and-domains/main/domains/edam.owl", + ontologyPrefixIRI: "http://edamontology.org/", + toolsTaxonomyRoot: "operation_0004", + dataDimensionsTaxonomyRoots: ["data_0006", "format_1915"], + tool_annotations_path: "https://raw.githubusercontent.com/Workflomics/tools-and-domains/main/domains/proteomics/tools.json", + constraints_path: "https://raw.githubusercontent.com/Workflomics/tools-and-domains/main/domains/proteomics/constraints.json", + strict_tool_annotations: "true", + timeout_sec: parseInt(configParams.timeout as any), + solution_length: { + min: parseInt(configParams.minLength as any), + max: parseInt(configParams.maxLength as any), + }, + solutions: parseInt(configParams.solutions as any), + number_of_execution_scripts: parseInt(configParams.solutions as any), + number_of_generated_graphs: parseInt(configParams.solutions as any), + debug_mode: "false", + use_workflow_input: "all", + use_all_generated_data: "one", + tool_seq_repeat: "false", + inputs: [], + outputs: [], + constraints: [], + }; + + // 1. I/O aus dem geparsten Workflow — format-URI wird der format_1915-Dimension zugeordnet + parsedWorkflow.inputs.forEach((input) => { + config.inputs.push({ + "http://edamontology.org/data_0006": ["http://edamontology.org/data_0006"], + "http://edamontology.org/format_1915": [input.id], + }); + }); + parsedWorkflow.outputs.forEach((output) => { + config.outputs.push({ + "http://edamontology.org/data_0006": ["http://edamontology.org/data_0006"], + "http://edamontology.org/format_1915": [output.id], + }); + }); + + // 2. Knoten-Constraints (Kap. 4.3): Keep → use_m, Ban → not_use_m, Vary → kein Constraint + const nodeLabelById = Object.fromEntries(parsedWorkflow.nodes.map((n) => [n.id, n.label])); + Object.entries(stepStatus).forEach(([id, status]) => { + const label = nodeLabelById[id]; + if (!label) return; + if (status === "Keep") { + config.constraints.push({ constraintid: "use_m", parameters: [{ operation_0004: [label] }] }); + } else if (status === "Ban") { + config.constraints.push({ constraintid: "not_use_m", parameters: [{ operation_0004: [label] }] }); + } + // Vary: APE hat freie Wahl, kein Constraint + }); + + // 3. Kanten-Constraints (Chain, Break) + Object.entries(edgeStatus).forEach(([edgeId, status]) => { + const ep = edgeEndpoints[edgeId]; + if (!ep) return; + const sourceLabel = nodeLabelById[ep.source]; + const targetLabel = nodeLabelById[ep.target]; + if (!sourceLabel || !targetLabel) return; + if (status === "chain") { + config.constraints.push({ + constraintid: "connected_op", + parameters: [{ operation_0004: [sourceLabel] }, { operation_0004: [targetLabel] }], + }); + } else if (status === "break") { + config.constraints.push({ + constraintid: "not_connected_op", + parameters: [{ operation_0004: [sourceLabel] }, { operation_0004: [targetLabel] }], + }); + } + }); + + return JSON.stringify(config, null, 2); +}; From 501fd4e00cce5eacf2cf6376272f0f90822c297a Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:40:59 +0200 Subject: [PATCH 06/28] fix: use typed NodeProps and rename Fixed status to Keep in WorkflowNode --- .../flow/WorkflowNode.tsx | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/components/generate_alternatives/flow/WorkflowNode.tsx b/src/components/generate_alternatives/flow/WorkflowNode.tsx index 1e20f0c1..e3988d75 100644 --- a/src/components/generate_alternatives/flow/WorkflowNode.tsx +++ b/src/components/generate_alternatives/flow/WorkflowNode.tsx @@ -1,8 +1,9 @@ import React from "react"; import { Handle, Position, NodeProps } from "reactflow"; import { Icons } from "../ui/Icons"; +import { WorkflowNodeData } from "../types"; -const WorkflowNode = ({ data }: NodeProps) => { +const WorkflowNode = ({ data }: NodeProps) => { const { label, type, status } = data; const isData = type !== "tool"; @@ -17,15 +18,15 @@ const WorkflowNode = ({ data }: NodeProps) => { labelStyle = "text-slate-500 font-medium italic"; icon = ; } else { - if (status === "Fixed") { + if (status === "Keep") { containerStyle = "bg-white border-slate-300 shadow-sm border-l-4 border-l-slate-600"; icon = ; labelStyle = "text-slate-800 font-bold"; statusIndicator = ( - KEEP - + KEEP + ); } else if (status === "Vary") { containerStyle = @@ -34,8 +35,8 @@ const WorkflowNode = ({ data }: NodeProps) => { labelStyle = "text-slate-900 font-bold"; statusIndicator = ( - VARY - + VARY + ); } else if (status === "Ban") { containerStyle = @@ -44,8 +45,8 @@ const WorkflowNode = ({ data }: NodeProps) => { labelStyle = "text-slate-400 line-through decoration-slate-400"; statusIndicator = ( - BAN - + BAN + ); } } @@ -69,15 +70,15 @@ const WorkflowNode = ({ data }: NodeProps) => { position={Position.Top} className="!bg-slate-300 !w-2 !h-2 !-mt-1" /> -
{icon}
+
{icon}
- - {type} - + + {type} +
- - {label} - + + {label} +
{statusIndicator && ( @@ -92,4 +93,4 @@ const WorkflowNode = ({ data }: NodeProps) => { ); }; -export default WorkflowNode; \ No newline at end of file +export default WorkflowNode; From fc43ac980905b20e7f9dae22c0e7e046571953c4 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:41:05 +0200 Subject: [PATCH 07/28] feat: implement useWorkflowState hook with CWL upload and conservative initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handles CWL file upload via POST /alternatives/parse, builds the ReactFlow graph from the parsed DAG, and initialises all tool nodes to Keep status (§4.3 conservative strategy). Node clicks cycle through Keep/Vary/Ban; edge clicks toggle chain/break constraints. --- .../hooks/useWorkflowState.tsx | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 src/components/generate_alternatives/hooks/useWorkflowState.tsx diff --git a/src/components/generate_alternatives/hooks/useWorkflowState.tsx b/src/components/generate_alternatives/hooks/useWorkflowState.tsx new file mode 100644 index 00000000..b2742be4 --- /dev/null +++ b/src/components/generate_alternatives/hooks/useWorkflowState.tsx @@ -0,0 +1,192 @@ +import { useState, useEffect, useCallback } from "react"; +import { useNodesState, useEdgesState, MarkerType } from "reactflow"; +import { getLayoutedElements } from "../utils/layoutHelper"; +import { NodeStatus, ParsedWorkflow } from "../types"; + +const APE_PARSE_URL = "/ape/alternatives/parse"; + +export const useWorkflowState = () => { + const [parsedWorkflow, setParsedWorkflow] = useState(null); + const [workflowKey, setWorkflowKey] = useState(0); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [stepStatus, setStepStatus] = useState>({}); + const [edgeStatus, setEdgeStatus] = useState>({}); + const [edgeEndpoints, setEdgeEndpoints] = useState>({}); + const [configParams, setConfigParams] = useState({ + timeout: 120, + minLength: 3, + maxLength: 8, + solutions: 10, + }); + + // Kap. 4.3: Graph neu aufbauen und alle Tools auf Keep initialisieren, sobald ein Workflow geladen wird + useEffect(() => { + if (!parsedWorkflow) return; + const nodeTypeById = Object.fromEntries(parsedWorkflow.nodes.map((n) => [n.id, n.type ?? "tool"])); + + const rfNodes = parsedWorkflow.nodes.map((node) => ({ + id: node.id, + type: "custom", + data: { label: node.label, type: node.type ?? "tool", status: "Keep" as NodeStatus }, + position: { x: 0, y: 0 }, + })); + + const rfEdges = parsedWorkflow.edges.map((edge) => ({ + id: `e-${edge.source}-${edge.target}`, + source: edge.source, + target: edge.target, + type: "custom", + // Nur Tool→Tool-Kanten sind per Klick mit Constraints belegbar + data: { + status: null, + isInteractable: nodeTypeById[edge.source] === "tool" && nodeTypeById[edge.target] === "tool", + source: edge.source, + target: edge.target, + }, + markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" }, + style: { stroke: "#94a3b8", strokeWidth: 2 }, + })); + + const layouted = getLayoutedElements(rfNodes, rfEdges); + setNodes(layouted.nodes); + setEdges(layouted.edges); + + // Kap. 4.3: nur Tool-Nodes bekommen einen initialen Keep-Status + const initialStatus: Record = {}; + parsedWorkflow.nodes + .filter((n) => (n.type ?? "tool") === "tool") + .forEach(({ id }) => { initialStatus[id] = "Keep"; }); + setStepStatus(initialStatus); + setEdgeStatus({}); + + // source/target pro Edge-ID für robustes Label-Lookup ohne ID-String-Parsing + const endpoints: Record = {}; + parsedWorkflow.edges.forEach(({ source, target }) => { + endpoints[`e-${source}-${target}`] = { source, target }; + }); + setEdgeEndpoints(endpoints); + }, [parsedWorkflow, setNodes, setEdges]); + + // Visuelles Update bei Status-Änderungen + useEffect(() => { + setNodes((nds) => + nds.map((node) => { + if ((node.data.type ?? "tool") !== "tool") return node; + const currentStatus = stepStatus[node.id] || "Keep"; + if (node.data.status === currentStatus) return node; + return { ...node, data: { ...node.data, status: currentStatus } }; + }) + ); + + setEdges((eds) => + eds.map((edge) => { + const status = edgeStatus[edge.id]; + let stroke = "#94a3b8"; + let strokeDasharray = "0"; + let zIndex = 0; + + if (status === "chain") { + stroke = "#0d9488"; + zIndex = 10; + } else if (status === "break") { + stroke = "#e11d48"; + strokeDasharray = "5,5"; + zIndex = 10; + } + + return { + ...edge, + zIndex, + data: { ...edge.data, status }, + style: { ...edge.style, stroke, strokeWidth: status ? 3 : 2, strokeDasharray }, + markerEnd: { type: MarkerType.ArrowClosed, color: stroke }, + }; + }) + ); + }, [stepStatus, edgeStatus, setNodes, setEdges]); + + // Linksklick: Keep → Vary → Ban → Keep + const handleNodeClick = useCallback((event: React.MouseEvent, node: any) => { + event.preventDefault(); + if ((node.data.type ?? "tool") !== "tool") return; + setStepStatus((prev) => { + const current = prev[node.id] ?? "Keep"; + const next: NodeStatus = current === "Keep" ? "Vary" : current === "Vary" ? "Ban" : "Keep"; + return { ...prev, [node.id]: next }; + }); + }, []); + + // Rechtsklick: Keep → Ban → Vary → Keep + const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: any) => { + event.preventDefault(); + if ((node.data.type ?? "tool") !== "tool") return; + setStepStatus((prev) => { + const current = prev[node.id] ?? "Keep"; + const next: NodeStatus = current === "Keep" ? "Ban" : current === "Ban" ? "Vary" : "Keep"; + return { ...prev, [node.id]: next }; + }); + }, []); + + const handleEdgeClick = useCallback((event: React.MouseEvent, edge: any) => { + if (!edge.data?.isInteractable) return; + setEdgeStatus((prev) => { + const next = { ...prev }; + const current = prev[edge.id]; + if (!current) next[edge.id] = "chain"; + else if (current === "chain") next[edge.id] = "break"; + else delete next[edge.id]; + return next; + }); + }, []); + + const handleConfigChange = (key: string, value: string | number) => { + setConfigParams((prev) => ({ ...prev, [key]: value })); + }; + + const uploadCwlFile = useCallback(async (file: File) => { + setIsLoading(true); + setUploadError(null); + const formData = new FormData(); + formData.append("cwl_file", file); + try { + const response = await fetch(APE_PARSE_URL, { method: "POST", body: formData }); + if (!response.ok) { + const msg = await response.text(); + throw new Error(msg || `Server error ${response.status}`); + } + const data: ParsedWorkflow = await response.json(); + setParsedWorkflow(data); + setWorkflowKey((k) => k + 1); + setUploadedFileName(file.name); + } catch (err: any) { + setUploadError(err.message ?? "Upload failed"); + } finally { + setIsLoading(false); + } + }, []); + + return { + nodes, + edges, + onNodesChange, + onEdgesChange, + handleNodeClick, + handleNodeContextMenu, + handleEdgeClick, + stepStatus, + edgeStatus, + configParams, + handleConfigChange, + parsedWorkflow, + workflowKey, + edgeEndpoints, + uploadCwlFile, + uploadedFileName, + isLoading, + uploadError, + }; +}; From 84998bbd327b8be92b77b33e1ec56edb4893b2a1 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:41:11 +0200 Subject: [PATCH 08/28] feat: implement ConstraintSidebar with settings form and JSON preview modal Shows active Keep/Ban/Chain/Break constraints, APE synthesis parameters (min/max length, timeout, solutions), a generate button with loading state, and a modal to inspect the generated APE config JSON. --- .../sidebar/ConstraintSidebar.tsx | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx diff --git a/src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx b/src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx new file mode 100644 index 00000000..8ebd1501 --- /dev/null +++ b/src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx @@ -0,0 +1,212 @@ +import React, { useMemo, useState } from "react"; +import { Icons } from "../ui/Icons"; +import { NodeStatus, ParsedWorkflow } from "../types"; +import { generateApeConfig } from "../utils/apeConfigBuilder"; + +interface ConstraintSidebarProps { + stepStatus: Record; + edgeStatus: Record; + edgeEndpoints: Record; + nodeLabels: Record; + configParams: { + timeout: number; + minLength: number; + maxLength: number; + solutions: number; + }; + onConfigChange: (key: string, value: string) => void; + parsedWorkflow: ParsedWorkflow; + onGenerate: () => void; + isGenerating: boolean; + generationError: string; +} + +export default function ConstraintSidebar({ + stepStatus, + edgeStatus, + edgeEndpoints, + nodeLabels, + configParams, + onConfigChange, + parsedWorkflow, + onGenerate, + isGenerating, + generationError, +}: ConstraintSidebarProps) { + const [showJson, setShowJson] = useState(false); + const [copied, setCopied] = useState(false); + + const activeConstraints = useMemo(() => { + const list: any[] = []; + + Object.entries(stepStatus).forEach(([id, status]) => { + const label = nodeLabels[id]; + if (!label) return; + if (status === "Keep") { + list.push({ id: `node-${id}`, label: `Keep: ${label}`, icon: Icons.Gear, color: "text-slate-600 bg-slate-100 border-slate-200" }); + } else if (status === "Ban") { + list.push({ id: `node-${id}`, label: `Exclude: ${label}`, icon: Icons.Ban, color: "text-rose-600 bg-rose-50 border-rose-200" }); + } + }); + + Object.entries(edgeStatus).forEach(([edgeId, status]) => { + const ep = edgeEndpoints[edgeId]; + if (!ep) return; + const sourceLabel = nodeLabels[ep.source]; + const targetLabel = nodeLabels[ep.target]; + if (!sourceLabel || !targetLabel) return; + if (status === "chain") { + list.push({ id: edgeId, label: `Chain: ${sourceLabel} → ${targetLabel}`, icon: Icons.Link, color: "text-teal-700 bg-teal-50 border-teal-200" }); + } else if (status === "break") { + list.push({ id: edgeId, label: `Break: ${sourceLabel} ↛ ${targetLabel}`, icon: Icons.Scissors, color: "text-rose-600 bg-rose-50 border-rose-200" }); + } + }); + + return list; + }, [stepStatus, edgeStatus, edgeEndpoints, nodeLabels]); + + const jsonString = useMemo( + () => showJson + ? generateApeConfig(stepStatus, edgeStatus, edgeEndpoints, configParams, parsedWorkflow) + : "", + // eslint-disable-next-line react-hooks/exhaustive-deps + [showJson] + ); + + const handleCopy = () => { + const json = generateApeConfig(stepStatus, edgeStatus, edgeEndpoints, configParams, parsedWorkflow); + navigator.clipboard.writeText(json).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( + <> +
+ + {/* Active Constraints Panel */} +
+
+
+ +
+

+ Active Constraints +

+
+ + {activeConstraints.length === 0 ? ( +
+ +

No active constraints

+
+ ) : ( +
+ {activeConstraints.map((item) => ( +
+ + {item.label} +
+ ))} +
+ )} +
+ + {/* APE Settings Panel */} +
+

+ APE Settings +

+
+ {["minLength", "maxLength", "solutions", "timeout"].map((key) => ( +
+ + onConfigChange(key, e.target.value)} + className="w-full bg-slate-50 border border-slate-200 rounded p-2 text-xs focus:ring-1 focus:ring-[#f06455] outline-none" + /> +
+ ))} +
+ + {generationError && ( +

{generationError}

+ )} + +
+
+ + {/* JSON Modal */} + {showJson && ( +
setShowJson(false)} + > +
e.stopPropagation()} + > + {/* Header */} +
+
+ + APE Configuration JSON +
+
+ + +
+
+ + {/* JSON body */} +
+
+                                {jsonString}
+                            
+
+
+
+ )} + + ); +} From 8fcd85994c3399ba1127499a956fee12a90afcbb Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:41:17 +0200 Subject: [PATCH 09/28] feat: implement GenerateAlternatives page with upload zone, graph view, and synthesis Shows a drag-and-drop CWL upload zone before a file is loaded, then switches to a ReactFlow graph with a filename pill overlay. Builds the APE synthesis config from the current constraint state and navigates to /explore/results after successful synthesis. --- .../GenerateAlternatives.tsx | 244 +++++++++++++----- 1 file changed, 175 insertions(+), 69 deletions(-) diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx index 17e4e6bd..e6995f34 100644 --- a/src/components/generate_alternatives/GenerateAlternatives.tsx +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -1,85 +1,191 @@ -import React, { useState, useMemo, useEffect } from "react"; -import ReactFlow, { - Background, - useNodesState, - useEdgesState, - MarkerType, -} from "reactflow"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import ReactFlow, { Background, useReactFlow } from "reactflow"; import "reactflow/dist/style.css"; +import { useNavigate } from "react-router-dom"; +import { observer } from "mobx-react-lite"; import WorkflowNode from "./flow/WorkflowNode"; import CustomEdge from "./flow/CustomEdge"; -import { getLayoutedElements } from "./utils/layoutHelper"; +import { useWorkflowState } from "./hooks/useWorkflowState"; +import ConstraintSidebar from "./sidebar/ConstraintSidebar"; +import { Icons } from "./ui/Icons"; +import { useStore } from "../../store"; +import { generateApeConfig } from "./utils/apeConfigBuilder"; -const initialData = [ - { id: "fasta", label: "Protein sequence", type: "input", inputs: [] }, - { id: "mzml", label: "Mass spectrum", type: "input", inputs: [] }, - { id: "comet", label: "Comet", type: "tool", inputs: ["fasta", "mzml"] }, - { id: "pepprop", label: "PeptideProphet", type: "tool", inputs: ["comet", "fasta", "mzml"] }, - { id: "protprop", label: "ProteinProphet", type: "tool", inputs: ["pepprop", "fasta"] }, - { id: "out1", label: "Over-representation data", type: "output", inputs: ["protprop"] }, -]; +function FitViewOnChange({ trigger }: { trigger: number }) { + const { fitView } = useReactFlow(); + useEffect(() => { + const id = setTimeout(() => fitView({ duration: 300, padding: 0.15 }), 50); + return () => clearTimeout(id); + }, [trigger, fitView]); + return null; +} -export default function GenerateAlternatives() { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); +const GenerateAlternatives = observer(() => { + const { exploreDataStore } = useStore(); + const navigate = useNavigate(); + const { + nodes, + edges, + onNodesChange, + onEdgesChange, + handleNodeClick, + handleNodeContextMenu, + handleEdgeClick, + stepStatus, + edgeStatus, + edgeEndpoints, + configParams, + handleConfigChange, + parsedWorkflow, + workflowKey, + uploadCwlFile, + uploadedFileName, + isLoading, + uploadError, + } = useWorkflowState(); - // Wichtig: Die Registrierung der Custom Types const nodeTypes = useMemo(() => ({ custom: WorkflowNode }), []); const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []); + const fileInputRef = useRef(null); - // 3. Graph initialisieren (aus initialData) - useEffect(() => { - const rfNodes = initialData.map((item) => ({ - id: item.id, - type: "custom", - data: { - label: item.label, - type: item.type, - status: "Fixed", // Standardstatus - }, - position: { x: 0, y: 0 }, - })); + const nodeLabels = useMemo( + () => parsedWorkflow ? Object.fromEntries(parsedWorkflow.nodes.map((n) => [n.id, n.label])) : {}, + [parsedWorkflow] + ); - const rfEdges = initialData.flatMap((item) => - (item.inputs || []).map((parentId) => { - const sourceNode = initialData.find((d) => d.id === parentId); - const targetNode = initialData.find((d) => d.id === item.id); - return { - id: `e-${parentId}-${item.id}`, - source: parentId, - target: item.id, - type: "custom", - data: { - status: null, - isInteractable: sourceNode?.type === "tool" && targetNode?.type === "tool", - }, - markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" }, - style: { stroke: "#94a3b8", strokeWidth: 2 }, - }; - }) - ); + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) uploadCwlFile(file); + e.target.value = ""; + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (file) uploadCwlFile(file); + }; - // Layout berechnen und setzen - const layouted = getLayoutedElements(rfNodes, rfEdges); - setNodes(layouted.nodes); - setEdges(layouted.edges); - }, [setNodes, setEdges]); + const handleGenerate = useCallback(async () => { + if (!parsedWorkflow) return; + const config = JSON.parse( + generateApeConfig(stepStatus, edgeStatus, edgeEndpoints, configParams, parsedWorkflow) + ); + // §5.4: Tool-Sequenz des Eingabeworkflows als Referenz für Post-Filtering + const inputToolSequence = parsedWorkflow.nodes + .filter((n) => (n.type ?? "tool") === "tool") + .map((n) => n.label) + .join("->"); + try { + await exploreDataStore.runSynthesisWithRawConfig(config, inputToolSequence); + navigate("/explore/results"); + } catch { + // error shown via exploreDataStore.generationError + } + }, [parsedWorkflow, stepStatus, edgeStatus, edgeEndpoints, configParams, exploreDataStore, navigate]); return ( - // Ein Full-Screen Container für den Test -
- - - +
+ + +
+ {/* Graph area */} +
+ {parsedWorkflow ? ( + <> + {/* Filename pill + change-file button */} +
+ + + {uploadedFileName} + + +
+ +
+ + + + +
+ + ) : ( + /* Upload zone */ +
!isLoading && fileInputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={handleDrop} + > + {isLoading ? ( + <> + + + + +

Workflow wird geladen…

+ + ) : ( + <> +
+ +
+
+

CWL-Datei hochladen

+

Hier hineinziehen oder klicken zum Auswählen

+
+ {uploadError && ( +

+ {uploadError} +

+ )} + + )} +
+ )} +
+ + {/* Sidebar only visible after upload */} + {parsedWorkflow && ( + + )} +
); -} \ No newline at end of file +}); + +export default GenerateAlternatives; From 876c83c0b160182d29c3d68243207898ce332069 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:41:24 +0200 Subject: [PATCH 10/28] feat: add runSynthesisWithRawConfig with post-filtering to ExploreDataStore Accepts a pre-built APE config object and an optional input tool sequence string. After synthesis, filters out any solution whose descriptive_name matches the input workflow's tool sequence to ensure all returned alternatives genuinely differ from the uploaded workflow. --- src/stores/ExploreDataStore.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/stores/ExploreDataStore.ts b/src/stores/ExploreDataStore.ts index d4309aa5..78989286 100644 --- a/src/stores/ExploreDataStore.ts +++ b/src/stores/ExploreDataStore.ts @@ -213,6 +213,39 @@ export class ExploreDataStore { // Handle error, display fallback image, or show error message }); } + + runSynthesisWithRawConfig(config: object, excludeToolSequence?: string): Promise { + this.isGenerating = true; + this.generationError = ""; + this.workflowSolutions = []; + return fetch("/ape/run_synthesis_and_bench", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }) + .then((response) => { + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return response.json(); + }) + .then((data: WorkflowSolution[]) => { + // §5.4: Lösungen mit identischer Tool-Sequenz zum Eingabeworkflow entfernen + const filtered = excludeToolSequence + ? data.filter((s) => s.descriptive_name !== excludeToolSequence) + : data; + this.workflowSolutions = filtered; + this.workflowSolutions.forEach((solution) => { + solution.isSelected = true; + this.loadImage(solution); + this.loadBenchmarkData(solution); + }); + this.isGenerating = false; + }) + .catch((error) => { + this.generationError = error.message ?? "Synthesis failed"; + this.isGenerating = false; + throw error; + }); + } } const exploreDataStore = new ExploreDataStore(); From 44930bb83d457e20e206e1effef4ff9442cfa711 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:41:29 +0200 Subject: [PATCH 11/28] feat: add Generate Alternatives card to home page --- src/components/Home.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Home.tsx b/src/components/Home.tsx index a0589909..ee19ad97 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -11,6 +11,7 @@ const Home: FC = () => {
+
From d778d71dcb07881de322776c207048e1fba1ab88 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Tue, 12 May 2026 21:41:34 +0200 Subject: [PATCH 12/28] chore: add PostgREST configuration for local development --- database/postgrest.conf | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 database/postgrest.conf diff --git a/database/postgrest.conf b/database/postgrest.conf new file mode 100644 index 00000000..9cba3644 --- /dev/null +++ b/database/postgrest.conf @@ -0,0 +1,12 @@ +# Verbindung zur Datenbank +db-uri = "postgres://db_user:root@localhost:5432/workflomics" + +# Welches Schema soll über die API erreichbar sein? +# Da deine Tabellen in "public" liegen: +db-schema = "public" + +# Die Rolle für nicht authentifizierte Anfragen +db-anon-role = "web_anon" + +# Der Port, auf dem die API lauscht +server-port = 3001 \ No newline at end of file From a2e8187828fd685f46e2aa0f6960cb055a1dba30 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Thu, 14 May 2026 13:05:21 +0200 Subject: [PATCH 13/28] feat: warm EDAM labels on page mount with loading indicator Call GET /ape/alternatives/warm when GenerateAlternatives mounts. Show a full-page spinner while the backend loads the EDAM ontology; show an error state if loading fails. Upload zone renders only after warm-up completes. --- .../GenerateAlternatives.tsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx index e6995f34..732a0491 100644 --- a/src/components/generate_alternatives/GenerateAlternatives.tsx +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactFlow, { Background, useReactFlow } from "reactflow"; import "reactflow/dist/style.css"; import { useNavigate } from "react-router-dom"; @@ -21,9 +21,20 @@ function FitViewOnChange({ trigger }: { trigger: number }) { return null; } +const APE_WARM_URL = "/ape/alternatives/warm"; + const GenerateAlternatives = observer(() => { const { exploreDataStore } = useStore(); const navigate = useNavigate(); + const [isWarming, setIsWarming] = useState(true); + const [warmError, setWarmError] = useState(null); + + useEffect(() => { + fetch(APE_WARM_URL) + .then((res) => { if (!res.ok) throw new Error(`Server error ${res.status}`); }) + .catch((err) => setWarmError(err.message ?? "EDAM-Ontologie konnte nicht geladen werden.")) + .finally(() => setIsWarming(false)); + }, []); const { nodes, edges, @@ -84,6 +95,26 @@ const GenerateAlternatives = observer(() => { } }, [parsedWorkflow, stepStatus, edgeStatus, edgeEndpoints, configParams, exploreDataStore, navigate]); + if (isWarming) { + return ( +
+ + + + +

EDAM-Ontologie wird geladen…

+
+ ); + } + + if (warmError) { + return ( +
+

{warmError}

+
+ ); + } + return (
Date: Thu, 14 May 2026 13:13:05 +0200 Subject: [PATCH 14/28] refactor: remove warm-up call and page-level loading indicator EDAM loading is handled transparently by the backend on the first /parse request. No explicit frontend warm-up needed. --- .../GenerateAlternatives.tsx | 33 +------------------ 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx index 732a0491..e6995f34 100644 --- a/src/components/generate_alternatives/GenerateAlternatives.tsx +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import ReactFlow, { Background, useReactFlow } from "reactflow"; import "reactflow/dist/style.css"; import { useNavigate } from "react-router-dom"; @@ -21,20 +21,9 @@ function FitViewOnChange({ trigger }: { trigger: number }) { return null; } -const APE_WARM_URL = "/ape/alternatives/warm"; - const GenerateAlternatives = observer(() => { const { exploreDataStore } = useStore(); const navigate = useNavigate(); - const [isWarming, setIsWarming] = useState(true); - const [warmError, setWarmError] = useState(null); - - useEffect(() => { - fetch(APE_WARM_URL) - .then((res) => { if (!res.ok) throw new Error(`Server error ${res.status}`); }) - .catch((err) => setWarmError(err.message ?? "EDAM-Ontologie konnte nicht geladen werden.")) - .finally(() => setIsWarming(false)); - }, []); const { nodes, edges, @@ -95,26 +84,6 @@ const GenerateAlternatives = observer(() => { } }, [parsedWorkflow, stepStatus, edgeStatus, edgeEndpoints, configParams, exploreDataStore, navigate]); - if (isWarming) { - return ( -
- - - - -

EDAM-Ontologie wird geladen…

-
- ); - } - - if (warmError) { - return ( -
-

{warmError}

-
- ); - } - return (
Date: Thu, 14 May 2026 14:50:10 +0200 Subject: [PATCH 15/28] test: add T-MAP-01 for apeConfigBuilder constraint generation --- .../utils/apeConfigBuilder.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/components/generate_alternatives/utils/apeConfigBuilder.test.ts diff --git a/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts b/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts new file mode 100644 index 00000000..7e3e8c9a --- /dev/null +++ b/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts @@ -0,0 +1,130 @@ +import { generateApeConfig } from "./apeConfigBuilder"; +import { ParsedWorkflow } from "../types"; + +/** + * T-MAP-01: Validierung der APE-Konfigurationsgenerierung (FA 2–5). + * Prüft die JSON-Ausgabe des apeConfigBuilder für alle Constraint-Status. + */ + +const WORKFLOW: ParsedWorkflow = { + nodes: [ + { id: "input_1", label: "input_1", type: "input" }, + { id: "ToolA_01", label: "ToolA", type: "tool" }, + { id: "ToolB_01", label: "ToolB", type: "tool" }, + { id: "ToolC_01", label: "ToolC", type: "tool" }, + { id: "output_1", label: "output_1", type: "output" }, + ], + edges: [ + { source: "input_1", target: "ToolA_01" }, + { source: "ToolA_01", target: "ToolB_01" }, + { source: "ToolB_01", target: "ToolC_01" }, + { source: "ToolC_01", target: "output_1" }, + ], + inputs: [{ id: "http://edamontology.org/format_3728", label: "LocARNA PP" }], + outputs: [{ id: "http://edamontology.org/format_3244", label: "mzXML" }], +}; + +const DEFAULT_PARAMS = { timeout: 120, minLength: 3, maxLength: 8, solutions: 10 }; +const NO_EDGE_STATUS: Record = {}; +const NO_EDGE_ENDPOINTS: Record = {}; + +function parse( + stepStatus: Record, + edgeStatus = NO_EDGE_STATUS, + edgeEndpoints = NO_EDGE_ENDPOINTS +) { + return JSON.parse(generateApeConfig( + stepStatus as any, + edgeStatus, + edgeEndpoints, + DEFAULT_PARAMS, + WORKFLOW + )); +} + +// ── Keep (use_m) ───────────────────────────────────────────────────────────── + +test("T-MAP-01: Keep generates use_m constraint", () => { + const config = parse({ ToolA_01: "Keep" }); + const c = config.constraints.find((x: any) => x.constraintid === "use_m"); + expect(c).toBeDefined(); + expect(c.parameters[0].operation_0004).toContain("ToolA"); +}); + +// ── Ban (not_use_m) ─────────────────────────────────────────────────────────── + +test("T-MAP-01: Ban generates not_use_m constraint", () => { + const config = parse({ ToolB_01: "Ban" }); + const c = config.constraints.find((x: any) => x.constraintid === "not_use_m"); + expect(c).toBeDefined(); + expect(c.parameters[0].operation_0004).toContain("ToolB"); +}); + +// ── Vary (no constraint) ────────────────────────────────────────────────────── + +test("T-MAP-01: Vary produces no constraint entry", () => { + const config = parse({ ToolA_01: "Vary" }); + expect(config.constraints).toHaveLength(0); +}); + +// ── Chain (connected_op) ────────────────────────────────────────────────────── + +test("T-MAP-01: Chain generates connected_op constraint", () => { + const edgeId = "e-ToolA_01-ToolB_01"; + const config = parse( + {}, + { [edgeId]: "chain" }, + { [edgeId]: { source: "ToolA_01", target: "ToolB_01" } } + ); + const c = config.constraints.find((x: any) => x.constraintid === "connected_op"); + expect(c).toBeDefined(); + expect(c.parameters[0].operation_0004).toContain("ToolA"); + expect(c.parameters[1].operation_0004).toContain("ToolB"); +}); + +// ── Break (not_connected_op) ────────────────────────────────────────────────── + +test("T-MAP-01: Break generates not_connected_op constraint", () => { + const edgeId = "e-ToolB_01-ToolC_01"; + const config = parse( + {}, + { [edgeId]: "break" }, + { [edgeId]: { source: "ToolB_01", target: "ToolC_01" } } + ); + const c = config.constraints.find((x: any) => x.constraintid === "not_connected_op"); + expect(c).toBeDefined(); + expect(c.parameters[0].operation_0004).toContain("ToolB"); + expect(c.parameters[1].operation_0004).toContain("ToolC"); +}); + +// ── I/O-Mapping ─────────────────────────────────────────────────────────────── + +test("T-MAP-01: inputs are mapped from parsed workflow", () => { + const config = parse({}); + expect(config.inputs).toHaveLength(1); + expect(config.inputs[0]["http://edamontology.org/format_1915"]) + .toContain("http://edamontology.org/format_3728"); +}); + +test("T-MAP-01: outputs are mapped from parsed workflow", () => { + const config = parse({}); + expect(config.outputs).toHaveLength(1); + expect(config.outputs[0]["http://edamontology.org/format_1915"]) + .toContain("http://edamontology.org/format_3244"); +}); + +// ── Kombination ─────────────────────────────────────────────────────────────── + +test("T-MAP-01: multiple constraints combine correctly", () => { + const edgeId = "e-ToolA_01-ToolB_01"; + const config = parse( + { ToolA_01: "Keep", ToolC_01: "Ban" }, + { [edgeId]: "chain" }, + { [edgeId]: { source: "ToolA_01", target: "ToolB_01" } } + ); + expect(config.constraints).toHaveLength(3); + const ids = config.constraints.map((c: any) => c.constraintid); + expect(ids).toContain("use_m"); + expect(ids).toContain("not_use_m"); + expect(ids).toContain("connected_op"); +}); From f615e21225aed312e6c9369b467692f0af4b7db3 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 15 Jun 2026 13:46:20 +0200 Subject: [PATCH 16/28] chore: ignore local postgrest binary --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f07ecbf6..67ace2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ web.zip # Docker-compose postgres_data/* + +# Local PostgREST binary (platform-specific, ~17MB) +database/postgrest From 2b41c7d97906a4138bbcac0c9c15b56b6bfe2099 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 15 Jun 2026 13:46:20 +0200 Subject: [PATCH 17/28] fix: resolve CWL tool labels to taxonomy IDs in APE constraints APE generates step names from a tool's label, but use_m/not_use_m/connected_op constraints must reference the taxonomy id, which differs for some tools (e.g. mzRecal -> mzrecal1, Sage -> Sage-proteomics, idconvert -> two ids). Add LABEL_TO_TOOL_IDS to map each label to its id(s); unknown labels fall back to the label itself. --- .../utils/apeConfigBuilder.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/generate_alternatives/utils/apeConfigBuilder.tsx b/src/components/generate_alternatives/utils/apeConfigBuilder.tsx index 306b75c0..58a20f46 100644 --- a/src/components/generate_alternatives/utils/apeConfigBuilder.tsx +++ b/src/components/generate_alternatives/utils/apeConfigBuilder.tsx @@ -1,5 +1,29 @@ import { NodeStatus, ParsedWorkflow } from "../types"; +// Maps CWL step labels (= APE predicateLabel) to their taxonomy IDs in tools.json. +// Required because APE generates step names from label, but constraints need the id. +// Source: https://github.com/Workflomics/tools-and-domains/blob/main/domains/proteomics/tools.json +const LABEL_TO_TOOL_IDS: Record = { + Comet: ["Comet"], + PeptideProphet: ["PeptideProphet"], + ProteinProphet: ["ProteinProphet"], + StPeter: ["StPeter"], + mzRecal: ["mzrecal1"], + idconvert: ["idconvert_to_pepXML", "idconvert_to_mzIdentML"], + GOEnrichment: ["GOEnrichment"], + gProfiler: ["gProfiler"], + XTandem: ["XTandem"], + protXml2IdList: ["protXml2IdList"], + MS_Amanda: ["MS_Amanda"], + MSFragger: ["MSFragger"], + Sage: ["Sage-proteomics"], + wcloud: ["wcloud"], + word_cloud: ["word_cloud"], + pepXml2ProteinNameList: ["pepXml2ProteinNameList"], +}; + +const toolIds = (label: string): string[] => LABEL_TO_TOOL_IDS[label] ?? [label]; + export const generateApeConfig = ( stepStatus: Record, edgeStatus: Record, @@ -52,9 +76,9 @@ export const generateApeConfig = ( const label = nodeLabelById[id]; if (!label) return; if (status === "Keep") { - config.constraints.push({ constraintid: "use_m", parameters: [{ operation_0004: [label] }] }); + config.constraints.push({ constraintid: "use_m", parameters: [{ operation_0004: toolIds(label) }] }); } else if (status === "Ban") { - config.constraints.push({ constraintid: "not_use_m", parameters: [{ operation_0004: [label] }] }); + config.constraints.push({ constraintid: "not_use_m", parameters: [{ operation_0004: toolIds(label) }] }); } // Vary: APE hat freie Wahl, kein Constraint }); @@ -69,12 +93,12 @@ export const generateApeConfig = ( if (status === "chain") { config.constraints.push({ constraintid: "connected_op", - parameters: [{ operation_0004: [sourceLabel] }, { operation_0004: [targetLabel] }], + parameters: [{ operation_0004: toolIds(sourceLabel) }, { operation_0004: toolIds(targetLabel) }], }); } else if (status === "break") { config.constraints.push({ constraintid: "not_connected_op", - parameters: [{ operation_0004: [sourceLabel] }, { operation_0004: [targetLabel] }], + parameters: [{ operation_0004: toolIds(sourceLabel) }, { operation_0004: toolIds(targetLabel) }], }); } }); From 02cddeec1a9b17e8e134b103426eb9a7c371a4a0 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 15 Jun 2026 13:46:20 +0200 Subject: [PATCH 18/28] test: rename apeConfigBuilder tests to camelCase --- .../utils/apeConfigBuilder.test.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts b/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts index 7e3e8c9a..165e1ca8 100644 --- a/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts +++ b/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts @@ -1,11 +1,6 @@ import { generateApeConfig } from "./apeConfigBuilder"; import { ParsedWorkflow } from "../types"; -/** - * T-MAP-01: Validierung der APE-Konfigurationsgenerierung (FA 2–5). - * Prüft die JSON-Ausgabe des apeConfigBuilder für alle Constraint-Status. - */ - const WORKFLOW: ParsedWorkflow = { nodes: [ { id: "input_1", label: "input_1", type: "input" }, @@ -44,7 +39,7 @@ function parse( // ── Keep (use_m) ───────────────────────────────────────────────────────────── -test("T-MAP-01: Keep generates use_m constraint", () => { +test("testKeepGeneratesUseMConstraint", () => { const config = parse({ ToolA_01: "Keep" }); const c = config.constraints.find((x: any) => x.constraintid === "use_m"); expect(c).toBeDefined(); @@ -53,7 +48,7 @@ test("T-MAP-01: Keep generates use_m constraint", () => { // ── Ban (not_use_m) ─────────────────────────────────────────────────────────── -test("T-MAP-01: Ban generates not_use_m constraint", () => { +test("testBanGeneratesNotUseMConstraint", () => { const config = parse({ ToolB_01: "Ban" }); const c = config.constraints.find((x: any) => x.constraintid === "not_use_m"); expect(c).toBeDefined(); @@ -62,14 +57,14 @@ test("T-MAP-01: Ban generates not_use_m constraint", () => { // ── Vary (no constraint) ────────────────────────────────────────────────────── -test("T-MAP-01: Vary produces no constraint entry", () => { +test("testVaryProducesNoConstraint", () => { const config = parse({ ToolA_01: "Vary" }); expect(config.constraints).toHaveLength(0); }); // ── Chain (connected_op) ────────────────────────────────────────────────────── -test("T-MAP-01: Chain generates connected_op constraint", () => { +test("testChainGeneratesConnectedOp", () => { const edgeId = "e-ToolA_01-ToolB_01"; const config = parse( {}, @@ -84,7 +79,7 @@ test("T-MAP-01: Chain generates connected_op constraint", () => { // ── Break (not_connected_op) ────────────────────────────────────────────────── -test("T-MAP-01: Break generates not_connected_op constraint", () => { +test("testBreakGeneratesNotConnectedOp", () => { const edgeId = "e-ToolB_01-ToolC_01"; const config = parse( {}, @@ -99,14 +94,14 @@ test("T-MAP-01: Break generates not_connected_op constraint", () => { // ── I/O-Mapping ─────────────────────────────────────────────────────────────── -test("T-MAP-01: inputs are mapped from parsed workflow", () => { +test("testInputsMappedFromWorkflow", () => { const config = parse({}); expect(config.inputs).toHaveLength(1); expect(config.inputs[0]["http://edamontology.org/format_1915"]) .toContain("http://edamontology.org/format_3728"); }); -test("T-MAP-01: outputs are mapped from parsed workflow", () => { +test("testOutputsMappedFromWorkflow", () => { const config = parse({}); expect(config.outputs).toHaveLength(1); expect(config.outputs[0]["http://edamontology.org/format_1915"]) @@ -115,7 +110,7 @@ test("T-MAP-01: outputs are mapped from parsed workflow", () => { // ── Kombination ─────────────────────────────────────────────────────────────── -test("T-MAP-01: multiple constraints combine correctly", () => { +test("testMultipleConstraintsCombine", () => { const edgeId = "e-ToolA_01-ToolB_01"; const config = parse( { ToolA_01: "Keep", ToolC_01: "Ban" }, From b2be24cead9913442b52d03371a52c44bfa5ddab Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 15 Jun 2026 13:46:31 +0200 Subject: [PATCH 19/28] feat: retry synthesis request and surface backend error details Convert runSynthesisWithRawConfig to async/await, retry the synthesis call once on failure (1.5s delay), and include the response body in the error so transient backend issues surface clearly. Add ExploreDataStore tests for the post-filtering of identical tool sequences. --- src/stores/ExploreDataStore.test.ts | 116 ++++++++++++++++++++++++++++ src/stores/ExploreDataStore.ts | 79 +++++++++++-------- 2 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 src/stores/ExploreDataStore.test.ts diff --git a/src/stores/ExploreDataStore.test.ts b/src/stores/ExploreDataStore.test.ts new file mode 100644 index 00000000..936dcb59 --- /dev/null +++ b/src/stores/ExploreDataStore.test.ts @@ -0,0 +1,116 @@ +import { ExploreDataStore } from "./ExploreDataStore"; +import { WorkflowSolution } from "./WorkflowTypes"; + +jest.mock("mobx-persist-store", () => ({ + makePersistable: jest.fn().mockResolvedValue(undefined), +})); + +const G0 = "Comet->PeptideProphet->ProteinProphet->protXml2IdList->gProfiler"; +const G1 = "XTandem->PeptideProphet->ProteinProphet->protXml2IdList->gProfiler"; + +function makeSolution(descriptive_name: string): WorkflowSolution { + return { + run_id: "run1", + workflow_length: 5, + workflow_name: "candidate_workflow_1", + descriptive_name, + description: "", + cwl_name: "candidate_workflow_1.cwl", + figure_name: "candidate_workflow_1", + benchmark_file: "candidate_workflow_1.json", + isSelected: false, + image: undefined, + benchmarkData: undefined, + }; +} + +function mockFetchWith(solutions: WorkflowSolution[]) { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => solutions, + } as any); +} + +function okResponse(solutions: WorkflowSolution[]) { + return { ok: true, json: async () => solutions } as any; +} + +function errorResponse(status: number, statusText: string, body: string) { + return { ok: false, status, statusText, text: async () => body } as any; +} + +function makeStore() { + const store = new ExploreDataStore(); + jest.spyOn(store, "loadImage").mockImplementation(() => {}); + jest.spyOn(store, "loadBenchmarkData").mockImplementation(() => {}); + return store; +} + +// ── Post-Filter ─────────────────────────────────────────────────────────────── + +test("testPostFilterRemovesMatchingSequence", async () => { + const store = makeStore(); + mockFetchWith([makeSolution(G0), makeSolution(G1)]); + + await store.runSynthesisWithRawConfig({}, G0); + + expect(store.workflowSolutions).toHaveLength(1); + expect(store.workflowSolutions[0].descriptive_name).toBe(G1); +}); + +test("testPostFilterKeepsNonMatchingWorkflows", async () => { + const store = makeStore(); + mockFetchWith([makeSolution(G0), makeSolution(G1)]); + + await store.runSynthesisWithRawConfig({}, "Sage->SomeOtherSequence"); + + expect(store.workflowSolutions).toHaveLength(2); +}); + +test("testPostFilterNoopWithoutExcludeParam", async () => { + const store = makeStore(); + mockFetchWith([makeSolution(G0), makeSolution(G1)]); + + await store.runSynthesisWithRawConfig({}); + + expect(store.workflowSolutions).toHaveLength(2); +}); + +// ── Retry bei transientem 400 (abgebrochener edam.owl-Download) ───────────────── + +// Macht den Retry-Delay synchron, damit die Tests nicht real warten muessen. +function stubRetryDelay() { + return jest.spyOn(global, "setTimeout").mockImplementation(((cb: () => void) => { + cb(); + return 0 as unknown as NodeJS.Timeout; + }) as any); +} + +test("testRetryRecoversFromTransient400", async () => { + const timer = stubRetryDelay(); + const store = makeStore(); + global.fetch = jest.fn() + .mockResolvedValueOnce(errorResponse(400, "Bad Request", "invalid ontology format")) + .mockResolvedValueOnce(okResponse([makeSolution(G0), makeSolution(G1)])); + + await store.runSynthesisWithRawConfig({}); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(store.workflowSolutions).toHaveLength(2); + expect(store.generationError).toBe(""); + timer.mockRestore(); +}); + +test("testRetryExhaustedSurfacesResponseBody", async () => { + const timer = stubRetryDelay(); + const store = makeStore(); + global.fetch = jest.fn() + .mockResolvedValue(errorResponse(400, "Bad Request", "invalid ontology format")); + + await expect(store.runSynthesisWithRawConfig({})).rejects.toThrow("invalid ontology format"); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(store.generationError).toContain("invalid ontology format"); + expect(store.isGenerating).toBe(false); + timer.mockRestore(); +}); diff --git a/src/stores/ExploreDataStore.ts b/src/stores/ExploreDataStore.ts index 78989286..1be9d036 100644 --- a/src/stores/ExploreDataStore.ts +++ b/src/stores/ExploreDataStore.ts @@ -1,9 +1,9 @@ -import { makeAutoObservable } from "mobx"; -import { makePersistable } from "mobx-persist-store"; -import { UserParams, WorkflowSolution, isTaxParameterComplete } from "./WorkflowTypes"; -import { ApeTaxTuple } from "./TaxStore"; -import { ConstraintInstance } from "./ConstraintStore"; -import { Domain, DomainConfig, JsonConstraintInstance } from "./DomainStore"; +import {makeAutoObservable} from "mobx"; +import {makePersistable} from "mobx-persist-store"; +import {isTaxParameterComplete, UserParams, WorkflowSolution} from "./WorkflowTypes"; +import {ApeTaxTuple} from "./TaxStore"; +import {ConstraintInstance} from "./ConstraintStore"; +import {Domain, DomainConfig, JsonConstraintInstance} from "./DomainStore"; const emptyUserConfig = (): UserParams => { @@ -190,8 +190,7 @@ export class ExploreDataStore { }) .then((response) => response.blob()) .then((blob) => { - const url = URL.createObjectURL(blob); - solution.image = url; + solution.image = URL.createObjectURL(blob); }) .catch((error) => { console.error("Error:", error); @@ -214,37 +213,51 @@ export class ExploreDataStore { }); } - runSynthesisWithRawConfig(config: object, excludeToolSequence?: string): Promise { + async runSynthesisWithRawConfig(config: object, excludeToolSequence?: string): Promise { this.isGenerating = true; this.generationError = ""; this.workflowSolutions = []; - return fetch("/ape/run_synthesis_and_bench", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), - }) - .then((response) => { - if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); - return response.json(); - }) - .then((data: WorkflowSolution[]) => { - // §5.4: Lösungen mit identischer Tool-Sequenz zum Eingabeworkflow entfernen - const filtered = excludeToolSequence - ? data.filter((s) => s.descriptive_name !== excludeToolSequence) - : data; - this.workflowSolutions = filtered; - this.workflowSolutions.forEach((solution) => { - solution.isSelected = true; - this.loadImage(solution); - this.loadBenchmarkData(solution); + try { + const maxAttempts = 2; + const retryDelayMs = 1500; + let response!: Response; + let lastError = ""; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + response = await fetch("/ape/run_synthesis_and_bench", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(config), }); - this.isGenerating = false; - }) - .catch((error) => { + if (response.ok) break; + const body = await response.text().catch(() => ""); + lastError = `${response.status} ${response.statusText}${body ? `: ${body}` : ""}`; + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + if (!response.ok) + throw new Error(lastError || "Synthesis failed"); + const data = await response.json(); + this.workflowSolutions = excludeToolSequence + ? data.filter((s: { descriptive_name: string; }) => s.descriptive_name !== excludeToolSequence) + : data; + this.workflowSolutions.forEach((solution) => { + solution.isSelected = true; + this.loadImage(solution); + this.loadBenchmarkData(solution); + }); + this.isGenerating = false; + } catch (error: unknown) { + if (error instanceof Error) { this.generationError = error.message ?? "Synthesis failed"; this.isGenerating = false; - throw error; - }); + } + else { + this.generationError = "Unknown error" + error; + this.isGenerating = false; + } + throw error; + } } } From ac4194625a9b9c0aa1f0ad1ee2bcb6c91cbc8544 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 15 Jun 2026 13:46:31 +0200 Subject: [PATCH 20/28] feat: redesign workflow nodes and size dagre layout per node type Two-line tool cards with a clipped accent bar for status; compact dashed data nodes for inputs/outputs. Dagre now uses separate dimensions per node type (tool 210x70, data 180x38) for correct automatic layout. --- .../generate_alternatives/flow/CustomEdge.tsx | 1 - .../flow/WorkflowNode.tsx | 130 ++++++++---------- .../utils/layoutHelper.tsx | 23 +++- 3 files changed, 75 insertions(+), 79 deletions(-) diff --git a/src/components/generate_alternatives/flow/CustomEdge.tsx b/src/components/generate_alternatives/flow/CustomEdge.tsx index 6a218441..4a4b4081 100644 --- a/src/components/generate_alternatives/flow/CustomEdge.tsx +++ b/src/components/generate_alternatives/flow/CustomEdge.tsx @@ -4,7 +4,6 @@ import { Icons } from "../ui/Icons"; const CustomEdge = ({ - id, sourceX, sourceY, targetX, diff --git a/src/components/generate_alternatives/flow/WorkflowNode.tsx b/src/components/generate_alternatives/flow/WorkflowNode.tsx index e3988d75..d93bfee1 100644 --- a/src/components/generate_alternatives/flow/WorkflowNode.tsx +++ b/src/components/generate_alternatives/flow/WorkflowNode.tsx @@ -5,90 +5,76 @@ import { WorkflowNodeData } from "../types"; const WorkflowNode = ({ data }: NodeProps) => { const { label, type, status } = data; - const isData = type !== "tool"; - let containerStyle = "bg-white border-slate-200"; - let icon = ; - let labelStyle = "text-slate-600"; - let statusIndicator = null; + if (type !== "tool") { + return ( +
+ + + {label} + +
+ ); + } - if (isData) { - containerStyle = - "bg-slate-50 border-slate-200 border-l-4 border-l-slate-300"; - labelStyle = "text-slate-500 font-medium italic"; - icon = ; - } else { - if (status === "Keep") { - containerStyle = - "bg-white border-slate-300 shadow-sm border-l-4 border-l-slate-600"; - icon = ; - labelStyle = "text-slate-800 font-bold"; - statusIndicator = ( - - KEEP + const statusConfig = { + Keep: { + card: "bg-white border-slate-200 shadow-sm", + accent: "bg-slate-400", + icon: , + labelCls: "text-slate-800", + badge: ( + + Keep - ); - } else if (status === "Vary") { - containerStyle = - "bg-[#fff7ed] border-[#f06455] shadow-md border-l-4 border-l-[#f06455]"; - icon = ; - labelStyle = "text-slate-900 font-bold"; - statusIndicator = ( - - VARY + ), + }, + Vary: { + card: "bg-white border-[#f06455]/30 shadow-md", + accent: "bg-[#f06455]", + icon: , + labelCls: "text-slate-800", + badge: ( + + Vary - ); - } else if (status === "Ban") { - containerStyle = - "bg-slate-50 border-slate-200 opacity-60 border-l-4 border-l-rose-400"; - icon = ; - labelStyle = "text-slate-400 line-through decoration-slate-400"; - statusIndicator = ( - - BAN + ), + }, + Ban: { + card: "bg-slate-50 border-rose-200 opacity-60", + accent: "bg-rose-400", + icon: , + labelCls: "text-slate-400 line-through decoration-slate-400", + badge: ( + + Ban - ); - } - } + ), + }, + }; - const stripedBg = - status === "Ban" - ? { - backgroundImage: - "linear-gradient(135deg, #f1f5f9 25%, #ffffff 25%, #ffffff 50%, #f1f5f9 50%, #f1f5f9 75%, #ffffff 75%, #ffffff 100%)", - backgroundSize: "20px 20px", - } - : {}; + const cfg = statusConfig[status as keyof typeof statusConfig] ?? statusConfig.Keep; return ( -
- -
{icon}
-
- - {type} - -
- +
+ {/* Thin accent bar — clipped to card corners by overflow-hidden */} +
+ + + +
+
+
{cfg.icon}
+ {label}
+
+ {cfg.badge} +
- {statusIndicator && ( -
{statusIndicator}
- )} - + +
); }; diff --git a/src/components/generate_alternatives/utils/layoutHelper.tsx b/src/components/generate_alternatives/utils/layoutHelper.tsx index f6c33a6e..f9d34b94 100644 --- a/src/components/generate_alternatives/utils/layoutHelper.tsx +++ b/src/components/generate_alternatives/utils/layoutHelper.tsx @@ -1,14 +1,22 @@ import dagre from "dagre"; import { Node, Edge } from "reactflow"; +const TOOL_W = 210; +const TOOL_H = 70; +const DATA_W = 180; +const DATA_H = 38; + export const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { const dagreGraph = new dagre.graphlib.Graph(); dagreGraph.setDefaultEdgeLabel(() => ({})); - // TB = Top to Bottom dagreGraph.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 }); nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: 190, height: 60 }); + const isTool = node.data?.type === "tool"; + dagreGraph.setNode(node.id, { + width: isTool ? TOOL_W : DATA_W, + height: isTool ? TOOL_H : DATA_H, + }); }); edges.forEach((edge) => { @@ -18,15 +26,18 @@ export const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { dagre.layout(dagreGraph); const layoutedNodes = nodes.map((node) => { - const nodeWithPosition = dagreGraph.node(node.id); + const pos = dagreGraph.node(node.id); + const isTool = node.data?.type === "tool"; + const w = isTool ? TOOL_W : DATA_W; + const h = isTool ? TOOL_H : DATA_H; return { ...node, position: { - x: nodeWithPosition.x - 190 / 2, - y: nodeWithPosition.y - 60 / 2, + x: pos.x - w / 2, + y: pos.y - h / 2, }, }; }); return { nodes: layoutedNodes, edges }; -}; \ No newline at end of file +}; From 0730e6b33225c9d18c881e058f80927caba5fd53 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 15 Jun 2026 13:46:31 +0200 Subject: [PATCH 21/28] feat: add contextual InfoTooltip help and JSON syntax highlighting InfoTooltip renders configurable contextual help on the graph canvas, the active-constraints panel, and the APE-settings panel. The Show-JSON modal highlights the generated config without an external dependency. Canvas height now scales with the viewport. --- .../GenerateAlternatives.tsx | 20 +++++- .../sidebar/ConstraintSidebar.tsx | 65 +++++++++++++++++-- .../generate_alternatives/ui/InfoTooltip.tsx | 35 ++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 src/components/generate_alternatives/ui/InfoTooltip.tsx diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx index e6995f34..7078b669 100644 --- a/src/components/generate_alternatives/GenerateAlternatives.tsx +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -9,6 +9,7 @@ import CustomEdge from "./flow/CustomEdge"; import { useWorkflowState } from "./hooks/useWorkflowState"; import ConstraintSidebar from "./sidebar/ConstraintSidebar"; import { Icons } from "./ui/Icons"; +import { InfoTooltip } from "./ui/InfoTooltip"; import { useStore } from "../../store"; import { generateApeConfig } from "./utils/apeConfigBuilder"; @@ -94,9 +95,26 @@ const GenerateAlternatives = observer(() => { onChange={handleFileChange} /> -
+
{/* Graph area */}
+ {/* Graph info icon — always visible */} +
+ +

Visualizes the uploaded CWL v1.2 workflow as an interactive graph.

+
    +
  • Left-click a node: KeepVaryBan
  • +
  • Right-click a node: reverse cycle
  • +
  • Click a tool↔tool edge: Chain / Break
  • +
+

Input and output nodes are not interactive.

+ } + /> +
+ {parsedWorkflow ? ( <> {/* Filename pill + change-file button */} diff --git a/src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx b/src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx index 8ebd1501..178538ff 100644 --- a/src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx +++ b/src/components/generate_alternatives/sidebar/ConstraintSidebar.tsx @@ -1,8 +1,32 @@ import React, { useMemo, useState } from "react"; import { Icons } from "../ui/Icons"; +import { InfoTooltip } from "../ui/InfoTooltip"; import { NodeStatus, ParsedWorkflow } from "../types"; import { generateApeConfig } from "../utils/apeConfigBuilder"; +// Matches: quoted strings (optionally followed by a colon = key), booleans, null, numbers +const JSON_TOKEN_RE = /("(?:\\.|[^"\\])*"(?:\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/; + +function JsonHighlight({ json }: { json: string }) { + const parts = json.split(JSON_TOKEN_RE); + return ( +
+            {parts.map((part, i) => {
+                if (!part) return null;
+                if (part.startsWith('"')) {
+                    return part.trimEnd().endsWith(':')
+                        ? {part}
+                        : {part};
+                }
+                if (part === 'true' || part === 'false') return {part};
+                if (part === 'null') return {part};
+                if (/^-?\d/.test(part)) return {part};
+                return {part};
+            })}
+        
+ ); +} + interface ConstraintSidebarProps { stepStatus: Record; edgeStatus: Record; @@ -91,9 +115,23 @@ export default function ConstraintSidebar({
-

+

Active Constraints

+ +

Lists all constraints passed to APE for synthesis.

+
    +
  • Keep — tool must appear in every result
  • +
  • Vary — no constraint, tool can be substituted
  • +
  • Ban — tool is excluded from all results
  • +
  • Chain — two tools must appear consecutively
  • +
  • Break — two tools must not appear consecutively
  • +
+ } + />
{activeConstraints.length === 0 ? ( @@ -115,9 +153,24 @@ export default function ConstraintSidebar({ {/* APE Settings Panel */}
-

- APE Settings -

+
+

+ APE Settings +

+ +

Configure the APE synthesis parameters.

+
    +
  • Min / Max Length — allowed range of workflow steps
  • +
  • Solutions — max number of alternatives to generate
  • +
  • Timeout — solver time limit in seconds
  • +
+

The uploaded workflow is always excluded from results.

+ } + /> +
{["minLength", "maxLength", "solutions", "timeout"].map((key) => (
@@ -200,9 +253,7 @@ export default function ConstraintSidebar({ {/* JSON body */}
-
-                                {jsonString}
-                            
+
diff --git a/src/components/generate_alternatives/ui/InfoTooltip.tsx b/src/components/generate_alternatives/ui/InfoTooltip.tsx new file mode 100644 index 00000000..5d5338ab --- /dev/null +++ b/src/components/generate_alternatives/ui/InfoTooltip.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +interface Props { + text: React.ReactNode; + /** Which edge of the icon the tooltip box is anchored to horizontally. */ + align?: "left" | "right"; + /** Whether the tooltip opens above or below the icon. */ + side?: "top" | "bottom"; +} + +export function InfoTooltip({ text, align = "right", side = "top" }: Props) { + const verticalPos = side === "top" ? "bottom-full mb-2" : "top-full mt-2"; + const arrowVertical = side === "top" ? "top-full -mt-1" : "bottom-full -mb-1"; + const horizontalPos = align === "left" ? "left-0" : "right-0"; + const arrowHorizontal = align === "left" ? "left-2" : "right-2"; + + return ( +
+ +
+ {text} +
+
+
+ ); +} From 5edec82d9675cb6150265a50a83db1746d57e7b1 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 15 Jun 2026 13:46:31 +0200 Subject: [PATCH 22/28] chore: update alternatives home card copy, drop CWL-input download --- src/components/Home.tsx | 2 +- src/components/explore/GenerationResults.tsx | 35 +------------------- 2 files changed, 2 insertions(+), 35 deletions(-) diff --git a/src/components/Home.tsx b/src/components/Home.tsx index ee19ad97..87cf8519 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -11,7 +11,7 @@ const Home: FC = () => {
- +
diff --git a/src/components/explore/GenerationResults.tsx b/src/components/explore/GenerationResults.tsx index 7f3e92e5..40130575 100644 --- a/src/components/explore/GenerationResults.tsx +++ b/src/components/explore/GenerationResults.tsx @@ -68,26 +68,6 @@ const GenerationResults: React.FC = observer((props) => { }); }; - const downloadInputFile = (run_id: string) => { - fetch(`/ape/cwl_input?run_id=${run_id}`) - .then((response) => response.text()) - .then((data) => { - const blob = new Blob([data], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "input.yml"; - link.click(); - URL.revokeObjectURL(url); - }) - .catch((error) => { - console.error( - "There has been a problem with accessing cwl input file from the REST APE service:", - error - ); - }); - }; - const downloadSelectedWorkflows = () => { const selectedWorkflows: WorkflowSolution[] = workflowSolutions.filter( (workflow: WorkflowSolution) => workflow.isSelected @@ -278,20 +258,7 @@ const GenerationResults: React.FC = observer((props) => { Download
selected
-
- -
+
Date: Tue, 16 Jun 2026 13:57:05 +0200 Subject: [PATCH 23/28] refactor: change cwl upload from german to english --- .../generate_alternatives/GenerateAlternatives.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx index 7078b669..b4c66326 100644 --- a/src/components/generate_alternatives/GenerateAlternatives.tsx +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -128,7 +128,7 @@ const GenerateAlternatives = observer(() => { disabled={isLoading} className="ml-1 text-xs text-slate-400 hover:text-slate-700 disabled:opacity-50 transition-colors shrink-0" > - {isLoading ? "Parsing…" : "Ändern"} + {isLoading ? "Parsing…" : "Change"}
@@ -172,8 +172,8 @@ const GenerateAlternatives = observer(() => {
-

CWL-Datei hochladen

-

Hier hineinziehen oder klicken zum Auswählen

+

Upload CWL-File

+

Drag here or click to upload

{uploadError && (

From e65ff4763aca66d005f5c2ed69ef5565ca44ae1f Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Wed, 24 Jun 2026 17:24:37 +0200 Subject: [PATCH 24/28] refactor: remove unnecessary references --- src/components/generate_alternatives/GenerateAlternatives.tsx | 2 +- .../generate_alternatives/hooks/useWorkflowState.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/generate_alternatives/GenerateAlternatives.tsx b/src/components/generate_alternatives/GenerateAlternatives.tsx index b4c66326..2ff61f7f 100644 --- a/src/components/generate_alternatives/GenerateAlternatives.tsx +++ b/src/components/generate_alternatives/GenerateAlternatives.tsx @@ -72,7 +72,7 @@ const GenerateAlternatives = observer(() => { const config = JSON.parse( generateApeConfig(stepStatus, edgeStatus, edgeEndpoints, configParams, parsedWorkflow) ); - // §5.4: Tool-Sequenz des Eingabeworkflows als Referenz für Post-Filtering + // Tool-Sequenz des Eingabeworkflows als Referenz für Post-Filtering const inputToolSequence = parsedWorkflow.nodes .filter((n) => (n.type ?? "tool") === "tool") .map((n) => n.label) diff --git a/src/components/generate_alternatives/hooks/useWorkflowState.tsx b/src/components/generate_alternatives/hooks/useWorkflowState.tsx index b2742be4..586fbf5b 100644 --- a/src/components/generate_alternatives/hooks/useWorkflowState.tsx +++ b/src/components/generate_alternatives/hooks/useWorkflowState.tsx @@ -23,7 +23,7 @@ export const useWorkflowState = () => { solutions: 10, }); - // Kap. 4.3: Graph neu aufbauen und alle Tools auf Keep initialisieren, sobald ein Workflow geladen wird + // Graph neu aufbauen und alle Tools auf Keep initialisieren, sobald ein Workflow geladen wird useEffect(() => { if (!parsedWorkflow) return; const nodeTypeById = Object.fromEntries(parsedWorkflow.nodes.map((n) => [n.id, n.type ?? "tool"])); @@ -55,7 +55,7 @@ export const useWorkflowState = () => { setNodes(layouted.nodes); setEdges(layouted.edges); - // Kap. 4.3: nur Tool-Nodes bekommen einen initialen Keep-Status + // Nur Tool-Nodes bekommen einen initialen Keep-Status const initialStatus: Record = {}; parsedWorkflow.nodes .filter((n) => (n.type ?? "tool") === "tool") From c58ee5f2cdf570854312c67cf05b4bd9e0136dad Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 29 Jun 2026 13:12:01 +0200 Subject: [PATCH 25/28] chore: remove postgrest.conf based on review --- .gitignore | 3 +++ database/postgrest.conf | 12 ------------ 2 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 database/postgrest.conf diff --git a/.gitignore b/.gitignore index 67ace2bf..4ec4b5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ postgres_data/* # Local PostgREST binary (platform-specific, ~17MB) database/postgrest + +# Local postgrest config +database/postgrest.conf diff --git a/database/postgrest.conf b/database/postgrest.conf deleted file mode 100644 index 9cba3644..00000000 --- a/database/postgrest.conf +++ /dev/null @@ -1,12 +0,0 @@ -# Verbindung zur Datenbank -db-uri = "postgres://db_user:root@localhost:5432/workflomics" - -# Welches Schema soll über die API erreichbar sein? -# Da deine Tabellen in "public" liegen: -db-schema = "public" - -# Die Rolle für nicht authentifizierte Anfragen -db-anon-role = "web_anon" - -# Der Port, auf dem die API lauscht -server-port = 3001 \ No newline at end of file From ae41155d972d3193bd68031c616f4eba9e78df33 Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Mon, 29 Jun 2026 13:31:34 +0200 Subject: [PATCH 26/28] fix: restore accidentally removed code in GenerationResults.tsx --- src/components/explore/GenerationResults.tsx | 35 +++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/explore/GenerationResults.tsx b/src/components/explore/GenerationResults.tsx index 40130575..7f3e92e5 100644 --- a/src/components/explore/GenerationResults.tsx +++ b/src/components/explore/GenerationResults.tsx @@ -68,6 +68,26 @@ const GenerationResults: React.FC = observer((props) => { }); }; + const downloadInputFile = (run_id: string) => { + fetch(`/ape/cwl_input?run_id=${run_id}`) + .then((response) => response.text()) + .then((data) => { + const blob = new Blob([data], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "input.yml"; + link.click(); + URL.revokeObjectURL(url); + }) + .catch((error) => { + console.error( + "There has been a problem with accessing cwl input file from the REST APE service:", + error + ); + }); + }; + const downloadSelectedWorkflows = () => { const selectedWorkflows: WorkflowSolution[] = workflowSolutions.filter( (workflow: WorkflowSolution) => workflow.isSelected @@ -258,7 +278,20 @@ const GenerationResults: React.FC = observer((props) => { Download
selected

- +
+ +
Date: Fri, 3 Jul 2026 12:09:13 +0200 Subject: [PATCH 27/28] fix: replace constraint_id "not_use_m" with the existing "nuse_m" and update the tests accordingly --- .../generate_alternatives/utils/apeConfigBuilder.test.ts | 6 +++--- .../generate_alternatives/utils/apeConfigBuilder.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts b/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts index 165e1ca8..734e42fa 100644 --- a/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts +++ b/src/components/generate_alternatives/utils/apeConfigBuilder.test.ts @@ -46,11 +46,11 @@ test("testKeepGeneratesUseMConstraint", () => { expect(c.parameters[0].operation_0004).toContain("ToolA"); }); -// ── Ban (not_use_m) ─────────────────────────────────────────────────────────── +// ── Ban (nuse_m) ─────────────────────────────────────────────────────────── test("testBanGeneratesNotUseMConstraint", () => { const config = parse({ ToolB_01: "Ban" }); - const c = config.constraints.find((x: any) => x.constraintid === "not_use_m"); + const c = config.constraints.find((x: any) => x.constraintid === "nuse_m"); expect(c).toBeDefined(); expect(c.parameters[0].operation_0004).toContain("ToolB"); }); @@ -120,6 +120,6 @@ test("testMultipleConstraintsCombine", () => { expect(config.constraints).toHaveLength(3); const ids = config.constraints.map((c: any) => c.constraintid); expect(ids).toContain("use_m"); - expect(ids).toContain("not_use_m"); + expect(ids).toContain("nuse_m"); expect(ids).toContain("connected_op"); }); diff --git a/src/components/generate_alternatives/utils/apeConfigBuilder.tsx b/src/components/generate_alternatives/utils/apeConfigBuilder.tsx index 58a20f46..5dac5649 100644 --- a/src/components/generate_alternatives/utils/apeConfigBuilder.tsx +++ b/src/components/generate_alternatives/utils/apeConfigBuilder.tsx @@ -70,7 +70,7 @@ export const generateApeConfig = ( }); }); - // 2. Knoten-Constraints (Kap. 4.3): Keep → use_m, Ban → not_use_m, Vary → kein Constraint + // 2. Knoten-Constraints (Kap. 4.3): Keep → use_m, Ban → nuse_m, Vary → kein Constraint const nodeLabelById = Object.fromEntries(parsedWorkflow.nodes.map((n) => [n.id, n.label])); Object.entries(stepStatus).forEach(([id, status]) => { const label = nodeLabelById[id]; @@ -78,7 +78,7 @@ export const generateApeConfig = ( if (status === "Keep") { config.constraints.push({ constraintid: "use_m", parameters: [{ operation_0004: toolIds(label) }] }); } else if (status === "Ban") { - config.constraints.push({ constraintid: "not_use_m", parameters: [{ operation_0004: toolIds(label) }] }); + config.constraints.push({ constraintid: "nuse_m", parameters: [{ operation_0004: toolIds(label) }] }); } // Vary: APE hat freie Wahl, kein Constraint }); From 82ae4472f449abdc17aab3b5fbf1b3b09ef2c24b Mon Sep 17 00:00:00 2001 From: Aaron Strachardt Date: Fri, 3 Jul 2026 12:18:03 +0200 Subject: [PATCH 28/28] chore: add Aaron Strachardt to the citation.cff --- CITATION.cff | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CITATION.cff b/CITATION.cff index ed71be12..f6296160 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -40,6 +40,11 @@ authors: email: n.m.palmblad@lumc.nl affiliation: Leiden University Medical Center, Netherlands orcid: 'https://orcid.org/0000-0002-5865-8994' + - family-names: Strachardt + given-names: Aaron + email: aaron.strachardt@desy.de + affiliation: University of Potsdam, Germany + orcid: 'https://orcid.org/0009-0004-9542-1154' identifiers: - type: doi value: 10.5281/zenodo.10047136