@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.
@@ -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
+ });
@@ -218,6 +218,182 @@ describe('computeLineMarks', () => {
218
218
  });
219
219
  });
220
220
 
221
+ describe('x-axis sorting', () => {
222
+ it('sorts unsorted temporal data so points increase left-to-right', () => {
223
+ const spec: NormalizedChartSpec = {
224
+ ...makeSingleSeriesSpec(),
225
+ data: [
226
+ { date: '2022-01-01', value: 30 },
227
+ { date: '2020-01-01', value: 10 },
228
+ { date: '2021-01-01', value: 40 },
229
+ ],
230
+ };
231
+ const scales = computeScales(spec, chartArea, spec.data);
232
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
233
+
234
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
235
+ // Points should have monotonically increasing x pixel values
236
+ for (let i = 1; i < lineMark.points.length; i++) {
237
+ expect(lineMark.points[i].x).toBeGreaterThan(lineMark.points[i - 1].x);
238
+ }
239
+ });
240
+
241
+ it('sorts reverse-ordered dates correctly', () => {
242
+ const spec: NormalizedChartSpec = {
243
+ ...makeSingleSeriesSpec(),
244
+ data: [
245
+ { date: '2025-01-01', value: 50 },
246
+ { date: '2024-01-01', value: 40 },
247
+ { date: '2023-01-01', value: 30 },
248
+ { date: '2022-01-01', value: 20 },
249
+ { date: '2021-01-01', value: 10 },
250
+ ],
251
+ };
252
+ const scales = computeScales(spec, chartArea, spec.data);
253
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
254
+
255
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
256
+ expect(lineMark.points).toHaveLength(5);
257
+ for (let i = 1; i < lineMark.points.length; i++) {
258
+ expect(lineMark.points[i].x).toBeGreaterThan(lineMark.points[i - 1].x);
259
+ }
260
+ });
261
+
262
+ it('sorts unsorted numeric x-axis data', () => {
263
+ const spec: NormalizedChartSpec = {
264
+ ...makeSingleSeriesSpec(),
265
+ data: [
266
+ { date: 2022, value: 30 },
267
+ { date: 2020, value: 10 },
268
+ { date: 2021, value: 40 },
269
+ ],
270
+ encoding: {
271
+ x: { field: 'date', type: 'quantitative' },
272
+ y: { field: 'value', type: 'quantitative' },
273
+ },
274
+ };
275
+ const scales = computeScales(spec, chartArea, spec.data);
276
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
277
+
278
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
279
+ for (let i = 1; i < lineMark.points.length; i++) {
280
+ expect(lineMark.points[i].x).toBeGreaterThan(lineMark.points[i - 1].x);
281
+ }
282
+ });
283
+
284
+ it('sorts each series independently in multi-series', () => {
285
+ const spec: NormalizedChartSpec = {
286
+ ...makeMultiSeriesSpec(),
287
+ data: [
288
+ { date: '2022-01-01', value: 30, country: 'US' },
289
+ { date: '2020-01-01', value: 10, country: 'US' },
290
+ { date: '2021-01-01', value: 40, country: 'US' },
291
+ { date: '2022-01-01', value: 45, country: 'UK' },
292
+ { date: '2020-01-01', value: 15, country: 'UK' },
293
+ { date: '2021-01-01', value: 35, country: 'UK' },
294
+ ],
295
+ };
296
+ const scales = computeScales(spec, chartArea, spec.data);
297
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
298
+
299
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
300
+ for (const lm of lineMarks) {
301
+ for (let i = 1; i < lm.points.length; i++) {
302
+ expect(lm.points[i].x).toBeGreaterThan(lm.points[i - 1].x);
303
+ }
304
+ }
305
+ });
306
+
307
+ it('attaches data rows in sorted order on marks', () => {
308
+ const spec: NormalizedChartSpec = {
309
+ ...makeSingleSeriesSpec(),
310
+ data: [
311
+ { date: '2022-01-01', value: 30 },
312
+ { date: '2020-01-01', value: 10 },
313
+ { date: '2021-01-01', value: 40 },
314
+ ],
315
+ };
316
+ const scales = computeScales(spec, chartArea, spec.data);
317
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
318
+
319
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
320
+ // The data array on the mark should be chronologically ordered
321
+ const dates = lineMark.data!.map((r) => r.date);
322
+ expect(dates).toEqual(['2020-01-01', '2021-01-01', '2022-01-01']);
323
+ });
324
+
325
+ it('sorts data before handling null y-value line breaks', () => {
326
+ // Unsorted data with a null in the middle chronologically.
327
+ // After sorting: 2020 (10), 2021 (null), 2022 (30) -> line breaks at 2021
328
+ const spec: NormalizedChartSpec = {
329
+ ...makeSingleSeriesSpec(),
330
+ data: [
331
+ { date: '2022-01-01', value: 30 },
332
+ { date: '2021-01-01', value: null },
333
+ { date: '2020-01-01', value: 10 },
334
+ ],
335
+ };
336
+ const scales = computeScales(spec, chartArea, spec.data);
337
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
338
+
339
+ const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
340
+ // Null is excluded, so only 2 valid points
341
+ expect(lineMark.points).toHaveLength(2);
342
+ // The two valid points should still be left-to-right
343
+ expect(lineMark.points[1].x).toBeGreaterThan(lineMark.points[0].x);
344
+ });
345
+
346
+ it('produces identical output for already-sorted data', () => {
347
+ // Verify sorting doesn't break pre-sorted input (regression check)
348
+ const sorted = makeSingleSeriesSpec(); // already chronological
349
+ const shuffled: NormalizedChartSpec = {
350
+ ...sorted,
351
+ data: [
352
+ { date: '2021-01-01', value: 40 },
353
+ { date: '2020-01-01', value: 10 },
354
+ { date: '2022-01-01', value: 30 },
355
+ ],
356
+ };
357
+
358
+ const sortedScales = computeScales(sorted, chartArea, sorted.data);
359
+ const sortedMarks = computeLineMarks(sorted, sortedScales, chartArea, fullStrategy);
360
+
361
+ const shuffledScales = computeScales(shuffled, chartArea, shuffled.data);
362
+ const shuffledMarks = computeLineMarks(shuffled, shuffledScales, chartArea, fullStrategy);
363
+
364
+ const sortedLine = sortedMarks.find((m): m is LineMark => m.type === 'line')!;
365
+ const shuffledLine = shuffledMarks.find((m): m is LineMark => m.type === 'line')!;
366
+
367
+ // Both should produce the same pixel positions
368
+ expect(sortedLine.points).toEqual(shuffledLine.points);
369
+ });
370
+
371
+ it('sorts within-year dates by month in multi-series', () => {
372
+ const spec: NormalizedChartSpec = {
373
+ ...makeMultiSeriesSpec(),
374
+ data: [
375
+ { date: '2020-12-01', value: 30, country: 'US' },
376
+ { date: '2020-03-01', value: 10, country: 'US' },
377
+ { date: '2020-07-01', value: 20, country: 'US' },
378
+ { date: '2020-12-01', value: 45, country: 'UK' },
379
+ { date: '2020-03-01', value: 15, country: 'UK' },
380
+ { date: '2020-07-01', value: 25, country: 'UK' },
381
+ ],
382
+ };
383
+ const scales = computeScales(spec, chartArea, spec.data);
384
+ const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
385
+
386
+ const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
387
+ expect(lineMarks).toHaveLength(2);
388
+
389
+ for (const lm of lineMarks) {
390
+ // Data rows should be Mar -> Jul -> Dec
391
+ const dates = lm.data!.map((r) => r.date);
392
+ expect(dates).toEqual(['2020-03-01', '2020-07-01', '2020-12-01']);
393
+ }
394
+ });
395
+ });
396
+
221
397
  describe('edge cases', () => {
222
398
  it('returns empty array when no x encoding', () => {
223
399
  const spec: NormalizedChartSpec = {
@@ -307,6 +483,122 @@ describe('computeAreaMarks', () => {
307
483
  expect(seriesKeys).toContain('UK');
308
484
  });
309
485
 
486
+ describe('x-axis sorting', () => {
487
+ it('sorts unsorted temporal data for single area', () => {
488
+ const spec: NormalizedChartSpec = {
489
+ ...makeSingleSeriesSpec(),
490
+ data: [
491
+ { date: '2022-01-01', value: 30 },
492
+ { date: '2020-01-01', value: 10 },
493
+ { date: '2021-01-01', value: 40 },
494
+ ],
495
+ };
496
+ const scales = computeScales(spec, chartArea, spec.data);
497
+ const marks = computeAreaMarks(spec, scales, chartArea);
498
+
499
+ expect(marks).toHaveLength(1);
500
+ for (let i = 1; i < marks[0].topPoints.length; i++) {
501
+ expect(marks[0].topPoints[i].x).toBeGreaterThan(marks[0].topPoints[i - 1].x);
502
+ }
503
+ });
504
+
505
+ it('sorts unsorted temporal data for stacked area', () => {
506
+ const spec: NormalizedChartSpec = {
507
+ ...makeMultiSeriesSpec(),
508
+ data: [
509
+ { date: '2022-01-01', value: 30, country: 'US' },
510
+ { date: '2020-01-01', value: 10, country: 'US' },
511
+ { date: '2021-01-01', value: 40, country: 'US' },
512
+ { date: '2022-01-01', value: 45, country: 'UK' },
513
+ { date: '2020-01-01', value: 15, country: 'UK' },
514
+ { date: '2021-01-01', value: 35, country: 'UK' },
515
+ ],
516
+ };
517
+ const scales = computeScales(spec, chartArea, spec.data);
518
+ const marks = computeAreaMarks(spec, scales, chartArea);
519
+
520
+ for (const mark of marks) {
521
+ for (let i = 1; i < mark.topPoints.length; i++) {
522
+ expect(mark.topPoints[i].x).toBeGreaterThan(mark.topPoints[i - 1].x);
523
+ }
524
+ }
525
+ });
526
+
527
+ it('attaches sorted data rows on single area marks', () => {
528
+ const spec: NormalizedChartSpec = {
529
+ ...makeSingleSeriesSpec(),
530
+ data: [
531
+ { date: '2022-01-01', value: 30 },
532
+ { date: '2020-01-01', value: 10 },
533
+ { date: '2021-01-01', value: 40 },
534
+ ],
535
+ };
536
+ const scales = computeScales(spec, chartArea, spec.data);
537
+ const marks = computeAreaMarks(spec, scales, chartArea);
538
+
539
+ const dates = marks[0].data!.map((r) => r.date);
540
+ expect(dates).toEqual(['2020-01-01', '2021-01-01', '2022-01-01']);
541
+ });
542
+
543
+ it('sorts stacked area with 3+ series and shuffled dates', () => {
544
+ const spec: NormalizedChartSpec = {
545
+ type: 'line',
546
+ data: [
547
+ { date: '2022-01-01', value: 30, region: 'A' },
548
+ { date: '2020-01-01', value: 10, region: 'A' },
549
+ { date: '2021-01-01', value: 20, region: 'A' },
550
+ { date: '2021-01-01', value: 25, region: 'B' },
551
+ { date: '2022-01-01', value: 35, region: 'B' },
552
+ { date: '2020-01-01', value: 15, region: 'B' },
553
+ { date: '2022-01-01', value: 40, region: 'C' },
554
+ { date: '2020-01-01', value: 5, region: 'C' },
555
+ { date: '2021-01-01', value: 30, region: 'C' },
556
+ ],
557
+ encoding: {
558
+ x: { field: 'date', type: 'temporal' },
559
+ y: { field: 'value', type: 'quantitative' },
560
+ color: { field: 'region', type: 'nominal' },
561
+ },
562
+ chrome: {},
563
+ annotations: [],
564
+ responsive: true,
565
+ theme: {},
566
+ darkMode: 'off',
567
+ labels: { density: 'auto', format: '' },
568
+ };
569
+ const scales = computeScales(spec, chartArea, spec.data);
570
+ const marks = computeAreaMarks(spec, scales, chartArea);
571
+
572
+ expect(marks).toHaveLength(3);
573
+ for (const mark of marks) {
574
+ for (let i = 1; i < mark.topPoints.length; i++) {
575
+ expect(mark.topPoints[i].x).toBeGreaterThan(mark.topPoints[i - 1].x);
576
+ }
577
+ }
578
+ });
579
+
580
+ it('produces identical output for pre-sorted and shuffled single area data', () => {
581
+ const preSorted = makeSingleSeriesSpec();
582
+ const shuffled: NormalizedChartSpec = {
583
+ ...preSorted,
584
+ data: [
585
+ { date: '2021-01-01', value: 40 },
586
+ { date: '2020-01-01', value: 10 },
587
+ { date: '2022-01-01', value: 30 },
588
+ ],
589
+ };
590
+
591
+ const preSortedScales = computeScales(preSorted, chartArea, preSorted.data);
592
+ const preSortedMarks = computeAreaMarks(preSorted, preSortedScales, chartArea);
593
+
594
+ const shuffledScales = computeScales(shuffled, chartArea, shuffled.data);
595
+ const shuffledMarks = computeAreaMarks(shuffled, shuffledScales, chartArea);
596
+
597
+ expect(preSortedMarks[0].topPoints).toEqual(shuffledMarks[0].topPoints);
598
+ expect(preSortedMarks[0].bottomPoints).toEqual(shuffledMarks[0].bottomPoints);
599
+ });
600
+ });
601
+
310
602
  it('stacked areas: each layer has different baselines', () => {
311
603
  const spec = makeMultiSeriesSpec();
312
604
  const scales = computeScales(spec, chartArea, spec.data);
@@ -320,6 +612,78 @@ describe('computeAreaMarks', () => {
320
612
  expect(firstBottom).not.toBe(secondBottom);
321
613
  }
322
614
  });
615
+
616
+ it('stacked areas: y-domain covers the stacked sum, not individual max', () => {
617
+ // Three series each with value 100 at the same x point. The stacked sum
618
+ // is 300, so the y-scale domain must go up to at least 300. Without the
619
+ // stacked domain fix, the domain only reaches 100 and the top layers clip.
620
+ const spec: NormalizedChartSpec = {
621
+ type: 'area',
622
+ data: [
623
+ { date: '2020-01-01', value: 100, group: 'A' },
624
+ { date: '2021-01-01', value: 100, group: 'A' },
625
+ { date: '2020-01-01', value: 100, group: 'B' },
626
+ { date: '2021-01-01', value: 100, group: 'B' },
627
+ { date: '2020-01-01', value: 100, group: 'C' },
628
+ { date: '2021-01-01', value: 100, group: 'C' },
629
+ ],
630
+ encoding: {
631
+ x: { field: 'date', type: 'temporal' },
632
+ y: { field: 'value', type: 'quantitative' },
633
+ color: { field: 'group', type: 'nominal' },
634
+ },
635
+ chrome: {},
636
+ annotations: [],
637
+ responsive: true,
638
+ theme: {},
639
+ darkMode: 'off',
640
+ labels: { density: 'auto', format: '' },
641
+ };
642
+ const scales = computeScales(spec, chartArea, spec.data);
643
+ const marks = computeAreaMarks(spec, scales, chartArea);
644
+
645
+ expect(marks).toHaveLength(3);
646
+
647
+ // The topmost layer's top points should be within the chart area, not
648
+ // clipped beyond it. With a proper stacked domain the y-scale covers
649
+ // 0..300 (niced), so all pixel positions stay within bounds.
650
+ const lastLayer = marks[marks.length - 1];
651
+ for (const pt of lastLayer.topPoints) {
652
+ expect(pt.y).toBeGreaterThanOrEqual(chartArea.y);
653
+ expect(pt.y).toBeLessThanOrEqual(chartArea.y + chartArea.height);
654
+ }
655
+ });
656
+
657
+ it('returns empty marks for unparseable temporal data', () => {
658
+ // Quarterly strings like '2022-Q1' are not valid Date strings. The engine
659
+ // should handle this gracefully (empty marks) rather than crashing or
660
+ // producing NaN-filled paths.
661
+ const spec: NormalizedChartSpec = {
662
+ type: 'area',
663
+ data: [
664
+ { quarter: '2022-Q1', revenue: 45, segment: 'Services' },
665
+ { quarter: '2022-Q2', revenue: 52, segment: 'Services' },
666
+ { quarter: '2022-Q1', revenue: 120, segment: 'Products' },
667
+ { quarter: '2022-Q2', revenue: 135, segment: 'Products' },
668
+ ],
669
+ encoding: {
670
+ x: { field: 'quarter', type: 'temporal' },
671
+ y: { field: 'revenue', type: 'quantitative' },
672
+ color: { field: 'segment', type: 'nominal' },
673
+ },
674
+ chrome: {},
675
+ annotations: [],
676
+ responsive: true,
677
+ theme: {},
678
+ darkMode: 'off',
679
+ labels: { density: 'auto', format: '' },
680
+ };
681
+ const scales = computeScales(spec, chartArea, spec.data);
682
+ const marks = computeAreaMarks(spec, scales, chartArea);
683
+
684
+ // Should produce empty marks since dates can't be parsed, not crash
685
+ expect(marks).toHaveLength(0);
686
+ });
323
687
  });
324
688
 
325
689
  // ---------------------------------------------------------------------------
@@ -12,7 +12,7 @@ import { area, curveMonotoneX, line, stack, stackOffsetNone, stackOrderNone } fr
12
12
 
13
13
  import type { NormalizedChartSpec } from '../../compiler/types';
14
14
  import type { ResolvedScales } from '../../layout/scales';
15
- import { getColor, scaleValue } from '../utils';
15
+ import { getColor, scaleValue, sortByField } from '../utils';
16
16
 
17
17
  // ---------------------------------------------------------------------------
18
18
  // Constants
@@ -64,10 +64,13 @@ function computeSingleArea(
64
64
  for (const [seriesKey, rows] of groups) {
65
65
  const color = getColor(scales, seriesKey);
66
66
 
67
+ // Sort rows by x-axis field so areas draw left-to-right
68
+ const sortedRows = sortByField(rows, xChannel.field);
69
+
67
70
  // Compute points, filtering out null values
68
71
  const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
69
72
 
70
- for (const row of rows) {
73
+ for (const row of sortedRows) {
71
74
  const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
72
75
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
73
76
 
@@ -147,6 +150,9 @@ function computeStackedArea(
147
150
  return computeSingleArea(spec, scales, chartArea);
148
151
  }
149
152
 
153
+ // Sort data by x field so stacked areas render left-to-right
154
+ const sortedData = sortByField(spec.data, xChannel.field);
155
+
150
156
  // Collect unique series keys and x values, and build a lookup from
151
157
  // (x-value, series-key) -> original data row so stacked area marks
152
158
  // get original rows instead of pivot rows.
@@ -155,7 +161,7 @@ function computeStackedArea(
155
161
  const rowsByXSeries = new Map<string, DataRow>();
156
162
  const rowsByX = new Map<string, DataRow[]>();
157
163
 
158
- for (const row of spec.data) {
164
+ for (const row of sortedData) {
159
165
  const xStr = String(row[xChannel.field]);
160
166
  const series = String(row[colorField]);
161
167
  seriesKeys.add(series);
@@ -20,7 +20,7 @@ import { curveMonotoneX, line } from 'd3-shape';
20
20
 
21
21
  import type { NormalizedChartSpec } from '../../compiler/types';
22
22
  import type { ResolvedScales } from '../../layout/scales';
23
- import { getColor, groupByField, scaleValue } from '../utils';
23
+ import { getColor, groupByField, scaleValue, sortByField } from '../utils';
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
26
  // Constants
@@ -64,6 +64,9 @@ export function computeLineMarks(
64
64
  for (const [seriesKey, rows] of groups) {
65
65
  const color = getColor(scales, seriesKey);
66
66
 
67
+ // Sort rows by x-axis field so lines draw left-to-right
68
+ const sortedRows = sortByField(rows, xChannel.field);
69
+
67
70
  // Compute pixel positions for each data point, preserving nulls
68
71
  // for line break handling
69
72
  const pointsWithData: {
@@ -76,7 +79,7 @@ export function computeLineMarks(
76
79
  const segments: { x: number; y: number }[][] = [];
77
80
  let currentSegment: { x: number; y: number }[] = [];
78
81
 
79
- for (const row of rows) {
82
+ for (const row of sortedRows) {
80
83
  const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
81
84
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
82
85