diff --git a/calm-studio/packages/web-component/src/render/flowOverlay.test.ts b/calm-studio/packages/web-component/src/render/flowOverlay.test.ts index b2eec9579..d542b41b8 100644 --- a/calm-studio/packages/web-component/src/render/flowOverlay.test.ts +++ b/calm-studio/packages/web-component/src/render/flowOverlay.test.ts @@ -156,6 +156,25 @@ describe('renderFlowOverlay', () => { expect(svg).toContain('keyPoints="0;1"'); }); + it('renders badges from the schema description field when summary is absent', () => { + const schemaFlow: CalmFlow = { + 'unique-id': 'schema-flow', + name: 'Schema Flow', + description: 'Flow using only schema-defined fields', + transitions: [ + { + 'relationship-unique-id': 'rel-1', + 'sequence-number': 1, + description: 'Client sends order request', + direction: 'source-to-destination', + }, + ], + }; + const svg = renderFlowOverlay(schemaFlow, twoEdgeLayouts); + expect(svg).toContain('Client sends order request'); + expect(svg).not.toContain('undefined'); + }); + it('Test 4: renders sequence badge with correct number for each transition', () => { const svg = renderFlowOverlay(sampleFlow, twoEdgeLayouts); // Sequence numbers 1 and 2 should appear in badge text elements @@ -207,6 +226,154 @@ describe('renderFlowOverlay', () => { expect(badgeCount).toBe(1); }); + it('spreads badges when multiple transitions share a relationship, and all remain visible', () => { + const sharedFlow: CalmFlow = { + 'unique-id': 'shared-flow', + name: 'Shared', + description: 'Two transitions over one relationship', + transitions: [ + { + 'relationship-unique-id': 'rel-1', + 'sequence-number': 1, + description: 'Request', + direction: 'source-to-destination', + }, + { + 'relationship-unique-id': 'rel-1', + 'sequence-number': 2, + description: 'Response', + direction: 'destination-to-source', + }, + ], + }; + const svg = renderFlowOverlay(sharedFlow, twoEdgeLayouts); + const badges = (svg.match(/class="flow-badge/g) ?? []).length; + expect(badges).toBe(2); + expect(svg).toContain('>1<'); + expect(svg).toContain('>2<'); + // Badge centers must not coincide: collect cx values of badge circles + const cxs = [...svg.matchAll(/ m[1] + ',' + m[2]); + expect(new Set(cxs).size).toBe(cxs.length); + }); + + it('separates request/response badges even on a short edge (return-lane offset)', () => { + const shortLayouts = new Map([ + ['rel-s', [{ id: 'rel-s', points: [ { x: 100, y: 100 }, { x: 100, y: 140 } ] }]], + ]); + const rrFlow: CalmFlow = { + 'unique-id': 'rr', + name: 'RR', + description: 'request-response on a 40px edge', + transitions: [ + { 'relationship-unique-id': 'rel-s', 'sequence-number': 1, description: 'req', direction: 'source-to-destination' }, + { 'relationship-unique-id': 'rel-s', 'sequence-number': 2, description: 'res', direction: 'destination-to-source' }, + ], + }; + const svg = renderFlowOverlay(rrFlow, shortLayouts); + const centers = [...svg.matchAll(/ [Number(m[1]), Number(m[2])]); + expect(centers.length).toBe(2); + const [a, b] = centers as [[number, number], [number, number]]; + const dist = Math.hypot(a[0] - b[0], a[1] - b[1]); + expect(dist).toBeGreaterThanOrEqual(18); + }); + + it('renders destination-to-source badges hollow (response convention)', () => { + const svg = renderFlowOverlay(reverseFlow, twoEdgeLayouts); + // reverseFlow's single transition is destination-to-source + expect(svg).toContain('class="flow-badge flow-badge-reverse"'); + expect(svg).toContain('fill="#ffffff"'); + }); + + it('forward badges stay solid', () => { + const svg = renderFlowOverlay(sampleFlow, twoEdgeLayouts); + expect(svg).not.toContain('flow-badge-reverse'); + }); + + it('labels badges from legacy summary-only transitions (backward compatibility)', () => { + const legacyFlow = { + 'unique-id': 'legacy-flow', + name: 'Legacy', + description: 'summary-only transition', + transitions: [ + { + 'relationship-unique-id': 'rel-1', + 'sequence-number': 1, + summary: 'Legacy label text', + direction: 'source-to-destination', + }, + ], + } as unknown as CalmFlow; + const svg = renderFlowOverlay(legacyFlow, twoEdgeLayouts); + expect(svg).toContain('Legacy label text'); + expect(svg).not.toContain('undefined'); + }); + + it('prefers description over summary when both are present', () => { + const bothFlow = { + 'unique-id': 'both-flow', + name: 'Both', + description: 'both fields', + transitions: [ + { + 'relationship-unique-id': 'rel-1', + 'sequence-number': 1, + description: 'Schema label', + summary: 'Legacy label', + direction: 'source-to-destination', + }, + ], + } as unknown as CalmFlow; + const svg = renderFlowOverlay(bothFlow, twoEdgeLayouts); + expect(svg).toContain('Schema label'); + expect(svg).not.toContain('Legacy label'); + }); + + it('orders badge spread by sequence-number, not document order', () => { + const outOfOrder: CalmFlow = { + 'unique-id': 'ooo', + name: 'OOO', + description: 'transitions listed out of sequence', + transitions: [ + { 'relationship-unique-id': 'rel-1', 'sequence-number': 2, description: 'second', direction: 'source-to-destination' }, + { 'relationship-unique-id': 'rel-1', 'sequence-number': 1, description: 'first', direction: 'source-to-destination' }, + ], + }; + const svg = renderFlowOverlay(outOfOrder, twoEdgeLayouts); + // rel-1's path starts at (10,20) and heads toward (200,80): earlier along + // the path = smaller x. Badge "1" must sit earlier than badge "2". + const badge1 = /]*\/>\s*]*>1]*\/>\s*]*>2 { + const svg = renderFlowOverlay(reverseFlow, twoEdgeLayouts); + // reverseFlow: single destination-to-source transition on rel-1. Midpoint of + // rel-1's path by length lies on the segment (10,20)->(100,20)->(200,80); + // whatever the exact point, an uncrowded badge must sit ON the polyline. + const m = /= 10 && x <= 100; + const onSecond = x >= 100 && x <= 200 && Math.abs((y - 20) / (x - 100) - 60 / 100) < 0.01; + expect(onFirst || onSecond).toBe(true); + }); + + it('emits rounded coordinates (max 2 decimal places)', () => { + const svg = renderFlowOverlay(sampleFlow, twoEdgeLayouts); + expect(/\d\.\d{3,}/.test(svg)).toBe(false); + }); + + it('uses data-description on badges', () => { + const svg = renderFlowOverlay(sampleFlow, twoEdgeLayouts); + expect(svg).toContain('data-description='); + expect(svg).not.toContain('data-summary='); + }); + it('Test 7: handles flow with 3+ transitions (multi-edge flow)', () => { const svg = renderFlowOverlay(multiEdgeFlow, twoEdgeLayouts); // Three transitions, rel-3 exists in layout diff --git a/calm-studio/packages/web-component/src/render/flowOverlay.ts b/calm-studio/packages/web-component/src/render/flowOverlay.ts index 6634728f3..c1fa55e04 100644 --- a/calm-studio/packages/web-component/src/render/flowOverlay.ts +++ b/calm-studio/packages/web-component/src/render/flowOverlay.ts @@ -37,6 +37,26 @@ export function renderFlowOverlay( ): string { const parts: string[] = ['']; + // Rank transitions within each relationship by sequence-number (not document + // order) so badges on a shared edge spread along the path in flow order. + const perRelationshipCount = new Map(); + const rankInRelationship = new Map(); + { + const groups = new Map(); + for (const t of flow.transitions) { + const id = t['relationship-unique-id']; + const g = groups.get(id) ?? []; + g.push(t); + groups.set(id, g); + } + for (const [id, g] of groups) { + perRelationshipCount.set(id, g.length); + [...g] + .sort((a, b) => a['sequence-number'] - b['sequence-number']) + .forEach((t, rank) => rankInRelationship.set(t, rank)); + } + } + for (const transition of flow.transitions) { const layouts = (edgeLayoutsByRelationship.get(transition['relationship-unique-id']) ?? []).filter( (l) => l.points.length >= 2 @@ -70,18 +90,46 @@ export function renderFlowOverlay( ); } - // One sequence badge per transition, placed at the first edge's midpoint - const midIdx = Math.floor(badgeEdge.points.length / 2); - const midPoint = badgeEdge.points[midIdx] ?? badgeEdge.points[0]; - if (midPoint === undefined) continue; - const midX = midPoint.x; - const midY = midPoint.y; + // One sequence badge per transition. Transitions sharing a relationship + // spread along the first edge's path (rank k of n sits at (k+1)/(n+1), + // ranked by sequence-number) so request/response pairs never stack. + const relId = transition['relationship-unique-id']; + const rank = rankInRelationship.get(transition) ?? 0; + const count = perRelationshipCount.get(relId) ?? 1; + const fraction = (rank + 1) / (count + 1); + const walk = walkPolyline(badgeEdge.points); + const badgePoint = pointOnWalk(walk, fraction); + if (badgePoint === undefined) continue; + // Reverse (response) badges shift perpendicular to the path — a "return + // lane" — but only when the edge is crowded: multiple transitions whose + // along-path spacing is below a badge diameter. A lone reverse badge + // stays on its edge. Which side the lane lands on follows the layout + // engine's point ordering; it can in principle overlap a neighbouring + // element — biasing away from the diagram centroid would be the fuller + // fix if that shows up in practice. + const isReverse = direction === 'destination-to-source'; + const crowded = count > 1 && walk.total / (count + 1) < 20; + const normal = crowded ? normalOnWalk(walk, fraction) : undefined; + const laneOffset = isReverse && normal !== undefined ? 22 : 0; + const midX = round2(badgePoint.x + (normal?.x ?? 0) * laneOffset); + const midY = round2(badgePoint.y + (normal?.y ?? 0) * laneOffset); + // The flow schema defines `description` on transitions; `summary` was a + // legacy CalmStudio field. Prefer the schema field, fall back for older files. + const transitionLabel = + transition.description ?? (transition as { summary?: string }).summary ?? ''; + // Static-render convention: forward (request) badges are solid; reverse + // destination-to-source (response) badges render hollow, so direction is + // legible without the animation. + const badgeClass = isReverse ? 'flow-badge flow-badge-reverse' : 'flow-badge'; + const circleFill = isReverse ? '#ffffff' : '#3b82f6'; + const circleExtra = isReverse ? ' stroke="#3b82f6" stroke-width="2"' : ''; + const numberFill = isReverse ? '#3b82f6' : 'white'; parts.push( - ``, - ` `, - ` ${transition['sequence-number']}`, - ` ${escapeAttr(transition.summary)}`, + ``, + ` `, + ` ${transition['sequence-number']}`, + ` ${escapeAttr(transitionLabel)}`, `` ); } @@ -158,6 +206,62 @@ function getReferencedNodeIdsWithFlatFallback(rel: CalmRelationship): string[] { // Helpers // --------------------------------------------------------------------------- +/** Precomputed polyline segments and total length, shared by point/normal lookups. */ +interface PolylineWalk { + segments: Array<{ a: { x: number; y: number }; b: { x: number; y: number }; len: number }>; + total: number; + first: { x: number; y: number } | undefined; + last: { x: number; y: number } | undefined; +} + +function walkPolyline(points: Array<{ x: number; y: number }>): PolylineWalk { + const segments: PolylineWalk['segments'] = []; + let total = 0; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]; + const b = points[i]; + if (a === undefined || b === undefined) continue; + const len = Math.hypot(b.x - a.x, b.y - a.y); + segments.push({ a, b, len }); + total += len; + } + return { segments, total, first: points[0], last: points[points.length - 1] }; +} + +/** Point at a given fraction (0..1) of the walk; falls back to the first point. */ +function pointOnWalk(walk: PolylineWalk, fraction: number): { x: number; y: number } | undefined { + if (walk.total === 0) return walk.first; + let target = Math.min(Math.max(fraction, 0), 1) * walk.total; + for (const seg of walk.segments) { + if (target <= seg.len) { + const t = seg.len === 0 ? 0 : target / seg.len; + return { x: round2(seg.a.x + (seg.b.x - seg.a.x) * t), y: round2(seg.a.y + (seg.b.y - seg.a.y) * t) }; + } + target -= seg.len; + } + return walk.last; +} + +/** Unit normal of the segment containing the fraction point; undefined for degenerate walks. */ +function normalOnWalk(walk: PolylineWalk, fraction: number): { x: number; y: number } | undefined { + if (walk.total === 0) return undefined; + let target = Math.min(Math.max(fraction, 0), 1) * walk.total; + for (const seg of walk.segments) { + if (target <= seg.len) { + if (seg.len === 0) return undefined; + const tx = (seg.b.x - seg.a.x) / seg.len; + const ty = (seg.b.y - seg.a.y) / seg.len; + return { x: -ty, y: tx }; + } + target -= seg.len; + } + return undefined; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + function escapeAttr(str: string): string { return str .replace(/&/g, '&')