@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/README.md +112 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +159 -52
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/compile-chart.test.ts +123 -0
- package/src/annotations/__tests__/compute.test.ts +226 -0
- package/src/annotations/compute.ts +116 -46
- package/src/charts/__tests__/utils.test.ts +195 -0
- package/src/charts/line/__tests__/compute.test.ts +364 -0
- package/src/charts/line/area.ts +9 -3
- package/src/charts/line/compute.ts +5 -2
- package/src/charts/utils.ts +48 -0
- package/src/compile.ts +33 -4
- package/src/compiler/normalize.ts +2 -0
- package/src/compiler/types.ts +4 -0
- package/src/graphs/__tests__/encoding.test.ts +101 -0
- package/src/graphs/compile-graph.ts +6 -1
- package/src/graphs/encoding.ts +30 -6
- package/src/graphs/types.ts +6 -0
- package/src/layout/axes.ts +5 -4
- package/src/layout/scales.ts +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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:
|
|
497
|
-
y:
|
|
498
|
-
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);
|