@jbrowse/capture 5.0.0-beta.11 → 5.0.0-beta.13

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.
@@ -0,0 +1,766 @@
1
+ // The callout overlay behind `jb2capture --annotations`, the website's figure
2
+ // generator and the desktop selenium harness
3
+ // (`products/jbrowse-desktop/test/annotations.ts`). Puppeteer injects
4
+ // `drawAnnotationOverlay` into page context through `annotations.ts`, selenium
5
+ // through `executeScript`, so a callout drawn on a desktop figure, a web figure
6
+ // and a reader's own capture is the same shape, color and geometry.
7
+ //
8
+ // Everything below the types is deliberately import-free: the function is
9
+ // serialized to source and evaluated in the page, so anything it closes over
10
+ // out here would be undefined in there.
11
+ // A callout drawn over the captured page (SVG overlay) before the screenshot,
12
+ // to reproduce the red arrows / boxes / text labels that hand-made teaching
13
+ // figures use. Coordinates are viewport CSS px.
14
+ //
15
+ // Prefer `anchor` over raw x/y in every case: an anchored callout resolves its
16
+ // geometry at capture time, a hand-tuned coordinate goes stale silently.
17
+ // `dx`/`dy` nudge the anchored position.
18
+ export const ANNOTATION_TYPES = [
19
+ 'arrow',
20
+ 'box',
21
+ 'text',
22
+ 'circle',
23
+ 'legend',
24
+ 'trapezoid',
25
+ ];
26
+ export const ANNOTATION_OVERLAY_ID = '__screenshot_annotation_overlay';
27
+ // A locus authored on an anchor, pre-parsed out here in node so the
28
+ // page-context code stays a pure lookup. 1-based inclusive in, interbase out,
29
+ // matching core's parseLocString (which needs an assembly-aware isValidRefName
30
+ // callback we don't have out here — the refName is instead resolved through the
31
+ // assembly's aliases inside the view's own getHighlightCoords).
32
+ //
33
+ // Splits on the LAST colon, so a refName may contain colons as long as
34
+ // coordinates follow it.
35
+ export function parseAnnotationLocus(locus,
36
+ // What a SINGLE coordinate means, which depends on the callout drawn from it.
37
+ // An arrow head or a text pill points AT the position, and the zero-width
38
+ // interval between two bases is the honest answer for that — it is what the
39
+ // callout centres on. A box has to wrap it, and a box built on that same
40
+ // zero-width region is centred on the boundary rather than around the base:
41
+ // the column ends up in one half of the frame with the stroke drawn over it,
42
+ // which is what "the boxes cover up the variant they are trying to show"
43
+ // looked like at one base per ~6 css px. `wrap` asks for the base's own
44
+ // column instead. (`locusAnchor.ts`'s click parser always wants the base, for
45
+ // the neighbouring reason: a click cannot land on an edge.)
46
+ wrap = false) {
47
+ const cleaned = locus.replaceAll(/[\s,]/g, '');
48
+ const idx = cleaned.lastIndexOf(':');
49
+ const refName = cleaned.slice(0, idx);
50
+ const coords = cleaned.slice(idx + 1);
51
+ const match = /^(\d+)(?:\.\.|-)?(\d+)?$/.exec(coords);
52
+ if (idx === -1 || !match) {
53
+ throw new Error(`annotation anchor loc "${locus}" is not <refName>:<start>[-<end>]`);
54
+ }
55
+ const start = Number(match[1]) - 1;
56
+ const end = match[2] ? Number(match[2]) : wrap ? start + 1 : start;
57
+ return { refName, start, end };
58
+ }
59
+ // Draw the annotations as a fixed SVG overlay covering the viewport so the
60
+ // callouts composite into the screenshot, reproducing the red arrows / boxes /
61
+ // text labels of hand-made teaching figures without an external image editor.
62
+ //
63
+ // Runs in PAGE CONTEXT (serialized to source by both harnesses), so it takes no
64
+ // imports and returns plain data: the anchors that resolved to nothing, and the
65
+ // items that resolved but drew outside the capture. The caller turns either into
66
+ // a thrown error rather than leaving a callout parked at the origin or shipping
67
+ // a figure the callout is missing from.
68
+ export function drawAnnotationOverlay(items, overlayId) {
69
+ const NS = 'http://www.w3.org/2000/svg';
70
+ const svg = document.createElementNS(NS, 'svg');
71
+ svg.id = overlayId;
72
+ svg.setAttribute('style', 'position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:none');
73
+ // Model anchoring. The app already knows where a locus is painted, so ask
74
+ // it rather than re-deriving the layout from measured pixels: the track's
75
+ // rendering container is the element the blocks draw into, and
76
+ // getHighlightCoords maps a region into that same space (aliases
77
+ // resolved, scroll subtracted).
78
+ function viewAt(path) {
79
+ let view = window
80
+ .JBrowseSession;
81
+ for (const i of Array.isArray(path) ? path : [path ?? 0]) {
82
+ view = view?.views?.[i];
83
+ }
84
+ return view;
85
+ }
86
+ // The element a view's own DOM sits in: app-core's ViewContainer around a
87
+ // session view, or the LinearGenomeView's own box, which is all a row of a
88
+ // synteny stack has.
89
+ const viewContainerSelector = (view) => ['view-container', 'linear-genome-view']
90
+ .map(t => `[data-testid="${t}-${CSS.escape(view.id)}"]`)
91
+ .join(', ');
92
+ function modelRect(anchor) {
93
+ const view = viewAt(anchor.view);
94
+ if (!view) {
95
+ return undefined;
96
+ }
97
+ const el = anchor.trackId
98
+ ? document.querySelector(`[data-testid="trackRenderingContainer-${CSS.escape(view.id)}-${CSS.escape(anchor.trackId)}"]`)
99
+ : document
100
+ .querySelector(viewContainerSelector(view))
101
+ ?.querySelector('[data-testid="tracksContainer"]');
102
+ if (!el) {
103
+ return undefined;
104
+ }
105
+ const r = el.getBoundingClientRect();
106
+ // no fracY anchors the whole track band (what a box wants); a fracY
107
+ // collapses it to one horizontal line through the track (what an arrow
108
+ // head or a text baseline wants)
109
+ const band = anchor.fracY === undefined
110
+ ? { top: r.top, height: r.height }
111
+ : { top: r.top + anchor.fracY * r.height, height: 0 };
112
+ if (!anchor.region) {
113
+ return { left: r.left, width: r.width, ...band };
114
+ }
115
+ // the view's own assembly, so getHighlightCoords resolves the locus's
116
+ // refName through it: without a name it cannot, and a 'chr1' authored
117
+ // against an assembly whose canonical refName is '1' resolves to nothing
118
+ const coords = view.getHighlightCoords?.({
119
+ ...anchor.region,
120
+ assemblyName: view.assemblyNames?.[0],
121
+ });
122
+ if (!coords) {
123
+ return undefined;
124
+ }
125
+ return {
126
+ left: r.left + coords.left,
127
+ // getHighlightCoords floors width at 3px so a zoomed-out band stays
128
+ // visible; a point locus wants a true zero so the callout centers on it
129
+ width: anchor.region.end > anchor.region.start ? coords.width : 0,
130
+ ...band,
131
+ };
132
+ }
133
+ // Resolve an anchor to a live element: a CSS selector, or the
134
+ // smallest-area element whose visible text matches (so a callout can point
135
+ // at a menu item / button without a testid).
136
+ function domRect(anchor) {
137
+ let scope = document.body;
138
+ if (anchor.view !== undefined) {
139
+ const view = viewAt(anchor.view);
140
+ scope = view ? document.querySelector(viewContainerSelector(view)) : null;
141
+ }
142
+ if (!scope) {
143
+ return undefined;
144
+ }
145
+ if (anchor.selector) {
146
+ return scope.querySelector(anchor.selector)?.getBoundingClientRect();
147
+ }
148
+ if (anchor.text) {
149
+ const want = anchor.text.trim().toLowerCase();
150
+ let best;
151
+ let bestArea = Number.POSITIVE_INFINITY;
152
+ for (const el of scope.querySelectorAll('*')) {
153
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
154
+ const txt = (el.textContent !== null ? el.textContent : '')
155
+ .trim()
156
+ .toLowerCase();
157
+ const matches = txt === want || (el.childElementCount === 0 && txt.includes(want));
158
+ // getBoundingClientRect forces layout, so only measure the elements
159
+ // that actually matched — this scan walks the whole document, and on
160
+ // a page like the 1104-row TCGA stack measuring every node instead
161
+ // costs thousands of synchronous layouts
162
+ if (matches) {
163
+ const rect = el.getBoundingClientRect();
164
+ const area = rect.width * rect.height;
165
+ if (rect.width > 0 && rect.height > 0 && area < bestArea) {
166
+ best = rect;
167
+ bestArea = area;
168
+ }
169
+ }
170
+ }
171
+ return best;
172
+ }
173
+ return undefined;
174
+ }
175
+ // a selector/text anchor is always a DOM lookup, so the two kinds can't
176
+ // be silently mixed into an anchor that resolves through neither
177
+ const isModel = (anchor) => anchor.selector === undefined &&
178
+ anchor.text === undefined &&
179
+ (anchor.trackId !== undefined ||
180
+ anchor.loc !== undefined ||
181
+ anchor.view !== undefined);
182
+ const misses = [];
183
+ function anchorRect(anchor) {
184
+ if (!anchor) {
185
+ return undefined;
186
+ }
187
+ // a graph-node or dotplot anchor arrives already resolved by the caller —
188
+ // both are one canvas with nothing per feature to measure in here — and an
189
+ // unresolved one carries no rect, so it falls through to the miss below
190
+ const preResolved = anchor.graphNode !== undefined ||
191
+ anchor.hLoc !== undefined ||
192
+ anchor.vLoc !== undefined;
193
+ const rect = preResolved
194
+ ? anchor.rect
195
+ : isModel(anchor)
196
+ ? modelRect(anchor)
197
+ : domRect(anchor);
198
+ if (!rect) {
199
+ misses.push(JSON.stringify(anchor));
200
+ return undefined;
201
+ }
202
+ // built field by field, not spread: domRect hands back a live DOMRect,
203
+ // whose left/top/width/height are prototype getters and so survive
204
+ // neither a spread nor Object.assign
205
+ return {
206
+ left: rect.left + (anchor.dx ?? 0),
207
+ top: rect.top + (anchor.dy ?? 0),
208
+ width: rect.width,
209
+ height: rect.height,
210
+ };
211
+ }
212
+ // Which point of a resolved rect an anchor names. Both ends of an arrow read
213
+ // it, so a tail can sit at an element's edge the same way a head can. It used
214
+ // to be inlined for the head only, and a tail carrying an `alignX` was
215
+ // silently the rect's centre instead: tcga/mutations_cdh1_histology asked for
216
+ // a short vertical arrow at a track's left edge + 400 and drew a diagonal
217
+ // across the whole panel, because a track's rect is the full view width and
218
+ // half of that is what the tail was off by.
219
+ function unionRect(a, b) {
220
+ const left = Math.min(a.left, b.left);
221
+ const top = Math.min(a.top, b.top);
222
+ return {
223
+ left,
224
+ top,
225
+ width: Math.max(a.left + a.width, b.left + b.width) - left,
226
+ height: Math.max(a.top + a.height, b.top + b.height) - top,
227
+ };
228
+ }
229
+ function anchorPoint(rect, anchor) {
230
+ const alignX = anchor?.alignX ?? 'center';
231
+ const alignY = anchor?.alignY ?? 'center';
232
+ return {
233
+ x: alignX === 'left'
234
+ ? rect.left
235
+ : alignX === 'right'
236
+ ? rect.left + rect.width
237
+ : rect.left + rect.width / 2,
238
+ y: alignY === 'top'
239
+ ? rect.top
240
+ : alignY === 'bottom'
241
+ ? rect.top + rect.height
242
+ : rect.top + rect.height / 2,
243
+ };
244
+ }
245
+ // Apply anchoring: fill in x/y (the anchor's point on the element) and, for
246
+ // box/ring shapes, width/height (element bounds + padding), then nudge by
247
+ // dx/dy.
248
+ const resolved = items.map(a => {
249
+ const dx = a.dx ?? 0;
250
+ const dy = a.dy ?? 0;
251
+ const from = a.fromAnchor ? anchorRect(a.fromAnchor) : undefined;
252
+ const tail = from ? anchorPoint(from, a.fromAnchor) : a.from;
253
+ const anchored = anchorRect(a.anchor);
254
+ const r = a.type === 'box' && from && anchored
255
+ ? unionRect(from, anchored)
256
+ : anchored;
257
+ if (!r) {
258
+ return {
259
+ ...a,
260
+ from: tail,
261
+ x: (a.x ?? 0) + dx,
262
+ y: (a.y ?? 0) + dy,
263
+ target: undefined,
264
+ fromRect: from,
265
+ toRect: undefined,
266
+ };
267
+ }
268
+ const pad = a.pad ?? 6;
269
+ // a numbered badge stays a fixed small disc; a hollow ring grows to wrap
270
+ // the anchored element
271
+ const ringRadius = Math.max(r.width, r.height) / 2 + pad;
272
+ const { x: px, y: py } = anchorPoint(r, a.anchor);
273
+ // A box's pad goes outward where there is room and inward where there is
274
+ // not. An element flush against the window edge — a docked drawer, a panel
275
+ // pinned to the right — has no outside on that side, so a stroke drawn
276
+ // there is off frame: the track-settings figure kept exactly one of its four
277
+ // edges, a red line hovering in the page margin with nothing to read it
278
+ // against. Clamped to the stroke's own half-width so the whole line lands.
279
+ const inset = (a.strokeWidth ?? 5) / 2;
280
+ const boxLeft = Math.max(inset, r.left - pad + dx);
281
+ const boxTop = Math.max(inset, r.top - pad + dy);
282
+ const boxRight = Math.min(window.innerWidth - inset, r.left + r.width + pad + dx);
283
+ const boxBottom = Math.min(window.innerHeight - inset, r.top + r.height + pad + dy);
284
+ return {
285
+ ...a,
286
+ from: tail,
287
+ x: a.type === 'box' ? boxLeft : px + dx,
288
+ y: a.type === 'box' ? boxTop : py + dy,
289
+ // the anchored point BEFORE the annotation's own dx/dy, which for a
290
+ // `leader` pill is the difference between where the label sits and what it
291
+ // names — the arrow needs both
292
+ target: { x: px, y: py },
293
+ width: a.width ?? boxRight - boxLeft,
294
+ height: a.height ?? boxBottom - boxTop,
295
+ radius: a.radius ?? (a.text ? 16 : ringRadius),
296
+ // A trapezoid is the one shape that needs both anchors as RECTS rather
297
+ // than as points: its two horizontal edges are the two elements' facing
298
+ // edges, so the widths are the geometry and not a decoration on it.
299
+ fromRect: from,
300
+ toRect: r,
301
+ };
302
+ });
303
+ // arrowhead length in marker units (path spans x:0..ARROW_LEN); the line
304
+ // is shortened by this much (scaled by strokeWidth, since markerUnits
305
+ // defaults to strokeWidth) so it ends at the arrowhead's base
306
+ const ARROW_LEN = 8;
307
+ const defs = document.createElementNS(NS, 'defs');
308
+ svg.append(defs);
309
+ // One marker per distinct arrow color. A single shared marker recolored
310
+ // per arrow would paint every head in whichever color was drawn last.
311
+ const markerIds = new Map();
312
+ function arrowMarker(color) {
313
+ const existing = markerIds.get(color);
314
+ if (existing) {
315
+ return existing;
316
+ }
317
+ const id = `arrowhead-${markerIds.size}`;
318
+ const marker = document.createElementNS(NS, 'marker');
319
+ marker.setAttribute('id', id);
320
+ marker.setAttribute('markerWidth', '10');
321
+ marker.setAttribute('markerHeight', '10');
322
+ // anchor the marker at its BASE (refX=0) so the line stops at the base
323
+ // and the triangle extends forward to the target, covering the line end
324
+ // — this avoids the butt-capped line poking past the sharp tip as a "nub"
325
+ marker.setAttribute('refX', '0');
326
+ marker.setAttribute('refY', '3');
327
+ marker.setAttribute('orient', 'auto');
328
+ const arrowPath = document.createElementNS(NS, 'path');
329
+ arrowPath.setAttribute('d', `M0,0 L${ARROW_LEN},3 L0,6 Z`);
330
+ arrowPath.setAttribute('fill', color);
331
+ marker.append(arrowPath);
332
+ defs.append(marker);
333
+ markerIds.set(color, id);
334
+ return id;
335
+ }
336
+ // One line with a head on it, shared by the `arrow` annotation and by the
337
+ // arrow a `leader` pill draws for itself, so the two cannot end up different
338
+ // marks in the same figure.
339
+ function drawArrowLine(from, to, color, strokeWidth) {
340
+ // pull the line endpoint back to the arrowhead's base so the triangle
341
+ // (placed base-first at the endpoint) extends forward to the true target;
342
+ // the line end is then hidden under the filled head
343
+ const ddx = to.x - from.x;
344
+ const ddy = to.y - from.y;
345
+ const dist = Math.hypot(ddx, ddy) || 1;
346
+ const headLen = ARROW_LEN * strokeWidth;
347
+ const line = document.createElementNS(NS, 'line');
348
+ line.setAttribute('x1', String(from.x));
349
+ line.setAttribute('y1', String(from.y));
350
+ line.setAttribute('x2', String(to.x - (ddx / dist) * headLen));
351
+ line.setAttribute('y2', String(to.y - (ddy / dist) * headLen));
352
+ line.setAttribute('stroke', color);
353
+ // the arrowhead marker uses markerUnits=strokeWidth, so a thinner line also
354
+ // shrinks the head proportionally
355
+ line.setAttribute('stroke-width', String(strokeWidth));
356
+ line.setAttribute('marker-end', `url(#${arrowMarker(color)})`);
357
+ return line;
358
+ }
359
+ // How much air a leader leaves at each end: clear of the pill's border so the
360
+ // tail is not drawn on it, and short of the target so the head names a point
361
+ // without covering it.
362
+ const LEADER_TAIL_GAP = 5;
363
+ const LEADER_HEAD_GAP = 14;
364
+ // The arrow a `leader` pill draws back to what it names. The tail is the point
365
+ // where the target's direction leaves the MEASURED pill, so it is the pill's
366
+ // own edge at any label length and at any angle; nothing here is a written
367
+ // offset. Undefined when the pill covers the target, which the caller reports
368
+ // rather than drawing a backwards arrow.
369
+ function leaderArrow(pill, target, color, strokeWidth) {
370
+ const midX = pill.left + pill.width / 2;
371
+ const midY = pill.top + pill.height / 2;
372
+ const dx = target.x - midX;
373
+ const dy = target.y - midY;
374
+ const dist = Math.hypot(dx, dy);
375
+ if (dist === 0) {
376
+ return undefined;
377
+ }
378
+ // the pill boundary along that direction: whichever of the two half-extents
379
+ // the ray reaches first
380
+ const edge = Math.min(Math.abs(dx) > 0 ? pill.width / 2 / Math.abs(dx) : Infinity, Math.abs(dy) > 0 ? pill.height / 2 / Math.abs(dy) : Infinity);
381
+ const start = edge * dist + LEADER_TAIL_GAP;
382
+ const end = dist - LEADER_HEAD_GAP;
383
+ if (!(end > start)) {
384
+ return undefined;
385
+ }
386
+ const at = (d) => ({
387
+ x: midX + (dx / dist) * d,
388
+ y: midY + (dy / dist) * d,
389
+ });
390
+ return drawArrowLine(at(start), at(end), color, strokeWidth);
391
+ }
392
+ // append the overlay now (before drawing) so text getBBox() resolves for
393
+ // the optional background pill below
394
+ document.body.append(svg);
395
+ // Text pills paint last, over every arrow and box, whatever order the spec
396
+ // listed them in. An arrow that names a pill has to start inside it: the
397
+ // pill's width is only known once the text is measured in the page, so a
398
+ // spec can place the tail near the pill but never exactly at its edge, and
399
+ // the leftover line drawn across the white callout reads as a mistake.
400
+ // Sorting here rather than asking every spec to list its pills last, which
401
+ // is a rule nothing would check.
402
+ const isPill = (t) => t === 'text' || t === 'legend';
403
+ const drawOrder = [
404
+ ...resolved.filter(a => !isPill(a.type)),
405
+ ...resolved.filter(a => isPill(a.type)),
406
+ ];
407
+ // An anchor that resolves and THEN draws outside the capture is the other
408
+ // half of the miss above, and the half nothing caught: the callout is correct,
409
+ // the spec is correct, and the figure ships without it. Measured from what was
410
+ // actually drawn rather than from the intended geometry, because a pill's own
411
+ // size is only known once its text is measured in the page.
412
+ //
413
+ // Reported only when the item lands ENTIRELY outside. A partial clip is left
414
+ // alone deliberately — a box already clamps its pad inward at the frame edge,
415
+ // so the shapes that reach the edge do so on purpose, and a threshold on
416
+ // "mostly visible" would fire on those without a measurement to set it from.
417
+ const offFrame = [];
418
+ // jsdom, where this file's unit tests run, measures every element as a zero
419
+ // rect; taken at face value that calls every callout off-frame. A zero-sized
420
+ // overlay root is how "no layout here" reads from inside page context.
421
+ const canMeasure = svg.getBoundingClientRect().width > 0;
422
+ function reportIfOffFrame(a, from) {
423
+ const drawn = [...svg.children].slice(from);
424
+ if (!canMeasure || drawn.length === 0) {
425
+ return;
426
+ }
427
+ let left = Infinity;
428
+ let top = Infinity;
429
+ let right = -Infinity;
430
+ let bottom = -Infinity;
431
+ for (const el of drawn) {
432
+ const r = el.getBoundingClientRect();
433
+ left = Math.min(left, r.left);
434
+ top = Math.min(top, r.top);
435
+ right = Math.max(right, r.right);
436
+ bottom = Math.max(bottom, r.bottom);
437
+ }
438
+ // Does the drawn box INTERSECT the viewport — not does the intersection
439
+ // have area. A perfectly vertical arrow (both ends on one x, which two
440
+ // anchors sharing a locus give you) has a zero-width box, and measured as
441
+ // an area it has none wherever it sits: an arrow in the middle of the frame
442
+ // reported as invisible and failed its whole figure.
443
+ //
444
+ // A pill is the exception to leaving partial clips alone: nothing clamps
445
+ // it, so any part past the edge is cut text.
446
+ const inside = isPill(a.type)
447
+ ? left >= -1 &&
448
+ right <= window.innerWidth + 1 &&
449
+ top >= -1 &&
450
+ bottom <= window.innerHeight + 1
451
+ : right >= 0 &&
452
+ left <= window.innerWidth &&
453
+ bottom >= 0 &&
454
+ top <= window.innerHeight;
455
+ if (inside) {
456
+ return;
457
+ }
458
+ offFrame.push(JSON.stringify({
459
+ type: a.type,
460
+ text: a.text,
461
+ anchor: a.anchor,
462
+ drawn: { left, top, right, bottom },
463
+ viewport: { width: window.innerWidth, height: window.innerHeight },
464
+ }));
465
+ }
466
+ for (const a of drawOrder) {
467
+ const drawnFrom = svg.childElementCount;
468
+ const color = a.color ?? '#e3242b';
469
+ const cx = a.x;
470
+ const cy = a.y;
471
+ if (a.type === 'arrow' && a.from) {
472
+ // anchored arrow: head points at the resolved element center
473
+ svg.append(drawArrowLine(a.from, {
474
+ x: a.anchor ? cx : (a.to?.x ?? 0),
475
+ y: a.anchor ? cy : (a.to?.y ?? 0),
476
+ }, color, a.strokeWidth ?? 4));
477
+ }
478
+ else if (a.type === 'trapezoid' && a.fromRect && a.toRect) {
479
+ // The lineage wedge: `fromAnchor` is the span the zoom came FROM,
480
+ // `anchor` the panel it opened into, and the two horizontal edges are
481
+ // those two elements' own facing edges. Which pair of edges face each
482
+ // other is read off the rects rather than declared, so the same
483
+ // annotation works whichever way round the composition stacks its parts.
484
+ const A = a.fromRect;
485
+ const B = a.toRect;
486
+ const span = (r, anchor) => {
487
+ const [f0, f1] = anchor?.fracX ?? [0, 1];
488
+ return [r.left + r.width * f0, r.left + r.width * f1];
489
+ };
490
+ const [aLeft, aRight] = span(A, a.fromAnchor);
491
+ const [bLeft, bRight] = span(B, a.anchor);
492
+ const aAbove = A.top + A.height / 2 < B.top + B.height / 2;
493
+ const aY = aAbove ? A.top + A.height : A.top;
494
+ const bY = aAbove ? B.top : B.top + B.height;
495
+ const poly = document.createElementNS(NS, 'polygon');
496
+ poly.setAttribute('points', `${aLeft},${aY} ${aRight},${aY} ${bRight},${bY} ${bLeft},${bY}`);
497
+ poly.setAttribute('fill', color);
498
+ poly.setAttribute('fill-opacity', String(a.fillOpacity ?? 0.14));
499
+ poly.setAttribute('stroke', 'none');
500
+ svg.append(poly);
501
+ // Only the SLANTED sides are stroked. The other two are the panels' own
502
+ // edges, and a line drawn along one of them reads as a border the figure
503
+ // grew rather than as part of the wedge.
504
+ for (const [x1, x2] of [
505
+ [aLeft, bLeft],
506
+ [aRight, bRight],
507
+ ]) {
508
+ const side = document.createElementNS(NS, 'line');
509
+ side.setAttribute('x1', String(x1));
510
+ side.setAttribute('y1', String(aY));
511
+ side.setAttribute('x2', String(x2));
512
+ side.setAttribute('y2', String(bY));
513
+ side.setAttribute('stroke', color);
514
+ side.setAttribute('stroke-width', String(a.strokeWidth ?? 3));
515
+ svg.append(side);
516
+ }
517
+ }
518
+ else if (a.type === 'trapezoid') {
519
+ // A trapezoid with only one end is not a shape. Reported rather than
520
+ // skipped, because the caller turns misses into a thrown error and a
521
+ // silently-undrawn lineage wedge is exactly the kind of thing that gets
522
+ // committed.
523
+ misses.push(JSON.stringify({
524
+ trapezoid: 'needs both anchor and fromAnchor to resolve',
525
+ anchor: a.anchor,
526
+ fromAnchor: a.fromAnchor,
527
+ }));
528
+ }
529
+ else if (a.type === 'box') {
530
+ const rect = document.createElementNS(NS, 'rect');
531
+ rect.setAttribute('x', String(cx));
532
+ rect.setAttribute('y', String(cy));
533
+ rect.setAttribute('width', String(a.width ?? 0));
534
+ rect.setAttribute('height', String(a.height ?? 0));
535
+ rect.setAttribute('rx', '4');
536
+ // a positive fillOpacity tints the box with a translucent wash of its
537
+ // own colour (a "light green/orange" highlight); otherwise hollow
538
+ rect.setAttribute('fill', a.fillOpacity ? color : 'none');
539
+ if (a.fillOpacity) {
540
+ rect.setAttribute('fill-opacity', String(a.fillOpacity));
541
+ }
542
+ rect.setAttribute('stroke', color);
543
+ rect.setAttribute('stroke-width', String(a.strokeWidth ?? 5));
544
+ svg.append(rect);
545
+ }
546
+ else if (a.type === 'circle') {
547
+ const radius = a.radius ?? 16;
548
+ const circle = document.createElementNS(NS, 'circle');
549
+ circle.setAttribute('cx', String(cx));
550
+ circle.setAttribute('cy', String(cy));
551
+ circle.setAttribute('r', String(radius));
552
+ circle.setAttribute('stroke', color);
553
+ circle.setAttribute('stroke-width', String(a.strokeWidth ?? 5));
554
+ // filled badge when it carries a label, hollow ring otherwise
555
+ circle.setAttribute('fill', a.text ? color : 'none');
556
+ svg.append(circle);
557
+ if (a.text) {
558
+ const text = document.createElementNS(NS, 'text');
559
+ text.setAttribute('x', String(cx));
560
+ text.setAttribute('y', String(cy));
561
+ text.setAttribute('fill', a.textColor ?? '#fff');
562
+ text.setAttribute('text-anchor', 'middle');
563
+ text.setAttribute('dominant-baseline', 'central');
564
+ text.setAttribute('font-family', 'system-ui, sans-serif');
565
+ text.setAttribute('font-size', String(a.fontSize ?? 18));
566
+ text.setAttribute('font-weight', '700');
567
+ text.textContent = a.text;
568
+ svg.append(text);
569
+ }
570
+ }
571
+ else if (a.type === 'legend' && a.entries?.length) {
572
+ // A color key: swatch + label per row, in one white pill styled like the
573
+ // text callout so the two read as the same annotation vocabulary. The
574
+ // pill's width comes from the measured labels, so nothing here is a
575
+ // hand-tuned box that goes stale when an entry is renamed.
576
+ const fontFamily = 'system-ui, sans-serif';
577
+ const fontSize = Math.max(a.fontSize ?? 20, 13);
578
+ const swatch = fontSize;
579
+ const gap = Math.round(fontSize * 0.5);
580
+ const rowHeight = Math.round(fontSize * 1.5);
581
+ const padX = 12;
582
+ const padY = 10;
583
+ const group = document.createElementNS(NS, 'g');
584
+ svg.append(group);
585
+ const labels = document.createElementNS(NS, 'text');
586
+ labels.setAttribute('fill', '#000');
587
+ labels.setAttribute('font-family', fontFamily);
588
+ labels.setAttribute('font-size', String(fontSize));
589
+ labels.setAttribute('font-weight', '600');
590
+ labels.setAttribute('dominant-baseline', 'central');
591
+ group.append(labels);
592
+ for (const [i, entry] of a.entries.entries()) {
593
+ const tspan = document.createElementNS(NS, 'tspan');
594
+ tspan.setAttribute('x', '0');
595
+ tspan.setAttribute('y', String(i * rowHeight + rowHeight / 2));
596
+ tspan.textContent = entry.label;
597
+ labels.append(tspan);
598
+ }
599
+ const labelWidth = labels.getBBox().width;
600
+ // right-align the whole pill on x when asked, which is what a key placed
601
+ // against the frame's right edge needs: its width is only known here
602
+ const width = padX * 2 + swatch + gap + labelWidth;
603
+ const height = padY * 2 + a.entries.length * rowHeight;
604
+ const left = a.textAlign === 'end' ? cx - width : cx;
605
+ const top = cy;
606
+ const rect = document.createElementNS(NS, 'rect');
607
+ rect.setAttribute('x', String(left));
608
+ rect.setAttribute('y', String(top));
609
+ rect.setAttribute('width', String(width));
610
+ rect.setAttribute('height', String(height));
611
+ rect.setAttribute('rx', '6');
612
+ rect.setAttribute('fill', '#fff');
613
+ rect.setAttribute('stroke', color);
614
+ rect.setAttribute('stroke-width', '3');
615
+ group.prepend(rect);
616
+ for (const [i, entry] of a.entries.entries()) {
617
+ const sw = document.createElementNS(NS, 'rect');
618
+ sw.setAttribute('x', String(left + padX));
619
+ sw.setAttribute('y', String(top + padY + i * rowHeight + (rowHeight - swatch) / 2));
620
+ sw.setAttribute('width', String(swatch));
621
+ sw.setAttribute('height', String(swatch));
622
+ sw.setAttribute('rx', '3');
623
+ sw.setAttribute('fill', entry.color);
624
+ sw.setAttribute('stroke', '#0006');
625
+ sw.setAttribute('stroke-width', '1');
626
+ group.append(sw);
627
+ }
628
+ labels.setAttribute('transform', `translate(${left + padX + swatch + gap},${top + padY})`);
629
+ // the labels were appended before the swatches so getBBox measured them
630
+ // in isolation; move them back on top so nothing overlaps a swatch
631
+ group.append(labels);
632
+ }
633
+ else if (a.type === 'text' && a.text) {
634
+ // Callout style: white pill, red border, black text, larger default
635
+ // font, with word-wrapping once a line exceeds maxWidth. `background`
636
+ // and `textColor` override the first and third; see their doc comments
637
+ // for when that is worth doing.
638
+ const fontFamily = 'system-ui, sans-serif';
639
+ const fontWeight = '600';
640
+ const fontSize = Math.max(a.fontSize ?? 22, 13);
641
+ const maxWidth = a.maxWidth ?? 420;
642
+ const measure = (s) => {
643
+ const t = document.createElementNS(NS, 'text');
644
+ t.setAttribute('font-family', fontFamily);
645
+ t.setAttribute('font-size', String(fontSize));
646
+ t.setAttribute('font-weight', fontWeight);
647
+ t.textContent = s;
648
+ svg.append(t);
649
+ const w = t.getBBox().width;
650
+ t.remove();
651
+ return w;
652
+ };
653
+ // A newline in the text is a hard break, so a callout can author a
654
+ // list; each resulting paragraph then word-wraps on its own within
655
+ // maxWidth. (Splitting the whole string on /\s+/ instead silently
656
+ // flattened an authored list into one run-on paragraph.) An empty
657
+ // paragraph — from a blank line — becomes a '' entry, drawn below as
658
+ // a gap rather than a tspan.
659
+ const lines = [];
660
+ for (const paragraph of a.text.split('\n')) {
661
+ const words = paragraph.trim();
662
+ if (words === '') {
663
+ lines.push('');
664
+ }
665
+ else {
666
+ let cur = '';
667
+ for (const word of words.split(/\s+/)) {
668
+ const test = cur ? `${cur} ${word}` : word;
669
+ if (cur && measure(test) > maxWidth) {
670
+ lines.push(cur);
671
+ cur = word;
672
+ }
673
+ else {
674
+ cur = test;
675
+ }
676
+ }
677
+ lines.push(cur);
678
+ }
679
+ }
680
+ const lineHeight = fontSize * 1.25;
681
+ const text = document.createElementNS(NS, 'text');
682
+ text.setAttribute('x', String(cx));
683
+ text.setAttribute('y', String(cy));
684
+ text.setAttribute('fill', a.textColor ?? '#000');
685
+ text.setAttribute('font-family', fontFamily);
686
+ text.setAttribute('font-size', String(fontSize));
687
+ text.setAttribute('font-weight', fontWeight);
688
+ text.setAttribute('text-anchor', a.textAlign === 'end' ? 'end' : 'start');
689
+ // An empty tspan gets no layout, so a blank line can't be drawn as
690
+ // one: carry its half-line gap into the next real line's dy instead.
691
+ let gap = 0;
692
+ let isFirst = true;
693
+ for (const ln of lines) {
694
+ if (ln === '') {
695
+ gap += lineHeight * 0.5;
696
+ }
697
+ else {
698
+ const tspan = document.createElementNS(NS, 'tspan');
699
+ tspan.setAttribute('x', String(cx));
700
+ tspan.setAttribute('dy', isFirst ? '0' : String(lineHeight + gap));
701
+ tspan.textContent = ln;
702
+ text.append(tspan);
703
+ gap = 0;
704
+ isFirst = false;
705
+ }
706
+ }
707
+ svg.append(text);
708
+ const bbox = text.getBBox();
709
+ const padX = 10;
710
+ const padY = 7;
711
+ const width = bbox.width + padX * 2;
712
+ const height = bbox.height + padY * 2;
713
+ let left = bbox.x - padX;
714
+ let top = bbox.y - padY;
715
+ // A leader pill is placed by its PILL rather than by its text baseline:
716
+ // the facing edge lands on (cx, cy) and the other side of it follows from
717
+ // whatever the label measured. That is what lets `dx` mean the same gap
718
+ // for every callout in a figure, and it is the same measurement the arrow
719
+ // below leaves from — so the two cannot part company.
720
+ //
721
+ // Moved by a transform rather than by rewriting every tspan's x: getBBox
722
+ // reports the element's own user space, so the numbers above stay the ones
723
+ // that were measured.
724
+ if (a.leader && a.target) {
725
+ const wantLeft = a.target.x > cx ? cx - width : cx;
726
+ const wantTop = cy - height / 2;
727
+ text.setAttribute('transform', `translate(${wantLeft - left},${wantTop - top})`);
728
+ left = wantLeft;
729
+ top = wantTop;
730
+ }
731
+ const rect = document.createElementNS(NS, 'rect');
732
+ rect.setAttribute('x', String(left));
733
+ rect.setAttribute('y', String(top));
734
+ rect.setAttribute('width', String(width));
735
+ rect.setAttribute('height', String(height));
736
+ rect.setAttribute('rx', '6');
737
+ rect.setAttribute('fill', a.background ?? '#fff');
738
+ rect.setAttribute('stroke', a.color ?? '#e3242b');
739
+ rect.setAttribute('stroke-width', '3');
740
+ text.before(rect);
741
+ if (a.leader) {
742
+ // Both failures are silent otherwise — the pill draws, the arrow does
743
+ // not, and only a committed PNG says so.
744
+ const line = a.target
745
+ ? leaderArrow({ left, top, width, height }, a.target, color, a.strokeWidth ?? 4)
746
+ : undefined;
747
+ if (line) {
748
+ // under its own pill, so a label that ends up closer than planned
749
+ // reads as a short arrow rather than as a line drawn over white
750
+ rect.before(line);
751
+ }
752
+ else {
753
+ misses.push(JSON.stringify({
754
+ leader: a.target
755
+ ? 'the label sits on what it names — raise dx'
756
+ : 'a leader needs an anchor to point back at',
757
+ text: a.text,
758
+ anchor: a.anchor,
759
+ }));
760
+ }
761
+ }
762
+ }
763
+ reportIfOffFrame(a, drawnFrom);
764
+ }
765
+ return { unresolved: misses, offFrame };
766
+ }