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
6 changes: 6 additions & 0 deletions .changeset/line-cap-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/charts': minor
---

Add `lineCap` and `lineJoin` options to `lineY` and `lineX`. Both options use
the renderer-supported `SceneStyle` values and continue to default to `round`.
18 changes: 18 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ Each entry records:
| F-292 | Fixed preview paints ignored the selected site theme | Tooling | resolved |
| F-293 | Root scale slots blocked named axes | API | resolved |
| F-294 | Automatic mark renderers imposed shared host plumbing | API | resolved |
| F-295 | Line marks forced round endpoints | API | resolved |

## Findings

Expand Down Expand Up @@ -8505,3 +8506,20 @@ Each entry records:
React Native Metro gates, and framework package checks pass. Bundle boundary
checks keep SVG-only entries free of Canvas and measure the opt-in mixed
representative and React consumers at 35.68 KiB and 41.63 KiB gzip.

### F-295 - Line marks forced round endpoints

- Status: resolved
- Severity: medium
- Owner: API
- Observed in: migrating Rewardo's composed statistics chart from Recharts
- Friction: `lineY` and `lineX` exposed stroke width, opacity, dash pattern,
and curve options, but hard-coded round caps and joins. Reproducing the
previous Recharts line, which inherited SVG's butt cap, required replacing
or patching rendered output.
- Decision: expose `lineCap` and `lineJoin` on the shared line options using
the values from `SceneStyle`. Preserve round as the default for both options.
- Verification: public type tests bind both line APIs to the scene renderer's
supported values. Scene, SVG, Canvas, motion, export, and React Native tests
preserve configured values. The Rewardo chart can use `lineCap: 'butt'` on
its existing pink `lineY` mark.
32 changes: 16 additions & 16 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
"policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.",
"bundles": {
"D3-scale line scene": {
"bytes": 50813,
"gzip": 19070
"bytes": 50836,
"gzip": 19079
},
"D3-scale line + static SVG": {
"bytes": 55544,
"gzip": 20776
"bytes": 55567,
"gzip": 20785
},
"Representative marks": {
"bytes": 75617,
"gzip": 27582
"bytes": 75640,
"gzip": 27591
},
"TanStack DOM host": {
"bytes": 74947,
Expand All @@ -23,24 +23,24 @@
"gzip": 26831
},
"React line consumer": {
"bytes": 100766,
"gzip": 36222
"bytes": 100789,
"gzip": 36231
},
"Compact-scale line scene": {
"bytes": 33265,
"gzip": 11935
"bytes": 33288,
"gzip": 11944
},
"React compact-scale line consumer": {
"bytes": 83266,
"gzip": 29144
"bytes": 83289,
"gzip": 29153
},
"Custom-scale line scene": {
"bytes": 31447,
"gzip": 11193
"bytes": 31470,
"gzip": 11201
},
"D3 linear-scale line scene": {
"bytes": 50745,
"gzip": 19032
"bytes": 50768,
"gzip": 19041
}
}
}
36 changes: 18 additions & 18 deletions benchmarks/comparison/bundle-baseline.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"schemaVersion": 4,
"generatedAt": "2026-08-26T21:51:12.832Z",
"generatedAt": "2026-08-27T21:38:32.371Z",
"packageVersions": {
"tanstack": "0.15.0",
"tanstack": "0.16.0",
"chartjs": "4.5.1",
"echarts": "6.1.0",
"recharts": "3.10.1",
Expand All @@ -12,7 +12,7 @@
"tanstack": {
"kind": "workspace",
"revision": "3df87d71f0305e5a450c10b66940f76f3e14259a",
"inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e"
"inputDigest": "sha256:a5332d205ee6b678314f0a444dc2198cb002c6b55b49c7bf1c5acd0ea7ed1783"
},
"chartjs": {
"kind": "package",
Expand Down Expand Up @@ -45,25 +45,25 @@
},
"bundles": {
"tanstack-line-basic": {
"minifiedBytes": 110431,
"gzipBytes": 39891,
"brotliBytes": 35325,
"incrementalGzipBytes": 39891,
"incrementalBrotliBytes": 35325
"minifiedBytes": 110454,
"gzipBytes": 39901,
"brotliBytes": 35367,
"incrementalGzipBytes": 39901,
"incrementalBrotliBytes": 35367
},
"tanstack-line-interactive": {
"minifiedBytes": 115872,
"gzipBytes": 41648,
"brotliBytes": 36746,
"incrementalGzipBytes": 41648,
"incrementalBrotliBytes": 36746
"minifiedBytes": 115895,
"gzipBytes": 41661,
"brotliBytes": 36828,
"incrementalGzipBytes": 41661,
"incrementalBrotliBytes": 36828
},
"tanstack-line-advanced": {
"minifiedBytes": 123062,
"gzipBytes": 43974,
"brotliBytes": 38764,
"incrementalGzipBytes": 43974,
"incrementalBrotliBytes": 38764
"minifiedBytes": 123085,
"gzipBytes": 43988,
"brotliBytes": 38803,
"incrementalGzipBytes": 43988,
"incrementalBrotliBytes": 38803
},
"tanstack-bar-basic": {
"minifiedBytes": 119225,
Expand Down
1 change: 1 addition & 0 deletions benchmarks/conformance/cases/70-composed-chart/case.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"left axis and stacked right axes",
"daily observations",
"monotone curves",
"butt line cap",
"resolved maximum bar thickness"
],
"geometry": [
Expand Down
1 change: 1 addition & 0 deletions benchmarks/conformance/cases/70-composed-chart/example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const createExampleChart = (input: ChartOptions) => {
y: 'temp_min',
stroke: '#ff7300',
strokeWidth: 2,
lineCap: 'butt',
curve: monotone,
}),
dot(rows, {
Expand Down
1 change: 1 addition & 0 deletions benchmarks/conformance/catalog-index.json
Original file line number Diff line number Diff line change
Expand Up @@ -2394,6 +2394,7 @@
"left axis and stacked right axes",
"daily observations",
"monotone curves",
"butt line cap",
"resolved maximum bar thickness"
],
"geometry": [
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/conformance/previews/70-composed-chart.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions benchmarks/conformance/previews/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"width": 288,
"height": 192,
"sourceHash": "d3177faf65fca2414b431c195a003cf9afdceb8ae2f1b37e4f8cb81578dc8293",
"sourceHash": "99895d05b94419426e1065524564e39ba82fc1cdeae9c5875b4718ba6aeae5ee",
"assets": [
{
"id": "01-line-gaps",
Expand Down Expand Up @@ -306,8 +306,8 @@
},
{
"id": "70-composed-chart",
"sha256": "642139631cae72ef76ff81e148e6d40e2ff83225a11b582a32878d0f166beae7",
"bytes": 14436
"sha256": "db93c404c4e097cad237dca330dab72a1080f1e38e873755ef7ca86a81bbeb30",
"bytes": 14435
},
{
"id": "71-recharts-population-pyramid",
Expand Down
4 changes: 2 additions & 2 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ output model.

## Bundle snapshot

Baseline date: `2026-08-26`.
Baseline date: `2026-08-27`.

Controlled ranges cover 12 independently built, minified browser consumers:
line, bar, area, and scatter at basic, interactive, and advanced tiers. Only
Expand All @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July

| Library | Bundle size | React externalized | Evidence |
| ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- |
| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite |
| TanStack Charts | 38.97–44.98 KiB | Not applicable | Controlled suite |
| D3 | 90 KB gzip | — | External main export |
| Chart.js | 44.70–58.21 KiB | — | Controlled suite |
| Apache ECharts | 153.10–173.18 KiB | — | Controlled suite |
Expand Down
5 changes: 5 additions & 0 deletions docs/examples/lines-and-areas.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const chart = defineChart({
y: 'downloads',
points: true,
stroke: '#2563eb',
lineCap: 'butt',
}),
],
scales: {
Expand All @@ -64,6 +65,10 @@ const chart = defineChart({
export default chart
```

`lineCap: 'butt'` ends the stroke at each path endpoint. Omit it to retain the
default round endpoints. `lineX` accepts the same `lineCap` and `lineJoin`
options.

## Compare several series from a common baseline

Indexing each series to its first observation compares relative change when the
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/marks/line-and-area.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function lineY<TDatum>(
| `strokeOpacity` | `number` | SVG default | Stroke opacity |
| `strokeWidth` | `number` | `2.25` | Stroke width |
| `strokeDasharray` | `string` | None | SVG dash array |
| `lineCap` | `SceneStyle["lineCap"]` | `"round"` | Stroke endpoint shape |
| `lineJoin` | `SceneStyle["lineJoin"]` | `"round"` | Stroke corner shape |
| `points` | `boolean` | `false` | Draws a radius-`2.5` dot at each valid point |
| `curve` | `ChartCurve` | Straight segments | Optional path generator |
| `states` | `readonly ChartMarkState[]` | None | Focus-driven presentation overrides |
Expand Down
4 changes: 2 additions & 2 deletions packages/charts-core/docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ output model.

## Bundle snapshot

Baseline date: `2026-08-26`.
Baseline date: `2026-08-27`.

Controlled ranges cover 12 independently built, minified browser consumers:
line, bar, area, and scatter at basic, interactive, and advanced tiers. Only
Expand All @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July

| Library | Bundle size | React externalized | Evidence |
| ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- |
| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite |
| TanStack Charts | 38.97–44.98 KiB | Not applicable | Controlled suite |
| D3 | 90 KB gzip | — | External main export |
| Chart.js | 44.70–58.21 KiB | — | Controlled suite |
| Apache ECharts | 153.10–173.18 KiB | — | Controlled suite |
Expand Down
5 changes: 5 additions & 0 deletions packages/charts-core/docs/examples/lines-and-areas.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const chart = defineChart({
y: 'downloads',
points: true,
stroke: '#2563eb',
lineCap: 'butt',
}),
],
scales: {
Expand All @@ -64,6 +65,10 @@ const chart = defineChart({
export default chart
```

`lineCap: 'butt'` ends the stroke at each path endpoint. Omit it to retain the
default round endpoints. `lineX` accepts the same `lineCap` and `lineJoin`
options.

## Compare several series from a common baseline

Indexing each series to its first observation compares relative change when the
Expand Down
2 changes: 2 additions & 0 deletions packages/charts-core/docs/reference/marks/line-and-area.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function lineY<TDatum>(
| `strokeOpacity` | `number` | SVG default | Stroke opacity |
| `strokeWidth` | `number` | `2.25` | Stroke width |
| `strokeDasharray` | `string` | None | SVG dash array |
| `lineCap` | `SceneStyle["lineCap"]` | `"round"` | Stroke endpoint shape |
| `lineJoin` | `SceneStyle["lineJoin"]` | `"round"` | Stroke corner shape |
| `points` | `boolean` | `false` | Draws a radius-`2.5` dot at each valid point |
| `curve` | `ChartCurve` | Straight segments | Optional path generator |
| `states` | `readonly ChartMarkState[]` | None | Focus-driven presentation overrides |
Expand Down
73 changes: 73 additions & 0 deletions packages/charts-core/src/canvas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,79 @@ afterEach(() => {
})

describe('Canvas renderer', () => {
it('paints authored caps and joins for both line directions', () => {
const scene = createChartScene(
defineChart({
marks: [
lineY([4, 9], { lineCap: 'butt', lineJoin: 'bevel' }),
lineX([4, 9], { lineCap: 'square', lineJoin: 'miter' }),
],
scales: {
x: { scale: scaleLinear },
y: { scale: scaleLinear },
},
guides: false,
}),
{ width: 300, height: 180 },
)
const container = document.createElement('div')
const surface = createCanvasChartRenderer().mount(container, () => {})

surface.render(scene, renderOptions())

const canvas = container.querySelector<HTMLCanvasElement>(
'.ts-chart-canvas__scene',
)
const painted = canvas ? contexts.get(canvas) : undefined
if (!painted) throw new Error('Expected a painted scene canvas')
expect(painted.operations).toEqual(
expect.arrayContaining([
'lineCap:butt',
'lineJoin:bevel',
'lineCap:square',
'lineJoin:miter',
]),
)

surface.destroy()
})

it('paints round caps and joins by default for both line directions', () => {
const scene = createChartScene(
defineChart({
marks: [lineY([4, 9]), lineX([4, 9])],
scales: {
x: { scale: scaleLinear },
y: { scale: scaleLinear },
},
guides: false,
}),
{ width: 300, height: 180 },
)
const container = document.createElement('div')
const surface = createCanvasChartRenderer().mount(container, () => {})

surface.render(scene, renderOptions())

const canvas = container.querySelector<HTMLCanvasElement>(
'.ts-chart-canvas__scene',
)
const painted = canvas ? contexts.get(canvas) : undefined
if (!painted) throw new Error('Expected a painted scene canvas')
expect(
painted.operations.filter((operation) =>
operation.startsWith('lineCap:'),
),
).toEqual(['lineCap:round', 'lineCap:round'])
expect(
painted.operations.filter((operation) =>
operation.startsWith('lineJoin:'),
),
).toEqual(['lineJoin:round', 'lineJoin:round'])

surface.destroy()
})

it('composes renderer-tagged marks in source order', () => {
const data = [
{ id: 'a', category: 'A', value: 3 },
Expand Down
4 changes: 4 additions & 0 deletions packages/charts-core/src/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ describe('optional export', () => {
defineChart({
marks: [
lineY([1, 3, 2], {
lineCap: 'butt',
lineJoin: 'bevel',
stroke: 'currentColor',
}),
],
Expand All @@ -34,6 +36,8 @@ describe('optional export', () => {
expect(result).toContain('width="480"')
expect(result).toContain('height="260"')
expect(result).toContain('aria-label="Exported chart"')
expect(result).toContain('stroke-linecap="butt"')
expect(result).toContain('stroke-linejoin="bevel"')
expect(result).not.toContain('data-ts-focus-layer')
})

Expand Down
Loading