@jbrowse/capture 5.0.0-beta.10 → 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.
- package/README.md +52 -2
- package/esm/annotationOverlay.d.ts +77 -0
- package/esm/annotationOverlay.js +766 -0
- package/esm/annotationSpec.d.ts +7 -0
- package/esm/annotationSpec.js +70 -0
- package/esm/annotations.d.ts +14 -0
- package/esm/annotations.js +68 -0
- package/esm/args.d.ts +2 -1
- package/esm/args.js +17 -5
- package/esm/bin.js +10 -21
- package/esm/capture.d.ts +9 -2
- package/esm/capture.js +41 -11
- package/esm/catalog.d.ts +11 -6
- package/esm/catalog.js +37 -14
- package/esm/dotplotAnchor.d.ts +4 -0
- package/esm/dotplotAnchor.js +104 -0
- package/esm/graphAnchor.d.ts +47 -0
- package/esm/graphAnchor.js +120 -0
- package/esm/index.d.ts +2 -0
- package/esm/index.js +1 -0
- package/esm/jsonArgs.d.ts +4 -0
- package/esm/jsonArgs.js +34 -0
- package/esm/poll.d.ts +7 -0
- package/esm/poll.js +20 -0
- package/esm/ready.d.ts +1 -1
- package/esm/ready.js +6 -2
- package/esm/sessionGate.d.ts +8 -2
- package/esm/sessionGate.js +15 -5
- package/esm/url.d.ts +6 -5
- package/esm/url.js +20 -5
- package/esm/version.d.ts +1 -1
- package/esm/version.js +1 -1
- package/esm/waits.js +3 -5
- package/package.json +5 -3
|
@@ -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
|
+
}
|