diff --git a/.changeset/opposite-axis-side.md b/.changeset/opposite-axis-side.md new file mode 100644 index 00000000..7fa60e50 --- /dev/null +++ b/.changeset/opposite-axis-side.md @@ -0,0 +1,8 @@ +--- +'@tanstack/charts': minor +--- + +Add `axis.side` so an axis can sit on the opposite plot edge, moving its stubs, +tick labels, title, crosshair value label, and automatic margin with it. A +right-to-left locale reads the value axis on the right through +`y.axis.side: 'end'`. diff --git a/API-FRICTION.md b/API-FRICTION.md index 65b3bfcc..b76b4d52 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-12 +Last updated: 2026-08-14 ## Triage rule @@ -321,6 +321,7 @@ Each entry records: | F-282 | Collection actions followed the viewport instead of the card | Application | resolved | | F-283 | Interactive chart shells rendered inert controls | Application | resolved | | F-284 | Stagger timing required repeated callback arithmetic | API | resolved | +| F-285 | A right-to-left locale could not move the value axis | API | resolved | ## Findings @@ -8231,3 +8232,32 @@ Each entry records: filters, offsets, invalid inputs, native spread, and field precedence. The isolated entry is 0.26 KiB gzip and retains neither the SVG motion renderer nor spring physics. + +### F-285 — A right-to-left locale could not move the value axis + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: migrating a bilingual production dashboard from Recharts, whose + Arabic locale reads the value axis on the right +- Friction: `ChartAxisPresentationOptions` exposed the axis line, ticks, tick + labels, and title, but no placement, so the y axis always rendered against + the left plot edge. `x.reverse` already ordered categories right to left, + which left the axis as the only wrong element on the screen. Mirroring the + container with `transform: scaleX(-1)` reversed the marks and the tick text + and broke pointer hit-testing, and `direction: rtl` on the host changed + nothing because placement is resolved during layout. The migration shipped + with a knowingly misplaced axis in Arabic. +- Decision: add `axis.side` with `start` and `end` rather than physical `left` + and `right`, so one spelling serves both dimensions and composes with the + existing `reverse`. Resolve the placement once per axis into a plot edge and + an outward sign, then derive the axis line, tick stubs, tick labels and their + default anchor, the title and its rotation, and the crosshair value label + from that pair. Automatic margins already grow from measured label bounds, so + the reserved gutter follows the placement without new layout code. +- Verification: scene tests assert the end-side y axis line, stubs, labels, and + 90 degree title against the right plot edge with the reserved margin moving + from left to right, the end-side x axis against the top edge, and an explicit + `start` producing a node tree identical to an unset side. A crosshair test + asserts both value labels follow the placement, including the label anchor. + The existing 897 core tests pass unchanged. diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 25ca8b2b..6bd46eda 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-14T05:55:21.257Z", + "generatedAt": "2026-08-14T11:31:36.733Z", "packageVersions": { - "tanstack": "0.12.0", + "tanstack": "0.13.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "56567ec51902d8af0d0d730f3a31206999cc8438", - "inputDigest": "sha256:6755913ae5f9c34a2bbb1ccb7cd2144274bc37d74051c091fc279ac3d1d7a4cd" + "revision": "86accd5af82bac906a61a8b4d9c8d0f3e7c52683", + "inputDigest": "sha256:854e073c6e413ecf04e09890586bbfc0ecb4afae5146c428346de3776b306283" }, "chartjs": { "kind": "package", @@ -45,423 +45,423 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 105944, - "gzipBytes": 38533, - "brotliBytes": 34109, - "incrementalGzipBytes": 38533, - "incrementalBrotliBytes": 34109 + "minifiedBytes": 106472, + "gzipBytes": 38546, + "brotliBytes": 34330, + "incrementalGzipBytes": 38546, + "incrementalBrotliBytes": 34330 }, "tanstack-line-interactive": { - "minifiedBytes": 111385, - "gzipBytes": 40302, - "brotliBytes": 35551, - "incrementalGzipBytes": 40302, - "incrementalBrotliBytes": 35551 + "minifiedBytes": 111913, + "gzipBytes": 40282, + "brotliBytes": 35758, + "incrementalGzipBytes": 40282, + "incrementalBrotliBytes": 35758 }, "tanstack-line-advanced": { - "minifiedBytes": 118575, - "gzipBytes": 42629, - "brotliBytes": 37541, - "incrementalGzipBytes": 42629, - "incrementalBrotliBytes": 37541 + "minifiedBytes": 119103, + "gzipBytes": 42611, + "brotliBytes": 37746, + "incrementalGzipBytes": 42611, + "incrementalBrotliBytes": 37746 }, "tanstack-bar-basic": { - "minifiedBytes": 114612, - "gzipBytes": 41793, - "brotliBytes": 36837, - "incrementalGzipBytes": 41793, - "incrementalBrotliBytes": 36837 + "minifiedBytes": 115144, + "gzipBytes": 41824, + "brotliBytes": 37092, + "incrementalGzipBytes": 41824, + "incrementalBrotliBytes": 37092 }, "tanstack-bar-interactive": { - "minifiedBytes": 118904, - "gzipBytes": 43127, - "brotliBytes": 37954, - "incrementalGzipBytes": 43127, - "incrementalBrotliBytes": 37954 + "minifiedBytes": 119436, + "gzipBytes": 43156, + "brotliBytes": 38186, + "incrementalGzipBytes": 43156, + "incrementalBrotliBytes": 38186 }, "tanstack-bar-advanced": { - "minifiedBytes": 119243, - "gzipBytes": 43268, - "brotliBytes": 38025, - "incrementalGzipBytes": 43268, - "incrementalBrotliBytes": 38025 + "minifiedBytes": 119775, + "gzipBytes": 43278, + "brotliBytes": 38297, + "incrementalGzipBytes": 43278, + "incrementalBrotliBytes": 38297 }, "tanstack-area-basic": { - "minifiedBytes": 111028, - "gzipBytes": 40457, - "brotliBytes": 35802, - "incrementalGzipBytes": 40457, - "incrementalBrotliBytes": 35802 + "minifiedBytes": 111554, + "gzipBytes": 40493, + "brotliBytes": 36030, + "incrementalGzipBytes": 40493, + "incrementalBrotliBytes": 36030 }, "tanstack-area-interactive": { - "minifiedBytes": 116469, - "gzipBytes": 42189, - "brotliBytes": 37272, - "incrementalGzipBytes": 42189, - "incrementalBrotliBytes": 37272 + "minifiedBytes": 116995, + "gzipBytes": 42214, + "brotliBytes": 37420, + "incrementalGzipBytes": 42214, + "incrementalBrotliBytes": 37420 }, "tanstack-area-advanced": { - "minifiedBytes": 123841, - "gzipBytes": 44639, - "brotliBytes": 39311, - "incrementalGzipBytes": 44639, - "incrementalBrotliBytes": 39311 + "minifiedBytes": 124366, + "gzipBytes": 44577, + "brotliBytes": 39497, + "incrementalGzipBytes": 44577, + "incrementalBrotliBytes": 39497 }, "tanstack-scatter-basic": { - "minifiedBytes": 106983, - "gzipBytes": 38919, - "brotliBytes": 34369, - "incrementalGzipBytes": 38919, - "incrementalBrotliBytes": 34369 + "minifiedBytes": 107511, + "gzipBytes": 38921, + "brotliBytes": 34605, + "incrementalGzipBytes": 38921, + "incrementalBrotliBytes": 34605 }, "tanstack-scatter-interactive": { - "minifiedBytes": 112424, - "gzipBytes": 40678, - "brotliBytes": 35859, - "incrementalGzipBytes": 40678, - "incrementalBrotliBytes": 35859 + "minifiedBytes": 112952, + "gzipBytes": 40659, + "brotliBytes": 36008, + "incrementalGzipBytes": 40659, + "incrementalBrotliBytes": 36008 }, "tanstack-scatter-advanced": { - "minifiedBytes": 112440, - "gzipBytes": 40683, - "brotliBytes": 35866, - "incrementalGzipBytes": 40683, - "incrementalBrotliBytes": 35866 + "minifiedBytes": 112968, + "gzipBytes": 40665, + "brotliBytes": 36081, + "incrementalGzipBytes": 40665, + "incrementalBrotliBytes": 36081 }, "chartjs-line-basic": { "minifiedBytes": 137909, - "gzipBytes": 49198, + "gzipBytes": 49062, "brotliBytes": 43162, - "incrementalGzipBytes": 49198, + "incrementalGzipBytes": 49062, "incrementalBrotliBytes": 43162 }, "chartjs-line-interactive": { "minifiedBytes": 162217, - "gzipBytes": 56989, + "gzipBytes": 56910, "brotliBytes": 49793, - "incrementalGzipBytes": 56989, + "incrementalGzipBytes": 56910, "incrementalBrotliBytes": 49793 }, "chartjs-line-advanced": { "minifiedBytes": 162332, - "gzipBytes": 57013, + "gzipBytes": 56938, "brotliBytes": 49850, - "incrementalGzipBytes": 57013, + "incrementalGzipBytes": 56938, "incrementalBrotliBytes": 49850 }, "chartjs-bar-basic": { "minifiedBytes": 134665, - "gzipBytes": 47821, + "gzipBytes": 47674, "brotliBytes": 41871, - "incrementalGzipBytes": 47821, + "incrementalGzipBytes": 47674, "incrementalBrotliBytes": 41871 }, "chartjs-bar-interactive": { "minifiedBytes": 160971, - "gzipBytes": 56127, + "gzipBytes": 56070, "brotliBytes": 49004, - "incrementalGzipBytes": 56127, + "incrementalGzipBytes": 56070, "incrementalBrotliBytes": 49004 }, "chartjs-bar-advanced": { "minifiedBytes": 161080, - "gzipBytes": 56143, + "gzipBytes": 56085, "brotliBytes": 48983, - "incrementalGzipBytes": 56143, + "incrementalGzipBytes": 56085, "incrementalBrotliBytes": 48983 }, "chartjs-area-basic": { "minifiedBytes": 144954, - "gzipBytes": 51617, + "gzipBytes": 51545, "brotliBytes": 45221, - "incrementalGzipBytes": 51617, + "incrementalGzipBytes": 51545, "incrementalBrotliBytes": 45221 }, "chartjs-area-interactive": { "minifiedBytes": 169258, - "gzipBytes": 59553, + "gzipBytes": 59495, "brotliBytes": 51912, - "incrementalGzipBytes": 59553, + "incrementalGzipBytes": 59495, "incrementalBrotliBytes": 51912 }, "chartjs-area-advanced": { "minifiedBytes": 169451, - "gzipBytes": 59608, + "gzipBytes": 59545, "brotliBytes": 51964, - "incrementalGzipBytes": 59608, + "incrementalGzipBytes": 59545, "incrementalBrotliBytes": 51964 }, "chartjs-scatter-basic": { "minifiedBytes": 129054, - "gzipBytes": 45776, + "gzipBytes": 45678, "brotliBytes": 40186, - "incrementalGzipBytes": 45776, + "incrementalGzipBytes": 45678, "incrementalBrotliBytes": 40186 }, "chartjs-scatter-interactive": { "minifiedBytes": 153508, - "gzipBytes": 53639, + "gzipBytes": 53574, "brotliBytes": 46923, - "incrementalGzipBytes": 53639, + "incrementalGzipBytes": 53574, "incrementalBrotliBytes": 46923 }, "chartjs-scatter-advanced": { "minifiedBytes": 153648, - "gzipBytes": 53663, + "gzipBytes": 53606, "brotliBytes": 46948, - "incrementalGzipBytes": 53663, + "incrementalGzipBytes": 53606, "incrementalBrotliBytes": 46948 }, "echarts-line-basic": { "minifiedBytes": 476626, - "gzipBytes": 163218, + "gzipBytes": 162601, "brotliBytes": 138781, - "incrementalGzipBytes": 163218, + "incrementalGzipBytes": 162601, "incrementalBrotliBytes": 138781 }, "echarts-line-interactive": { "minifiedBytes": 519922, - "gzipBytes": 177292, + "gzipBytes": 176624, "brotliBytes": 150569, - "incrementalGzipBytes": 177292, + "incrementalGzipBytes": 176624, "incrementalBrotliBytes": 150569 }, "echarts-line-advanced": { "minifiedBytes": 520047, - "gzipBytes": 177303, + "gzipBytes": 176637, "brotliBytes": 150458, - "incrementalGzipBytes": 177303, + "incrementalGzipBytes": 176637, "incrementalBrotliBytes": 150458 }, "echarts-bar-basic": { "minifiedBytes": 471607, - "gzipBytes": 161066, + "gzipBytes": 160513, "brotliBytes": 137111, - "incrementalGzipBytes": 161066, + "incrementalGzipBytes": 160513, "incrementalBrotliBytes": 137111 }, "echarts-bar-interactive": { "minifiedBytes": 514879, - "gzipBytes": 175077, + "gzipBytes": 174519, "brotliBytes": 148895, - "incrementalGzipBytes": 175077, + "incrementalGzipBytes": 174519, "incrementalBrotliBytes": 148895 }, "echarts-bar-advanced": { "minifiedBytes": 514986, - "gzipBytes": 175090, + "gzipBytes": 174525, "brotliBytes": 148981, - "incrementalGzipBytes": 175090, + "incrementalGzipBytes": 174525, "incrementalBrotliBytes": 148981 }, "echarts-area-basic": { "minifiedBytes": 476678, - "gzipBytes": 163235, + "gzipBytes": 162621, "brotliBytes": 138867, - "incrementalGzipBytes": 163235, + "incrementalGzipBytes": 162621, "incrementalBrotliBytes": 138867 }, "echarts-area-interactive": { "minifiedBytes": 519974, - "gzipBytes": 177315, + "gzipBytes": 176645, "brotliBytes": 150520, - "incrementalGzipBytes": 177315, + "incrementalGzipBytes": 176645, "incrementalBrotliBytes": 150520 }, "echarts-area-advanced": { "minifiedBytes": 520156, - "gzipBytes": 177339, + "gzipBytes": 176680, "brotliBytes": 150610, - "incrementalGzipBytes": 177339, + "incrementalGzipBytes": 176680, "incrementalBrotliBytes": 150610 }, "echarts-scatter-basic": { "minifiedBytes": 460263, - "gzipBytes": 156771, + "gzipBytes": 156232, "brotliBytes": 133671, - "incrementalGzipBytes": 156771, + "incrementalGzipBytes": 156232, "incrementalBrotliBytes": 133671 }, "echarts-scatter-interactive": { "minifiedBytes": 503602, - "gzipBytes": 170834, + "gzipBytes": 170235, "brotliBytes": 145301, - "incrementalGzipBytes": 170834, + "incrementalGzipBytes": 170235, "incrementalBrotliBytes": 145301 }, "echarts-scatter-advanced": { "minifiedBytes": 503750, - "gzipBytes": 170850, + "gzipBytes": 170250, "brotliBytes": 145298, - "incrementalGzipBytes": 170850, + "incrementalGzipBytes": 170250, "incrementalBrotliBytes": 145298 }, "recharts-line-basic": { "minifiedBytes": 516124, - "gzipBytes": 157666, + "gzipBytes": 157205, "brotliBytes": 132967, - "incrementalGzipBytes": 98002, + "incrementalGzipBytes": 97650, "incrementalBrotliBytes": 82770 }, "recharts-line-interactive": { "minifiedBytes": 569293, - "gzipBytes": 170891, + "gzipBytes": 170262, "brotliBytes": 142940, - "incrementalGzipBytes": 111089, + "incrementalGzipBytes": 110708, "incrementalBrotliBytes": 92758 }, "recharts-line-advanced": { "minifiedBytes": 569484, - "gzipBytes": 170961, + "gzipBytes": 170340, "brotliBytes": 143105, - "incrementalGzipBytes": 111132, + "incrementalGzipBytes": 110754, "incrementalBrotliBytes": 92799 }, "recharts-bar-basic": { "minifiedBytes": 513246, - "gzipBytes": 156758, + "gzipBytes": 156379, "brotliBytes": 132293, - "incrementalGzipBytes": 97244, + "incrementalGzipBytes": 96826, "incrementalBrotliBytes": 82104 }, "recharts-bar-interactive": { "minifiedBytes": 575659, - "gzipBytes": 172257, + "gzipBytes": 171681, "brotliBytes": 144455, - "incrementalGzipBytes": 112520, + "incrementalGzipBytes": 112107, "incrementalBrotliBytes": 94481 }, "recharts-bar-advanced": { "minifiedBytes": 575832, - "gzipBytes": 172304, + "gzipBytes": 171721, "brotliBytes": 144611, - "incrementalGzipBytes": 112603, + "incrementalGzipBytes": 112187, "incrementalBrotliBytes": 94513 }, "recharts-area-basic": { "minifiedBytes": 518281, - "gzipBytes": 158330, + "gzipBytes": 157839, "brotliBytes": 133402, - "incrementalGzipBytes": 98628, + "incrementalGzipBytes": 98263, "incrementalBrotliBytes": 83217 }, "recharts-area-interactive": { "minifiedBytes": 571430, - "gzipBytes": 171532, + "gzipBytes": 170903, "brotliBytes": 143560, - "incrementalGzipBytes": 111719, + "incrementalGzipBytes": 111327, "incrementalBrotliBytes": 93273 }, "recharts-area-advanced": { "minifiedBytes": 571650, - "gzipBytes": 171596, + "gzipBytes": 170959, "brotliBytes": 143626, - "incrementalGzipBytes": 111792, + "incrementalGzipBytes": 111402, "incrementalBrotliBytes": 93454 }, "recharts-scatter-basic": { "minifiedBytes": 517521, - "gzipBytes": 158424, + "gzipBytes": 157960, "brotliBytes": 133726, - "incrementalGzipBytes": 98731, + "incrementalGzipBytes": 98370, "incrementalBrotliBytes": 83564 }, "recharts-scatter-interactive": { "minifiedBytes": 566601, - "gzipBytes": 170556, + "gzipBytes": 169991, "brotliBytes": 142846, - "incrementalGzipBytes": 110817, + "incrementalGzipBytes": 110409, "incrementalBrotliBytes": 92693 }, "recharts-scatter-advanced": { "minifiedBytes": 567429, - "gzipBytes": 170764, + "gzipBytes": 170180, "brotliBytes": 143128, - "incrementalGzipBytes": 111001, + "incrementalGzipBytes": 110593, "incrementalBrotliBytes": 92816 }, "observable-plot-line-basic": { "minifiedBytes": 268405, - "gzipBytes": 91644, + "gzipBytes": 90877, "brotliBytes": 78197, - "incrementalGzipBytes": 91644, + "incrementalGzipBytes": 90877, "incrementalBrotliBytes": 78197 }, "observable-plot-line-interactive": { "minifiedBytes": 268479, - "gzipBytes": 91685, + "gzipBytes": 90917, "brotliBytes": 78194, - "incrementalGzipBytes": 91685, + "incrementalGzipBytes": 90917, "incrementalBrotliBytes": 78194 }, "observable-plot-line-advanced": { "minifiedBytes": 268495, - "gzipBytes": 91689, + "gzipBytes": 90921, "brotliBytes": 78163, - "incrementalGzipBytes": 91689, + "incrementalGzipBytes": 90921, "incrementalBrotliBytes": 78163 }, "observable-plot-bar-basic": { "minifiedBytes": 247693, - "gzipBytes": 87275, + "gzipBytes": 86434, "brotliBytes": 74598, - "incrementalGzipBytes": 87275, + "incrementalGzipBytes": 86434, "incrementalBrotliBytes": 74598 }, "observable-plot-bar-interactive": { "minifiedBytes": 247765, - "gzipBytes": 87328, + "gzipBytes": 86487, "brotliBytes": 74559, - "incrementalGzipBytes": 87328, + "incrementalGzipBytes": 86487, "incrementalBrotliBytes": 74559 }, "observable-plot-bar-advanced": { "minifiedBytes": 247780, - "gzipBytes": 87319, + "gzipBytes": 86494, "brotliBytes": 74611, - "incrementalGzipBytes": 87319, + "incrementalGzipBytes": 86494, "incrementalBrotliBytes": 74611 }, "observable-plot-area-basic": { "minifiedBytes": 275460, - "gzipBytes": 94102, + "gzipBytes": 93353, "brotliBytes": 80235, - "incrementalGzipBytes": 94102, + "incrementalGzipBytes": 93353, "incrementalBrotliBytes": 80235 }, "observable-plot-area-interactive": { "minifiedBytes": 275542, - "gzipBytes": 94149, + "gzipBytes": 93397, "brotliBytes": 80338, - "incrementalGzipBytes": 94149, + "incrementalGzipBytes": 93397, "incrementalBrotliBytes": 80338 }, "observable-plot-area-advanced": { "minifiedBytes": 274338, - "gzipBytes": 93850, + "gzipBytes": 93095, "brotliBytes": 79998, - "incrementalGzipBytes": 93850, + "incrementalGzipBytes": 93095, "incrementalBrotliBytes": 79998 }, "observable-plot-scatter-basic": { "minifiedBytes": 242656, - "gzipBytes": 85340, + "gzipBytes": 84479, "brotliBytes": 72769, - "incrementalGzipBytes": 85340, + "incrementalGzipBytes": 84479, "incrementalBrotliBytes": 72769 }, "observable-plot-scatter-interactive": { "minifiedBytes": 242728, - "gzipBytes": 85396, + "gzipBytes": 84524, "brotliBytes": 72831, - "incrementalGzipBytes": 85396, + "incrementalGzipBytes": 84524, "incrementalBrotliBytes": 72831 }, "observable-plot-scatter-advanced": { "minifiedBytes": 242744, - "gzipBytes": 85403, + "gzipBytes": 84530, "brotliBytes": 72875, - "incrementalGzipBytes": 85403, + "incrementalGzipBytes": 84530, "incrementalBrotliBytes": 72875 } } diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 2fc9e828..d0d70ce3 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "52f3f890587d74815d2bd2fb3c6ef09a97d84d333a76312ade628afb03062b9f", + "sourceHash": "434028c578030ddf43bab907dc1f8ca28785be299848baeaac5195f8a92302c3", "assets": [ { "id": "01-line-gaps", diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index af9d82b9..1644ee29 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -156,11 +156,27 @@ const x = { | `axis.ticks` | Configure candidates, stubs, padding, and formatting | | `axis.tickLabels` | Configure label rotation and collision thinning | | `axis.label` | Configure the axis title and offset | +| `axis.side` | Place the axis on the opposite plot edge | | `grid` | Draw 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. +`axis.side` defaults to `start`, which places the y axis left and the x axis +bottom. `end` places the y axis right and the x axis top, and moves that axis's +stubs, tick labels, title, and crosshair value label with it. Automatic margins +follow the placement, so the reserved gutter moves rather than being duplicated. + +A right-to-left locale reads the value axis on the right, which is `side` on the +y axis combined with `reverse` on the x axis: + +```ts +const chart = { + x: { scale: xScale, reverse: true }, + y: { scale: yScale, axis: { side: 'end' } }, +} +``` + Candidate generation and label layout are separate. Choose at most one of `axis.ticks.count`, `axis.ticks.spacing`, and `axis.ticks.values`. Grid lines and tick stubs use the generated candidates; label thinning does not remove diff --git a/docs/reference/types.md b/docs/reference/types.md index 62399f0d..3e9ad03b 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -206,7 +206,8 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisOptions` | Required positional scale and optional guide behavior | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | | `ChartAxisGuideOptions` | Guide behavior without the scale field | -| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | +| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, title, and side presentation | +| `ChartAxisSide` | Plot edge that carries the axis | | `ChartAxisTickOptions` | Candidate values, density, formatting, size, and padding | | `ChartAxisTickLabelOptions` | Per-candidate typography, anchor, offset, rotation, and thinning | | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | 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 af9d82b9..1644ee29 100644 --- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md +++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md @@ -156,11 +156,27 @@ const x = { | `axis.ticks` | Configure candidates, stubs, padding, and formatting | | `axis.tickLabels` | Configure label rotation and collision thinning | | `axis.label` | Configure the axis title and offset | +| `axis.side` | Place the axis on the opposite plot edge | | `grid` | Draw 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. +`axis.side` defaults to `start`, which places the y axis left and the x axis +bottom. `end` places the y axis right and the x axis top, and moves that axis's +stubs, tick labels, title, and crosshair value label with it. Automatic margins +follow the placement, so the reserved gutter moves rather than being duplicated. + +A right-to-left locale reads the value axis on the right, which is `side` on the +y axis combined with `reverse` on the x axis: + +```ts +const chart = { + x: { scale: xScale, reverse: true }, + y: { scale: yScale, axis: { side: 'end' } }, +} +``` + Candidate generation and label layout are separate. Choose at most one of `axis.ticks.count`, `axis.ticks.spacing`, and `axis.ticks.values`. Grid lines and tick stubs use the generated candidates; label thinning does not remove diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index 62399f0d..3e9ad03b 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -206,7 +206,8 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisOptions` | Required positional scale and optional guide behavior | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | | `ChartAxisGuideOptions` | Guide behavior without the scale field | -| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | +| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, title, and side presentation | +| `ChartAxisSide` | Plot edge that carries the axis | | `ChartAxisTickOptions` | Candidate values, density, formatting, size, and padding | | `ChartAxisTickLabelOptions` | Per-candidate typography, anchor, offset, rotation, and thinning | | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | diff --git a/packages/charts-core/src/crosshair-resolver.ts b/packages/charts-core/src/crosshair-resolver.ts index f65ce54a..6d6fc0a5 100644 --- a/packages/charts-core/src/crosshair-resolver.ts +++ b/packages/charts-core/src/crosshair-resolver.ts @@ -1,4 +1,4 @@ -import { measureSceneLabelBounds } from './guide-layout' +import { axisPlacement, measureSceneLabelBounds } from './guide-layout' import { valueKey } from './scales' import type { ChartBounds, @@ -219,6 +219,8 @@ function resolveFocusGuide( }) } if (guide.x.label && target.xValue !== undefined) { + const placement = axisPlacement('x', guide.chart, guide.x.side) + const baselineDrop = placement.sign > 0 ? guide.x.label.fontSize * 0.8 : 0 children.push( ...guideLabels( clampLabel( @@ -229,10 +231,9 @@ function resolveFocusGuide( 'ts-chart__crosshair-label ts-chart__crosshair-label--x', x: labelX, y: - guide.chart.y + - guide.chart.height + - guide.x.label.offset + - guide.x.label.fontSize * 0.8, + placement.edge + + placement.sign * guide.x.label.offset + + baselineDrop, text: formatGuideValue( scene, 'x', @@ -287,6 +288,7 @@ function resolveFocusGuide( }) } if (guide.y.label && target.yValue !== undefined) { + const placement = axisPlacement('y', guide.chart, guide.y.side) children.push( ...guideLabels( clampLabel( @@ -295,7 +297,7 @@ function resolveFocusGuide( key: `${guide.key}:y-label`, className: 'ts-chart__crosshair-label ts-chart__crosshair-label--y', - x: guide.chart.x - guide.y.label.offset, + x: placement.edge + placement.sign * guide.y.label.offset, y: labelY, text: formatGuideValue( scene, @@ -303,7 +305,7 @@ function resolveFocusGuide( target.yValue, guide.y.label.format, ), - anchor: 'end', + anchor: placement.sign > 0 ? 'start' : 'end', baseline: 'middle', fontSize: guide.y.label.fontSize, fontWeight: guide.y.label.fontWeight, diff --git a/packages/charts-core/src/crosshair.test.ts b/packages/charts-core/src/crosshair.test.ts index 1466c2e0..d5e49b26 100644 --- a/packages/charts-core/src/crosshair.test.ts +++ b/packages/charts-core/src/crosshair.test.ts @@ -10,6 +10,7 @@ import { createChartScene, defineChart } from './scene' import { stack } from './stack' import { linearAxes } from './test-scales' import type { + ChartAxisSide, ChartFocusState, ChartPoint, SceneGroup, @@ -612,6 +613,19 @@ describe('crosshair', () => { [...positioned.under, ...positioned.over].flatMap(crosshairNodes), ).toHaveLength(1) }) + + it('follows the axis side with its value labels', () => { + const start = crosshairLabels('start') + const end = crosshairLabels('end') + + expect(start.y.x).toBeLessThan(start.chart.x) + expect(start.y.anchor).toBe('end') + expect(start.x.y).toBeGreaterThan(start.chart.y + start.chart.height) + + expect(end.y.x).toBeGreaterThan(end.chart.x + end.chart.width) + expect(end.y.anchor).toBe('start') + expect(end.x.y).toBeLessThan(end.chart.y) + }) }) if (false) { @@ -636,6 +650,35 @@ function focus(point: ChartPoint): ChartFocusState { return { primary: point, group: [point], source: 'pointer', pinned: false } } +function crosshairLabels(side: ChartAxisSide) { + const rows = [ + { x: 0, y: 1 }, + { x: 2, y: 3 }, + ] + const scene = createChartScene( + defineChart({ + marks: [ + dot(rows, { x: 'x', y: 'y' }), + crosshair({ x: { label: true }, y: { label: true } }), + ], + x: { scale: scaleLinear().domain([0, 2]), axis: { side } }, + y: { scale: scaleLinear().domain([0, 4]), axis: { side } }, + }), + { width: 320, height: 180 }, + ) + const point = scene.points.find((candidate) => candidate.datum === rows[1])! + const guide = findNode( + resolveFocusPresentation(scene, focus(point)).over, + 'crosshair-1', + ) as SceneGroup + + return { + chart: scene.chart, + x: findNode(guide.children, 'crosshair-1:x-label:text') as SceneLabel, + y: findNode(guide.children, 'crosshair-1:y-label:text') as SceneLabel, + } +} + function findNode( nodes: readonly SceneNode[], key: string, diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index ac1f6b6b..93daf6d5 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -1,4 +1,5 @@ import type { + ChartAxisSide, ChartBounds, ChartMargin, ChartTextMeasurer, @@ -27,6 +28,23 @@ export interface GuideMarginOptions { measureText?: ChartTextMeasurer } +export interface AxisPlacement { + edge: number + sign: 1 | -1 +} + +export function axisPlacement( + axis: 'x' | 'y', + chart: ChartBounds, + side: ChartAxisSide | undefined, +): AxisPlacement { + const end = side === 'end' + if (axis === 'x') { + return { edge: end ? chart.y : chart.y + chart.height, sign: end ? -1 : 1 } + } + return { edge: end ? chart.x + chart.width : chart.x, sign: end ? 1 : -1 } +} + export function estimateSceneText( text: string, style: ChartTextMeasureOptions, diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index ef08ee04..24c47c40 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -281,6 +281,7 @@ export type { ChartAxisOptions, ChartAxisLabelOptions, ChartAxisPresentationOptions, + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelThinOptions, diff --git a/packages/charts-core/src/scene-layout.test.ts b/packages/charts-core/src/scene-layout.test.ts index c94758ef..347a72cd 100644 --- a/packages/charts-core/src/scene-layout.test.ts +++ b/packages/charts-core/src/scene-layout.test.ts @@ -6,6 +6,7 @@ import { createMark } from './mark' import { createChartScene, defineChart } from './scene' import { text } from './text' import type { + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelValue, @@ -803,8 +804,70 @@ describe('automatic scene guide layout', () => { true, ) }) + + it('places an end-side y axis against the right plot edge', () => { + const scene = sceneWithSides({ y: 'end' }) + const nodes = flatten(scene.nodes) + const right = scene.chart.x + scene.chart.width + const axis = nodes.find((node) => node.key === 'y-axis')! + const tick = nodes.find((node) => node.key.startsWith('y-tick-rule:'))! + const label = nodes.find( + (node): node is SceneLabel => + node.kind === 'label' && node.key.startsWith('y-tick-label:'), + )! + + expect(axis).toMatchObject({ x1: right, x2: right }) + expect(tick).toMatchObject({ x1: right + 4, x2: right }) + expect(label).toMatchObject({ x: right + 8, anchor: 'start' }) + expect(nodes.find((node) => node.key === 'y-label')).toMatchObject({ + rotate: 90, + }) + expect(scene.margin.right).toBeGreaterThan(scene.margin.left) + }) + + it('places an end-side x axis against the top plot edge', () => { + const scene = sceneWithSides({ x: 'end' }) + const nodes = flatten(scene.nodes) + const top = scene.chart.y + const axis = nodes.find((node) => node.key === 'x-axis')! + const tick = nodes.find((node) => node.key.startsWith('x-tick-rule:'))! + const label = nodes.find( + (node): node is SceneLabel => + node.kind === 'label' && node.key.startsWith('x-tick-label:'), + )! + + expect(axis).toMatchObject({ y1: top, y2: top }) + expect(tick).toMatchObject({ y1: top, y2: top - 4 }) + expect(label).toMatchObject({ y: top - 8 }) + expect(scene.margin.top).toBeGreaterThan(scene.margin.bottom) + }) + + it('keeps start-side placement identical to an unset side', () => { + const explicit = flatten(sceneWithSides({ x: 'start', y: 'start' }).nodes) + const implicit = flatten(sceneWithSides({}).nodes) + + expect(explicit).toEqual(implicit) + }) }) +function sceneWithSides(sides: { x?: ChartAxisSide; y?: ChartAxisSide }) { + return createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + x: { + scale: scaleLinear().domain([0, 2]), + axis: { side: sides.x, ticks: { values: [0, 2] }, label: 'Release' }, + }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { side: sides.y, ticks: { values: [0, 3] }, label: 'Downloads' }, + }, + }), + { width: 480, height: 240 }, + { measureText }, + ) +} + function sceneWithYFormat(format: (value: unknown) => string) { return createChartScene( defineChart({ diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 396b3159..dc8a2008 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1,8 +1,10 @@ import { createColorScale, valueKey } from './scales' import { resolveConfiguredScale } from './configured-scale' import { + axisPlacement, measureSceneLabelBounds, withChartTextTypography, + type AxisPlacement, } from './guide-layout' import { nearestScenePoint } from './nearest' import { mapScenePointReferences } from './scene-point-map' @@ -13,6 +15,7 @@ import type { MaterializedChannel, ChartAxisOptions, ChartAxisPresentationOptions, + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelValue, @@ -34,6 +37,7 @@ import type { ChartMarkState, ChartPoint, SceneFocusGuide, + SceneFocusGuideAxis, ResolvedColorScale, ChartScene, ChartScaleResolver, @@ -283,6 +287,8 @@ function createChartSceneWithScaleResolver< const translateX = scales.x.viewport?.translate ?? 0 const translateY = scales.y.viewport?.translate ?? 0 const focusGuides: SceneFocusGuide[] = [] + const xAxisSide = axisPresentation(definition.x)?.side + const yAxisSide = axisPresentation(definition.y)?.side const firstBaseMarkIndex = marks.findIndex( (mark) => !mark.focus && !mark.focusGuideOnly, ) @@ -343,7 +349,12 @@ function createChartSceneWithScaleResolver< ? 'under' : 'over' for (const guide of rendered.focusGuides ?? []) { - focusGuides.push({ ...guide, placement: guide.placement ?? placement }) + focusGuides.push({ + ...guide, + placement: guide.placement ?? placement, + x: withGuideAxisSide(guide.x, xAxisSide), + y: withGuideAxisSide(guide.y, yAxisSide), + }) } if (mark.focus) { const retarget = mark.focus.retarget === true @@ -1075,6 +1086,8 @@ function createAxes( const showY = axes & 2 const xAxis = axisPresentation(definition.x) const yAxis = axisPresentation(definition.y) + const xPlacement = axisPlacement('x', chart, xAxis?.side) + const yPlacement = axisPlacement('y', chart, yAxis?.side) const children: SceneNode[] = !showX || xAxis?.line === false ? [] @@ -1084,8 +1097,8 @@ function createAxes( key: 'x-axis', x1: chart.x, x2: chart.x + chart.width, - y1: chart.y + chart.height, - y2: chart.y + chart.height, + y1: xPlacement.edge, + y2: xPlacement.edge, style: { stroke: theme.foreground, strokeOpacity: 0.28, @@ -1096,8 +1109,8 @@ function createAxes( children.push({ kind: 'rule', key: 'y-axis', - x1: chart.x, - x2: chart.x, + x1: yPlacement.edge, + x2: yPlacement.edge, y1: chart.y, y2: chart.y + chart.height, style: { @@ -1108,8 +1121,8 @@ function createAxes( } const xTickLabels = tickLabelPresentation(xAxis) const yTickLabels = tickLabelPresentation(yAxis) - let xTickBottom = chart.y + chart.height - let yTickLeft = chart.x + let xTickExtent = xPlacement.edge + let yTickExtent = yPlacement.edge const inset = axes ? automaticGuideInset : 0 const margin = uniformMargin(inset) @@ -1136,7 +1149,7 @@ function createAxes( : createTickLabelCandidates( 'x', withKeptTicks(scales.x, definition.x, xTickLabels), - chart, + xPlacement, xTickSize, xTickPadding, xTickLabels, @@ -1151,7 +1164,7 @@ function createAxes( : createTickLabelCandidates( 'y', withKeptTicks(scales.y, definition.y, yTickLabels), - chart, + yPlacement, yTickSize, yTickPadding, yTickLabels, @@ -1177,8 +1190,8 @@ function createAxes( key: `x-tick-rule:${key}`, x1: tick.position, x2: tick.position, - y1: chart.y + chart.height, - y2: chart.y + chart.height + xTickSize, + y1: xPlacement.edge, + y2: xPlacement.edge + xPlacement.sign * xTickSize, style: { stroke: theme.foreground, strokeOpacity: 0.28, @@ -1190,7 +1203,7 @@ function createAxes( for (const candidate of showX ? visibleXLabels : []) { const bounds = addLabel(candidate.label) if (axisLabelText(xAxis) && axisLabelOffset(xAxis) === 'auto') { - xTickBottom = Math.max(xTickBottom, bounds.y + bounds.height) + xTickExtent = extendTickExtent(xTickExtent, bounds, 'x', xPlacement.sign) } children.push(candidate.label) } @@ -1201,8 +1214,8 @@ function createAxes( children.push({ kind: 'rule', key: `y-tick-rule:${key}`, - x1: chart.x - yTickSize, - x2: chart.x, + x1: yPlacement.edge + yPlacement.sign * yTickSize, + x2: yPlacement.edge, y1: tick.position, y2: tick.position, style: { @@ -1216,7 +1229,7 @@ function createAxes( for (const candidate of showY ? visibleYLabels : []) { const bounds = addLabel(candidate.label) if (axisLabelText(yAxis) && axisLabelOffset(yAxis) === 'auto') { - yTickLeft = Math.min(yTickLeft, bounds.x) + yTickExtent = extendTickExtent(yTickExtent, bounds, 'y', yPlacement.sign) } children.push(candidate.label) } @@ -1225,16 +1238,17 @@ function createAxes( if (showX && xAxisLabel) { const offset = axisLabelOffset(xAxis) const hasOffset = offset !== 'auto' + const hangingBaseline = !hasOffset && xPlacement.sign > 0 const label: SceneLabel = { kind: 'label', key: 'x-label', x: chart.x + chart.width / 2, y: hasOffset - ? chart.y + chart.height + Math.max(0, finiteMargin(offset)) - : xTickBottom + 8, + ? xPlacement.edge + xPlacement.sign * Math.max(0, finiteMargin(offset)) + : xTickExtent + xPlacement.sign * 8, text: xAxisLabel, anchor: 'middle', - baseline: hasOffset ? 'auto' : 'hanging', + baseline: hangingBaseline ? 'hanging' : 'auto', fontSize: width < 360 ? 10 : 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 }, @@ -1248,25 +1262,28 @@ function createAxes( const yLabel: SceneLabel = { kind: 'label', key: 'y-label', - x: chart.x, + x: yPlacement.edge, y: chart.y + chart.height / 2, text: yAxisLabel, anchor: 'middle', baseline: 'middle', - rotate: -90, + rotate: yPlacement.sign > 0 ? 90 : -90, fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 }, } const offset = axisLabelOffset(yAxis) if (offset !== 'auto') { - yLabel.x = chart.x - Math.max(0, finiteMargin(offset)) + yLabel.x = + yPlacement.edge + yPlacement.sign * Math.max(0, finiteMargin(offset)) } else { const localBounds = measureSceneLabelBounds( { ...yLabel, x: 0, y: 0 }, measureText, ) - yLabel.x = yTickLeft - 8 - (localBounds.x + localBounds.width) + const localEdge = + yPlacement.sign > 0 ? localBounds.x : localBounds.x + localBounds.width + yLabel.x = yTickExtent + yPlacement.sign * 8 - localEdge } addLabel(yLabel) children.push(yLabel) @@ -1344,6 +1361,26 @@ function axisLabelOffset( : 'auto' } +function withGuideAxisSide( + axis: SceneFocusGuideAxis | undefined, + side: ChartAxisSide | undefined, +): SceneFocusGuideAxis | undefined { + if (!axis || side === undefined) return axis + return { ...axis, side } +} + +function extendTickExtent( + extent: number, + bounds: ChartBounds, + axis: 'x' | 'y', + sign: 1 | -1, +): number { + const near = axis === 'x' ? bounds.y : bounds.x + const far = near + (axis === 'x' ? bounds.height : bounds.width) + if (sign > 0) return Math.max(extent, far) + return Math.min(extent, near) +} + interface TickLabelCandidate { value: ChartValue label: SceneLabel @@ -1384,7 +1421,7 @@ function withKeptTicks( function createTickLabelCandidates( axis: 'x' | 'y', ticks: readonly (ChartTick & { hard?: boolean })[], - chart: ChartBounds, + placement: AxisPlacement, size: number, padding: number, options: ChartAxisTickLabelOptions, @@ -1408,9 +1445,11 @@ function createTickLabelCandidates( const opacity = resolveTickLabelValue(options.opacity, context) const dx = resolveTickLabelValue(options.dx, context) ?? 0 const dy = resolveTickLabelValue(options.dy, context) ?? 0 + const crossAnchor = placement.sign > 0 ? 'start' : 'end' + const baselineDrop = placement.sign > 0 ? fontSize * 0.8 : 0 const defaultAnchor = axis === 'y' - ? 'end' + ? crossAnchor : (rotate ?? 0) < 0 ? 'end' : (rotate ?? 0) > 0 @@ -1424,7 +1463,11 @@ function createTickLabelCandidates( kind: 'label', key: `x-tick-label:${valueKey(tick.value)}`, x: tick.position + dx, - y: chart.y + chart.height + size + padding + fontSize * 0.8 + dy, + y: + placement.edge + + placement.sign * (size + padding) + + baselineDrop + + dy, text: tick.label, anchor, rotate, @@ -1438,7 +1481,7 @@ function createTickLabelCandidates( : { kind: 'label', key: `y-tick-label:${valueKey(tick.value)}`, - x: chart.x - size - padding + dx, + x: placement.edge + placement.sign * (size + padding) + dx, y: tick.position + dy, text: tick.label, anchor, diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 17059fbf..008ac139 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -358,11 +358,18 @@ export interface ChartAxisLabelOptions { motion?: ChartMotionDefinition } +/** + * Axis placement. `y` places `start` left and `end` right; `x` places `start` + * bottom and `end` top. + */ +export type ChartAxisSide = 'start' | 'end' + export interface ChartAxisPresentationOptions { line?: boolean ticks?: false | ChartAxisTickOptions tickLabels?: false | ChartAxisTickLabelOptions label?: string | ChartAxisLabelOptions + side?: ChartAxisSide motion?: ChartMotionDefinition } @@ -1179,6 +1186,8 @@ export interface SceneFocusGuideAxis { label?: SceneFocusGuideLabel /** Categorical band geometry that replaces the axis rule when present. */ band?: SceneFocusGuideBand + /** Chart axis placement the guide label follows. */ + side?: ChartAxisSide } export interface SceneFocusGuideBand { diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index b4a1e9fa..abfa8923 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -178,6 +178,7 @@ export type { ChartAxisOptions, ChartAxisLabelOptions, ChartAxisPresentationOptions, + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelThinOptions,