@opendata-ai/openchart-engine 2.10.0 → 2.11.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.10.0",
3
+ "version": "2.11.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.10.0",
48
+ "@opendata-ai/openchart-core": "2.11.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -1,8 +1,8 @@
1
- import type { LayoutStrategy } from '@opendata-ai/openchart-core';
1
+ import type { AxisTick, LayoutStrategy } from '@opendata-ai/openchart-core';
2
2
  import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import type { NormalizedChartSpec } from '../compiler/types';
5
- import { computeAxes, effectiveDensity } from '../layout/axes';
5
+ import { computeAxes, effectiveDensity, thinTicksUntilFit, ticksOverlap } from '../layout/axes';
6
6
  import { computeScales } from '../layout/scales';
7
7
 
8
8
  const lineSpec: NormalizedChartSpec = {
@@ -300,3 +300,180 @@ describe('effectiveDensity', () => {
300
300
  expect(effectiveDensity('full', 400, X_MINIMAL, X_REDUCED)).toBe('full');
301
301
  });
302
302
  });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // ticksOverlap unit tests
306
+ // ---------------------------------------------------------------------------
307
+
308
+ describe('ticksOverlap', () => {
309
+ const fontSize = 12;
310
+ const fontWeight = 400;
311
+
312
+ it('returns false for empty or single tick', () => {
313
+ expect(ticksOverlap([], fontSize, fontWeight)).toBe(false);
314
+ expect(ticksOverlap([{ value: 0, position: 100, label: 'A' }], fontSize, fontWeight)).toBe(
315
+ false,
316
+ );
317
+ });
318
+
319
+ it('returns false when ticks are well-spaced', () => {
320
+ const ticks: AxisTick[] = [
321
+ { value: 0, position: 0, label: 'A' },
322
+ { value: 1, position: 200, label: 'B' },
323
+ { value: 2, position: 400, label: 'C' },
324
+ ];
325
+ expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(false);
326
+ });
327
+
328
+ it('returns true when ticks are too close together', () => {
329
+ const ticks: AxisTick[] = [
330
+ { value: 0, position: 0, label: 'January 2025' },
331
+ { value: 1, position: 30, label: 'February 2025' },
332
+ { value: 2, position: 60, label: 'March 2025' },
333
+ ];
334
+ expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(true);
335
+ });
336
+
337
+ it('uses measureText when provided', () => {
338
+ const ticks: AxisTick[] = [
339
+ { value: 0, position: 0, label: 'A' },
340
+ { value: 1, position: 100, label: 'B' },
341
+ ];
342
+
343
+ // With a measureText that reports very wide labels, they should overlap
344
+ const wideMeasure = () => ({ width: 200, height: 12 });
345
+ expect(ticksOverlap(ticks, fontSize, fontWeight, wideMeasure)).toBe(true);
346
+
347
+ // With a measureText that reports very narrow labels, they should not overlap
348
+ const narrowMeasure = () => ({ width: 1, height: 12 });
349
+ expect(ticksOverlap(ticks, fontSize, fontWeight, narrowMeasure)).toBe(false);
350
+ });
351
+ });
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // thinTicksUntilFit unit tests
355
+ // ---------------------------------------------------------------------------
356
+
357
+ describe('thinTicksUntilFit', () => {
358
+ const fontSize = 12;
359
+ const fontWeight = 400;
360
+
361
+ it('returns original array when no overlap', () => {
362
+ const ticks: AxisTick[] = [
363
+ { value: 0, position: 0, label: 'A' },
364
+ { value: 1, position: 200, label: 'B' },
365
+ { value: 2, position: 400, label: 'C' },
366
+ ];
367
+ const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
368
+ expect(result).toBe(ticks); // Same reference, not a copy
369
+ });
370
+
371
+ it('thins overlapping ticks while keeping first and last', () => {
372
+ // Ticks at 10px intervals with long labels that will overlap
373
+ const ticks: AxisTick[] = Array.from({ length: 8 }, (_, i) => ({
374
+ value: i,
375
+ position: i * 10,
376
+ label: 'Long Label Text',
377
+ }));
378
+
379
+ const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
380
+
381
+ // Should have fewer ticks than the original
382
+ expect(result.length).toBeLessThan(ticks.length);
383
+ // Should always keep first and last
384
+ expect(result[0]).toBe(ticks[0]);
385
+ expect(result[result.length - 1]).toBe(ticks[ticks.length - 1]);
386
+ // Should have at least MIN_TICK_COUNT (2)
387
+ expect(result.length).toBeGreaterThanOrEqual(2);
388
+ });
389
+
390
+ it('returns at least 2 ticks even when labels are very wide', () => {
391
+ const ticks: AxisTick[] = [
392
+ { value: 0, position: 0, label: 'Very Long Label That Is Wide' },
393
+ { value: 1, position: 5, label: 'Another Very Long Label' },
394
+ { value: 2, position: 10, label: 'Yet Another Long Label Here' },
395
+ ];
396
+ const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
397
+ expect(result.length).toBeGreaterThanOrEqual(2);
398
+ });
399
+ });
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Text-aware tick density integration tests
403
+ // ---------------------------------------------------------------------------
404
+
405
+ describe('text-aware tick density', () => {
406
+ it('produces fewer x-axis ticks in narrow charts', () => {
407
+ const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
408
+ const wideArea = { x: 50, y: 50, width: 800, height: 300 };
409
+
410
+ const scalesNarrow = computeScales(lineSpec, narrowArea, lineSpec.data);
411
+ const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
412
+
413
+ const axesNarrow = computeAxes(scalesNarrow, narrowArea, fullStrategy, theme);
414
+ const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
415
+
416
+ expect(axesNarrow.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
417
+ });
418
+
419
+ it('does not thin x-axis ticks when explicit tickCount is set', () => {
420
+ const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
421
+ const specWithTickCount: NormalizedChartSpec = {
422
+ ...lineSpec,
423
+ encoding: {
424
+ x: { field: 'date', type: 'temporal', axis: { tickCount: 8 } },
425
+ y: { field: 'value', type: 'quantitative' },
426
+ },
427
+ };
428
+
429
+ const scales = computeScales(specWithTickCount, narrowArea, specWithTickCount.data);
430
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
431
+
432
+ // With explicit tickCount, the engine should not thin
433
+ // D3 may return fewer than 8 for this small dataset, but the point is
434
+ // thinTicksUntilFit should not be called
435
+ expect(axes.x!.ticks.length).toBeGreaterThan(0);
436
+ });
437
+
438
+ it('band scale shows all categories regardless of width', () => {
439
+ const categories = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
440
+ const barSpec: NormalizedChartSpec = {
441
+ ...lineSpec,
442
+ type: 'column',
443
+ data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
444
+ encoding: {
445
+ x: { field: 'cat', type: 'nominal' },
446
+ y: { field: 'val', type: 'quantitative' },
447
+ },
448
+ };
449
+
450
+ const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
451
+ const scales = computeScales(barSpec, narrowArea, barSpec.data);
452
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
453
+
454
+ // Band scales show all categories (auto-rotation handles overlap instead)
455
+ expect(axes.x!.ticks.length).toBe(categories.length);
456
+ });
457
+
458
+ it('passes measureText to auto-rotation detection', () => {
459
+ const barSpec: NormalizedChartSpec = {
460
+ ...lineSpec,
461
+ type: 'column',
462
+ data: [
463
+ { cat: 'A', val: 10 },
464
+ { cat: 'B', val: 20 },
465
+ ],
466
+ encoding: {
467
+ x: { field: 'cat', type: 'nominal' },
468
+ y: { field: 'val', type: 'quantitative' },
469
+ },
470
+ };
471
+
472
+ // measureText that reports very wide labels should trigger rotation
473
+ const wideMeasure = () => ({ width: 1000, height: 12 });
474
+ const scales = computeScales(barSpec, chartArea, barSpec.data);
475
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
476
+
477
+ expect(axes.x!.tickAngle).toBe(-45);
478
+ });
479
+ });
@@ -354,8 +354,8 @@ describe('computeAnnotations', () => {
354
354
  // The offset annotation should be shifted by the dx/dy amount
355
355
  const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
356
356
  const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
357
- expect(dx).toBe(20);
358
- expect(dy).toBe(-30);
357
+ expect(dx).toBeCloseTo(20);
358
+ expect(dy).toBeCloseTo(-30);
359
359
  });
360
360
  });
361
361
 
@@ -514,8 +514,8 @@ describe('computeAnnotations', () => {
514
514
 
515
515
  const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
516
516
  const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
517
- expect(dx).toBe(20);
518
- expect(dy).toBe(10);
517
+ expect(dx).toBeCloseTo(20);
518
+ expect(dy).toBeCloseTo(10);
519
519
  });
520
520
  });
521
521
 
@@ -773,4 +773,173 @@ describe('computeAnnotations', () => {
773
773
  expect(connector.from.x).toBeCloseTo(label.x + 66, 1);
774
774
  });
775
775
  });
776
+
777
+ // -----------------------------------------------------------------
778
+ // Annotation-to-annotation collision resolution
779
+ // -----------------------------------------------------------------
780
+
781
+ describe('annotation-to-annotation collision', () => {
782
+ it('nudges second annotation when two overlap at the same data point', () => {
783
+ const spec = makeSpec([
784
+ { type: 'text', x: '2020-01-01', y: 20, text: 'First note' },
785
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Second note' },
786
+ ]);
787
+ const scales = computeScales(spec, chartArea, spec.data);
788
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
789
+
790
+ expect(annotations).toHaveLength(2);
791
+
792
+ const label1 = annotations[0].label!;
793
+ const label2 = annotations[1].label!;
794
+
795
+ // Both should be visible
796
+ expect(label1.visible).toBe(true);
797
+ expect(label2.visible).toBe(true);
798
+
799
+ // Labels should not overlap: their positions should differ
800
+ const samePosition = label1.x === label2.x && label1.y === label2.y;
801
+ expect(samePosition).toBe(false);
802
+ });
803
+
804
+ it('nudges second annotation when nearby data points produce overlapping labels', () => {
805
+ const spec = makeSpec([
806
+ { type: 'text', x: '2020-01-01', y: 20, text: 'First annotation' },
807
+ { type: 'text', x: '2020-01-01', y: 21, text: 'Second annotation' },
808
+ ]);
809
+ const scales = computeScales(spec, chartArea, spec.data);
810
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
811
+
812
+ expect(annotations).toHaveLength(2);
813
+
814
+ const label1 = annotations[0].label!;
815
+ const label2 = annotations[1].label!;
816
+
817
+ // After collision resolution, bounding boxes should not overlap
818
+ // Check that at least one coordinate differs meaningfully
819
+ const dy = Math.abs(label1.y - label2.y);
820
+ const dx = Math.abs(label1.x - label2.x);
821
+ expect(dx + dy).toBeGreaterThan(5);
822
+ });
823
+
824
+ it('recomputes connector origin after nudging', () => {
825
+ const spec = makeSpec([
826
+ { type: 'text', x: '2020-01-01', y: 20, text: 'First note' },
827
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Second note' },
828
+ ]);
829
+ const scales = computeScales(spec, chartArea, spec.data);
830
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
831
+
832
+ const nudgedLabel = annotations[1].label!;
833
+
834
+ // The nudged annotation should still have a connector
835
+ expect(nudgedLabel.connector).toBeDefined();
836
+
837
+ // The connector "from" should be near the nudged label position, not the original
838
+ const connFrom = nudgedLabel.connector!.from;
839
+ const labelCenterX = nudgedLabel.x;
840
+ const labelCenterY = nudgedLabel.y;
841
+
842
+ // Connector origin should be within a reasonable distance of the label
843
+ const distFromLabel = Math.sqrt(
844
+ (connFrom.x - labelCenterX) ** 2 + (connFrom.y - labelCenterY) ** 2,
845
+ );
846
+ // Should be within the label's bounding box range (text width + height)
847
+ expect(distFromLabel).toBeLessThan(200);
848
+ });
849
+
850
+ it('resolves three overlapping annotations without any collision', () => {
851
+ const spec = makeSpec([
852
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Note A' },
853
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Note B' },
854
+ { type: 'text', x: '2020-01-01', y: 20, text: 'Note C' },
855
+ ]);
856
+ const scales = computeScales(spec, chartArea, spec.data);
857
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
858
+
859
+ expect(annotations).toHaveLength(3);
860
+
861
+ // All three should be visible
862
+ for (const ann of annotations) {
863
+ expect(ann.label!.visible).toBe(true);
864
+ }
865
+
866
+ // All three should have distinct positions
867
+ const positions = annotations.map((a) => `${a.label!.x.toFixed(1)},${a.label!.y.toFixed(1)}`);
868
+ const unique = new Set(positions);
869
+ expect(unique.size).toBe(3);
870
+ });
871
+
872
+ it('does not nudge annotations that already have distinct positions', () => {
873
+ const spec = makeSpec([
874
+ {
875
+ type: 'text',
876
+ x: '2020-01-01',
877
+ y: 10,
878
+ text: 'Low point',
879
+ anchor: 'bottom',
880
+ offset: { dx: 0, dy: 40 },
881
+ },
882
+ {
883
+ type: 'text',
884
+ x: '2022-01-01',
885
+ y: 40,
886
+ text: 'High point',
887
+ anchor: 'top',
888
+ offset: { dx: 0, dy: -40 },
889
+ },
890
+ ]);
891
+ const scales = computeScales(spec, chartArea, spec.data);
892
+
893
+ // Compute once to get positions
894
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
895
+
896
+ expect(annotations).toHaveLength(2);
897
+
898
+ // These annotations are far apart, so positions should match what they'd
899
+ // be without collision resolution (i.e., not nudged)
900
+ const specSingle1 = makeSpec([spec.annotations[0]]);
901
+ const specSingle2 = makeSpec([spec.annotations[1]]);
902
+ const single1 = computeAnnotations(specSingle1, scales, chartArea, fullStrategy);
903
+ const single2 = computeAnnotations(specSingle2, scales, chartArea, fullStrategy);
904
+
905
+ expect(annotations[0].label!.x).toBeCloseTo(single1[0].label!.x, 1);
906
+ expect(annotations[0].label!.y).toBeCloseTo(single1[0].label!.y, 1);
907
+ expect(annotations[1].label!.x).toBeCloseTo(single2[0].label!.x, 1);
908
+ expect(annotations[1].label!.y).toBeCloseTo(single2[0].label!.y, 1);
909
+ });
910
+ });
911
+
912
+ // -----------------------------------------------------------------
913
+ // Obstacle avoidance (label bounds as obstacles)
914
+ // -----------------------------------------------------------------
915
+
916
+ describe('obstacle avoidance', () => {
917
+ it('nudges annotation away from an obstacle rect at the same position', () => {
918
+ const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Annotation' }]);
919
+ const scales = computeScales(spec, chartArea, spec.data);
920
+
921
+ // Place an obstacle rect exactly where the annotation would land
922
+ const withoutObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy);
923
+ const originalLabel = withoutObstacles[0].label!;
924
+
925
+ const obstacle: Rect = {
926
+ x: originalLabel.x - 5,
927
+ y: originalLabel.y - 5,
928
+ width: 80,
929
+ height: 30,
930
+ };
931
+
932
+ const withObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy, false, [
933
+ obstacle,
934
+ ]);
935
+
936
+ expect(withObstacles).toHaveLength(1);
937
+ const nudgedLabel = withObstacles[0].label!;
938
+ expect(nudgedLabel.visible).toBe(true);
939
+
940
+ // The annotation should have moved away from the obstacle
941
+ const moved = nudgedLabel.x !== originalLabel.x || nudgedLabel.y !== originalLabel.y;
942
+ expect(moved).toBe(true);
943
+ });
944
+ });
776
945
  });
@@ -24,7 +24,7 @@ import type {
24
24
  TextAnnotation,
25
25
  TextStyle,
26
26
  } from '@opendata-ai/openchart-core';
27
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
27
+ import { detectCollision, estimateTextWidth } from '@opendata-ai/openchart-core';
28
28
  import type { ScaleBand, ScaleLinear, ScaleTime } from 'd3-scale';
29
29
  import type { NormalizedChartSpec } from '../compiler/types';
30
30
  import type { ResolvedScales } from '../layout/scales';
@@ -549,14 +549,43 @@ function estimateLabelBounds(label: ResolvedLabel): Rect {
549
549
  return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
550
550
  }
551
551
 
552
- /** Check if two rects overlap. */
553
- function rectsOverlap(a: Rect, b: Rect): boolean {
554
- return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
555
- }
556
-
557
552
  /** Padding between annotation and obstacle when nudging. */
558
553
  const NUDGE_PADDING = 6;
559
554
 
555
+ /**
556
+ * Generate candidate displacement vectors to move `selfBounds` clear of each
557
+ * obstacle in 4 directions (below, above, left, right), sorted by smallest
558
+ * movement first.
559
+ */
560
+ function generateNudgeCandidates(
561
+ selfBounds: Rect,
562
+ obstacles: Rect[],
563
+ padding: number,
564
+ ): { dx: number; dy: number; distance: number }[] {
565
+ const candidates: { dx: number; dy: number; distance: number }[] = [];
566
+
567
+ for (const obs of obstacles) {
568
+ // Below: shift self so its top edge clears the obstacle bottom
569
+ const belowDy = obs.y + obs.height + padding - selfBounds.y;
570
+ candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
571
+
572
+ // Above: shift self so its bottom edge clears the obstacle top
573
+ const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
574
+ candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
575
+
576
+ // Left: shift self so its right edge clears the obstacle left
577
+ const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
578
+ candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
579
+
580
+ // Right: shift self so its left edge clears the obstacle right
581
+ const rightDx = obs.x + obs.width + padding - selfBounds.x;
582
+ candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
583
+ }
584
+
585
+ candidates.sort((a, b) => a.distance - b.distance);
586
+ return candidates;
587
+ }
588
+
560
589
  /**
561
590
  * Try to reposition a text annotation to avoid overlapping with obstacle rects
562
591
  * (legend bounds, etc.). First tries standard anchor alternatives, then
@@ -573,7 +602,7 @@ function nudgeAnnotationFromObstacles(
573
602
 
574
603
  const labelBounds = estimateLabelBounds(annotation.label);
575
604
  const collidingObs = obstacles.filter(
576
- (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(labelBounds, obs),
605
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs),
577
606
  );
578
607
 
579
608
  if (collidingObs.length === 0) return false;
@@ -583,39 +612,9 @@ function nudgeAnnotationFromObstacles(
583
612
  const py = resolvePosition(originalAnnotation.y, scales.y);
584
613
  if (px === null || py === null) return false;
585
614
 
586
- // Generate candidate positions: calculated offsets to clear each obstacle
587
- const candidates: { dx: number; dy: number; distance: number }[] = [];
615
+ const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
588
616
  const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split('\n').length);
589
617
 
590
- for (const obs of collidingObs) {
591
- // Below obstacle: shift label so its top edge clears the obstacle bottom
592
- const currentLabelTop = labelBounds.y;
593
- const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
594
- const belowDy = targetLabelTop - currentLabelTop;
595
- candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
596
-
597
- // Above obstacle: shift label so its bottom edge clears the obstacle top
598
- const currentLabelBottom = labelBounds.y + labelBounds.height;
599
- const targetLabelBottom = obs.y - NUDGE_PADDING;
600
- const aboveDy = targetLabelBottom - currentLabelBottom;
601
- candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
602
-
603
- // Left of obstacle: shift label so its right edge clears the obstacle left
604
- const currentLabelRight = labelBounds.x + labelBounds.width;
605
- const targetLabelRight = obs.x - NUDGE_PADDING;
606
- const leftDx = targetLabelRight - currentLabelRight;
607
- candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
608
-
609
- // Right of obstacle: shift label so its left edge clears the obstacle right
610
- const currentLabelLeft = labelBounds.x;
611
- const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
612
- const rightDx = targetLabelLeft - currentLabelLeft;
613
- candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
614
- }
615
-
616
- // Sort candidates by distance (prefer smallest movement)
617
- candidates.sort((a, b) => a.distance - b.distance);
618
-
619
618
  for (const { dx, dy } of candidates) {
620
619
  const newLabelX = annotation.label.x + dx;
621
620
  const newLabelY = annotation.label.y + dy;
@@ -651,7 +650,7 @@ function nudgeAnnotationFromObstacles(
651
650
 
652
651
  // Check no collisions with any obstacle
653
652
  const stillCollides = obstacles.some(
654
- (obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(candidateBounds, obs),
653
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs),
655
654
  );
656
655
  if (stillCollides) continue;
657
656
 
@@ -660,11 +659,14 @@ function nudgeAnnotationFromObstacles(
660
659
  // the text doesn't go completely off-screen.
661
660
  const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
662
661
  const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
662
+ // Allow nudged labels to extend into the chrome region below the chart
663
+ // (source/footer area) since annotations near the bottom edge often
664
+ // need to shift into that space to avoid marks or the brand watermark.
663
665
  const inBounds =
664
666
  labelCenterX >= chartArea.x &&
665
667
  labelCenterX <= chartArea.x + chartArea.width + 100 &&
666
668
  labelCenterY >= chartArea.y - fontSize &&
667
- labelCenterY <= chartArea.y + chartArea.height + fontSize;
669
+ labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
668
670
 
669
671
  if (inBounds) {
670
672
  // When nudged vertically (directly above/below the data), use a caret
@@ -683,6 +685,117 @@ function nudgeAnnotationFromObstacles(
683
685
  return false;
684
686
  }
685
687
 
688
+ // ---------------------------------------------------------------------------
689
+ // Annotation-to-annotation collision resolution
690
+ // ---------------------------------------------------------------------------
691
+
692
+ /**
693
+ * Resolve collisions between text annotation labels using a greedy algorithm.
694
+ *
695
+ * Iterates through text annotations in order, building a list of "placed"
696
+ * bounding rects. When a later annotation overlaps an already-placed one,
697
+ * it tries offset positions (below, above, left, right) to find a
698
+ * non-colliding spot. Recomputes the connector origin after nudging.
699
+ */
700
+ function resolveAnnotationCollisions(
701
+ annotations: ResolvedAnnotation[],
702
+ originalSpecs: NormalizedChartSpec['annotations'],
703
+ scales: ResolvedScales,
704
+ chartArea: Rect,
705
+ ): void {
706
+ const placedBounds: Rect[] = [];
707
+
708
+ for (let i = 0; i < annotations.length; i++) {
709
+ const annotation = annotations[i];
710
+ if (annotation.type !== 'text' || !annotation.label) {
711
+ continue;
712
+ }
713
+
714
+ const bounds = estimateLabelBounds(annotation.label);
715
+
716
+ // Check against all previously placed annotation labels
717
+ const collidingBounds = placedBounds.filter(
718
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb),
719
+ );
720
+
721
+ if (collidingBounds.length > 0) {
722
+ // Find the original spec to get data point coordinates for connector recomputation
723
+ const originalSpec = originalSpecs[i];
724
+
725
+ if (originalSpec?.type === 'text') {
726
+ const px = resolvePosition(originalSpec.x, scales.x);
727
+ const py = resolvePosition(originalSpec.y, scales.y);
728
+
729
+ if (px !== null && py !== null) {
730
+ const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
731
+ const fontSize = bounds.height / Math.max(1, annotation.label.text.split('\n').length);
732
+
733
+ for (const { dx, dy } of candidates) {
734
+ const newLabelX = annotation.label.x + dx;
735
+ const newLabelY = annotation.label.y + dy;
736
+
737
+ const candidateLabel: ResolvedLabel = {
738
+ ...annotation.label,
739
+ x: newLabelX,
740
+ y: newLabelY,
741
+ };
742
+ const candidateBounds = estimateLabelBounds(candidateLabel);
743
+
744
+ // Check no collisions with any placed label
745
+ const stillCollides = placedBounds.some(
746
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb),
747
+ );
748
+ if (stillCollides) continue;
749
+
750
+ // Check the label center stays reasonably in bounds
751
+ const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
752
+ const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
753
+ const inBounds =
754
+ labelCenterX >= chartArea.x &&
755
+ labelCenterX <= chartArea.x + chartArea.width + 100 &&
756
+ labelCenterY >= chartArea.y - fontSize &&
757
+ labelCenterY <= chartArea.y + chartArea.height + fontSize;
758
+
759
+ if (inBounds) {
760
+ // Recompute connector origin for the new position
761
+ let newConnector = annotation.label.connector;
762
+ if (newConnector) {
763
+ const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
764
+ const annFontWeight =
765
+ annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
766
+ const connStyle =
767
+ newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
768
+ const newFrom = computeConnectorOrigin(
769
+ newLabelX,
770
+ newLabelY,
771
+ annotation.label.text,
772
+ annFontSize,
773
+ annFontWeight,
774
+ px,
775
+ py,
776
+ connStyle,
777
+ );
778
+ newConnector = { ...newConnector, from: newFrom };
779
+ }
780
+
781
+ annotation.label = {
782
+ ...annotation.label,
783
+ x: newLabelX,
784
+ y: newLabelY,
785
+ connector: newConnector,
786
+ };
787
+ break;
788
+ }
789
+ }
790
+ }
791
+ }
792
+ }
793
+
794
+ // Add this annotation's final bounds to the placed list
795
+ placedBounds.push(estimateLabelBounds(annotation.label));
796
+ }
797
+ }
798
+
686
799
  // ---------------------------------------------------------------------------
687
800
  // Public API
688
801
  // ---------------------------------------------------------------------------
@@ -696,7 +809,8 @@ function nudgeAnnotationFromObstacles(
696
809
  *
697
810
  * When obstacle rects are provided (e.g. legend bounds), text annotations
698
811
  * that overlap with them are automatically repositioned using alternate
699
- * anchor directions.
812
+ * anchor directions. After individual obstacle avoidance, annotation-to-
813
+ * annotation collisions are resolved using a greedy placement algorithm.
700
814
  */
701
815
  export function computeAnnotations(
702
816
  spec: NormalizedChartSpec,
@@ -737,6 +851,9 @@ export function computeAnnotations(
737
851
  }
738
852
  }
739
853
 
854
+ // Resolve annotation-to-annotation collisions (greedy, order-preserving)
855
+ resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
856
+
740
857
  // Sort by zIndex (lower first, undefined treated as 0)
741
858
  annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
742
859