@bpmnkit/canvas 0.0.27 → 0.0.29

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/renderer.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readDiColor } from "@bpmnkit/core";
2
+ import { wrapText } from "./measure.js";
2
3
  // ── SVG helpers ───────────────────────────────────────────────────────────────
3
4
  const NS = "http://www.w3.org/2000/svg";
4
5
  function svgEl(tag) {
@@ -9,36 +10,6 @@ function attr(el, attrs) {
9
10
  el.setAttribute(k, String(v));
10
11
  }
11
12
  // ── Text helpers ──────────────────────────────────────────────────────────────
12
- const AVG_CHAR_PX = 6.5; // approximate width at 11px system-ui
13
- /**
14
- * Splits `text` into lines that fit within `maxPx` pixels.
15
- * Uses an average character-width estimate rather than actual text measurement
16
- * to keep this dependency-free and synchronous.
17
- */
18
- function wrapText(text, maxPx) {
19
- if (!text.trim())
20
- return [];
21
- const words = text.split(/\s+/);
22
- const lines = [];
23
- let line = "";
24
- for (const word of words) {
25
- const candidate = line ? `${line} ${word}` : word;
26
- if (candidate.length * AVG_CHAR_PX <= maxPx) {
27
- line = candidate;
28
- }
29
- else if (line) {
30
- lines.push(line);
31
- line = word;
32
- }
33
- else {
34
- // Single word wider than maxPx — use as-is
35
- line = word;
36
- }
37
- }
38
- if (line)
39
- lines.push(line);
40
- return lines.length > 0 ? lines : [text];
41
- }
42
13
  /**
43
14
  * Creates a `<text>` element (or multi-line group) centred at (`cx`, `cy`).
44
15
  * When `topAlign` is true, multi-line text flows downward from `cy` rather
@@ -113,6 +84,69 @@ function waypointsToRoundedPath(waypoints) {
113
84
  }
114
85
  return parts.join(" ");
115
86
  }
87
+ /**
88
+ * Returns the point on `geom`'s outline along the ray from its centre toward
89
+ * `toward`. Used to dock connection endpoints onto the true shape outline
90
+ * (circle for events, diamond for gateways) instead of the bounding box.
91
+ */
92
+ function dockPoint(geom, toward) {
93
+ const cx = geom.x + geom.width / 2;
94
+ const cy = geom.y + geom.height / 2;
95
+ const dx = toward.x - cx;
96
+ const dy = toward.y - cy;
97
+ if (dx === 0 && dy === 0)
98
+ return { x: cx, y: cy };
99
+ if (geom.kind === "circle") {
100
+ const r = Math.min(geom.width, geom.height) / 2;
101
+ const len = Math.sqrt(dx * dx + dy * dy);
102
+ return { x: cx + (dx / len) * r, y: cy + (dy / len) * r };
103
+ }
104
+ if (geom.kind === "diamond") {
105
+ // Ray/diamond intersection: |t·dx|/(w/2) + |t·dy|/(h/2) = 1
106
+ const t = 1 / (Math.abs(dx) / (geom.width / 2) + Math.abs(dy) / (geom.height / 2));
107
+ return { x: cx + dx * t, y: cy + dy * t };
108
+ }
109
+ // Rectangle: scale the ray to the nearest border.
110
+ const tx = dx !== 0 ? geom.width / 2 / Math.abs(dx) : Number.POSITIVE_INFINITY;
111
+ const ty = dy !== 0 ? geom.height / 2 / Math.abs(dy) : Number.POSITIVE_INFINITY;
112
+ const t = Math.min(tx, ty);
113
+ return { x: cx + dx * t, y: cy + dy * t };
114
+ }
115
+ /**
116
+ * Crops the first and last segments of `waypoints` to the source and target
117
+ * shape outlines. Display-only — the DI waypoints are never mutated.
118
+ */
119
+ function cropWaypoints(waypoints, source, target) {
120
+ const out = waypoints.map((w) => ({ x: w.x, y: w.y }));
121
+ if (out.length < 2)
122
+ return out;
123
+ if (source) {
124
+ const next = out[1];
125
+ const first = out[0];
126
+ if (next && first)
127
+ out[0] = dockPoint(source, next);
128
+ }
129
+ if (target) {
130
+ const prev = out[out.length - 2];
131
+ const last = out[out.length - 1];
132
+ if (prev && last)
133
+ out[out.length - 1] = dockPoint(target, prev);
134
+ }
135
+ return out;
136
+ }
137
+ /** Classifies a shape's docking outline from its BPMN element type. */
138
+ function geomKind(type) {
139
+ if (type === "startEvent" ||
140
+ type === "endEvent" ||
141
+ type === "intermediateCatchEvent" ||
142
+ type === "intermediateThrowEvent" ||
143
+ type === "boundaryEvent") {
144
+ return "circle";
145
+ }
146
+ if (type?.endsWith("Gateway"))
147
+ return "diamond";
148
+ return "rect";
149
+ }
116
150
  // ── Task type icons (14×14, origin at 0,0) ───────────────────────────────────
117
151
  /**
118
152
  * Returns inner SVG markup for task-type icons positioned at the top-left
@@ -202,25 +236,61 @@ function gatewayMarker(type) {
202
236
  return "";
203
237
  }
204
238
  }
205
- // ── Sub-process bottom markers ────────────────────────────────────────────────
206
- function subProcessMarker(type, multiInstance) {
207
- let base;
208
- if (type === "adHocSubProcess") {
209
- base = `<path d="M-7 0Q-4 -4 0 0Q4 4 7 0" class="bpmnkit-icon"/>`;
210
- }
211
- else {
212
- base = `<rect x="-7" y="-7" width="14" height="14" rx="1" class="bpmnkit-icon"/>
213
- <path d="M0 -4v8M-4 0h8" class="bpmnkit-icon"/>`;
214
- }
215
- if (multiInstance === "sequential") {
216
- // Three horizontal lines ≡ (sequential)
217
- base += `<g transform="translate(16 0)"><path d="M-4 -4h8M-4 0h8M-4 4h8" class="bpmnkit-icon"/></g>`;
218
- }
219
- else if (multiInstance === "parallel") {
220
- // Three vertical lines ||| (parallel)
221
- base += `<g transform="translate(16 0)"><path d="M-4 -4v8M0 -4v8M4 -4v8" class="bpmnkit-icon"/></g>`;
239
+ // ── Activity bottom markers ───────────────────────────────────────────────────
240
+ /** Glyphs (each centred at 0,0, ~14×14) for the activity marker row. */
241
+ const MARKER_GLYPHS = {
242
+ miParallel: `<path d="M-4 -5v10M0 -5v10M4 -5v10" class="bpmnkit-icon"/>`,
243
+ miSequential: `<path d="M-5 -4h10M-5 0h10M-5 4h10" class="bpmnkit-icon"/>`,
244
+ // Two left-pointing triangles (compensation "rewind")
245
+ compensation: `<path d="M1 -3.5l-5 3.5 5 3.5zM6 -3.5l-5 3.5 5 3.5z" class="bpmnkit-icon"/>`,
246
+ // Tilde (ad-hoc)
247
+ adHoc: `<path d="M-7 1Q-4 -3 0 1Q4 5 7 1" class="bpmnkit-icon"/>`,
248
+ // Box with a plus (collapsed activity)
249
+ collapsed: `<rect x="-7" y="-7" width="14" height="14" rx="1" class="bpmnkit-icon"/><path d="M0 -4v8M-4 0h8" class="bpmnkit-icon"/>`,
250
+ };
251
+ /**
252
+ * Returns the inner SVG markup for an activity's bottom marker row (loop
253
+ * characteristics, compensation, ad-hoc, and the collapsed `+` marker),
254
+ * laid out as a horizontal row centred at the origin.
255
+ *
256
+ * Callers translate the returned group to the bottom-centre of the activity.
257
+ */
258
+ function activityMarkers(el, shape, skipCollapsed = false) {
259
+ const glyphs = [];
260
+ const type = el.type;
261
+ // Multi-instance loop
262
+ const lc = "loopCharacteristics" in el ? el.loopCharacteristics : undefined;
263
+ if (lc)
264
+ glyphs.push(lc.isSequential ? MARKER_GLYPHS.miSequential : MARKER_GLYPHS.miParallel);
265
+ // Compensation
266
+ if (el.isForCompensation)
267
+ glyphs.push(MARKER_GLYPHS.compensation);
268
+ // Ad-hoc
269
+ if (type === "adHocSubProcess")
270
+ glyphs.push(MARKER_GLYPHS.adHoc);
271
+ // Collapsed marker: call activities always show it; sub-process variants
272
+ // show it only when collapsed (not expanded into visible children). Skipped
273
+ // when the caller renders an interactive drill-down button instead.
274
+ const isSubProcessType = type === "subProcess" ||
275
+ type === "adHocSubProcess" ||
276
+ type === "eventSubProcess" ||
277
+ type === "transaction";
278
+ if (!skipCollapsed &&
279
+ (type === "callActivity" || (isSubProcessType && shape.isExpanded !== true))) {
280
+ glyphs.push(MARKER_GLYPHS.collapsed);
222
281
  }
223
- return base;
282
+ if (glyphs.length === 0)
283
+ return "";
284
+ const step = 16;
285
+ const start = -((glyphs.length - 1) * step) / 2;
286
+ return glyphs
287
+ .map((glyph, i) => `<g transform="translate(${start + i * step} 0)">${glyph}</g>`)
288
+ .join("");
289
+ }
290
+ /** Marker for an event with multiple event definitions (pentagon). */
291
+ function multipleEventMarker(filled) {
292
+ const cls = filled ? "bpmnkit-icon-solid" : "bpmnkit-icon";
293
+ return `<path d="M0 -5.5L5.2 -1.7L3.2 4.5H-3.2L-5.2 -1.7Z" class="${cls}"/>`;
224
294
  }
225
295
  function buildIndex(defs) {
226
296
  const elements = new Map();
@@ -228,7 +298,9 @@ function buildIndex(defs) {
228
298
  const annotations = new Map();
229
299
  const participants = new Map();
230
300
  const lanes = new Map();
231
- const messageFlowIds = new Set();
301
+ const associations = new Map();
302
+ const groups = new Map();
303
+ const messageFlows = new Map();
232
304
  const defaultFlowIds = new Set();
233
305
  function indexProcess(flowElements, sequenceFlows) {
234
306
  for (const el of flowElements) {
@@ -262,6 +334,12 @@ function buildIndex(defs) {
262
334
  for (const ta of proc.textAnnotations) {
263
335
  annotations.set(ta.id, ta);
264
336
  }
337
+ for (const assoc of proc.associations) {
338
+ associations.set(assoc.id, assoc);
339
+ }
340
+ for (const group of proc.groups) {
341
+ groups.set(group.id, group);
342
+ }
265
343
  if (proc.laneSet)
266
344
  indexLaneSet(proc.laneSet);
267
345
  }
@@ -272,11 +350,27 @@ function buildIndex(defs) {
272
350
  for (const ta of collab.textAnnotations) {
273
351
  annotations.set(ta.id, ta);
274
352
  }
353
+ for (const assoc of collab.associations) {
354
+ associations.set(assoc.id, assoc);
355
+ }
356
+ for (const group of collab.groups) {
357
+ groups.set(group.id, group);
358
+ }
275
359
  for (const mf of collab.messageFlows) {
276
- messageFlowIds.add(mf.id);
360
+ messageFlows.set(mf.id, mf);
277
361
  }
278
362
  }
279
- return { elements, flows, annotations, participants, lanes, messageFlowIds, defaultFlowIds };
363
+ return {
364
+ elements,
365
+ flows,
366
+ annotations,
367
+ participants,
368
+ lanes,
369
+ associations,
370
+ groups,
371
+ messageFlows,
372
+ defaultFlowIds,
373
+ };
280
374
  }
281
375
  // ── Color helper ─────────────────────────────────────────────────────────────
282
376
  /** Applies bioc/color namespace attributes as inline style on a shape body element. */
@@ -326,14 +420,16 @@ function renderEvent(shape, el, instanceId) {
326
420
  });
327
421
  g.appendChild(inner);
328
422
  }
329
- // Event definition marker
330
- const eventDef = el && "eventDefinitions" in el && el.eventDefinitions.length > 0
331
- ? el.eventDefinitions[0]
332
- : undefined;
333
- if (eventDef) {
423
+ // Event definition marker(s)
424
+ const eventDefs = el && "eventDefinitions" in el ? el.eventDefinitions : [];
425
+ if (eventDefs.length > 0) {
334
426
  const markerG = svgEl("g");
335
427
  attr(markerG, { transform: `translate(${cx} ${cy})` });
336
- markerG.innerHTML = eventMarker(eventDef.type, isThrow && !isEnd);
428
+ // Multiple event definitions render as a single "multiple" pentagon.
429
+ markerG.innerHTML =
430
+ eventDefs.length > 1
431
+ ? multipleEventMarker(isThrow && !isEnd)
432
+ : eventMarker(eventDefs[0].type, isThrow && !isEnd);
337
433
  g.appendChild(markerG);
338
434
  }
339
435
  // Accessibility
@@ -348,7 +444,7 @@ function renderEvent(shape, el, instanceId) {
348
444
  });
349
445
  return g;
350
446
  }
351
- function renderTask(shape, el, instanceId) {
447
+ function renderTask(shape, el, instanceId, drillable = false) {
352
448
  const { width, height } = shape.bounds;
353
449
  const g = svgEl("g");
354
450
  // Body
@@ -398,17 +494,33 @@ function renderTask(shape, el, instanceId) {
398
494
  const labelEl = makeLabel(el.name, width / 2, height / 2, labelMaxW);
399
495
  g.appendChild(labelEl);
400
496
  }
401
- // Sub-process expand/adHoc marker at bottom centre
402
- if (el?.type === "subProcess" ||
403
- el?.type === "adHocSubProcess" ||
404
- el?.type === "eventSubProcess" ||
405
- el?.type === "transaction") {
406
- const lc = "loopCharacteristics" in el ? el.loopCharacteristics : undefined;
407
- const multiInstance = lc ? (lc.isSequential ? "sequential" : "parallel") : undefined;
408
- const markerG = svgEl("g");
409
- attr(markerG, { transform: `translate(${width / 2} ${height - 10})` });
410
- markerG.innerHTML = subProcessMarker(el.type, multiInstance);
411
- g.appendChild(markerG);
497
+ // Activity markers (multi-instance, compensation, ad-hoc, collapsed `+`)
498
+ // at bottom centre. Drillable sub-processes render the collapsed `+` via the
499
+ // interactive drill-down button below, so it is skipped here.
500
+ if (el) {
501
+ const markers = activityMarkers(el, shape, drillable);
502
+ if (markers) {
503
+ const markerG = svgEl("g");
504
+ attr(markerG, { transform: `translate(${width / 2} ${height - 10})` });
505
+ markerG.innerHTML = markers;
506
+ g.appendChild(markerG);
507
+ }
508
+ }
509
+ // Drill-down affordance: a clickable `+` button at bottom centre that opens
510
+ // the sub-process's own plane.
511
+ if (drillable) {
512
+ const btn = svgEl("g");
513
+ attr(btn, {
514
+ class: "bpmnkit-drilldown",
515
+ transform: `translate(${width / 2} ${height - 10})`,
516
+ "data-bpmnkit-drilldown": shape.bpmnElement,
517
+ "aria-label": "Open sub-process",
518
+ role: "button",
519
+ });
520
+ btn.innerHTML =
521
+ `<rect x="-8" y="-8" width="16" height="16" rx="2" class="bpmnkit-drilldown-box"/>` +
522
+ `<path d="M0 -4v8M-4 0h8" class="bpmnkit-icon"/>`;
523
+ g.appendChild(btn);
412
524
  }
413
525
  const label = el?.name ?? el?.type ?? "task";
414
526
  attr(g, {
@@ -419,6 +531,16 @@ function renderTask(shape, el, instanceId) {
419
531
  "data-bpmnkit-id": shape.bpmnElement,
420
532
  "data-bpmnkit-instance": instanceId,
421
533
  });
534
+ // Expandable containers announce their collapsed/expanded state to AT.
535
+ const type = el?.type;
536
+ const isContainer = type === "subProcess" ||
537
+ type === "adHocSubProcess" ||
538
+ type === "eventSubProcess" ||
539
+ type === "transaction" ||
540
+ type === "callActivity";
541
+ if (isContainer) {
542
+ g.setAttribute("aria-expanded", type === "callActivity" ? "false" : String(shape.isExpanded === true));
543
+ }
422
544
  return g;
423
545
  }
424
546
  function renderGateway(shape, el, instanceId) {
@@ -453,67 +575,50 @@ function renderGateway(shape, el, instanceId) {
453
575
  });
454
576
  return g;
455
577
  }
456
- function renderPool(shape, participant, instanceId) {
578
+ const TITLE_BAR = 30;
579
+ /**
580
+ * Renders a swimlane (pool or lane). The title bar sits on the left with
581
+ * rotated text for horizontal lanes (the default), or across the top with
582
+ * upright text when `shape.isHorizontal === false` (vertical pools/lanes).
583
+ */
584
+ function renderSwimlane(shape, name, kind, instanceId) {
457
585
  const { width, height } = shape.bounds;
586
+ const vertical = shape.isHorizontal === false;
458
587
  const g = svgEl("g");
459
- // Pool body
460
588
  const bg = svgEl("rect");
461
- attr(bg, { x: 0, y: 0, width, height, class: "bpmnkit-pool-body" });
589
+ attr(bg, { x: 0, y: 0, width, height, class: `bpmnkit-${kind}-body` });
462
590
  g.appendChild(bg);
463
- // Title bar (left column, 30px wide)
464
591
  const titleBar = svgEl("rect");
465
- attr(titleBar, { x: 0, y: 0, width: 30, height, class: "bpmnkit-pool-header" });
592
+ attr(titleBar, vertical
593
+ ? { x: 0, y: 0, width, height: TITLE_BAR, class: `bpmnkit-${kind}-header` }
594
+ : { x: 0, y: 0, width: TITLE_BAR, height, class: `bpmnkit-${kind}-header` });
466
595
  g.appendChild(titleBar);
467
- // Pool name (rotated in title bar)
468
- if (participant?.name) {
596
+ if (name) {
469
597
  const text = svgEl("text");
470
- attr(text, {
471
- class: "bpmnkit-label",
472
- transform: `translate(15 ${height / 2}) rotate(-90)`,
473
- });
474
- text.textContent = participant.name;
598
+ attr(text, vertical
599
+ ? { class: "bpmnkit-label", x: width / 2, y: TITLE_BAR / 2 }
600
+ : {
601
+ class: "bpmnkit-label",
602
+ transform: `translate(${TITLE_BAR / 2} ${height / 2}) rotate(-90)`,
603
+ });
604
+ text.textContent = name;
475
605
  g.appendChild(text);
476
606
  }
477
607
  attr(g, {
478
- class: "bpmnkit-shape bpmnkit-pool",
608
+ class: `bpmnkit-shape bpmnkit-${kind}`,
479
609
  tabindex: "-1",
480
610
  role: "region",
481
- "aria-label": participant?.name ?? "Pool",
611
+ "aria-label": name ?? (kind === "pool" ? "Pool" : "Lane"),
482
612
  "data-bpmnkit-id": shape.bpmnElement,
483
613
  "data-bpmnkit-instance": instanceId,
484
614
  });
485
615
  return g;
486
616
  }
617
+ function renderPool(shape, participant, instanceId) {
618
+ return renderSwimlane(shape, participant?.name, "pool", instanceId);
619
+ }
487
620
  function renderLane(shape, lane, instanceId) {
488
- const { width, height } = shape.bounds;
489
- const g = svgEl("g");
490
- // Lane body
491
- const bg = svgEl("rect");
492
- attr(bg, { x: 0, y: 0, width, height, class: "bpmnkit-lane-body" });
493
- g.appendChild(bg);
494
- // Title bar (left column, 30px wide)
495
- const titleBar = svgEl("rect");
496
- attr(titleBar, { x: 0, y: 0, width: 30, height, class: "bpmnkit-lane-header" });
497
- g.appendChild(titleBar);
498
- // Lane name (rotated in title bar)
499
- if (lane?.name) {
500
- const text = svgEl("text");
501
- attr(text, {
502
- class: "bpmnkit-label",
503
- transform: `translate(15 ${height / 2}) rotate(-90)`,
504
- });
505
- text.textContent = lane.name;
506
- g.appendChild(text);
507
- }
508
- attr(g, {
509
- class: "bpmnkit-shape bpmnkit-lane",
510
- tabindex: "-1",
511
- role: "region",
512
- "aria-label": lane?.name ?? "Lane",
513
- "data-bpmnkit-id": shape.bpmnElement,
514
- "data-bpmnkit-instance": instanceId,
515
- });
516
- return g;
621
+ return renderSwimlane(shape, lane?.name, "lane", instanceId);
517
622
  }
518
623
  function renderAnnotation(shape, text, instanceId) {
519
624
  const { width, height } = shape.bounds;
@@ -543,6 +648,96 @@ function renderAnnotation(shape, text, instanceId) {
543
648
  });
544
649
  return g;
545
650
  }
651
+ function renderDataObjectReference(shape, el, instanceId) {
652
+ const { width, height } = shape.bounds;
653
+ const fold = Math.min(14, width / 3);
654
+ const g = svgEl("g");
655
+ // Document outline with a folded top-right corner.
656
+ const body = svgEl("path");
657
+ attr(body, {
658
+ d: `M0 0 H${width - fold} L${width} ${fold} V${height} H0 Z`,
659
+ class: "bpmnkit-data-body",
660
+ });
661
+ applyColor(body, shape);
662
+ g.appendChild(body);
663
+ const foldPath = svgEl("path");
664
+ attr(foldPath, { d: `M${width - fold} 0 V${fold} H${width}`, class: "bpmnkit-icon" });
665
+ g.appendChild(foldPath);
666
+ // Collection marker (three vertical bars) at bottom centre.
667
+ if (el?.type === "dataObjectReference" && el.isCollection) {
668
+ const marker = svgEl("g");
669
+ attr(marker, { transform: `translate(${width / 2} ${height - 6})` });
670
+ marker.innerHTML = `<path d="M-3 -5v9M0 -5v9M3 -5v9" class="bpmnkit-icon"/>`;
671
+ g.appendChild(marker);
672
+ }
673
+ attr(g, {
674
+ class: "bpmnkit-shape",
675
+ tabindex: "-1",
676
+ role: "img",
677
+ "aria-label": el?.name ?? "Data object",
678
+ "data-bpmnkit-id": shape.bpmnElement,
679
+ "data-bpmnkit-instance": instanceId,
680
+ });
681
+ return g;
682
+ }
683
+ function renderDataStoreReference(shape, el, instanceId) {
684
+ const { width, height } = shape.bounds;
685
+ const ry = Math.min(8, height / 6);
686
+ const g = svgEl("g");
687
+ // Cylinder body.
688
+ const body = svgEl("path");
689
+ attr(body, {
690
+ d: `M0 ${ry} A ${width / 2} ${ry} 0 0 0 ${width} ${ry} V ${height - ry} A ${width / 2} ${ry} 0 0 1 0 ${height - ry} Z`,
691
+ class: "bpmnkit-datastore-body",
692
+ });
693
+ applyColor(body, shape);
694
+ g.appendChild(body);
695
+ // Top ellipse + a couple of stacked-disk arcs.
696
+ const top = svgEl("ellipse");
697
+ attr(top, { cx: width / 2, cy: ry, rx: width / 2, ry, class: "bpmnkit-datastore-body" });
698
+ applyColor(top, shape);
699
+ g.appendChild(top);
700
+ const disks = svgEl("path");
701
+ attr(disks, {
702
+ d: `M0 ${ry * 2} A ${width / 2} ${ry} 0 0 0 ${width} ${ry * 2} M0 ${ry * 3.4} A ${width / 2} ${ry} 0 0 0 ${width} ${ry * 3.4}`,
703
+ class: "bpmnkit-icon",
704
+ });
705
+ g.appendChild(disks);
706
+ attr(g, {
707
+ class: "bpmnkit-shape",
708
+ tabindex: "-1",
709
+ role: "img",
710
+ "aria-label": el?.name ?? "Data store",
711
+ "data-bpmnkit-id": shape.bpmnElement,
712
+ "data-bpmnkit-instance": instanceId,
713
+ });
714
+ return g;
715
+ }
716
+ function renderGroup(shape, instanceId) {
717
+ const { width, height } = shape.bounds;
718
+ const g = svgEl("g");
719
+ // Dashed rounded rectangle; border-only hit target so the interior is
720
+ // click-through (elements inside a group stay selectable).
721
+ const body = svgEl("rect");
722
+ attr(body, {
723
+ x: 0,
724
+ y: 0,
725
+ width,
726
+ height,
727
+ rx: 8,
728
+ class: "bpmnkit-group-body",
729
+ "pointer-events": "stroke",
730
+ });
731
+ g.appendChild(body);
732
+ attr(g, {
733
+ class: "bpmnkit-shape bpmnkit-group",
734
+ tabindex: "-1",
735
+ role: "group",
736
+ "data-bpmnkit-id": shape.bpmnElement,
737
+ "data-bpmnkit-instance": instanceId,
738
+ });
739
+ return g;
740
+ }
546
741
  // ── External label ────────────────────────────────────────────────────────────
547
742
  /**
548
743
  * Renders an external label at absolute diagram coordinates.
@@ -558,25 +753,31 @@ function renderExternalLabel(absX, absY, labelW, labelH, text, topAlign = false)
558
753
  return g;
559
754
  }
560
755
  // ── Edge renderer ─────────────────────────────────────────────────────────────
561
- function renderEdge(edge, flow, markerId, isDefault) {
756
+ function renderEdge(edge, flow, instanceId, isDefault, isConditional, waypoints) {
757
+ const ids = markerIds(instanceId);
562
758
  const g = svgEl("g");
563
759
  attr(g, {
564
760
  class: "bpmnkit-edge",
565
761
  "data-bpmnkit-id": edge.bpmnElement,
566
762
  });
567
- if (edge.waypoints.length < 2)
763
+ if (waypoints.length < 2)
568
764
  return g;
569
765
  const path = svgEl("path");
570
- attr(path, {
571
- d: waypointsToRoundedPath(edge.waypoints),
766
+ const pathAttrs = {
767
+ d: waypointsToRoundedPath(waypoints),
572
768
  class: "bpmnkit-edge-path",
573
- "marker-end": `url(#${markerId})`,
574
- });
769
+ "marker-end": `url(#${ids.arrow})`,
770
+ };
771
+ // Conditional sequence flow → diamond at the source (mutually exclusive
772
+ // with the default-flow slash: a flow is never both).
773
+ if (isConditional)
774
+ pathAttrs["marker-start"] = `url(#${ids.conditional})`;
775
+ attr(path, pathAttrs);
575
776
  g.appendChild(path);
576
777
  // Wide transparent stroke so the edge is easy to click
577
778
  const hitPath = svgEl("path");
578
779
  attr(hitPath, {
579
- d: waypointsToRoundedPath(edge.waypoints),
780
+ d: waypointsToRoundedPath(waypoints),
580
781
  fill: "none",
581
782
  stroke: "transparent",
582
783
  "stroke-width": "12",
@@ -585,8 +786,8 @@ function renderEdge(edge, flow, markerId, isDefault) {
585
786
  g.appendChild(hitPath);
586
787
  // Default-flow slash mark near the source end
587
788
  if (isDefault) {
588
- const wp0 = edge.waypoints[0];
589
- const wp1 = edge.waypoints[1];
789
+ const wp0 = waypoints[0];
790
+ const wp1 = waypoints[1];
590
791
  if (wp0 && wp1) {
591
792
  const dx = wp1.x - wp0.x;
592
793
  const dy = wp1.y - wp0.y;
@@ -618,31 +819,67 @@ function renderEdge(edge, flow, markerId, isDefault) {
618
819
  }
619
820
  return g;
620
821
  }
621
- function renderAssociation(edge) {
822
+ function renderAssociation(edge, association, instanceId, waypoints) {
823
+ const ids = markerIds(instanceId);
622
824
  const g = svgEl("g");
623
825
  attr(g, { class: "bpmnkit-edge", "data-bpmnkit-id": edge.bpmnElement });
624
826
  const path = svgEl("path");
625
- attr(path, { d: waypointsToRoundedPath(edge.waypoints), class: "bpmnkit-edge-assoc" });
827
+ const pathAttrs = {
828
+ d: waypointsToRoundedPath(waypoints),
829
+ class: "bpmnkit-edge-assoc",
830
+ };
831
+ // Directed associations get an open arrowhead; "Both" is bidirectional.
832
+ const direction = association?.associationDirection;
833
+ if (direction === "One" || direction === "Both") {
834
+ pathAttrs["marker-end"] = `url(#${ids.openArrow})`;
835
+ }
836
+ if (direction === "Both") {
837
+ pathAttrs["marker-start"] = `url(#${ids.openArrow})`;
838
+ }
839
+ attr(path, pathAttrs);
626
840
  g.appendChild(path);
627
841
  return g;
628
842
  }
629
- // ── SVG defs (arrow markers) ──────────────────────────────────────────────────
843
+ /** Derives this canvas instance's per-marker element IDs. */
844
+ function markerIds(instanceId) {
845
+ return {
846
+ arrow: `bpmnkit-arrow-${instanceId}`,
847
+ openArrow: `bpmnkit-open-arrow-${instanceId}`,
848
+ conditional: `bpmnkit-conditional-${instanceId}`,
849
+ messageStart: `bpmnkit-msgstart-${instanceId}`,
850
+ };
851
+ }
630
852
  /**
631
853
  * Creates the SVG `<defs>` section for this canvas instance.
632
854
  * Uses `instanceId` to make marker IDs unique per canvas, avoiding
633
855
  * conflicts when multiple canvases are mounted on the same page.
856
+ *
857
+ * @returns the filled-arrowhead marker ID (sequence-flow terminus). Other
858
+ * marker IDs are derived from `instanceId` via {@link markerIds}.
634
859
  */
635
860
  export function createDefs(svg, instanceId) {
636
- const markerId = `bpmnkit-arrow-${instanceId}`;
861
+ const ids = markerIds(instanceId);
637
862
  const defs = svgEl("defs");
638
863
  defs.innerHTML = `
639
- <marker id="${markerId}" markerWidth="8" markerHeight="6"
864
+ <marker id="${ids.arrow}" markerWidth="8" markerHeight="6"
640
865
  refX="7" refY="3" orient="auto" markerUnits="strokeWidth">
641
866
  <path d="M0,0 L8,3 L0,6 Z" class="bpmnkit-arrow-fill"/>
642
867
  </marker>
868
+ <marker id="${ids.openArrow}" markerWidth="14" markerHeight="10"
869
+ refX="10" refY="5" orient="auto" markerUnits="userSpaceOnUse">
870
+ <path d="M1,1 L10,5 L1,9" class="bpmnkit-open-arrow"/>
871
+ </marker>
872
+ <marker id="${ids.conditional}" markerWidth="16" markerHeight="10"
873
+ refX="0" refY="5" orient="auto" markerUnits="userSpaceOnUse">
874
+ <path d="M0,5 L7,1 L14,5 L7,9 Z" class="bpmnkit-conditional-marker"/>
875
+ </marker>
876
+ <marker id="${ids.messageStart}" markerWidth="12" markerHeight="10"
877
+ refX="0" refY="5" orient="auto" markerUnits="userSpaceOnUse">
878
+ <circle cx="5" cy="5" r="4" class="bpmnkit-msg-marker"/>
879
+ </marker>
643
880
  `;
644
881
  svg.appendChild(defs);
645
- return markerId;
882
+ return ids.arrow;
646
883
  }
647
884
  // ── Dot-grid background ───────────────────────────────────────────────────────
648
885
  /**
@@ -679,147 +916,186 @@ export function createGrid(svg, instanceId) {
679
916
  svg.appendChild(bg);
680
917
  return pattern;
681
918
  }
919
+ /** Builds a {@link RenderContext} for a plane (index + docking geometry). */
920
+ export function buildRenderContext(defs, plane, drillableIds, instanceId) {
921
+ const index = buildIndex(defs);
922
+ const geomById = new Map();
923
+ for (const shape of plane.shapes) {
924
+ const { x, y, width, height } = shape.bounds;
925
+ geomById.set(shape.bpmnElement, {
926
+ x,
927
+ y,
928
+ width,
929
+ height,
930
+ kind: geomKind(index.elements.get(shape.bpmnElement)?.type),
931
+ });
932
+ }
933
+ return { index, geomById, drillableIds, instanceId };
934
+ }
935
+ /** Renders a single edge's `<g>` (no DOM insertion). */
936
+ export function renderEdgeGroup(edge, ctx) {
937
+ const { index, geomById, instanceId } = ctx;
938
+ const flow = index.flows.get(edge.bpmnElement);
939
+ if (flow) {
940
+ const isDefault = index.defaultFlowIds.has(edge.bpmnElement);
941
+ // A conditional flow shows a diamond at its source, but only when the
942
+ // source is an activity (not a gateway) and it is not the default flow.
943
+ const source = index.elements.get(flow.sourceRef);
944
+ const sourceIsGateway = source?.type.endsWith("Gateway") ?? false;
945
+ const isConditional = !!flow.conditionExpression && !isDefault && !sourceIsGateway;
946
+ const wps = cropWaypoints(edge.waypoints, geomById.get(flow.sourceRef), geomById.get(flow.targetRef));
947
+ return renderEdge(edge, flow, instanceId, isDefault, isConditional, wps);
948
+ }
949
+ if (index.messageFlows.has(edge.bpmnElement)) {
950
+ // Message flow — dashed line with a hollow source circle and an open
951
+ // arrowhead at the target.
952
+ const g = svgEl("g");
953
+ attr(g, { class: "bpmnkit-edge", "data-bpmnkit-id": edge.bpmnElement });
954
+ if (edge.waypoints.length >= 2) {
955
+ const mf = index.messageFlows.get(edge.bpmnElement);
956
+ const wps = cropWaypoints(edge.waypoints, mf ? geomById.get(mf.sourceRef) : undefined, mf ? geomById.get(mf.targetRef) : undefined);
957
+ const ids = markerIds(instanceId);
958
+ const path = svgEl("path");
959
+ attr(path, {
960
+ d: waypointsToRoundedPath(wps),
961
+ class: "bpmnkit-msgflow-path",
962
+ "marker-start": `url(#${ids.messageStart})`,
963
+ "marker-end": `url(#${ids.openArrow})`,
964
+ });
965
+ g.appendChild(path);
966
+ }
967
+ return g;
968
+ }
969
+ // Association or unknown edge type
970
+ const assoc = index.associations.get(edge.bpmnElement);
971
+ const wps = cropWaypoints(edge.waypoints, assoc ? geomById.get(assoc.sourceRef) : undefined, assoc ? geomById.get(assoc.targetRef) : undefined);
972
+ return renderAssociation(edge, assoc, instanceId, wps);
973
+ }
974
+ /** Renders a single shape's `<g>` (+ optional external label), without DOM insertion. */
975
+ export function renderShapeGroup(shape, ctx) {
976
+ const { index, drillableIds, instanceId } = ctx;
977
+ const el = index.elements.get(shape.bpmnElement);
978
+ const { x, y } = shape.bounds;
979
+ const type = el?.type ?? "";
980
+ const place = (group, layer, annotation) => {
981
+ attr(group, { transform: `translate(${x} ${y})` });
982
+ return {
983
+ group,
984
+ layer,
985
+ label: null,
986
+ rendered: { id: shape.bpmnElement, element: group, shape, flowElement: el, annotation },
987
+ };
988
+ };
989
+ if (type === "startEvent" ||
990
+ type === "endEvent" ||
991
+ type === "intermediateCatchEvent" ||
992
+ type === "intermediateThrowEvent" ||
993
+ type === "boundaryEvent") {
994
+ return withExternalLabel(place(renderEvent(shape, el, instanceId), "shapes"), shape, el);
995
+ }
996
+ if (type === "exclusiveGateway" ||
997
+ type === "parallelGateway" ||
998
+ type === "inclusiveGateway" ||
999
+ type === "eventBasedGateway" ||
1000
+ type === "complexGateway") {
1001
+ return withExternalLabel(place(renderGateway(shape, el, instanceId), "shapes"), shape, el);
1002
+ }
1003
+ if (type === "dataObjectReference") {
1004
+ return withExternalLabel(place(renderDataObjectReference(shape, el, instanceId), "shapes"), shape, el);
1005
+ }
1006
+ if (type === "dataStoreReference") {
1007
+ return withExternalLabel(place(renderDataStoreReference(shape, el, instanceId), "shapes"), shape, el);
1008
+ }
1009
+ if (type === "" && !el) {
1010
+ if (index.groups.has(shape.bpmnElement))
1011
+ return place(renderGroup(shape, instanceId), "containers");
1012
+ const annotation = index.annotations.get(shape.bpmnElement);
1013
+ if (annotation !== undefined) {
1014
+ return place(renderAnnotation(shape, annotation.text, instanceId), "shapes", annotation);
1015
+ }
1016
+ if (index.participants.has(shape.bpmnElement)) {
1017
+ return place(renderPool(shape, index.participants.get(shape.bpmnElement), instanceId), "containers");
1018
+ }
1019
+ if (index.lanes.has(shape.bpmnElement)) {
1020
+ return place(renderLane(shape, index.lanes.get(shape.bpmnElement), instanceId), "containers");
1021
+ }
1022
+ // Unknown shape — invisible placeholder
1023
+ const g = svgEl("g");
1024
+ attr(g, { "data-bpmnkit-id": shape.bpmnElement, "data-bpmnkit-instance": instanceId });
1025
+ return place(g, "shapes");
1026
+ }
1027
+ return withExternalLabel(place(renderTask(shape, el, instanceId, drillableIds.has(shape.bpmnElement)), "shapes"), shape, el);
1028
+ }
1029
+ const EXTERNAL_LABEL_TYPES = new Set([
1030
+ "startEvent",
1031
+ "endEvent",
1032
+ "intermediateCatchEvent",
1033
+ "intermediateThrowEvent",
1034
+ "boundaryEvent",
1035
+ "exclusiveGateway",
1036
+ "parallelGateway",
1037
+ "inclusiveGateway",
1038
+ "eventBasedGateway",
1039
+ "complexGateway",
1040
+ "dataObjectReference",
1041
+ "dataStoreReference",
1042
+ ]);
1043
+ /** Attaches an external label (below the shape) to a rendered shape group, if applicable. */
1044
+ function withExternalLabel(result, shape, el) {
1045
+ if (el?.name && EXTERNAL_LABEL_TYPES.has(el.type)) {
1046
+ const lb = shape.label?.bounds ?? {
1047
+ x: shape.bounds.x + shape.bounds.width / 2 - 40,
1048
+ y: shape.bounds.y + shape.bounds.height + 6,
1049
+ width: 80,
1050
+ height: 20,
1051
+ };
1052
+ // topAlign=true: multi-line text flows downward from the top of the label
1053
+ // bounds, so long labels never extend upward into the shape.
1054
+ result.label = renderExternalLabel(lb.x, lb.y, lb.width, lb.height, el.name, true);
1055
+ }
1056
+ return result;
1057
+ }
682
1058
  /**
683
- * Renders a `BpmnDefinitions` model into SVG element groups, appending them
684
- * to `edgesLayer` and `shapesLayer` respectively.
1059
+ * Renders a `BpmnDefinitions` model into SVG element groups, appending them to
1060
+ * the provided layers. Thin wrapper over {@link buildRenderContext} +
1061
+ * {@link renderEdgeGroup}/{@link renderShapeGroup}, retained for existing
1062
+ * callers (the editor). New code should prefer the {@link Scene} class.
685
1063
  *
686
1064
  * Edges are placed below shapes (rendered first) so connection lines don't
687
- * cover shape bodies. Shapes are rendered in DI order so container shapes
1065
+ * cover shape bodies. Shapes are rendered in DI order so container shapes
688
1066
  * (sub-processes) appear before their children.
689
1067
  */
690
- export function render(defs, containersLayer, edgesLayer, shapesLayer, labelsLayer, markerId, instanceId) {
691
- const index = buildIndex(defs);
1068
+ export function render(defs, containersLayer, edgesLayer, shapesLayer, labelsLayer, _markerId, instanceId,
1069
+ /** The DI plane to render. Defaults to the first diagram's plane. */
1070
+ targetPlane,
1071
+ /** Element ids that own their own plane and can be drilled into. */
1072
+ drillableIds = new Set()) {
692
1073
  const shapes = [];
693
1074
  const edges = [];
694
- const plane = defs.diagrams[0]?.plane;
1075
+ const plane = targetPlane ?? defs.diagrams[0]?.plane;
695
1076
  if (!plane)
696
1077
  return { shapes, edges };
697
- // ── Edges ─────────────────────────────────────────────────────────
1078
+ const ctx = buildRenderContext(defs, plane, drillableIds, instanceId);
698
1079
  for (const edge of plane.edges) {
699
- const flow = index.flows.get(edge.bpmnElement);
700
- let g;
701
- if (flow) {
702
- g = renderEdge(edge, flow, markerId, index.defaultFlowIds.has(edge.bpmnElement));
703
- }
704
- else if (index.messageFlowIds.has(edge.bpmnElement)) {
705
- // Message flow — dashed arrow between pools
706
- g = svgEl("g");
707
- attr(g, { class: "bpmnkit-edge", "data-bpmnkit-id": edge.bpmnElement });
708
- if (edge.waypoints.length >= 2) {
709
- const path = svgEl("path");
710
- attr(path, {
711
- d: waypointsToRoundedPath(edge.waypoints),
712
- class: "bpmnkit-msgflow-path",
713
- "marker-end": `url(#${markerId})`,
714
- });
715
- g.appendChild(path);
716
- }
717
- }
718
- else {
719
- // Association or unknown edge type
720
- g = renderAssociation(edge);
721
- }
1080
+ const g = renderEdgeGroup(edge, ctx);
722
1081
  edgesLayer.appendChild(g);
723
1082
  edges.push({ id: edge.bpmnElement, element: g, edge });
724
1083
  }
725
- // ── Shapes ────────────────────────────────────────────────────────
726
1084
  for (const shape of plane.shapes) {
727
- const el = index.elements.get(shape.bpmnElement);
728
- const { x, y } = shape.bounds;
729
- let g;
730
- const type = el?.type ?? "";
731
- if (type === "startEvent" ||
732
- type === "endEvent" ||
733
- type === "intermediateCatchEvent" ||
734
- type === "intermediateThrowEvent" ||
735
- type === "boundaryEvent") {
736
- g = renderEvent(shape, el, instanceId);
737
- }
738
- else if (type === "exclusiveGateway" ||
739
- type === "parallelGateway" ||
740
- type === "inclusiveGateway" ||
741
- type === "eventBasedGateway" ||
742
- type === "complexGateway") {
743
- g = renderGateway(shape, el, instanceId);
744
- }
745
- else if (type === "" && !el) {
746
- // Could be: text annotation, pool (participant), or lane
747
- const annotation = index.annotations.get(shape.bpmnElement);
748
- if (annotation !== undefined) {
749
- g = renderAnnotation(shape, annotation.text, instanceId);
750
- attr(g, { transform: `translate(${x} ${y})` });
751
- shapesLayer.appendChild(g);
752
- shapes.push({ id: shape.bpmnElement, element: g, shape, flowElement: el, annotation });
753
- continue;
754
- }
755
- if (index.participants.has(shape.bpmnElement)) {
756
- g = renderPool(shape, index.participants.get(shape.bpmnElement), instanceId);
757
- attr(g, { transform: `translate(${x} ${y})` });
758
- containersLayer.appendChild(g);
759
- shapes.push({ id: shape.bpmnElement, element: g, shape, flowElement: el });
760
- continue;
761
- }
762
- if (index.lanes.has(shape.bpmnElement)) {
763
- g = renderLane(shape, index.lanes.get(shape.bpmnElement), instanceId);
764
- attr(g, { transform: `translate(${x} ${y})` });
765
- containersLayer.appendChild(g);
766
- shapes.push({ id: shape.bpmnElement, element: g, shape, flowElement: el });
767
- continue;
768
- }
769
- // Unknown shape — invisible placeholder
770
- g = svgEl("g");
771
- attr(g, { "data-bpmnkit-id": shape.bpmnElement, "data-bpmnkit-instance": instanceId });
772
- attr(g, { transform: `translate(${x} ${y})` });
773
- shapesLayer.appendChild(g);
774
- shapes.push({ id: shape.bpmnElement, element: g, shape, flowElement: el });
775
- continue;
776
- }
777
- else {
778
- g = renderTask(shape, el, instanceId);
779
- }
780
- attr(g, { transform: `translate(${x} ${y})` });
781
- shapesLayer.appendChild(g);
782
- shapes.push({ id: shape.bpmnElement, element: g, shape, flowElement: el });
783
- // External labels for events and gateways — use stored bounds or default to bottom-centred
784
- const isExternalLabelType = type === "startEvent" ||
785
- type === "endEvent" ||
786
- type === "intermediateCatchEvent" ||
787
- type === "intermediateThrowEvent" ||
788
- type === "boundaryEvent" ||
789
- type === "exclusiveGateway" ||
790
- type === "parallelGateway" ||
791
- type === "inclusiveGateway" ||
792
- type === "eventBasedGateway" ||
793
- type === "complexGateway";
794
- if (el?.name && isExternalLabelType) {
795
- const lb = shape.label?.bounds ?? {
796
- x: shape.bounds.x + shape.bounds.width / 2 - 40,
797
- y: shape.bounds.y + shape.bounds.height + 6,
798
- width: 80,
799
- height: 20,
800
- };
801
- // topAlign=true: multi-line text flows downward from the top of the
802
- // label bounds, so long labels never extend upward into the shape.
803
- const labelG = renderExternalLabel(lb.x, lb.y, lb.width, lb.height, el.name, true);
804
- labelsLayer.appendChild(labelG);
805
- }
806
- }
807
- // Edge labels at their absolute label bounds
808
- for (const edge of plane.edges) {
809
- const flow = index.flows.get(edge.bpmnElement);
810
- if (flow?.name && edge.label?.bounds) {
811
- // Already rendered inside the edge group — skip duplicate
812
- // (renderEdge adds the label when label.bounds is present)
813
- }
1085
+ const { group, layer, label, rendered } = renderShapeGroup(shape, ctx);
1086
+ (layer === "containers" ? containersLayer : shapesLayer).appendChild(group);
1087
+ if (label)
1088
+ labelsLayer.appendChild(label);
1089
+ shapes.push(rendered);
814
1090
  }
815
1091
  return { shapes, edges };
816
1092
  }
817
1093
  /**
818
- * Computes the bounding box of all shapes in the first DI diagram plane.
819
- * Returns `null` if the diagram has no shapes.
1094
+ * Computes the bounding box of all shapes in a DI plane (the first diagram's
1095
+ * plane by default). Returns `null` if the plane has no shapes.
820
1096
  */
821
- export function computeDiagramBounds(defs) {
822
- const plane = defs.diagrams[0]?.plane;
1097
+ export function computeDiagramBounds(defs, targetPlane) {
1098
+ const plane = targetPlane ?? defs.diagrams[0]?.plane;
823
1099
  if (!plane || plane.shapes.length === 0)
824
1100
  return null;
825
1101
  let minX = Number.POSITIVE_INFINITY;