Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/10516-xaxis-presentation-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ carrying only `field` renders as before.

`position` on `xAxis` now survives normalization too, but at this change no renderer reads
it: the x axis draws along the bottom whatever it says.

⚠️ **Dated note, 2026-09-25 — the renderer now reads `xAxis.position` — objectui#10587.**
Later in this same release the x axis takes the side `position` names where that axis can
run along it, and a side it cannot take draws the default side with a note naming the key.
The sentence above is kept as the reading of this change; the objectui#10587 entry states
what ships.
30 changes: 30 additions & 0 deletions .changeset/10587-xaxis-position.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/plugin-charts': patch
---

A chart's `xAxis.position` now places the x axis, and a side that axis cannot take is
refused with a note that names the key (objectui#10587).

`position` is declared on `@objectstack/spec`'s `ChartAxisSchema`, which `xAxis` takes by
reference, and since objectui#10516 the normalizer keeps it on the x-axis object. No axis
read it, so `xAxis: { field: 'month', position: 'top' }` validated and still drew the axis
along the bottom.

- **bar, line, area and scatter** (and a combo derived from its series' families): the x
axis runs across the plot. `top` draws it along the top; `bottom` draws it along the
bottom, the default.
- **horizontal-bar**: the `xAxis` object configures the category axis, which runs down the
plot there (its `format` already applied to that axis). `left` or `right` puts the
category axis on that side; the default stays the left.
- **A side the axis cannot take** (`left` / `right` across the plot, `top` / `bottom` on
horizontal-bar) draws the axis at its default side, and the chart shows a note under the
plot (`data-chart-note="x-axis-position"`) naming `xAxis.position`, the value, and the
side the axis was drawn on. The value still validates: `ChartAxisSchema` is shared by the
x and y axes, and this change does not narrow it.

A top axis also mirrors its title and its rotated tick labels, so both stay on the far side
of the axis from the plot.

**User-visible render change** only for a chart whose `xAxis` declares `position`. The
bottom / no-position controls in `ChartRenderer.xAxisPosition-10587.test.tsx` pin that a
chart declaring none still draws its x axis along the bottom with no note.
2 changes: 1 addition & 1 deletion content/docs/plugins/plugin-charts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ const schema: ChartSchema = {
| `min` / `max` | `number` | Pin the ends of a numeric axis: the value axes, and the x axis of a `scatter` chart. |
| `stepSize` | `number` | Distance between ticks on a numeric axis. |
| `showGridLines` | `boolean` | `false` on the first `yAxis` entry hides the horizontal grid lines; `true` on `xAxis` draws vertical ones. |
| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | The side of the chart the axis belongs to. |
| `position` | `'left' \| 'right' \| 'top' \| 'bottom'` | The side of the chart the axis belongs to. On `xAxis` it is one of the two sides that axis can run along: `top` or `bottom` on bar (`column`), line, area and `scatter` charts, whose x axis runs across the plot; `left` or `right` on a `horizontal-bar` chart, where `xAxis` configures the category axis running down the plot. Any other side draws the axis at its default side (the bottom; the left on `horizontal-bar`), and the chart shows a note under the plot naming `xAxis.position`. On `yAxis` it does not yet reliably set the side the axis is drawn on (objectui#10654, the y-side twin of objectui#10587). |
| `logarithmic` | `boolean` | Logarithmic scale on a numeric axis. |

A bare string is not an axis object. `xAxis: 'month'` is the sibling spelling of `xAxisKey` and folds onto it at parse; a single object or a bare string on `yAxis` is refused — write a one-entry list.
Expand Down
141 changes: 134 additions & 7 deletions packages/plugin-charts/src/AdvancedChartImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ export interface AdvancedChartImplProps {
onChartClick?: (event: ChartSegmentClickEvent) => void;
/**
* Spec `ChartAxis` presentation for the category axis — `format` (tick
* formatter), `title`, `showGridLines`. Its `field` already arrived as
* formatter), `title`, `showGridLines`, `position` (the side it is drawn
* on — see `placeXAxis`), and on scatter's measure x axis `min` / `max` /
* `stepSize` / `logarithmic` as well. Its `field` already arrived as
* {@link AdvancedChartImplProps.xAxisKey}. Resolved by
* `normalizeChartSchema`, so the renderer never parses the author shape.
*/
Expand Down Expand Up @@ -490,6 +492,99 @@ const SCATTER_Y_AXIS_PADDING = {
*/
const Y_AXIS_TITLE_LAYOUT = { angle: -90, position: 'insideLeft' } as const;
const X_AXIS_TITLE_LAYOUT = { position: 'insideBottom', offset: -4 } as const;
/**
* The x-axis title of an axis drawn along the TOP (objectui#10587): the
* bottom layout mirrored, so the title sits on the far side of the tick labels
* from the plot, as it does below a bottom axis. `insideBottom` on a top axis
* would put it between the ticks and the plot.
*/
const X_AXIS_TOP_TITLE_LAYOUT = { position: 'insideTop', offset: -4 } as const;

/** The x-axis title layout for the side {@link placeXAxis} put the axis on. */
function xAxisTitleLayoutFor(across: 'top' | 'bottom' | undefined) {
return across === 'top' ? X_AXIS_TOP_TITLE_LAYOUT : X_AXIS_TITLE_LAYOUT;
}

/** The four sides the spec `ChartAxis.position` enumerates. */
type AxisSide = 'left' | 'right' | 'top' | 'bottom';

/**
* Where the spec `xAxis` is drawn, read from its `position` (objectui#10587).
*
* The spec declares `position` on `ChartAxisSchema` for both axes, and the
* docs row says it names "the side of the chart the axis belongs to". Which two
* sides are open to the x axis depends on which way that axis runs, and the
* answer is read off the branches below rather than decided here:
*
* - **bar / line / area / combo / scatter** draw the spec `xAxis` as a
* recharts `<XAxis>` running ACROSS the plot (scatter's is a number axis,
* the rest a category band), so `top` / `bottom` are its sides.
* - **horizontal-bar** swaps the axis roles. Its categories run DOWN the plot
* on a `<YAxis type="category">`, and the spec `yAxis` configures its
* number `<XAxis>` (`yAxisSpecProps(primaryY)` in that branch). The `xAxis`
* object already follows the categories there — its `format` is that
* `<YAxis>`'s tick formatter — so its `position` follows them too, and
* `left` / `right` are its sides. Reading it as "the horizontal axis"
* instead would split one axis object across two drawn axes.
*
* A side the axis cannot take is REFUSED, not dropped and not guessed at: the
* axis draws at its default side (the bottom; the left on horizontal-bar) and
* the chart carries {@link xAxisPositionNote}. ⛔ Never mapped to a "nearest"
* open side: `left` names no side of an axis that runs across the plot, and
* any side chosen for it would be one the author did not write.
*
* No `position` returns nothing, so no `orientation` prop is emitted and a chart
* that declares none renders exactly as it did before.
*/
function placeXAxis(
position: AxisSide | undefined,
categoriesRunDown: boolean,
): { across?: 'top' | 'bottom'; down?: 'left' | 'right'; refused?: AxisSide } {
if (!position) return {};
if (categoriesRunDown) {
return position === 'left' || position === 'right' ? { down: position } : { refused: position };
}
return position === 'top' || position === 'bottom' ? { across: position } : { refused: position };
}

/**
* The note a cartesian chart carries when its `xAxis.position` names a side
* that axis cannot take — see {@link placeXAxis}. `null` otherwise, which keeps
* every other chart's DOM unchanged (`ChartFootnote` with no note returns its
* children as they are).
*
* The chart still draws: the axis sits at its default side, and every other
* key on the axis object is honoured. That is why this is a note in the
* `ChartFootnote` channel the file's other partial answers use, and not a
* `ChartRefusal`, which replaces the whole tile — a refusal there would blank a
* chart that plots correctly over one presentation key. No console warning
* either, for the reason the other notes give: the sentence already names the
* key, the value and the side the axis was drawn on.
*/
function xAxisPositionNote(refused: AxisSide | undefined, categoriesRunDown: boolean): React.ReactNode {
if (!refused) return null;
return (
<p role="note" data-chart-note="x-axis-position" className="px-1 text-xs text-muted-foreground">
<code className="font-mono">xAxis.position</code> is{' '}
<code className="font-mono">{refused}</code> &mdash;{' '}
{categoriesRunDown ? (
<>this chart&apos;s categories run down its side, so its x axis sits on the left or the right. It is drawn on the left.</>
) : (
<>this chart&apos;s x axis runs across it, so it sits at the top or the bottom. It is drawn at the bottom.</>
)}
</p>
);
}

/**
* Several notes for one `ChartFootnote`. With one note (or none) it returns that
* note itself, so a chart that already carried a single note keeps its exact DOM.
*/
function joinNotes(...notes: React.ReactNode[]): React.ReactNode {
const present = notes.filter((n) => n != null && n !== false);
if (present.length <= 1) return present[0] ?? null;
return <>{present.map((n, i) => <React.Fragment key={i}>{n}</React.Fragment>)}</>;
}

/**
* Recharts props derived from one spec axis that plots NUMBERS — the domain
Expand All @@ -508,7 +603,7 @@ const X_AXIS_TITLE_LAYOUT = { position: 'insideBottom', offset: -4 } as const;
function numericAxisSpecProps(
axis: NormalizedAxis | undefined,
values: number[],
labelLayout: typeof Y_AXIS_TITLE_LAYOUT | typeof X_AXIS_TITLE_LAYOUT,
labelLayout: typeof Y_AXIS_TITLE_LAYOUT | typeof X_AXIS_TITLE_LAYOUT | typeof X_AXIS_TOP_TITLE_LAYOUT,
) {
if (!axis) return {};
const domain = domainFor(axis);
Expand Down Expand Up @@ -1579,6 +1674,20 @@ function AdvancedChartImplInner({
horizontal: showYGrid,
};

// Spec `xAxis.position` — which side the category axis sits on, or the side
// it was refused (objectui#10587; see `placeXAxis`). `chartType` is the
// EFFECTIVE family: a bar / line / area whose series disagree is placed as
// the vertical combo it became; `comboBaseFamily` never widens a
// horizontal-bar, so one with mixed series stays horizontal and keeps the
// `left` / `right` reading.
const categoriesRunDown = chartType === 'horizontal-bar';
const {
across: xAxisAcross,
down: xAxisDown,
refused: xAxisPositionRefused,
} = placeXAxis(xAxisSpec?.position, categoriesRunDown);
const xPositionNote = xAxisPositionNote(xAxisPositionRefused, categoriesRunDown);

// Legend is on unless the author turned it off (spec default `true`).
const legendVisible = showLegend !== false;

Expand Down Expand Up @@ -1688,9 +1797,15 @@ function AdvancedChartImplInner({
? { interval: 0 as const }
: { interval: 'preserveStartEnd' as const, minTickGap: 0 }),
tickFormatter: xAxisTickFormatter,
...(xAxisSpec?.title ? { label: { value: xAxisSpec.title, ...X_AXIS_TITLE_LAYOUT } } : {}),
...(rotateXLabels && { angle: -35, textAnchor: 'end' as const, height: 60 }),
}), [labelEveryBucket, rotateXLabels, xAxisTickFormatter, xAxisSpec?.title]);
// objectui#10587 — the declared side, emitted only when one was declared
// and is open to this axis (see `placeXAxis`).
...(xAxisAcross ? { orientation: xAxisAcross } : {}),
...(xAxisSpec?.title ? { label: { value: xAxisSpec.title, ...xAxisTitleLayoutFor(xAxisAcross) } } : {}),
// A rotated label hangs AWAY from the plot: down-left under a bottom axis
// (`-35`), and up-left over a top one (`35`) — the same `-35` above the
// plot would slant every label down into the marks.
...(rotateXLabels && { angle: xAxisAcross === 'top' ? 35 : -35, textAnchor: 'end' as const, height: 60 }),
}), [labelEveryBucket, rotateXLabels, xAxisTickFormatter, xAxisSpec?.title, xAxisAcross]);

// #2942 — the non-series spec families used to fall through the component
// map's `|| BarChart` into a bar shell whose series marks all returned
Expand Down Expand Up @@ -2156,7 +2271,7 @@ function AdvancedChartImplInner({
}
return (
<ChartFootnote
note={unplottedPointsNote(points.plottable, points.total, xAxisKey, scatterYKey)}
note={joinNotes(unplottedPointsNote(points.plottable, points.total, xAxisKey, scatterYKey), xPositionNote)}
>
<ChartContainer config={config} className={className} {...containerProps}>
<ScatterChart>
Expand Down Expand Up @@ -2187,7 +2302,11 @@ function AdvancedChartImplInner({
minTickGap={isMobile ? 32 : 48}
padding={SCATTER_X_AXIS_PADDING}
{...(scatterXTickFormatter ? { tickFormatter: scatterXTickFormatter } : {})}
{...numericAxisSpecProps(xAxisSpec, scatterXValues, X_AXIS_TITLE_LAYOUT)}
// objectui#10587 — scatter's measure axis runs across the plot like
// every category x axis, so `top` / `bottom` are its sides too
// (see `placeXAxis`).
{...(xAxisAcross ? { orientation: xAxisAcross } : {})}
{...numericAxisSpecProps(xAxisSpec, scatterXValues, xAxisTitleLayoutFor(xAxisAcross))}
/>
<YAxis
type="number"
Expand Down Expand Up @@ -2272,6 +2391,7 @@ function AdvancedChartImplInner({
if (chartType === 'combo') {
return (
<ChartFrame title={title} subtitle={subtitle}>
<ChartFootnote note={xPositionNote}>
<ChartContainer config={config} className={className} {...containerProps}>
{/* `ComposedChart`, not `BarChart`, is the Recharts container built to
host mixed marks. Under `BarChart` an `<Area>` child renders nothing
Expand Down Expand Up @@ -2340,6 +2460,7 @@ function AdvancedChartImplInner({
})}
</ComposedChart>
</ChartContainer>
</ChartFootnote>
</ChartFrame>
);
}
Expand All @@ -2363,6 +2484,7 @@ function AdvancedChartImplInner({

return (
<ChartFrame title={title} subtitle={subtitle}>
<ChartFootnote note={xPositionNote}>
<ChartContainer config={config} className={className} {...containerProps}>
<ChartComponent data={data} layout={isHorizontal ? 'vertical' : 'horizontal'} {...cartesianClickProps}>
<defs>
Expand All @@ -2385,13 +2507,17 @@ function AdvancedChartImplInner({
{/* Horizontal bars swap the axis roles: the VALUE axis is x, so the
spec y-axis config (domain/format/scale) applies to it. */}
<XAxis type="number" tickLine={false} axisLine={false} tickFormatter={yTickFormatter} {...yAxisSpecProps(primaryY)} />
{/* The spec `xAxis` follows the categories onto this axis, its
`position` included: `left` / `right` are its sides here
(objectui#10587, see `placeXAxis`). */}
<YAxis
type="category"
dataKey={xAxisKey}
tickLine={false}
axisLine={false}
width={Math.min(140, Math.max(60, Math.max(...data.map(d => String(d[xAxisKey] ?? '').length)) * 7))}
tickFormatter={xTickFormatter}
{...(xAxisDown ? { orientation: xAxisDown } : {})}
/>
</>
) : (
Expand Down Expand Up @@ -2488,6 +2614,7 @@ function AdvancedChartImplInner({
})}
</ChartComponent>
</ChartContainer>
</ChartFootnote>
</ChartFrame>
);
}
Expand Down
Loading
Loading