diff --git a/.changeset/issue-9-chart-authoring.md b/.changeset/issue-9-chart-authoring.md new file mode 100644 index 00000000..9aa8df13 --- /dev/null +++ b/.changeset/issue-9-chart-authoring.md @@ -0,0 +1,13 @@ +--- +'@tanstack/charts': minor +--- + +Replace the flat axis guide options with composable axis, grid, tick, and +responsive label configuration. Add shared focus-layer marks, animated inline +mark states, coordinate-based tooltip anchoring, and unified stack/group +layouts with inferred stacking. +Add data-first group, numeric/calendar/two-dimensional bin, window, cumulative, +rank, normalize, select, and row-stack transforms. Results use named group +fields, flat row extension, explicit reducers and ordering, source lineage, +object-bag callbacks, ordinary-function escape hatches, and granular entry +points. diff --git a/API-FRICTION.md b/API-FRICTION.md index 680956ab..b7383c2b 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -193,7 +193,13 @@ Each entry records: | F-155 | Optional tooltip code burdened every chart consumer | API | resolved | | F-156 | Releases stranded manual Unreleased migration notes | Tooling/Release | monitoring | | F-157 | Conformance monitoring blocked unrelated changes | Tooling | resolved | -| F-158 | Sankey widths required a custom scene renderer | API | resolved | +| F-158 | Focus presentation was fixed to one renderer marker | API | resolved | +| F-159 | Axis scale and presentation controls were interleaved | API | resolved | +| F-160 | Responsive tick labels had no collision policy | API | resolved | +| F-161 | Tooltip anchors could not fix coordinates independently | API | resolved | +| F-162 | Focus styling required duplicate marks | API | resolved | +| F-163 | Cross-row transforms lacked a public ownership boundary | API | resolved | +| F-164 | Sankey widths required a custom scene renderer | API | resolved | ## Findings @@ -380,11 +386,11 @@ Each entry records: scale with an authored string range/interpolator; bare D3 defaults are numeric or empty and fail instead of becoming invalid CSS paint. - Group inference: when `z` is omitted, connected line and area marks use - `color` to partition paths because one row-level color channel necessarily - identifies those paths. A grouped bar does the same only when `groupScale` - requests subgroup geometry. Explicit `z` remains authoritative and can - differ from `color`; point marks never change geometry merely because they - have a color channel. + discrete `color` to partition paths because one row-level color channel + necessarily identifies those paths. Bars use it as series identity after + stacked or explicit grouped geometry is known. Continuous color cannot infer + a series. Explicit `z` remains authoritative and can differ from `color`; + color never selects stacked versus grouped geometry. - Legend behavior: `colorLegend` uses swatches for categorical scales, a gradient for continuous and sequential scales, and exact stepped bins and boundaries for quantize, quantile, and threshold scales. @@ -459,14 +465,19 @@ Each entry records: thick and shifted each away from its categorical tick. - Expected: the supplied D3 band scale completely owns bar position and thickness. Series or color identity must not silently change geometry. -- Decision: bars fill the primary scale bandwidth by default, with no implicit - inset. `z` remains series identity and a color fallback. True side-by-side - bars inject a secondary D3 band scale through `groupScale`; TanStack copies - it and supplies the primary bandwidth as its responsive range. -- Verification: focused tests cover exact band starts, widths, centers, - source-scale immutability, non-positional `z`, and injected grouped scales. - Stats grouped and stacked snapshots now match Plot rectangle geometry in - both orientations. +- Decision: a single quantitative bar channel is a length and stacks + implicitly at repeated positions. Explicit endpoints opt out of stacking but + remain compatible with orthogonal `layout: group()` positioning. Side-by-side + geometry requires `layout: group()`, optionally with a copied D3 band scale. + `z` supplies series identity; discrete `color` may infer identity only after + geometry is selected. `layout: stack()` exposes order, reversal, diverging, + normalization, centering, and wiggle offsets for bars and areas while + preserving the default implicit stack. +- Verification: focused tests cover implicit diverging bars, normalized and + ordered stacks, area stacks, explicit endpoints with grouping, explicit + grouping, source-scale immutability, and continuous-color rejection. Catalog + conformance case 72 passes with explicit stacked endpoints adjacent to an + independent grouped bar. ### F-014 — Responsive nicing duplicates layout calculations @@ -673,10 +684,11 @@ Each entry records: entire 320px surface. - Decision: make omitted margin sides automatic. Solve the minimum guide bounds from formatted text, anchors, and rotations; treat numeric sides as hard - overrides; expose resolved bounds for aligned application UI. Keep label - containment separate from tick collision. Content-dependent examples omit - margin locks, and axis titles use the same compact 10px typography as ticks - below 360px. + overrides; expose resolved bounds for aligned application UI. Candidate + generation now precedes rotated-label collision thinning, while containment + remains a separate constraint. The final thinned bounds feed the iterative + margin solver. Content-dependent examples omit margin locks, and axis titles + use the same compact 10px typography as ticks below 360px. - Verification: six guide-bound tests cover deterministic measurement, anchors, baselines, rotation, translated groups, and all four sides. Five scene-layout tests cover long labels and titles, rotated endpoints, narrow-to-wide @@ -1870,10 +1882,9 @@ Each entry records: four bars without temporal context. - Expected: compact and nested charts can independently show or hide each axis guide without custom rendering. -- Decision: add per-axis guide visibility at the narrow axis configuration - layer while retaining `guides: false` as the positionless-chart shorthand. - The option must not add a universal dependency or affect charts that keep - both guides. +- Decision: use `axis: false` at the narrow scale configuration layer while + retaining `guides: false` as the positionless-chart shorthand. Grid + visibility is independent from axis visibility. - Verification: scene-layout tests cover x-only, y-only, both, and neither, including automatic margins and grid suppression. The nested-tooltip case retains period labels while hiding its y guide. The full 79-case matrix @@ -3795,7 +3806,139 @@ Each entry records: execution. The main CI contract rejects any conformance dependency while retaining every exact-revision catalog publication guard. -### F-158 — Sankey widths required a custom scene renderer +### F-158 — Focus presentation was fixed to one renderer marker + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: implementing the background-highlight feedback in issue #9 +- Friction: the host could paint only one hardcoded point marker. A focused + category band, rule, active bar, or custom effect required renderer-specific + DOM mutation or a second interaction loop. +- Decision: `whenFocused` filters an ordinary mark from the centralized + `ChartFocusState`. Matching supports primary, group, key, x, y, and series. + Mark order owns under/over placement; filtered marks infer scales but do not + add hit targets. Custom surfaces receive primary, group, source, and pinned + state. +- Verification: SVG tests filter band geometry by semantic x, Canvas tests + preserve the cached base layer while painting underlays and overlays, and + renderer tests verify pointer/keyboard source and pinned state. + +### F-159 — Axis scale and presentation controls were interleaved + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: grid-without-tick-stubs feedback in issue #9 +- Friction: flat scale, axis, tick, title, and grid options coupled visibility. + Hiding the guide also removed grid lines; hiding stubs retained their + geometry in layout. +- Decision: keep `scale`, `nice`, `reverse`, and `grid` at the scale layer and + nest baseline, ticks, tick labels, and title under `axis`. `axis: false` + retains the scale, and `axis.ticks.size: 0` omits stub nodes and their space. +- Verification: scene-layout tests cover independent grid and axis visibility, + zero-size stub omission, formatter placement, title offsets, and mutually + exclusive candidate policies. The combined issue #9 foundation adds 1,340 + gzip bytes to the locked line scene and 2,883 gzip bytes to the + representative-mark entry; the reviewed universal baseline and isolated + ceilings record that cost. + +### F-160 — Responsive tick labels had no collision policy + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: responsive-axis-label feedback in issue #9 +- Friction: authors could choose a tick count or rotation, but neither + guaranteed readable labels as length changed. There was no way to hard-keep + important interior labels. +- Decision: generate semantic candidates from exactly one of count, pixel + spacing, or explicit values; then rotate and collision-thin labels. Thinning + defaults on, categorical x softly prioritizes ends, and `keep` hard-retains + exact labels without adding grid lines or stubs. +- Verification: tests cover width-dependent candidate counts, rotated thinning, + the `thin: false` matrix, soft ends, hard interior retention, and kept + label-only values through iterative automatic margins. Shared facet axes + remeasure after their final cell width changes the thinning result; the + 320-pixel Anscombe case keeps its right edge label contained. That convergence + adds 0.25 kB gzip to the isolated facet bundle (18.65 kB total), covered by + its reviewed 18.8 kB ceiling without changing any exact universal baseline. + +### F-161 — Tooltip anchors could not fix coordinates independently + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: fixed-tooltip-placement feedback in issue #9 +- Friction: point, pointer, and group-center presets moved both coordinates. + Fixing a grouped tooltip to the plot top while following a value on x + required duplicated plot-bound calculations in a callback. +- Decision: accept `{ x, y }` anchors with point, pointer, value, group-center, + and plot-edge choices per coordinate. Callback context exposes complete + focus, pointer, plot, surface, and resolved-scale state. +- Verification: renderer tests cover mixed plot-center/plot-top anchoring, + keyboard pointer fallback, and the complete typed callback context. + +### F-162 — Focus styling required duplicate marks + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: issue #9 pointer and grouped-tooltip examples +- Friction: enlarging or recoloring an existing focused dot required a second + `whenFocused(dot(...))` with duplicated data, position channels, key policy, + and paint. That replacement geometry could not interpolate from the base + mark and made callback-dependent presentation repeat channel logic. +- Decision: supported marks accept ordered inline `states`. A state selects + centralized focus by primary, group, key, x, y, series, unmatched, source, + pinned state, or a callback. Style callbacks receive one object containing + datum, index, data, point, focus, pointer, and a focus matcher. State styles + change presentation only; `whenFocused` remains the composition for new + transient geometry. +- Verification: SVG tests cover callback context, ordered active/unmatched + overrides, paint and radius transitions, and restoration. Canvas tests cover + equivalent repaint and restoration. Public type tests preserve the source + datum throughout the single-object callback, and catalog cases 34 and 35 + exercise primary and grouped state styles. The complexity audit replaced a + quadratic node/point scan with a per-mark prefix index. Static line-scene + plumbing adds 71 gzip bytes; the complete SVG DOM host adds 994 gzip bytes, + recorded in the reviewed universal bundle baseline. + +### F-163 — Cross-row transforms lacked a public ownership boundary + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: replacing manual histogram, grouped-reducer, rolling-average, + extrema, and stack preparation in the issue #9 authoring pass +- Friction: canonical docs assigned every transform to application code while a + private legacy package exposed a different options-rewriting transform model. + Authors had no public typed path for common reductions, no consistent source + lineage, and no clear distinction between reusable stack rows and mark-local + stack/group layout. The first public pass then exposed opaque `key` tuples, + nested `datum.datum` paths, input-order-only windows, and implicit reducer + defaults. Real heatmap, cumulative histogram, ECDF, bump, Bollinger, and + temporal aggregation cases still required one-off D3 glue. +- Decision: expose eager data-first grouping, numeric and temporal binning, + two-dimensional binning, window, cumulative, rank, normalize, select, and + stack-row helpers. Group fields are named. One-to-one transforms extend flat + input rows. Windowed operations accept explicit ordering. Every output names + its reducer. Common reductions use compact string names; descriptive, + dispersion, endpoint, change, ratio, and quantile reducers are separately + tree-shakeable functions. Ordinary functions + are the custom composition escape hatch; there is no `transformData` + protocol or hidden reactive graph. `stack()` and `group()` remain mark + layouts. +- Verification: focused unit and type tests cover callback inference, named + compound groups, aligned grouped and two-dimensional bins, calendar-aligned + empty periods, ordered rolling and cumulative lineage, ranks, quantiles, + normalization, selection, and shared stack semantics. Conformance consumers + use flat rows directly. Each transform family has a packed granular entry and + independent bundle measurement; ordinary mark entries remain protected by + exact baselines. The private legacy transform export and its 402 lines of + duplicate implementation/tests remain removed. + +### F-164 — Sankey widths required a custom scene renderer - Status: resolved - Severity: medium diff --git a/ISSUE-9-DESIGN-NOTES.md b/ISSUE-9-DESIGN-NOTES.md new file mode 100644 index 00000000..f2081891 --- /dev/null +++ b/ISSUE-9-DESIGN-NOTES.md @@ -0,0 +1,582 @@ +# Issue #9 design decisions + +Source: https://github.com/TanStack/charts/issues/9 + +This is the working decision log for reviewing the issue sequentially. Update +it after each feedback item is settled. It records API direction, not an +implementation plan or compatibility promise. + +## Review status + +1. Background highlight and focused-mark presentation — implemented +2. Gridlines without tick stubs — implemented +3. Responsive axis labels — implemented +4. Fixed tooltip placement — implemented +5. Stacked and grouped bar authoring — implemented +6. General data transforms — implemented + +## Implementation audit + +### Complexity + +- State selection and callback evaluation live in one renderer-neutral scene + resolver. SVG reconciles the resolved scene; Canvas repaints it and uses its + existing crossfade path for transitions. +- Stateful scene groups build a prefix index once per focus paint. Point marks + resolve a node in near-constant time instead of scanning every point for + every node. This removes the quadratic dense-dot path found during review. +- Pointer movement repaints only when a scene contains inline states. Canvas + must repaint its base scene when existing marks change or unmatched marks + fade; that is an intentional cost, not duplicated interaction state. +- The resolver is the largest new runtime unit (315 source lines). Mark files + only attach typed state metadata; focus resolution remains centralized. + +### Duplication + +- Bars and both area orientations use one `stackValues` adapter over one D3 + stack engine. The previous outer-transform registration and three repeated + extent-expansion helpers are gone. +- SVG and Canvas share selectors, callbacks, ordered overrides, reduced-motion + handling, and transition selection. Their remaining code differs only at + the paint boundary. +- Native stack authoring removes 90 net lines from the stacked and normalized + area examples by deleting their tidy-to-wide D3 preparation. + +### Bundle size + +- Against the compact-scales and opt-in-tooltip `main` baseline, the complete + core authoring work adds 1.38 kB gzip to a static D3-scale line scene and + 1.37 kB to the equivalent compact-scale scene. A React compact-scale line is + 16.59 kB gzip, up 2.69 kB; the additional host cost covers focus filtering, + inline state resolution and transitions, and the richer axis model. +- Representative marks add 3.33 kB gzip because that entry exercises the + state-capable mark metadata plus native stack/group layout. The renderer- + neutral host remains 10.04 kB, while the SVG DOM host is 14.00 kB and the + Canvas host is 15.18 kB. +- Tooltip remains opt-in after the merge. The extension adds 3.52 kB gzip to a + React compact-scale line; portal transport adds another 0.79 kB. Static, + compact-scale, renderer-neutral, and adapter entries enforce that tooltip and + portal modules are absent unless selected. +- The committed 12-consumer comparison matrix now spans 26.62-32.17 kB gzip + for TanStack Charts across line, bar, area, and scatter tiers. Its baseline + records the exact source revision separately from documentation-only commits. +- Every transform family now has an isolated gzip ceiling and a retained-input + boundary. Numeric and 2D bins may retain `d3-array`; row stacks may retain + `d3-shape`; all other families reject those dependencies and every granular + entry rejects unrelated transform families. +- Static line, compact-scale line, and tooltip kernels also reject transform + modules. This makes root-export tree shaking a release gate rather than a + one-time observation. +- Exact universal baselines are refreshed only after the source audit. The + reviewed ceilings retain narrow headroom, and the full bundle policy remains + the release gate. + +### General transform boundary + +- General transforms are eager, pure row-to-row functions. They do not mutate + mark options, create a hidden transform graph, or own framework reactivity. +- Accessors and reducers use single object arguments. Group dimensions keep + their names; aggregations retain `source` and `sourceIndexes`; one-to-one + transforms extend flat input rows. +- `groupBy`, one- and two-dimensional numeric bins, calendar bins, `window`, + `cumulative`, `rank`, `normalize`, `select`, and row stacks cover the common + cross-row operations. Ordinary functions are the custom composition escape + hatch. There is no transform protocol or hidden reactive graph. +- Row stacks and mark stacks share `stackValues`. `stack()`/`group()` remain + mark-local layout; row transforms are for reusable or inspectable data. +- Root exports remain tree-shakeable, and every transform family also has a + granular `@tanstack/charts/transform/*` entry point. + +### Transform implementation audit + +- The complete transform layer is 1,726 source lines across the public value + and reducer contracts, shared grouping/reduction internals, and ten transform + families. Four new evidenced families replace repeated application logic; + the public protocol and nested-row adapters were removed rather than carried + beside the new model. +- Every transform family has a granular entry point. Numeric, two-dimensional, + and calendar binning are separate entries so specialized logic does not + enlarge ordinary histograms. Reducers and grouping mechanics remain shared. +- Field/accessor materialization, named-group identity, grouping, reducer + preparation, and source lineage are shared. Named reducer inputs are + evaluated once per source, avoiding a group-by-source rescan. Row stacks call + the same `stackValues` engine as mark layouts. The obsolete private D3-core + transform module and its tests are removed, deleting 402 duplicated lines. +- Group, normalize, select, rank, and stack are linear in source rows plus + emitted output. Numeric bins add threshold construction. Calendar bins bucket + rows in one pass; two-dimensional bins assign each row once before emitting + the cell matrix. Window and cumulative work remain proportional to the + observable reducer windows because custom reducers and exact lineage receive + every contributing row. +- Eight conformance consumers now use flat named transform results directly, + removing 60 net source lines and six direct D3 aggregation/rank imports. +- The common transform suite is 16.59 kB minified / 6.16 kB gzip. Individual + families range from 0.77 kB gzip (`rank`) to 2.89 kB (`binX`); calendar bins + are 1.30 kB, 2D bins 2.75 kB, and cumulative 1.03 kB. The full + advanced reducer set is 0.42 kB gzip and is absent unless imported. +- Keeping advanced reducers outside the shared string switch reduced a complete + histogram from the first-pass 18.75 kB gzip to 18.37 kB on the merged compact + core, below its 18.7 kB ceiling. The comparable direct-D3 histogram is 17.09 + kB. Retained-input checks prove transform code is absent from locked + non-transform consumers and that each granular entry keeps only its intended + family and shared internals. + +## 1. Focus presentation + +### Decision + +Model interaction presentation as ordinary marks filtered by one centralized +chart focus state. Do not create a separate renderer primitive for every +effect such as a point, band, rule, or active bar. + +Observable Plot's pointer render transform is the useful precedent: an +interactive transform filters an ordinary mark to the active datum. TanStack +should retain its stronger centralized focus resolution rather than letting +each interactive mark resolve the pointer independently. + +A representative API is: + +```ts +defineChart({ + focus: 'group-x', + marks: [ + whenFocused( + bandX(rows, { + x: 'date', + fill: '#94a3b8', + fillOpacity: 0.16, + inset: -6, + }), + { + match: 'x', + }, + ), + barY(rows, { + x: 'date', + y: 'value', + color: 'category', + }), + ], +}) +``` + +The implementation follows this contract: + +- one `ChartFocusState` drives tooltips and every focus-filtered mark; +- the state distinguishes the primary point, focused group, input source, and + pinned state; +- filters can match the primary point, focused group, stable point key, shared + x value, shared y value, or group; +- ordinary mark order controls whether an effect is before or after other + marks while axes retain their intended foreground placement; +- full mark data is available for channel and scale inference while the focus + filter controls the rows rendered in the transient state; +- the hardcoded focus circle becomes an implicit focus-filtered mark; +- custom renderers receive complete interaction state rather than + `paintFocus(point, points)`; +- SVG and Canvas must share the behavior without DOM mutation as the public + extension mechanism; +- pointer updates must not rebuild the complete chart scene or repaint + unrelated base geometry. + +### Related work to preserve in the design + +- Controlled or programmatic focus for linked charts, legends, and tables. +- Honest separation between semantic points, hit geometry, and presentation + geometry. +- Keyboard, pointer, pinned, restored, and programmatic focus parity. +- Deterministic composition, non-interactive effect nodes, and transient export + behavior. +- Facets and non-Cartesian coordinate systems must be supported by the general + transform contract; Cartesian helpers can remain conveniences. + +Specialized helpers such as `focusBandX` can be built later as compositions of +an ordinary mark and the focus filter. They should not define the core model. + +Existing marks use inline state styles. This is separate from `whenFocused`: +the former changes presentation on existing geometry, while the latter adds +transient geometry. + +```ts +dot(rows, { + x: 'date', + y: 'value', + r: 3, + states: [ + { + when: { focus: 'primary' }, + style: { + r: ({ datum }) => (datum.priority ? 9 : 7), + fill: ({ point }) => point.color, + }, + transition: { duration: 140, easing: 'ease-out' }, + }, + { + when: { focus: 'unmatched' }, + style: { opacity: 0.25 }, + }, + ], +}) +``` + +Callback values receive one object containing `datum`, `index`, `data`, +`point`, `focus`, `pointer`, and `matches`. Later matching states override +earlier properties. State styles cannot change data, channels, keys, layout, +or scale values. + +## 2. Axis and grid configuration + +### Decision + +Use the freedom to make breaking changes. Replace the current flat mixture of +scale behavior and guide presentation with a nested axis model. Keep grid +presentation independent from the axis. + +```ts +y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + line: true, + ticks: { count: 5, size: 0, padding: 4, format: formatCurrency }, + tickLabels: { rotate: 0 }, + label: { + text: 'Revenue', + offset: 'auto', + }, + }, +} +``` + +The automatic-to-explicit range is: + +```ts +// Inferred axis. +y: { scale: scaleLinear } + +// Inferred axis plus grid. +y: { scale: scaleLinear, grid: true } + +// Labels and grid without tick stubs. +y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { size: 0 } }, +} + +// Labels and grid without an axis baseline or tick stubs. +y: { + scale: scaleLinear, + grid: true, + axis: { + line: false, + ticks: { size: 0 }, + }, +} + +// Materialized scale without a visible axis. +y: { + scale: scaleLinear, + axis: false, +} +``` + +This replaces: + +- `guide` with `axis`; +- `ticks` with `axis.ticks.count`; +- `format` with `axis.ticks.format`; +- `tickRotate` with `axis.tickLabels.rotate`; +- `label` and `labelOffset` with `axis.label`; +- the current coupling between `guide: false` and `grid`. + +`axis.ticks.size: 0` should omit tick-stub nodes. Tick padding and automatic +guide margins must use the resolved tick size rather than preserving hidden +four-pixel geometry. + +Do not add explicit `axisX()` or `gridY()` guide marks based only on this +feedback. They introduce additional layout, duplication, positioning, and +facet semantics without current task evidence. + +## 3. Responsive tick labels + +### Decision + +Separate semantic tick generation from label layout. Length-aware scales and +axes choose candidate values first. Rotation and thinning then operate as +orthogonal label policies, with thinning enabled by default as the final +readability guarantee. + +```ts +x: { + scale: scaleBand, + axis: { + ticks: { + spacing: 80, + size: 0, + }, + tickLabels: { + rotate: -35, + thin: { + minGap: 8, + priority: 'ends', + keep: [launchDate], + }, + }, + }, +} +``` + +The resolution pipeline is: + +```text +available axis length +→ requested tick count +→ scale-generated candidate values +→ formatted and rotated label bounds +→ collision thinning +→ automatic guide margins +``` + +Candidate tick policies are mutually exclusive: + +```ts +ticks: { + spacing: 80 +} // length-aware count +ticks: { + count: 5 +} // explicit count hint +ticks: { + values: importantDates +} // exact candidates +``` + +The scale owns semantic candidates. A D3 scale may return a different number +than requested to preserve meaningful numeric or calendar intervals. Band +scales normally use their complete domain as the candidate set. + +Label policies are independent: + +```ts +tickLabels: {} // horizontal and automatically thinned +tickLabels: { rotate: -35 } // rotated and automatically thinned +tickLabels: { thin: false } // every horizontal label +tickLabels: { rotate: -35, thin: false } // every rotated label +tickLabels: false // no labels +``` + +Automatic rotation is not a default. Rotation changes reading direction and +chart height; authors opt into it. Thinning only prevents unreadable overlap +and remains enabled unless explicitly disabled. + +Thinning supports both soft priority and hard retention: + +```ts +tickLabels: { + thin: { + priority: 'ends', + keep: [launchDate, migrationDate], + }, +} +``` + +- `priority` influences the best collision-free subset. +- `keep` guarantees that exact labels render. +- Hard-kept labels are placed first and ordinary colliding labels are removed. +- If hard-kept labels collide with each other, both remain because the author + explicitly required them. +- Exact kept values are label-only by default; they do not implicitly add a + tick stub or gridline. + +For categorical x axes, first and last candidates receive soft priority by +default. They are not hard-kept when the available length cannot fit both. + +Gridlines and tick stubs use the scale-generated candidates before label +thinning. Hiding a label does not remove its stub or gridline. A shorter axis +may still produce fewer gridlines when its length-aware scale generates fewer +candidate ticks. + +Responsive scene layout iterates candidate resolution, label measurement, +thinning, and margins until stable. It uses a conservative result if a +threshold oscillates. Font loading and resize relayout may revise the visible +subset without restarting mark animation. + +## 4. Tooltip anchoring along an axis + +### Decision + +Allow each tooltip anchor coordinate to select its source independently. +Avoid combinatorial presets such as `group-top`, `pointer-top`, and +`value-bottom`. + +```ts +tooltip: { + anchor: { + x: 'value', + y: 'plot-top', + }, + placement: 'bottom', + offset: 12, +} +``` + +`x: 'value'` maps the primary focus point's semantic `xValue` through the +resolved x scale. For dodged bars, this locates the outer category center +rather than a subgroup center or an average that changes when a series is +missing. + +Coordinate sources are axis-specific: + +```ts +anchor: { + x: + | 'point' + | 'pointer' + | 'value' + | 'group-center' + | 'plot-left' + | 'plot-center' + | 'plot-right', + y: + | 'point' + | 'pointer' + | 'value' + | 'group-center' + | 'plot-top' + | 'plot-center' + | 'plot-bottom', +} +``` + +Existing whole-anchor shorthands such as `point`, `pointer`, and +`group-center` can expand to the corresponding x/y pair. + +The full callback remains available and receives complete interaction and +geometry context: + +```ts +anchor: (_points, { focus, pointer, plot, surface, scales }) => ({ + x: scales.x.map(focus.primary.xValue), + y: plot.y, +}) +``` + +Use `plot` for inner plotting bounds rather than the current misleading +`chart` context property. `surface` describes the complete rendered size. + +Fallbacks resolve per coordinate: + +- an unavailable pointer coordinate falls back to the primary point; +- a non-finite semantic value mapping falls back to the primary point; +- an empty group falls back to the primary point; +- an invalid custom coordinate falls back to the primary point. + +## 5. Stacked and grouped mark authoring + +### Decision + +Treat stacking and grouping as different geometric capabilities. Series +identity may be inferred from appearance after the geometry is known, but an +appearance channel must not select the geometry. + +For stackable interval marks, a single value channel represents a length and +is converted to endpoints by an implicit stack transform: + +```ts +barY(rows, { + x: 'date', + y: 'value', + color: 'category', +}) +``` + +Explicit endpoints opt out: + +```ts +barY(rows, { + x: 'date', + y1: 'start', + y2: 'end', + color: 'category', +}) +``` + +Stack behavior can be configured in the same layout slot used by grouping: + +```ts +barY(rows, { + x: 'date', + y: 'value', + color: 'category', + layout: stack({ + order: 'input', + offset: 'normalize', + }), +}) +``` + +The semantics follow Observable Plot's coherent distinction: `y` is a length, +while `y1` and `y2` are already resolved extents. `color` can infer series +identity, so it implies the default stack at repeated positions. `layout` is +optional unless stack order or offset is configured. + +Grouping remains an explicit geometric choice: + +```ts +barY(rows, { + x: 'date', + y: 'value', + color: 'category', + layout: group(), +}) +``` + +The fully explicit form supplies series identity independently: + +```ts +barY(rows, { + x: 'date', + y: 'value', + z: 'category', + color: 'category', + layout: group(), +}) +``` + +Series resolution obeys these rules: + +- explicit `z` wins; +- otherwise, a discrete color channel may infer series identity once the + geometry is known; +- a continuous color channel cannot infer series identity; +- color alone never switches a mark between stacked and grouped geometry; +- grouping without explicit or inferable series identity is a configuration + error. + +Therefore, supplying only `color` to a stackable interval mark produces +colored stacked intervals. Authors expecting side-by-side intervals must +request grouping explicitly. + +Do not expose one generic `layout` option on every mark. Capabilities follow +mark semantics: + +- bars support implicit length-to-extent stacking and explicit grouping; +- areas share the reusable stack transform; +- lines use positional values and do not stack by default, although a + discrete stroke or color may infer the separate paths required to render + series; +- dots, text, and rules overlap at repeated positions unless an explicit + displacement transform such as dodge or jitter is used; +- cells and heatmaps use aggregation or binning rather than stacking; +- arcs use a dedicated angular or pie transform. + +Computed geometry is part of the transform contract: + +- scale domains use computed extents; +- labels and custom renderers can access endpoints and midpoints; +- tooltips receive the original value and computed endpoints; +- focus, legends, and interaction use the resolved series identity; +- transformed points preserve their original datum; +- types expose only the capabilities supported by each mark. diff --git a/README.md b/README.md index 09c22cb7..82ddec29 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ const revenueChart = defineChart({ y: { scale: scaleLinear, nice: true, - label: 'Revenue', grid: true, + axis: { label: 'Revenue' }, }, tooltip, }) diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index bed6150d..e631ee44 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -21,15 +21,23 @@ parsed input list. This distinguishes code that contributes bytes from a zero-byte re-export. The compact-scale fixtures require their exact subpaths and reject sibling scale implementations. The React base rejects tooltip and portal code, the tooltip consumer rejects portal code, and the portal consumer -may add only its transport module over the tooltip consumer. +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 a 7 KiB gzip ceiling. The React line consumer has a 15 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`, +scene has an 8.1 KiB gzip ceiling. The React line consumer has a 16.8 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`. +Every public transform family has an isolated budget and retained-input +allowlist. Numeric and 2D bins may retain `d3-array`, and row stacks may retain +`d3-shape`. Other transform entries reject those dependencies, while every +granular entry rejects unrelated transform families. The suite fixture proves +the common families still compose without pulling calendar bins or advanced +reducers into the default set. + Non-cartesian capability subpaths follow the same policy. Polar and geographic entries measure complete scenes through static SVG: a minimal arc, D3 pie, full scale-backed gauge composition, a scale-backed polar line and scatter diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 5fa29eb1..67749703 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": 35111, - "gzip": 13764 + "bytes": 39086, + "gzip": 15178 }, "D3-scale line + static SVG": { - "bytes": 38163, - "gzip": 14887 + "bytes": 42027, + "gzip": 16267 }, "Representative marks": { - "bytes": 49068, - "gzip": 17979 + "bytes": 57688, + "gzip": 21392 }, "TanStack DOM host": { - "bytes": 31272, - "gzip": 11615 + "bytes": 39048, + "gzip": 14334 }, "React adapter": { - "bytes": 33442, - "gzip": 12314 + "bytes": 41216, + "gzip": 15037 }, "React line consumer": { - "bytes": 55812, - "gzip": 21281 + "bytes": 63725, + "gzip": 24054 }, "Compact-scale line scene": { - "bytes": 17541, - "gzip": 6711 + "bytes": 21523, + "gzip": 8112 }, "React compact-scale line consumer": { - "bytes": 38375, - "gzip": 14227 + "bytes": 46285, + "gzip": 16986 }, "Custom-scale line scene": { - "bytes": 15755, - "gzip": 5961 + "bytes": 19734, + "gzip": 7371 }, "D3 linear-scale line scene": { - "bytes": 35043, - "gzip": 13727 + "bytes": 39018, + "gzip": 15142 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a0238116..31d0e5fd 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 3, - "generatedAt": "2026-07-31T20:36:32.985Z", + "generatedAt": "2026-07-31T21:26:25.210Z", "packageVersions": { - "tanstack": "0.0.2", + "tanstack": "0.2.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,7 +11,7 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "2be6729b5e73afe553cb17b1c054f04dda00a614" + "revision": "e997c89bb84d1d468c12e58e44bd45c6d50fd5c3" }, "chartjs": { "kind": "package", @@ -44,88 +44,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 63594, - "gzipBytes": 24326, - "brotliBytes": 21671, - "incrementalGzipBytes": 24326, - "incrementalBrotliBytes": 21671 + "minifiedBytes": 72320, + "gzipBytes": 27357, + "brotliBytes": 24261, + "incrementalGzipBytes": 27357, + "incrementalBrotliBytes": 24261 }, "tanstack-line-interactive": { - "minifiedBytes": 68776, - "gzipBytes": 25969, - "brotliBytes": 23068, - "incrementalGzipBytes": 25969, - "incrementalBrotliBytes": 23068 + "minifiedBytes": 77498, + "gzipBytes": 29075, + "brotliBytes": 25671, + "incrementalGzipBytes": 29075, + "incrementalBrotliBytes": 25671 }, "tanstack-line-advanced": { - "minifiedBytes": 75961, - "gzipBytes": 28274, - "brotliBytes": 25010, - "incrementalGzipBytes": 28274, - "incrementalBrotliBytes": 25010 + "minifiedBytes": 84679, + "gzipBytes": 31380, + "brotliBytes": 27671, + "incrementalGzipBytes": 31380, + "incrementalBrotliBytes": 27671 }, "tanstack-bar-basic": { - "minifiedBytes": 66332, - "gzipBytes": 25290, - "brotliBytes": 22478, - "incrementalGzipBytes": 25290, - "incrementalBrotliBytes": 22478 + "minifiedBytes": 79327, + "gzipBytes": 30044, + "brotliBytes": 26537, + "incrementalGzipBytes": 30044, + "incrementalBrotliBytes": 26537 }, "tanstack-bar-interactive": { - "minifiedBytes": 70369, - "gzipBytes": 26537, - "brotliBytes": 23494, - "incrementalGzipBytes": 26537, - "incrementalBrotliBytes": 23494 + "minifiedBytes": 83364, + "gzipBytes": 31284, + "brotliBytes": 27529, + "incrementalGzipBytes": 31284, + "incrementalBrotliBytes": 27529 }, "tanstack-bar-advanced": { - "minifiedBytes": 71759, - "gzipBytes": 27018, - "brotliBytes": 23957, - "incrementalGzipBytes": 27018, - "incrementalBrotliBytes": 23957 + "minifiedBytes": 83703, + "gzipBytes": 31434, + "brotliBytes": 27623, + "incrementalGzipBytes": 31434, + "incrementalBrotliBytes": 27623 }, "tanstack-area-basic": { - "minifiedBytes": 63472, - "gzipBytes": 24300, - "brotliBytes": 21642, - "incrementalGzipBytes": 24300, - "incrementalBrotliBytes": 21642 + "minifiedBytes": 76134, + "gzipBytes": 28817, + "brotliBytes": 25536, + "incrementalGzipBytes": 28817, + "incrementalBrotliBytes": 25536 }, "tanstack-area-interactive": { - "minifiedBytes": 68658, - "gzipBytes": 25963, - "brotliBytes": 23099, - "incrementalGzipBytes": 25963, - "incrementalBrotliBytes": 23099 + "minifiedBytes": 81316, + "gzipBytes": 30565, + "brotliBytes": 26893, + "incrementalGzipBytes": 30565, + "incrementalBrotliBytes": 26893 }, "tanstack-area-advanced": { - "minifiedBytes": 77071, - "gzipBytes": 28733, - "brotliBytes": 25325, - "incrementalGzipBytes": 28733, - "incrementalBrotliBytes": 25325 + "minifiedBytes": 88679, + "gzipBytes": 32946, + "brotliBytes": 28983, + "incrementalGzipBytes": 32946, + "incrementalBrotliBytes": 28983 }, "tanstack-scatter-basic": { - "minifiedBytes": 63346, - "gzipBytes": 24249, - "brotliBytes": 21571, - "incrementalGzipBytes": 24249, - "incrementalBrotliBytes": 21571 + "minifiedBytes": 72025, + "gzipBytes": 27255, + "brotliBytes": 24145, + "incrementalGzipBytes": 27255, + "incrementalBrotliBytes": 24145 }, "tanstack-scatter-interactive": { - "minifiedBytes": 68528, - "gzipBytes": 25872, - "brotliBytes": 22974, - "incrementalGzipBytes": 25872, - "incrementalBrotliBytes": 22974 + "minifiedBytes": 77203, + "gzipBytes": 28999, + "brotliBytes": 25586, + "incrementalGzipBytes": 28999, + "incrementalBrotliBytes": 25586 }, "tanstack-scatter-advanced": { - "minifiedBytes": 68544, - "gzipBytes": 25877, - "brotliBytes": 22920, - "incrementalGzipBytes": 25877, - "incrementalBrotliBytes": 22920 + "minifiedBytes": 77219, + "gzipBytes": 29004, + "brotliBytes": 25583, + "incrementalGzipBytes": 29004, + "incrementalBrotliBytes": 25583 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/comparison/libraries/tanstack/area.ts b/benchmarks/comparison/libraries/tanstack/area.ts index e95ef14e..5c324a60 100644 --- a/benchmarks/comparison/libraries/tanstack/area.ts +++ b/benchmarks/comparison/libraries/tanstack/area.ts @@ -43,13 +43,13 @@ const definition = (input: BenchmarkInput) => ? [xMinimum(input), xMaximum(input)] : [0, Math.max(1, input.rows.length - 1)], ), - ticks: 6, grid: true, + axis: { ticks: { count: 6 } }, }, y: { scale: scaleLinear().domain([0, BENCHMARK_ADVANCED ? 200 : 100]), - ticks: 5, grid: true, + axis: { ticks: { count: 5 } }, }, color: BENCHMARK_INTERACTIVE ? { diff --git a/benchmarks/comparison/libraries/tanstack/bar.ts b/benchmarks/comparison/libraries/tanstack/bar.ts index 770fb230..7e0d3a54 100644 --- a/benchmarks/comparison/libraries/tanstack/bar.ts +++ b/benchmarks/comparison/libraries/tanstack/bar.ts @@ -38,8 +38,8 @@ const definition = (input: BenchmarkInput) => }, y: { scale: scaleLinear().domain([0, BENCHMARK_ADVANCED ? 200 : 100]), - ticks: 5, grid: true, + axis: { ticks: { count: 5 } }, }, color: BENCHMARK_INTERACTIVE ? { diff --git a/benchmarks/comparison/libraries/tanstack/base.ts b/benchmarks/comparison/libraries/tanstack/base.ts index 319f1130..dc342e3e 100644 --- a/benchmarks/comparison/libraries/tanstack/base.ts +++ b/benchmarks/comparison/libraries/tanstack/base.ts @@ -167,9 +167,11 @@ export function mountDefinition( typeof point.xValue === 'number' ? point.xValue : undefined, } } - const points = container.querySelectorAll( - 'svg circle[data-ts-key]:not([data-ts-chart-focus])', - ) + const points = [ + ...container.querySelectorAll( + 'svg circle[data-ts-key]', + ), + ].filter((element) => !element.closest('[data-ts-focus-layer]')) const point = points[ Math.round( diff --git a/benchmarks/comparison/libraries/tanstack/line.ts b/benchmarks/comparison/libraries/tanstack/line.ts index 18937229..b36459ce 100644 --- a/benchmarks/comparison/libraries/tanstack/line.ts +++ b/benchmarks/comparison/libraries/tanstack/line.ts @@ -38,13 +38,13 @@ const definition = (input: BenchmarkInput) => ? [xMinimum(input), xMaximum(input)] : [0, Math.max(1, input.rows.length - 1)], ), - ticks: 6, grid: true, + axis: { ticks: { count: 6 } }, }, y: { scale: scaleLinear().domain([0, 100]), - ticks: 5, grid: true, + axis: { ticks: { count: 5 } }, }, color: BENCHMARK_INTERACTIVE ? { diff --git a/benchmarks/comparison/libraries/tanstack/scatter.ts b/benchmarks/comparison/libraries/tanstack/scatter.ts index 6a281fbc..8296114f 100644 --- a/benchmarks/comparison/libraries/tanstack/scatter.ts +++ b/benchmarks/comparison/libraries/tanstack/scatter.ts @@ -30,13 +30,13 @@ const definition = (input: BenchmarkInput) => ? [xMinimum(input), xMaximum(input)] : [0, Math.max(1, input.rows.length - 1)], ), - ticks: 6, grid: true, + axis: { ticks: { count: 6 } }, }, y: { scale: scaleLinear().domain([0, 100]), - ticks: 5, grid: true, + axis: { ticks: { count: 5 } }, }, color: BENCHMARK_INTERACTIVE ? { diff --git a/benchmarks/conformance/README.md b/benchmarks/conformance/README.md index ed5f2009..cfc75802 100644 --- a/benchmarks/conformance/README.md +++ b/benchmarks/conformance/README.md @@ -16,7 +16,8 @@ Each case owns: - an agent creation task and maintenance task. The reference may use its built-in transforms. TanStack receives the same raw -rows and injects the granular D3 primitive when transformation is needed. +rows and uses TanStack's data transforms or a granular D3 primitive when +transformation is needed. Precomputing both sides would hide the bundle and authoring tradeoff being measured. @@ -309,7 +310,8 @@ reject 1/8 while TanStack Charts rejects 8/8. Keep adding Plot cases while they can be expressed as one of: 1. a recipe over existing marks; -2. granular D3 preparation supplied by the consumer; +2. a TanStack data transform or granular D3 preparation supplied by the + consumer; 3. a tree-shakeable optional mark with an isolated entry point. Every optional addition must receive its own bundle measurement. The locked diff --git a/benchmarks/conformance/cases/01-line-gaps/tanstack.ts b/benchmarks/conformance/cases/01-line-gaps/tanstack.ts index aa7b5dfc..1580a8a9 100644 --- a/benchmarks/conformance/cases/01-line-gaps/tanstack.ts +++ b/benchmarks/conformance/cases/01-line-gaps/tanstack.ts @@ -16,15 +16,8 @@ const definition = (input: ConformanceInput) => { strokeWidth: 2.25, }), ], - x: { - scale: scaleUtc, - label: 'Week', - }, - y: { - scale: scaleLinear, - label: 'Close (USD)', - grid: true, - }, + x: { scale: scaleUtc, axis: { label: 'Week' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Close (USD)' } }, }) } diff --git a/benchmarks/conformance/cases/02-multi-line-end-labels/tanstack.ts b/benchmarks/conformance/cases/02-multi-line-end-labels/tanstack.ts index b4ce39b9..c66ce8d4 100644 --- a/benchmarks/conformance/cases/02-multi-line-end-labels/tanstack.ts +++ b/benchmarks/conformance/cases/02-multi-line-end-labels/tanstack.ts @@ -31,14 +31,11 @@ const definition = (input: ConformanceInput) => { fontWeight: 600, }), ], - x: { - scale: scaleUtc, - label: 'Week', - }, + x: { scale: scaleUtc, axis: { label: 'Week' } }, y: { scale: scaleLinear, - label: 'Unemployed (thousands)', grid: true, + axis: { label: 'Unemployed (thousands)' }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts b/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts index b77bf275..00ff616d 100644 --- a/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts +++ b/benchmarks/conformance/cases/03-temperature-range-band/tanstack.ts @@ -27,15 +27,8 @@ const definition = (input: ConformanceInput) => { strokeWidth: 1.75, }), ], - x: { - scale: scaleUtc, - label: 'Week', - }, - y: { - scale: scaleLinear, - label: 'Temperature (°F)', - grid: true, - }, + x: { scale: scaleUtc, axis: { label: 'Week' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Temperature (°F)' } }, }) } diff --git a/benchmarks/conformance/cases/04-stacked-time-area/case.json b/benchmarks/conformance/cases/04-stacked-time-area/case.json index a591218f..d8f3e62e 100644 --- a/benchmarks/conformance/cases/04-stacked-time-area/case.json +++ b/benchmarks/conformance/cases/04-stacked-time-area/case.json @@ -4,15 +4,13 @@ "id": "04-stacked-time-area", "title": "Industry unemployment stacked area", "family": "composition", - "intent": "Stack every industry in the unemployment dataset over a shared UTC axis. Plot applies stackY to the original rows; TanStack Charts exposes its d3-shape interval adaptation beside the chart.", + "intent": "Stack every industry in the unemployment dataset over a shared UTC axis from the original observations.", "support": "composed", "features": [ "area", "stack", "multi-series", - "tidy-to-wide", - "d3-array-group", - "d3-shape-stack", + "native stack layout", "fixture:industry-unemployment" ], "geometry": [ @@ -24,7 +22,7 @@ "url": "https://observablehq.com/plot/transforms/stack" }, "ai": { - "create": "Create a responsive stacked UTC area chart from the industries dataset's original date, industry, and unemployed fields. Plot may apply stackY directly; TanStack Charts adapts granular d3-shape output into explicit y1/y2 channels while retaining each source row.", - "maintain": "Convert the chart to a normalized 100% stack while keeping both implementations on the same imported industry observations and the transform visible beside the TanStack chart." + "create": "Create a responsive stacked UTC area chart from the industries dataset's original date, industry, and unemployed fields using each library's native stack semantics.", + "maintain": "Convert the chart to a normalized 100% stack while keeping both implementations on the same imported industry observations." } } diff --git a/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts b/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts index 528f409a..07b7e5a9 100644 --- a/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts +++ b/benchmarks/conformance/cases/04-stacked-time-area/tanstack.ts @@ -1,9 +1,6 @@ import { areaY, colorLegend, defineChart, ruleY } from '@tanstack/charts' -import { group } from 'd3-array' import { scaleLinear, scaleUtc } from 'd3-scale' -import { stack } from 'd3-shape' import { industries } from '@charts-poc/demo-data/industries' -import type { IndustriesRow } from '@charts-poc/demo-data/industries' import type { ConformanceInput, ConformanceMount } from '../../types' import { tanstackMount } from '../../shared/mount' @@ -20,45 +17,28 @@ const colors = [ '#bab0ab', ] -interface WideTimePoint { - date: Date - byIndustry: ReadonlyMap -} - -interface StackedIndustryPoint extends IndustriesRow { - y1: number - y2: number -} - -const definition = (_input: ConformanceInput) => { - const rows = stackRows(industries) - - return defineChart({ +const definition = (_input: ConformanceInput) => + defineChart({ marks: [ - areaY(rows, { + areaY(industries, { x: 'date', - y1: 'y1', - y2: 'y2', + y: 'unemployed', color: 'industry', fillOpacity: 0.78, }), ruleY([0]), ], - x: { - scale: scaleUtc, - label: 'Month', - }, + x: { scale: scaleUtc, axis: { label: 'Month' } }, y: { scale: scaleLinear, - label: 'Unemployed (thousands)', grid: true, + axis: { label: 'Unemployed (thousands)' }, }, color: { range: colors, legend: colorLegend({ label: 'Industry' }), }, }) -} export const mount: ConformanceMount = tanstackMount( definition, @@ -72,32 +52,3 @@ export const mount: ConformanceMount = tanstackMount( })} · ${datum.unemployed.toLocaleString('en-US')} thousand unemployed`, }, ) - -function stackRows( - rows: readonly IndustriesRow[], -): readonly StackedIndustryPoint[] { - const industryNames = Array.from(new Set(rows.map((row) => row.industry))) - const wideRows = Array.from( - group(rows, (row) => row.date.getTime()).values(), - toWideRow, - ) - - return stack() - .keys(industryNames) - .value((row, industry) => row.byIndustry.get(industry)?.unemployed ?? 0)( - wideRows, - ) - .flatMap((series) => - series.flatMap((point): readonly StackedIndustryPoint[] => { - const source = point.data.byIndustry.get(series.key) - return source ? [{ ...source, y1: point[0], y2: point[1] }] : [] - }), - ) -} - -function toWideRow(rows: IndustriesRow[]): WideTimePoint { - return { - date: rows[0]?.date ?? new Date(0), - byIndustry: new Map(rows.map((row) => [row.industry, row] as const)), - } -} diff --git a/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts b/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts index 765ee6f6..28d425bd 100644 --- a/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts +++ b/benchmarks/conformance/cases/13-interval-timeline/tanstack.ts @@ -25,14 +25,10 @@ const definition = (input: ConformanceInput) => { radius: 3, }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Share price ($)', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Share price ($)' } }, y: { scale: () => scaleBand().paddingInner(0.16), - format: (value) => date.format(value), + axis: { ticks: { format: (value) => date.format(value) } }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/14-error-bars/tanstack.ts b/benchmarks/conformance/cases/14-error-bars/tanstack.ts index a3347ac8..1aac84cd 100644 --- a/benchmarks/conformance/cases/14-error-bars/tanstack.ts +++ b/benchmarks/conformance/cases/14-error-bars/tanstack.ts @@ -43,11 +43,7 @@ const definition = (input: ConformanceInput) => { x: { scale: () => scaleBand().padding(0.22), }, - y: { - scale: scaleLinear, - grid: true, - label: 'Body mass (g)', - }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Body mass (g)' } }, }) } diff --git a/benchmarks/conformance/cases/15-boxplot/tanstack.ts b/benchmarks/conformance/cases/15-boxplot/tanstack.ts index 5b171f52..d41ebe8a 100644 --- a/benchmarks/conformance/cases/15-boxplot/tanstack.ts +++ b/benchmarks/conformance/cases/15-boxplot/tanstack.ts @@ -54,7 +54,7 @@ const definition = () => y: { scale: scaleLinear, grid: true, - label: 'Speed of light (km/s minus 299,000)', + axis: { label: 'Speed of light (km/s minus 299,000)' }, }, }) diff --git a/benchmarks/conformance/cases/16-lollipop/tanstack.ts b/benchmarks/conformance/cases/16-lollipop/tanstack.ts index 6fa8a008..e3ca1db4 100644 --- a/benchmarks/conformance/cases/16-lollipop/tanstack.ts +++ b/benchmarks/conformance/cases/16-lollipop/tanstack.ts @@ -33,8 +33,10 @@ const definition = (_input: ConformanceInput) => { y: { scale: scaleLinear, grid: true, - label: 'Frequency', - format: (value) => percent.format(value), + axis: { + ticks: { format: (value) => percent.format(value) }, + label: 'Frequency', + }, }, }) } diff --git a/benchmarks/conformance/cases/17-dumbbell/tanstack.ts b/benchmarks/conformance/cases/17-dumbbell/tanstack.ts index 04f7f881..41575d4d 100644 --- a/benchmarks/conformance/cases/17-dumbbell/tanstack.ts +++ b/benchmarks/conformance/cases/17-dumbbell/tanstack.ts @@ -32,7 +32,7 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLinear, grid: true, - label: '90th/10th percentile wage ratio', + axis: { label: '90th/10th percentile wage ratio' }, }, y: { scale: () => scaleBand().padding(0.22), diff --git a/benchmarks/conformance/cases/18-cumulative-histogram/tanstack.ts b/benchmarks/conformance/cases/18-cumulative-histogram/tanstack.ts index ba392cd9..a63eab8c 100644 --- a/benchmarks/conformance/cases/18-cumulative-histogram/tanstack.ts +++ b/benchmarks/conformance/cases/18-cumulative-histogram/tanstack.ts @@ -1,66 +1,40 @@ import { olympians } from '@charts-poc/demo-data/olympians' -import { defineChart, rect } from '@tanstack/charts' -import { bin, thresholdScott } from 'd3-array' +import { binX, cumulative, defineChart, rect } from '@tanstack/charts' +import { thresholdScott } from 'd3-array' import { scaleLinear } from 'd3-scale' import { tanstackMount } from '../../shared/mount' import type { OlympiansRow } from '@charts-poc/demo-data/olympians' import type { ConformanceInput } from '../../types' -interface CumulativeBin { - id: string - x0: number - x1: number - count: number -} - type OlympianWithWeight = OlympiansRow & { weight: number } const completeOlympians = olympians.filter( (row): row is OlympianWithWeight => row.weight !== null, ) -const createBins = bin() - .value((row) => row.weight) - .thresholds(thresholdScott) - const definition = (input: ConformanceInput) => { - let cumulative = 0 - const bins: CumulativeBin[] = createBins( - completeOlympians.slice(input.revision * 8), - ).flatMap((bucket, index) => { - cumulative += bucket.length - return bucket.x0 === undefined || bucket.x1 === undefined - ? [] - : [ - { - id: `bin:${index}`, - x0: bucket.x0, - x1: bucket.x1, - count: cumulative, - }, - ] + const bins = binX(completeOlympians.slice(input.revision * 8), { + value: 'weight', + thresholds: thresholdScott, + outputs: { count: { reduce: 'count' } }, + }) + const cumulativeBins = cumulative(bins, { + orderBy: 'x1', + outputs: { cumulativeCount: { value: 'count', reduce: 'sum' } }, }) return defineChart({ marks: [ - rect(bins, { - x1: 'x0', - x2: 'x1', + rect(cumulativeBins, { + x1: 'x1', + x2: 'x2', y1: () => 0, - y2: 'count', + y2: 'cumulativeCount', fill: '#2563eb', inset: 1, }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Weight (kg)', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Cumulative count', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (kg)' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Cumulative count' } }, }) } diff --git a/benchmarks/conformance/cases/19-moving-average-line/case.json b/benchmarks/conformance/cases/19-moving-average-line/case.json index 7b2ed4a3..a33574fa 100644 --- a/benchmarks/conformance/cases/19-moving-average-line/case.json +++ b/benchmarks/conformance/cases/19-moving-average-line/case.json @@ -12,7 +12,7 @@ "window transform", "temperature", "two-series", - "d3-array-mean" + "TanStack window" ], "geometry": [ { "role": "line", "count": 2 }, diff --git a/benchmarks/conformance/cases/19-moving-average-line/tanstack.ts b/benchmarks/conformance/cases/19-moving-average-line/tanstack.ts index 6486751d..bbdec8ce 100644 --- a/benchmarks/conformance/cases/19-moving-average-line/tanstack.ts +++ b/benchmarks/conformance/cases/19-moving-average-line/tanstack.ts @@ -1,15 +1,20 @@ -import { defineChart, lineY, ruleY } from '@tanstack/charts' -import { mean } from 'd3-array' +import { defineChart, lineY, ruleY, window } from '@tanstack/charts' import { scaleLinear, scaleUtc } from 'd3-scale' import { sfTemperatures } from '@charts-poc/demo-data/sf-temperatures' -import type { SfTemperaturesRow } from '@charts-poc/demo-data/sf-temperatures' import { tanstackMount } from '../../shared/mount' import type { ConformanceInput, ConformanceMount } from '../../types' const windowSize = 14 const definition = (_input: ConformanceInput) => { - const rows = trailingMeans(sfTemperatures) + const rows = window(sfTemperatures, { + size: windowSize, + partial: false, + outputs: { + high: { value: 'high', reduce: 'mean' }, + low: { value: 'low', reduce: 'mean' }, + }, + }) return defineChart({ marks: [ @@ -30,14 +35,11 @@ const definition = (_input: ConformanceInput) => { strokeDasharray: '4 4', }), ], - x: { - scale: scaleUtc, - label: 'Date', - }, + x: { scale: scaleUtc, axis: { label: 'Date' } }, y: { scale: scaleLinear, grid: true, - label: 'Fourteen-day average temperature (°F)', + axis: { label: 'Fourteen-day average temperature (°F)' }, }, }) } @@ -46,21 +48,3 @@ export const mount: ConformanceMount = tanstackMount( definition, 'Fourteen-day average high and low temperature in San Francisco', ) - -function trailingMeans( - rows: readonly SfTemperaturesRow[], -): readonly SfTemperaturesRow[] { - const output: SfTemperaturesRow[] = [] - - for (let index = windowSize - 1; index < rows.length; index++) { - const row = rows[index] - if (!row) continue - const window = rows.slice(index - windowSize + 1, index + 1) - const high = mean(window, (point) => point.high) - const low = mean(window, (point) => point.low) - if (high === undefined || low === undefined) continue - output.push({ date: row.date, high, low }) - } - - return output -} diff --git a/benchmarks/conformance/cases/20-normalized-stacked-area/case.json b/benchmarks/conformance/cases/20-normalized-stacked-area/case.json index 337da97a..4fd2d098 100644 --- a/benchmarks/conformance/cases/20-normalized-stacked-area/case.json +++ b/benchmarks/conformance/cases/20-normalized-stacked-area/case.json @@ -10,8 +10,7 @@ "area", "normalized stack", "100 percent composition", - "d3-shape-stack", - "d3-shape-stackOffsetExpand", + "native stack layout", "percent axis" ], "geometry": [ @@ -23,7 +22,7 @@ "url": "https://observablehq.com/plot/transforms/stack" }, "ai": { - "create": "Create a responsive normalized UTC area chart from the industries dataset's original date, industry, and unemployed fields, with Plot's native normalize offset and explicit granular d3-shape intervals for TanStack Charts.", + "create": "Create a responsive normalized UTC area chart from the industries dataset's original date, industry, and unemployed fields using each library's native normalize offset.", "maintain": "Change the industry order while keeping each month normalized to 100%, colors stable, the percent guide accurate, and the imported rows untouched." } } diff --git a/benchmarks/conformance/cases/20-normalized-stacked-area/tanstack.ts b/benchmarks/conformance/cases/20-normalized-stacked-area/tanstack.ts index 4590ed14..25b22193 100644 --- a/benchmarks/conformance/cases/20-normalized-stacked-area/tanstack.ts +++ b/benchmarks/conformance/cases/20-normalized-stacked-area/tanstack.ts @@ -1,23 +1,10 @@ -import { areaY, colorLegend, defineChart, ruleY } from '@tanstack/charts' +import { areaY, colorLegend, defineChart, ruleY, stack } from '@tanstack/charts' import { format } from 'd3-format' -import { group } from 'd3-array' import { scaleLinear, scaleUtc } from 'd3-scale' -import { stack, stackOffsetExpand } from 'd3-shape' import { industries } from '@charts-poc/demo-data/industries' -import type { IndustriesRow } from '@charts-poc/demo-data/industries' import { tanstackMount } from '../../shared/mount' import type { ConformanceInput, ConformanceMount } from '../../types' -interface WideTimePoint { - date: Date - byIndustry: ReadonlyMap -} - -interface NormalizedIndustryPoint extends IndustriesRow { - y1: number - y2: number -} - const percent = format('.0%') const colors = [ '#4e79a7', @@ -32,66 +19,31 @@ const colors = [ '#bab0ab', ] -const definition = (_input: ConformanceInput) => { - const rows = normalizedIntervals(industries) - - return defineChart({ +const definition = (_input: ConformanceInput) => + defineChart({ marks: [ - areaY(rows, { + areaY(industries, { x: 'date', - y1: 'y1', - y2: 'y2', + y: 'unemployed', color: 'industry', fillOpacity: 0.82, + layout: stack({ offset: 'normalize' }), }), ruleY([0]), ], - x: { - scale: scaleUtc, - label: 'Month', - }, + x: { scale: scaleUtc, axis: { label: 'Month' } }, y: { scale: scaleLinear().domain([0, 1]), grid: true, - label: 'Share of unemployment', - format: percent, + axis: { ticks: { format: percent }, label: 'Share of unemployment' }, }, color: { range: colors, legend: colorLegend({ label: 'Industry' }), }, }) -} export const mount: ConformanceMount = tanstackMount( definition, 'Industry share of unemployment', ) - -function normalizedIntervals( - rows: readonly IndustriesRow[], -): readonly NormalizedIndustryPoint[] { - const industryNames = Array.from(new Set(rows.map((row) => row.industry))) - const wideRows = Array.from( - group(rows, (row) => row.date.getTime()).values(), - toWideRow, - ) - - return stack() - .keys(industryNames) - .value((row, industry) => row.byIndustry.get(industry)?.unemployed ?? 0) - .offset(stackOffsetExpand)(wideRows) - .flatMap((series) => - series.flatMap((point): readonly NormalizedIndustryPoint[] => { - const source = point.data.byIndustry.get(series.key) - return source ? [{ ...source, y1: point[0], y2: point[1] }] : [] - }), - ) -} - -function toWideRow(rows: IndustriesRow[]): WideTimePoint { - return { - date: rows[0]?.date ?? new Date(0), - byIndustry: new Map(rows.map((row) => [row.industry, row] as const)), - } -} diff --git a/benchmarks/conformance/cases/21-streamgraph/tanstack.ts b/benchmarks/conformance/cases/21-streamgraph/tanstack.ts index 20842801..b7bf7a12 100644 --- a/benchmarks/conformance/cases/21-streamgraph/tanstack.ts +++ b/benchmarks/conformance/cases/21-streamgraph/tanstack.ts @@ -43,14 +43,11 @@ const definition = (_input: ConformanceInput) => { fillOpacity: 0.85, }), ], - x: { - scale: scaleUtc, - label: 'Month', - }, + x: { scale: scaleUtc, axis: { label: 'Month' } }, y: { scale: scaleLinear, grid: true, - label: 'Unemployed (thousands)', + axis: { label: 'Unemployed (thousands)' }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/22-bollinger-band/tanstack.ts b/benchmarks/conformance/cases/22-bollinger-band/tanstack.ts index ee7567b0..4d34e305 100644 --- a/benchmarks/conformance/cases/22-bollinger-band/tanstack.ts +++ b/benchmarks/conformance/cases/22-bollinger-band/tanstack.ts @@ -1,5 +1,4 @@ -import { areaY, defineChart, lineY } from '@tanstack/charts' -import { deviation, mean } from 'd3-array' +import { areaY, defineChart, deviation, lineY, window } from '@tanstack/charts' import { scaleLinear, scaleUtc } from 'd3-scale' import { aapl } from '@charts-poc/demo-data/aapl' import type { AaplRow } from '@charts-poc/demo-data/aapl' @@ -35,15 +34,8 @@ const definition = (input: ConformanceInput) => { strokeWidth: 2.25, }), ], - x: { - scale: scaleUtc, - label: 'Date', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Apple close (USD)', - }, + x: { scale: scaleUtc, axis: { label: 'Date' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Apple close (USD)' } }, }) } @@ -55,23 +47,20 @@ export const mount: ConformanceMount = tanstackMount( function bollingerIntervals( rows: readonly AaplRow[], ): readonly BollingerPoint[] { - const output: BollingerPoint[] = [] - - for (let index = windowSize - 1; index < rows.length; index++) { - const row = rows[index] - if (!row) continue - const window = rows.slice(index - windowSize + 1, index + 1) - const meanClose = mean(window, (point) => point.Close) - if (meanClose === undefined) continue - const spread = - (deviation(window, (point) => point.Close) ?? 0) * deviationMultiplier - output.push({ + return window(rows, { + size: windowSize, + orderBy: 'Date', + partial: false, + outputs: { + meanClose: { value: 'Close', reduce: 'mean' }, + closeDeviation: { value: 'Close', reduce: deviation }, + }, + }).map(({ closeDeviation, ...row }) => { + const spread = closeDeviation * deviationMultiplier + return { ...row, - meanClose, - lowerClose: meanClose - spread, - upperClose: meanClose + spread, - }) - } - - return output + lowerClose: row.meanClose - spread, + upperClose: row.meanClose + spread, + } + }) } diff --git a/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts b/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts index 7984abfb..90d5b695 100644 --- a/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts +++ b/benchmarks/conformance/cases/24-quantitative-binned-heatmap/tanstack.ts @@ -1,6 +1,5 @@ import { penguins } from '@charts-poc/demo-data/penguins' -import { colorGradientLegend, defineChart, rect } from '@tanstack/charts' -import { bin } from 'd3-array' +import { binXY, colorGradientLegend, defineChart, rect } from '@tanstack/charts' import { scaleLinear, scaleSequential } from 'd3-scale' import { tanstackMount } from '../../shared/mount' import type { PenguinsRow } from '@charts-poc/demo-data/penguins' @@ -11,53 +10,22 @@ type PenguinBill = PenguinsRow & { readonly culmen_depth_mm: number } -interface QuantitativeHeatCell { - id: string - x1: number - x2: number - y1: number - y2: number - count: number -} - const xBoundaries = [30, 34, 38, 42, 46, 50, 54, 58, 62] const yBoundaries = [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] -const createXBins = bin() - .value((row) => row.culmen_length_mm) - .domain([30, 62]) - .thresholds(xBoundaries.slice(1, -1)) -const createYBins = bin() - .value((row) => row.culmen_depth_mm) - .domain([12, 23]) - .thresholds(yBoundaries.slice(1, -1)) - const definition = (input: ConformanceInput) => { - const cells: QuantitativeHeatCell[] = [] const rows = penguins .filter((row): row is PenguinBill => { return row.culmen_length_mm !== null && row.culmen_depth_mm !== null }) .slice(input.revision * 8, input.revision * 8 + 320) - for (const xBucket of createXBins(rows)) { - if (xBucket.x0 === undefined || xBucket.x1 === undefined) continue - for (const yBucket of createYBins(xBucket)) { - if ( - yBucket.x0 === undefined || - yBucket.x1 === undefined || - yBucket.length === 0 - ) - continue - cells.push({ - id: `${xBucket.x0}:${yBucket.x0}`, - x1: xBucket.x0, - x2: xBucket.x1, - y1: yBucket.x0, - y2: yBucket.x1, - count: yBucket.length, - }) - } - } + const cells = binXY(rows, { + x: 'culmen_length_mm', + y: 'culmen_depth_mm', + xThresholds: xBoundaries, + yThresholds: yBoundaries, + outputs: { count: { reduce: 'count' } }, + }).filter((cell) => cell.count > 0) return defineChart({ marks: [ @@ -73,12 +41,12 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLinear().domain([30, 62]), grid: true, - label: 'Bill length (mm)', + axis: { label: 'Bill length (mm)' }, }, y: { scale: scaleLinear().domain([12, 23]), grid: true, - label: 'Bill depth (mm)', + axis: { label: 'Bill depth (mm)' }, }, color: { scale: scaleSequential, diff --git a/benchmarks/conformance/cases/25-calendar-heatmap/tanstack.ts b/benchmarks/conformance/cases/25-calendar-heatmap/tanstack.ts index 36099964..37c0fb58 100644 --- a/benchmarks/conformance/cases/25-calendar-heatmap/tanstack.ts +++ b/benchmarks/conformance/cases/25-calendar-heatmap/tanstack.ts @@ -27,8 +27,7 @@ const definition = (input: ConformanceInput) => { ], x: { scale: () => scaleBand().paddingInner(0.06).paddingOuter(0.03), - label: 'Week', - format: (value) => `W${value + 1}`, + axis: { ticks: { format: (value) => `W${value + 1}` }, label: 'Week' }, }, y: { scale: scaleBand() diff --git a/benchmarks/conformance/cases/26-diverging-likert/tanstack.ts b/benchmarks/conformance/cases/26-diverging-likert/tanstack.ts index 7bc23bc0..31b31f3c 100644 --- a/benchmarks/conformance/cases/26-diverging-likert/tanstack.ts +++ b/benchmarks/conformance/cases/26-diverging-likert/tanstack.ts @@ -26,8 +26,10 @@ const definition = () => x: { scale: scaleLinear, grid: true, - label: '← more disagree · Number of responses · more agree →', - format: (value) => `${Math.abs(value)}`, + axis: { + ticks: { format: (value) => `${Math.abs(value)}` }, + label: '← more disagree · Number of responses · more agree →', + }, }, y: { scale: () => scaleBand().paddingInner(0.14).paddingOuter(0.08), diff --git a/benchmarks/conformance/cases/27-parallel-coordinates/tanstack.ts b/benchmarks/conformance/cases/27-parallel-coordinates/tanstack.ts index 89d53436..2bfa89e3 100644 --- a/benchmarks/conformance/cases/27-parallel-coordinates/tanstack.ts +++ b/benchmarks/conformance/cases/27-parallel-coordinates/tanstack.ts @@ -40,7 +40,7 @@ const definition = () => y: { scale: scaleLinear().domain([0, 100]), grid: true, - label: 'Relative performance within sample', + axis: { label: 'Relative performance within sample' }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/28-candlestick/tanstack.ts b/benchmarks/conformance/cases/28-candlestick/tanstack.ts index df1418c1..337a1183 100644 --- a/benchmarks/conformance/cases/28-candlestick/tanstack.ts +++ b/benchmarks/conformance/cases/28-candlestick/tanstack.ts @@ -48,11 +48,7 @@ const definition = (input: ConformanceInput) => { }), ], x: { scale: scaleUtc }, - y: { - scale: scaleLinear, - grid: true, - label: 'Price', - }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Price' } }, }) } diff --git a/benchmarks/conformance/cases/29-waterfall/tanstack.ts b/benchmarks/conformance/cases/29-waterfall/tanstack.ts index 7cc0941f..328316fa 100644 --- a/benchmarks/conformance/cases/29-waterfall/tanstack.ts +++ b/benchmarks/conformance/cases/29-waterfall/tanstack.ts @@ -45,12 +45,12 @@ const definition = () => ], x: { scale: () => scaleBand().padding(0.14), - tickRotate: width < 560 ? -32 : 0, + axis: { tickLabels: { rotate: width < 560 ? -32 : 0 } }, }, y: { scale: scaleLinear, grid: true, - label: 'Change in gasoline price (USD per gallon)', + axis: { label: 'Change in gasoline price (USD per gallon)' }, }, color: { domain: kinds, diff --git a/benchmarks/conformance/cases/30-slopegraph/tanstack.ts b/benchmarks/conformance/cases/30-slopegraph/tanstack.ts index e8027760..3c55d45c 100644 --- a/benchmarks/conformance/cases/30-slopegraph/tanstack.ts +++ b/benchmarks/conformance/cases/30-slopegraph/tanstack.ts @@ -49,7 +49,7 @@ const definition = (input: ConformanceInput) => { y: { scale: scaleLinear, grid: true, - label: '90th/10th percentile wage ratio', + axis: { label: '90th/10th percentile wage ratio' }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/31-linear-regression/tanstack.ts b/benchmarks/conformance/cases/31-linear-regression/tanstack.ts index 232a92fd..5e456ce9 100644 --- a/benchmarks/conformance/cases/31-linear-regression/tanstack.ts +++ b/benchmarks/conformance/cases/31-linear-regression/tanstack.ts @@ -67,15 +67,11 @@ const definition = (input: ConformanceInput) => { strokeWidth: 2, }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Power (hp)', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Power (hp)' } }, y: { scale: scaleLinear, grid: true, - label: 'Fuel economy (mpg)', + axis: { label: 'Fuel economy (mpg)' }, }, }) } diff --git a/benchmarks/conformance/cases/32-change-arrows/tanstack.ts b/benchmarks/conformance/cases/32-change-arrows/tanstack.ts index 55044122..63be8c29 100644 --- a/benchmarks/conformance/cases/32-change-arrows/tanstack.ts +++ b/benchmarks/conformance/cases/32-change-arrows/tanstack.ts @@ -36,15 +36,11 @@ const definition = () => { headLength: 8, }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Log₁₀ population', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Log₁₀ population' } }, y: { scale: scaleLinear, grid: true, - label: '90th-to-10th-percentile wage ratio', + axis: { label: '90th-to-10th-percentile wage ratio' }, }, }) } diff --git a/benchmarks/conformance/cases/33-difference-chart/tanstack.ts b/benchmarks/conformance/cases/33-difference-chart/tanstack.ts index 8b2d721e..577596b6 100644 --- a/benchmarks/conformance/cases/33-difference-chart/tanstack.ts +++ b/benchmarks/conformance/cases/33-difference-chart/tanstack.ts @@ -45,14 +45,9 @@ const definition = (input: ConformanceInput) => { ], x: { scale: scaleUtc, - ticks: 9, - format: formatDifferenceMonth, - }, - y: { - scale: scaleLinear, - ticks: 6, - grid: true, + axis: { ticks: { count: 9, format: formatDifferenceMonth } }, }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, color: { domain: ['positive', 'negative'], range: ['#16a34a', '#dc2626'], diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/case.json b/benchmarks/conformance/cases/34-pointer-tooltip/case.json index b93d0a35..55c83cc1 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/case.json +++ b/benchmarks/conformance/cases/34-pointer-tooltip/case.json @@ -4,12 +4,13 @@ "id": "34-pointer-tooltip", "title": "Pointer-selected tooltip", "family": "interaction", - "intent": "Reveal an Apple closing-price observation on pointer proximity while keeping the chart keyboard-focusable and the original AAPL fields intact.", + "intent": "Emphasize and reveal an Apple closing-price observation on pointer proximity while keeping the chart keyboard-focusable and the original AAPL fields intact.", "support": "native", "features": [ "pointer interaction", "tooltip", "nearest point", + "focus-filtered dot", "typed formatter", "keyboard focus" ], @@ -32,6 +33,7 @@ { "path": "tooltip.text", "includes": "Apple" } ] }, + { "type": "screenshot", "name": "focused-dot-open" }, { "type": "pointerLeave" }, { "type": "assert", diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts index 3ccb530c..44633e50 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/34-pointer-tooltip/tanstack.ts @@ -25,14 +25,21 @@ const definition = (input: ConformanceInput) => { y: 'Close', fill: '#2563eb', r: 3, + states: [ + { + when: { focus: 'primary' }, + style: { + r: 7, + stroke: 'Canvas', + strokeWidth: 2, + }, + transition: { duration: 140, easing: 'ease-out' }, + }, + ], }), ], x: { scale: scaleUtc }, - y: { - scale: scaleLinear, - grid: true, - label: 'Apple close (USD)', - }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Apple close (USD)' } }, }) } diff --git a/benchmarks/conformance/cases/35-grouped-tooltip/case.json b/benchmarks/conformance/cases/35-grouped-tooltip/case.json index 6d905014..fd3ada59 100644 --- a/benchmarks/conformance/cases/35-grouped-tooltip/case.json +++ b/benchmarks/conformance/cases/35-grouped-tooltip/case.json @@ -4,10 +4,11 @@ "id": "35-grouped-tooltip", "title": "Grouped X tooltip", "family": "interaction", - "intent": "Compare unemployment in manufacturing, construction, and finance at the nearest shared month in one pointer interaction.", + "intent": "Highlight and compare unemployment in manufacturing, construction, and finance at the nearest shared month in one pointer interaction.", "support": "native", "features": [ "grouped tooltip", + "focus-filtered date band", "nearest x focus", "multi-series line", "automatic series swatches", diff --git a/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts b/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts index f029087f..b5e6bde3 100644 --- a/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/35-grouped-tooltip/tanstack.ts @@ -1,4 +1,11 @@ -import { defineChart, dot, lineY, mountChart } from '@tanstack/charts' +import { + bandX, + defineChart, + dot, + lineY, + mountChart, + whenFocused, +} from '@tanstack/charts' import type { ChartHostOptions } from '@tanstack/charts' import { tooltip } from '@tanstack/charts/tooltip' import { portal } from '@tanstack/charts/tooltip/portal' @@ -16,8 +23,19 @@ const colors = ['#2563eb', '#f97316', '#10b981'] const definition = (input: ConformanceInput) => { const rows = selectGroupedTooltipData(industries, input.revision) + const dates = rows.filter((row) => row.industry === industryNames[0]) return defineChart({ marks: [ + whenFocused( + bandX(dates, { + x: 'date', + fill: '#64748b', + fillOpacity: 0.14, + inset: 3, + radius: 4, + }), + { match: 'x' }, + ), lineY(rows, { x: 'date', y: 'unemployed', @@ -28,13 +46,24 @@ const definition = (input: ConformanceInput) => { y: 'unemployed', color: 'industry', r: 2.5, + states: [ + { + when: { focus: 'group' }, + style: { r: 5, stroke: 'Canvas', strokeWidth: 1.5 }, + transition: { duration: 140, easing: 'ease-out' }, + }, + { + when: { focus: 'unmatched' }, + style: { opacity: 0.3 }, + }, + ], }), ], x: { scale: scaleUtc }, y: { scale: scaleLinear, grid: true, - label: 'Unemployed (thousands)', + axis: { label: 'Unemployed (thousands)' }, }, color: { domain: industryNames, diff --git a/benchmarks/conformance/cases/37-delaunay-network/tanstack.ts b/benchmarks/conformance/cases/37-delaunay-network/tanstack.ts index 7155a51c..b5176de1 100644 --- a/benchmarks/conformance/cases/37-delaunay-network/tanstack.ts +++ b/benchmarks/conformance/cases/37-delaunay-network/tanstack.ts @@ -85,15 +85,11 @@ const definition = (input: ConformanceInput) => { r: 4, }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Weight (lb)', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (lb)' } }, y: { scale: scaleLinear, grid: true, - label: 'Fuel economy (mpg)', + axis: { label: 'Fuel economy (mpg)' }, }, }) } diff --git a/benchmarks/conformance/cases/42-vector-field/tanstack.ts b/benchmarks/conformance/cases/42-vector-field/tanstack.ts index 7be52f55..e05eb2bc 100644 --- a/benchmarks/conformance/cases/42-vector-field/tanstack.ts +++ b/benchmarks/conformance/cases/42-vector-field/tanstack.ts @@ -18,16 +18,8 @@ const definition = () => stroke: '#2563eb', }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Longitude', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Latitude', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Longitude' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Latitude' } }, }) export const mount = tanstackMount(definition, 'Two-dimensional vector field') diff --git a/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts b/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts index 2b1ab299..58682658 100644 --- a/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts +++ b/benchmarks/conformance/cases/43-hexbin-density/tanstack.ts @@ -64,12 +64,12 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLinear().domain([1500, 5500]), grid: true, - label: 'Weight (lb)', + axis: { label: 'Weight (lb)' }, }, y: { scale: scaleLinear().domain([5, 50]), grid: true, - label: 'Fuel economy (mpg)', + axis: { label: 'Fuel economy (mpg)' }, }, color: { scale: scaleThreshold, diff --git a/benchmarks/conformance/cases/50-empirical-cdf/tanstack.ts b/benchmarks/conformance/cases/50-empirical-cdf/tanstack.ts index 062b953d..71ea687f 100644 --- a/benchmarks/conformance/cases/50-empirical-cdf/tanstack.ts +++ b/benchmarks/conformance/cases/50-empirical-cdf/tanstack.ts @@ -1,6 +1,5 @@ import { cars } from '@charts-poc/demo-data/cars' -import { d3Curve, defineChart, lineY } from '@tanstack/charts' -import { rank } from 'd3-array' +import { d3Curve, defineChart, lineY, rank } from '@tanstack/charts' import { scaleLinear } from 'd3-scale' import { curveStepAfter } from 'd3-shape' import { tanstackMount } from '../../shared/mount' @@ -9,10 +8,6 @@ import type { ConformanceInput } from '../../types' type CarWithEconomy = CarsRow & { 'economy (mpg)': number } -type EmpiricalPoint = CarWithEconomy & { - probability: number -} - const completeCars = cars.filter( (row): row is CarWithEconomy => row['economy (mpg)'] !== null, ) @@ -26,10 +21,10 @@ const definition = (input: ConformanceInput) => { const source = completeCars .slice(input.revision * 8) .sort((left, right) => left['economy (mpg)'] - right['economy (mpg)']) - const ranks = rank(source.map((row) => row['economy (mpg)'])) - const rows: readonly EmpiricalPoint[] = source.map((row, index) => ({ + const ranked = rank(source, { value: 'economy (mpg)', order: 'ascending' }) + const rows = ranked.map((row) => ({ ...row, - probability: ((ranks[index] ?? 0) + 1) / source.length, + probability: row.rank / source.length, })) return defineChart({ @@ -45,13 +40,15 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLinear, grid: true, - label: 'Fuel economy (mpg)', + axis: { label: 'Fuel economy (mpg)' }, }, y: { scale: scaleLinear().domain([0, 1]), grid: true, - label: 'Cumulative proportion', - format: (value) => percent.format(value), + axis: { + ticks: { format: (value) => percent.format(value) }, + label: 'Cumulative proportion', + }, }, }) } diff --git a/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts b/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts index c3129e1e..f9a517e3 100644 --- a/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts +++ b/benchmarks/conformance/cases/51-faceted-distributions/tanstack.ts @@ -88,14 +88,15 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLinear().domain([2500, 6500]), grid: true, - label: 'Body mass (g)', + axis: { label: 'Body mass (g)' }, }, y: { scale: scaleLinear().domain([0, 0.4]), grid: true, - ticks: 3, - label: 'Proportion', - format: (value) => percent.format(value), + axis: { + ticks: { count: 3, format: (value) => percent.format(value) }, + label: 'Proportion', + }, }, }), }), diff --git a/benchmarks/conformance/cases/53-log-scale-scatter/tanstack.ts b/benchmarks/conformance/cases/53-log-scale-scatter/tanstack.ts index 91cd420f..d632e6f3 100644 --- a/benchmarks/conformance/cases/53-log-scale-scatter/tanstack.ts +++ b/benchmarks/conformance/cases/53-log-scale-scatter/tanstack.ts @@ -32,13 +32,9 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLog().domain([200, 30_000]), grid: true, - label: 'Class size', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Hierarchy depth', + axis: { label: 'Class size' }, }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Hierarchy depth' } }, }) } diff --git a/benchmarks/conformance/cases/54-bump-ranking/tanstack.ts b/benchmarks/conformance/cases/54-bump-ranking/tanstack.ts index c497da41..d356b624 100644 --- a/benchmarks/conformance/cases/54-bump-ranking/tanstack.ts +++ b/benchmarks/conformance/cases/54-bump-ranking/tanstack.ts @@ -1,15 +1,18 @@ -import { d3Curve, defineChart, dot, lineY, text } from '@tanstack/charts' -import { group, rank } from 'd3-array' +import { + d3Curve, + defineChart, + dot, + lineY, + rank, + select, + text, +} from '@tanstack/charts' import { scaleLinear, scaleUtc } from 'd3-scale' import { curveBumpX } from 'd3-shape' import { industries } from '@charts-poc/demo-data/industries' import type { IndustriesRow } from '@charts-poc/demo-data/industries' import { tanstackMount } from '../../shared/mount' -interface RankedIndustry extends IndustriesRow { - rank: number -} - const colors = ['#2563eb', '#ea580c', '#059669', '#7c3aed', '#db2777'] const includedIndustries = [ 'Wholesale and Retail Trade', @@ -27,8 +30,13 @@ const observations = industries.filter( ) const definition = () => { - const rows = rankWithinDate(observations) - const labels = lastByIndustry(rows) + const rows = rank(observations, { + by: 'date', + value: 'unemployed', + order: 'descending', + ties: 'competition', + }) + const labels = select(rows, { by: 'industry', select: 'last' }) return defineChart({ marks: [ @@ -56,16 +64,18 @@ const definition = () => { ], x: { scale: scaleUtc, - ticks: 7, - format: (date) => `${date.getUTCFullYear()}`, - label: 'Year', + axis: { + ticks: { count: 7, format: (date) => `${date.getUTCFullYear()}` }, + label: 'Year', + }, }, y: { scale: scaleLinear().domain([5.2, 0.8]), - ticks: 5, - format: (value) => `#${value}`, grid: true, - label: 'Rank', + axis: { + ticks: { count: 5, format: (value) => `#${value}` }, + label: 'Rank', + }, }, color: { domain: includedIndustries, @@ -78,29 +88,3 @@ export const mount = tanstackMount( definition, 'Annual unemployment rank by industry', ) - -function rankWithinDate( - rows: readonly IndustriesRow[], -): readonly RankedIndustry[] { - const output: RankedIndustry[] = [] - - for (const dateRows of group(rows, (row) => row.date.getTime()).values()) { - const ranks = rank(dateRows, (row: IndustriesRow) => -row.unemployed) - - dateRows.forEach((row, index) => { - const rowRank = ranks[index] - if (rowRank === undefined || !Number.isFinite(rowRank)) return - output.push({ ...row, rank: rowRank + 1 }) - }) - } - - return output -} - -function lastByIndustry( - rows: readonly RankedIndustry[], -): readonly RankedIndustry[] { - return Array.from(group(rows, (row) => row.industry).values()) - .map((industryRows) => industryRows.at(-1)) - .filter((row): row is RankedIndustry => row !== undefined) -} diff --git a/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts b/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts index 7fc8c5b8..2d635e49 100644 --- a/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts +++ b/benchmarks/conformance/cases/55-indexed-multi-line/tanstack.ts @@ -45,15 +45,14 @@ const definition = () => { dx: 5, }), ], - x: { - scale: scaleUtc, - label: 'Month', - }, + x: { scale: scaleUtc, axis: { label: 'Month' } }, y: { scale: scaleLinear, grid: true, - format: formatIndex, - label: 'Change from January 2008', + axis: { + ticks: { format: formatIndex }, + label: 'Change from January 2008', + }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/56-connected-scatter/tanstack.ts b/benchmarks/conformance/cases/56-connected-scatter/tanstack.ts index 28905457..27f27d46 100644 --- a/benchmarks/conformance/cases/56-connected-scatter/tanstack.ts +++ b/benchmarks/conformance/cases/56-connected-scatter/tanstack.ts @@ -44,12 +44,12 @@ const definition = () => x: { scale: scaleLinear, grid: true, - label: 'Miles driven per person', + axis: { label: 'Miles driven per person' }, }, y: { scale: scaleLinear, grid: true, - label: 'Cost of gasoline ($ per gallon)', + axis: { label: 'Cost of gasoline ($ per gallon)' }, }, }) diff --git a/benchmarks/conformance/cases/57-scatter-marginal-histograms/tanstack.ts b/benchmarks/conformance/cases/57-scatter-marginal-histograms/tanstack.ts index 3ecdcd1e..308cc8c9 100644 --- a/benchmarks/conformance/cases/57-scatter-marginal-histograms/tanstack.ts +++ b/benchmarks/conformance/cases/57-scatter-marginal-histograms/tanstack.ts @@ -69,14 +69,15 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLinear().domain([170, 245]), grid: true, - format: visibleFlipperTick, - label: 'Flipper length (mm)', + axis: { + ticks: { format: visibleFlipperTick }, + label: 'Flipper length (mm)', + }, }, y: { scale: scaleLinear().domain([2500, 7000]), grid: true, - format: visibleMassTick, - label: 'Body mass (g)', + axis: { ticks: { format: visibleMassTick }, label: 'Body mass (g)' }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/58-select-extrema/case.json b/benchmarks/conformance/cases/58-select-extrema/case.json index 4eca951e..8ec7fba0 100644 --- a/benchmarks/conformance/cases/58-select-extrema/case.json +++ b/benchmarks/conformance/cases/58-select-extrema/case.json @@ -11,8 +11,7 @@ "minimum selection", "maximum selection", "direct annotations", - "d3-array least", - "d3-array greatest" + "TanStack select" ], "geometry": [ { "role": "line", "count": 1 }, diff --git a/benchmarks/conformance/cases/58-select-extrema/tanstack.ts b/benchmarks/conformance/cases/58-select-extrema/tanstack.ts index 1d9f93a2..eb0e71ea 100644 --- a/benchmarks/conformance/cases/58-select-extrema/tanstack.ts +++ b/benchmarks/conformance/cases/58-select-extrema/tanstack.ts @@ -1,5 +1,4 @@ -import { defineChart, dot, lineY, text } from '@tanstack/charts' -import { greatest, least } from 'd3-array' +import { defineChart, dot, lineY, select, text } from '@tanstack/charts' import { scaleLinear, scaleUtc } from 'd3-scale' import { aapl } from '@charts-poc/demo-data/aapl' import type { AaplRow } from '@charts-poc/demo-data/aapl' @@ -41,15 +40,8 @@ const definition = () => { dy: (point) => point.dy, }), ], - x: { - scale: scaleUtc, - label: 'Date', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Apple close (USD)', - }, + x: { scale: scaleUtc, axis: { label: 'Date' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Apple close (USD)' } }, }) } @@ -61,8 +53,8 @@ export const mount = tanstackMount( function selectExtrema( rows: readonly AaplRow[], ): readonly ExtremumAnnotation[] { - const minimum = least(rows, (point) => point.Close) - const maximum = greatest(rows, (point) => point.Close) + const minimum = select(rows, { value: 'Close', select: 'min' })[0] + const maximum = select(rows, { value: 'Close', select: 'max' })[0] const annotations: ExtremumAnnotation[] = [] if (minimum) { diff --git a/benchmarks/conformance/cases/59-grouped-reducer-bars/case.json b/benchmarks/conformance/cases/59-grouped-reducer-bars/case.json index d4adda8e..320b16d1 100644 --- a/benchmarks/conformance/cases/59-grouped-reducer-bars/case.json +++ b/benchmarks/conformance/cases/59-grouped-reducer-bars/case.json @@ -12,7 +12,7 @@ "mean reducer", "raw tidy rows", "direct value labels", - "d3-array rollups" + "TanStack groupBy" ], "geometry": [ { "role": "bar", "count": 3 }, diff --git a/benchmarks/conformance/cases/59-grouped-reducer-bars/tanstack.ts b/benchmarks/conformance/cases/59-grouped-reducer-bars/tanstack.ts index 8eb27c27..343aec6b 100644 --- a/benchmarks/conformance/cases/59-grouped-reducer-bars/tanstack.ts +++ b/benchmarks/conformance/cases/59-grouped-reducer-bars/tanstack.ts @@ -1,5 +1,4 @@ -import { barY, defineChart, text } from '@tanstack/charts' -import { mean, rollups } from 'd3-array' +import { barY, defineChart, groupBy, text } from '@tanstack/charts' import { scaleBand, scaleLinear } from 'd3-scale' import { penguins } from '@charts-poc/demo-data/penguins' import type { PenguinsRow } from '@charts-poc/demo-data/penguins' @@ -7,11 +6,6 @@ import { tanstackMount } from '../../shared/mount' type PenguinWithMass = PenguinsRow & { body_mass_g: number } -interface SpeciesMean { - species: string - meanBodyMass: number -} - const observations = penguins.filter( (row): row is PenguinWithMass => row.body_mass_g !== null, ) @@ -19,7 +13,12 @@ const formatMass = (value: number) => value.toLocaleString('en-US', { maximumFractionDigits: 3 }) const definition = () => { - const rows = summarize(observations) + const rows = groupBy(observations, { + by: 'species', + outputs: { + meanBodyMass: { value: 'body_mass_g', reduce: 'mean' }, + }, + }) return defineChart({ marks: [ @@ -43,7 +42,7 @@ const definition = () => { y: { scale: scaleLinear, grid: true, - label: 'Mean body mass (g)', + axis: { label: 'Mean body mass (g)' }, }, }) } @@ -52,13 +51,3 @@ export const mount = tanstackMount( definition, 'Mean penguin body mass by species', ) - -function summarize(rows: readonly PenguinWithMass[]): readonly SpeciesMean[] { - return rollups( - rows, - (values) => mean(values, (row) => row.body_mass_g), - (row) => row.species, - ).flatMap(([species, meanBodyMass]) => - meanBodyMass === undefined ? [] : [{ species, meanBodyMass }], - ) -} diff --git a/benchmarks/conformance/cases/60-lag-autocorrelation/tanstack.ts b/benchmarks/conformance/cases/60-lag-autocorrelation/tanstack.ts index 4fa4be49..507f6e73 100644 --- a/benchmarks/conformance/cases/60-lag-autocorrelation/tanstack.ts +++ b/benchmarks/conformance/cases/60-lag-autocorrelation/tanstack.ts @@ -1,5 +1,4 @@ -import { defineChart, dot, lineY } from '@tanstack/charts' -import { pairs } from 'd3-array' +import { defineChart, dot, first, lineY, window } from '@tanstack/charts' import { scaleLinear } from 'd3-scale' import { aapl } from '@charts-poc/demo-data/aapl' import type { AaplRow } from '@charts-poc/demo-data/aapl' @@ -43,12 +42,12 @@ const definition = () => { x: { scale: scaleLinear().domain(closeDomain), grid: true, - label: 'Previous close (USD)', + axis: { label: 'Previous close (USD)' }, }, y: { scale: scaleLinear().domain(closeDomain), grid: true, - label: 'Current close (USD)', + axis: { label: 'Current close (USD)' }, }, }) } @@ -59,8 +58,10 @@ export const mount = tanstackMount( ) function lagPairs(rows: readonly AaplRow[]): readonly LagPoint[] { - return pairs(rows, (previous, current) => ({ - ...current, - PreviousClose: previous.Close, - })) + return window(rows, { + size: 2, + orderBy: 'Date', + partial: false, + outputs: { PreviousClose: { value: 'Close', reduce: first } }, + }) } diff --git a/benchmarks/conformance/cases/61-quantile-ribbon/tanstack.ts b/benchmarks/conformance/cases/61-quantile-ribbon/tanstack.ts index e5938819..99e5420a 100644 --- a/benchmarks/conformance/cases/61-quantile-ribbon/tanstack.ts +++ b/benchmarks/conformance/cases/61-quantile-ribbon/tanstack.ts @@ -31,14 +31,11 @@ const definition = () => { strokeWidth: 2.25, }), ], - x: { - scale: scaleUtc, - label: 'Month', - }, + x: { scale: scaleUtc, axis: { label: 'Month' } }, y: { scale: scaleLinear, grid: true, - label: 'Unemployed people by industry (thousands)', + axis: { label: 'Unemployed people by industry (thousands)' }, }, }) } diff --git a/benchmarks/conformance/cases/62-ridgeline-density/tanstack.ts b/benchmarks/conformance/cases/62-ridgeline-density/tanstack.ts index 6a0325a5..bb91653a 100644 --- a/benchmarks/conformance/cases/62-ridgeline-density/tanstack.ts +++ b/benchmarks/conformance/cases/62-ridgeline-density/tanstack.ts @@ -37,15 +37,19 @@ const definition = (input: ConformanceInput) => { ], x: { scale: scaleLinear().domain([4, 10]), - label: 'IMDb rating', grid: true, + axis: { label: 'IMDb rating' }, }, y: { scale: scaleLinear().domain([-0.08, 2.86]), - ticks: seasons.length, - format: (value) => { - const season = seasons[Math.round(value)] - return season === undefined ? '' : `Season ${season}` + axis: { + ticks: { + count: seasons.length, + format: (value) => { + const season = seasons[Math.round(value)] + return season === undefined ? '' : `Season ${season}` + }, + }, }, }, color: { diff --git a/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts b/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts index a64aba95..cf0e8d26 100644 --- a/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts +++ b/benchmarks/conformance/cases/63-violin-distributions/tanstack.ts @@ -49,14 +49,14 @@ const definition = (input: ConformanceInput) => { ], x: { scale: scaleLinear().domain([0.5, 3.5]), - ticks: violinSpecies.length, - format: (value) => violinSpecies[Math.round(value) - 1] ?? '', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Body mass (g)', + axis: { + ticks: { + count: violinSpecies.length, + format: (value) => violinSpecies[Math.round(value) - 1] ?? '', + }, + }, }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Body mass (g)' } }, color: { range: colors, }, diff --git a/benchmarks/conformance/cases/64-marimekko-mosaic/tanstack.ts b/benchmarks/conformance/cases/64-marimekko-mosaic/tanstack.ts index f23f1151..0f88e4f5 100644 --- a/benchmarks/conformance/cases/64-marimekko-mosaic/tanstack.ts +++ b/benchmarks/conformance/cases/64-marimekko-mosaic/tanstack.ts @@ -31,13 +31,11 @@ const definition = () => { ], x: { scale: scaleLinear().domain([0, 1]), - format: percent, - label: 'Share of responses', + axis: { ticks: { format: percent }, label: 'Share of responses' }, }, y: { scale: scaleLinear().domain([0, 1.12]), - format: percent, - label: 'Within-question share', + axis: { ticks: { format: percent }, label: 'Within-question share' }, }, color: { domain: mosaicResponses, diff --git a/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts b/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts index 8d7c2014..46d8b059 100644 --- a/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/tanstack.ts @@ -98,15 +98,11 @@ const definition = (rows: readonly CompleteCar[]) => r: 4, }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Weight (lb)', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (lb)' } }, y: { scale: scaleLinear, grid: true, - label: 'Fuel economy (mpg)', + axis: { label: 'Fuel economy (mpg)' }, }, color: { range: colors, diff --git a/benchmarks/conformance/cases/70-composed-chart/tanstack.ts b/benchmarks/conformance/cases/70-composed-chart/tanstack.ts index 769b521b..9786991f 100644 --- a/benchmarks/conformance/cases/70-composed-chart/tanstack.ts +++ b/benchmarks/conformance/cases/70-composed-chart/tanstack.ts @@ -52,13 +52,11 @@ const definition = (input: ConformanceInput) => ], x: { scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), - format: (value) => dateFormat.format(value), - }, - y: { - scale: scaleLinear, - ticks: 5, - grid: true, + axis: { + ticks: { format: (value: Date) => dateFormat.format(value) }, + }, }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 5 } } }, margin: { top: 20, right: 20, bottom: 50, left: 80 }, } }) diff --git a/benchmarks/conformance/cases/71-recharts-population-pyramid/tanstack.ts b/benchmarks/conformance/cases/71-recharts-population-pyramid/tanstack.ts index 4ff9131f..d16dee4f 100644 --- a/benchmarks/conformance/cases/71-recharts-population-pyramid/tanstack.ts +++ b/benchmarks/conformance/cases/71-recharts-population-pyramid/tanstack.ts @@ -26,10 +26,14 @@ const definition = (input: ConformanceInput) => { ], x: { scale: scaleLinear().domain([-80, 80]), - ticks: 5, - format: (value) => Math.abs(value).toLocaleString('en-US'), - label: 'Penguins observed', grid: true, + axis: { + ticks: { + count: 5, + format: (value) => Math.abs(value).toLocaleString('en-US'), + }, + label: 'Penguins observed', + }, }, y: { scale: () => scaleBand().paddingInner(0.02).paddingOuter(0.01), diff --git a/benchmarks/conformance/cases/72-recharts-mixed-bars/tanstack.ts b/benchmarks/conformance/cases/72-recharts-mixed-bars/tanstack.ts index adcc3cbd..1666b257 100644 --- a/benchmarks/conformance/cases/72-recharts-mixed-bars/tanstack.ts +++ b/benchmarks/conformance/cases/72-recharts-mixed-bars/tanstack.ts @@ -1,5 +1,5 @@ import { weather } from '@charts-poc/demo-data/weather' -import { barY, defineChart } from '@tanstack/charts' +import { barY, defineChart, group } from '@tanstack/charts' import { scaleBand, scaleLinear } from 'd3-scale' import { tanstackMount } from '../../shared/mount' import type { ConformanceInput } from '../../types' @@ -27,7 +27,7 @@ const definition = (input: ConformanceInput) => { y2: 'precipitation', z: () => 'stack', fill: '#8884d8', - groupScale, + layout: group({ scale: groupScale }), inset: 1, }), barY(rows, { @@ -36,7 +36,7 @@ const definition = (input: ConformanceInput) => { y2: (row) => row.precipitation + row.wind, z: () => 'stack', fill: '#82ca9d', - groupScale, + layout: group({ scale: groupScale }), inset: 1, }), barY(rows, { @@ -44,19 +44,15 @@ const definition = (input: ConformanceInput) => { y: 'temp_max', z: () => 'independent', fill: '#ffc658', - groupScale, + layout: group({ scale: groupScale }), inset: 1, }), ], x: { scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), - format: (value) => dateFormat.format(value), - }, - y: { - scale: scaleLinear, - ticks: 5, - grid: true, + axis: { ticks: { format: (value) => dateFormat.format(value) } }, }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 5 } } }, margin: { top: 20, right: 20, bottom: 50, left: 80 }, }) } diff --git a/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts b/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts index 753c3852..e729a4bc 100644 --- a/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts +++ b/benchmarks/conformance/cases/73-many-point-scatter/tanstack.ts @@ -23,16 +23,8 @@ const definition = (input: ConformanceInput) => { fillOpacity: 0.72, }), ], - x: { - scale: scaleLinear, - ticks: 6, - grid: true, - }, - y: { - scale: scaleLinear, - ticks: 6, - grid: true, - }, + x: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, color: { range: colors, }, diff --git a/benchmarks/conformance/cases/80-echarts-axis-pointer/tanstack.ts b/benchmarks/conformance/cases/80-echarts-axis-pointer/tanstack.ts index 0c1c291a..0e251530 100644 --- a/benchmarks/conformance/cases/80-echarts-axis-pointer/tanstack.ts +++ b/benchmarks/conformance/cases/80-echarts-axis-pointer/tanstack.ts @@ -49,17 +49,20 @@ const definition = (input: ConformanceInput) => { ], x: { scale: scaleUtc, - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - timeZone: 'UTC', - }), + axis: { + ticks: { + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + timeZone: 'UTC', + }), + }, + }, }, y: { scale: scaleLinear, - ticks: 5, grid: true, - label: 'Unemployed (thousands)', + axis: { ticks: { count: 5 }, label: 'Unemployed (thousands)' }, }, color: { domain: axisPointerIndustries, diff --git a/benchmarks/conformance/cases/81-recharts-interactive-legend/tanstack.ts b/benchmarks/conformance/cases/81-recharts-interactive-legend/tanstack.ts index c93b1194..146d3832 100644 --- a/benchmarks/conformance/cases/81-recharts-interactive-legend/tanstack.ts +++ b/benchmarks/conformance/cases/81-recharts-interactive-legend/tanstack.ts @@ -54,17 +54,20 @@ const definition = (input: InteractiveLegendInput) => { ], x: { scale: scaleUtc, - format: (date) => - date.toLocaleDateString('en-US', { - month: 'short', - timeZone: 'UTC', - }), + axis: { + ticks: { + format: (date) => + date.toLocaleDateString('en-US', { + month: 'short', + timeZone: 'UTC', + }), + }, + }, }, y: { scale: scaleLinear().domain(yDomain), - ticks: 5, grid: true, - label: 'Unemployed (thousands)', + axis: { ticks: { count: 5 }, label: 'Unemployed (thousands)' }, }, color: { domain: legendSeries.map((series) => series.id), diff --git a/benchmarks/conformance/cases/82-chart-table-selection/tanstack.ts b/benchmarks/conformance/cases/82-chart-table-selection/tanstack.ts index 0e820656..db6e644b 100644 --- a/benchmarks/conformance/cases/82-chart-table-selection/tanstack.ts +++ b/benchmarks/conformance/cases/82-chart-table-selection/tanstack.ts @@ -48,15 +48,11 @@ const definition = (input: ChartTableInput) => { ] : []), ], - x: { - scale: scaleLinear, - label: 'Flipper length (mm)', - }, + x: { scale: scaleLinear, axis: { label: 'Flipper length (mm)' } }, y: { scale: scaleLinear, - ticks: 5, grid: true, - label: 'Body mass (g)', + axis: { ticks: { count: 5 }, label: 'Body mass (g)' }, }, margin: { top: 16, right: 24, bottom: 42, left: 62 }, }) diff --git a/benchmarks/conformance/cases/83-focus-context-window/tanstack.ts b/benchmarks/conformance/cases/83-focus-context-window/tanstack.ts index dbd1e96a..086a42a1 100644 --- a/benchmarks/conformance/cases/83-focus-context-window/tanstack.ts +++ b/benchmarks/conformance/cases/83-focus-context-window/tanstack.ts @@ -63,13 +63,9 @@ const detailDefinition = (input: DetailInput) => { ], x: { scale: scaleUtc().domain([input.window.start, input.window.end]), - label: 'Selected time window', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Close ($)', + axis: { label: 'Selected time window' }, }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Close ($)' } }, margin: detailMargin, }) } @@ -87,17 +83,18 @@ const overviewDefinition = (input: ConformanceInput) => { ], x: { scale: scaleUtc().domain(fullDomain), - ticks: 4, - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - timeZone: 'UTC', - }), - }, - y: { - scale: scaleLinear, - guide: false, + axis: { + ticks: { + count: 4, + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + timeZone: 'UTC', + }), + }, + }, }, + y: { scale: scaleLinear, axis: false }, margin: overviewMargin, }) } diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/tanstack.ts b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/tanstack.ts index 1b321692..fc070d75 100644 --- a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/tanstack.ts +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/tanstack.ts @@ -58,13 +58,12 @@ const mainDefinition = (input: MainChartInput) => { ], x: { scale: scaleLinear().domain([170, 235]), - label: 'Flipper length (mm)', + axis: { label: 'Flipper length (mm)' }, }, y: { scale: scaleLinear().domain([3000, 6000]), - ticks: 5, grid: true, - label: 'Body mass (g)', + axis: { ticks: { count: 5 }, label: 'Body mass (g)' }, }, margin: { top: 18, right: 24, bottom: 42, left: 68 }, }) @@ -83,10 +82,7 @@ const miniDefinition = (input: MiniChartInput) => x: { scale: () => scaleBand().paddingInner(0.18).paddingOuter(0.08), }, - y: { - scale: scaleLinear, - guide: false, - }, + y: { scale: scaleLinear, axis: false }, margin: { top: 6, right: 6, bottom: 24, left: 6 }, }) diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts index ecd77f0b..0f76c1f3 100644 --- a/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/tanstack.ts @@ -64,7 +64,7 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleUtc().domain(resourceTimelineDomain), grid: true, - ticks: Math.max(6, Math.floor(width / 84)), + axis: { ticks: { count: Math.max(6, Math.floor(width / 84)) } }, }, y: { scale: scaleBand() @@ -72,7 +72,7 @@ const definition = (input: ConformanceInput) => { .paddingInner(0.08) .paddingOuter(0.04), grid: false, - guide: false, + axis: false, }, color: { domain: timelineStatuses, diff --git a/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts b/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts index 4134e960..b181542d 100644 --- a/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts +++ b/benchmarks/conformance/cases/86-streaming-window-preservation/tanstack.ts @@ -61,24 +61,27 @@ const definition = (input: StreamingChartInput) => { ], x: { scale: scaleUtc().domain(input.viewport), - label: - input.viewportMode === 'locked' - ? 'Locked viewport' - : input.viewportMode === 'latest' - ? 'Following latest' - : 'All samples', - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), + axis: { + ticks: { + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + }, + label: + input.viewportMode === 'locked' + ? 'Locked viewport' + : input.viewportMode === 'latest' + ? 'Following latest' + : 'All samples', + }, }, y: { scale: scaleLinear, - ticks: 5, grid: true, - label: 'Downloads', + axis: { ticks: { count: 5 }, label: 'Downloads' }, }, margin: { top: 18, right: 24, bottom: 44, left: 58 }, }) diff --git a/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.ts b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.ts index 1a695e26..c2a0af41 100644 --- a/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.ts +++ b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/tanstack.ts @@ -74,18 +74,23 @@ const definition = (input: SynchronizedViewInput) => { ], x: { scale: scaleUtc, - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - timeZone: 'UTC', - }), + axis: { + ticks: { + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + timeZone: 'UTC', + }), + }, + }, }, y: { scale: scaleLinear().domain(synchronizedCursorYDomains[input.view]), - ticks: 4, grid: true, - label: input.view === 'current' ? '2020 travelers' : '2019 travelers', - format: formatTravelers, + axis: { + ticks: { count: 4, format: formatTravelers }, + label: input.view === 'current' ? '2020 travelers' : '2019 travelers', + }, }, margin: { top: 16, diff --git a/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.ts b/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.ts index bc750970..d99ecbfb 100644 --- a/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.ts +++ b/benchmarks/conformance/cases/88-echarts-free-cursor/tanstack.ts @@ -66,15 +66,11 @@ const definition = (input: ConformanceInput) => { strokeWidth: 1, }), ], - x: { - scale: configuredXScale, - label: 'Horsepower', - }, + x: { scale: configuredXScale, axis: { label: 'Horsepower' } }, y: { scale: configuredYScale, - ticks: 7, grid: true, - label: 'Fuel economy (mpg)', + axis: { ticks: { count: 7 }, label: 'Fuel economy (mpg)' }, }, margin: { top: 22, diff --git a/benchmarks/conformance/cases/89-brush-range-selection/tanstack.ts b/benchmarks/conformance/cases/89-brush-range-selection/tanstack.ts index 3ec2d45e..d5b3e9d8 100644 --- a/benchmarks/conformance/cases/89-brush-range-selection/tanstack.ts +++ b/benchmarks/conformance/cases/89-brush-range-selection/tanstack.ts @@ -68,14 +68,15 @@ const definition = (input: ConformanceInput) => { ], x: { scale: brushScale, - format: (value) => brushMonthFormatter.format(value), - label: 'Month', + axis: { + ticks: { format: (value) => brushMonthFormatter.format(value) }, + label: 'Month', + }, }, y: { scale: scaleLinear, - ticks: 4, grid: true, - label: 'AAPL close ($)', + axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, }, margin: { top: 52, right: 24, bottom: 44, left: 58 }, }) diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts index 3049ea11..cf4e08b5 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts +++ b/benchmarks/conformance/cases/90-zoomable-time-window/tanstack.ts @@ -63,19 +63,22 @@ const definition = (input: ZoomChartInput) => { ], x: { scale: scaleForWindow(input.window), - label: 'Date', - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), + axis: { + ticks: { + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + }, + label: 'Date', + }, }, y: { scale: scaleLinear, - ticks: 4, grid: true, - label: 'AAPL close ($)', + axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, }, margin: { top: 56, right: 24, bottom: 44, left: 58 }, }) diff --git a/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts b/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts index 93a24c8d..88bbe29c 100644 --- a/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts +++ b/benchmarks/conformance/cases/91-timeline-playback-scrubber/tanstack.ts @@ -54,18 +54,21 @@ const definition = (input: ConformanceInput) => { ], x: { scale: scaleUtc, - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), + axis: { + ticks: { + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + }, + }, }, y: { scale: scaleLinear, - ticks: 4, grid: true, - label: 'AAPL close ($)', + axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, }, margin, }) diff --git a/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts b/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts index 824a905d..2920a540 100644 --- a/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts +++ b/benchmarks/conformance/cases/92-editable-event-range/tanstack.ts @@ -97,12 +97,16 @@ const definition = (input: EditableChartInput) => { x: { scale: scaleUtc().domain(editableDomain), grid: true, - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), + axis: { + ticks: { + format: (value: Date) => + value.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + }, + }, }, y: { scale: scaleBand() diff --git a/benchmarks/conformance/cases/bar-grouped/tanstack.ts b/benchmarks/conformance/cases/bar-grouped/tanstack.ts index eeca544a..e5c8c517 100644 --- a/benchmarks/conformance/cases/bar-grouped/tanstack.ts +++ b/benchmarks/conformance/cases/bar-grouped/tanstack.ts @@ -1,5 +1,5 @@ import { penguins } from '@charts-poc/demo-data/penguins' -import { barY, colorLegend, defineChart } from '@tanstack/charts' +import { barY, colorLegend, defineChart, group } from '@tanstack/charts' import { rollups } from 'd3-array' import { scaleBand, scaleLinear } from 'd3-scale' import { tanstackMount } from '../../shared/mount' @@ -27,19 +27,20 @@ const definition = (input: ConformanceInput) => x: 'species', y: 'count', color: 'sex', - groupScale: scaleBand().domain(sexDomain).paddingInner(0.08), + layout: group({ + scale: scaleBand().domain(sexDomain).paddingInner(0.08), + }), inset: 1, }), ], x: { scale: () => scaleBand().paddingInner(0.14).paddingOuter(0.06), - tickRotate: width < 640 ? -32 : 0, + axis: { tickLabels: { rotate: width < 640 ? -32 : 0 } }, }, y: { scale: scaleLinear, - label: 'Penguins', - ticks: 5, grid: true, + axis: { ticks: { count: 5 }, label: 'Penguins' }, }, color: { range: sexColors, diff --git a/benchmarks/conformance/cases/bar-horizontal-ranking/tanstack.ts b/benchmarks/conformance/cases/bar-horizontal-ranking/tanstack.ts index ce5c4540..25065170 100644 --- a/benchmarks/conformance/cases/bar-horizontal-ranking/tanstack.ts +++ b/benchmarks/conformance/cases/bar-horizontal-ranking/tanstack.ts @@ -21,9 +21,8 @@ const definition = (input: ConformanceInput) => { ], x: { scale: scaleLinear, - label: '2015 population', - ticks: 5, grid: true, + axis: { ticks: { count: 5 }, label: '2015 population' }, }, y: { scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), diff --git a/benchmarks/conformance/cases/bar-stacked/case.json b/benchmarks/conformance/cases/bar-stacked/case.json index 36217303..1b0178c6 100644 --- a/benchmarks/conformance/cases/bar-stacked/case.json +++ b/benchmarks/conformance/cases/bar-stacked/case.json @@ -8,16 +8,17 @@ "support": "composed", "features": [ "stacked bars", - "D3 stack intervals", + "stack transform", "categorical color inference", "monthly time intervals", - "explicit lower and upper bounds" + "implicit lower and upper bounds" ], "geometry": [ { - "role": "rect", + "role": "bar", "count": 69, - "maxCount": 72 + "maxCount": 72, + "rendererRoles": { "observable-plot": "rect", "tanstack": "bar" } }, { "role": "rule", diff --git a/benchmarks/conformance/cases/bar-stacked/tanstack.ts b/benchmarks/conformance/cases/bar-stacked/tanstack.ts index 8eebc811..f9626f8d 100644 --- a/benchmarks/conformance/cases/bar-stacked/tanstack.ts +++ b/benchmarks/conformance/cases/bar-stacked/tanstack.ts @@ -1,48 +1,40 @@ import { crimeanWar } from '@charts-poc/demo-data/crimean-war' -import { defineChart, rect, ruleY } from '@tanstack/charts' +import { barY, defineChart, ruleY, stack } from '@tanstack/charts' import { scaleLinear, scaleUtc } from 'd3-scale' -import { stack, stackOrderReverse } from 'd3-shape' import { tanstackMount } from '../../shared/mount' -import type { CrimeanWarRow } from '@charts-poc/demo-data/crimean-war' import type { ConformanceInput } from '../../types' const causes = ['disease', 'wounds', 'other'] as const const causeColors = ['#4269d0', '#ff725c', '#efb118'] -type Cause = (typeof causes)[number] - -interface MortalityInterval { - date: Date - nextMonth: Date - cause: Cause - deaths: number - y1: number - y2: number -} const definition = (input: ConformanceInput) => { - const intervals = stackRows(crimeanWar.slice(input.revision)) + const rows = crimeanWar.slice(input.revision).flatMap((row) => + causes.map((cause) => ({ + date: row.date, + cause, + deaths: row[cause], + })), + ) return defineChart({ marks: [ - rect(intervals, { - x1: 'date', - x2: 'nextMonth', - y1: 'y1', - y2: 'y2', + barY(rows, { + x: 'date', + y: 'deaths', + z: 'cause', color: 'cause', + layout: stack({ order: [...causes].reverse() }), }), ruleY([0]), ], x: { scale: scaleUtc, - ticks: 6, - format: (value) => month.format(value), + axis: { ticks: { count: 6, format: (value) => month.format(value) } }, }, y: { scale: scaleLinear, - label: 'Deaths', - ticks: 5, grid: true, + axis: { ticks: { count: 5 }, label: 'Deaths' }, }, color: { domain: causes, range: causeColors }, }) @@ -54,26 +46,3 @@ const month = new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC', }) - -function stackRows(rows: readonly CrimeanWarRow[]): MortalityInterval[] { - return stack() - .keys(causes) - .order(stackOrderReverse) - .value((row, cause) => row[cause])(rows) - .flatMap((layer) => - layer.map((point) => ({ - date: point.data.date, - nextMonth: new Date( - Date.UTC( - point.data.date.getUTCFullYear(), - point.data.date.getUTCMonth() + 1, - 1, - ), - ), - cause: layer.key, - deaths: point[1] - point[0], - y1: point[0], - y2: point[1], - })), - ) -} diff --git a/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts b/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts index b230cab8..89b5cd39 100644 --- a/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts +++ b/benchmarks/conformance/cases/bar-vertical-sorted/tanstack.ts @@ -22,14 +22,18 @@ const definition = (_input: ConformanceInput) => ], x: { scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), - tickRotate: width < 560 ? -32 : 0, + axis: { tickLabels: { rotate: width < 560 ? -32 : 0 } }, }, y: { scale: scaleLinear, - label: 'Frequency', - ticks: 5, grid: true, - format: (value) => percent.format(value), + axis: { + ticks: { + count: 5, + format: (value: number) => percent.format(value), + }, + label: 'Frequency', + }, }, } }) diff --git a/benchmarks/conformance/cases/facets-anscombe/tanstack.ts b/benchmarks/conformance/cases/facets-anscombe/tanstack.ts index 053219c7..eb7e4186 100644 --- a/benchmarks/conformance/cases/facets-anscombe/tanstack.ts +++ b/benchmarks/conformance/cases/facets-anscombe/tanstack.ts @@ -24,12 +24,12 @@ const definition = (_input: ConformanceInput) => x: { scale: scaleLinear().domain([3, 20]), grid: true, - ticks: 5, + axis: { ticks: { count: 5 } }, }, y: { scale: scaleLinear().domain([2, 14]), grid: true, - ticks: 4, + axis: { ticks: { count: 4 } }, }, }), }), diff --git a/benchmarks/conformance/cases/heatmap-labeled/tanstack.ts b/benchmarks/conformance/cases/heatmap-labeled/tanstack.ts index 13c981c0..35e28ff1 100644 --- a/benchmarks/conformance/cases/heatmap-labeled/tanstack.ts +++ b/benchmarks/conformance/cases/heatmap-labeled/tanstack.ts @@ -48,14 +48,14 @@ const definition = (_input: ConformanceInput) => .domain(episodeDomain) .paddingInner(0.04) .paddingOuter(0.02), - label: 'Episode', + axis: { label: 'Episode' }, }, y: { scale: scaleBand() .domain(seasonDomain) .paddingInner(0.04) .paddingOuter(0.02), - label: 'Season', + axis: { label: 'Season' }, }, color: { scale: () => scaleLinear().range(ratingColors), diff --git a/benchmarks/conformance/cases/histogram/case.json b/benchmarks/conformance/cases/histogram/case.json index 1ab984b4..f6e43354 100644 --- a/benchmarks/conformance/cases/histogram/case.json +++ b/benchmarks/conformance/cases/histogram/case.json @@ -19,6 +19,6 @@ }, "ai": { "create": "Create a histogram directly from the cars economy (mpg) field using fixed five-mpg boundaries from 5 through 50.", - "maintain": "Change the threshold boundaries while retaining the imported rows and keeping the Plot and granular D3 transforms semantically equivalent." + "maintain": "Change the threshold boundaries while retaining the imported rows and keeping the Plot and TanStack transforms semantically equivalent." } } diff --git a/benchmarks/conformance/cases/histogram/tanstack.ts b/benchmarks/conformance/cases/histogram/tanstack.ts index f419dfda..d2162ea3 100644 --- a/benchmarks/conformance/cases/histogram/tanstack.ts +++ b/benchmarks/conformance/cases/histogram/tanstack.ts @@ -1,50 +1,28 @@ import { cars } from '@charts-poc/demo-data/cars' -import { defineChart, rect } from '@tanstack/charts' -import { bin } from 'd3-array' +import { binX, defineChart, rect } from '@tanstack/charts' import { scaleLinear } from 'd3-scale' import { tanstackMount } from '../../shared/mount' import type { CarsRow } from '@charts-poc/demo-data/cars' import type { ConformanceInput } from '../../types' -interface HistogramBin { - id: string - x0: number - x1: number - count: number -} - type CarWithEconomy = CarsRow & { 'economy (mpg)': number } const completeCars = cars.filter( (row): row is CarWithEconomy => row['economy (mpg)'] !== null, ) const boundaries = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50] -const createBins = bin() - .value((row) => row['economy (mpg)']) - .domain([boundaries[0] ?? 5, boundaries.at(-1) ?? 50]) - .thresholds(boundaries.slice(1, -1)) - const definition = (input: ConformanceInput) => { - const bins: readonly HistogramBin[] = createBins( - completeCars.slice(input.revision * 8), - ).flatMap((bucket, index) => - bucket.x0 === undefined || bucket.x1 === undefined - ? [] - : [ - { - id: `bin:${index}`, - x0: bucket.x0, - x1: bucket.x1, - count: bucket.length, - }, - ], - ) + const bins = binX(completeCars.slice(input.revision * 8), { + value: 'economy (mpg)', + thresholds: boundaries, + outputs: { count: { reduce: 'count' } }, + }) return defineChart({ marks: [ rect(bins, { - x1: 'x0', - x2: 'x1', + x1: 'x1', + x2: 'x2', y1: () => 0, y2: 'count', fill: '#2563eb', @@ -54,19 +32,15 @@ const definition = (input: ConformanceInput) => { x: { scale: scaleLinear, grid: true, - label: 'Fuel economy (mpg)', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Count', + axis: { label: 'Fuel economy (mpg)' }, }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Count' } }, }) } export const mount = tanstackMount(definition, 'Histogram of fuel economy', { format: ({ datum }) => - `${datum.x0.toLocaleString('en-US')}–${datum.x1.toLocaleString( + `${datum.x1.toLocaleString('en-US')}–${datum.x2.toLocaleString( 'en-US', )} · ${datum.count.toLocaleString('en-US')} observations`, }) diff --git a/benchmarks/conformance/cases/scatter-bubble/tanstack.ts b/benchmarks/conformance/cases/scatter-bubble/tanstack.ts index 97e37e82..f3373c03 100644 --- a/benchmarks/conformance/cases/scatter-bubble/tanstack.ts +++ b/benchmarks/conformance/cases/scatter-bubble/tanstack.ts @@ -34,16 +34,8 @@ const definition = (input: ConformanceInput) => { strokeWidth: 0.75, }), ], - x: { - scale: scaleLinear, - grid: true, - label: 'Bill length (mm)', - }, - y: { - scale: scaleLinear, - grid: true, - label: 'Bill depth (mm)', - }, + x: { scale: scaleLinear, grid: true, axis: { label: 'Bill length (mm)' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Bill depth (mm)' } }, color: { range: groupRange, legend: colorLegend({ label: 'Species' }), diff --git a/benchmarks/entries/charts-histogram-svg.ts b/benchmarks/entries/charts-histogram-svg.ts index 7a0560ef..610fc81a 100644 --- a/benchmarks/entries/charts-histogram-svg.ts +++ b/benchmarks/entries/charts-histogram-svg.ts @@ -1,25 +1,26 @@ import { colorGradientLegend, + binX, createChartScene, defineChart, rect, renderChartSvg, } from '@tanstack/charts' -import { bin } from 'd3-array' import { scaleLinear } from 'd3-scale' -const bins = bin() - .value((value) => value) - .thresholds(5)([1, 2, 2, 3, 5, 8, 13, 21]) +const bins = binX([1, 2, 2, 3, 5, 8, 13, 21], { + value: ({ datum }) => datum, + thresholds: 5, +}) const definition = defineChart({ marks: [ rect(bins, { - x: (entry) => ((entry.x0 ?? 0) + (entry.x1 ?? 0)) / 2, - x1: (entry) => entry.x0, - x2: (entry) => entry.x1, + x: 'x', + x1: 'x1', + x2: 'x2', y1: () => 0, - y2: (entry) => entry.length, - z: (entry) => entry.length, + y2: 'value', + z: 'value', }), ], x: { scale: scaleLinear().domain([0, 25]) }, diff --git a/benchmarks/entries/charts-transform-bin-time.ts b/benchmarks/entries/charts-transform-bin-time.ts new file mode 100644 index 00000000..6dedf920 --- /dev/null +++ b/benchmarks/entries/charts-transform-bin-time.ts @@ -0,0 +1,19 @@ +import { binTimeX } from '@tanstack/charts/transform/bin-time' +const day = { + floor: (date: Date) => + new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ), + offset: (date: Date, step = 1) => + new Date(date.getTime() + step * 86_400_000), + range(start: Date, stop: Date, step = 1) { + const result: Date[] = [] + for (let date = start; date < stop; date = this.offset(date, step)) + result.push(date) + return result + }, +} +export const output = binTimeX([{ date: new Date() }], { + value: 'date', + interval: day, +}) diff --git a/benchmarks/entries/charts-transform-bin-xy.ts b/benchmarks/entries/charts-transform-bin-xy.ts new file mode 100644 index 00000000..1dc2e50f --- /dev/null +++ b/benchmarks/entries/charts-transform-bin-xy.ts @@ -0,0 +1,11 @@ +import { binXY } from '@tanstack/charts/transform/bin-xy' +const rows = [ + { x: 1, y: 2 }, + { x: 2, y: 1 }, +] +export const output = binXY(rows, { + x: 'x', + y: 'y', + xThresholds: 2, + yThresholds: 2, +}) diff --git a/benchmarks/entries/charts-transform-bin.ts b/benchmarks/entries/charts-transform-bin.ts new file mode 100644 index 00000000..4454ba3e --- /dev/null +++ b/benchmarks/entries/charts-transform-bin.ts @@ -0,0 +1,3 @@ +import { binX } from '@tanstack/charts/transform/bin' +const rows = [{ value: 1 }, { value: 2 }, { value: 3 }] +export const output = binX(rows, { value: 'value', thresholds: 2 }) diff --git a/benchmarks/entries/charts-transform-cumulative.ts b/benchmarks/entries/charts-transform-cumulative.ts new file mode 100644 index 00000000..fd8bf101 --- /dev/null +++ b/benchmarks/entries/charts-transform-cumulative.ts @@ -0,0 +1,4 @@ +import { cumulative } from '@tanstack/charts/transform/cumulative' +export const output = cumulative([{ value: 1 }], { + outputs: { total: { value: 'value', reduce: 'sum' } }, +}) diff --git a/benchmarks/entries/charts-transform-group.ts b/benchmarks/entries/charts-transform-group.ts new file mode 100644 index 00000000..5ab69811 --- /dev/null +++ b/benchmarks/entries/charts-transform-group.ts @@ -0,0 +1,5 @@ +import { groupBy } from '@tanstack/charts/transform/group' +export const output = groupBy([{ group: 'a', value: 1 }], { + by: 'group', + outputs: { total: { value: 'value', reduce: 'sum' } }, +}) diff --git a/benchmarks/entries/charts-transform-normalize.ts b/benchmarks/entries/charts-transform-normalize.ts new file mode 100644 index 00000000..82763cbe --- /dev/null +++ b/benchmarks/entries/charts-transform-normalize.ts @@ -0,0 +1,2 @@ +import { normalize } from '@tanstack/charts/transform/normalize' +export const output = normalize([{ value: 1 }], { value: 'value' }) diff --git a/benchmarks/entries/charts-transform-rank.ts b/benchmarks/entries/charts-transform-rank.ts new file mode 100644 index 00000000..2ebe2145 --- /dev/null +++ b/benchmarks/entries/charts-transform-rank.ts @@ -0,0 +1,2 @@ +import { rank } from '@tanstack/charts/transform/rank' +export const output = rank([{ value: 1 }], { value: 'value' }) diff --git a/benchmarks/entries/charts-transform-reduce.ts b/benchmarks/entries/charts-transform-reduce.ts new file mode 100644 index 00000000..78bae77a --- /dev/null +++ b/benchmarks/entries/charts-transform-reduce.ts @@ -0,0 +1,20 @@ +import { + deviation, + difference, + first, + last, + median, + quantile, + ratio, + variance, +} from '@tanstack/charts/transform/reduce' +export const output = { + deviation, + difference, + first, + last, + median, + p90: quantile(0.9), + ratio, + variance, +} diff --git a/benchmarks/entries/charts-transform-select.ts b/benchmarks/entries/charts-transform-select.ts new file mode 100644 index 00000000..2224eb46 --- /dev/null +++ b/benchmarks/entries/charts-transform-select.ts @@ -0,0 +1,2 @@ +import { select } from '@tanstack/charts/transform/select' +export const output = select([{ value: 1 }], { value: 'value', select: 'max' }) diff --git a/benchmarks/entries/charts-transform-stack.ts b/benchmarks/entries/charts-transform-stack.ts new file mode 100644 index 00000000..b012a4f7 --- /dev/null +++ b/benchmarks/entries/charts-transform-stack.ts @@ -0,0 +1,2 @@ +import { stackRowsY } from '@tanstack/charts/transform/stack' +export const output = stackRowsY([{ x: 'a', y: 1 }], { x: 'x', y: 'y' }) diff --git a/benchmarks/entries/charts-transform-suite.ts b/benchmarks/entries/charts-transform-suite.ts new file mode 100644 index 00000000..9b2a30c1 --- /dev/null +++ b/benchmarks/entries/charts-transform-suite.ts @@ -0,0 +1,41 @@ +import { binX } from '@tanstack/charts/transform/bin' +import { groupBy } from '@tanstack/charts/transform/group' +import { normalize } from '@tanstack/charts/transform/normalize' +import { select } from '@tanstack/charts/transform/select' +import { stackRowsY } from '@tanstack/charts/transform/stack' +import { window } from '@tanstack/charts/transform/window' +import { binXY } from '@tanstack/charts/transform/bin-xy' +import { cumulative } from '@tanstack/charts/transform/cumulative' +import { rank } from '@tanstack/charts/transform/rank' + +const rows = [ + { category: 'A', series: 'one', value: 1 }, + { category: 'A', series: 'two', value: 2 }, + { category: 'B', series: 'one', value: 3 }, + { category: 'B', series: 'two', value: 4 }, +] + +export const transformed = { + bins: binX(rows, { value: 'value', thresholds: 2 }), + groups: groupBy(rows, { + by: 'category', + outputs: { total: { value: 'value', reduce: 'sum' } }, + }), + normalized: normalize(rows, { value: 'value', by: 'category' }), + selected: select(rows, { value: 'value', select: 'max' }), + stacked: stackRowsY(rows, { x: 'category', y: 'value', z: 'series' }), + rolling: window(rows, { + size: 2, + outputs: { average: { value: 'value', reduce: 'mean' } }, + }), + cells: binXY(rows, { + x: 'value', + y: 'value', + xThresholds: 2, + yThresholds: 2, + }), + cumulative: cumulative(rows, { + outputs: { total: { value: 'value', reduce: 'sum' } }, + }), + ranked: rank(rows, { value: 'value' }), +} diff --git a/benchmarks/entries/charts-transform-window.ts b/benchmarks/entries/charts-transform-window.ts new file mode 100644 index 00000000..584da50d --- /dev/null +++ b/benchmarks/entries/charts-transform-window.ts @@ -0,0 +1,6 @@ +import { window } from '@tanstack/charts/transform/window' +export const output = window([{ order: 1, value: 1 }], { + orderBy: 'order', + size: 2, + outputs: { average: { value: 'value', reduce: 'mean' } }, +}) diff --git a/benchmarks/rendering.ts b/benchmarks/rendering.ts index e2c4adc8..9be4607c 100644 --- a/benchmarks/rendering.ts +++ b/benchmarks/rendering.ts @@ -121,13 +121,13 @@ const nativeDownloads = defineChart({ ], x: { scale: scaleUtc().domain(downloadData.map((point) => point.date)), - ticks: 6, + axis: { ticks: { count: 6 } }, }, y: { scale: scaleLinear() .domain([0, max(downloadData, (point) => point.downloads) ?? 1]) .nice(5), - ticks: 5, + axis: { ticks: { count: 5 } }, }, }) const largeData = Array.from({ length: 10_000 }, (_, index) => ({ @@ -296,11 +296,11 @@ const createNativeDynamicDefinition = (input: { ], x: { scale: scaleUtc().domain(input.points.map((point) => point.date)), - ticks: 6, + axis: { ticks: { count: 6 } }, }, y: { scale: scaleLinear().domain([0, maximum]).nice(5), - ticks: 5, + axis: { ticks: { count: 5 } }, }, } }) diff --git a/docs/comparison.md b/docs/comparison.md index d5530e57..8823b9ca 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ evidence without turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `2be6729` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `e997c89` | | [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 `2be6729`. +at page render time. The measured TanStack workspace revision is `e997c89`. ## Capability matrix @@ -59,7 +59,7 @@ that lane externalizes React and React DOM. | Library | Full cold-page gzip | React externalized | | --------------- | ------------------: | -----------------: | -| TanStack Charts | 23.68–28.06 KiB | — | +| TanStack Charts | 26.62–32.17 KiB | — | | Chart.js | 44.70–58.21 KiB | — | | Apache ECharts | 153.10–173.18 KiB | — | | Recharts | 153.08–168.27 KiB | 94.96–109.96 KiB | @@ -93,11 +93,12 @@ authoring. TanStack deliberately keeps several responsibilities outside the default runtime: -| Responsibility | Owner | -| --------------------------------------------------- | ------------------------------------------ | -| Binning, stacking, statistics, and spatial layouts | Application code using granular D3 modules | -| Brush, zoom, scrubber, and editor state | Application state and optional D3 behavior | -| Data fetching, cleaning, filtering, and persistence | The application's data and state layers | +| Responsibility | Owner | +| --------------------------------------------------- | ---------------------------------------------------- | +| Binning, grouping, stacking, and statistics | Hoistable TanStack transforms or granular D3 modules | +| Spatial layouts | Application code using a suitable layout library | +| Brush, zoom, scrubber, and editor state | Application state and optional D3 behavior | +| Data fetching, cleaning, filtering, and persistence | The application's data and state layers | Choose Chart.js when Canvas-first standard charts and its plugin ecosystem fit the application. Choose Apache ECharts for a broad built-in controller and diff --git a/docs/concepts/chart-definitions.md b/docs/concepts/chart-definitions.md index 9d582a59..9f0d3b7f 100644 --- a/docs/concepts/chart-definitions.md +++ b/docs/concepts/chart-definitions.md @@ -38,8 +38,8 @@ const letterFrequencies = defineChart({ y: { scale: scaleLinear, nice: true, - label: 'Frequency', grid: true, + axis: { label: 'Frequency' }, }, }) ``` @@ -59,7 +59,7 @@ const productRanking = defineChart({ x: { scale: scaleLinear, nice: true, - ticks: width < 480 ? 4 : 7, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, }, y: { scale: () => scaleBand().padding(0.1), @@ -110,7 +110,7 @@ function ProductRanking({ rows, metric }: Props) { x: { scale: scaleLinear, nice: true, - ticks: width < 480 ? 4 : 7, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, }, y: { scale: () => scaleBand().padding(0.1), diff --git a/docs/concepts/data-and-channels.md b/docs/concepts/data-and-channels.md index d21eef33..36f42433 100644 --- a/docs/concepts/data-and-channels.md +++ b/docs/concepts/data-and-channels.md @@ -89,9 +89,9 @@ also supplies that path grouping. When both are present, `z` wins for geometry and interaction grouping while `color` remains an independent color-scale value. Omitting `color` reuses `z` for color. -On bars, neither channel implicitly invents grouped-bar geometry. Supply a D3 -`groupScale` when multiple bars must occupy sub-bands within one category. -The scale uses `z` when present, otherwise `color`. See +Bars stack their length channel by default. Use `layout: group()` when multiple +bars must occupy sub-bands within one category. Grouping uses `z` when present, +otherwise a discrete `color` channel may infer series identity. See [Bars and Rankings](../examples/bars-and-rankings.md). ## Color channels and constants @@ -248,28 +248,23 @@ const marks = [ The definition’s interaction datum becomes the honest union of point-emitting mark data. Callbacks narrow that union using your existing discriminants or type guards. -## Derived data remains application-owned +## Derived data stays explicit -Grouping, binning, stacking, sorting, aggregation, and spatial preparation -happen in ordinary application code before mark construction: +Grouping, binning, rolling, normalization, selection, and reusable stack +endpoints happen before mark construction. Use the pure transforms from +TanStack Charts or an ordinary application function: ```ts -const bins = bin().domain([minimum, maximum]).thresholds(24)(values) - -const rows = bins.map((items, index) => ({ - id: index, - x1: items.x0 ?? minimum, - x2: items.x1 ?? maximum, - count: items.length, - items, -})) +const bins = binX(observations, { + value: 'latency', + thresholds: 24, +}) ``` -The transform can run beside `defineChart` or inside the framework primitive -that memoizes the complete definition. The resulting rows flow into ordinary -marks. Install the granular D3 module used by the transform and its matching -type package. [Scales and D3](./scales-and-d3.md) routes each responsibility to -official D3 documentation without duplicating it. +Transforms return materialized typed rows and retain source lineage. They do +not rewrite mark options or own reactivity. Run them beside `defineChart` or +inside the framework primitive that memoizes the definition. The resulting +rows flow into ordinary marks. [Transforms and Reactivity](../guides/transforms-and-reactivity.md) shows the complete raw-data-to-mark path and separates application memoization from @@ -365,13 +360,13 @@ const bubbleChart = defineChart({ ], x: { scale: scaleLinear, - label: 'Bill length (mm)', grid: true, + axis: { label: 'Bill length (mm)' }, }, y: { scale: scaleLinear, - label: 'Bill depth (mm)', grid: true, + axis: { label: 'Bill depth (mm)' }, }, color: { scale: scaleOrdinal(species, ['#2563eb', '#f97316', '#10b981']), diff --git a/docs/concepts/grammar-of-graphics.md b/docs/concepts/grammar-of-graphics.md index f62d278b..3cdf1bb1 100644 --- a/docs/concepts/grammar-of-graphics.md +++ b/docs/concepts/grammar-of-graphics.md @@ -137,13 +137,13 @@ const axes = { x: { scale: scaleUtc, nice: true, - label: 'Month', + axis: { label: 'Month' }, }, y: { scale: scaleLinear, nice: true, - label: 'Revenue', grid: true, + axis: { label: 'Revenue' }, }, } ``` @@ -159,10 +159,14 @@ Axis guide options live next to their scale: ```ts const y = { scale: revenueScale, - label: 'Monthly revenue', - format: (value: number) => `$${Math.round(value / 1_000)}k`, - ticks: 5, grid: true, + axis: { + label: 'Monthly revenue', + ticks: { + count: 5, + format: (value: number) => `$${Math.round(value / 1_000)}k`, + }, + }, } ``` @@ -272,7 +276,7 @@ const composedChart = defineChart({ ], x: { scale: () => scaleBand().padding(0.12), - label: 'Date', + axis: { label: 'Date' }, }, y: { scale: scaleLinear, diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index 1f5d93f3..ce2f4cc7 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -63,12 +63,14 @@ const chart = defineChart(({ width }) => ({ marks: [lineY(rows, { x: 'date', y: 'value' })], x: { scale: xScale, - ticks: width < 420 ? 4 : 8, - tickRotate: width < 520 ? -30 : undefined, + axis: { + ticks: { count: width < 420 ? 4 : 8 }, + tickLabels: { rotate: width < 520 ? -30 : undefined }, + }, }, y: { scale: yScale, - label: width < 480 ? undefined : 'Weekly downloads', + axis: { label: width < 480 ? undefined : 'Weekly downloads' }, }, })) ``` @@ -124,10 +126,9 @@ inherited font and relayout after web fonts load. Advanced renderers can supply `measureText`. Its metrics include painted x and y offsets relative to the requested anchor and baseline, not only width and height. This is necessary for correct containment of rotated and anchored labels. -Automatic margins contain chart-owned guides and Cartesian `text` marks; they do -not avoid collisions between adjacent labels. Use `ticks` to reduce density, -`tickRotate` when guide labels overlap, and explicit text placement when data -labels overlap one another. +Automatic margins contain chart-owned guides and Cartesian `text` marks. Axis +tick labels are thinned against their measured, optionally rotated bounds. +Explicit text placement remains responsible for data-label collisions. ## Axis guide options @@ -136,34 +137,61 @@ Each axis combines a required scale factory or instance with optional guide cont ```ts const x = { scale: xScale, - label: 'Month', - ticks: 6, - format: (date: Date) => monthFormatter.format(date), - tickRotate: -30, - labelOffset: 12, grid: false, + axis: { + ticks: { + count: 6, + format: (date: Date) => monthFormatter.format(date), + }, + tickLabels: { rotate: -30 }, + label: { text: 'Month', offset: 12 }, + }, } ``` -| Option | Purpose | -| ------------- | -------------------------------------------------- | -| `guide` | Show or hide this axis, its title, ticks, and grid | -| `ticks` | Suggested tick count | -| `format` | Format a typed tick value | -| `grid` | Draw grid lines at ticks | -| `label` | Axis title | -| `reverse` | Reverse the responsive range | -| `tickRotate` | Rotate tick labels in degrees | -| `labelOffset` | Add distance between the axis and title | +| Option | Purpose | +| ----------------- | ---------------------------------------------------- | +| `axis` | Configure the axis or hide it with `false` | +| `axis.line` | Show or hide the baseline | +| `axis.ticks` | Configure candidates, stubs, padding, and formatting | +| `axis.tickLabels` | Configure label rotation and collision thinning | +| `axis.label` | Configure the axis title and offset | +| `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. +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 +either. `axis.ticks.size: 0` removes stubs while retaining labels and grid +lines. + +Rotation and thinning are independent. Thinning is enabled by default and +uses measured rotated bounds: + +```ts +const x = { + scale: xScale, + axis: { + ticks: { spacing: 80 }, + tickLabels: { + rotate: -35, + thin: { minGap: 8, priority: 'ends', keep: importantDates }, + }, + }, +} +``` + +Hard-kept labels are retained even when they collide. Values absent from the +candidate set add labels only. + Hide one guide without removing its scale: ```ts const x = { scale: xScale, - guide: false, + axis: false, } ``` @@ -247,7 +275,8 @@ barX(rows, { The D3 band scale’s `paddingInner` and `paddingOuter` determine category spacing. `inset` removes additional pixels from both bar edges after layout. -For side-by-side bars, `groupScale` subdivides the primary bandwidth. See [Bars and Rankings](../examples/bars-and-rankings.md). +For side-by-side bars, `layout: group()` subdivides the primary bandwidth. See +[Bars and Rankings](../examples/bars-and-rankings.md). ## Scene and pointer coordinates @@ -327,9 +356,11 @@ const rankingChart = defineChart({ x: { scale: scaleLinear, nice: true, - label: '2015 population', - format: (value) => compact.format(value), grid: true, + axis: { + label: '2015 population', + ticks: { format: (value) => compact.format(value) }, + }, }, y: { scale: () => scaleBand().paddingInner(0.12).paddingOuter(0.06), diff --git a/docs/concepts/marks-and-layering.md b/docs/concepts/marks-and-layering.md index 52127e99..8badf7ab 100644 --- a/docs/concepts/marks-and-layering.md +++ b/docs/concepts/marks-and-layering.md @@ -92,11 +92,10 @@ rule. When `z` is omitted on a connected line or area, an authored `color` channel supplies the path groups as well as color semantics. Explicit `z` always wins when the two fields differ. -Bars use the primary band by default. Supply a configured D3 `groupScale` for -side-by-side bars; it groups by `z` when present and otherwise by `color`. -When rows should stack, prepare explicit `y1` and `y2` or `x1` and `x2` -intervals before the mark. TanStack Charts does not guess whether bars should -dodge, stack, or overlap. +Bars stack their single quantitative channel by default. Use +`layout: group({ scale })` for side-by-side bars; it groups by `z` when present +and otherwise by discrete `color`. Explicit `y1`/`y2` or `x1`/`x2` channels +opt out of implicit stacking and preserve authored intervals. ## Line and area gaps @@ -239,13 +238,13 @@ const temperatureChart = defineChart({ ], x: { scale: scaleUtc, - label: 'Day', + axis: { label: 'Day' }, }, y: { scale: scaleLinear, nice: true, - label: 'Temperature (°F)', grid: true, + axis: { label: 'Temperature (°F)' }, }, }) ``` diff --git a/docs/concepts/scales-and-d3.md b/docs/concepts/scales-and-d3.md index db3b359f..876108bc 100644 --- a/docs/concepts/scales-and-d3.md +++ b/docs/concepts/scales-and-d3.md @@ -201,7 +201,9 @@ const categoryScale = () => TanStack Charts applies the plot range, reads the scale bandwidth, and treats the mapped value as the center of the band for mark and interaction coordinates. Bars use the primary bandwidth by default. -For grouped bars, pass a second band scale as the mark’s `groupScale`. Its range is assigned within the primary band. Grouping is explicit because `z` alone cannot decide whether a chart should overlap, stack, dodge, or only color its rows. +For grouped bars, use `layout: group({ scale })`. The supplied band scale is +copied and its range is assigned within the primary band. Grouping is explicit; +the default length-channel geometry is stacked. ## Color scales @@ -288,25 +290,22 @@ Horizontal `areaX` marks use the separate `d3AreaXCurve` bridge from `@tanstack/ ## Transforms produce rows -D3 transforms do not need a TanStack wrapper: +TanStack Charts includes typed, data-first helpers for common transforms: ```ts -import { bin, max } from 'd3-array' - -const upper = max(values) ?? 1 -const histogram = bin() - .domain([0, upper]) - .thresholds(20)(values) - .map((items, index) => ({ - id: index, - x1: items.x0 ?? 0, - x2: items.x1 ?? upper, - count: items.length, - items, - })) +import { binX } from '@tanstack/charts/transform/bin' + +const histogram = binX(rows, { + value: 'value', + thresholds: 20, +}) ``` -Pass `histogram` to `rect`, `barY`, `lineY`, `dot`, or a custom mark according to the desired geometry. Keep substantial transforms in ordinary functions beside the definition and memoize them through application reactivity when necessary. +Pass the result to `rect`, `barY`, `lineY`, `dot`, or a custom mark. The +helpers use the same granular D3 kernels as the chart runtime while retaining +typed source lineage. Domain-specific D3 transforms still work directly; no +adapter or library-owned series shape is required. Keep substantial transforms +beside the definition and memoize them through application reactivity. The same rule applies to stacks, pies, hierarchies, force layouts, and server-prepared intervals: preserve the useful output as typed rows, then map @@ -381,13 +380,13 @@ const logChart = defineChart({ ], x: { scale: scaleLog().domain([200, 30_000]), - label: 'Class size', grid: true, + axis: { label: 'Class size' }, }, y: { scale: scaleLinear, - label: 'Hierarchy depth', grid: true, + axis: { label: 'Hierarchy depth' }, }, }) ``` diff --git a/docs/config.json b/docs/config.json index bd20b808..2ed74ff9 100644 --- a/docs/config.json +++ b/docs/config.json @@ -304,6 +304,10 @@ "label": "Chart Spec", "to": "reference/chart-spec" }, + { + "label": "Data Transforms", + "to": "reference/transforms" + }, { "label": "Scales, Guides, and Color", "to": "reference/scales-guides-and-color" diff --git a/docs/examples/interactive-charts.md b/docs/examples/interactive-charts.md index 81cd3eab..67ac2cf7 100644 --- a/docs/examples/interactive-charts.md +++ b/docs/examples/interactive-charts.md @@ -16,6 +16,8 @@ selection, or product record. | Reader task | Start with | | ------------------------------------------------------------ | ---------------------------------------------- | | Inspect one point or a same-x group | Native chart focus and tooltip | +| Paint a band, rule, or mark only for the active datum/group | `whenFocused` around an ordinary mark | +| Resize, recolor, or fade existing marks during focus | Inline mark `states` | | Keep rich framework detail open, including another chart | Pinned composed tooltip body | | Navigate a wide schedule without changing its semantic scale | Native horizontal scrolling | | Crop and pan a continuous domain | Controlled zoom and viewport state | @@ -25,6 +27,56 @@ selection, or product record. the controlled gesture loop. [Tooltips and Focus](../guides/tooltips-and-focus.md) defines the native inspection path. +## Compare focus marks + +A focused dot can resize and restyle the existing pointer target: + +```ts +dot(rows, { + x: 'Date', + y: 'Close', + r: 3, + fill: '#2563eb', + states: [ + { + when: { focus: 'primary' }, + style: { r: 7, stroke: 'Canvas', strokeWidth: 2 }, + transition: { duration: 140, easing: 'ease-out' }, + }, + ], +}) +``` + + + +A focused band emphasizes the shared x value for every series. Its position +before the lines places it underneath them: + +```ts +marks: [ + whenFocused( + bandX(dates, { + x: 'date', + fill: '#64748b', + fillOpacity: 0.14, + inset: 3, + }), + { match: 'x' }, + ), + lineY(rows, { x: 'date', y: 'unemployed', color: 'industry' }), +] +``` + +[Open the grouped focus example](https://tanstack.com/charts/catalog/35-grouped-tooltip/) +to inspect its live chart and complete source. + ## Pin rich nested detail A rich tooltip can compose the native rows with framework UI, including a diff --git a/docs/examples/stacked-and-composition.md b/docs/examples/stacked-and-composition.md index 01c53665..12fa89cf 100644 --- a/docs/examples/stacked-and-composition.md +++ b/docs/examples/stacked-and-composition.md @@ -24,16 +24,24 @@ values remain available elsewhere. | Which subgroup values must be compared precisely across groups? | Grouped bars or aligned small multiples | | Do contributions extend in positive and negative directions? | Diverging stack around an explicit zero | -Prepare explicit interval endpoints for every stacked row. The application -owns series order, offset, normalization, and the denominator behind every -proportion. [Scales and D3](../concepts/scales-and-d3.md) defines that -preparation boundary. +Single-value bar and area channels stack implicitly. Use `layout: stack()` +when the order or offset must be explicit; supply interval endpoints when the +application has already computed them. ## Preserve totals with a stacked area -A stacked area combines a shared ordered x domain with explicit lower and upper -boundaries for every series. The top boundary carries the total; the thickness -of each layer carries its contribution. +A stacked area combines a shared ordered x domain with one length per series. +The top boundary carries the total; the thickness of each layer carries its +contribution. + +```ts +areaY(rows, { + x: 'date', + y: 'value', + color: 'series', + layout: stack({ order: ['Core', 'Services'] }), +}) +``` -Treat offset and layer order as analytical parameters. Keep them deterministic -across revisions, preserve stable series colors, and provide exact values -through [Tooltips and Focus](../guides/tooltips-and-focus.md). +Use `offset: 'center'` or `offset: 'wiggle'` and an explicit order when the +composition depends on them. Keep those values stable across revisions, +preserve series colors, and provide exact values through +[Tooltips and Focus](../guides/tooltips-and-focus.md). Use an ordinary stacked area when totals or baselines are part of the question. diff --git a/docs/framework/octane/adapter.md b/docs/framework/octane/adapter.md index c8d1eac1..e43766aa 100644 --- a/docs/framework/octane/adapter.md +++ b/docs/framework/octane/adapter.md @@ -58,7 +58,7 @@ The default Node target renders the complete `.ts-chart-host`, `.ts-chart-surface`, and accessible SVG at `initialWidth`. The browser target hydrates the same structure before mounting the host. -The Canvas entry renders a deterministic named root and two `aria-hidden` +The Canvas entry renders a deterministic named root and three `aria-hidden` canvases on the server. It paints no server pixels. The browser adopts the elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. diff --git a/docs/framework/octane/quick-start.md b/docs/framework/octane/quick-start.md index 7ff8f6c0..7dc21447 100644 --- a/docs/framework/octane/quick-start.md +++ b/docs/framework/octane/quick-start.md @@ -57,9 +57,11 @@ const letterFrequencyChart = defineChart({ y: { scale: scaleLinear, nice: true, - label: 'Frequency', - format: (value) => percent.format(value), grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, tooltip, }) diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index a34a074a..d6eeabb9 100644 --- a/docs/framework/react/adapter.md +++ b/docs/framework/react/adapter.md @@ -85,7 +85,7 @@ The client renders the same initial structure, then the layout effect adopts and reconciles that SVG. There is no placeholder-only server mode. The Canvas entry emits the same outer structure with a named Canvas root and -two `aria-hidden` canvases. It does not paint pixels on the server. The client +three `aria-hidden` canvases. It does not paint pixels on the server. The client adopts those elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. diff --git a/docs/framework/react/quick-start.md b/docs/framework/react/quick-start.md index 74a3fc15..c0be4aa1 100644 --- a/docs/framework/react/quick-start.md +++ b/docs/framework/react/quick-start.md @@ -57,9 +57,11 @@ const letterFrequencyChart = defineChart({ y: { scale: scaleLinear, nice: true, - label: 'Frequency', - format: (value) => percent.format(value), grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, tooltip, }) diff --git a/docs/guides/accessibility.md b/docs/guides/accessibility.md index e07860a9..b3619777 100644 --- a/docs/guides/accessibility.md +++ b/docs/guides/accessibility.md @@ -33,7 +33,7 @@ visible nearby: The SVG renderer emits an image role, a chart roledescription, and a `` when a description is supplied. The Canvas renderer places the same image role, name, roledescription, description, and tab index on its root while keeping its -two paint canvases `aria-hidden`. Do not put instructions, conclusions, and all +three paint canvases `aria-hidden`. Do not put instructions, conclusions, and all underlying data into one enormous accessible name. ## Preserve semantic context outside the surface diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 67243c20..f61bb378 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -27,6 +27,8 @@ import { d3Curve } from '@tanstack/charts/d3/shape' import { tooltip } from '@tanstack/charts/tooltip' import { portal } from '@tanstack/charts/tooltip/portal' import { scaleLinear } from '@tanstack/charts-scales/linear' +import { groupBy } from '@tanstack/charts/transform/group' +import { window } from '@tanstack/charts/transform/window' ``` Canvas is opt-in. The default core and every default framework entry remain @@ -64,10 +66,18 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 15 kB gzip. +The locked compact React line consumer must remain at or below 16.8 kB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, -`d3-interpolate`, `d3-color`, and sibling compact-scale entries. Separate -incremental gates limit tooltip and portal growth. +`d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. +Separate incremental gates limit tooltip and portal growth. + +Transforms are root exports for convenience, but their granular subpaths are +the smallest contract for reusable preparation code. Ordinary line, compact- +scale, and tooltip-only bundle fixtures reject every transform module. Each +transform family has its own gzip ceiling and rejects unrelated families. +Numeric and 2D bins intentionally use `d3-array`; row stacking uses `d3-shape`; +grouping, calendar bins, windows, cumulative values, ranks, normalization, +selection, and advanced reducers do not retain either dependency. ## Import D3 by capability diff --git a/docs/guides/dynamic-data-and-animation.md b/docs/guides/dynamic-data-and-animation.md index a7552322..9500a935 100644 --- a/docs/guides/dynamic-data-and-animation.md +++ b/docs/guides/dynamic-data-and-animation.md @@ -27,7 +27,7 @@ function RankingChart({ rows, metric, accent }: Props) { x: { scale: scaleLinear, nice: true, - ticks: width < 420 ? 4 : 7, + axis: { ticks: { count: width < 420 ? 4 : 7 } }, }, y: { scale: () => scaleBand().padding(0.1), diff --git a/docs/guides/exporting.md b/docs/guides/exporting.md index 08f12f24..fb2fbd00 100644 --- a/docs/guides/exporting.md +++ b/docs/guides/exporting.md @@ -91,12 +91,12 @@ coordinate system. Pass the Canvas root or an ancestor containing it to the same `renderChartImage` or `downloadChartImage` functions. The exporter draws the base scene layer at the requested dimensions and scale. Set -`includeFocus: true` to composite the focus overlay; it is excluded by +`includeFocus: true` to composite the focus layers; they are excluded by default. -Canvas focus is painted on a separate overlay so pointer movement does not -repaint the base scene. Applications that need only the raw base bitmap may -also call `toBlob()` or `toDataURL()` on +Canvas focus is painted on underlay and overlay canvases so pointer movement +does not repaint the base scene. Applications that need only the raw base +bitmap may also call `toBlob()` or `toDataURL()` on `CanvasChartSurface.canvas`. Unlike SVG serialization, Canvas export does not retain vector geometry, accessible markup, or independently styleable nodes. @@ -129,7 +129,7 @@ needs embedded or inlined assets. - Fonts and external resources are portable. - Focus decoration is included only when meaningful. - Raster scale is chosen for the target medium. -- A Canvas export intentionally includes or excludes the focus overlay. +- A Canvas export intentionally includes or excludes focus layers. See [Rendering and Export](../reference/rendering-and-export.md) for every function and option. diff --git a/docs/guides/migrating.md b/docs/guides/migrating.md index ce3f47ea..6954720a 100644 --- a/docs/guides/migrating.md +++ b/docs/guides/migrating.md @@ -49,6 +49,16 @@ Map each visible layer independently: Then assign explicit scales and guides. Complex charts are usually several ordinary marks sharing a coordinate system, not one specialized chart type. +For the current breaking API: + +- move axis presentation under `axis`; +- use `axis: false` to hide one axis while retaining its scale; +- move candidate count and formatting under `axis.ticks`; +- move rotation and thinning under `axis.tickLabels`; +- use a single bar/area value as stack length, explicit endpoints to opt out, + and `layout: group()` for side-by-side bars; +- replace renderer-specific focus decoration with `whenFocused`. + See [Marks and Layering](../concepts/marks-and-layering.md) and the [Example Gallery](../examples/index.md). diff --git a/docs/guides/responsive-charts.md b/docs/guides/responsive-charts.md index 326cc78b..75cffaa2 100644 --- a/docs/guides/responsive-charts.md +++ b/docs/guides/responsive-charts.md @@ -70,7 +70,7 @@ const definition = defineChart({ x: { scale: scaleLinear, nice: true, - label: 'Weekly downloads', + axis: { label: 'Weekly downloads' }, }, y: { scale: () => scaleBand().padding(0.1), @@ -87,9 +87,9 @@ margin: { ``` `margin: 0` locks every side and is appropriate for guide-free sparklines. -Automatic margins guarantee containment, not collision-free labels. Use -`ticks`, `tickRotate`, or a different representation when labels compete for -the same axis space. +Axis labels thin automatically after candidate generation and optional +rotation. Use `axis.ticks.spacing`, `axis.tickLabels.rotate`, hard-kept labels, +or a different representation when labels compete for the same axis space. + +A focused band emphasizes the shared x value for every series. Its position +before the lines places it underneath them: + +```ts +marks: [ + whenFocused( + bandX(dates, { + x: 'date', + fill: '#64748b', + fillOpacity: 0.14, + inset: 3, + }), + { match: 'x' }, + ), + lineY(rows, { x: 'date', y: 'unemployed', color: 'industry' }), +] +``` + +[Open the grouped focus example](https://tanstack.com/charts/catalog/35-grouped-tooltip/) +to inspect its live chart and complete source. + ## Pin rich nested detail A rich tooltip can compose the native rows with framework UI, including a diff --git a/packages/charts-core/docs/examples/stacked-and-composition.md b/packages/charts-core/docs/examples/stacked-and-composition.md index 01c53665..12fa89cf 100644 --- a/packages/charts-core/docs/examples/stacked-and-composition.md +++ b/packages/charts-core/docs/examples/stacked-and-composition.md @@ -24,16 +24,24 @@ values remain available elsewhere. | Which subgroup values must be compared precisely across groups? | Grouped bars or aligned small multiples | | Do contributions extend in positive and negative directions? | Diverging stack around an explicit zero | -Prepare explicit interval endpoints for every stacked row. The application -owns series order, offset, normalization, and the denominator behind every -proportion. [Scales and D3](../concepts/scales-and-d3.md) defines that -preparation boundary. +Single-value bar and area channels stack implicitly. Use `layout: stack()` +when the order or offset must be explicit; supply interval endpoints when the +application has already computed them. ## Preserve totals with a stacked area -A stacked area combines a shared ordered x domain with explicit lower and upper -boundaries for every series. The top boundary carries the total; the thickness -of each layer carries its contribution. +A stacked area combines a shared ordered x domain with one length per series. +The top boundary carries the total; the thickness of each layer carries its +contribution. + +```ts +areaY(rows, { + x: 'date', + y: 'value', + color: 'series', + layout: stack({ order: ['Core', 'Services'] }), +}) +``` -Treat offset and layer order as analytical parameters. Keep them deterministic -across revisions, preserve stable series colors, and provide exact values -through [Tooltips and Focus](../guides/tooltips-and-focus.md). +Use `offset: 'center'` or `offset: 'wiggle'` and an explicit order when the +composition depends on them. Keep those values stable across revisions, +preserve series colors, and provide exact values through +[Tooltips and Focus](../guides/tooltips-and-focus.md). Use an ordinary stacked area when totals or baselines are part of the question. diff --git a/packages/charts-core/docs/framework/octane/adapter.md b/packages/charts-core/docs/framework/octane/adapter.md index c8d1eac1..e43766aa 100644 --- a/packages/charts-core/docs/framework/octane/adapter.md +++ b/packages/charts-core/docs/framework/octane/adapter.md @@ -58,7 +58,7 @@ The default Node target renders the complete `.ts-chart-host`, `.ts-chart-surface`, and accessible SVG at `initialWidth`. The browser target hydrates the same structure before mounting the host. -The Canvas entry renders a deterministic named root and two `aria-hidden` +The Canvas entry renders a deterministic named root and three `aria-hidden` canvases on the server. It paints no server pixels. The browser adopts the elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. diff --git a/packages/charts-core/docs/framework/octane/quick-start.md b/packages/charts-core/docs/framework/octane/quick-start.md index 7ff8f6c0..7dc21447 100644 --- a/packages/charts-core/docs/framework/octane/quick-start.md +++ b/packages/charts-core/docs/framework/octane/quick-start.md @@ -57,9 +57,11 @@ const letterFrequencyChart = defineChart({ y: { scale: scaleLinear, nice: true, - label: 'Frequency', - format: (value) => percent.format(value), grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, tooltip, }) diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md index a34a074a..d6eeabb9 100644 --- a/packages/charts-core/docs/framework/react/adapter.md +++ b/packages/charts-core/docs/framework/react/adapter.md @@ -85,7 +85,7 @@ The client renders the same initial structure, then the layout effect adopts and reconciles that SVG. There is no placeholder-only server mode. The Canvas entry emits the same outer structure with a named Canvas root and -two `aria-hidden` canvases. It does not paint pixels on the server. The client +three `aria-hidden` canvases. It does not paint pixels on the server. The client adopts those elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. diff --git a/packages/charts-core/docs/framework/react/quick-start.md b/packages/charts-core/docs/framework/react/quick-start.md index 74a3fc15..c0be4aa1 100644 --- a/packages/charts-core/docs/framework/react/quick-start.md +++ b/packages/charts-core/docs/framework/react/quick-start.md @@ -57,9 +57,11 @@ const letterFrequencyChart = defineChart({ y: { scale: scaleLinear, nice: true, - label: 'Frequency', - format: (value) => percent.format(value), grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, tooltip, }) diff --git a/packages/charts-core/docs/guides/accessibility.md b/packages/charts-core/docs/guides/accessibility.md index e07860a9..b3619777 100644 --- a/packages/charts-core/docs/guides/accessibility.md +++ b/packages/charts-core/docs/guides/accessibility.md @@ -33,7 +33,7 @@ visible nearby: The SVG renderer emits an image role, a chart roledescription, and a `` when a description is supplied. The Canvas renderer places the same image role, name, roledescription, description, and tab index on its root while keeping its -two paint canvases `aria-hidden`. Do not put instructions, conclusions, and all +three paint canvases `aria-hidden`. Do not put instructions, conclusions, and all underlying data into one enormous accessible name. ## Preserve semantic context outside the surface 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 67243c20..f61bb378 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -27,6 +27,8 @@ import { d3Curve } from '@tanstack/charts/d3/shape' import { tooltip } from '@tanstack/charts/tooltip' import { portal } from '@tanstack/charts/tooltip/portal' import { scaleLinear } from '@tanstack/charts-scales/linear' +import { groupBy } from '@tanstack/charts/transform/group' +import { window } from '@tanstack/charts/transform/window' ``` Canvas is opt-in. The default core and every default framework entry remain @@ -64,10 +66,18 @@ const interactive = defineChart(definition, { }) ``` -The locked compact React line consumer must remain at or below 15 kB gzip. +The locked compact React line consumer must remain at or below 16.8 kB gzip. Its retained-module gate rejects tooltip, portal, `d3-scale`, `d3-format`, -`d3-interpolate`, `d3-color`, and sibling compact-scale entries. Separate -incremental gates limit tooltip and portal growth. +`d3-interpolate`, `d3-color`, transforms, and sibling compact-scale entries. +Separate incremental gates limit tooltip and portal growth. + +Transforms are root exports for convenience, but their granular subpaths are +the smallest contract for reusable preparation code. Ordinary line, compact- +scale, and tooltip-only bundle fixtures reject every transform module. Each +transform family has its own gzip ceiling and rejects unrelated families. +Numeric and 2D bins intentionally use `d3-array`; row stacking uses `d3-shape`; +grouping, calendar bins, windows, cumulative values, ranks, normalization, +selection, and advanced reducers do not retain either dependency. ## Import D3 by capability diff --git a/packages/charts-core/docs/guides/dynamic-data-and-animation.md b/packages/charts-core/docs/guides/dynamic-data-and-animation.md index a7552322..9500a935 100644 --- a/packages/charts-core/docs/guides/dynamic-data-and-animation.md +++ b/packages/charts-core/docs/guides/dynamic-data-and-animation.md @@ -27,7 +27,7 @@ function RankingChart({ rows, metric, accent }: Props) { x: { scale: scaleLinear, nice: true, - ticks: width < 420 ? 4 : 7, + axis: { ticks: { count: width < 420 ? 4 : 7 } }, }, y: { scale: () => scaleBand().padding(0.1), diff --git a/packages/charts-core/docs/guides/exporting.md b/packages/charts-core/docs/guides/exporting.md index 08f12f24..fb2fbd00 100644 --- a/packages/charts-core/docs/guides/exporting.md +++ b/packages/charts-core/docs/guides/exporting.md @@ -91,12 +91,12 @@ coordinate system. Pass the Canvas root or an ancestor containing it to the same `renderChartImage` or `downloadChartImage` functions. The exporter draws the base scene layer at the requested dimensions and scale. Set -`includeFocus: true` to composite the focus overlay; it is excluded by +`includeFocus: true` to composite the focus layers; they are excluded by default. -Canvas focus is painted on a separate overlay so pointer movement does not -repaint the base scene. Applications that need only the raw base bitmap may -also call `toBlob()` or `toDataURL()` on +Canvas focus is painted on underlay and overlay canvases so pointer movement +does not repaint the base scene. Applications that need only the raw base +bitmap may also call `toBlob()` or `toDataURL()` on `CanvasChartSurface.canvas`. Unlike SVG serialization, Canvas export does not retain vector geometry, accessible markup, or independently styleable nodes. @@ -129,7 +129,7 @@ needs embedded or inlined assets. - Fonts and external resources are portable. - Focus decoration is included only when meaningful. - Raster scale is chosen for the target medium. -- A Canvas export intentionally includes or excludes the focus overlay. +- A Canvas export intentionally includes or excludes focus layers. See [Rendering and Export](../reference/rendering-and-export.md) for every function and option. diff --git a/packages/charts-core/docs/guides/migrating.md b/packages/charts-core/docs/guides/migrating.md index ce3f47ea..6954720a 100644 --- a/packages/charts-core/docs/guides/migrating.md +++ b/packages/charts-core/docs/guides/migrating.md @@ -49,6 +49,16 @@ Map each visible layer independently: Then assign explicit scales and guides. Complex charts are usually several ordinary marks sharing a coordinate system, not one specialized chart type. +For the current breaking API: + +- move axis presentation under `axis`; +- use `axis: false` to hide one axis while retaining its scale; +- move candidate count and formatting under `axis.ticks`; +- move rotation and thinning under `axis.tickLabels`; +- use a single bar/area value as stack length, explicit endpoints to opt out, + and `layout: group()` for side-by-side bars; +- replace renderer-specific focus decoration with `whenFocused`. + See [Marks and Layering](../concepts/marks-and-layering.md) and the [Example Gallery](../examples/index.md). diff --git a/packages/charts-core/docs/guides/responsive-charts.md b/packages/charts-core/docs/guides/responsive-charts.md index 326cc78b..75cffaa2 100644 --- a/packages/charts-core/docs/guides/responsive-charts.md +++ b/packages/charts-core/docs/guides/responsive-charts.md @@ -70,7 +70,7 @@ const definition = defineChart({ x: { scale: scaleLinear, nice: true, - label: 'Weekly downloads', + axis: { label: 'Weekly downloads' }, }, y: { scale: () => scaleBand().padding(0.1), @@ -87,9 +87,9 @@ margin: { ``` `margin: 0` locks every side and is appropriate for guide-free sparklines. -Automatic margins guarantee containment, not collision-free labels. Use -`ticks`, `tickRotate`, or a different representation when labels compete for -the same axis space. +Axis labels thin automatically after candidate generation and optional +rotation. Use `axis.ticks.spacing`, `axis.tickLabels.rotate`, hard-kept labels, +or a different representation when labels compete for the same axis space.