@formicoidea/labre-framework-bpmn 0.32.0 → 0.34.1

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.
Files changed (49) hide show
  1. package/dist/actions.d.ts +202 -6
  2. package/dist/actions.js +427 -43
  3. package/dist/background.d.ts +2 -0
  4. package/dist/background.js +158 -0
  5. package/dist/commands-manifest.d.ts +18 -0
  6. package/dist/commands-manifest.js +226 -0
  7. package/dist/commands.js +496 -5
  8. package/dist/consts.d.ts +195 -4
  9. package/dist/consts.js +230 -4
  10. package/dist/element-renderer.d.ts +10 -4
  11. package/dist/element-renderer.js +14 -55
  12. package/dist/element-view.d.ts +119 -8
  13. package/dist/element-view.js +274 -30
  14. package/dist/export.d.ts +277 -0
  15. package/dist/export.js +1802 -0
  16. package/dist/facts.d.ts +48 -0
  17. package/dist/facts.js +127 -0
  18. package/dist/import.d.ts +69 -0
  19. package/dist/import.js +1476 -0
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.js +47 -0
  22. package/dist/interchange.d.ts +109 -0
  23. package/dist/interchange.js +191 -0
  24. package/dist/morph.d.ts +61 -0
  25. package/dist/morph.js +118 -0
  26. package/dist/node/node-renderer.d.ts +0 -9
  27. package/dist/node/node-renderer.js +294 -17
  28. package/dist/pool-hit.d.ts +98 -0
  29. package/dist/pool-hit.js +130 -0
  30. package/dist/presets.d.ts +168 -0
  31. package/dist/presets.js +327 -0
  32. package/dist/profiles.d.ts +2 -0
  33. package/dist/profiles.js +189 -0
  34. package/dist/roles.d.ts +96 -0
  35. package/dist/roles.js +410 -0
  36. package/dist/rules.d.ts +199 -0
  37. package/dist/rules.js +1539 -0
  38. package/dist/templates/index.js +116 -9
  39. package/dist/toolbar/bpmn-senior-button.js +8 -2
  40. package/dist/toolbar/config.d.ts +28 -2
  41. package/dist/toolbar/config.js +93 -4
  42. package/dist/toolbar/icons.d.ts +67 -0
  43. package/dist/toolbar/icons.js +141 -0
  44. package/dist/toolbar/senior-tool.js +1 -0
  45. package/dist/translations.d.ts +3 -1
  46. package/dist/translations.js +38 -3
  47. package/dist/view.d.ts +6 -2
  48. package/dist/view.js +68 -5
  49. package/package.json +6 -2
@@ -1,15 +1,70 @@
1
1
  import { ElementRendererExtension, } from '@formicoidea/labre-core/blocks/surface';
2
2
  import { shape as shapeRenderer } from '@formicoidea/labre-core/gfx/shape';
3
- import { DefaultTheme } from '@formicoidea/labre-core/model';
3
+ import { DefaultTheme, } from '@formicoidea/labre-core/model';
4
4
  /**
5
- * Renderer for a BPMN flow-object node. The shape body (ellipse / rounded rect
6
- * / diamond) is drawn by REUSING the native shape renderer — so stroke width,
7
- * colors, inner text and theme behave exactly like a native shape. Only
8
- * `gatewayExclusive` is decorated: an X drawn on top in the node's (editable)
9
- * stroke color. Events and task are plain native shapes.
5
+ * Renderer for a BPMN flow-object node.
6
+ *
7
+ * The shape body (ellipse / rounded rect / diamond) is drawn by REUSING the
8
+ * native shape renderer so stroke width, colors, inner text and theme behave
9
+ * exactly like a native shape. On top of it, this file paints the MARKERS the
10
+ * notation asks for, all of them stroke-based, scale-aware and drawn in the
11
+ * node's own (editable) stroke colour:
12
+ *
13
+ * - events — envelope (message), clock (timer), solid disc (terminate);
14
+ * - tasks — a person (user) or a gear (service) in the top-left corner;
15
+ * - activity — the `+` box at the bottom edge (sub-process, call activity);
16
+ * - gateway — the X (exclusive) or the `+` (parallel);
17
+ * - data — folded page, cylinder, open bracket.
18
+ *
19
+ * The `group` is the one artefact of the profile this file does NOT touch: its
20
+ * dashed, rounded, unfilled rectangle is entirely a native shape's own doing.
21
+ *
22
+ * The last three are different in kind from the rest: their silhouette is not a
23
+ * native shape, so the glyph draws the BODY too — fill and outline — and the
24
+ * native rect underneath is created unfilled and unstroked (see `NODE_PRESETS`
25
+ * in `actions.ts`). It is still what carries the inner text, the selection
26
+ * bounds and the connector anchors.
27
+ *
28
+ * ## Simplifications against bpmn.io, deliberately
29
+ *
30
+ * - **Message start vs message end**: the spec fills the end event's envelope
31
+ * solid and leaves the start event's hollow. Both are drawn hollow here, and
32
+ * the distinction is carried by the ring weight the two already have — thin
33
+ * green for a start, thick red for an end — which is the louder signal of the
34
+ * two and the one that is legible zoomed out.
35
+ * - The **timer** has no hour ticks and the **data store** no shelf lines: at
36
+ * the sizes this canvas draws them, both read as noise around the shape.
10
37
  *
11
38
  * Mirrors the EDGY node renderer.
12
39
  */
40
+ /**
41
+ * The artefacts BPMN draws BARE — a plain native shape with nothing on it.
42
+ * Everything else in the union is decorated here.
43
+ *
44
+ * `group` is bare for a different reason from the other three. They are
45
+ * undecorated because the notation puts no marker on them; the group has a
46
+ * distinctive look — a dashed, rounded, unfilled rectangle — and it is here
47
+ * because that look is expressible as a native shape's own properties
48
+ * (`strokeStyle: dash`, `radius`, `filled: false`). Drawing it by hand would
49
+ * have meant re-implementing dashes the shape renderer already does, and losing
50
+ * the editability that comes free with them.
51
+ *
52
+ * Written as the short list rather than the long one, so that the glyph kinds
53
+ * are DERIVED from the model's union instead of restated beside it: a kind
54
+ * added to `BpmnNodeKind` is a glyph kind by default, and the exhaustiveness
55
+ * check at the bottom of this file then refuses to compile until it is drawn.
56
+ * The alternative — a hand-maintained set of the thirteen decorated kinds — is
57
+ * the one per-kind table in this pack that could not be made compile-total, and
58
+ * a kind missing from it paints the WRONG picture rather than none.
59
+ */
60
+ const UNDECORATED_KINDS = {
61
+ startEvent: true,
62
+ endEvent: true,
63
+ task: true,
64
+ group: true,
65
+ };
66
+ const isUndecorated = (kind) => Object.hasOwn(UNDECORATED_KINDS, kind);
67
+ const TAU = Math.PI * 2;
13
68
  export const bpmnNode = (model, ctx, matrix, renderer, rc, bound) => {
14
69
  const [, , w, h] = model.deserializedXYWH;
15
70
  const cx = w / 2;
@@ -22,21 +77,243 @@ export const bpmnNode = (model, ctx, matrix, renderer, rc, bound) => {
22
77
  .translateSelf(-cx, -cy);
23
78
  // Native shape (fill / stroke / inner text / theme handled natively).
24
79
  shapeRenderer(model, ctx, matrix, renderer, rc, bound);
25
- if (model.kind !== 'gatewayExclusive')
80
+ const kind = model.kind;
81
+ if (isUndecorated(kind))
26
82
  return;
27
83
  const color = renderer.getColorValue(model.strokeColor, DefaultTheme.shapeStrokeColor, true);
28
- // ── Exclusive-gateway X, centred and sized to the diamond ───────────
29
- const r = Math.min(w, h) * 0.2;
84
+ const strokeWidth = model.strokeWidth || 1;
85
+ /**
86
+ * The smaller half-extent, the unit every glyph is sized against — and the
87
+ * floor under every radius derived from it.
88
+ *
89
+ * An element can be dragged to nothing: the resize manager takes the absolute
90
+ * value of the dragged extents but sets no minimum size. `arc` and `ellipse`
91
+ * THROW on a negative radius (`IndexSizeError`) rather than clamping, and the
92
+ * surface render loop wraps no renderer in a `try`, so one such throw aborts
93
+ * the rest of the frame with an unbalanced save stack. Clamping at the source
94
+ * covers every arc in this file at once; the two radii that do not come from
95
+ * here — the data store's, which subtract the stroke first — are clamped
96
+ * where they are computed. Same guard, same reason, as the native ellipse
97
+ * renderer's (`gfx/shape/src/element-renderer/shape/ellipse.ts`), the one
98
+ * native shape that draws a real ellipse and the one that already does this.
99
+ */
100
+ const unit = Math.max(0, Math.min(w, h));
30
101
  ctx.setTransform(glyphMatrix);
31
- ctx.translate(cx, cy);
32
102
  ctx.strokeStyle = color;
33
- ctx.lineWidth = Math.max(2, Math.min(w, h) * 0.06);
34
103
  ctx.lineCap = 'round';
35
- ctx.beginPath();
36
- ctx.moveTo(-r, -r);
37
- ctx.lineTo(r, r);
38
- ctx.moveTo(-r, r);
39
- ctx.lineTo(r, -r);
40
- ctx.stroke();
104
+ ctx.lineJoin = 'round';
105
+ // ── Gateways: X (exclusive) or + (parallel), centred on the diamond ──
106
+ if (kind === 'gatewayExclusive' || kind === 'gatewayParallel') {
107
+ const r = unit * 0.2;
108
+ ctx.translate(cx, cy);
109
+ ctx.lineWidth = Math.max(2, unit * 0.06);
110
+ ctx.beginPath();
111
+ if (kind === 'gatewayExclusive') {
112
+ ctx.moveTo(-r, -r);
113
+ ctx.lineTo(r, r);
114
+ ctx.moveTo(-r, r);
115
+ ctx.lineTo(r, -r);
116
+ }
117
+ else {
118
+ ctx.moveTo(-r, 0);
119
+ ctx.lineTo(r, 0);
120
+ ctx.moveTo(0, -r);
121
+ ctx.lineTo(0, r);
122
+ }
123
+ ctx.stroke();
124
+ return;
125
+ }
126
+ // ── Events ───────────────────────────────────────────────────────────
127
+ if (kind === 'startEventMessage' || kind === 'endEventMessage') {
128
+ // Envelope: a rectangle a little under half the ring's diameter, with the
129
+ // flap folded down to just past its middle.
130
+ const ew = unit * 0.44;
131
+ const eh = ew * 0.7;
132
+ const x = cx - ew / 2;
133
+ const y = cy - eh / 2;
134
+ ctx.lineWidth = Math.max(1, unit * 0.04);
135
+ ctx.beginPath();
136
+ ctx.moveTo(x, y);
137
+ ctx.lineTo(x + ew, y);
138
+ ctx.lineTo(x + ew, y + eh);
139
+ ctx.lineTo(x, y + eh);
140
+ ctx.lineTo(x, y);
141
+ ctx.moveTo(x, y);
142
+ ctx.lineTo(cx, y + eh * 0.62);
143
+ ctx.lineTo(x + ew, y);
144
+ ctx.stroke();
145
+ return;
146
+ }
147
+ if (kind === 'startEventTimer') {
148
+ // Clock: a rim and two hands, at twelve and at four.
149
+ const r = unit * 0.24;
150
+ ctx.lineWidth = Math.max(1, unit * 0.04);
151
+ ctx.beginPath();
152
+ ctx.arc(cx, cy, r, 0, TAU);
153
+ ctx.stroke();
154
+ ctx.beginPath();
155
+ ctx.moveTo(cx, cy);
156
+ ctx.lineTo(cx, cy - r * 0.72);
157
+ ctx.moveTo(cx, cy);
158
+ ctx.lineTo(cx + r * 0.52, cy + r * 0.38);
159
+ ctx.stroke();
160
+ return;
161
+ }
162
+ if (kind === 'endEventTerminate') {
163
+ // Terminate: a solid disc — the process stops here and nothing else runs.
164
+ ctx.fillStyle = color;
165
+ ctx.beginPath();
166
+ ctx.arc(cx, cy, unit * 0.28, 0, TAU);
167
+ ctx.fill();
168
+ return;
169
+ }
170
+ // ── Activities ───────────────────────────────────────────────────────
171
+ if (kind === 'taskUser' || kind === 'taskService') {
172
+ // Both markers sit in the top-left corner, inside a square of the same
173
+ // side, so a row of tasks reads as a column of markers down the left.
174
+ const side = unit * 0.24;
175
+ const inset = unit * 0.1;
176
+ const ox = inset + side / 2;
177
+ const oy = inset + side / 2;
178
+ ctx.lineWidth = Math.max(1, unit * 0.028);
179
+ if (kind === 'taskUser') {
180
+ // A head over a pair of shoulders.
181
+ ctx.beginPath();
182
+ ctx.arc(ox, oy - side * 0.2, side * 0.2, 0, TAU);
183
+ ctx.stroke();
184
+ ctx.beginPath();
185
+ ctx.arc(ox, oy + side * 0.42, side * 0.36, Math.PI, TAU);
186
+ ctx.stroke();
187
+ return;
188
+ }
189
+ // A gear: hub, body and eight teeth.
190
+ const rOuter = side * 0.5;
191
+ const rBody = rOuter * 0.72;
192
+ ctx.beginPath();
193
+ ctx.arc(ox, oy, rBody, 0, TAU);
194
+ ctx.stroke();
195
+ ctx.beginPath();
196
+ ctx.arc(ox, oy, rOuter * 0.28, 0, TAU);
197
+ ctx.stroke();
198
+ ctx.beginPath();
199
+ for (let i = 0; i < 8; i++) {
200
+ const a = (i * Math.PI) / 4;
201
+ ctx.moveTo(ox + Math.cos(a) * rBody, oy + Math.sin(a) * rBody);
202
+ ctx.lineTo(ox + Math.cos(a) * rOuter, oy + Math.sin(a) * rOuter);
203
+ }
204
+ ctx.stroke();
205
+ return;
206
+ }
207
+ if (kind === 'subProcess' || kind === 'callActivity') {
208
+ // The collapsed marker: a small boxed `+` on the bottom edge, saying
209
+ // "there is a whole process folded up in here". The call activity carries
210
+ // the SAME marker — what tells the two apart is its thick border, which is
211
+ // a creation-time preset rather than anything drawn here.
212
+ const side = unit * 0.2;
213
+ const bx = cx;
214
+ const by = h - unit * 0.1 - side / 2;
215
+ const half = side / 2;
216
+ const arm = side * 0.3;
217
+ ctx.lineWidth = Math.max(1, unit * 0.028);
218
+ ctx.beginPath();
219
+ ctx.moveTo(bx - half, by - half);
220
+ ctx.lineTo(bx + half, by - half);
221
+ ctx.lineTo(bx + half, by + half);
222
+ ctx.lineTo(bx - half, by + half);
223
+ ctx.lineTo(bx - half, by - half);
224
+ ctx.moveTo(bx - arm, by);
225
+ ctx.lineTo(bx + arm, by);
226
+ ctx.moveTo(bx, by - arm);
227
+ ctx.lineTo(bx, by + arm);
228
+ ctx.stroke();
229
+ return;
230
+ }
231
+ // ── Data and artifacts: the glyph IS the body ────────────────────────
232
+ // The native rect under these three is unfilled and unstroked, so both the
233
+ // fill and the outline are drawn here — off the model's own colours, which
234
+ // keeps them editable from the shape toolbar like every other node's.
235
+ const fill = renderer.getColorValue(model.fillColor, DefaultTheme.shapeFillColor, true);
236
+ const half = strokeWidth / 2;
237
+ const x0 = half;
238
+ const y0 = half;
239
+ const x1 = w - half;
240
+ const y1 = h - half;
241
+ ctx.lineWidth = strokeWidth;
242
+ ctx.fillStyle = fill;
243
+ if (kind === 'dataObject') {
244
+ // A page with its top-right corner turned down.
245
+ const fold = Math.min(w, h) * 0.28;
246
+ ctx.beginPath();
247
+ ctx.moveTo(x0, y0);
248
+ ctx.lineTo(x1 - fold, y0);
249
+ ctx.lineTo(x1, y0 + fold);
250
+ ctx.lineTo(x1, y1);
251
+ ctx.lineTo(x0, y1);
252
+ ctx.lineTo(x0, y0);
253
+ ctx.fill();
254
+ ctx.stroke();
255
+ // The fold itself, drawn after the body so it is not painted over.
256
+ ctx.beginPath();
257
+ ctx.moveTo(x1 - fold, y0);
258
+ ctx.lineTo(x1 - fold, y0 + fold);
259
+ ctx.lineTo(x1, y0 + fold);
260
+ ctx.stroke();
261
+ return;
262
+ }
263
+ if (kind === 'dataStore') {
264
+ // A cylinder: an elliptical lid, two straight sides and a bulging floor.
265
+ // Clamped: both subtract the stroke width first, so both go NEGATIVE on an
266
+ // element dragged narrower (or shorter) than its own border — and a
267
+ // negative radius is the one thing `ellipse` throws on. See the note on
268
+ // `unit` above.
269
+ const rx = Math.max(0, (x1 - x0) / 2);
270
+ const ry = Math.max(0, (y1 - y0) * 0.16);
271
+ const mx = (x0 + x1) / 2;
272
+ const top = y0 + ry;
273
+ const bottom = y1 - ry;
274
+ ctx.beginPath();
275
+ ctx.moveTo(x0, top);
276
+ ctx.lineTo(x0, bottom);
277
+ // Floor, left to right through the lowest point.
278
+ ctx.ellipse(mx, bottom, rx, ry, 0, Math.PI, 0, true);
279
+ ctx.lineTo(x1, top);
280
+ // Back up the front of the lid, right to left.
281
+ ctx.ellipse(mx, top, rx, ry, 0, 0, Math.PI, false);
282
+ ctx.closePath();
283
+ ctx.fill();
284
+ ctx.stroke();
285
+ // The lid's own far edge, which the body path does not include.
286
+ ctx.beginPath();
287
+ ctx.ellipse(mx, top, rx, ry, 0, 0, TAU);
288
+ ctx.stroke();
289
+ return;
290
+ }
291
+ if (kind === 'textAnnotation') {
292
+ // An open bracket down the leading edge and nothing else — no fill, no
293
+ // closing edge. A note is attached to the picture, not framed in it, and
294
+ // the three missing sides are what say so.
295
+ const arm = Math.min(w * 0.18, h * 0.35);
296
+ ctx.beginPath();
297
+ ctx.moveTo(x0 + arm, y0);
298
+ ctx.lineTo(x0, y0);
299
+ ctx.lineTo(x0, y1);
300
+ ctx.lineTo(x0 + arm, y1);
301
+ ctx.stroke();
302
+ return;
303
+ }
304
+ /**
305
+ * Every glyph kind is drawn above, and this is what keeps that true: `kind`
306
+ * is narrowed to `never` here only if the branches are exhaustive over
307
+ * {@link BpmnGlyphKind}, so a kind added to the model's union without a
308
+ * marker of its own stops the build.
309
+ *
310
+ * Which is the whole point of closing the last branch rather than letting it
311
+ * fall through. A renderer that silently paints an annotation bracket on
312
+ * somebody's new artefact is worse than one that does not paint it at all:
313
+ * the first is a wrong picture nobody is told about, the second is a missing
314
+ * one everybody can see.
315
+ */
316
+ const unhandled = kind;
317
+ void unhandled;
41
318
  };
42
319
  export const BpmnNodeRendererExtension = ElementRendererExtension('bpmnNode', bpmnNode);
@@ -0,0 +1,98 @@
1
+ import { type BackgroundRect, backgroundPlot } from '@formicoidea/labre-core/blocks/surface';
2
+ /**
3
+ * Where a pointer is on a pool, in ELEMENT-LOCAL model units.
4
+ *
5
+ * Pure, and lifted out of `element-view.ts` so the answers can be asserted
6
+ * without an editor, a viewport or a canvas around them. The view converts the
7
+ * pointer and hands it here; everything about WHICH gesture a point means is
8
+ * decided in this file.
9
+ *
10
+ * Every box is derived from `backgroundInstanceZones` /
11
+ * `backgroundInstanceZoneBand` — the same two functions the renderer paints
12
+ * from and the audit reports from. Nothing here restates a coordinate, so a
13
+ * target cannot drift away from the thing it is a target for.
14
+ *
15
+ * ponytail: a ROTATED pool is not accounted for — the caller converts by
16
+ * subtraction, so every box assumes an upright pool. Same reserve
17
+ * `backgroundAxisFacts` documents, for the same reason: nothing rotates a
18
+ * framework background today. Upgrade: rotate the local point by
19
+ * `-model.rotate` about the element centre, in the caller that has the element.
20
+ */
21
+ /** What these functions need of a pool: its box and its lanes. */
22
+ export interface BpmnPoolGeometry {
23
+ deserializedXYWH: readonly number[];
24
+ lanes?: unknown;
25
+ }
26
+ /** A lane band and, when the declaration asks for one, its title strip. */
27
+ export interface BpmnLaneBand {
28
+ top: number;
29
+ height: number;
30
+ strip: BackgroundRect | null;
31
+ }
32
+ export interface BpmnPoolBands {
33
+ plot: ReturnType<typeof backgroundPlot>;
34
+ bands: BpmnLaneBand[];
35
+ }
36
+ /** The lane bands of this pool, or `null` when it has no usable partition. */
37
+ export declare function bpmnPoolBands(model: BpmnPoolGeometry): BpmnPoolBands | null;
38
+ /**
39
+ * A hit box's width, grown to stay reachable when the board is zoomed out.
40
+ *
41
+ * 44 view pixels is the touch-target floor, converted to model units so the box
42
+ * is at least a fingertip wide however far out the pool is drawn; the painted
43
+ * width wins once the pool is large enough on screen for it to. The growth is
44
+ * CAPPED, because a target that swallows the thing it sits next to is its own
45
+ * kind of broken — at 0.2 zoom an uncapped floor would make a lane's title band
46
+ * wider than the flow area it titles.
47
+ */
48
+ export declare function bpmnReachable(painted: number, cap: number, zoom: number): number;
49
+ /**
50
+ * The INTERNAL lane boundary the point is on, as the index of the lane BELOW
51
+ * it — so `i` separates lane `i - 1` from lane `i`. `null` for anywhere else.
52
+ *
53
+ * Internal only: the outer edges belong to the plot, and dragging one would be
54
+ * a resize of the pool, which the handles already do.
55
+ */
56
+ export declare function bpmnLaneBoundaryAt(model: BpmnPoolGeometry, local: readonly [number, number]): number | null;
57
+ /**
58
+ * The lane whose TITLE BAND the point is in, or `null`.
59
+ *
60
+ * The band, not a corner box (PO recette, 2026-08-26): a lane name is written
61
+ * down a strip at the lane's leading edge, so that strip is what you aim at to
62
+ * change it. Its whole height is live — the name is centred in it, and a band
63
+ * you may only click the top of would be a target that lies about where it is.
64
+ */
65
+ export declare function bpmnLaneTitleBandAt(model: BpmnPoolGeometry, local: readonly [number, number], zoom: number): number | null;
66
+ /**
67
+ * Whether the point is in the POOL's own title band — the left margin strip the
68
+ * participant name is written up.
69
+ *
70
+ * The whole pool used to open the participant editor. That was right while a
71
+ * pool held one name; with a name per lane it would mean a double-click in the
72
+ * middle of the flow area renames the participant, which is neither of the two
73
+ * things a user double-clicking there could have meant.
74
+ */
75
+ export declare function bpmnInPoolTitleBand(model: BpmnPoolGeometry, local: readonly [number, number], zoom: number): boolean;
76
+ /** What a double-click at this point would rename. */
77
+ export type BpmnPoolTarget = {
78
+ kind: 'lane';
79
+ index: number;
80
+ } | {
81
+ kind: 'participant';
82
+ };
83
+ /**
84
+ * THE arbiter: which name, if any, a point on a pool is aiming at.
85
+ *
86
+ * The two title bands are adjacent, and both grow when the board is zoomed out
87
+ * (`bpmnReachable`), so on a small pool they OVERLAP — at zoom 1 a 560-unit
88
+ * pool already has a 28-unit participant band grown to 44, which reaches into
89
+ * the lane strip beside it. Something has to arbitrate, and it is this
90
+ * function rather than the view, so the answer can be asserted without an
91
+ * editor and so there is exactly one of it.
92
+ *
93
+ * The LANE wins the overlap. The strip is painted with the lane's name written
94
+ * down it, so it is the name a user is looking at when they aim there; handing
95
+ * their double-click to the participant instead would rename the one thing they
96
+ * demonstrably were not pointing at.
97
+ */
98
+ export declare function bpmnPoolTargetAt(model: BpmnPoolGeometry, local: readonly [number, number], zoom: number): BpmnPoolTarget | null;
@@ -0,0 +1,130 @@
1
+ import { backgroundInstanceZoneBand, backgroundInstanceZones, backgroundPlot, } from '@formicoidea/labre-core/blocks/surface';
2
+ import { BPMN_POOL_BACKGROUND } from './background.js';
3
+ import { POOL_LANE_GRAB } from './consts.js';
4
+ /** The lane bands of this pool, or `null` when it has no usable partition. */
5
+ export function bpmnPoolBands(model) {
6
+ const [, , w, h] = model.deserializedXYWH;
7
+ const plot = backgroundPlot(BPMN_POOL_BACKGROUND, w, h);
8
+ if (!(plot.width > 0) || !(plot.height > 0))
9
+ return null;
10
+ const zones = backgroundInstanceZones(BPMN_POOL_BACKGROUND, model);
11
+ if (zones.length === 0)
12
+ return null;
13
+ return {
14
+ plot,
15
+ bands: zones.map(zone => ({
16
+ top: plot.y0 + zone.rect.y * plot.height,
17
+ height: zone.rect.h * plot.height,
18
+ strip: backgroundInstanceZoneBand(BPMN_POOL_BACKGROUND, zone, plot),
19
+ })),
20
+ };
21
+ }
22
+ /**
23
+ * A hit box's width, grown to stay reachable when the board is zoomed out.
24
+ *
25
+ * 44 view pixels is the touch-target floor, converted to model units so the box
26
+ * is at least a fingertip wide however far out the pool is drawn; the painted
27
+ * width wins once the pool is large enough on screen for it to. The growth is
28
+ * CAPPED, because a target that swallows the thing it sits next to is its own
29
+ * kind of broken — at 0.2 zoom an uncapped floor would make a lane's title band
30
+ * wider than the flow area it titles.
31
+ */
32
+ export function bpmnReachable(painted, cap, zoom) {
33
+ return Math.max(painted, Math.min(44 / (zoom || 1), cap));
34
+ }
35
+ /**
36
+ * The INTERNAL lane boundary the point is on, as the index of the lane BELOW
37
+ * it — so `i` separates lane `i - 1` from lane `i`. `null` for anywhere else.
38
+ *
39
+ * Internal only: the outer edges belong to the plot, and dragging one would be
40
+ * a resize of the pool, which the handles already do.
41
+ */
42
+ export function bpmnLaneBoundaryAt(model, local) {
43
+ const geometry = bpmnPoolBands(model);
44
+ if (!geometry)
45
+ return null;
46
+ const { plot, bands } = geometry;
47
+ // The strip on the left is the participant's name, not the flow area: a
48
+ // separator does not run through it, so neither does its grab zone.
49
+ if (local[0] < plot.x0 || local[0] > plot.x1)
50
+ return null;
51
+ for (let i = 1; i < bands.length; i++) {
52
+ if (Math.abs(local[1] - bands[i].top) <= POOL_LANE_GRAB)
53
+ return i;
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * The lane whose TITLE BAND the point is in, or `null`.
59
+ *
60
+ * The band, not a corner box (PO recette, 2026-08-26): a lane name is written
61
+ * down a strip at the lane's leading edge, so that strip is what you aim at to
62
+ * change it. Its whole height is live — the name is centred in it, and a band
63
+ * you may only click the top of would be a target that lies about where it is.
64
+ */
65
+ export function bpmnLaneTitleBandAt(model, local, zoom) {
66
+ const geometry = bpmnPoolBands(model);
67
+ if (!geometry)
68
+ return null;
69
+ const { plot, bands } = geometry;
70
+ for (let i = 0; i < bands.length; i++) {
71
+ const strip = bands[i].strip;
72
+ if (!strip)
73
+ continue;
74
+ // Grown across the strip only; along it, a lane is already as tall as it
75
+ // is. Capped at half the plot so the target never covers the flow area.
76
+ const width = bpmnReachable(strip.w, plot.width / 2, zoom);
77
+ if (local[0] >= strip.x &&
78
+ local[0] <= strip.x + width &&
79
+ local[1] >= strip.y &&
80
+ local[1] <= strip.y + strip.h) {
81
+ return i;
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ /**
87
+ * Whether the point is in the POOL's own title band — the left margin strip the
88
+ * participant name is written up.
89
+ *
90
+ * The whole pool used to open the participant editor. That was right while a
91
+ * pool held one name; with a name per lane it would mean a double-click in the
92
+ * middle of the flow area renames the participant, which is neither of the two
93
+ * things a user double-clicking there could have meant.
94
+ */
95
+ export function bpmnInPoolTitleBand(model, local, zoom) {
96
+ const [, , w, h] = model.deserializedXYWH;
97
+ const plot = backgroundPlot(BPMN_POOL_BACKGROUND, w, h);
98
+ // The band IS the margin, clamped to a pool narrower than its own margin —
99
+ // the same degenerate case the renderer clamps.
100
+ const painted = Math.min(plot.x0, w);
101
+ if (!(painted > 0))
102
+ return false;
103
+ // Grown rightwards, capped at twice the margin: past that lies the lane
104
+ // strip, which has its own claim on those units — see `bpmnPoolTargetAt`.
105
+ const width = bpmnReachable(painted, painted * 2, zoom);
106
+ return local[0] >= 0 && local[0] <= width && local[1] >= 0 && local[1] <= h;
107
+ }
108
+ /**
109
+ * THE arbiter: which name, if any, a point on a pool is aiming at.
110
+ *
111
+ * The two title bands are adjacent, and both grow when the board is zoomed out
112
+ * (`bpmnReachable`), so on a small pool they OVERLAP — at zoom 1 a 560-unit
113
+ * pool already has a 28-unit participant band grown to 44, which reaches into
114
+ * the lane strip beside it. Something has to arbitrate, and it is this
115
+ * function rather than the view, so the answer can be asserted without an
116
+ * editor and so there is exactly one of it.
117
+ *
118
+ * The LANE wins the overlap. The strip is painted with the lane's name written
119
+ * down it, so it is the name a user is looking at when they aim there; handing
120
+ * their double-click to the participant instead would rename the one thing they
121
+ * demonstrably were not pointing at.
122
+ */
123
+ export function bpmnPoolTargetAt(model, local, zoom) {
124
+ const index = bpmnLaneTitleBandAt(model, local, zoom);
125
+ if (index !== null)
126
+ return { kind: 'lane', index };
127
+ if (bpmnInPoolTitleBand(model, local, zoom))
128
+ return { kind: 'participant' };
129
+ return null;
130
+ }