@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "1.2.0",
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.0.0",
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: 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);
@@ -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
+ });