Source: #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.
- Background highlight and focused-mark presentation — implemented
- Gridlines without tick stubs — implemented
- Responsive axis labels — implemented
- Fixed tooltip placement — implemented
- Stacked and grouped bar authoring — implemented
- General data transforms — implemented
- 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.
- Bars and both area orientations use one
stackValuesadapter 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.
- Against the compact-scales and opt-in-tooltip
mainbaseline, 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 retaind3-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 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
sourceandsourceIndexes; 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.
- 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
stackValuesengine 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.
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:
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
ChartFocusStatedrives 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.
- 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.
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.
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.
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:
// 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:
guidewithaxis;tickswithaxis.ticks.count;formatwithaxis.ticks.format;tickRotatewithaxis.tickLabels.rotate;labelandlabelOffsetwithaxis.label;- the current coupling between
guide: falseandgrid.
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.
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.
x: {
scale: scaleBand,
axis: {
ticks: {
spacing: 80,
size: 0,
},
tickLabels: {
rotate: -35,
thin: {
minGap: 8,
priority: 'ends',
keep: [launchDate],
},
},
},
}The resolution pipeline is:
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:
ticks: {
spacing: 80
} // length-aware count
ticks: {
count: 5
} // explicit count hint
ticks: {
values: importantDates
} // exact candidatesThe 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:
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 labelsAutomatic 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:
tickLabels: {
thin: {
priority: 'ends',
keep: [launchDate, migrationDate],
},
}priorityinfluences the best collision-free subset.keepguarantees 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.
Allow each tooltip anchor coordinate to select its source independently.
Avoid combinatorial presets such as group-top, pointer-top, and
value-bottom.
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:
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:
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.
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:
barY(rows, {
x: 'date',
y: 'value',
color: 'category',
})Explicit endpoints opt out:
barY(rows, {
x: 'date',
y1: 'start',
y2: 'end',
color: 'category',
})Stack behavior can be configured in the same layout slot used by grouping:
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:
barY(rows, {
x: 'date',
y: 'value',
color: 'category',
layout: group(),
})The fully explicit form supplies series identity independently:
barY(rows, {
x: 'date',
y: 'value',
z: 'category',
color: 'category',
layout: group(),
})Series resolution obeys these rules:
- explicit
zwins; - 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.