@opendata-ai/openchart-engine 2.0.0 → 2.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "2.0.0",
48
+ "@opendata-ai/openchart-core": "2.2.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -66,6 +66,7 @@ export function makeLineSpec(): NormalizedChartSpec {
66
66
  theme: {},
67
67
  darkMode: 'off',
68
68
  labels: { density: 'auto', format: '' },
69
+ hiddenSeries: [],
69
70
  };
70
71
  }
71
72
 
@@ -92,6 +93,7 @@ export function makeBarSpec(): NormalizedChartSpec {
92
93
  theme: {},
93
94
  darkMode: 'off',
94
95
  labels: { density: 'auto', format: '' },
96
+ hiddenSeries: [],
95
97
  };
96
98
  }
97
99
 
@@ -120,5 +122,6 @@ export function makeScatterSpec(): NormalizedChartSpec {
120
122
  theme: {},
121
123
  darkMode: 'off',
122
124
  labels: { density: 'auto', format: '' },
125
+ hiddenSeries: [],
123
126
  };
124
127
  }
@@ -260,6 +260,129 @@ describe('compileChart', () => {
260
260
  ),
261
261
  ).toThrow('compileTable');
262
262
  });
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // hiddenSeries
266
+ // ---------------------------------------------------------------------------
267
+
268
+ it('hiddenSeries filters out data for hidden series from marks', () => {
269
+ const spec = {
270
+ ...lineSpec,
271
+ hiddenSeries: ['UK'],
272
+ };
273
+ const layout = compileChart(spec, { width: 600, height: 400 });
274
+
275
+ // With UK hidden, only US marks should be present.
276
+ // Line marks carry a series property.
277
+ const lineMarks = layout.marks.filter((m) => m.type === 'line');
278
+ for (const mark of lineMarks) {
279
+ if (mark.type === 'line') {
280
+ expect(mark.series).not.toBe('UK');
281
+ }
282
+ }
283
+
284
+ // Legend should still have entries for both series (hidden ones are dimmed, not removed)
285
+ expect(layout.legend.entries.length).toBe(2);
286
+ expect(layout.legend.entries.some((e) => e.label === 'US')).toBe(true);
287
+ expect(layout.legend.entries.some((e) => e.label === 'UK')).toBe(true);
288
+ });
289
+
290
+ it('hiddenSeries with all series hidden produces no marks', () => {
291
+ const spec = {
292
+ ...lineSpec,
293
+ hiddenSeries: ['US', 'UK'],
294
+ };
295
+ const layout = compileChart(spec, { width: 600, height: 400 });
296
+ // No data left means no marks
297
+ expect(layout.marks.length).toBe(0);
298
+ });
299
+
300
+ it('hiddenSeries with empty array behaves normally', () => {
301
+ const spec = {
302
+ ...lineSpec,
303
+ hiddenSeries: [],
304
+ };
305
+ const layout = compileChart(spec, { width: 600, height: 400 });
306
+ expect(layout.marks.length).toBeGreaterThan(0);
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // scale.clip
311
+ // ---------------------------------------------------------------------------
312
+
313
+ it('scale.clip filters data rows outside the y-axis domain', () => {
314
+ const spec = {
315
+ type: 'scatter' as const,
316
+ data: [
317
+ { x: 1, y: 5 },
318
+ { x: 2, y: 15 },
319
+ { x: 3, y: 25 },
320
+ { x: 4, y: 35 },
321
+ ],
322
+ encoding: {
323
+ x: { field: 'x', type: 'quantitative' as const },
324
+ y: {
325
+ field: 'y',
326
+ type: 'quantitative' as const,
327
+ scale: { domain: [10, 30] as [number, number], clip: true },
328
+ },
329
+ },
330
+ };
331
+ const layout = compileChart(spec, { width: 600, height: 400 });
332
+
333
+ // Only y=15 and y=25 should remain (y=5 and y=35 are outside [10,30])
334
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
335
+ expect(pointMarks.length).toBe(2);
336
+ });
337
+
338
+ it('scale.clip filters data rows outside the x-axis domain', () => {
339
+ const spec = {
340
+ type: 'scatter' as const,
341
+ data: [
342
+ { x: 1, y: 10 },
343
+ { x: 5, y: 20 },
344
+ { x: 10, y: 30 },
345
+ { x: 15, y: 40 },
346
+ ],
347
+ encoding: {
348
+ x: {
349
+ field: 'x',
350
+ type: 'quantitative' as const,
351
+ scale: { domain: [3, 12] as [number, number], clip: true },
352
+ },
353
+ y: { field: 'y', type: 'quantitative' as const },
354
+ },
355
+ };
356
+ const layout = compileChart(spec, { width: 600, height: 400 });
357
+
358
+ // Only x=5 and x=10 should remain
359
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
360
+ expect(pointMarks.length).toBe(2);
361
+ });
362
+
363
+ it('scale.clip=false does not filter data even with domain set', () => {
364
+ const spec = {
365
+ type: 'scatter' as const,
366
+ data: [
367
+ { x: 1, y: 5 },
368
+ { x: 2, y: 15 },
369
+ { x: 3, y: 25 },
370
+ ],
371
+ encoding: {
372
+ x: { field: 'x', type: 'quantitative' as const },
373
+ y: {
374
+ field: 'y',
375
+ type: 'quantitative' as const,
376
+ scale: { domain: [10, 20] as [number, number], clip: false },
377
+ },
378
+ },
379
+ };
380
+ const layout = compileChart(spec, { width: 600, height: 400 });
381
+
382
+ // All 3 points should still be present (clip is false)
383
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
384
+ expect(pointMarks.length).toBe(3);
385
+ });
263
386
  });
264
387
 
265
388
  describe('compileTable', () => {
@@ -451,4 +451,230 @@ describe('computeAnnotations', () => {
451
451
  expect(dy).toBe(-10);
452
452
  });
453
453
  });
454
+
455
+ // -----------------------------------------------------------------
456
+ // Connector origin auto-selection
457
+ // -----------------------------------------------------------------
458
+
459
+ describe('connector origin auto-selection', () => {
460
+ it('connector starts from top edge when data point is above label', () => {
461
+ // Push label far below AND to the right so the top-center is unambiguously closest
462
+ const spec = makeSpec([
463
+ {
464
+ type: 'text',
465
+ x: '2020-01-01',
466
+ y: 20,
467
+ text: 'Below point',
468
+ anchor: 'bottom',
469
+ offset: { dx: 0, dy: 150 },
470
+ },
471
+ ]);
472
+ const scales = computeScales(spec, chartArea, spec.data);
473
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
474
+
475
+ const label = annotations[0].label!;
476
+ const connector = label.connector!;
477
+ const fontSize = 12; // DEFAULT_ANNOTATION_FONT_SIZE
478
+
479
+ // Data point is far above the label, so connector should exit from the top edge
480
+ const topEdgeY = label.y - fontSize;
481
+ expect(connector.from.y).toBeCloseTo(topEdgeY, 0);
482
+ });
483
+
484
+ it('connector starts from bottom edge when data point is below label', () => {
485
+ // Push label far above the data point
486
+ const spec = makeSpec([
487
+ {
488
+ type: 'text',
489
+ x: '2020-01-01',
490
+ y: 20,
491
+ text: 'Above point',
492
+ anchor: 'top',
493
+ offset: { dx: 0, dy: -80 },
494
+ },
495
+ ]);
496
+ const scales = computeScales(spec, chartArea, spec.data);
497
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
498
+
499
+ const label = annotations[0].label!;
500
+ const connector = label.connector!;
501
+ const fontSize = 12;
502
+ const lineHeight = 1.3;
503
+ const lines = label.text.split('\n');
504
+
505
+ // Data point is below the label, so connector should exit from the bottom edge
506
+ const bottomEdgeY = label.y - fontSize + lines.length * fontSize * lineHeight;
507
+ expect(connector.from.y).toBeCloseTo(bottomEdgeY, 0);
508
+ });
509
+
510
+ it('connector starts from left edge when data point is left of label', () => {
511
+ // Push label far to the right of the data point
512
+ const spec = makeSpec([
513
+ {
514
+ type: 'text',
515
+ x: '2020-01-01',
516
+ y: 20,
517
+ text: 'Right of point',
518
+ anchor: 'right',
519
+ offset: { dx: 120, dy: 0 },
520
+ },
521
+ ]);
522
+ const scales = computeScales(spec, chartArea, spec.data);
523
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
524
+
525
+ const label = annotations[0].label!;
526
+ const connector = label.connector!;
527
+
528
+ // Data point is to the left, so connector should exit from the left edge
529
+ // For single-line text, left edge x = label.x
530
+ expect(connector.from.x).toBeCloseTo(label.x, 0);
531
+ });
532
+
533
+ it('connector starts from right edge when data point is right of label', () => {
534
+ // Push label far to the left of the data point
535
+ const spec = makeSpec([
536
+ {
537
+ type: 'text',
538
+ x: '2021-01-01',
539
+ y: 20,
540
+ text: 'Left of point',
541
+ anchor: 'left',
542
+ offset: { dx: -120, dy: 0 },
543
+ },
544
+ ]);
545
+ const scales = computeScales(spec, chartArea, spec.data);
546
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
547
+
548
+ const label = annotations[0].label!;
549
+ const connector = label.connector!;
550
+
551
+ // Data point is to the right, so connector should exit from the right edge
552
+ // Right edge is at label.x + textWidth
553
+ // For single-line "Left of point" with fontSize=12, weight=400:
554
+ // textWidth = 14 chars * 12 * 0.55 * 1.0 = 92.4
555
+ expect(connector.from.x).toBeGreaterThan(label.x + 50); // well past the center
556
+ });
557
+
558
+ it('multi-line text connector uses correct centered bounding box', () => {
559
+ // Multi-line text with label pushed below data point
560
+ const spec = makeSpec([
561
+ {
562
+ type: 'text',
563
+ x: '2020-01-01',
564
+ y: 20,
565
+ text: 'First line\nSecond line',
566
+ anchor: 'bottom',
567
+ offset: { dx: 0, dy: 80 },
568
+ },
569
+ ]);
570
+ const scales = computeScales(spec, chartArea, spec.data);
571
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
572
+
573
+ const label = annotations[0].label!;
574
+ const connector = label.connector!;
575
+
576
+ // For multi-line, labelX is the center. The connector should exit
577
+ // from the top-center since data point is above.
578
+ // Top-center x should be at labelX (the center of the multi-line text)
579
+ expect(connector.from.x).toBeCloseTo(label.x, 0);
580
+ });
581
+
582
+ it('connector origin changes when label moves to the other side', () => {
583
+ // Same data point, label pushed right vs left
584
+ const specRight = makeSpec([
585
+ {
586
+ type: 'text',
587
+ x: '2020-01-01',
588
+ y: 20,
589
+ text: 'Test label',
590
+ anchor: 'right',
591
+ offset: { dx: 120, dy: 0 },
592
+ },
593
+ ]);
594
+ const specLeft = makeSpec([
595
+ {
596
+ type: 'text',
597
+ x: '2020-01-01',
598
+ y: 20,
599
+ text: 'Test label',
600
+ anchor: 'left',
601
+ offset: { dx: -120, dy: 0 },
602
+ },
603
+ ]);
604
+ const scalesRight = computeScales(specRight, chartArea, specRight.data);
605
+ const scalesLeft = computeScales(specLeft, chartArea, specLeft.data);
606
+
607
+ const annotationsRight = computeAnnotations(specRight, scalesRight, chartArea, fullStrategy);
608
+ const annotationsLeft = computeAnnotations(specLeft, scalesLeft, chartArea, fullStrategy);
609
+
610
+ const labelRight = annotationsRight[0].label!;
611
+ const labelLeft = annotationsLeft[0].label!;
612
+ const fromRight = labelRight.connector!.from;
613
+ const fromLeft = labelLeft.connector!.from;
614
+
615
+ // When label is to the right of data, connector exits from left edge (= label.x)
616
+ expect(fromRight.x).toBeCloseTo(labelRight.x, 0);
617
+ // When label is to the left of data, connector exits from right edge (label.x + textWidth)
618
+ expect(fromLeft.x).toBeGreaterThan(labelLeft.x + 30); // well past the left edge
619
+ });
620
+
621
+ it('connectorOffset is still applied on top of auto-selected origin', () => {
622
+ const specWithOffset = makeSpec([
623
+ {
624
+ type: 'text',
625
+ x: '2020-01-01',
626
+ y: 20,
627
+ text: 'Offset test',
628
+ anchor: 'bottom',
629
+ offset: { dx: 0, dy: 80 },
630
+ connectorOffset: { from: { dx: 10, dy: 5 } },
631
+ },
632
+ ]);
633
+ const specNoOffset = makeSpec([
634
+ {
635
+ type: 'text',
636
+ x: '2020-01-01',
637
+ y: 20,
638
+ text: 'Offset test',
639
+ anchor: 'bottom',
640
+ offset: { dx: 0, dy: 80 },
641
+ },
642
+ ]);
643
+ const scales = computeScales(specWithOffset, chartArea, specWithOffset.data);
644
+
645
+ const withOffset = computeAnnotations(specWithOffset, scales, chartArea, fullStrategy);
646
+ const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
647
+
648
+ const fromWith = withOffset[0].label!.connector!.from;
649
+ const fromWithout = withoutOffset[0].label!.connector!.from;
650
+
651
+ expect(fromWith.x - fromWithout.x).toBeCloseTo(10, 0);
652
+ expect(fromWith.y - fromWithout.y).toBeCloseTo(5, 0);
653
+ });
654
+
655
+ it('curve connector still uses right edge regardless of data point position', () => {
656
+ // Push label below and to the right, but curve should still start from right edge
657
+ const spec = makeSpec([
658
+ {
659
+ type: 'text',
660
+ x: '2020-01-01',
661
+ y: 20,
662
+ text: 'Curve test',
663
+ anchor: 'bottom',
664
+ offset: { dx: 0, dy: 80 },
665
+ connector: 'curve',
666
+ },
667
+ ]);
668
+ const scales = computeScales(spec, chartArea, spec.data);
669
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
670
+
671
+ const label = annotations[0].label!;
672
+ const connector = label.connector!;
673
+
674
+ // Curve should start from right edge of text, not top edge
675
+ // Right edge x ≈ label.x + textWidth
676
+ // "Curve test" = 10 chars * 12 * 0.55 = 66
677
+ expect(connector.from.x).toBeCloseTo(label.x + 66, 1);
678
+ });
679
+ });
454
680
  });
@@ -35,6 +35,7 @@ import type { ResolvedScales } from '../layout/scales';
35
35
 
36
36
  const DEFAULT_ANNOTATION_FONT_SIZE = 12;
37
37
  const DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
38
+ const DEFAULT_LINE_HEIGHT = 1.3;
38
39
  const DEFAULT_RANGE_FILL = '#f0c040';
39
40
  const DEFAULT_RANGE_OPACITY = 0.15;
40
41
  const DEFAULT_REFLINE_DASH = '4 3';
@@ -97,11 +98,36 @@ function makeAnnotationLabelStyle(
97
98
  fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
98
99
  fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
99
100
  fill: fill ?? defaultFill,
100
- lineHeight: 1.3,
101
+ lineHeight: DEFAULT_LINE_HEIGHT,
101
102
  textAnchor: 'start',
102
103
  };
103
104
  }
104
105
 
106
+ /**
107
+ * Compute the bounding box of annotation text at a given label position.
108
+ * Multi-line text is centered at labelX; single-line starts at labelX.
109
+ */
110
+ function computeTextBounds(
111
+ labelX: number,
112
+ labelY: number,
113
+ text: string,
114
+ fontSize: number,
115
+ fontWeight: number,
116
+ ): Rect {
117
+ const lines = text.split('\n');
118
+ const isMultiLine = lines.length > 1;
119
+ const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
120
+ const totalHeight = lines.length * fontSize * DEFAULT_LINE_HEIGHT;
121
+ const x = isMultiLine ? labelX - maxWidth / 2 : labelX;
122
+
123
+ return {
124
+ x,
125
+ y: labelY - fontSize,
126
+ width: maxWidth,
127
+ height: totalHeight,
128
+ };
129
+ }
130
+
105
131
  /**
106
132
  * Apply anchor direction to compute label offset from data point.
107
133
  * Returns { dx, dy } pixel offsets.
@@ -144,6 +170,57 @@ function applyOffset(
144
170
  };
145
171
  }
146
172
 
173
+ // ---------------------------------------------------------------------------
174
+ // Connector origin: pick the edge midpoint closest to the data point
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Compute the connector origin point on the text bounding box.
179
+ * For straight connectors, finds the edge midpoint (top, bottom, left, right)
180
+ * closest to the data point. For curve connectors, always uses the right edge.
181
+ */
182
+ function computeConnectorOrigin(
183
+ labelX: number,
184
+ labelY: number,
185
+ text: string,
186
+ fontSize: number,
187
+ fontWeight: number,
188
+ targetX: number,
189
+ targetY: number,
190
+ connectorStyle: 'straight' | 'curve',
191
+ ): { x: number; y: number } {
192
+ const box = computeTextBounds(labelX, labelY, text, fontSize, fontWeight);
193
+ const boxCenterX = box.x + box.width / 2;
194
+ const boxCenterY = box.y + box.height / 2;
195
+
196
+ // Curve connectors always start from the right edge
197
+ if (connectorStyle === 'curve') {
198
+ return {
199
+ x: box.x + box.width,
200
+ y: boxCenterY,
201
+ };
202
+ }
203
+
204
+ // Normalize the vector from box center to target by the box half-dimensions.
205
+ // This accounts for the box aspect ratio: a wide text box should prefer
206
+ // top/bottom exits even when the target is also offset horizontally.
207
+ const halfW = box.width / 2 || 1;
208
+ const halfH = box.height / 2 || 1;
209
+ const ndx = (targetX - boxCenterX) / halfW;
210
+ const ndy = (targetY - boxCenterY) / halfH;
211
+
212
+ if (Math.abs(ndy) >= Math.abs(ndx)) {
213
+ // Target is more above/below than left/right → use top or bottom edge
214
+ return ndy < 0
215
+ ? { x: boxCenterX, y: box.y } // top
216
+ : { x: boxCenterX, y: box.y + box.height }; // bottom
217
+ }
218
+ // Target is more left/right → use left or right edge
219
+ return ndx < 0
220
+ ? { x: box.x, y: boxCenterY } // left
221
+ : { x: box.x + box.width, y: boxCenterY }; // right
222
+ }
223
+
147
224
  // ---------------------------------------------------------------------------
148
225
  // Text annotation
149
226
  // ---------------------------------------------------------------------------
@@ -178,25 +255,19 @@ function resolveTextAnnotation(
178
255
  const showConnector = annotation.connector !== false;
179
256
  const connectorStyle = annotation.connector === 'curve' ? 'curve' : 'straight';
180
257
 
181
- // Compute connector origin based on style and text layout
258
+ // Compute connector origin: pick the edge midpoint closest to the data point
182
259
  const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
183
260
  const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
184
- const lines = annotation.text.split('\n');
185
- const lineHeight = 1.3;
186
- let connectorFromX: number;
187
- if (connectorStyle === 'curve') {
188
- // Curved connectors start from the right edge of the text
189
- connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight);
190
- } else if (lines.length > 1) {
191
- // Multi-line text uses text-anchor: middle, so labelX is already the center
192
- connectorFromX = labelX;
193
- } else {
194
- // Straight connectors start from the horizontal center of the text
195
- connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight) / 2;
196
- }
197
-
198
- // Connector from.y sits at the bottom of the text block
199
- const connectorFromY = labelY + (lines.length - 1) * fontSize * lineHeight + fontSize * 0.3;
261
+ const { x: connectorFromX, y: connectorFromY } = computeConnectorOrigin(
262
+ labelX,
263
+ labelY,
264
+ annotation.text,
265
+ fontSize,
266
+ fontWeight,
267
+ px,
268
+ py,
269
+ connectorStyle,
270
+ );
200
271
 
201
272
  // Apply user-provided connector endpoint offsets
202
273
  const baseFrom = { x: connectorFromX, y: connectorFromY };
@@ -402,25 +473,9 @@ function resolveRefLineAnnotation(
402
473
 
403
474
  /** Estimate the bounding box of an annotation label. */
404
475
  function estimateLabelBounds(label: ResolvedLabel): Rect {
405
- const lines = label.text.split('\n');
406
476
  const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
407
477
  const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
408
- const lineHeight = label.style.lineHeight ?? 1.3;
409
-
410
- const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
411
- const totalHeight = lines.length * fontSize * lineHeight;
412
-
413
- // Multi-line text is rendered with text-anchor: middle by the SVG renderer,
414
- // so the text is centered at label.x. Single-line uses the style's textAnchor.
415
- const isMultiLine = lines.length > 1;
416
- const anchorX = isMultiLine ? label.x - maxWidth / 2 : label.x;
417
-
418
- return {
419
- x: anchorX,
420
- y: label.y - fontSize,
421
- width: maxWidth,
422
- height: totalHeight,
423
- };
478
+ return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
424
479
  }
425
480
 
426
481
  /** Check if two rects overlap. */
@@ -491,19 +546,34 @@ function nudgeAnnotationFromObstacles(
491
546
  candidates.sort((a, b) => a.distance - b.distance);
492
547
 
493
548
  for (const { dx, dy } of candidates) {
549
+ const newLabelX = annotation.label.x + dx;
550
+ const newLabelY = annotation.label.y + dy;
551
+
552
+ // Recompute connector origin for the new label position so the connector
553
+ // exits from the edge closest to the data point after nudging.
554
+ let newConnector = annotation.label.connector;
555
+ if (newConnector) {
556
+ const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
557
+ const annFontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
558
+ const connStyle = newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
559
+ const newFrom = computeConnectorOrigin(
560
+ newLabelX,
561
+ newLabelY,
562
+ annotation.label.text,
563
+ annFontSize,
564
+ annFontWeight,
565
+ px,
566
+ py,
567
+ connStyle,
568
+ );
569
+ newConnector = { ...newConnector, from: newFrom };
570
+ }
571
+
494
572
  const candidateLabel: ResolvedLabel = {
495
573
  ...annotation.label,
496
- x: annotation.label.x + dx,
497
- y: annotation.label.y + dy,
498
- connector: annotation.label.connector
499
- ? {
500
- ...annotation.label.connector,
501
- from: {
502
- x: annotation.label.connector.from.x + dx,
503
- y: annotation.label.connector.from.y + dy,
504
- },
505
- }
506
- : undefined,
574
+ x: newLabelX,
575
+ y: newLabelY,
576
+ connector: newConnector,
507
577
  };
508
578
 
509
579
  const candidateBounds = estimateLabelBounds(candidateLabel);