Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mellow-hamster-zero-stack.md
Original file line number Diff line number Diff line change
@@ -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.
100 changes: 100 additions & 0 deletions packages/charts-core/src/stack-internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof stackExtents>,
index: number,
Expand Down
57 changes: 55 additions & 2 deletions packages/charts-core/src/stack-internal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
stack as d3Stack,
stackOffsetDiverging,
stackOffsetExpand,
stackOffsetNone,
stackOffsetSilhouette,
Expand Down Expand Up @@ -81,7 +80,7 @@ export function stackExtents(
? stackOffsetSilhouette
: options.offset === 'wiggle'
? stackOffsetWiggle
: stackOffsetDiverging
: stackOffsetDivergingZeroAware
const generator = d3Stack<Record<string, number>, string>()
.keys(identities)
.value((row, key) => row[key] ?? 0)
Expand Down Expand Up @@ -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<any, any>[],
order: Iterable<number>,
): 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<StackOptions>) {
const anchor = options.anchor
if (!anchor) return undefined
Expand Down