@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
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/charts/line/area.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
package/src/charts/utils.ts
CHANGED
|
@@ -80,6 +80,54 @@ export function groupByField(data: DataRow[], field: string | undefined): Map<st
|
|
|
80
80
|
return groups;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Sorting
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sort data rows by a field value in ascending order.
|
|
89
|
+
*
|
|
90
|
+
* Type-aware: numbers compared numerically, Date objects by timestamp,
|
|
91
|
+
* string-encoded numbers parsed and compared numerically, and everything
|
|
92
|
+
* else compared lexicographically (which also handles ISO date strings).
|
|
93
|
+
* Nulls are sorted last. Returns a new array (no mutation).
|
|
94
|
+
*/
|
|
95
|
+
export function sortByField(data: DataRow[], field: string): DataRow[] {
|
|
96
|
+
if (data.length <= 1) return [...data];
|
|
97
|
+
|
|
98
|
+
return [...data].sort((a, b) => {
|
|
99
|
+
const aVal = a[field];
|
|
100
|
+
const bVal = b[field];
|
|
101
|
+
|
|
102
|
+
// Nulls last
|
|
103
|
+
if (aVal == null && bVal == null) return 0;
|
|
104
|
+
if (aVal == null) return 1;
|
|
105
|
+
if (bVal == null) return -1;
|
|
106
|
+
|
|
107
|
+
// Both numbers
|
|
108
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
109
|
+
return aVal - bVal;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Both Dates
|
|
113
|
+
if (aVal instanceof Date && bVal instanceof Date) {
|
|
114
|
+
return aVal.getTime() - bVal.getTime();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// String values: try numeric parse, then lexicographic
|
|
118
|
+
const aStr = String(aVal);
|
|
119
|
+
const bStr = String(bVal);
|
|
120
|
+
|
|
121
|
+
const aNum = Number(aStr);
|
|
122
|
+
const bNum = Number(bStr);
|
|
123
|
+
if (Number.isFinite(aNum) && Number.isFinite(bNum)) {
|
|
124
|
+
return aNum - bNum;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return aStr.localeCompare(bStr);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
83
131
|
// ---------------------------------------------------------------------------
|
|
84
132
|
// Color helpers
|
|
85
133
|
// ---------------------------------------------------------------------------
|
package/src/layout/axes.ts
CHANGED
|
@@ -56,12 +56,13 @@ function continuousTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity
|
|
|
56
56
|
function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
|
|
57
57
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
58
58
|
const domain: string[] = scale.domain();
|
|
59
|
-
const
|
|
59
|
+
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
60
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
60
61
|
|
|
61
|
-
// Band scales (bar charts)
|
|
62
|
-
// Only thin
|
|
62
|
+
// Band scales (bar charts) show all category labels by default.
|
|
63
|
+
// Only thin when there's an explicit tickCount override or for point/ordinal scales.
|
|
63
64
|
let selectedValues = domain;
|
|
64
|
-
if (resolvedScale.type !== 'band' && domain.length > maxTicks) {
|
|
65
|
+
if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
|
|
65
66
|
const step = Math.ceil(domain.length / maxTicks);
|
|
66
67
|
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
67
68
|
}
|
package/src/layout/scales.ts
CHANGED
|
@@ -367,10 +367,15 @@ export function computeScales(
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
if (encoding.y) {
|
|
370
|
-
// For stacked columns, the y-domain needs the max category
|
|
371
|
-
// Without this, stacked
|
|
370
|
+
// For stacked columns and stacked areas, the y-domain needs the max category
|
|
371
|
+
// sum, not the max individual value. Without this, stacked marks would clip
|
|
372
|
+
// above the chart area.
|
|
372
373
|
let yData = data;
|
|
373
|
-
if (
|
|
374
|
+
if (
|
|
375
|
+
(spec.type === 'column' || spec.type === 'area') &&
|
|
376
|
+
encoding.color &&
|
|
377
|
+
encoding.y.type === 'quantitative'
|
|
378
|
+
) {
|
|
374
379
|
const xField = encoding.x?.field;
|
|
375
380
|
const yField = encoding.y.field;
|
|
376
381
|
if (xField) {
|