@cyoda/workflow-viewer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/components/WorkflowViewer.tsx
2
- import { useMemo, useState as useState2 } from "react";
2
+ import { useEffect, useMemo, useState as useState2 } from "react";
3
+ import { computeHighlightSet, inspectGraphFocus, projectToGraph } from "@cyoda/workflow-graph";
3
4
 
4
5
  // src/theme/tokens.ts
5
6
  var workflowPalette = {
@@ -125,6 +126,29 @@ function simpleLayout(graph) {
125
126
  }
126
127
  return { positions, width: maxWidth + 24, height: yCursor };
127
128
  }
129
+ function nudgeLabels(items) {
130
+ const sorted = [...items].sort((a, b) => a.midX - b.midX || a.midY - b.midY);
131
+ const placed = [];
132
+ const result = /* @__PURE__ */ new Map();
133
+ for (const item of sorted) {
134
+ const { midX } = item;
135
+ let { midY } = item;
136
+ const halfW = item.pillW / 2;
137
+ const halfH = item.pillH / 2;
138
+ let attempts = 0;
139
+ while (attempts < 20) {
140
+ const overlaps = placed.some(
141
+ (p) => midX + halfW > p.x - p.w / 2 && midX - halfW < p.x + p.w / 2 && midY + halfH > p.y - p.h / 2 && midY - halfH < p.y + p.h / 2
142
+ );
143
+ if (!overlaps) break;
144
+ midY += item.pillH + 4;
145
+ attempts++;
146
+ }
147
+ placed.push({ x: midX, y: midY, w: item.pillW, h: item.pillH });
148
+ result.set(item.id, { midX, midY });
149
+ }
150
+ return result;
151
+ }
128
152
  function groupByWorkflow(nodes) {
129
153
  const out = /* @__PURE__ */ new Map();
130
154
  for (const n of nodes) {
@@ -245,8 +269,7 @@ function laneColor(edge, opts) {
245
269
  return e.automated;
246
270
  }
247
271
  function laneDashArray(edge) {
248
- if (edge.disabled) return "3 2";
249
- if (edge.isLoopback) return "6 4";
272
+ if (edge.manual) return "2 4";
250
273
  return void 0;
251
274
  }
252
275
 
@@ -261,9 +284,9 @@ function polylineToPath(points) {
261
284
  }
262
285
  function computeEdgeGeometry(edge, source, target) {
263
286
  const sx = source.x + source.width / 2;
264
- const sy = source.y + source.height / 2;
287
+ const sy = source.y + source.height;
265
288
  const tx = target.x + target.width / 2;
266
- const ty = target.y + target.height / 2;
289
+ const ty = target.y;
267
290
  if (edge.isSelf) {
268
291
  const rightX = source.x + source.width;
269
292
  const topY = source.y + source.height / 3;
@@ -299,7 +322,6 @@ function EdgePath({
299
322
  const d = route && route.points.length >= 2 ? polylineToPath(route.points) : computeEdgeGeometry(edge, source, target).d;
300
323
  const strokeWidth = selected || highlighted ? geometry.edge.strokeWidth + 0.8 : edge.isLoopback ? geometry.edge.loopStrokeWidth : geometry.edge.strokeWidth;
301
324
  const opacity = dimmed ? 0.25 : 1;
302
- const isManualSolid = edge.manual && !edge.disabled && !edge.isLoopback;
303
325
  return /* @__PURE__ */ jsxs(
304
326
  "g",
305
327
  {
@@ -324,16 +346,6 @@ function EdgePath({
324
346
  strokeDasharray: dash,
325
347
  markerEnd: `url(#wf-arrow-${colorKey(color)})`
326
348
  }
327
- ),
328
- isManualSolid && /* @__PURE__ */ jsx(
329
- "path",
330
- {
331
- d,
332
- fill: "none",
333
- stroke: workflowPalette.neutrals.white,
334
- strokeWidth: 0.6,
335
- pointerEvents: "none"
336
- }
337
349
  )
338
350
  ]
339
351
  }
@@ -422,6 +434,7 @@ function StartMarker({ position }) {
422
434
  return /* @__PURE__ */ jsx3("g", { "aria-hidden": "true", children: /* @__PURE__ */ jsx3(
423
435
  "circle",
424
436
  {
437
+ "data-testid": "start-marker",
425
438
  cx,
426
439
  cy,
427
440
  r,
@@ -437,7 +450,7 @@ function roleCategoryLabel(node) {
437
450
  if (node.role === "initial" || node.role === "initial-terminal") return "INITIAL";
438
451
  if (node.role === "terminal") return "TERMINAL";
439
452
  if (node.category === "MANUAL_REVIEW") return "MANUAL REVIEW";
440
- if (node.category === "PROCESSING_STATE") return "PROCESSING STATE";
453
+ if (node.category === "PROCESSING_STATE") return "PROCESSING";
441
454
  return "STATE";
442
455
  }
443
456
 
@@ -454,7 +467,6 @@ function paletteFor(node) {
454
467
  // src/theme/badges.ts
455
468
  function badgesFor(summary, flags) {
456
469
  const out = [];
457
- if (flags.manual) out.push({ key: "manual", label: "Manual" });
458
470
  if (summary.processor) {
459
471
  if (summary.processor.kind === "single") {
460
472
  out.push({ key: "processor", label: summary.processor.name });
@@ -470,11 +482,6 @@ function badgesFor(summary, flags) {
470
482
  out.push({ key: "criterion", label: "Criterion" });
471
483
  }
472
484
  }
473
- if (summary.execution?.kind === "sync") {
474
- out.push({ key: "execution", label: "SYNC" });
475
- } else if (summary.execution?.kind === "asyncSameTx") {
476
- out.push({ key: "execution", label: "ASYNC_SAME_TX" });
477
- }
478
485
  if (flags.disabled) out.push({ key: "disabled", label: "Disabled" });
479
486
  return out;
480
487
  }
@@ -718,25 +725,47 @@ function pickBadgePalette(key) {
718
725
  // src/components/WorkflowViewer.tsx
719
726
  import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
720
727
  function WorkflowViewer({
721
- graph,
728
+ graph: graphInput,
729
+ document,
722
730
  layout,
723
731
  width = "100%",
724
732
  height = "100%",
725
733
  selectedId,
726
734
  onSelectionChange,
735
+ surface = "website",
736
+ layoutMode,
737
+ viewerLayout,
738
+ interaction = "hover-highlight",
739
+ onInspect,
740
+ showStartMarker = false,
727
741
  className
728
742
  }) {
743
+ const graph = useMemo(() => {
744
+ if (graphInput) return graphInput;
745
+ if (document) return projectToGraph(document);
746
+ throw new Error("WorkflowViewer requires either graph or document.");
747
+ }, [graphInput, document]);
748
+ const visibleGraph = useMemo(() => {
749
+ if (showStartMarker) return graph;
750
+ return {
751
+ ...graph,
752
+ nodes: graph.nodes.filter((node) => node.kind !== "startMarker"),
753
+ edges: graph.edges.filter((edge) => edge.kind !== "startMarker")
754
+ };
755
+ }, [graph, showStartMarker]);
756
+ const graphLayout = typeof layout === "string" ? void 0 : layout;
757
+ const productLayout = viewerLayout ?? layoutMode ?? (typeof layout === "string" ? layout : void 0) ?? "embedded";
729
758
  const effectiveLayout = useMemo(
730
- () => layout ?? simpleLayout(graph),
731
- [graph, layout]
759
+ () => normalizeLayoutForVisibleGraph(graphLayout, visibleGraph) ?? simpleLayout(visibleGraph),
760
+ [graphLayout, visibleGraph]
732
761
  );
733
762
  const pan = usePanZoom();
734
763
  const [internalSelection, setInternalSelection] = useState2(null);
735
764
  const [hovered, setHovered] = useState2(null);
736
765
  const selection = selectedId ?? internalSelection;
737
766
  const stateNodes = useMemo(
738
- () => graph.nodes.filter((n) => n.kind === "state"),
739
- [graph.nodes]
767
+ () => visibleGraph.nodes.filter((n) => n.kind === "state"),
768
+ [visibleGraph.nodes]
740
769
  );
741
770
  const stateById = useMemo(() => {
742
771
  const m = /* @__PURE__ */ new Map();
@@ -744,22 +773,71 @@ function WorkflowViewer({
744
773
  return m;
745
774
  }, [stateNodes]);
746
775
  const transitionEdges = useMemo(
747
- () => graph.edges.filter((e) => e.kind === "transition"),
748
- [graph.edges]
749
- );
750
- const highlightSet = useMemo(
751
- () => computeHighlightSet(hovered ?? selection, graph.nodes, graph.edges),
752
- [hovered, selection, graph.nodes, graph.edges]
776
+ () => visibleGraph.edges.filter((e) => e.kind === "transition"),
777
+ [visibleGraph.edges]
753
778
  );
779
+ useEffect(() => {
780
+ if (process.env.NODE_ENV === "production" || graphLayout) return;
781
+ const sourceCounts = /* @__PURE__ */ new Map();
782
+ for (const e of graph.edges) {
783
+ if (e.kind !== "transition") continue;
784
+ sourceCounts.set(e.sourceId, (sourceCounts.get(e.sourceId) ?? 0) + 1);
785
+ }
786
+ if ([...sourceCounts.values()].some((n) => n > 1)) {
787
+ console.warn(
788
+ "[WorkflowViewer] Rendering without an ELK layout \u2014 branching graphs may not look polished. Pass a layout from `layoutGraph()` (@cyoda/workflow-layout) for best results."
789
+ );
790
+ }
791
+ }, [graphLayout, visibleGraph.edges]);
792
+ const fallbackLabelPositions = useMemo(() => {
793
+ if (effectiveLayout.edges) return null;
794
+ const CHAR_W = 6.5;
795
+ const PILL_H = 24;
796
+ const items = transitionEdges.flatMap((edge) => {
797
+ const source = effectiveLayout.positions.get(edge.sourceId);
798
+ const target = effectiveLayout.positions.get(edge.targetId);
799
+ if (!source || !target) return [];
800
+ const { midX, midY } = computeEdgeGeometry(edge, source, target);
801
+ const pillW = Math.max(40, edge.summary.display.length * CHAR_W + 12);
802
+ return [{ id: edge.id, midX, midY, pillW, pillH: PILL_H }];
803
+ });
804
+ return nudgeLabels(items);
805
+ }, [effectiveLayout, transitionEdges]);
806
+ const focusId = hovered ?? selection;
807
+ const highlightSet = useMemo(() => {
808
+ if (interaction === "none") return null;
809
+ if (interaction === "select") {
810
+ return computeHighlightSet(selection, visibleGraph.nodes, visibleGraph.edges);
811
+ }
812
+ return computeHighlightSet(focusId, visibleGraph.nodes, visibleGraph.edges);
813
+ }, [interaction, focusId, selection, visibleGraph.nodes, visibleGraph.edges]);
754
814
  const anythingFocused = highlightSet !== null;
755
815
  const handleSelect = (id) => {
816
+ if (interaction === "none") return;
756
817
  setInternalSelection(id);
757
818
  onSelectionChange?.(id);
758
819
  };
759
820
  const handleBackgroundClick = () => {
821
+ if (interaction === "none") return;
760
822
  setInternalSelection(null);
761
823
  onSelectionChange?.(null);
762
824
  };
825
+ const handleHoverEnter = (id) => {
826
+ if (interaction === "hover-highlight" || interaction === "hover-path") {
827
+ setHovered(id);
828
+ }
829
+ if (interaction === "hover-path") {
830
+ onInspect?.(inspectGraphFocus(visibleGraph, id));
831
+ }
832
+ };
833
+ const handleHoverLeave = () => {
834
+ if (interaction === "hover-highlight" || interaction === "hover-path") {
835
+ setHovered(null);
836
+ }
837
+ if (interaction === "hover-path") {
838
+ onInspect?.(null);
839
+ }
840
+ };
763
841
  return /* @__PURE__ */ jsxs5(
764
842
  "svg",
765
843
  {
@@ -777,8 +855,12 @@ function WorkflowViewer({
777
855
  style: {
778
856
  background: workflowPalette.neutrals.white,
779
857
  fontFamily: "inherit",
780
- userSelect: "none"
858
+ userSelect: "none",
859
+ ...productLayout === "fullWidth" ? { display: "block", width: "100%", height: "100%" } : null
781
860
  },
861
+ "data-surface": surface,
862
+ "data-layout": productLayout,
863
+ "data-interaction": interaction,
782
864
  "data-testid": "workflow-viewer",
783
865
  children: [
784
866
  /* @__PURE__ */ jsx6(Defs, {}),
@@ -808,8 +890,8 @@ function WorkflowViewer({
808
890
  dimmed: isDimmed,
809
891
  selected: isEdgeSelected,
810
892
  onSelect: handleSelect,
811
- onHoverEnter: setHovered,
812
- onHoverLeave: () => setHovered(null)
893
+ onHoverEnter: handleHoverEnter,
894
+ onHoverLeave: handleHoverLeave
813
895
  },
814
896
  edge.id
815
897
  );
@@ -819,7 +901,7 @@ function WorkflowViewer({
819
901
  const target = effectiveLayout.positions.get(edge.targetId);
820
902
  if (!source || !target) return null;
821
903
  const route = effectiveLayout.edges?.get(edge.id);
822
- const labelPos = route ? { midX: route.labelX, midY: route.labelY } : computeEdgeGeometry(edge, source, target);
904
+ const labelPos = route ? { midX: route.labelX, midY: route.labelY } : fallbackLabelPositions?.get(edge.id) ?? computeEdgeGeometry(edge, source, target);
823
905
  const isHighlighted = highlightSet?.has(edge.id) ?? false;
824
906
  const isDimmed = anythingFocused && !isHighlighted;
825
907
  return /* @__PURE__ */ jsx6(
@@ -835,13 +917,13 @@ function WorkflowViewer({
835
917
  `label-${edge.id}`
836
918
  );
837
919
  }),
838
- graph.nodes.map((node) => renderNode(node, effectiveLayout, {
920
+ visibleGraph.nodes.map((node) => renderNode(node, effectiveLayout, {
839
921
  selection,
840
922
  highlightSet,
841
923
  anythingFocused,
842
924
  onSelect: handleSelect,
843
- onHoverEnter: setHovered,
844
- onHoverLeave: () => setHovered(null)
925
+ onHoverEnter: handleHoverEnter,
926
+ onHoverLeave: handleHoverLeave
845
927
  }))
846
928
  ]
847
929
  }
@@ -850,6 +932,30 @@ function WorkflowViewer({
850
932
  }
851
933
  );
852
934
  }
935
+ function normalizeLayoutForVisibleGraph(layout, graph) {
936
+ if (!layout) return void 0;
937
+ const visibleNodeIds = new Set(graph.nodes.map((node) => node.id));
938
+ const positions = new Map(
939
+ Array.from(layout.positions.entries()).filter(([nodeId]) => visibleNodeIds.has(nodeId))
940
+ );
941
+ const visibleEdgeIds = new Set(graph.edges.map((edge) => edge.id));
942
+ const edges = layout.edges ? new Map(Array.from(layout.edges.entries()).filter(([edgeId]) => visibleEdgeIds.has(edgeId))) : void 0;
943
+ return {
944
+ ...layout,
945
+ positions,
946
+ edges,
947
+ width: computeLayoutBound(positions, "x"),
948
+ height: computeLayoutBound(positions, "y")
949
+ };
950
+ }
951
+ function computeLayoutBound(positions, axis) {
952
+ let max = 0;
953
+ for (const position of positions.values()) {
954
+ const bound = axis === "x" ? position.x + position.width : position.y + position.height;
955
+ if (bound > max) max = bound;
956
+ }
957
+ return Math.max(max + 24, 72);
958
+ }
853
959
  function renderNode(node, layout, ctx) {
854
960
  const pos = layout.positions.get(node.id);
855
961
  if (!pos) return null;
@@ -883,29 +989,6 @@ function smallPositionForMarker(pos) {
883
989
  height: size
884
990
  };
885
991
  }
886
- function computeHighlightSet(focusedId, nodes, edges) {
887
- if (!focusedId) return null;
888
- const set = /* @__PURE__ */ new Set();
889
- set.add(focusedId);
890
- const node = nodes.find((n) => n.id === focusedId);
891
- if (node) {
892
- for (const e of edges) {
893
- if (e.kind !== "transition") continue;
894
- if (e.sourceId === focusedId || e.targetId === focusedId) {
895
- set.add(e.id);
896
- set.add(e.sourceId);
897
- set.add(e.targetId);
898
- }
899
- }
900
- return set;
901
- }
902
- const edge = edges.find((e) => e.id === focusedId);
903
- if (edge && edge.kind === "transition") {
904
- set.add(edge.sourceId);
905
- set.add(edge.targetId);
906
- }
907
- return set;
908
- }
909
992
  export {
910
993
  WorkflowViewer,
911
994
  simpleLayout