diff --git a/.changeset/10516-xaxis-presentation-gate.md b/.changeset/10516-xaxis-presentation-gate.md index 11838f1101..867b4255c9 100644 --- a/.changeset/10516-xaxis-presentation-gate.md +++ b/.changeset/10516-xaxis-presentation-gate.md @@ -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. diff --git a/.changeset/10587-xaxis-position.md b/.changeset/10587-xaxis-position.md new file mode 100644 index 0000000000..f4cb55685e --- /dev/null +++ b/.changeset/10587-xaxis-position.md @@ -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. diff --git a/content/docs/plugins/plugin-charts.mdx b/content/docs/plugins/plugin-charts.mdx index 14afd35477..5ca1e03af6 100644 --- a/content/docs/plugins/plugin-charts.mdx +++ b/content/docs/plugins/plugin-charts.mdx @@ -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. diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index 364cc5f556..d97fcee0ea 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -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. */ @@ -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 `` 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 ``, and the spec `yAxis` configures its + * number `` (`yAxisSpecProps(primaryY)` in that branch). The `xAxis` + * object already follows the categories there — its `format` is that + * ``'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 ( +

+ xAxis.position is{' '} + {refused} —{' '} + {categoriesRunDown ? ( + <>this chart'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's x axis runs across it, so it sits at the top or the bottom. It is drawn at the bottom. + )} +

+ ); +} + +/** + * 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) => {n})}; +} /** * Recharts props derived from one spec axis that plots NUMBERS — the domain @@ -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); @@ -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; @@ -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 @@ -2156,7 +2271,7 @@ function AdvancedChartImplInner({ } return ( @@ -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))} /> + {/* `ComposedChart`, not `BarChart`, is the Recharts container built to host mixed marks. Under `BarChart` an `` child renders nothing @@ -2340,6 +2460,7 @@ function AdvancedChartImplInner({ })} + ); } @@ -2363,6 +2484,7 @@ function AdvancedChartImplInner({ return ( + @@ -2385,6 +2507,9 @@ 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. */} + {/* The spec `xAxis` follows the categories onto this axis, its + `position` included: `left` / `right` are its sides here + (objectui#10587, see `placeXAxis`). */} String(d[xAxisKey] ?? '').length)) * 7))} tickFormatter={xTickFormatter} + {...(xAxisDown ? { orientation: xAxisDown } : {})} /> ) : ( @@ -2488,6 +2614,7 @@ function AdvancedChartImplInner({ })} + ); } diff --git a/packages/plugin-charts/src/ChartRenderer.xAxisPosition-10587.test.tsx b/packages/plugin-charts/src/ChartRenderer.xAxisPosition-10587.test.tsx new file mode 100644 index 0000000000..7f0b43bf9f --- /dev/null +++ b/packages/plugin-charts/src/ChartRenderer.xAxisPosition-10587.test.tsx @@ -0,0 +1,240 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10587 — the spec `xAxis.position` places the x axis. + * + * `position` is one of the keys of `@objectstack/spec`'s `ChartAxisSchema`, + * which `ChartSchema.xAxis` takes by reference; since objectui#10516 the + * normalizer keeps it on the x-axis object. No `` read it, so + * `position: 'top'` validated, survived normalization and drew the axis along + * the bottom. + * + * These go through `ChartRenderer`, the component `type: 'chart'` resolves to, + * with the axis written in the spec's shape, so the normalizer and the + * renderer run as one path. The reading is the rendered DOM: recharts stamps + * each tick label with the `orientation` of the axis that drew it. + * + * The mapping these pin is `placeXAxis`'s: + * - bar / line / area / combo / scatter — the x axis runs across the plot: + * `top` / `bottom` place it, `left` / `right` are refused; + * - horizontal-bar — the `xAxis` object configures the category axis, which + * runs down the plot: `left` / `right` place it, `top` / `bottom` are + * refused. + * A refused side leaves the axis at its default side and adds the + * `x-axis-position` note naming the key. Every case that names an open side + * (and every case that names none) is also the control that no note appears. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; + +// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0x0 +// under the headless DOM, so nothing paints. Hand it a fixed box. +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactElement<{ width?: number; height?: number }> }) => + React.cloneElement(children, { width: 510, height: 350 }), + }; +}); + +import { ChartRenderer } from './ChartRenderer'; +// `ChartRenderer` lazy-loads `./AdvancedChartImpl`. Importing it here with the +// SAME specifier moves that cost into the import phase, which no test timeout +// applies to (AGENTS.md "测试纪律"). +import './AdvancedChartImpl'; + +afterEach(cleanup); + +const ROWS = [ + { month: 'Jan', revenue: 10, cost: 3 }, + { month: 'Feb', revenue: 20, cost: 5 }, + { month: 'Mar', revenue: 15, cost: 4 }, +]; +const CATEGORIES = new Set(ROWS.map((r) => r.month)); + +type Side = 'left' | 'right' | 'top' | 'bottom'; + +async function draw( + chartType: string, + xAxis: Record, + extra: Record = {}, + data: Array> = ROWS, +) { + const { container } = render( + , + ); + await waitFor(() => expect(container.querySelector('.recharts-surface')).toBeTruthy()); + return container; +} + +const tickTexts = (c: HTMLElement) => [...c.querySelectorAll('text.recharts-cartesian-axis-tick-value')]; + +/** The sides the CATEGORY tick labels (`Jan` / `Feb` / `Mar`) were drawn on. */ +const categorySides = (c: HTMLElement) => + [...new Set(tickTexts(c).filter((t) => CATEGORIES.has((t.textContent ?? '').trim())).map((t) => t.getAttribute('orientation')))]; + +/** Every side any tick label was drawn on. */ +const allSides = (c: HTMLElement) => new Set(tickTexts(c).map((t) => t.getAttribute('orientation'))); + +const positionNote = (c: HTMLElement) => c.querySelector('[data-chart-note="x-axis-position"]'); + +const at = (position: Side | undefined) => ({ field: 'month', ...(position ? { position } : {}) }); + +const COMBO = { series: [{ name: 'revenue', type: 'bar' }, { name: 'cost', type: 'line' }] }; + +describe('objectui#10587 — `xAxis.position` places an x axis that runs across the plot', () => { + it.each(['bar', 'line', 'area'])('%s: `top` draws the category axis along the top', async (chartType) => { + const c = await draw(chartType, at('top')); + expect(categorySides(c), '`position: top` was not read by the x axis').toEqual(['top']); + expect(positionNote(c)).toBeNull(); + }); + + it('combo: `top` draws the category axis along the top', async () => { + const c = await draw('bar', at('top'), COMBO); + expect(categorySides(c)).toEqual(['top']); + expect(positionNote(c)).toBeNull(); + }); + + it('scatter: `top` draws the measure x axis along the top', async () => { + const c = await draw('scatter', { field: 'cost', position: 'top' }); + const sides = allSides(c); + expect(sides.has('top'), 'the scatter x axis ignored `position: top`').toBe(true); + expect(sides.has('bottom')).toBe(false); + expect(positionNote(c)).toBeNull(); + }); + + it.each([ + ['bar', 'bottom'], + ['bar', undefined], + ['line', 'bottom'], + ['line', undefined], + ['area', 'bottom'], + ['area', undefined], + ] as const)('(control) %s with position %s draws the category axis along the bottom', async (chartType, position) => { + const c = await draw(chartType, at(position)); + expect(categorySides(c)).toEqual(['bottom']); + expect(positionNote(c)).toBeNull(); + }); + + it('(control) scatter with no position draws its x axis along the bottom', async () => { + const c = await draw('scatter', { field: 'cost' }); + expect(allSides(c).has('bottom')).toBe(true); + expect(allSides(c).has('top')).toBe(false); + }); +}); + +describe('objectui#10587 — on horizontal-bar `xAxis.position` places the category axis, which runs down the plot', () => { + it('`right` draws the category axis on the right', async () => { + const c = await draw('horizontal-bar', at('right')); + expect(categorySides(c), 'horizontal-bar ignored `xAxis.position: right`').toEqual(['right']); + expect(positionNote(c)).toBeNull(); + }); + + it.each(['left', undefined] as const)('(control) position %s draws the category axis on the left', async (position) => { + const c = await draw('horizontal-bar', at(position)); + expect(categorySides(c)).toEqual(['left']); + expect(positionNote(c)).toBeNull(); + }); + + it.each(['top', 'bottom'] as const)('`%s` is refused: the note names `xAxis.position` and the axis stays on the left', async (position) => { + const c = await draw('horizontal-bar', at(position)); + const note = positionNote(c); + expect(note, `horizontal-bar took \`xAxis.position: ${position}\` in silence`).not.toBeNull(); + expect(note!.textContent).toContain('xAxis.position'); + expect(note!.textContent).toContain(position); + // The chart still draws: the category axis sits at its default side. + expect(categorySides(c)).toEqual(['left']); + }); +}); + +describe('objectui#10587 — `left` / `right` on an x axis that runs across the plot are refused, loudly', () => { + it.each([ + ['bar', 'left'], + ['bar', 'right'], + ['line', 'left'], + ['line', 'right'], + ['area', 'left'], + ['area', 'right'], + ] as const)('%s: `%s` adds the note naming `xAxis.position`, and the axis stays at the bottom', async (chartType, position) => { + const c = await draw(chartType, at(position)); + const note = positionNote(c); + expect(note, `\`xAxis.position: ${position}\` was taken in silence`).not.toBeNull(); + expect(note!.textContent).toContain('xAxis.position'); + expect(note!.textContent).toContain(position); + expect(categorySides(c)).toEqual(['bottom']); + }); + + it('combo: `left` adds the same note', async () => { + const c = await draw('bar', at('left'), COMBO); + expect(positionNote(c)).not.toBeNull(); + expect(categorySides(c)).toEqual(['bottom']); + }); + + it('scatter: `right` adds the same note', async () => { + const c = await draw('scatter', { field: 'cost', position: 'right' }); + expect(positionNote(c)).not.toBeNull(); + expect(allSides(c).has('bottom')).toBe(true); + }); +}); + +describe('objectui#10587 — a top x axis keeps its title and rotated labels on the far side from the plot', () => { + /** The rendered `y` of the axis title and of each category tick label. */ + const geometry = (c: HTMLElement) => { + const title = [...c.querySelectorAll('text.recharts-label')].find((t) => (t.textContent ?? '').trim() === 'Month'); + const ticks = tickTexts(c).filter((t) => CATEGORIES.has((t.textContent ?? '').trim())); + return { titleY: Number(title?.getAttribute('y')), tickYs: ticks.map((t) => Number(t.getAttribute('y'))) }; + }; + + it('`top`: the title sits above the tick labels', async () => { + const c = await draw('bar', { ...at('top'), title: 'Month' }); + const { titleY, tickYs } = geometry(c); + expect(tickYs.length).toBe(3); + expect(Number.isFinite(titleY)).toBe(true); + expect(titleY).toBeLessThan(Math.min(...tickYs)); + }); + + it('(control) `bottom`: the title sits below the tick labels', async () => { + const c = await draw('bar', { ...at('bottom'), title: 'Month' }); + const { titleY, tickYs } = geometry(c); + expect(tickYs.length).toBe(3); + expect(titleY).toBeGreaterThan(Math.max(...tickYs)); + }); + + const LONG = [ + { month: 'Quarter One', revenue: 10 }, + { month: 'Quarter Two', revenue: 20 }, + ]; + const rotations = (c: HTMLElement) => + tickTexts(c) + .filter((t) => (t.textContent ?? '').startsWith('Quarter')) + .map((t) => /rotate\((-?\d+)/.exec(t.getAttribute('transform') ?? '')?.[1]); + + it('`top`: long labels slant up, away from the plot', async () => { + const c = await draw('bar', at('top'), {}, LONG); + expect(rotations(c)).toEqual(['35', '35']); + }); + + it('(control) `bottom`: long labels slant down, away from the plot', async () => { + const c = await draw('bar', at('bottom'), {}, LONG); + expect(rotations(c)).toEqual(['-35', '-35']); + }); +});