Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions calm-studio/packages/web-component/src/render/flowOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Comment thread
rocketstack-matt marked this conversation as resolved.

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
Expand Down Expand Up @@ -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(/<circle cx="([\d.-]+)" cy="([\d.-]+)" r="10"/g)].map((m) => 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(/<circle cx="([\d.-]+)" cy="([\d.-]+)" r="10"/g)].map((m) => [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 = /<circle cx="([\d.]+)" [^>]*\/>\s*<text[^>]*>1</.exec(svg.replace(/\n/g, ' '));
const badge2 = /<circle cx="([\d.]+)" [^>]*\/>\s*<text[^>]*>2</.exec(svg.replace(/\n/g, ' '));
expect(badge1).not.toBeNull();
expect(badge2).not.toBeNull();
expect(Number((badge1 as RegExpExecArray)[1])).toBeLessThan(Number((badge2 as RegExpExecArray)[1]));
});

it('keeps a lone reverse badge on the path (no lateral offset when uncrowded)', () => {
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 = /<circle cx="([\d.]+)" cy="([\d.]+)" r="10"/.exec(svg);
expect(m).not.toBeNull();
const x = Number((m as RegExpExecArray)[1]);
const y = Number((m as RegExpExecArray)[2]);
// On-path check: y must be 20 (first segment) or on the second segment line
const onFirst = Math.abs(y - 20) < 0.01 && x >= 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
Expand Down
124 changes: 114 additions & 10 deletions calm-studio/packages/web-component/src/render/flowOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ export function renderFlowOverlay(
): string {
const parts: string[] = ['<g class="flow-overlay">'];

// 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<string, number>();
const rankInRelationship = new Map<unknown, number>();
{
const groups = new Map<string, typeof flow.transitions>();
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
Expand Down Expand Up @@ -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;
Comment thread
rocketstack-matt marked this conversation as resolved.
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(
`<g class="flow-badge" data-summary="${escapeAttr(transition.summary)}">`,
` <circle cx="${midX}" cy="${midY}" r="10" fill="#3b82f6"/>`,
` <text x="${midX}" y="${midY}" fill="white" font-size="9" font-weight="bold" text-anchor="middle" dominant-baseline="central">${transition['sequence-number']}</text>`,
` <title>${escapeAttr(transition.summary)}</title>`,
`<g class="${badgeClass}" data-description="${escapeAttr(transitionLabel)}">`,
` <circle cx="${midX}" cy="${midY}" r="10" fill="${circleFill}"${circleExtra}/>`,
` <text x="${midX}" y="${midY}" fill="${numberFill}" font-size="9" font-weight="bold" text-anchor="middle" dominant-baseline="central">${transition['sequence-number']}</text>`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transition['sequence-number'] goes into the badge text unescaped, while every other dynamic value here (and in nodeRenderer.ts) is routed through an escape helper. The type says number, but this is parsed JSON reaching {@html svgContent} in CalmDiagram.svelte:166 — a document with a string in that field injects markup. Raised in the original review body as a while-you're-here item; still open.

Suggested change
` <text x="${midX}" y="${midY}" fill="${numberFill}" font-size="9" font-weight="bold" text-anchor="middle" dominant-baseline="central">${transition['sequence-number']}</text>`,
` <text x="${midX}" y="${midY}" fill="${numberFill}" font-size="9" font-weight="bold" text-anchor="middle" dominant-baseline="central">${escapeAttr(String(transition['sequence-number']))}</text>`,

` <title>${escapeAttr(transitionLabel)}</title>`,
`</g>`
);
}
Expand Down Expand Up @@ -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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normal is always (-ty, tx), so which side of the edge a reverse badge lands on depends purely on the layout engine's point ordering — nothing stops it landing on top of a node, another edge, or off-canvas. A comment acknowledging that is probably enough for now; biasing away from the diagram centroid would be the fuller fix.

Separately, this function re-walks the segment lengths exactly as pointAtFraction does, and the two copies handle degenerate input differently (pointAtFraction returns first when total === 0, this returns undefined). One function returning { point, normal } would remove the duplication and the divergence.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on the duplication point from the earlier review: walkPolyline now shares the segment/length precompute, but pointOnWalk and normalOnWalk still each re-walk the segment list to find the target fraction. A single function returning { point, normal } (as originally suggested) would still collapse that. Non-blocking.

}
target -= seg.len;
}
return undefined;
}

function round2(n: number): number {
return Math.round(n * 100) / 100;
}

function escapeAttr(str: string): string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing, not introduced here: this duplicates nodeRenderer.ts's escapeXml but doesn't escape '. Since this file is already being touched, worth exporting escapeXml from nodeRenderer.ts and importing it here instead of maintaining two slightly different escaping helpers in the same package. Non-blocking.

return str
.replace(/&/g, '&amp;')
Expand Down
Loading