diff --git a/.changeset/mellow-hamster-zero-stack.md b/.changeset/mellow-hamster-zero-stack.md new file mode 100644 index 00000000..17aee709 --- /dev/null +++ b/.changeset/mellow-hamster-zero-stack.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Stacked marks no longer collapse a zero-valued cell to the axis. The default diverging offset now keeps zeros on their series' running baseline, so a stacked area with a zero data point renders a flat segment instead of a spike to zero. diff --git a/packages/charts-core/src/stack-internal.test.ts b/packages/charts-core/src/stack-internal.test.ts index 7a5d6586..eb62ad52 100644 --- a/packages/charts-core/src/stack-internal.test.ts +++ b/packages/charts-core/src/stack-internal.test.ts @@ -359,6 +359,106 @@ describe('anchored stacks', () => { }) }) +describe('diverging offset zero handling', () => { + it('keeps a zero-valued cell on the running top of a positive stack', () => { + const positiveRows = [ + { position: 0, series: 'A', value: 10 }, + { position: 0, series: 'B', value: 5 }, + { position: 1, series: 'A', value: 10 }, + { position: 1, series: 'B', value: 0 }, + ] as const + const extents = stackExtents( + positiveRows.map((row, index) => ({ index, ...row })), + { order: ['A', 'B'] }, + ) + + expectExtent(extents, 0, 0, 10) + expectExtent(extents, 1, 10, 15) + expectExtent(extents, 2, 0, 10) + expectExtent(extents, 3, 10, 10) + }) + + it('keeps a zero-valued cell on the running bottom of an exclusively negative stack', () => { + const negativeRows = [ + { position: 0, series: 'A', value: -10 }, + { position: 0, series: 'B', value: -5 }, + { position: 1, series: 'A', value: -10 }, + { position: 1, series: 'B', value: 0 }, + ] as const + const extents = stackExtents( + negativeRows.map((row, index) => ({ index, ...row })), + { order: ['A', 'B'] }, + ) + + expectExtent(extents, 0, -10, 0) + expectExtent(extents, 1, -15, -10) + expectExtent(extents, 2, -10, 0) + expectExtent(extents, 3, -10, -10) + }) + + it('leaves a zero unchanged when its series is the only negative one in a mixed stack', () => { + const mixedRows = [ + { position: 0, series: 'A', value: 10 }, + { position: 0, series: 'B', value: -3 }, + { position: 1, series: 'A', value: 10 }, + { position: 1, series: 'B', value: 0 }, + ] as const + const extents = stackExtents( + mixedRows.map((row, index) => ({ index, ...row })), + { order: ['A', 'B'] }, + ) + + expectExtent(extents, 3, 0, 0) + }) + + it('leaves an all-zero position unchanged', () => { + const allZeroRows = [ + { position: 0, series: 'A', value: 10 }, + { position: 0, series: 'B', value: 5 }, + { position: 1, series: 'A', value: 0 }, + { position: 1, series: 'B', value: 0 }, + ] as const + const extents = stackExtents( + allZeroRows.map((row, index) => ({ index, ...row })), + { order: ['A', 'B'] }, + ) + + expectExtent(extents, 2, 0, 0) + expectExtent(extents, 3, 0, 0) + }) + + it('shares the zero-aware diverging policy with materialized vertical and horizontal rows', () => { + const positiveRows = [ + { position: 0, series: 'A', value: 10 }, + { position: 0, series: 'B', value: 5 }, + { position: 1, series: 'A', value: 10 }, + { position: 1, series: 'B', value: 0 }, + ] as const + const options = { order: ['A', 'B'] as const } + const vertical = stackRowsY(positiveRows, { + x: 'position', + y: 'value', + z: 'series', + ...options, + }) + const horizontal = stackRowsX(positiveRows, { + x: 'value', + y: 'position', + z: 'series', + ...options, + }) + + expect(horizontal).toHaveLength(vertical.length) + vertical.forEach((datum, index) => { + const transposed = horizontal[index]! + expect(transposed.x1).toBeCloseTo(datum.y1) + expect(transposed.x2).toBeCloseTo(datum.y2) + }) + expect(vertical[3]!.y1).toBeCloseTo(10) + expect(vertical[3]!.y2).toBeCloseTo(10) + }) +}) + function expectExtent( extents: ReturnType, index: number, diff --git a/packages/charts-core/src/stack-internal.ts b/packages/charts-core/src/stack-internal.ts index c8ee5a6c..b0264a78 100644 --- a/packages/charts-core/src/stack-internal.ts +++ b/packages/charts-core/src/stack-internal.ts @@ -1,6 +1,5 @@ import { stack as d3Stack, - stackOffsetDiverging, stackOffsetExpand, stackOffsetNone, stackOffsetSilhouette, @@ -81,7 +80,7 @@ export function stackExtents( ? stackOffsetSilhouette : options.offset === 'wiggle' ? stackOffsetWiggle - : stackOffsetDiverging + : stackOffsetDivergingZeroAware const generator = d3Stack, string>() .keys(identities) .value((row, key) => row[key] ?? 0) @@ -111,6 +110,60 @@ export function stackExtents( return output } +/** + * d3's `stackOffsetDiverging` assigns `[0, 0]` to a cell whose value is exactly zero, which + * parks it on the axis instead of on its stack's running baseline. That is invisible for bars + * but not for area and line marks, whose paths interpolate between adjacent positions: the band + * collapses to the axis and back, cutting through the layers below. + * + * Zero-valued cells here instead keep the baseline of the side their own series occupies, so the + * band pinches flat against its neighbours. Non-finite values are left to d3's original branch so + * marks still see them as gaps. + */ +function stackOffsetDivergingZeroAware( + series: Series[], + order: Iterable, +): void { + const n = series.length + if (n === 0) return + + // A zero has no sign of its own, so take the side from the series it belongs to. Only an + // exclusively negative series stacks downward; positive, mixed and all-zero series stack up. + const negativeSide = series.map((values) => { + let negative = false + let positive = false + for (const [start, end] of values) { + const dy = end - start + if (dy < 0) negative = true + else if (dy > 0) positive = true + } + return negative && !positive + }) + + const indices = [...order] + const m = series[indices[0]!]!.length + for (let j = 0; j < m; j += 1) { + let yp = 0 + let yn = 0 + for (const i of indices) { + const d = series[i]![j]! + const dy = d[1] - d[0] + if (dy > 0) { + d[0] = yp + d[1] = yp += dy + } else if (dy < 0) { + d[1] = yn + d[0] = yn += dy + } else if (dy === 0) { + d[0] = d[1] = negativeSide[i] ? yn : yp + } else { + d[0] = 0 + d[1] = dy // non-finite, preserved as in d3 + } + } + } +} + function resolveAnchorFraction(options: Readonly) { const anchor = options.anchor if (!anchor) return undefined