@coinbase/cds-mobile-visualization 3.6.2 → 3.8.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dts/chart/CartesianChart.d.ts +4 -8
  3. package/dts/chart/CartesianChart.d.ts.map +1 -1
  4. package/dts/chart/PeriodSelector.d.ts.map +1 -1
  5. package/dts/chart/area/Area.d.ts +3 -0
  6. package/dts/chart/area/Area.d.ts.map +1 -1
  7. package/dts/chart/area/AreaChart.d.ts.map +1 -1
  8. package/dts/chart/area/DottedArea.d.ts.map +1 -1
  9. package/dts/chart/area/GradientArea.d.ts.map +1 -1
  10. package/dts/chart/area/SolidArea.d.ts.map +1 -1
  11. package/dts/chart/bar/BarChart.d.ts.map +1 -1
  12. package/dts/chart/bar/BarStack.d.ts.map +1 -1
  13. package/dts/chart/gradient/Gradient.d.ts +14 -3
  14. package/dts/chart/gradient/Gradient.d.ts.map +1 -1
  15. package/dts/chart/line/DottedLine.d.ts.map +1 -1
  16. package/dts/chart/line/Line.d.ts +3 -0
  17. package/dts/chart/line/Line.d.ts.map +1 -1
  18. package/dts/chart/line/LineChart.d.ts.map +1 -1
  19. package/dts/chart/line/SolidLine.d.ts.map +1 -1
  20. package/dts/chart/utils/axis.d.ts +18 -8
  21. package/dts/chart/utils/axis.d.ts.map +1 -1
  22. package/dts/chart/utils/bar.d.ts +17 -7
  23. package/dts/chart/utils/bar.d.ts.map +1 -1
  24. package/dts/chart/utils/chart.d.ts +9 -0
  25. package/dts/chart/utils/chart.d.ts.map +1 -1
  26. package/dts/chart/utils/context.d.ts +3 -3
  27. package/dts/chart/utils/context.d.ts.map +1 -1
  28. package/dts/chart/utils/gradient.d.ts +14 -4
  29. package/dts/chart/utils/gradient.d.ts.map +1 -1
  30. package/esm/chart/CartesianChart.js +6 -4
  31. package/esm/chart/PeriodSelector.js +3 -4
  32. package/esm/chart/__stories__/ChartTransitions.stories.js +68 -0
  33. package/esm/chart/area/Area.js +0 -2
  34. package/esm/chart/area/AreaChart.js +15 -19
  35. package/esm/chart/area/DottedArea.js +6 -4
  36. package/esm/chart/area/GradientArea.js +6 -4
  37. package/esm/chart/area/SolidArea.js +3 -0
  38. package/esm/chart/area/__stories__/AreaChart.stories.js +189 -3
  39. package/esm/chart/bar/BarChart.js +14 -22
  40. package/esm/chart/bar/BarStack.js +15 -10
  41. package/esm/chart/bar/__stories__/BarChart.stories.js +84 -2
  42. package/esm/chart/gradient/Gradient.js +119 -26
  43. package/esm/chart/line/DottedLine.js +3 -0
  44. package/esm/chart/line/Line.js +1 -3
  45. package/esm/chart/line/LineChart.js +8 -4
  46. package/esm/chart/line/SolidLine.js +3 -0
  47. package/esm/chart/utils/axis.js +32 -4
  48. package/esm/chart/utils/bar.js +129 -76
  49. package/esm/chart/utils/chart.js +53 -21
  50. package/esm/chart/utils/gradient.js +15 -5
  51. package/package.json +5 -5
@@ -133,19 +133,20 @@ export function getStackGroups(series, defaultAxisId) {
133
133
  * @param bars - Array of bar items with current valuePos and length
134
134
  * @param stackGap - Gap size in pixels between adjacent bars
135
135
  * @param layout - The layout of the chart
136
- * @param baseline - Pixel position of the zero value on the value axis
136
+ * @param baseline - Value-axis baseline in data space
137
+ * @param baselinePx - Pixel position of the value-axis baseline on the value axis
137
138
  * @returns New array of bars with adjusted valuePos and length
138
139
  */
139
- function applyStackGap(bars, stackGap, layout, baseline) {
140
+ function applyStackGap(bars, stackGap, layout, baseline, baselinePx) {
140
141
  if (!stackGap || bars.length <= 1) return bars;
141
142
  const result = [...bars];
142
143
  const barsAboveBaseline = bars.filter(bar => {
143
144
  const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b);
144
- return bottom >= 0 && top !== bottom && bar.shouldApplyGap;
145
+ return bottom >= baseline && top !== bottom && bar.shouldApplyGap;
145
146
  });
146
147
  const barsBelowBaseline = bars.filter(bar => {
147
148
  const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b);
148
- return top <= 0 && bottom !== top && bar.shouldApplyGap;
149
+ return top <= baseline && bottom !== top && bar.shouldApplyGap;
149
150
  });
150
151
  const applyGapGroup = (group, growing) => {
151
152
  if (group.length <= 1) return;
@@ -153,7 +154,7 @@ function applyStackGap(bars, stackGap, layout, baseline) {
153
154
  const totalDataLength = group.reduce((sum, bar) => sum + bar.length, 0);
154
155
  const lengthReduction = totalGapSpace / totalDataLength;
155
156
  const sortedBars = growing ? [...group].sort((a, b) => b.valuePos - a.valuePos) : [...group].sort((a, b) => a.valuePos - b.valuePos);
156
- let currentEdge = baseline;
157
+ let currentEdge = baselinePx;
157
158
  sortedBars.forEach((bar, index) => {
158
159
  const newLength = bar.length * (1 - lengthReduction);
159
160
  let newValuePos;
@@ -194,20 +195,21 @@ function applyStackGap(bars, stackGap, layout, baseline) {
194
195
  * @param bars - Array of bar items with final valuePos, length, and dataValue
195
196
  * @param initialBarMinSizes - Per-bar initial sizes in pixels for entrance animation
196
197
  * @param stackGap - Gap between adjacent bars in pixels
197
- * @param baseline - Pixel position of the zero value on the value axis
198
+ * @param baseline - Value-axis baseline in data space
199
+ * @param baselinePx - Pixel position of the value-axis baseline on the value axis
198
200
  * @param layout - The layout of the chart
199
- * @returns Array of origin positions (one per bar, parallel to input), all defaulting to baseline
201
+ * @returns Array of origin positions (one per bar, parallel to input), all defaulting to baselinePx
200
202
  */
201
- function getBarOrigins(bars, initialBarMinSizes, stackGap, baseline, layout) {
202
- const result = bars.map(() => baseline);
203
+ function getBarOrigins(bars, initialBarMinSizes, stackGap, baseline, baselinePx, layout) {
204
+ const result = bars.map(() => baselinePx);
203
205
  if (bars.length === 0 || initialBarMinSizes.every(size => !size)) return result;
204
206
  const isPositive = bar => {
205
207
  const [lo, hi] = [...bar.dataValue].sort((a, b) => a - b);
206
- return lo >= 0 && hi !== lo;
208
+ return lo >= baseline && hi !== lo;
207
209
  };
208
210
  const isNegative = bar => {
209
211
  const [lo, hi] = [...bar.dataValue].sort((a, b) => a - b);
210
- return hi <= 0 && hi !== lo;
212
+ return hi <= baseline && hi !== lo;
211
213
  };
212
214
  const positiveBars = bars.map((bar, i) => ({
213
215
  bar,
@@ -219,7 +221,7 @@ function getBarOrigins(bars, initialBarMinSizes, stackGap, baseline, layout) {
219
221
  return isPositive(bar);
220
222
  }).sort((a, b) => layout === 'vertical' ? b.bar.valuePos - a.bar.valuePos : a.bar.valuePos - b.bar.valuePos);
221
223
  if (layout === 'vertical') {
222
- let currentPositive = baseline;
224
+ let currentPositive = baselinePx;
223
225
  positiveBars.forEach((_ref2, idx) => {
224
226
  var _initialBarMinSizes$i;
225
227
  let {
@@ -233,7 +235,7 @@ function getBarOrigins(bars, initialBarMinSizes, stackGap, baseline, layout) {
233
235
  }
234
236
  });
235
237
  } else {
236
- let currentPositive = baseline;
238
+ let currentPositive = baselinePx;
237
239
  positiveBars.forEach((_ref3, idx) => {
238
240
  var _initialBarMinSizes$i2;
239
241
  let {
@@ -257,7 +259,7 @@ function getBarOrigins(bars, initialBarMinSizes, stackGap, baseline, layout) {
257
259
  return isNegative(bar);
258
260
  }).sort((a, b) => layout === 'vertical' ? a.bar.valuePos - b.bar.valuePos : b.bar.valuePos + b.bar.length - (a.bar.valuePos + a.bar.length));
259
261
  if (layout === 'vertical') {
260
- let currentNegative = baseline;
262
+ let currentNegative = baselinePx;
261
263
  negativeBars.forEach((_ref5, idx) => {
262
264
  var _initialBarMinSizes$i3;
263
265
  let {
@@ -271,7 +273,7 @@ function getBarOrigins(bars, initialBarMinSizes, stackGap, baseline, layout) {
271
273
  }
272
274
  });
273
275
  } else {
274
- let currentNegative = baseline;
276
+ let currentNegative = baselinePx;
275
277
  negativeBars.forEach((_ref6, idx) => {
276
278
  var _initialBarMinSizes$i4;
277
279
  let {
@@ -382,11 +384,12 @@ export function getStackInitialClipRect(stackRect, layout, origin) {
382
384
  *
383
385
  * @param bars - Array of bar items with current valuePos and length
384
386
  * @param barMinSize - Minimum bar size in pixels
385
- * @param layout - The layout of the chart
386
- * @param baseline - Pixel position of the zero value on the value axis
387
+ * @param baseline - Value-axis baseline in data space
388
+ * @param baselinePx - Pixel position of the value-axis baseline on the value axis
389
+ * @param layout - Chart layout
387
390
  * @returns New array of bars with adjusted valuePos and length
388
391
  */
389
- function applyBarMinSize(bars, barMinSize, layout, baseline) {
392
+ function applyBarMinSize(bars, barMinSize, baseline, baselinePx, layout) {
390
393
  if (!barMinSize || bars.length === 0) return bars;
391
394
  const originalTotalLength = bars.reduce((sum, bar) => sum + bar.length, 0);
392
395
  const needsExpansion = bars.map(bar => bar.length < barMinSize);
@@ -429,45 +432,85 @@ function applyBarMinSize(bars, barMinSize, layout, baseline) {
429
432
  // independent of the current valuePos (which hasn't been repositioned yet).
430
433
  const barsAboveBaseline = stackedSortedBars.filter(bar => {
431
434
  const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b);
432
- return layout === 'vertical' ? bottom >= 0 && top !== bottom : top <= 0 && top !== bottom;
435
+ return bottom >= baseline && top !== bottom;
433
436
  });
434
437
  const barsBelowBaseline = stackedSortedBars.filter(bar => {
435
438
  const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b);
436
- return layout === 'vertical' ? top <= 0 && top !== bottom : bottom >= 0 && top !== bottom;
439
+ return top <= baseline && bottom !== top;
437
440
  });
438
441
 
439
- // Restack bars above baseline (growing away from it in the positive direction)
440
- let currentAbove = baseline;
441
- for (let i = barsAboveBaseline.length - 1; i >= 0; i--) {
442
- const bar = barsAboveBaseline[i];
443
- const newValuePos = currentAbove - bar.length;
444
- newPositions.set(bar.seriesId, {
445
- valuePos: newValuePos,
446
- length: bar.length
447
- });
448
- if (i > 0) {
449
- const nextBar = barsAboveBaseline[i - 1];
450
- const originalCurrent = bars.find(b => b.seriesId === bar.seriesId);
451
- const originalNext = bars.find(b => b.seriesId === nextBar.seriesId);
452
- const originalGap = originalCurrent.valuePos - (originalNext.valuePos + originalNext.length);
453
- currentAbove = newValuePos - originalGap;
442
+ // Restack bars above baseline (positive data side).
443
+ // vertical grow up (−Y from baseline); horizontal → grow right (+X from baseline).
444
+ if (layout === 'vertical') {
445
+ let currentAbove = baselinePx;
446
+ for (let i = barsAboveBaseline.length - 1; i >= 0; i--) {
447
+ const bar = barsAboveBaseline[i];
448
+ const newValuePos = currentAbove - bar.length;
449
+ newPositions.set(bar.seriesId, {
450
+ valuePos: newValuePos,
451
+ length: bar.length
452
+ });
453
+ if (i > 0) {
454
+ const nextBar = barsAboveBaseline[i - 1];
455
+ const originalCurrent = bars.find(b => b.seriesId === bar.seriesId);
456
+ const originalNext = bars.find(b => b.seriesId === nextBar.seriesId);
457
+ const originalGap = originalCurrent.valuePos - (originalNext.valuePos + originalNext.length);
458
+ currentAbove = newValuePos - originalGap;
459
+ }
460
+ }
461
+ } else {
462
+ let currentEdge = baselinePx;
463
+ for (let i = 0; i < barsAboveBaseline.length; i++) {
464
+ const bar = barsAboveBaseline[i];
465
+ newPositions.set(bar.seriesId, {
466
+ valuePos: currentEdge,
467
+ length: bar.length
468
+ });
469
+ if (i < barsAboveBaseline.length - 1) {
470
+ const nextBar = barsAboveBaseline[i + 1];
471
+ const originalCurrent = bars.find(b => b.seriesId === bar.seriesId);
472
+ const originalNext = bars.find(b => b.seriesId === nextBar.seriesId);
473
+ const originalGap = originalNext.valuePos - (originalCurrent.valuePos + originalCurrent.length);
474
+ currentEdge = currentEdge + bar.length + originalGap;
475
+ }
454
476
  }
455
477
  }
456
478
 
457
- // Restack bars below baseline (growing away from it in the negative direction)
458
- let currentBelow = baseline;
459
- for (let i = 0; i < barsBelowBaseline.length; i++) {
460
- const bar = barsBelowBaseline[i];
461
- newPositions.set(bar.seriesId, {
462
- valuePos: currentBelow,
463
- length: bar.length
464
- });
465
- if (i < barsBelowBaseline.length - 1) {
466
- const nextBar = barsBelowBaseline[i + 1];
467
- const originalCurrent = bars.find(b => b.seriesId === bar.seriesId);
468
- const originalNext = bars.find(b => b.seriesId === nextBar.seriesId);
469
- const originalGap = originalNext.valuePos - (originalCurrent.valuePos + originalCurrent.length);
470
- currentBelow = currentBelow + bar.length + originalGap;
479
+ // Restack bars below baseline (negative data side).
480
+ // vertical grow down (+Y); horizontal → grow left (−X).
481
+ if (layout === 'vertical') {
482
+ let currentBelow = baselinePx;
483
+ for (let i = 0; i < barsBelowBaseline.length; i++) {
484
+ const bar = barsBelowBaseline[i];
485
+ newPositions.set(bar.seriesId, {
486
+ valuePos: currentBelow,
487
+ length: bar.length
488
+ });
489
+ if (i < barsBelowBaseline.length - 1) {
490
+ const nextBar = barsBelowBaseline[i + 1];
491
+ const originalCurrent = bars.find(b => b.seriesId === bar.seriesId);
492
+ const originalNext = bars.find(b => b.seriesId === nextBar.seriesId);
493
+ const originalGap = originalNext.valuePos - (originalCurrent.valuePos + originalCurrent.length);
494
+ currentBelow = currentBelow + bar.length + originalGap;
495
+ }
496
+ }
497
+ } else {
498
+ const sortedBelow = [...barsBelowBaseline].sort((a, b) => b.valuePos - a.valuePos);
499
+ let currentEdge = baselinePx;
500
+ for (let i = sortedBelow.length - 1; i >= 0; i--) {
501
+ const bar = sortedBelow[i];
502
+ const newValuePos = currentEdge - bar.length;
503
+ newPositions.set(bar.seriesId, {
504
+ valuePos: newValuePos,
505
+ length: bar.length
506
+ });
507
+ if (i > 0) {
508
+ const nextBar = sortedBelow[i - 1];
509
+ const originalCurrent = bars.find(b => b.seriesId === bar.seriesId);
510
+ const originalNext = bars.find(b => b.seriesId === nextBar.seriesId);
511
+ const originalGap = originalCurrent.valuePos - (originalNext.valuePos + originalNext.length);
512
+ currentEdge = newValuePos - originalGap;
513
+ }
471
514
  }
472
515
  }
473
516
  }
@@ -493,10 +536,11 @@ function applyBarMinSize(bars, barMinSize, layout, baseline) {
493
536
  * @param layout - The layout of the chart
494
537
  * @param indexPos - Pixel position along the categorical (index) axis
495
538
  * @param thickness - Bar thickness in pixels
496
- * @param baseline - Pixel position of the zero value on the value axis
539
+ * @param baseline - Value-axis baseline in data space
540
+ * @param baselinePx - Pixel position of the value-axis baseline on the value axis
497
541
  * @returns Updated bars and stackBounds; unchanged if stackSize >= stackMinSize
498
542
  */
499
- function applyStackMinSize(bars, stackMinSize, stackSize, stackBounds, layout, indexPos, thickness, baseline) {
543
+ function applyStackMinSize(bars, stackMinSize, stackSize, stackBounds, layout, indexPos, thickness, baseline, baselinePx) {
500
544
  if (!stackMinSize || stackSize >= stackMinSize) return {
501
545
  bars,
502
546
  stackBounds
@@ -513,10 +557,10 @@ function applyStackMinSize(bars, stackMinSize, stackSize, stackBounds, layout, i
513
557
  const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b);
514
558
  let newValuePos;
515
559
  const newLength = stackMinSize;
516
- if (bottom >= 0 && top !== bottom) {
560
+ if (bottom >= baseline && top !== bottom) {
517
561
  // Bar is on the positive side: vertical→expands upward (↑), horizontal→expands rightward (→)
518
562
  newValuePos = layout === 'vertical' ? bar.valuePos - sizeIncrease : bar.valuePos;
519
- } else if (top <= 0 && top !== bottom) {
563
+ } else if (top <= baseline && top !== bottom) {
520
564
  // Bar is on the negative side: vertical→expands downward (↓), horizontal→expands leftward (←)
521
565
  newValuePos = layout === 'vertical' ? bar.valuePos : bar.valuePos - sizeIncrease;
522
566
  } else {
@@ -542,12 +586,12 @@ function applyStackMinSize(bars, stackMinSize, stackSize, stackBounds, layout, i
542
586
 
543
587
  // For vertical: positive bars are above baseline (smaller Y), negative bars are below (larger Y)
544
588
  // For horizontal: positive bars are right of baseline (larger X), negative bars are left (smaller X)
545
- const barsOnPositiveSide = layout === 'vertical' ? sortedBars.filter(bar => bar.valuePos + bar.length <= baseline) : sortedBars.filter(bar => bar.valuePos >= baseline);
546
- const barsOnNegativeSide = layout === 'vertical' ? sortedBars.filter(bar => bar.valuePos >= baseline) : sortedBars.filter(bar => bar.valuePos + bar.length <= baseline);
589
+ const barsOnPositiveSide = layout === 'vertical' ? sortedBars.filter(bar => bar.valuePos + bar.length <= baselinePx) : sortedBars.filter(bar => bar.valuePos >= baselinePx);
590
+ const barsOnNegativeSide = layout === 'vertical' ? sortedBars.filter(bar => bar.valuePos >= baselinePx) : sortedBars.filter(bar => bar.valuePos + bar.length <= baselinePx);
547
591
  const newPositions = new Map();
548
592
  if (layout === 'vertical') {
549
593
  // Stack from baseline upward (decreasing valuePos) for positive bars
550
- let currentPos = baseline;
594
+ let currentPos = baselinePx;
551
595
  for (let i = barsOnPositiveSide.length - 1; i >= 0; i--) {
552
596
  const bar = barsOnPositiveSide[i];
553
597
  const newLength = bar.length * barScaleFactor;
@@ -563,7 +607,7 @@ function applyStackMinSize(bars, stackMinSize, stackSize, stackBounds, layout, i
563
607
  }
564
608
  }
565
609
  // Stack from baseline downward (increasing valuePos) for negative bars
566
- let currentPosBelow = baseline;
610
+ let currentPosBelow = baselinePx;
567
611
  for (let i = 0; i < barsOnNegativeSide.length; i++) {
568
612
  const bar = barsOnNegativeSide[i];
569
613
  const newLength = bar.length * barScaleFactor;
@@ -579,7 +623,7 @@ function applyStackMinSize(bars, stackMinSize, stackSize, stackBounds, layout, i
579
623
  }
580
624
  } else {
581
625
  // Stack from baseline rightward (increasing valuePos) for positive bars
582
- let currentPos = baseline;
626
+ let currentPos = baselinePx;
583
627
  for (let i = 0; i < barsOnPositiveSide.length; i++) {
584
628
  const bar = barsOnPositiveSide[i];
585
629
  const newLength = bar.length * barScaleFactor;
@@ -594,7 +638,7 @@ function applyStackMinSize(bars, stackMinSize, stackSize, stackBounds, layout, i
594
638
  }
595
639
  }
596
640
  // Stack from baseline leftward (decreasing valuePos) for negative bars
597
- let currentPosLeft = baseline;
641
+ let currentPosLeft = baselinePx;
598
642
  for (let i = barsOnNegativeSide.length - 1; i >= 0; i--) {
599
643
  const bar = barsOnNegativeSide[i];
600
644
  const newLength = bar.length * barScaleFactor;
@@ -675,16 +719,23 @@ function applyBorderRadiusLogic(bars, layout, stackGap) {
675
719
  export const EPSILON = 1e-4;
676
720
 
677
721
  /**
678
- * Computes and clamps the stack baseline position on the value axis.
722
+ * Computes and clamps the value-axis baseline position in pixels.
723
+ *
724
+ * When `baseline` (data space) is omitted, the baseline is chosen heuristically from the scale domain:
725
+ * - If the full domain is positive, use domain min.
726
+ * - If the full domain is negative, use domain max.
727
+ * - If the domain crosses zero, use `0`.
728
+ * When `baseline` is set, that value is used as the data-space baseline instead.
679
729
  *
680
- * - If the full domain is positive, baseline is domain min.
681
- * - If the full domain is negative, baseline is domain max.
682
- * - If the domain crosses zero, baseline is 0.
730
+ * @param valueScale - Scale for the value axis
731
+ * @param stackRect - Bounding rect of the stack in pixels
732
+ * @param layout - Chart layout
733
+ * @param baseline - Optional value-axis baseline in data space
683
734
  */
684
- export function getStackBaseline(valueScale, stackRect, layout) {
735
+ export function getBaselinePx(valueScale, stackRect, layout, baseline) {
685
736
  const [domainMin, domainMax] = valueScale.domain();
686
- const baselineValue = domainMin >= 0 ? domainMin : domainMax <= 0 ? domainMax : 0;
687
- const baselinePos = valueScale(baselineValue);
737
+ const baselineInData = baseline != null ? baseline : domainMin >= 0 ? domainMin : domainMax <= 0 ? domainMax : 0;
738
+ const baselinePos = valueScale(baselineInData);
688
739
  if (layout === 'vertical') {
689
740
  return Math.max(stackRect.y, Math.min(baselinePos != null ? baselinePos : stackRect.y + stackRect.height, stackRect.y + stackRect.height));
690
741
  }
@@ -725,7 +776,8 @@ function getStackSizeForLayout(layout, stackRect) {
725
776
  * @param params.seriesGradients - Precomputed gradient configs per series (undefined entries are skipped)
726
777
  * @param params.roundBaseline - Whether to round the face touching the baseline
727
778
  * @param params.layout - The layout of the chart
728
- * @param params.baseline - Pixel position of the zero value on the value axis
779
+ * @param params.baseline - Value-axis baseline in data space
780
+ * @param params.baselinePx - Pixel position of the value-axis baseline on the value axis
729
781
  * @param params.stackGap - Gap between adjacent bars in pixels
730
782
  * @param params.barMinSize - Minimum individual bar size in pixels
731
783
  * @param params.stackMinSize - Minimum total stack size in pixels
@@ -744,7 +796,8 @@ export function getBars(params) {
744
796
  seriesGradients,
745
797
  roundBaseline,
746
798
  layout,
747
- baseline,
799
+ baseline = 0,
800
+ baselinePx,
748
801
  stackGap,
749
802
  barMinSize,
750
803
  stackMinSize,
@@ -766,10 +819,10 @@ export function getBars(params) {
766
819
  const originalValue = originalData == null ? void 0 : originalData[categoryIndex];
767
820
  const shouldApplyGap = !Array.isArray(originalValue);
768
821
  const [bottom, top] = [...value].sort((a, b) => a - b);
769
- const edgeBottom = (_valueScale = valueScale(bottom)) != null ? _valueScale : baseline;
770
- const edgeTop = (_valueScale2 = valueScale(top)) != null ? _valueScale2 : baseline;
771
- const roundTop = roundBaseline || Math.abs(edgeTop - baseline) >= EPSILON;
772
- const roundBottom = roundBaseline || Math.abs(edgeBottom - baseline) >= EPSILON;
822
+ const edgeBottom = (_valueScale = valueScale(bottom)) != null ? _valueScale : baselinePx;
823
+ const edgeTop = (_valueScale2 = valueScale(top)) != null ? _valueScale2 : baselinePx;
824
+ const roundTop = roundBaseline || Math.abs(top - baseline) >= EPSILON && Math.abs(edgeTop - baselinePx) >= EPSILON;
825
+ const roundBottom = roundBaseline || Math.abs(bottom - baseline) >= EPSILON && Math.abs(edgeBottom - baselinePx) >= EPSILON;
773
826
  const length = Math.abs(edgeBottom - edgeTop);
774
827
  const valuePos = Math.min(edgeBottom, edgeTop);
775
828
  if (length <= 0) return;
@@ -809,12 +862,12 @@ export function getBars(params) {
809
862
 
810
863
  // Apply proportional gap distribution to maintain total stack length
811
864
  if (stackGap && allBars.length > 1) {
812
- allBars = applyStackGap(allBars, stackGap, layout, baseline);
865
+ allBars = applyStackGap(allBars, stackGap, layout, baseline, baselinePx);
813
866
  }
814
867
 
815
868
  // Apply barMinSize constraints
816
869
  if (barMinSize) {
817
- allBars = applyBarMinSize(allBars, barMinSize, layout, baseline);
870
+ allBars = applyBarMinSize(allBars, barMinSize, baseline, baselinePx, layout);
818
871
  }
819
872
  allBars = applyBorderRadiusLogic(allBars, layout, stackGap);
820
873
 
@@ -824,7 +877,7 @@ export function getBars(params) {
824
877
  const maxValuePos = Math.max(...allBars.map(bar => bar.valuePos + bar.length));
825
878
  const stackSize = maxValuePos - minValuePos;
826
879
  const stackBounds = getStackBoundsForLayout(layout, indexPos, thickness, minValuePos, stackSize);
827
- const result = applyStackMinSize(allBars, stackMinSize, stackSize, stackBounds, layout, indexPos, thickness, baseline);
880
+ const result = applyStackMinSize(allBars, stackMinSize, stackSize, stackBounds, layout, indexPos, thickness, baseline, baselinePx);
828
881
  allBars = result.bars;
829
882
 
830
883
  // Reapply border radius logic only if we actually scaled
@@ -834,7 +887,7 @@ export function getBars(params) {
834
887
  }
835
888
  }
836
889
  const initialBarMinSizes = getInitialBarMinSizes(allBars, barMinSize, stackMinSize);
837
- const barOrigins = getBarOrigins(allBars, initialBarMinSizes, stackGap != null ? stackGap : 0, baseline, layout);
890
+ const barOrigins = getBarOrigins(allBars, initialBarMinSizes, stackGap != null ? stackGap : 0, baseline, baselinePx, layout);
838
891
  return allBars.map((bar, i) => _extends({}, bar, {
839
892
  x: layout === 'vertical' ? indexPos : bar.valuePos,
840
893
  y: layout === 'vertical' ? bar.valuePos : indexPos,
@@ -1,5 +1,6 @@
1
1
  import { isSharedValue } from 'react-native-reanimated';
2
2
  import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape';
3
+ import { defaultAxisId } from './axis';
3
4
  export const defaultStackId = 'DEFAULT_STACK_ID';
4
5
 
5
6
  /**
@@ -58,17 +59,45 @@ const createStackKey = series => {
58
59
  return series.stackId + ":" + xAxisId + ":" + yAxisId;
59
60
  };
60
61
 
62
+ /**
63
+ * Get the baseline for a series on the value axis for a series (stacking and plain numeric points).
64
+ * @returns The baseline for the series on the value axis, or `0` if none.
65
+ */
66
+ const getValueAxisBaselineForSeries = (layout, series, xAxisConfigs, yAxisConfigs) => {
67
+ var _series$yAxisId, _yAxisConfigs$find$ba, _yAxisConfigs$find;
68
+ if (layout === 'horizontal') {
69
+ var _series$xAxisId, _xAxisConfigs$find$ba, _xAxisConfigs$find;
70
+ const seriesAxisId = (_series$xAxisId = series.xAxisId) != null ? _series$xAxisId : defaultAxisId;
71
+ return (_xAxisConfigs$find$ba = (_xAxisConfigs$find = xAxisConfigs.find(a => a.id === seriesAxisId)) == null ? void 0 : _xAxisConfigs$find.baseline) != null ? _xAxisConfigs$find$ba : 0;
72
+ }
73
+ const seriesAxisId = (_series$yAxisId = series.yAxisId) != null ? _series$yAxisId : defaultAxisId;
74
+ return (_yAxisConfigs$find$ba = (_yAxisConfigs$find = yAxisConfigs.find(a => a.id === seriesAxisId)) == null ? void 0 : _yAxisConfigs$find.baseline) != null ? _yAxisConfigs$find$ba : 0;
75
+ };
76
+
61
77
  /**
62
78
  * Transforms series data into stacked data using D3's stack algorithm.
63
79
  * Returns a map of series ID to transformed [baseline, value] tuples.
64
80
  *
65
81
  * @param series - Array of series with potential stack properties
82
+ * @param layout - When set with axis configs, value-axis baselines are resolved for stacking
66
83
  * @returns Map of series ID to stacked data arrays
67
84
  */
68
- export const getStackedSeriesData = series => {
85
+ export const getStackedSeriesData = (series, layout, xAxisConfigs, yAxisConfigs) => {
69
86
  const stackedDataMap = new Map();
70
87
  const numericStackGroups = new Map();
71
88
  const individualSeries = [];
89
+ const normalizeSeriesData = seriesItem => {
90
+ if (!seriesItem.data) return;
91
+ const baseline = getValueAxisBaselineForSeries(layout, seriesItem, xAxisConfigs, yAxisConfigs);
92
+ return seriesItem.data.map(val => {
93
+ if (val === null) return null;
94
+ if (Array.isArray(val)) {
95
+ return val;
96
+ }
97
+ if (typeof val === 'number') return [baseline, val];
98
+ return null;
99
+ });
100
+ };
72
101
  series.forEach(s => {
73
102
  var _s$data2;
74
103
  const stackKey = createStackKey(s);
@@ -83,31 +112,34 @@ export const getStackedSeriesData = series => {
83
112
  }
84
113
  });
85
114
  individualSeries.forEach(s => {
86
- if (!s.data) return;
87
- const normalizedData = s.data.map(val => {
88
- if (val === null) return null;
89
- if (Array.isArray(val)) {
90
- return val;
91
- }
92
- if (typeof val === 'number') {
93
- return [0, val];
94
- }
95
- return null;
96
- });
115
+ const normalizedData = normalizeSeriesData(s);
116
+ if (!normalizedData) return;
97
117
  stackedDataMap.set(s.id, normalizedData);
98
118
  });
99
- numericStackGroups.forEach((groupSeries, stackKey) => {
119
+ numericStackGroups.forEach(groupSeries => {
120
+ // A lone series with stackId should still behave like a non-stacked series.
121
+ if (groupSeries.length < 2) {
122
+ groupSeries.forEach(singleSeries => {
123
+ const normalizedData = normalizeSeriesData(singleSeries);
124
+ if (!normalizedData) return;
125
+ stackedDataMap.set(singleSeries.id, normalizedData);
126
+ });
127
+ return;
128
+ }
100
129
  const maxLength = Math.max(...groupSeries.map(s => {
101
130
  var _s$data3;
102
131
  return ((_s$data3 = s.data) == null ? void 0 : _s$data3.length) || 0;
103
132
  }));
104
133
  if (maxLength === 0) return;
134
+ const first = groupSeries[0];
135
+ const groupBaseline = getValueAxisBaselineForSeries(layout, first, xAxisConfigs, yAxisConfigs);
105
136
  const dataset = new Array(maxLength).fill(undefined).map((_, i) => {
106
137
  const row = {};
107
138
  for (const s of groupSeries) {
108
139
  var _s$data4;
109
140
  const val = (_s$data4 = s.data) == null ? void 0 : _s$data4[i];
110
- const num = typeof val === 'number' ? val : 0;
141
+ // Stack around baseline by translating values into baseline-relative deltas.
142
+ const num = typeof val === 'number' ? val - groupBaseline : 0;
111
143
  row[s.id] = num;
112
144
  }
113
145
  return row;
@@ -118,7 +150,7 @@ export const getStackedSeriesData = series => {
118
150
  const seriesId = keys[layerIndex];
119
151
  const stackedData = layer.map(_ref => {
120
152
  let [bottom, top] = _ref;
121
- return [bottom, top];
153
+ return [bottom + groupBaseline, top + groupBaseline];
122
154
  });
123
155
  stackedDataMap.set(seriesId, stackedData);
124
156
  });
@@ -157,7 +189,7 @@ export const getLineData = data => {
157
189
  * Range represents the range of y-values from the data.
158
190
  * Handles stacking by transforming data when series have stack properties.
159
191
  */
160
- export const getChartRange = (series, min, max) => {
192
+ export const getChartRange = (series, layout, xAxisConfigs, yAxisConfigs, min, max) => {
161
193
  const range = {
162
194
  min,
163
195
  max
@@ -183,11 +215,11 @@ export const getChartRange = (series, min, max) => {
183
215
  const hasStacks = Array.from(stackGroups.keys()).some(k => k !== undefined);
184
216
  if (hasStacks) {
185
217
  // Get stacked data using the shared function
186
- const stackedDataMap = getStackedSeriesData(series);
218
+ const stackedDataMap = getStackedSeriesData(series, layout, xAxisConfigs, yAxisConfigs);
187
219
 
188
220
  // Find the extreme values from the stacked data
189
- let stackedMax = 0;
190
- let stackedMin = 0;
221
+ let stackedMax = -Infinity;
222
+ let stackedMin = Infinity;
191
223
  stackedDataMap.forEach(stackedData => {
192
224
  stackedData.forEach(point => {
193
225
  if (point !== null) {
@@ -199,8 +231,8 @@ export const getChartRange = (series, min, max) => {
199
231
  });
200
232
 
201
233
  // Don't add padding - let D3's nice() function handle axis padding
202
- if (range.min === undefined) range.min = Math.min(0, stackedMin);
203
- if (range.max === undefined) range.max = Math.max(0, stackedMax);
234
+ if (range.min === undefined) range.min = stackedMin === Infinity ? 0 : stackedMin;
235
+ if (range.max === undefined) range.max = stackedMax === -Infinity ? 0 : stackedMax;
204
236
  } else {
205
237
  // No stacking, calculate range from raw values
206
238
  const allValues = [];
@@ -9,6 +9,14 @@ import { applySerializableScale, isCategoricalScale, isSerializableScale } from
9
9
  * Defines a gradient.
10
10
  */
11
11
 
12
+ /**
13
+ * Resolves the axis used for gradient processing.
14
+ */
15
+ export const getGradientAxis = (gradient, layout) => {
16
+ var _gradient$axis;
17
+ return (_gradient$axis = gradient.axis) != null ? _gradient$axis : layout === 'horizontal' ? 'x' : 'y';
18
+ };
19
+
12
20
  /**
13
21
  * Resolves gradient stops, handling both static arrays and function forms.
14
22
  * When stops is a function, calls it with the domain bounds.
@@ -87,9 +95,10 @@ export const getColorWithOpacity = (color1, opacity) => {
87
95
  * Processes a GradientDefinition into a renderable GradientConfig.
88
96
  * Supports both numeric scales (linear, log) and categorical scales (band).
89
97
  *
90
- * @param gradient - GradientDefinition configuration (required)
91
- * @param xScale - X-axis scale (required)
92
- * @param yScale - Y-axis scale (required)
98
+ * @param gradient - GradientDefinition configuration
99
+ * @param xScale - X-axis scale
100
+ * @param yScale - Y-axis scale
101
+ * @param layout - Chart layout
93
102
  * @returns GradientConfig or null if gradient processing fails
94
103
  *
95
104
  * @example
@@ -110,11 +119,12 @@ export const getColorWithOpacity = (color1, opacity) => {
110
119
  * );
111
120
  * }
112
121
  */
113
- export const getGradientConfig = (gradient, xScale, yScale) => {
122
+ export const getGradientConfig = (gradient, xScale, yScale, layout) => {
114
123
  if (!gradient) return;
115
124
 
116
125
  // Get the scale based on axis
117
- const scale = gradient.axis === 'x' ? xScale : yScale;
126
+ const axis = getGradientAxis(gradient, layout);
127
+ const scale = axis === 'x' ? xScale : yScale;
118
128
  if (!scale) return;
119
129
 
120
130
  // Extract domain from scale
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinbase/cds-mobile-visualization",
3
- "version": "3.6.2",
3
+ "version": "3.8.0",
4
4
  "description": "Coinbase Design System - Mobile Visualization Native",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,9 +36,9 @@
36
36
  "CHANGELOG"
37
37
  ],
38
38
  "peerDependencies": {
39
- "@coinbase/cds-common": "^8.66.0",
39
+ "@coinbase/cds-common": "^8.70.0",
40
40
  "@coinbase/cds-lottie-files": "^3.3.4",
41
- "@coinbase/cds-mobile": "^8.66.0",
41
+ "@coinbase/cds-mobile": "^8.70.0",
42
42
  "@coinbase/cds-utils": "^2.3.5",
43
43
  "@shopify/react-native-skia": "^1.12.4 || ^2.0.0",
44
44
  "react": "^18.3.1",
@@ -57,9 +57,9 @@
57
57
  "@babel/preset-env": "^7.28.0",
58
58
  "@babel/preset-react": "^7.27.1",
59
59
  "@babel/preset-typescript": "^7.27.1",
60
- "@coinbase/cds-common": "^8.66.0",
60
+ "@coinbase/cds-common": "^8.70.0",
61
61
  "@coinbase/cds-lottie-files": "^3.3.4",
62
- "@coinbase/cds-mobile": "^8.66.0",
62
+ "@coinbase/cds-mobile": "^8.70.0",
63
63
  "@coinbase/cds-utils": "^2.3.5",
64
64
  "@shopify/react-native-skia": "1.12.4",
65
65
  "@types/react": "^18.3.12",