diff --git a/.changeset/styled-static-guides.md b/.changeset/styled-static-guides.md new file mode 100644 index 00000000..781ee695 --- /dev/null +++ b/.changeset/styled-static-guides.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add renderer-neutral stroke styling for Cartesian grid rules and axis lines through `grid` and `axis.line` style objects. Authored widths participate in automatic guide margins, and zero-width strokes stay hidden across renderers. diff --git a/API-FRICTION.md b/API-FRICTION.md index 0602df4a..6e8cfdb9 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -296,7 +296,7 @@ Each entry records: | F-257 | The release package graph leaked into application setup | API/Docs/Tooling | resolved | | F-258 | Tooltip chrome required specificity overrides | API/Documentation | resolved | | F-259 | Chart resources cannot declare patterns | API | open | -| F-260 | Static guides cannot express stroke treatment | API | open | +| F-260 | Static guides cannot express stroke treatment | API | resolved | | F-261 | Cartesian bars cannot round only exposed corners | API | resolved | | F-262 | Mark inference accepted an unsupported style option | API | resolved | | F-263 | Chromium transport suspension interrupted catalog previews | Tooling | resolved | @@ -7957,7 +7957,7 @@ Each entry records: ### F-260 — Static guides cannot express stroke treatment -- Status: open +- Status: resolved - Severity: medium - Owner: API - Observed in: the themed area and active bar dashboard cases 120 and 121 @@ -7965,11 +7965,22 @@ Each entry records: theme can set one guide paint, but an author cannot set grid or axis stroke width, dash, or opacity. F-112 added dashes to rule marks, and F-191 added tick-label styling. Neither entry covers static axis and grid strokes. -- Current decision: keep the catalog cases on native solid grids with - `theme.grid`. Use rule marks for styled annotations and `crosshair` for - styled focus guides. Do not synthesize repeated grid rules in an application - shell. Keep a guide-style object open for renderer, facet, export, and motion - evaluation. +- Decision: add a renderer-neutral `ChartGuideLineStyle` object to `grid` and + `axis.line` while retaining their boolean forms. Keep shared grid defaults on + the existing group and apply only authored overrides to each scale's rules. + Axis-line styles affect the baseline without changing tick stubs. Use + semantic style comparison for shared facet axis lines so equal objects + created per cell remain compatible while differing baselines are rejected. + Keep each facet cell's grid independent. Include authored stroke extents in + unlocked guide margins, and treat zero-width Canvas strokes as unpainted. +- Verification: focused scene tests preserve byte-equivalent boolean output, + empty-object defaults, per-scale overrides, zero width and opacity, and + baseline-only styling. Thick baseline and grid tests cover automatic surface + containment, including shared outer axes and independent facet grids. Facet + tests cover equal and different baseline values plus per-cell grid styles. + Motion tests cover numeric interpolation and discrete dash and cap updates. + SVG export, Canvas, React Native, and public type-barrel tests preserve the + renderer-neutral style fields, and Canvas skips zero-width strokes. ### F-261 — Cartesian bars cannot round only exposed corners diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 7d710f76..84d68cbb 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -25,8 +25,8 @@ may add only its transport module over the tooltip consumer. Ordinary line, compact-scale, and tooltip kernels also reject all transform modules. The compact linear scene and React consumer are both locked and budgeted. The -scene has an 11.94 KiB gzip ceiling. The React compact-scale line consumer has a -30.51 KiB ceiling with React and React DOM external. `d3-array` tick helpers are +scene has a 12.46 KiB gzip ceiling. The React compact-scale line consumer has a +31.03 KiB ceiling with React and React DOM external. `d3-array` tick helpers are allowed only in the compact linear path; categorical compact-scale kernels reject every D3 runtime input. All compact fixtures reject `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, and `internmap`. @@ -41,14 +41,22 @@ Cartesian axis-title styling is also part of the shared scene contract. Its static SVG fixture adds no retained modules over the ordinary line consumer and keeps authored title options under a 0.25 KiB incremental gzip ceiling. +Configurable Cartesian grid and axis-line strokes, including geometry-aware +automatic margins, are also part of the default scene contract. Their reviewed +shared-path cost is recorded in the locked entries and complete-consumer +budgets. In the cumulative release, guide styling adds 504 gzip bytes to the +compact scene and 516 bytes to the DOM host. Its complete-consumer ceilings +retain roughly 30 bytes of headroom above the measured output. + Continuous viewports and the controlled interaction controller are also part of the default scene and host contracts. Default static SVG consumes scene clips and gradients. Their reviewed shared-path cost is recorded in the locked entries and the corresponding complete-consumer budgets. Rolling path planning remains confined to the opt-in motion renderer, whose complete SVG budget is 22.45 KiB gzip. The cumulative styling, focus-motion, and radial-gradient -integration measures 22.42 KiB for this optional renderer and 29.81 KiB for the -composite SVG consumer, whose ceiling is 29.84 KiB. Radial resources add 112 +integration measures 22.42 KiB for this optional renderer. The composite SVG +consumer measures 30.33 KiB after guide styling, with a 30.36 KiB ceiling. +Radial resources add 112 gzip bytes to the static SVG consumer and 124 bytes to the DOM host. Scene-only consumers remain unchanged apart from one byte of compression variation. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 0b6c4916..171c60a8 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,44 +3,44 @@ "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": 51809, - "gzip": 19384 + "bytes": 53385, + "gzip": 19890 }, "D3-scale line + static SVG": { - "bytes": 57963, - "gzip": 21674 + "bytes": 59539, + "gzip": 22205 }, "Representative marks": { - "bytes": 81705, - "gzip": 29821 + "bytes": 83279, + "gzip": 30334 }, "TanStack DOM host": { - "bytes": 80267, - "gzip": 28054 + "bytes": 81843, + "gzip": 28570 }, "React adapter": { - "bytes": 82588, - "gzip": 28968 + "bytes": 84168, + "gzip": 29511 }, "React line consumer": { - "bytes": 106247, - "gzip": 38313 + "bytes": 107827, + "gzip": 38843 }, "Compact-scale line scene": { - "bytes": 34264, - "gzip": 12222 + "bytes": 35838, + "gzip": 12726 }, "React compact-scale line consumer": { - "bytes": 88747, - "gzip": 31212 + "bytes": 90327, + "gzip": 31742 }, "Custom-scale line scene": { - "bytes": 32446, - "gzip": 11484 + "bytes": 34020, + "gzip": 11990 }, "D3 linear-scale line scene": { - "bytes": 51741, - "gzip": 19346 + "bytes": 53317, + "gzip": 19852 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 30ba421f..470aab9f 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-09-10T04:36:47.292Z", + "generatedAt": "2026-09-10T04:42:04.324Z", "packageVersions": { "tanstack": "0.17.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "9e73e9d446c51a613e0781838acf1f718ac30e1b", - "inputDigest": "sha256:af21b1970c5acccf245346d7cac30c7e6e0df58468009de27618b712b514ad0e" + "revision": "87f59846de342cef801f73b84a2965ea0de71631", + "inputDigest": "sha256:0c6de97d4422ab7b195da9f1f3772c8a2fac130fe47ea9b0bb2efcd2380e09cb" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 116338, - "gzipBytes": 42039, - "brotliBytes": 37187, - "incrementalGzipBytes": 42039, - "incrementalBrotliBytes": 37187 + "minifiedBytes": 117914, + "gzipBytes": 42548, + "brotliBytes": 37655, + "incrementalGzipBytes": 42548, + "incrementalBrotliBytes": 37655 }, "tanstack-line-interactive": { - "minifiedBytes": 121905, - "gzipBytes": 43868, - "brotliBytes": 38653, - "incrementalGzipBytes": 43868, - "incrementalBrotliBytes": 38653 + "minifiedBytes": 123479, + "gzipBytes": 44400, + "brotliBytes": 39090, + "incrementalGzipBytes": 44400, + "incrementalBrotliBytes": 39090 }, "tanstack-line-advanced": { - "minifiedBytes": 129096, - "gzipBytes": 46213, - "brotliBytes": 40690, - "incrementalGzipBytes": 46213, - "incrementalBrotliBytes": 40690 + "minifiedBytes": 130673, + "gzipBytes": 46722, + "brotliBytes": 41096, + "incrementalGzipBytes": 46722, + "incrementalBrotliBytes": 41096 }, "tanstack-bar-basic": { - "minifiedBytes": 128778, - "gzipBytes": 46747, - "brotliBytes": 41174, - "incrementalGzipBytes": 46747, - "incrementalBrotliBytes": 41174 + "minifiedBytes": 130352, + "gzipBytes": 47280, + "brotliBytes": 41520, + "incrementalGzipBytes": 47280, + "incrementalBrotliBytes": 41520 }, "tanstack-bar-interactive": { - "minifiedBytes": 133200, - "gzipBytes": 48064, - "brotliBytes": 42256, - "incrementalGzipBytes": 48064, - "incrementalBrotliBytes": 42256 + "minifiedBytes": 134772, + "gzipBytes": 48610, + "brotliBytes": 42660, + "incrementalGzipBytes": 48610, + "incrementalBrotliBytes": 42660 }, "tanstack-bar-advanced": { - "minifiedBytes": 133539, - "gzipBytes": 48222, - "brotliBytes": 42349, - "incrementalGzipBytes": 48222, - "incrementalBrotliBytes": 42349 + "minifiedBytes": 135111, + "gzipBytes": 48751, + "brotliBytes": 42735, + "incrementalGzipBytes": 48751, + "incrementalBrotliBytes": 42735 }, "tanstack-area-basic": { - "minifiedBytes": 121522, - "gzipBytes": 44009, - "brotliBytes": 38924, - "incrementalGzipBytes": 44009, - "incrementalBrotliBytes": 38924 + "minifiedBytes": 123094, + "gzipBytes": 44591, + "brotliBytes": 39426, + "incrementalGzipBytes": 44591, + "incrementalBrotliBytes": 39426 }, "tanstack-area-interactive": { - "minifiedBytes": 127093, - "gzipBytes": 45848, - "brotliBytes": 40491, - "incrementalGzipBytes": 45848, - "incrementalBrotliBytes": 40491 + "minifiedBytes": 128661, + "gzipBytes": 46371, + "brotliBytes": 40864, + "incrementalGzipBytes": 46371, + "incrementalBrotliBytes": 40864 }, "tanstack-area-advanced": { - "minifiedBytes": 134467, - "gzipBytes": 48217, - "brotliBytes": 42509, - "incrementalGzipBytes": 48217, - "incrementalBrotliBytes": 42509 + "minifiedBytes": 136035, + "gzipBytes": 48727, + "brotliBytes": 42967, + "incrementalGzipBytes": 48727, + "incrementalBrotliBytes": 42967 }, "tanstack-scatter-basic": { - "minifiedBytes": 117344, - "gzipBytes": 42407, - "brotliBytes": 37496, - "incrementalGzipBytes": 42407, - "incrementalBrotliBytes": 37496 + "minifiedBytes": 118920, + "gzipBytes": 42909, + "brotliBytes": 37959, + "incrementalGzipBytes": 42909, + "incrementalBrotliBytes": 37959 }, "tanstack-scatter-interactive": { - "minifiedBytes": 122911, - "gzipBytes": 44231, - "brotliBytes": 38926, - "incrementalGzipBytes": 44231, - "incrementalBrotliBytes": 38926 + "minifiedBytes": 124483, + "gzipBytes": 44749, + "brotliBytes": 39464, + "incrementalGzipBytes": 44749, + "incrementalBrotliBytes": 39464 }, "tanstack-scatter-advanced": { - "minifiedBytes": 122927, - "gzipBytes": 44236, - "brotliBytes": 38951, - "incrementalGzipBytes": 44236, - "incrementalBrotliBytes": 38951 + "minifiedBytes": 124499, + "gzipBytes": 44755, + "brotliBytes": 39418, + "incrementalGzipBytes": 44755, + "incrementalBrotliBytes": 39418 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 778b5994..97ccadf5 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "01531a4e8c58ab62c022edb1d9ee7116d1ff82fb94cff4a89f307ecb83a2b6b0", + "sourceHash": "e718e368cd7171f8a3d954a3f05716633b0fbbfabd19a2f4fefced1810be1449", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 19560a4d..4c8f3253 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `9e73e9d` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `87f5984` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `9e73e9d`. +at page render time. The measured TanStack workspace revision is `87f5984`. ## Capability matrix @@ -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 | 41.05–47.09 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.55–47.61 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 | diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index ae21a276..ba811182 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -167,14 +167,16 @@ const x = { | Option | Purpose | | ----------------- | -------------------------------------------------------- | | `axis` | Configure the axis or hide it with `false` | -| `axis.line` | Show or hide the baseline | +| `axis.line` | Show, hide, or style the baseline | | `axis.ticks` | Configure candidates, stubs, padding, and formatting | | `axis.tickLabels` | Configure label rotation and collision thinning | | `axis.label` | Configure axis title text, typography, paint, and offset | -| `grid` | Draw grid lines at semantic candidates | +| `grid` | Draw or style grid lines at semantic candidates | | `reverse` | Reverse the responsive range | -The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted. +Both grids default to hidden when `grid` is omitted. A style object enables +the grid or baseline and accepts renderer-neutral stroke, opacity, width, +dash, and line-cap fields. Candidate generation and label layout are separate. Choose at most one of `axis.ticks.count`, `axis.ticks.spacing`, and `axis.ticks.values`. Grid lines diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 03b9f680..26324070 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 30.51 KiB gzip. +The locked compact React line consumer must remain at or below 31.03 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,222 gzip bytes -versus 19,384 with D3 linear scales. The equivalent React consumers measure -31,212 and 38,313 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,726 gzip bytes +versus 19,890 with D3 linear scales. The equivalent React consumers measure +31,742 and 38,843 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. diff --git a/docs/reference/chart-spec.md b/docs/reference/chart-spec.md index 5c5e4de6..cf6b13c3 100644 --- a/docs/reference/chart-spec.md +++ b/docs/reference/chart-spec.md @@ -128,8 +128,10 @@ Guide visibility and geometry are separate: - `scales.x.axis: false` or `scales.y.axis: false` hides one axis. - `guides: false` hides all guides and removes their implicit margin. -- Omitted `margin` sides are measured from ticks, rotation, titles, edge - overhang, color legends, and Cartesian `text` marks. +- `grid` and `axis.line` accept a boolean or a `ChartGuideLineStyle` object + with stroke, opacity, width, dash, and line-cap overrides. +- Omitted `margin` sides are measured from ticks, rotation, titles, guide + strokes, edge overhang, color legends, and Cartesian `text` marks. - `margin: 0` locks every side to zero. - `margin: { left: 80 }` locks only the left side. diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index cdb27ec0..677ad641 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -164,16 +164,24 @@ interface ChartPositionScaleOptions< side?: 'top' | 'right' | 'bottom' | 'left' } +interface ChartGuideLineStyle { + stroke?: string + strokeOpacity?: number + strokeWidth?: number + strokeDasharray?: string + lineCap?: 'butt' | 'round' | 'square' +} + interface ChartAxisOptions { scale: ChartScale | ChartScaleInput nice?: boolean | number reverse?: boolean viewport?: ChartAxisViewportOptions> - grid?: boolean + grid?: boolean | ChartGuideLineStyle axis?: | false | { - line?: boolean + line?: boolean | ChartGuideLineStyle ticks?: | false | { @@ -249,11 +257,17 @@ interface ChartAxisOptions { | `nice` | `false` | Nice the resolved domain using the responsive or supplied tick count. | | `reverse` | `false` | Reverses the responsive pixel range without changing the caller's scale. | | `viewport` | None | Commits a continuous semantic window and optional transient translation. | -| `grid` | `false` for x; `true` for y | Draws grid rules from semantic tick candidates. | +| `grid` | `false` | Draws grid rules from semantic tick candidates. | | `axis` | Inferred axis | Axis line, tick candidates, labels, and title; `false` hides the axis. | | `channel` | Inferred for `x` and `y` | Required on named scales; selects the Cartesian channel and range. | | `side` | `bottom` for x; `left` for y | Places an x axis on top/bottom or a y axis on left/right. | +Set `grid` or `axis.line` to a `ChartGuideLineStyle` object to enable the +guide and override its stroke, opacity, width, dash pattern, or line cap. +Omitted style fields keep the normal theme defaults. An empty object is +equivalent to `true`, and `axis.line` styles only the axis baseline, not its +tick stubs. Use finite non-negative widths and opacity values from zero to one. + ```ts type ChartContinuousValue = number | Date diff --git a/docs/reference/types.md b/docs/reference/types.md index dc774855..3724933a 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -215,7 +215,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisSide` | Cartesian axis side | | `CartesianScaleBindings` | Optional mark bindings to named `xScale` and `yScale` entries | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | -| `ChartAxisGuideOptions` | Guide behavior without the scale field | +| `ChartGuideLineStyle` | Static guide stroke treatment | | `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | | `ChartAxisTickOptions` | Candidate values, density, formatting, size, and padding | | `ChartAxisTickLabelOptions` | Per-candidate typography, anchor, offset, rotation, and thinning | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 19560a4d..4c8f3253 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `9e73e9d` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `87f5984` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `9e73e9d`. +at page render time. The measured TanStack workspace revision is `87f5984`. ## Capability matrix @@ -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 | 41.05–47.09 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.55–47.61 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 | diff --git a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md index ae21a276..ba811182 100644 --- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md +++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md @@ -167,14 +167,16 @@ const x = { | Option | Purpose | | ----------------- | -------------------------------------------------------- | | `axis` | Configure the axis or hide it with `false` | -| `axis.line` | Show or hide the baseline | +| `axis.line` | Show, hide, or style the baseline | | `axis.ticks` | Configure candidates, stubs, padding, and formatting | | `axis.tickLabels` | Configure label rotation and collision thinning | | `axis.label` | Configure axis title text, typography, paint, and offset | -| `grid` | Draw grid lines at semantic candidates | +| `grid` | Draw or style grid lines at semantic candidates | | `reverse` | Reverse the responsive range | -The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted. +Both grids default to hidden when `grid` is omitted. A style object enables +the grid or baseline and accepts renderer-neutral stroke, opacity, width, +dash, and line-cap fields. Candidate generation and label layout are separate. Choose at most one of `axis.ticks.count`, `axis.ticks.spacing`, and `axis.ticks.values`. Grid lines diff --git a/packages/charts-core/docs/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index 03b9f680..26324070 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -168,14 +168,14 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 30.51 KiB gzip. +The locked compact React line consumer must remain at or below 31.03 KiB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, `d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. Separate incremental gates limit tooltip and portal growth. -The current locked fixtures measure the compact line scene at 12,222 gzip bytes -versus 19,384 with D3 linear scales. The equivalent React consumers measure -31,212 and 38,313 gzip bytes with React and React DOM external. These are +The current locked fixtures measure the compact line scene at 12,726 gzip bytes +versus 19,890 with D3 linear scales. The equivalent React consumers measure +31,742 and 38,843 gzip bytes with React and React DOM external. These are fixture measurements, not universal savings claims; they show why the compact subset is the normal starting point. diff --git a/packages/charts-core/docs/reference/chart-spec.md b/packages/charts-core/docs/reference/chart-spec.md index 5c5e4de6..cf6b13c3 100644 --- a/packages/charts-core/docs/reference/chart-spec.md +++ b/packages/charts-core/docs/reference/chart-spec.md @@ -128,8 +128,10 @@ Guide visibility and geometry are separate: - `scales.x.axis: false` or `scales.y.axis: false` hides one axis. - `guides: false` hides all guides and removes their implicit margin. -- Omitted `margin` sides are measured from ticks, rotation, titles, edge - overhang, color legends, and Cartesian `text` marks. +- `grid` and `axis.line` accept a boolean or a `ChartGuideLineStyle` object + with stroke, opacity, width, dash, and line-cap overrides. +- Omitted `margin` sides are measured from ticks, rotation, titles, guide + strokes, edge overhang, color legends, and Cartesian `text` marks. - `margin: 0` locks every side to zero. - `margin: { left: 80 }` locks only the left side. diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index cdb27ec0..677ad641 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -164,16 +164,24 @@ interface ChartPositionScaleOptions< side?: 'top' | 'right' | 'bottom' | 'left' } +interface ChartGuideLineStyle { + stroke?: string + strokeOpacity?: number + strokeWidth?: number + strokeDasharray?: string + lineCap?: 'butt' | 'round' | 'square' +} + interface ChartAxisOptions { scale: ChartScale | ChartScaleInput nice?: boolean | number reverse?: boolean viewport?: ChartAxisViewportOptions> - grid?: boolean + grid?: boolean | ChartGuideLineStyle axis?: | false | { - line?: boolean + line?: boolean | ChartGuideLineStyle ticks?: | false | { @@ -249,11 +257,17 @@ interface ChartAxisOptions { | `nice` | `false` | Nice the resolved domain using the responsive or supplied tick count. | | `reverse` | `false` | Reverses the responsive pixel range without changing the caller's scale. | | `viewport` | None | Commits a continuous semantic window and optional transient translation. | -| `grid` | `false` for x; `true` for y | Draws grid rules from semantic tick candidates. | +| `grid` | `false` | Draws grid rules from semantic tick candidates. | | `axis` | Inferred axis | Axis line, tick candidates, labels, and title; `false` hides the axis. | | `channel` | Inferred for `x` and `y` | Required on named scales; selects the Cartesian channel and range. | | `side` | `bottom` for x; `left` for y | Places an x axis on top/bottom or a y axis on left/right. | +Set `grid` or `axis.line` to a `ChartGuideLineStyle` object to enable the +guide and override its stroke, opacity, width, dash pattern, or line cap. +Omitted style fields keep the normal theme defaults. An empty object is +equivalent to `true`, and `axis.line` styles only the axis baseline, not its +tick stubs. Use finite non-negative widths and opacity values from zero to one. + ```ts type ChartContinuousValue = number | Date diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index dc774855..3724933a 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -215,7 +215,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisSide` | Cartesian axis side | | `CartesianScaleBindings` | Optional mark bindings to named `xScale` and `yScale` entries | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | -| `ChartAxisGuideOptions` | Guide behavior without the scale field | +| `ChartGuideLineStyle` | Static guide stroke treatment | | `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | | `ChartAxisTickOptions` | Candidate values, density, formatting, size, and padding | | `ChartAxisTickLabelOptions` | Per-candidate typography, anchor, offset, rotation, and thinning | diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 69847d9f..761d2c22 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -819,6 +819,7 @@ describe('Canvas renderer', () => { strokeOpacity: 0.4, strokeWidth: 3, strokeDasharray: '2 4', + lineCap: 'round', }, children: [ { @@ -987,6 +988,7 @@ describe('Canvas renderer', () => { expect(fake.operations).toContain('translate:10,12') expect(fake.operations).toContain('clip') expect(fake.operations).toContain('setLineDash:2,4') + expect(fake.operations).toContain('lineCap:round') expect(fake.operations).toContain('lineJoin:round') expect(fake.operations).toContain('lineJoin:miter') expect(fake.operations).toEqual( @@ -1391,6 +1393,119 @@ describe('Canvas renderer', () => { surface.destroy() }) + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'skips invisible radial strokes before checking paint support at width %s', + (strokeWidth) => { + const container = document.createElement('div') + const surface = createCanvasChartRenderer().mount(container, () => {}) + const style = { fill: 'none', stroke: 'url(#radial)', strokeWidth } + try { + expect(() => + surface.render( + scene( + [ + { + kind: 'rule', + key: 'rule', + x1: 0, + y1: 0, + x2: 20, + y2: 20, + style, + }, + { kind: 'polyline', key: 'path', points: [], style }, + { + kind: 'label', + key: 'text', + x: 10, + y: 10, + text: 'Invisible', + anchor: 'middle', + fontSize: 12, + style, + }, + ], + [ + { + type: 'radial', + id: 'radial', + stops: [ + { offset: 0, color: '#ffffff' }, + { offset: 1, color: '#000000' }, + ], + }, + ], + ), + renderOptions(), + ), + ).not.toThrow() + const painted = contexts.get(surface.sceneCanvas) + if (!painted) throw new Error('Expected a painted scene canvas') + expect( + painted.operations.filter( + (operation) => + operation.startsWith('stroke:') || + operation.startsWith('strokeText:'), + ), + ).toEqual([]) + } finally { + surface.destroy() + } + }, + ) + + it('does not paint zero-width Canvas strokes', () => { + const container = document.createElement('div') + const surface = createCanvasChartRenderer().mount(container, () => {}) + + try { + surface.render( + scene([ + { + kind: 'rule', + key: 'visible-rule', + x1: 0, + y1: 0, + x2: 20, + y2: 20, + style: { stroke: '#123456', strokeWidth: 2 }, + }, + { + kind: 'rule', + key: 'hidden-rule', + x1: 0, + y1: 20, + x2: 20, + y2: 0, + style: { stroke: '#abcdef', strokeWidth: 0 }, + }, + { + kind: 'label', + key: 'hidden-label-outline', + x: 10, + y: 10, + text: 'Hidden outline', + anchor: 'middle', + fontSize: 12, + style: { fill: 'none', stroke: '#abcdef', strokeWidth: 0 }, + }, + ]), + renderOptions(), + ) + const painted = contexts.get(surface.sceneCanvas) + if (!painted) throw new Error('Expected a painted scene canvas') + + expect( + painted.operations.filter( + (operation) => operation === 'stroke:current', + ), + ).toHaveLength(1) + expect(painted.operations).not.toContain('strokeText:Hidden outline,0,0') + } finally { + surface.destroy() + } + }) + it('keeps the public canvas as a focus-free base bitmap', () => { const container = document.createElement('div') const renderer = createCanvasChartRenderer({ pixelRatio: 2 }) diff --git a/packages/charts-core/src/canvas.ts b/packages/charts-core/src/canvas.ts index e948cebe..a6a36678 100644 --- a/packages/charts-core/src/canvas.ts +++ b/packages/charts-core/src/canvas.ts @@ -1513,8 +1513,7 @@ function strokePath( bounds: ChartBounds | null, path: Path2D, ): void { - rejectRadialStroke(painter, state.stroke) - const stroke = resolvePaint(painter, state.stroke, bounds) + const stroke = resolveStrokePaint(painter, state, bounds) if (!stroke) return configureStroke(painter.context, state, stroke) painter.context.stroke(path) @@ -1543,8 +1542,7 @@ function strokeCurrentPath( state: PaintState, bounds: ChartBounds | null, ): void { - rejectRadialStroke(painter, state.stroke) - const stroke = resolvePaint(painter, state.stroke, bounds) + const stroke = resolveStrokePaint(painter, state, bounds) if (!stroke) return configureStroke(painter.context, state, stroke) painter.context.stroke() @@ -1563,6 +1561,16 @@ function configureStroke( context.setLineDash(parseDasharray(state.strokeDasharray)) } +function resolveStrokePaint( + painter: ScenePainter, + state: PaintState, + bounds: ChartBounds | null, +) { + if (!Number.isFinite(state.strokeWidth) || state.strokeWidth <= 0) return null + rejectRadialStroke(painter, state.stroke) + return resolvePaint(painter, state.stroke, bounds) +} + function paintLabel( painter: ScenePainter, node: Extract, @@ -1600,7 +1608,7 @@ function paintLabel( context.fillStyle = fill context.fillText(node.text, 0, 0) } - const stroke = resolvePaint(painter, state.stroke, null) + const stroke = resolveStrokePaint(painter, state, null) if (stroke) { configureStroke(context, state, stroke) context.strokeText(node.text, 0, 0) diff --git a/packages/charts-core/src/cartesian-scales.test.ts b/packages/charts-core/src/cartesian-scales.test.ts index 2fad842a..8650a293 100644 --- a/packages/charts-core/src/cartesian-scales.test.ts +++ b/packages/charts-core/src/cartesian-scales.test.ts @@ -35,6 +35,25 @@ describe('Cartesian scale registry', () => { expect(temperatureAxis.x1).toBeGreaterThan(percentAxis.x1) }) + it('keeps thick stacked axis lines from overlapping', () => { + const scene = createNamedScaleScene('ltr', true) + const rules = flatten(scene.nodes).filter( + (node): node is SceneRule => node.kind === 'rule', + ) + const percentAxis = rules.find((node) => node.key === 'percent-axis') + const temperatureAxis = rules.find( + (node) => node.key === 'temperature-axis', + ) + if (!percentAxis || !temperatureAxis) { + throw new Error('Expected both named right-side axes') + } + + expect( + temperatureAxis.x1 - 10 - (percentAxis.x1 + 10), + ).toBeGreaterThanOrEqual(8) + expect(temperatureAxis.x1 + 10).toBeLessThanOrEqual(scene.width) + }) + it('supports top, right, bottom, and left axis sides', () => { const scene = createChartScene( defineChart({ @@ -322,7 +341,17 @@ describe('Cartesian scale registry', () => { }) }) -function createNamedScaleScene(direction: 'ltr' | 'rtl' = 'ltr') { +function createNamedScaleScene( + direction: 'ltr' | 'rtl' = 'ltr', + thickAxisLines = false, +) { + const axis = thickAxisLines + ? { + line: { strokeWidth: 20 }, + ticks: false as const, + tickLabels: false as const, + } + : undefined return createChartScene( defineChart({ marks: [ @@ -347,11 +376,13 @@ function createNamedScaleScene(direction: 'ltr' | 'rtl' = 'ltr') { channel: 'y', side: 'right', scale: scaleLinear().domain([0, 1]), + axis, }, temperature: { channel: 'y', side: 'right', scale: scaleLinear().domain([-40, 40]), + axis, }, }, }), diff --git a/packages/charts-core/src/export.test.ts b/packages/charts-core/src/export.test.ts index 9b5caf94..04d88322 100644 --- a/packages/charts-core/src/export.test.ts +++ b/packages/charts-core/src/export.test.ts @@ -41,6 +41,60 @@ describe('optional export', () => { expect(result).not.toContain('data-ts-focus-layer') }) + it('preserves authored grid and axis line styles in serialized SVG', () => { + const axes = linearAxes([0, 2], [0, 3]) + const container = document.createElement('div') + container.innerHTML = renderChartSvg( + createChartScene( + defineChart({ + marks: [lineY([1, 3, 2])], + scales: { + x: { + ...axes.scales.x, + grid: { + stroke: '#2563eb', + strokeOpacity: 0.35, + strokeWidth: 2, + strokeDasharray: '4 2', + lineCap: 'round', + }, + axis: { + line: { + stroke: '#0f172a', + strokeOpacity: 0.7, + strokeWidth: 3, + strokeDasharray: '6 3', + lineCap: 'square', + }, + }, + }, + y: { ...axes.scales.y, grid: false, axis: false }, + }, + }), + { width: 480, height: 260 }, + ), + { ariaLabel: 'Styled guide export' }, + ) + const exported = document.createElement('div') + exported.innerHTML = serializeChartSvg(container) + + for (const root of [container, exported]) { + const grid = root.querySelector('[data-ts-key^="x-grid:"]') + const axis = root.querySelector('[data-ts-key="x-axis"]') + + expect(grid?.getAttribute('stroke')).toBe('#2563eb') + expect(grid?.getAttribute('stroke-opacity')).toBe('0.35') + expect(grid?.getAttribute('stroke-width')).toBe('2') + expect(grid?.getAttribute('stroke-dasharray')).toBe('4 2') + expect(grid?.getAttribute('stroke-linecap')).toBe('round') + expect(axis?.getAttribute('stroke')).toBe('#0f172a') + expect(axis?.getAttribute('stroke-opacity')).toBe('0.7') + expect(axis?.getAttribute('stroke-width')).toBe('3') + expect(axis?.getAttribute('stroke-dasharray')).toBe('6 3') + expect(axis?.getAttribute('stroke-linecap')).toBe('square') + } + }) + it('inlines computed presentation for gradient stops', () => { const container = document.createElement('div') container.innerHTML = renderChartSvgWithResources( diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 10bca559..42fbd462 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -5,6 +5,7 @@ import type { ChartGradientBase as RootChartGradientBase, ChartLinearGradient as RootChartLinearGradient, ChartRadialGradient as RootChartRadialGradient, + ChartGuideLineStyle as RootChartGuideLineStyle, CreateDotLayoutOptions as RootCreateDotLayoutOptions, DotLayout as RootDotLayout, DotLayoutResolveContext as RootDotLayoutResolveContext, @@ -14,6 +15,7 @@ import type { ChartGradientBase as UniversalChartGradientBase, ChartLinearGradient as UniversalChartLinearGradient, ChartRadialGradient as UniversalChartRadialGradient, + ChartGuideLineStyle as UniversalChartGuideLineStyle, CreateDotLayoutOptions as UniversalCreateDotLayoutOptions, DotLayout as UniversalDotLayout, DotLayoutResolveContext as UniversalDotLayoutResolveContext, @@ -24,6 +26,7 @@ import type { ChartLinearGradient as TypesChartLinearGradient, ChartRadialGradient as TypesChartRadialGradient, } from '@tanstack/charts/types' +import type { ChartGuideLineStyle as TypesChartGuideLineStyle } from '@tanstack/charts/types' const typeOnlySpecifiers = new Set(['@tanstack/charts/types']) const specializedLoaderSpecifiers = new Set([ @@ -116,6 +119,11 @@ describe('public package exports', () => { } }) + it('keeps guide line styles aligned across public type barrels', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + it('resolves every manifest capability subpath supported by the generic loader', async () => { const specifiers = Object.keys(packageJson.exports).map((subpath) => subpath === '.' diff --git a/packages/charts-core/src/facet.test.ts b/packages/charts-core/src/facet.test.ts index 40ba07ba..b94884d1 100644 --- a/packages/charts-core/src/facet.test.ts +++ b/packages/charts-core/src/facet.test.ts @@ -851,6 +851,151 @@ describe('facets', () => { ).toThrow(/guide options differ/) }) + it('compares outer axis lines by value and retains cell grid styles', () => { + const data = [ + { group: 'A', value: 1 }, + { group: 'B', value: 2 }, + ] + const definition = (difference?: 'line' | 'grid') => + facetChart(data, { + by: 'group', + columns: 2, + chart: (group, { key }) => ({ + marks: [lineY(group, { y: 'value' })], + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + grid: false, + axis: { + line: { + stroke: '#0f172a', + strokeOpacity: 0.6, + strokeWidth: difference === 'line' && key === 'B' ? 3 : 2, + strokeDasharray: '4 2', + lineCap: 'round', + }, + }, + }, + y: { + scale: scaleLinear().domain([0, 2]), + grid: { + stroke: '#cbd5e1', + strokeOpacity: 0.4, + strokeWidth: difference === 'grid' && key === 'B' ? 20 : 1, + strokeDasharray: + difference === 'grid' && key === 'B' ? '2 2' : '1 3', + lineCap: 'butt', + }, + }, + }, + }), + }) + + expect(() => + createChartScene(definition(), { width: 640, height: 260 }), + ).not.toThrow() + expect(() => + createChartScene(definition('line'), { width: 640, height: 260 }), + ).toThrow(/guide options differ/) + const independentGrids = createChartScene(definition('grid'), { + width: 640, + height: 260, + }) + expect( + directFacetCells(independentGrids.nodes, 'facet-0').map( + (cell) => + flatten(cell.children).find((node) => node.key.startsWith('y-grid:')) + ?.style?.strokeDasharray, + ), + ).toEqual(['1 3', '2 2']) + expect( + directFacetCells(independentGrids.nodes, 'facet-0').map( + (cell) => + flatten(cell.children).find((node) => node.key.startsWith('y-grid:')) + ?.style?.strokeWidth, + ), + ).toEqual([1, 20]) + }) + + it('keeps thick shared outer axis lines inside the surface', () => { + const data = [ + { group: 'A', value: 1 }, + { group: 'B', value: 2 }, + ] + const scene = createChartScene( + facetChart(data, { + by: 'group', + columns: 2, + chart: (group) => ({ + marks: [lineY(group, { y: 'value' })], + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + axis: { + line: { strokeWidth: 20, lineCap: 'square' }, + ticks: false, + tickLabels: false, + }, + }, + y: { scale: scaleLinear().domain([0, 2]), axis: false }, + }, + }), + }), + { width: 640, height: 260 }, + ) + const baselines = translatedRules(scene.nodes).filter( + ({ rule }) => rule.key === 'x-axis', + ) + + expect(baselines).toHaveLength(2) + for (const { x1, x2, y1, y2 } of baselines) { + expect(Math.min(x1, x2) - 10).toBeGreaterThanOrEqual(0) + expect(Math.max(x1, x2) + 10).toBeLessThanOrEqual(scene.width) + expect(Math.min(y1, y2) - 10).toBeGreaterThanOrEqual(0) + expect(Math.max(y1, y2) + 10).toBeLessThanOrEqual(scene.height) + } + }) + + it('keeps thick cell grids inside an outer-facet surface', () => { + const data = [ + { group: 'A', value: 1 }, + { group: 'B', value: 2 }, + ] + const scene = createChartScene( + facetChart(data, { + by: 'group', + columns: 2, + chart: (group) => ({ + marks: [lineY(group, { y: 'value' })], + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + grid: { strokeWidth: 20, lineCap: 'square' }, + axis: false, + }, + y: { + scale: scaleLinear().domain([0, 2]), + grid: { strokeWidth: 20, lineCap: 'square' }, + axis: false, + }, + }, + }), + }), + { width: 640, height: 260 }, + ) + const gridRules = translatedRules(scene.nodes).filter(({ rule }) => + rule.key.includes('-grid:'), + ) + + expect(gridRules.length).toBeGreaterThan(0) + for (const { x1, x2, y1, y2 } of gridRules) { + expect(Math.min(x1, x2) - 10).toBeGreaterThanOrEqual(0) + expect(Math.max(x1, x2) + 10).toBeLessThanOrEqual(scene.width) + expect(Math.min(y1, y2) - 10).toBeGreaterThanOrEqual(0) + expect(Math.max(y1, y2) + 10).toBeLessThanOrEqual(scene.height) + } + }) + it('does not render data marks during the outer-guide prepass', () => { const data = [ { group: 'A', value: 1 }, @@ -958,6 +1103,38 @@ function translatedLabels( }) } +function translatedRules( + nodes: readonly SceneNode[], + x = 0, + y = 0, +): { + rule: Extract + x1: number + x2: number + y1: number + y2: number +}[] { + return nodes.flatMap((node) => { + if (node.kind === 'rule') { + return [ + { + rule: node, + x1: node.x1 + x, + x2: node.x2 + x, + y1: node.y1 + y, + y2: node.y2 + y, + }, + ] + } + if (node.kind !== 'group') return [] + return translatedRules( + node.children, + x + (node.translateX ?? 0), + y + (node.translateY ?? 0), + ) + }) +} + function directFacetCells( nodes: readonly SceneNode[], facetId: string, diff --git a/packages/charts-core/src/facet.ts b/packages/charts-core/src/facet.ts index fb5e6ba2..e4ebdb14 100644 --- a/packages/charts-core/src/facet.ts +++ b/packages/charts-core/src/facet.ts @@ -12,6 +12,7 @@ import type { ChartMarkMotionOptions, ChartMargin, ChartMotionDefinition, + ChartGuideLineStyle, ChartPoint, ChartPositionScaleOptions, ChartScene, @@ -197,7 +198,6 @@ export function facet( layout, ), ) - assertOuterAxes(id, definitions, guideScenes) const margin = resolveOuterMargin({ id, entries, @@ -294,13 +294,21 @@ function resolveOuterMargin(options: { (node): node is SceneGroup => node.kind === 'group' && node.key === 'axes', ) - return axes - ? resolveGuideMargins( - axes, - { x: 0, y: 0, width: plotWidth, height: plotHeight }, - { measureText: layout?.measureText }, - ) - : { top: 0, right: 0, bottom: 0, left: 0 } + const grid = scene.nodes.find( + (node): node is SceneGroup => + node.kind === 'group' && node.key === 'grid', + ) + const plot = { x: 0, y: 0, width: plotWidth, height: plotHeight } + return maxMargins([ + axes + ? resolveGuideMargins(axes, plot, { + measureText: layout?.measureText, + }) + : { top: 0, right: 0, bottom: 0, left: 0 }, + grid + ? resolveGuideMargins(grid, plot, { inset: 0 }) + : { top: 0, right: 0, bottom: 0, left: 0 }, + ]) }) const next = maxMargins([margin, ...measured]) if (sameMargin(margin, next)) return next @@ -761,7 +769,7 @@ function sameAxis( const leftLabel = leftAxis?.label const rightLabel = rightAxis?.label return ( - leftAxis?.line === rightAxis?.line && + sameGuideLineStyle(leftAxis?.line, rightAxis?.line) && sameAxisTicks(leftTicks, rightTicks) && sameAxisTickLabels(leftLabels, rightLabels) && (typeof leftLabel === 'string' ? leftLabel : leftLabel?.text) === @@ -771,6 +779,25 @@ function sameAxis( ) } +function sameGuideLineStyle( + left: boolean | ChartGuideLineStyle | undefined, + right: boolean | ChartGuideLineStyle | undefined, +): boolean { + const leftEnabled = left !== false + const rightEnabled = right !== false + if (leftEnabled !== rightEnabled) return false + if (!leftEnabled) return true + const leftStyle = typeof left === 'object' ? left : undefined + const rightStyle = typeof right === 'object' ? right : undefined + return ( + leftStyle?.stroke === rightStyle?.stroke && + leftStyle?.strokeOpacity === rightStyle?.strokeOpacity && + leftStyle?.strokeWidth === rightStyle?.strokeWidth && + leftStyle?.strokeDasharray === rightStyle?.strokeDasharray && + leftStyle?.lineCap === rightStyle?.lineCap + ) +} + function positionScaleOptions( definition: StaticChartDefinition, ): Readonly> { diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index 22ec3599..205f8994 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -170,7 +170,48 @@ export function resolveGuideMargins( left = Math.max(left, plot.x - boundsLeft + inset) }) - return { top, right, bottom, left } + const strokeMargin = { top, right, bottom, left } + includeGuideStrokeMargins(strokeMargin, axes, plot) + + return strokeMargin +} + +export function includeGuideStrokeMargins( + margin: ChartMargin, + guides: SceneGroup, + plot: ChartBounds, +): void { + visitRules(guides, 0, 0, (rule, translateX, translateY) => { + const style = rule.style + const extendsGeometry = + style?.strokeWidth !== undefined || + (style?.lineCap !== undefined && style.lineCap !== 'butt') + if (!extendsGeometry || style?.stroke === 'none') return + const strokeWidth = style?.strokeWidth ?? 1 + if (!Number.isFinite(strokeWidth) || strokeWidth <= 0) return + const halfWidth = strokeWidth / 2 + const x1 = rule.x1 + translateX + const x2 = rule.x2 + translateX + const y1 = rule.y1 + translateY + const y2 = rule.y2 + translateY + const dx = x2 - x1 + const dy = y2 - y1 + const length = Math.hypot(dx, dy) + const unitX = length > 0 ? dx / length : 0 + const unitY = length > 0 ? dy / length : 0 + const cap = style?.lineCap && style.lineCap !== 'butt' ? halfWidth : 0 + const extendX = Math.abs(unitY) * halfWidth + Math.abs(unitX) * cap + const extendY = Math.abs(unitX) * halfWidth + Math.abs(unitY) * cap + const left = Math.min(x1, x2) - extendX + const right = Math.max(x1, x2) + extendX + const top = Math.min(y1, y2) - extendY + const bottom = Math.max(y1, y2) + extendY + + margin.top = Math.max(margin.top, plot.y - top) + margin.right = Math.max(margin.right, right - plot.x - plot.width) + margin.bottom = Math.max(margin.bottom, bottom - plot.y - plot.height) + margin.left = Math.max(margin.left, plot.x - left) + }) } function visitLabels( @@ -193,6 +234,30 @@ function visitLabels( } } +function visitRules( + node: SceneNode, + translateX: number, + translateY: number, + visit: ( + rule: Extract, + translateX: number, + translateY: number, + ) => void, +): void { + if (node.kind === 'rule') { + visit(node, translateX, translateY) + return + } + + if (node.kind !== 'group') return + + const childTranslateX = translateX + (node.translateX ?? 0) + const childTranslateY = translateY + (node.translateY ?? 0) + for (const child of node.children) { + visitRules(child, childTranslateX, childTranslateY, visit) + } +} + function rotateBounds( bounds: ChartBounds, originX: number, diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index 4a52a672..10c55ef1 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -351,6 +351,7 @@ export type { ChartGradient, ChartGradientBase, ChartGradientStop, + ChartGuideLineStyle, ChartKey, ChartLayoutOptions, ChartMargin, diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index 9c4ab2a5..3ffca69a 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -1740,6 +1740,86 @@ describe('SVG motion', () => { frames.restore() }) + it('animates numeric guide styles and snaps discrete guide styles', () => { + const definition = (target: boolean) => + defineChart({ + motion: { + transition: { type: 'tween', duration: 100, easing: 'linear' }, + }, + marks: [lineY([0, 1])], + margin: 0, + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + grid: { + strokeOpacity: target ? 0.8 : 0.2, + strokeWidth: target ? 4 : 2, + strokeDasharray: target ? '6 2' : '2 2', + lineCap: target ? 'round' : 'butt', + }, + axis: { + line: { + strokeOpacity: target ? 0.7 : 0.3, + strokeWidth: target ? 5 : 1, + strokeDasharray: target ? '8 2' : '1 1', + lineCap: target ? 'square' : 'butt', + }, + ticks: false, + tickLabels: false, + }, + }, + y: { scale: scaleLinear().domain([0, 1]), axis: false }, + }, + }) + const first = createChartScene(definition(false), { + width: 300, + height: 200, + }) + const next = createChartScene(definition(true), { + width: 300, + height: 200, + }) + const container = document.createElement('div') + const surface = motion({ initial: false }).mount(container, () => {}) + surface.render(first, { ariaLabel: 'Guide style motion' }) + const frames = installManagedFrames() + surface.render(next, { ariaLabel: 'Guide style motion' }) + const grid = container.querySelector( + '[data-ts-key^="x-grid:"]', + ) + const axis = container.querySelector( + '[data-ts-key="x-axis"]', + ) + + expect(grid?.dataset.tsMotionRole).toBe('grid') + expect(axis?.dataset.tsMotionRole).toBe('axis') + expect(grid?.getAttribute('stroke-dasharray')).toBe('6 2') + expect(grid?.getAttribute('stroke-linecap')).toBe('round') + expect(axis?.getAttribute('stroke-dasharray')).toBe('8 2') + expect(axis?.getAttribute('stroke-linecap')).toBe('square') + expect(Number(grid?.getAttribute('stroke-opacity'))).toBeCloseTo(0.2) + expect(Number(grid?.getAttribute('stroke-width'))).toBeCloseTo(2) + expect(Number(axis?.getAttribute('stroke-opacity'))).toBeCloseTo(0.3) + expect(Number(axis?.getAttribute('stroke-width'))).toBeCloseTo(1) + + frames.runAll(0) + frames.runAll(50) + expect(Number(grid?.getAttribute('stroke-opacity'))).toBeCloseTo(0.5) + expect(Number(grid?.getAttribute('stroke-width'))).toBeCloseTo(3) + expect(Number(axis?.getAttribute('stroke-opacity'))).toBeCloseTo(0.5) + expect(Number(axis?.getAttribute('stroke-width'))).toBeCloseTo(3) + + frames.runAll(100) + expect(Number(grid?.getAttribute('stroke-opacity'))).toBeCloseTo(0.8) + expect(Number(grid?.getAttribute('stroke-width'))).toBeCloseTo(4) + expect(Number(axis?.getAttribute('stroke-opacity'))).toBeCloseTo(0.7) + expect(Number(axis?.getAttribute('stroke-width'))).toBeCloseTo(5) + expect(grid?.hasAttribute('data-ts-motion-role')).toBe(false) + expect(axis?.hasAttribute('data-ts-motion-role')).toBe(false) + surface.destroy() + frames.restore() + }) + it('scopes named-axis motion by scale ID while retaining its channel', () => { const contexts: ChartMotionContext[] = [] const definition = defineChart({ diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index 3022a2f7..e4c6c19d 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -1277,6 +1277,213 @@ describe('native mark and channel scene', () => { } }) + it('keeps empty guide style objects byte-equivalent to true', () => { + const makeScene = (styleObjects: boolean) => { + const axes = linearAxes([0, 2], [0, 3]) + return createChartScene( + defineChart({ + marks: [lineY([1, 3, 2])], + scales: { + x: { + ...axes.scales.x, + grid: styleObjects ? {} : true, + axis: { line: styleObjects ? {} : true }, + }, + y: { ...axes.scales.y, grid: false, axis: false }, + }, + }), + { width: 480, height: 260 }, + ) + } + const legacy = makeScene(false) + const styled = makeScene(true) + + expect(styled.nodes).toEqual(legacy.nodes) + expect(renderChartSvg(styled, { ariaLabel: 'Guide defaults' })).toBe( + renderChartSvg(legacy, { ariaLabel: 'Guide defaults' }), + ) + }) + + it('styles each grid and only the axis baseline through guide options', () => { + const axes = linearAxes([0, 2], [0, 3]) + const scene = createChartScene( + defineChart({ + marks: [lineY([1, 3, 2])], + scales: { + x: { + ...axes.scales.x, + grid: { + stroke: '#2563eb', + strokeOpacity: 0.4, + strokeWidth: 2, + strokeDasharray: '4 2', + lineCap: 'round', + }, + axis: { + line: { + stroke: '#0f172a', + strokeOpacity: 0, + strokeWidth: 0, + strokeDasharray: '6 2', + lineCap: 'square', + }, + ticks: { values: [0, 1, 2] }, + }, + }, + y: { + ...axes.scales.y, + grid: { + strokeOpacity: 0, + strokeWidth: 0, + strokeDasharray: '1 3', + }, + }, + }, + }), + { width: 480, height: 260 }, + ) + const nodes = flatten(scene.nodes) + const grid = nodes.find((node) => node.key === 'grid') + const xGridRules = nodes.filter((node) => node.key.startsWith('x-grid:')) + const yGridRules = nodes.filter((node) => node.key.startsWith('y-grid:')) + const xAxis = nodes.find((node) => node.key === 'x-axis') + const xTickRules = nodes.filter((node) => + node.key.startsWith('x-tick-rule:'), + ) + + expect(grid?.style).toEqual({ + stroke: 'currentColor', + strokeOpacity: 0.11, + strokeWidth: 1, + }) + expect(xGridRules.length).toBeGreaterThan(0) + for (const rule of xGridRules) { + expect(rule.style).toEqual({ + stroke: '#2563eb', + strokeOpacity: 0.4, + strokeWidth: 2, + strokeDasharray: '4 2', + lineCap: 'round', + }) + } + expect(yGridRules.length).toBeGreaterThan(0) + for (const rule of yGridRules) { + expect(rule.style).toEqual({ + strokeOpacity: 0, + strokeWidth: 0, + strokeDasharray: '1 3', + }) + } + expect(xAxis?.style).toEqual({ + stroke: '#0f172a', + strokeOpacity: 0, + strokeWidth: 0, + strokeDasharray: '6 2', + lineCap: 'square', + }) + expect(xTickRules.length).toBeGreaterThan(0) + for (const rule of xTickRules) { + expect(rule.style).toEqual({ + stroke: 'currentColor', + strokeOpacity: 0.28, + }) + } + + const container = document.createElement('div') + container.innerHTML = renderChartSvg(scene, { ariaLabel: 'Styled guides' }) + const xGrid = container.querySelector('[data-ts-key^="x-grid:"]') + const yGrid = container.querySelector('[data-ts-key^="y-grid:"]') + const baseline = container.querySelector('[data-ts-key="x-axis"]') + const tick = container.querySelector('[data-ts-key^="x-tick-rule:"]') + + expect(xGrid?.getAttribute('stroke')).toBe('#2563eb') + expect(xGrid?.getAttribute('stroke-opacity')).toBe('0.4') + expect(xGrid?.getAttribute('stroke-width')).toBe('2') + expect(xGrid?.getAttribute('stroke-dasharray')).toBe('4 2') + expect(xGrid?.getAttribute('stroke-linecap')).toBe('round') + expect(yGrid?.getAttribute('stroke-opacity')).toBe('0') + expect(yGrid?.getAttribute('stroke-width')).toBe('0') + expect(yGrid?.getAttribute('stroke-dasharray')).toBe('1 3') + expect(baseline?.getAttribute('stroke')).toBe('#0f172a') + expect(baseline?.getAttribute('stroke-opacity')).toBe('0') + expect(baseline?.getAttribute('stroke-width')).toBe('0') + expect(baseline?.getAttribute('stroke-dasharray')).toBe('6 2') + expect(baseline?.getAttribute('stroke-linecap')).toBe('square') + expect(tick?.getAttribute('stroke')).toBe('currentColor') + expect(tick?.getAttribute('stroke-opacity')).toBe('0.28') + expect(tick?.hasAttribute('stroke-width')).toBe(false) + expect(tick?.hasAttribute('stroke-dasharray')).toBe(false) + expect(tick?.hasAttribute('stroke-linecap')).toBe(false) + }) + + it('reserves unlocked margins for thick guide strokes', () => { + const makeAxes = () => linearAxes([0, 1], [0, 1]).scales + const thickAxis = createChartScene( + defineChart({ + marks: [lineY([0, 1])], + scales: { + x: { + ...makeAxes().x, + axis: { + line: { strokeWidth: 20, lineCap: 'square' }, + ticks: false, + tickLabels: false, + }, + }, + y: { ...makeAxes().y, axis: false }, + }, + }), + { width: 300, height: 200 }, + ) + const thickGrid = createChartScene( + defineChart({ + marks: [lineY([0, 1])], + scales: { + x: { + ...makeAxes().x, + grid: { strokeWidth: 20, lineCap: 'square' }, + axis: false, + }, + y: { ...makeAxes().y, axis: false }, + }, + }), + { width: 300, height: 200 }, + ) + const zeroWidthGrid = createChartScene( + defineChart({ + marks: [lineY([0, 1])], + scales: { + x: { + ...makeAxes().x, + grid: { strokeWidth: 0, lineCap: 'square' }, + axis: false, + }, + y: { ...makeAxes().y, axis: false }, + }, + }), + { width: 300, height: 200 }, + ) + + expect(thickAxis.margin).toEqual({ + top: 4, + right: 10, + bottom: 10, + left: 10, + }) + expect(thickGrid.margin).toEqual({ + top: 10, + right: 10, + bottom: 10, + left: 10, + }) + expect(zeroWidthGrid.margin).toEqual({ + top: 0, + right: 0, + bottom: 0, + left: 0, + }) + }) + it('mounts, updates, interacts, and destroys through vanilla TypeScript', () => { const firstDatum = { id: 'a', x: 0, y: 10 } const definition = defineChart({ diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 7c07226b..2f7bc1f4 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1,6 +1,7 @@ import { createColorScale, valueKey } from './scales' import { resolveConfiguredScale } from './configured-scale' import { + includeGuideStrokeMargins, measureSceneLabelBounds, physicalTextAnchor, withChartTextTypography, @@ -29,6 +30,7 @@ import type { ResponsiveChartConfig, ChartColorLegend, ChartFocusFilter, + ChartGuideLineStyle, ChartLayoutOptions, ChartMargin, ChartMark, @@ -59,6 +61,7 @@ import type { SceneGroup, SceneLabel, SceneNode, + SceneStyle, } from './types' export const defaultChartTheme: ChartTheme = { @@ -1115,6 +1118,13 @@ function resolveSceneLayout( layout.measureText, layout.typography?.direction === 'rtl', ) + if (gridScales.length) { + includeGuideStrokeMargins( + resolvedAxes.margin, + createGrid(chart, gridScales, theme), + chart, + ) + } return { margin, chart, @@ -1196,7 +1206,7 @@ function hasScaleGuide( function hasScaleGrid( scale: PositionScaleDefinition | ResolvedPositionScale, ): boolean { - return scale.options != null && scale.options.grid === true + return scale.options != null && Boolean(scale.options.grid) } function resolveMarkLayouts( @@ -1338,6 +1348,7 @@ function createGrid( for (const guide of guides) { if (!guide.options?.grid) continue + const style = guideLineStyle(guide.options.grid) for (const tick of guide.scale.ticks) { const key = `${guide.id}-grid:${valueKey(tick.value)}` children.push( @@ -1349,6 +1360,7 @@ function createGrid( x2: tick.position, y1: chart.y, y2: chart.y + chart.height, + ...(style ? { style } : {}), } : { kind: 'rule', @@ -1357,6 +1369,7 @@ function createGrid( x2: chart.x + chart.width, y1: tick.position, y2: tick.position, + ...(style ? { style } : {}), }, ) } @@ -1393,11 +1406,20 @@ function createAxes( bottom: 0, left: 0, } + const guideCounts: Record = { + top: 0, + right: 0, + bottom: 0, + left: 0, + } const chartRight = chart.x + chart.width const chartBottom = chart.y + chart.height for (const guide of guides) { - const offset = offsets[guide.side] + const presentation = axisPresentation(guide.options) + const lineHalfWidth = guideLineHalfWidth(presentation?.line) + const offset = + offsets[guide.side] + (guideCounts[guide.side] > 0 ? lineHalfWidth : 0) margin[guide.side] = Math.max( margin[guide.side], offset + automaticGuideInset, @@ -1443,18 +1465,18 @@ function createAxes( ? outward - chartBottom : chart.x - outward offsets[guide.side] = Math.max(offset, distance) + 8 + guideCounts[guide.side] += 1 } - return { - axes: { - kind: 'group', - key: 'axes', - className: 'ts-chart__axes', - ariaHidden: true, - children, - }, - margin, + const axes: SceneGroup = { + kind: 'group', + key: 'axes', + className: 'ts-chart__axes', + ariaHidden: true, + children, } + includeGuideStrokeMargins(margin, axes, chart) + return { axes, margin } function renderXAxis( guide: ResolvedPositionScale, @@ -1473,8 +1495,11 @@ function createAxes( x2: chartRight, y1: axisY, y2: axisY, - style: axisStyle(), + style: axisStyle(presentation?.line), }) + includeCoordinate( + axisY + direction * guideLineHalfWidth(presentation?.line), + ) } const ticks = presentation?.ticks === false ? [] : guide.scale.ticks const tickSize = finiteMargin( @@ -1577,8 +1602,11 @@ function createAxes( x2: axisX, y1: chart.y, y2: chartBottom, - style: axisStyle(), + style: axisStyle(presentation?.line), }) + includeCoordinate( + axisX + direction * guideLineHalfWidth(presentation?.line), + ) } const ticks = presentation?.ticks === false ? [] : guide.scale.ticks const tickSize = finiteMargin( @@ -1671,9 +1699,42 @@ function createAxes( children.push(label) } - function axisStyle() { - return { stroke: theme.foreground, strokeOpacity: 0.28 } + function axisStyle(line?: boolean | ChartGuideLineStyle): SceneStyle { + return { + stroke: theme.foreground, + strokeOpacity: 0.28, + ...(guideLineStyle(line) ?? {}), + } + } +} + +function guideLineStyle( + value: boolean | ChartGuideLineStyle | undefined, +): SceneStyle | undefined { + if (!value || typeof value !== 'object') return undefined + const style: SceneStyle = {} + if (value.stroke !== undefined) style.stroke = value.stroke + if (value.strokeOpacity !== undefined) { + style.strokeOpacity = value.strokeOpacity + } + if (value.strokeWidth !== undefined) style.strokeWidth = value.strokeWidth + if (value.strokeDasharray !== undefined) { + style.strokeDasharray = value.strokeDasharray } + if (value.lineCap !== undefined) style.lineCap = value.lineCap + return Object.keys(style).length ? style : undefined +} + +function guideLineHalfWidth( + value: boolean | ChartGuideLineStyle | undefined, +): number { + if (!value || typeof value !== 'object' || value.stroke === 'none') return 0 + const strokeWidth = value.strokeWidth + return strokeWidth !== undefined && + Number.isFinite(strokeWidth) && + strokeWidth > 0 + ? strokeWidth / 2 + : 0 } function resolveTickCount( diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index f9707436..33f0a4c0 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -398,8 +398,18 @@ export interface ChartAxisLabelOptions { motion?: ChartMotionDefinition } +/** Renderer-neutral stroke presentation for Cartesian grid and axis lines. */ +export interface ChartGuideLineStyle { + stroke?: string + strokeOpacity?: number + strokeWidth?: number + strokeDasharray?: string + lineCap?: 'butt' | 'round' | 'square' +} + export interface ChartAxisPresentationOptions { - line?: boolean + /** Draws the axis baseline and optionally overrides its stroke presentation. */ + line?: boolean | ChartGuideLineStyle ticks?: false | ChartAxisTickOptions tickLabels?: false | ChartAxisTickLabelOptions label?: string | ChartAxisLabelOptions @@ -437,7 +447,7 @@ export interface ChartAxisOptions { /** A semantic window over the scale's complete configured or inferred domain. */ viewport?: ChartAxisViewportFor /** Grid lines use semantic tick candidates before label thinning. */ - grid?: boolean + grid?: boolean | ChartGuideLineStyle /** Axis presentation. False keeps the scale but omits the visible axis. */ axis?: false | ChartAxisPresentationOptions } diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index b54defdc..e27544b4 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -241,6 +241,7 @@ export type { ChartGradient, ChartGradientBase, ChartGradientStop, + ChartGuideLineStyle, ChartKey, ChartLayoutOptions, ChartMargin, diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index f10e6db8..5755e158 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -55,9 +55,12 @@ describe('React Native SVG scene renderer', () => { expect(markup).toContain('url(#native-one-fill)') expect(markup).toContain('