@opendata-ai/openchart-engine 1.2.0 → 2.1.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.js +101 -39
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- 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/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": "1.
|
|
3
|
+
"version": "2.1.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.1.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -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);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { sortByField } from '../utils';
|
|
3
|
+
|
|
4
|
+
describe('sortByField', () => {
|
|
5
|
+
// -----------------------------------------------------------------------
|
|
6
|
+
// Numeric sorting
|
|
7
|
+
// -----------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
it('sorts numeric values ascending', () => {
|
|
10
|
+
const data = [{ v: 30 }, { v: 10 }, { v: 20 }];
|
|
11
|
+
const sorted = sortByField(data, 'v');
|
|
12
|
+
expect(sorted.map((r) => r.v)).toEqual([10, 20, 30]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('sorts negative and positive numbers correctly', () => {
|
|
16
|
+
const data = [{ v: 5 }, { v: -3 }, { v: 0 }, { v: -1 }, { v: 2 }];
|
|
17
|
+
const sorted = sortByField(data, 'v');
|
|
18
|
+
expect(sorted.map((r) => r.v)).toEqual([-3, -1, 0, 2, 5]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('sorts floating point numbers correctly', () => {
|
|
22
|
+
const data = [{ v: 1.5 }, { v: 1.1 }, { v: 1.9 }, { v: 1.3 }];
|
|
23
|
+
const sorted = sortByField(data, 'v');
|
|
24
|
+
expect(sorted.map((r) => r.v)).toEqual([1.1, 1.3, 1.5, 1.9]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// -----------------------------------------------------------------------
|
|
28
|
+
// Date string sorting (ISO format)
|
|
29
|
+
// -----------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
it('sorts ISO date strings (YYYY-MM-DD) lexicographically', () => {
|
|
32
|
+
const data = [
|
|
33
|
+
{ date: '2022-01-01', v: 1 },
|
|
34
|
+
{ date: '2020-06-15', v: 2 },
|
|
35
|
+
{ date: '2021-03-10', v: 3 },
|
|
36
|
+
];
|
|
37
|
+
const sorted = sortByField(data, 'date');
|
|
38
|
+
expect(sorted.map((r) => r.date)).toEqual(['2020-06-15', '2021-03-10', '2022-01-01']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('sorts dates within the same year by month and day', () => {
|
|
42
|
+
const data = [
|
|
43
|
+
{ date: '2020-12-25' },
|
|
44
|
+
{ date: '2020-01-01' },
|
|
45
|
+
{ date: '2020-06-15' },
|
|
46
|
+
{ date: '2020-03-20' },
|
|
47
|
+
];
|
|
48
|
+
const sorted = sortByField(data, 'date');
|
|
49
|
+
expect(sorted.map((r) => r.date)).toEqual([
|
|
50
|
+
'2020-01-01',
|
|
51
|
+
'2020-03-20',
|
|
52
|
+
'2020-06-15',
|
|
53
|
+
'2020-12-25',
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('sorts full ISO datetime strings with time component', () => {
|
|
58
|
+
const data = [
|
|
59
|
+
{ ts: '2020-01-01T23:59:59Z' },
|
|
60
|
+
{ ts: '2020-01-01T00:00:00Z' },
|
|
61
|
+
{ ts: '2020-01-01T12:30:00Z' },
|
|
62
|
+
];
|
|
63
|
+
const sorted = sortByField(data, 'ts');
|
|
64
|
+
expect(sorted.map((r) => r.ts)).toEqual([
|
|
65
|
+
'2020-01-01T00:00:00Z',
|
|
66
|
+
'2020-01-01T12:30:00Z',
|
|
67
|
+
'2020-01-01T23:59:59Z',
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('sorts reverse-ordered dates correctly', () => {
|
|
72
|
+
const data = [
|
|
73
|
+
{ date: '2025-01-01' },
|
|
74
|
+
{ date: '2024-01-01' },
|
|
75
|
+
{ date: '2023-01-01' },
|
|
76
|
+
{ date: '2022-01-01' },
|
|
77
|
+
];
|
|
78
|
+
const sorted = sortByField(data, 'date');
|
|
79
|
+
expect(sorted.map((r) => r.date)).toEqual([
|
|
80
|
+
'2022-01-01',
|
|
81
|
+
'2023-01-01',
|
|
82
|
+
'2024-01-01',
|
|
83
|
+
'2025-01-01',
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// -----------------------------------------------------------------------
|
|
88
|
+
// String-encoded numbers (year columns from CSV data)
|
|
89
|
+
// -----------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
it('sorts string-encoded year numbers numerically', () => {
|
|
92
|
+
const data = [{ year: '2022' }, { year: '2020' }, { year: '2021' }];
|
|
93
|
+
const sorted = sortByField(data, 'year');
|
|
94
|
+
expect(sorted.map((r) => r.year)).toEqual(['2020', '2021', '2022']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('sorts string-encoded decimal numbers numerically', () => {
|
|
98
|
+
const data = [{ v: '10.5' }, { v: '2.3' }, { v: '100.1' }];
|
|
99
|
+
const sorted = sortByField(data, 'v');
|
|
100
|
+
expect(sorted.map((r) => r.v)).toEqual(['2.3', '10.5', '100.1']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// -----------------------------------------------------------------------
|
|
104
|
+
// Date objects
|
|
105
|
+
// -----------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
it('sorts Date objects by timestamp', () => {
|
|
108
|
+
const d1 = new Date('2020-06-15T00:00:00');
|
|
109
|
+
const d2 = new Date('2021-06-15T00:00:00');
|
|
110
|
+
const d3 = new Date('2022-06-15T00:00:00');
|
|
111
|
+
const data = [{ d: d3 }, { d: d1 }, { d: d2 }];
|
|
112
|
+
const sorted = sortByField(data, 'd');
|
|
113
|
+
expect(sorted.map((r) => r.d)).toEqual([d1, d2, d3]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
// Null / undefined handling
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
it('pushes nulls to the end', () => {
|
|
121
|
+
const data = [{ v: null }, { v: 10 }, { v: 30 }, { v: null }, { v: 20 }];
|
|
122
|
+
const sorted = sortByField(data, 'v');
|
|
123
|
+
expect(sorted.map((r) => r.v)).toEqual([10, 20, 30, null, null]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('pushes undefined (missing field) to the end', () => {
|
|
127
|
+
const data = [{ other: 1 }, { v: 10, other: 2 }, { v: 20, other: 3 }];
|
|
128
|
+
const sorted = sortByField(data, 'v');
|
|
129
|
+
expect(sorted.map((r) => r.v)).toEqual([10, 20, undefined]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('handles all-null values without crashing', () => {
|
|
133
|
+
const data = [{ v: null }, { v: null }, { v: null }];
|
|
134
|
+
const sorted = sortByField(data, 'v');
|
|
135
|
+
expect(sorted).toHaveLength(3);
|
|
136
|
+
expect(sorted.every((r) => r.v === null)).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
// Duplicate values
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
it('handles duplicate values preserving both rows', () => {
|
|
144
|
+
const data = [{ v: 20 }, { v: 10 }, { v: 20 }, { v: 10 }];
|
|
145
|
+
const sorted = sortByField(data, 'v');
|
|
146
|
+
expect(sorted.map((r) => r.v)).toEqual([10, 10, 20, 20]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('handles duplicate date strings', () => {
|
|
150
|
+
const data = [
|
|
151
|
+
{ date: '2021-01-01', id: 'c' },
|
|
152
|
+
{ date: '2020-01-01', id: 'b' },
|
|
153
|
+
{ date: '2021-01-01', id: 'a' },
|
|
154
|
+
];
|
|
155
|
+
const sorted = sortByField(data, 'date');
|
|
156
|
+
// Both 2021 rows follow the 2020 row
|
|
157
|
+
expect(sorted[0].date).toBe('2020-01-01');
|
|
158
|
+
expect(sorted[1].date).toBe('2021-01-01');
|
|
159
|
+
expect(sorted[2].date).toBe('2021-01-01');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
// Edge cases
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
it('returns a new array (no mutation)', () => {
|
|
167
|
+
const data = [{ v: 30 }, { v: 10 }];
|
|
168
|
+
const sorted = sortByField(data, 'v');
|
|
169
|
+
expect(sorted).not.toBe(data);
|
|
170
|
+
expect(data[0].v).toBe(30);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('handles empty array', () => {
|
|
174
|
+
expect(sortByField([], 'v')).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('handles single element', () => {
|
|
178
|
+
const data = [{ v: 42 }];
|
|
179
|
+
const sorted = sortByField(data, 'v');
|
|
180
|
+
expect(sorted).toHaveLength(1);
|
|
181
|
+
expect(sorted[0].v).toBe(42);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('handles already-sorted data', () => {
|
|
185
|
+
const data = [{ v: 1 }, { v: 2 }, { v: 3 }];
|
|
186
|
+
const sorted = sortByField(data, 'v');
|
|
187
|
+
expect(sorted.map((r) => r.v)).toEqual([1, 2, 3]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('sorts pure string values lexicographically', () => {
|
|
191
|
+
const data = [{ name: 'cherry' }, { name: 'apple' }, { name: 'banana' }];
|
|
192
|
+
const sorted = sortByField(data, 'name');
|
|
193
|
+
expect(sorted.map((r) => r.name)).toEqual(['apple', 'banana', 'cherry']);
|
|
194
|
+
});
|
|
195
|
+
});
|