@principal-ai/principal-view-react 0.14.25 → 0.14.27

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.
@@ -36,6 +36,7 @@ import {
36
36
 
37
37
  /**
38
38
  * Minimal marker node for arrow-centric sequence diagrams
39
+ * Invisible - just used for positioning, all rendering done by edges
39
40
  */
40
41
  function SequenceMarkerNode({ data }: NodeProps) {
41
42
  return (
@@ -43,10 +44,7 @@ function SequenceMarkerNode({ data }: NodeProps) {
43
44
  style={{
44
45
  width: '100%',
45
46
  height: '100%',
46
- backgroundColor: 'var(--sequence-marker-bg, #6495ED)',
47
- borderRadius: '50%',
48
- border: '2px solid var(--sequence-marker-border, #4169E1)',
49
- boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
47
+ opacity: 0,
50
48
  }}
51
49
  title={data.fullName as string}
52
50
  >
@@ -65,7 +63,7 @@ function SequenceMarkerNode({ data }: NodeProps) {
65
63
  }
66
64
 
67
65
  /**
68
- * Sequence arrow edge with label
66
+ * Sequence arrow edge with label (dot to dot)
69
67
  */
70
68
  function SequenceArrowEdge({
71
69
  id,
@@ -131,6 +129,146 @@ function SequenceArrowEdge({
131
129
  );
132
130
  }
133
131
 
132
+ /**
133
+ * Participant-to-participant arrow edge (or activation bar for same-lane)
134
+ * Draws from source participant lifeline to target participant lifeline
135
+ */
136
+ function SequenceArrowParticipantEdge({
137
+ id,
138
+ sourceY,
139
+ targetY,
140
+ label,
141
+ data,
142
+ }: EdgeProps) {
143
+ const { theme } = useTheme();
144
+
145
+ // Use participant X positions from data (swimlane centers)
146
+ const sourceX = (data?.sourceParticipantX ?? 0) as number;
147
+ const targetX = (data?.targetParticipantX ?? 0) as number;
148
+ const safeSourceY = (sourceY ?? 0) as number;
149
+ const safeTargetY = (targetY ?? 0) as number;
150
+
151
+ // Check if this is same-lane (activation bar) or cross-lane (arrow)
152
+ const isSameLane = sourceX === targetX;
153
+ const isLastEvent = data?.isLastEvent === true;
154
+
155
+ // Style based on whether it's a move event (IPC) or transform event (internal)
156
+ const isMoveEvent = data?.isMoveEvent === true;
157
+ const strokeColor = isMoveEvent ? (theme.colors.accent || '#f48771') : theme.colors.primary;
158
+
159
+ // Same lane: render as activation bar
160
+ if (isSameLane) {
161
+ const barWidth = 12;
162
+ const eventSpacing = (data?.eventSpacing ?? 80) as number;
163
+
164
+ // For last event, use half the event spacing for bar height
165
+ let barHeight: number;
166
+ let barY: number;
167
+
168
+ if (isLastEvent) {
169
+ barHeight = eventSpacing / 2;
170
+ barY = safeSourceY; // Start at the event position
171
+ } else {
172
+ // Normal case: bar from source to target
173
+ const calculatedHeight = Math.abs(safeTargetY - safeSourceY);
174
+ // Ensure minimum height if events are at same position
175
+ barHeight = calculatedHeight > 0 ? calculatedHeight : eventSpacing / 2;
176
+ barY = Math.min(safeSourceY, safeTargetY);
177
+ }
178
+
179
+ const barX = sourceX - barWidth / 2;
180
+
181
+ return (
182
+ <>
183
+ {/* Activation bar */}
184
+ <svg>
185
+ <rect
186
+ x={barX}
187
+ y={barY}
188
+ width={barWidth}
189
+ height={barHeight}
190
+ fill={strokeColor}
191
+ fillOpacity={0.15}
192
+ stroke={strokeColor}
193
+ strokeWidth={2}
194
+ rx={2}
195
+ />
196
+ </svg>
197
+ {label && (
198
+ <EdgeLabelRenderer>
199
+ <div
200
+ style={{
201
+ position: 'absolute',
202
+ transform: `translate(0, -50%) translate(${sourceX + 15}px,${barY + barHeight / 2}px)`,
203
+ background: theme.colors.background,
204
+ padding: '2px 8px',
205
+ borderRadius: 4,
206
+ fontSize: theme.fontSizes[0],
207
+ fontWeight: theme.fontWeights.medium,
208
+ fontFamily: theme.fonts.body,
209
+ color: strokeColor,
210
+ border: `1px solid ${strokeColor}`,
211
+ pointerEvents: 'all',
212
+ whiteSpace: 'nowrap',
213
+ }}
214
+ className="nodrag nopan"
215
+ >
216
+ {label}
217
+ </div>
218
+ </EdgeLabelRenderer>
219
+ )}
220
+ </>
221
+ );
222
+ }
223
+
224
+ // Cross-lane: render as horizontal arrow at midpoint
225
+ const strokeWidth = isMoveEvent ? 2.5 : 2;
226
+ const markerEnd = isMoveEvent ? 'url(#sequence-arrow-move)' : 'url(#sequence-arrow)';
227
+
228
+ // Draw horizontal arrow at the midpoint between source and target Y positions
229
+ const arrowY = (safeSourceY + safeTargetY) / 2;
230
+ const path = `M ${sourceX} ${arrowY} L ${targetX} ${arrowY}`;
231
+ const labelX = (sourceX + targetX) / 2;
232
+ const labelY = arrowY;
233
+
234
+ return (
235
+ <>
236
+ <BaseEdge
237
+ id={id}
238
+ path={path}
239
+ style={{
240
+ stroke: strokeColor,
241
+ strokeWidth: strokeWidth,
242
+ }}
243
+ markerEnd={markerEnd}
244
+ />
245
+ {label && (
246
+ <EdgeLabelRenderer>
247
+ <div
248
+ style={{
249
+ position: 'absolute',
250
+ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY - 12}px)`,
251
+ background: theme.colors.background,
252
+ padding: isMoveEvent ? '3px 10px' : '2px 8px',
253
+ borderRadius: 4,
254
+ fontSize: theme.fontSizes[0],
255
+ fontWeight: isMoveEvent ? theme.fontWeights.bold : theme.fontWeights.medium,
256
+ fontFamily: theme.fonts.body,
257
+ color: strokeColor,
258
+ border: isMoveEvent ? `2px solid ${strokeColor}` : `1px solid ${strokeColor}`,
259
+ pointerEvents: 'all',
260
+ whiteSpace: 'nowrap',
261
+ }}
262
+ className="nodrag nopan"
263
+ >
264
+ {label}
265
+ </div>
266
+ </EdgeLabelRenderer>
267
+ )}
268
+ </>
269
+ );
270
+ }
271
+
134
272
  /**
135
273
  * Default node types including sequence marker
136
274
  */
@@ -139,10 +277,11 @@ const defaultSequenceNodeTypes: NodeTypes = {
139
277
  };
140
278
 
141
279
  /**
142
- * Default edge types including sequence arrow
280
+ * Default edge types including sequence arrow and participant arrow
143
281
  */
144
282
  const defaultSequenceEdgeTypes: EdgeTypes = {
145
283
  sequenceArrow: SequenceArrowEdge,
284
+ sequenceArrowParticipant: SequenceArrowParticipantEdge,
146
285
  };
147
286
 
148
287
  /**
@@ -394,9 +533,10 @@ function SequenceDiagramInner({
394
533
  zoomOnScroll
395
534
  style={{ background: theme.colors.background }}
396
535
  >
397
- {/* SVG defs for arrow marker */}
536
+ {/* SVG defs for arrow markers */}
398
537
  <svg style={{ position: 'absolute', width: 0, height: 0 }}>
399
538
  <defs>
539
+ {/* Standard arrow for transform events */}
400
540
  <marker
401
541
  id="sequence-arrow"
402
542
  viewBox="0 0 10 10"
@@ -411,6 +551,21 @@ function SequenceDiagramInner({
411
551
  fill={theme.colors.primary}
412
552
  />
413
553
  </marker>
554
+ {/* Accent arrow for move events (IPC calls) */}
555
+ <marker
556
+ id="sequence-arrow-move"
557
+ viewBox="0 0 10 10"
558
+ refX="8"
559
+ refY="5"
560
+ markerWidth="7"
561
+ markerHeight="7"
562
+ orient="auto-start-reverse"
563
+ >
564
+ <path
565
+ d="M 0 0 L 10 5 L 0 10 z"
566
+ fill={theme.colors.accent || '#f48771'}
567
+ />
568
+ </marker>
414
569
  </defs>
415
570
  </svg>
416
571
 
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Workflow Sequence Diagram
3
+ *
4
+ * A wrapper around SequenceDiagramRenderer that handles conversion from
5
+ * workflow scenarios to sequence diagram format, following the graph-to-sequence
6
+ * model where participants are scopes and events are move/transform operations.
7
+ */
8
+
9
+ import { useMemo } from 'react';
10
+ import type { WorkflowScenario, Canvas, ExtendedCanvas } from '@principal-ai/principal-view-core';
11
+ import { SequenceDiagramRenderer } from './SequenceDiagramRenderer';
12
+ import type { SequenceEvent, SequenceEdge } from '../hooks/useSequenceLayout';
13
+ import type { UseSequenceLayoutOptions } from '../hooks/useSequenceLayout';
14
+
15
+ /**
16
+ * Props for WorkflowSequenceDiagram
17
+ */
18
+ export interface WorkflowSequenceDiagramProps {
19
+ /** Workflow scenario to visualize */
20
+ scenario: WorkflowScenario;
21
+
22
+ /** Optional canvas for extracting scope metadata from OTEL nodes */
23
+ canvas?: Canvas | ExtendedCanvas | null;
24
+
25
+ /** Optional height for the diagram */
26
+ height?: number | string;
27
+
28
+ /** Optional width for the diagram */
29
+ width?: number | string;
30
+
31
+ /** Layout options for the sequence diagram */
32
+ layoutOptions?: UseSequenceLayoutOptions;
33
+
34
+ /** Whether to show controls */
35
+ showControls?: boolean;
36
+
37
+ /** Whether to show background grid */
38
+ showBackground?: boolean;
39
+
40
+ /** Optional class name */
41
+ className?: string;
42
+ }
43
+
44
+ /**
45
+ * Type guard to check if a node is an OTEL event node
46
+ */
47
+ function isOtelEventNode(node: any): node is {
48
+ type: 'otel-event';
49
+ event: { name: string };
50
+ otel?: { scope?: string };
51
+ label?: string;
52
+ } {
53
+ return node?.type === 'otel-event' && node?.event?.name;
54
+ }
55
+
56
+ /**
57
+ * Type guard to check if a node is an OTEL participant node
58
+ */
59
+ function isOtelParticipantNode(node: any): node is {
60
+ type: 'otel-participant';
61
+ participant: { name: string; scope?: string };
62
+ transformEvents?: Array<{ name: string }>;
63
+ } {
64
+ return node?.type === 'otel-participant' && node?.participant?.name;
65
+ }
66
+
67
+ /**
68
+ * Convert workflow scenario to sequence diagram format
69
+ */
70
+ function convertWorkflowToSequence(
71
+ scenario: WorkflowScenario,
72
+ canvas?: Canvas | ExtendedCanvas | null
73
+ ): {
74
+ events: SequenceEvent[];
75
+ edges: SequenceEdge[];
76
+ } {
77
+ // Extract event names from scenario template
78
+ const templateEvents = scenario.template.events || {};
79
+ const eventNames = Object.keys(templateEvents);
80
+
81
+ if (eventNames.length === 0) {
82
+ return { events: [], edges: [] };
83
+ }
84
+
85
+ // Build maps from canvas metadata
86
+ const eventToScopeMap = new Map<string, string>();
87
+ const eventToLabelMap = new Map<string, string>();
88
+ const eventToMoveEventMap = new Map<string, boolean>();
89
+
90
+ if (canvas?.nodes) {
91
+ for (const node of canvas.nodes) {
92
+ // Type guard narrowing - we know the structure but Canvas type doesn't include OTEL specifics
93
+ const nodeData = node as Record<string, any>;
94
+
95
+ // Handle OTEL event nodes
96
+ if (isOtelEventNode(nodeData)) {
97
+ const eventName = nodeData.event.name;
98
+
99
+ if (nodeData.otel?.scope) {
100
+ eventToScopeMap.set(eventName, nodeData.otel.scope);
101
+ }
102
+
103
+ if (nodeData.label) {
104
+ eventToLabelMap.set(eventName, nodeData.label);
105
+ }
106
+ }
107
+
108
+ // Handle OTEL participant nodes (participant-based model)
109
+ if (isOtelParticipantNode(nodeData)) {
110
+ const scope = nodeData.participant.scope || nodeData.participant.name;
111
+
112
+ // Mark transform events (internal to participant)
113
+ if (nodeData.transformEvents) {
114
+ for (const transformEvent of nodeData.transformEvents) {
115
+ eventToScopeMap.set(transformEvent.name, scope);
116
+ eventToMoveEventMap.set(transformEvent.name, false);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ // Convert to SequenceEvent format
124
+ const events: SequenceEvent[] = eventNames.map((eventName, index) => {
125
+ const scope = eventToScopeMap.get(eventName) || 'unknown';
126
+ const label = eventToLabelMap.get(eventName) || eventName.split('.').pop() || eventName;
127
+
128
+ // Determine if this is a move event
129
+ // Default to true unless explicitly marked as transform event in participant node
130
+ const isMoveEvent = eventToMoveEventMap.get(eventName) ?? true;
131
+
132
+ return {
133
+ id: `event-${index}`,
134
+ name: `${scope}.${eventName}`,
135
+ label,
136
+ moveEvent: isMoveEvent,
137
+ participant: scope,
138
+ };
139
+ });
140
+
141
+ // Create sequential edges
142
+ const edges: SequenceEdge[] = [];
143
+ for (let i = 0; i < events.length - 1; i++) {
144
+ edges.push({
145
+ id: `edge-${i}`,
146
+ fromEvent: events[i].id,
147
+ toEvent: events[i + 1].id,
148
+ });
149
+ }
150
+
151
+ return { events, edges };
152
+ }
153
+
154
+ /**
155
+ * WorkflowSequenceDiagram Component
156
+ *
157
+ * Renders a workflow scenario as a sequence diagram, automatically extracting
158
+ * participant scopes from the canvas and distinguishing between move events
159
+ * (cross-participant communication) and transform events (internal processing).
160
+ *
161
+ * @example
162
+ * ```tsx
163
+ * <WorkflowSequenceDiagram
164
+ * scenario={workflowScenario}
165
+ * canvas={otelCanvas}
166
+ * height={600}
167
+ * layoutOptions={{
168
+ * namespaceStrategy: 'first',
169
+ * eventSpacing: 80,
170
+ * }}
171
+ * />
172
+ * ```
173
+ */
174
+ export function WorkflowSequenceDiagram({
175
+ scenario,
176
+ canvas,
177
+ height = 600,
178
+ width = '100%',
179
+ layoutOptions,
180
+ showControls = true,
181
+ showBackground = false,
182
+ className,
183
+ }: WorkflowSequenceDiagramProps) {
184
+ // Convert workflow to sequence format
185
+ const { events, edges } = useMemo(
186
+ () => convertWorkflowToSequence(scenario, canvas),
187
+ [scenario, canvas]
188
+ );
189
+
190
+ // If no events, show empty state
191
+ if (events.length === 0) {
192
+ return (
193
+ <div
194
+ className={className}
195
+ style={{
196
+ width,
197
+ height,
198
+ display: 'flex',
199
+ alignItems: 'center',
200
+ justifyContent: 'center',
201
+ color: '#888',
202
+ }}
203
+ >
204
+ No events in workflow
205
+ </div>
206
+ );
207
+ }
208
+
209
+ // Render sequence diagram
210
+ return (
211
+ <SequenceDiagramRenderer
212
+ events={events}
213
+ edges={edges}
214
+ height={height}
215
+ width={width}
216
+ layoutOptions={layoutOptions}
217
+ showControls={showControls}
218
+ showBackground={showBackground}
219
+ className={className}
220
+ />
221
+ );
222
+ }
@@ -19,6 +19,10 @@ export interface SequenceEvent {
19
19
  label?: string;
20
20
  /** Optional event type for styling */
21
21
  type?: string;
22
+ /** Whether this is a move event (crosses participant boundaries) */
23
+ moveEvent?: boolean;
24
+ /** Participant this event belongs to (for move events, this is the target) */
25
+ participant?: string;
22
26
  /** Additional data to pass through to the node */
23
27
  data?: Record<string, unknown>;
24
28
  }
@@ -306,9 +310,6 @@ export function useSequenceLayout(
306
310
  // This creates horizontal "time layers" across all swimlanes
307
311
  const nodes: Node[] = [];
308
312
 
309
- // Build event lookup for edge label resolution
310
- const eventById = new Map(events.map((e) => [e.id, e]));
311
-
312
313
  for (let i = 0; i < events.length; i++) {
313
314
  const event = events[i];
314
315
  const originalNamespace = eventNamespaces.get(event.id)!;
@@ -332,6 +333,7 @@ export function useSequenceLayout(
332
333
  namespace: originalNamespace,
333
334
  visibleNamespace,
334
335
  timeLayer: i,
336
+ isMoveEvent: event.moveEvent === true,
335
337
  ...event.data,
336
338
  },
337
339
  style: {
@@ -341,32 +343,75 @@ export function useSequenceLayout(
341
343
  });
342
344
  }
343
345
 
344
- // Step 5: Create edges with labels derived from target event
345
- const edges: Edge[] = sequenceEdges.map((edge) => {
346
- const sourceNamespace = eventNamespaces.get(edge.fromEvent);
347
- const targetNamespace = eventNamespaces.get(edge.toEvent);
348
- const crossesLanes =
349
- namespaceToVisible.get(sourceNamespace!) !==
350
- namespaceToVisible.get(targetNamespace!);
351
-
352
- const targetEvent = eventById.get(edge.toEvent);
353
- const edgeLabel = edge.label || targetEvent?.label || targetEvent?.name.split('.').pop() || '';
346
+ // Step 5: Create edges - one per event, showing how to get to the NEXT event
347
+ // Each edge looks forward to determine what to render
348
+ const edges: Edge[] = [];
354
349
 
355
- return {
356
- id: edge.id,
357
- source: edge.fromEvent,
358
- target: edge.toEvent,
359
- type: 'sequenceArrow',
360
- label: edgeLabel,
361
- labelStyle: { fontSize: 12, fontWeight: 500 },
362
- labelBgStyle: { fill: 'white', fillOpacity: 0.8 },
363
- data: {
364
- crossesLanes,
365
- sourceNamespace,
366
- targetNamespace,
367
- },
368
- };
369
- });
350
+ for (let i = 0; i < events.length; i++) {
351
+ const currentEvent = events[i];
352
+ const currentNamespace = eventNamespaces.get(currentEvent.id)!;
353
+ const currentVisibleNs = namespaceToVisible.get(currentNamespace)!;
354
+ const currentLane = swimlaneByNamespace.get(currentVisibleNs)!;
355
+
356
+ // Look at the next event (if any)
357
+ if (i < events.length - 1) {
358
+ const nextEvent = events[i + 1];
359
+ const nextNamespace = eventNamespaces.get(nextEvent.id)!;
360
+ const nextVisibleNs = namespaceToVisible.get(nextNamespace)!;
361
+ const nextLane = swimlaneByNamespace.get(nextVisibleNs)!;
362
+ const nextIsMoveEvent = nextEvent.moveEvent === true;
363
+ const crossesLanes = currentVisibleNs !== nextVisibleNs;
364
+
365
+ // Label is from the CURRENT event (the one creating this edge)
366
+ const edgeLabel = currentEvent.label || currentEvent.name.split('.').pop() || currentEvent.name;
367
+
368
+ edges.push({
369
+ id: `edge-${currentEvent.id}-to-${nextEvent.id}`,
370
+ source: currentEvent.id,
371
+ target: nextEvent.id,
372
+ type: 'sequenceArrowParticipant',
373
+ label: edgeLabel,
374
+ labelStyle: { fontSize: 12, fontWeight: 500 },
375
+ labelBgStyle: { fill: 'white', fillOpacity: 0.8 },
376
+ data: {
377
+ crossesLanes,
378
+ sourceNamespace: currentNamespace,
379
+ targetNamespace: nextNamespace,
380
+ isMoveEvent: nextIsMoveEvent,
381
+ sourceEvent: currentEvent,
382
+ targetEvent: nextEvent,
383
+ sourceParticipantX: currentLane.x,
384
+ targetParticipantX: nextLane.x,
385
+ },
386
+ });
387
+ } else {
388
+ // Last event - render small activation bar to show it exists
389
+ const currentIsMoveEvent = currentEvent.moveEvent === true;
390
+ const edgeLabel = currentEvent.label || currentEvent.name.split('.').pop() || currentEvent.name;
391
+
392
+ edges.push({
393
+ id: `edge-${currentEvent.id}-end`,
394
+ source: currentEvent.id,
395
+ target: currentEvent.id,
396
+ type: 'sequenceArrowParticipant',
397
+ label: edgeLabel,
398
+ labelStyle: { fontSize: 12, fontWeight: 500 },
399
+ labelBgStyle: { fill: 'white', fillOpacity: 0.8 },
400
+ data: {
401
+ crossesLanes: false,
402
+ sourceNamespace: currentNamespace,
403
+ targetNamespace: currentNamespace,
404
+ isMoveEvent: currentIsMoveEvent,
405
+ sourceEvent: currentEvent,
406
+ targetEvent: currentEvent,
407
+ sourceParticipantX: currentLane.x,
408
+ targetParticipantX: currentLane.x,
409
+ isLastEvent: true,
410
+ eventSpacing,
411
+ },
412
+ });
413
+ }
414
+ }
370
415
 
371
416
  // Step 6: Compute total dimensions
372
417
  const totalWidth =
package/src/index.ts CHANGED
@@ -40,6 +40,9 @@ export type {
40
40
  export { SequenceDiagramRenderer } from './components/SequenceDiagramRenderer';
41
41
  export type { SequenceDiagramRendererProps } from './components/SequenceDiagramRenderer';
42
42
 
43
+ export { WorkflowSequenceDiagram } from './components/WorkflowSequenceDiagram';
44
+ export type { WorkflowSequenceDiagramProps } from './components/WorkflowSequenceDiagram';
45
+
43
46
  export { useSequenceLayout } from './hooks/useSequenceLayout';
44
47
  export type {
45
48
  SequenceEvent,