@astryxdesign/charts 0.6.2-canary.bfee8a4 → 0.6.2-canary.d4cc232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ export interface ChartAxisProps {
7
7
  maxTicks?: number;
8
8
  /** Custom tick formatter */
9
9
  tickFormat?: (value: unknown) => string;
10
- /** Truncate labels to this many characters (appends "\u2026"). */
10
+ /** Truncate labels to this many user-perceived characters (appends "\u2026"). */
11
11
  truncate?: number;
12
12
  /** Enable smooth transitions for streaming (default: true) */
13
13
  animated?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"ChartAxis.d.ts","sourceRoot":"","sources":["../src/ChartAxis.tsx"],"names":[],"mappings":"AA0BA,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,QAAQ,EAAE,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9C,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,CAAC;IACxC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAeD;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,EACxB,QAAQ,EACR,SAAa,EACb,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,QAAe,EACf,YAAoC,EACpC,SAAiB,GAClB,EAAE,cAAc,+BAuLhB"}
1
+ {"version":3,"file":"ChartAxis.d.ts","sourceRoot":"","sources":["../src/ChartAxis.tsx"],"names":[],"mappings":"AA4BA,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,QAAQ,EAAE,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9C,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4BAA4B;IAC5B,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,CAAC;IACxC,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAeD;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,EACxB,QAAQ,EACR,SAAa,EACb,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,QAAe,EACf,YAAoC,EACpC,SAAiB,GAClB,EAAE,cAAc,+BA4LhB"}
package/dist/ChartAxis.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @file ChartAxis.tsx (v2)
5
+ * @input Chart context scales, axis presentation props, and grapheme-safe text utilities
5
6
  * @output Renders an axis (top, right, bottom, left) using the chart's scales
6
7
  * @position Child of Chart v2; reads scales from chart context
7
8
  *
@@ -19,6 +20,7 @@
19
20
 
20
21
  import { useCallback, useMemo } from 'react';
21
22
  import * as stylex from '@stylexjs/stylex';
23
+ import { characterCount, truncateCharacters } from '@astryxdesign/core/utils';
22
24
  import '@astryxdesign/core/theme/tokens.stylex';
23
25
  import { colorVars } from '@astryxdesign/core/theme/tokens.stylex';
24
26
  import { useChart } from "./ChartContext.js";
@@ -85,20 +87,28 @@ export function ChartAxis({
85
87
  }, [tickFormat, scale, safeTickCount]);
86
88
  const format = useCallback(value => {
87
89
  const str = (tickFormat ?? autoFormat ?? String)(value);
88
- return truncate && str.length > truncate ? str.slice(0, truncate) + '\u2026' : str;
90
+ if (!truncate) {
91
+ return str;
92
+ }
93
+ const shortened = truncateCharacters(str, truncate, '');
94
+ return shortened === str ? str : shortened + '\u2026';
89
95
  }, [tickFormat, autoFormat, truncate]);
90
96
  const ticks = useMemo(() => {
97
+ // Format each generated tick once so density and rendering share the same
98
+ // grapheme-safe label instead of repeating consumer formatting/segmentation.
91
99
  let allTicks;
92
100
  if (isBandScale(scale)) {
93
101
  allTicks = scale.domain().map(d => ({
94
102
  value: d,
95
- offset: (scale(d) ?? 0) + scale.bandwidth() / 2
103
+ offset: (scale(d) ?? 0) + scale.bandwidth() / 2,
104
+ label: format(d)
96
105
  }));
97
106
  } else {
98
107
  const linearScale = scale;
99
108
  allTicks = linearScale.ticks(safeTickCount).map(d => ({
100
109
  value: d,
101
- offset: linearScale(d)
110
+ offset: linearScale(d),
111
+ label: format(d)
102
112
  }));
103
113
  }
104
114
 
@@ -111,7 +121,7 @@ export function ChartAxis({
111
121
  let cap = maxTicks;
112
122
  if (cap == null && allTicks.length > 1) {
113
123
  if (isHorizontal && width > 0) {
114
- const widestChars = allTicks.reduce((m, t) => Math.max(m, format(t.value).length), 1);
124
+ const widestChars = allTicks.reduce((m, tick) => Math.max(m, characterCount(tick.label)), 1);
115
125
  const approxLabelPx = widestChars * 7 + 16;
116
126
  cap = Math.max(1, Math.floor(width / approxLabelPx));
117
127
  } else if (!isHorizontal && height > 0) {
@@ -157,9 +167,9 @@ export function ChartAxis({
157
167
  }
158
168
  }), ticks.map(({
159
169
  value,
160
- offset
170
+ offset,
171
+ label
161
172
  }) => {
162
- const label = format(value);
163
173
  // Key off the raw tick value, not the formatted label: distinct ticks
164
174
  // can format (or truncate) to the same string and would collide as keys.
165
175
  const key = String(value);
@@ -1 +1 @@
1
- {"version":3,"file":"ChartGrid.d.ts","sourceRoot":"","sources":["../src/ChartGrid.tsx"],"names":[],"mappings":"AAeA,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AASD;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,EACxB,UAAiB,EACjB,QAAgB,EAChB,SAAa,GACd,EAAE,cAAc,+BAmEhB"}
1
+ {"version":3,"file":"ChartGrid.d.ts","sourceRoot":"","sources":["../src/ChartGrid.tsx"],"names":[],"mappings":"AAgBA,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AASD;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,EACxB,UAAiB,EACjB,QAAgB,EAChB,SAAa,GACd,EAAE,cAAc,+BAmEhB"}
package/dist/ChartGrid.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @file ChartGrid.tsx (v2)
5
+ * @input Chart context scales and dimensions, guide visibility, and tick density
5
6
  * @output Grid lines behind chart marks
6
7
  * @position Child of Chart v2; reads scales from chart context
7
8
  */
@@ -2,10 +2,10 @@
2
2
 
3
3
  /**
4
4
  * @file ChartSwatch.tsx
5
+ * @input Caller-owned CSS color and optional square or line shape
5
6
  * @output Color swatch primitive for legends, tooltips, and standalone use
6
- * @position Composable primitive — used by ChartLegend (and future
7
- * ChartTooltip), or independently anywhere a series indicator
8
- * is needed.
7
+ * @position Composable primitive — used by ChartLegend and ChartTooltip, or
8
+ * independently anywhere a series indicator is needed.
9
9
  *
10
10
  * @example
11
11
  * ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/charts",
3
- "version": "0.6.2-canary.bfee8a4",
3
+ "version": "0.6.2-canary.d4cc232",
4
4
  "description": "Astryx charts — a config-model data visualization library (d3-based). Published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@stylexjs/stylex": ">=0.10.0",
50
- "@astryxdesign/core": "0.6.2-canary.bfee8a4",
50
+ "@astryxdesign/core": "0.6.2-canary.d4cc232",
51
51
  "react": ">=19.0.0",
52
52
  "react-dom": ">=19.0.0"
53
53
  },
@@ -57,7 +57,7 @@
57
57
  "d3-shape": "^3.2.0"
58
58
  },
59
59
  "devDependencies": {
60
- "@astryxdesign/cli": "0.6.2-canary.bfee8a4",
60
+ "@astryxdesign/cli": "0.6.2-canary.d4cc232",
61
61
  "@babel/cli": "^7.29.7",
62
62
  "@babel/core": "^7.29.7",
63
63
  "@babel/preset-react": "^7.29.7",
@@ -0,0 +1,162 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
4
+
5
+ export const docs = {
6
+ name: 'ChartAxis',
7
+ displayName: 'Chart Axis',
8
+ group: 'Charts',
9
+ category: 'Data Visualization',
10
+ isHiddenFromOverview: true,
11
+ keywords: ['chart', 'axis', 'ticks', 'labels', 'scale', 'data visualization'],
12
+
13
+ usage: {
14
+ description:
15
+ 'ChartAxis renders tick labels and optional edge and tick lines from the scales owned by a parent Chart. Use it in the Chart axes slot for physical top, right, bottom, or left plot edges.',
16
+ bestPractices: [
17
+ {
18
+ guidance: true,
19
+ description:
20
+ 'Use ChartAxis inside Chart so its labels, ticks, and edge line share the same scales and plot dimensions as the marks.',
21
+ },
22
+ {
23
+ guidance: true,
24
+ description:
25
+ 'Use tickFormat for product-specific units or locale formatting, and maxTicks or truncate when dense category labels need a bounded presentation.',
26
+ },
27
+ {
28
+ guidance: false,
29
+ description:
30
+ 'Use axis labels or a pointer tooltip as the only way to obtain important values. Give Chart an accessible name and preserve an equivalent data view or summary.',
31
+ },
32
+ {
33
+ guidance: false,
34
+ description:
35
+ 'Hide the edge line while showing tick marks. showTicks intentionally keeps the line visible so each mark has a grounded edge.',
36
+ },
37
+ ],
38
+ accessibility: [
39
+ {
40
+ name: 'Chart alternative',
41
+ category: 'Content',
42
+ criterion: '1.1.1 Non-text Content',
43
+ requirement: 'Required on the parent Chart',
44
+ states: ['All'],
45
+ description:
46
+ 'Chart owns the accessible image name and supported small-data table. Tick labels supplement that chart-level alternative rather than replacing it.',
47
+ },
48
+ {
49
+ name: 'Label text integrity',
50
+ category: 'Content',
51
+ criterion: 'Unicode grapheme clusters',
52
+ requirement: 'Required when truncate is set',
53
+ states: ['Truncated labels'],
54
+ description:
55
+ 'Truncation keeps emoji, joined emoji sequences, flags, and combining marks intact before appending the ellipsis.',
56
+ },
57
+ ],
58
+ anatomy: [
59
+ {
60
+ name: 'Axis edge line',
61
+ required: false,
62
+ description:
63
+ 'Line along the selected plot edge. It is shown by default only on the bottom edge and whenever tick marks are shown.',
64
+ },
65
+ {
66
+ name: 'Tick marks',
67
+ required: false,
68
+ description:
69
+ 'Short lines extending outward from each retained tick position.',
70
+ },
71
+ {
72
+ name: 'Tick labels',
73
+ required: true,
74
+ description:
75
+ 'Formatted scale values or categories positioned outside the selected plot edge.',
76
+ },
77
+ ],
78
+ },
79
+
80
+ props: [
81
+ {
82
+ name: 'position',
83
+ type: "'top' | 'right' | 'bottom' | 'left'",
84
+ description: 'Physical plot edge on which to render the axis.',
85
+ required: true,
86
+ },
87
+ {
88
+ name: 'tickCount',
89
+ type: 'number',
90
+ description:
91
+ 'Approximate continuous-scale tick count. d3 chooses the final values.',
92
+ default: '5',
93
+ },
94
+ {
95
+ name: 'maxTicks',
96
+ type: 'number',
97
+ description:
98
+ 'Maximum displayed label count. Labels are evenly skipped when the generated set is larger.',
99
+ },
100
+ {
101
+ name: 'tickFormat',
102
+ type: '(value: unknown) => string',
103
+ description:
104
+ 'Formats each retained tick. When omitted, continuous scales use d3 formatting and band scales use their category strings.',
105
+ },
106
+ {
107
+ name: 'truncate',
108
+ type: 'number',
109
+ description:
110
+ 'Maximum content characters retained before an ellipsis is appended. User-perceived characters remain intact.',
111
+ },
112
+ {
113
+ name: 'animated',
114
+ type: 'boolean',
115
+ description:
116
+ 'Whether tick position and visibility changes use the built-in transition.',
117
+ default: 'true',
118
+ },
119
+ {
120
+ name: 'showAxisLine',
121
+ type: 'boolean',
122
+ description:
123
+ 'Whether to draw the edge line. Tick marks force the line on so they remain grounded.',
124
+ default: 'true for bottom; false for top, right, and left',
125
+ },
126
+ {
127
+ name: 'showTicks',
128
+ type: 'boolean',
129
+ description: 'Whether to draw an outward mark at each retained tick.',
130
+ default: 'false',
131
+ },
132
+ ],
133
+
134
+ examples: [
135
+ {
136
+ label: 'Bottom and left axes',
137
+ code: `import {Chart, ChartAxis, ChartGrid, bar} from '@astryxdesign/charts';
138
+
139
+ <Chart
140
+ data={monthlyRevenue}
141
+ xKey="month"
142
+ series={[bar('revenue')]}
143
+ grid={<ChartGrid horizontal />}
144
+ axes={
145
+ <>
146
+ <ChartAxis position="bottom" />
147
+ <ChartAxis position="left" tickFormat={value => String(value)} />
148
+ </>
149
+ }
150
+ />;`,
151
+ },
152
+ {
153
+ label: 'Dense category labels',
154
+ code: `<ChartAxis
155
+ position="bottom"
156
+ maxTicks={8}
157
+ truncate={12}
158
+ showTicks
159
+ />;`,
160
+ },
161
+ ],
162
+ };
@@ -0,0 +1,211 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 5
4
+ kind: component
5
+ id: component:ChartAxis
6
+ authority: draft
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: null
10
+ approved_at: null
11
+ owners: [cixzhang]
12
+ review_triggers: [public-api, behavior, layout, theming, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/charts/src/ChartAxis.test.tsx,
16
+ apps/storybook/stories/charts/ChartAxis.stories.tsx,
17
+ ]
18
+ modules: []
19
+ families: []
20
+ design_specs: []
21
+ architecture:
22
+ [
23
+ architecture:component-test-sufficiency,
24
+ architecture:component-theming-surface,
25
+ ]
26
+ contributing: []
27
+ system_specs: [spec:AST-002, spec:AST-029]
28
+ ---
29
+
30
+ # ChartAxis component contract
31
+
32
+ ## Contract at a glance
33
+
34
+ | Area | Contract |
35
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | Public contract | No API shape or default changes. Truncation and automatic horizontal density now count complete grapheme clusters under the objective Unicode text-segmentation standard. |
37
+ | Behavior | The selected physical plot edge determines scale, placement, label direction, and the default edge line; explicit formatting and density inputs refine labels. |
38
+ | End-user impact | Chart readers keep complete tick-label characters, and equally short Unicode labels are no longer thinned more aggressively than ASCII labels. |
39
+ | Builder impact | None. Existing callsites remain valid and receive direct CLI documentation for the current surface. |
40
+ | Compatibility/readiness | The canary API and defaults are preserved. This record is draft observational evidence; rendered and accessibility-tree evidence remains incomplete. |
41
+ | Review checks | Reject a changed public shape/default, an axis using the wrong Chart scale, broken physical-edge placement, split grapheme labels, or hidden line/tick precedence. |
42
+ | Governing rules | `spec:AST-029/FR3–FR7`, `architecture:component-test-sufficiency/INV1–INV9`, and [Unicode Text Segmentation](https://www.unicode.org/reports/tr29/). |
43
+
44
+ This table is a review projection; the body below is authoritative.
45
+
46
+ ## Intent
47
+
48
+ ChartAxis renders labelled Cartesian x or y scale output inside a Chart. It owns
49
+ physical-edge placement, tick selection and formatting, optional tick marks, and the
50
+ optional axis edge line while Chart owns the scale domains and plot geometry.
51
+
52
+ ## Compatibility and migration
53
+
54
+ - Released default preserved: yes; the package is canary-only and has no stable release.
55
+ - Compatibility class: no public API or default changes; Unicode truncation and automatic thinning intentionally change only labels containing multi-code-unit graphemes, while representative ASCII behavior is preserved.
56
+ - Controlled/uncontrolled behavior: not applicable.
57
+ - Migration decision: none; this draft does not authorize a public API change.
58
+
59
+ Consumer migration instructions belong in consumer docs and release notes.
60
+
61
+ ## Ownership boundary
62
+
63
+ **Owns**
64
+
65
+ - Selecting the Chart-provided x or y scale for one physical plot edge.
66
+ - Generating, formatting, thinning, truncating, positioning, and hiding tick labels.
67
+ - Rendering its optional edge line and optional outward tick marks.
68
+ - Applying optional transition styling to tick position and visibility changes.
69
+
70
+ **Does not own / non-goals**
71
+
72
+ - Data, domains, scale construction, or plot dimensions — owned by `component:Chart`.
73
+ - Grid lines — owned by `component:ChartGrid`.
74
+ - Product-specific units, locale choices, or label wording supplied through `tickFormat` — owned by the product callsite.
75
+ - A standalone chart data alternative — owned by `component:Chart` and its surrounding product content.
76
+
77
+ ## Public concepts
78
+
79
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
80
+ | ---------------- | -------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------ | --------------------- | ------------ | ------------------------------------------------------------------------------- |
81
+ | Physical edge | `top`, `right`, `bottom`, `left` | Chooses the plot edge, scale axis, transform, and outward label side. | Required for every axis. | none | `component:ChartAxis` | experimental | Unknown values are rejected by TypeScript. |
82
+ | Tick target | finite number | Requests an approximate continuous-scale tick count from d3. | Continuous scales; also configures the automatic formatter. | `5` | `component:ChartAxis` | experimental | Negative/non-finite values fall back to 5; fractions floor; values cap at 1000. |
83
+ | Label cap | number or absent | Evenly skips labels when the generated set exceeds the requested cap. | Every scale. | automatic from available space | `component:ChartAxis` | experimental | Values outside the intended positive-count domain are not specified. |
84
+ | Label formatter | `(value: unknown) => string` or absent | Replaces the band string or d3 continuous-scale formatter. | Every tick. | scale-appropriate built-in formatter | `component:ChartAxis` | experimental | Exceptions from consumer formatters propagate. |
85
+ | Label truncation | positive character count or absent | Keeps that many user-perceived characters, then appends an ellipsis. | Every formatted label. | absent | `component:ChartAxis` | experimental | Zero/omitted disables truncation; other invalid values are not specified. |
86
+ | Edge line | shown or hidden | Renders a line along the selected axis edge. | Every edge; tick marks force it on. | shown only for `bottom` | `component:ChartAxis` | experimental | Not applicable. |
87
+ | Tick marks | shown or hidden | Renders an outward perpendicular mark beside each visible tick label. | Every edge. | hidden | `component:ChartAxis` | experimental | Not applicable. |
88
+ | Tick transition | animated or immediate | Animates tick transform and opacity during scale changes. | Generated ticks. | animated | `component:ChartAxis` | experimental | Not applicable. |
89
+
90
+ ## Behavioral and layout contract
91
+
92
+ Draft requirements identify their basis so observed code is not mistaken for an
93
+ intentional decision.
94
+
95
+ | ID | Candidate invariant | Basis | Draft review state |
96
+ | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------ |
97
+ | FR1 | A top or bottom axis MUST consume Chart's x scale; a left or right axis MUST consume the categorical y scale when present and the linear y scale otherwise. | implementation, types, and focused tests | verify |
98
+ | FR2 | Continuous tick generation MUST use one sanitized count for both d3 tick generation and its default formatter; band scales MUST use their complete domain before thinning. | implementation | verify |
99
+ | FR3 | A supplied `tickFormat` MUST win; otherwise continuous scales use d3 formatting and band values use `String`. | implementation and focused tests | verify |
100
+ | FR4 | Explicit `maxTicks` MUST win over the size-derived cap. | implementation and focused tests | verify |
101
+ | FR5 | Truncation MUST keep complete Unicode grapheme clusters while preserving the documented content-count-plus-ellipsis output. | focused red/green test and Unicode Standard Annex #29 | settled |
102
+ | FR6 | Automatic horizontal density MUST estimate formatted label length in user-perceived characters rather than UTF-16 storage units. | focused red/green equivalence test and Unicode segmentation | settled |
103
+ | FR7 | Bottom MUST show an edge line by default and the other edges MUST hide it by default. `showTicks` MUST force the edge line on and render one mark per surviving tick. | implementation, JSDoc, focused tests, and Storybook | verify |
104
+ | FR8 | Bottom and right axes MUST translate to the corresponding plot edge. Top and left remain at the plot origin. Labels and tick marks extend outwards from that physical edge. | implementation and focused tests | verify |
105
+ | FR9 | On a mixed-sign linear y domain, the bottom edge line MUST align with y=0; categorical y leaves it on the plot edge. | implementation | verify |
106
+ | FR10 | Tick movement and visibility MAY transition when `animated` is true and MUST be immediate when false. Out-of-range ticks remain mounted with zero opacity during updates. | implementation and streaming examples; motion intent unsettled | human decision |
107
+
108
+ ### Allowed variation
109
+
110
+ - **AV1 — Tick count.** d3 may choose a nearby continuous tick count rather than the requested number.
111
+ - **AV2 — Label set.** Automatic and explicit density limits may omit evenly spaced labels while preserving their scale order.
112
+ - **AV3 — Supplied text.** Product callsites may format values in their own units and locale through `tickFormat`.
113
+
114
+ ### Representative states
115
+
116
+ | State | Required invariant | Allowed variation |
117
+ | --------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------- |
118
+ | Bottom categorical | Band labels are centered; the edge line is shown by default. | Automatic density may omit labels. |
119
+ | Top categorical | Band labels render outside the top edge. | Edge line remains hidden unless requested. |
120
+ | Left continuous | d3 labels render outside the left edge. | d3 chooses exact tick values. |
121
+ | Right continuous | Axis translates to the right edge and labels render outside it. | d3 chooses exact tick values. |
122
+ | Vertical categorical | Row labels use `yBandScale` and align with band centers. | Automatic density may omit labels. |
123
+ | Explicit formatting | Every retained label uses the supplied formatter. | Consumer-owned wording and units. |
124
+ | Truncated label | Complete grapheme clusters precede the appended ellipsis. | The caller chooses the content-character limit. |
125
+ | Tick marks shown | Every retained label has an outward mark and an edge line. | Edge-line visibility input is subsumed in this state. |
126
+ | Mixed-sign linear y data | Bottom line represents y=0 rather than the plot boundary. | Exact pixel position follows the supplied domain. |
127
+ | Streaming / changing domain | Tick positions and visibility update; animation follows `animated`. | Tick identity follows the raw value. |
128
+
129
+ ### Transformation and precedence order
130
+
131
+ - **ORD1 — Scale.** Resolve physical orientation → choose Chart scale → generate and format each tick once → derive the grapheme-aware density cap → evenly filter labels.
132
+ - **ORD2 — Label.** Apply the supplied formatter or the scale default → preserve complete grapheme clusters when truncating → render the result.
133
+ - **ORD3 — Edge.** Resolve the position default → apply `showAxisLine` → force the line on when `showTicks` is true.
134
+
135
+ ### Performance and resources
136
+
137
+ - **PR1 — Bounded continuous ticks.** Sanitization caps the d3 request at 1000 so a consumer value cannot allocate an unbounded tick array.
138
+ - **PR2 — Derived tick state.** Tick generation and formatting remain render-derived and memoized; each generated label is formatted once and shared by density and rendering, and ChartAxis owns no listener, observer, timer, or persistent resource.
139
+
140
+ ## Accessibility contract
141
+
142
+ - **AR1 — Visible text integrity.** A truncated tick label MUST preserve complete user-perceived characters under Unicode grapheme-cluster boundaries.
143
+ - **AR2 — Chart-level alternative.** ChartAxis renders inside Chart's named image. Chart owns the accessible chart name and supported small-data table; axis labels and pointer content do not replace that alternative.
144
+ - **AR3 — Nested axis semantics gap.** The implementation currently emits a position-labelled `role="group"`. Whether that group should be independently exposed and localized beneath Chart's `role="img"` remains unverified and requires owner review.
145
+
146
+ ## Design relationships
147
+
148
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
149
+ | ---------------- | ------------------------------------------------------ | ------------------------------------------------- | -------------- | ------------------ |
150
+ | Axis edge line | Grounds the physical plot edge when present. | unsettled; no current design record is linked | supporting | FR7, FR9 |
151
+ | Tick marks | Associate retained labels with positions on the scale. | unsettled; no current design record is linked | supporting | FR7 |
152
+ | Tick labels | Preserve readable value/category text around the plot. | Unicode grapheme boundaries settle text integrity | supporting | FR3, FR5, FR6, AR1 |
153
+ | Tick transition | Shows continuity during scale changes. | unsettled; `design:motion` remains draft | supporting | FR10 |
154
+
155
+ ### Theming anatomy
156
+
157
+ <!-- anatomy-theming:v1 -->
158
+
159
+ ```json
160
+ {
161
+ "Axis edge line": {
162
+ "none": {
163
+ "reason": "unsettled: ChartAxis consumes semantic color tokens but no current component or family contract decides whether the line is a public theme target."
164
+ }
165
+ },
166
+ "Tick marks": {
167
+ "none": {
168
+ "reason": "unsettled: ChartAxis consumes semantic color tokens but no current component or family contract decides whether tick marks are a public theme target."
169
+ }
170
+ },
171
+ "Tick labels": {
172
+ "none": {
173
+ "reason": "unsettled: ChartAxis consumes semantic color tokens but no current component or family contract decides whether labels are a public theme target."
174
+ }
175
+ }
176
+ }
177
+ ```
178
+
179
+ ## Family and system relationships
180
+
181
+ - No current family or design record governs ChartAxis.
182
+ - `architecture:component-test-sufficiency` owns rational public-state partitions, observable assertions, and red-before-green audit remediation.
183
+ - `architecture:component-theming-surface` owns anatomy-to-target disposition; token consumption alone does not enroll ChartAxis in public component theming.
184
+ - `spec:AST-002` owns public API admission and forbids an audit from smuggling a new public choice into a repair.
185
+ - `spec:AST-029` owns this observational backfill, closed evidence receipt, safe remediation, and fail-closed eligibility report.
186
+
187
+ ## Verification map
188
+
189
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
190
+ | ------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------- |
191
+ | FR1, FR8 | `ChartAxis.test.tsx`; `ChartAxis.stories.tsx` | top, right, bottom, left; band and linear | An edge uses the wrong scale, transform, or outward label side. | `audit:ChartAxis/behavior-layout` |
192
+ | FR2, FR3 | `ChartAxis.test.tsx` | default count; band, linear, and custom formatting | Generation and formatting disagree or a supplied formatter is ignored. | `audit:ChartAxis/behavior` |
193
+ | FR4 | `ChartAxis.test.tsx` | automatic cap and explicit `maxTicks` | Explicit capping stops winning over size-derived thinning. | `audit:ChartAxis/behavior` |
194
+ | FR5, FR6, AR1 | red-before-green grapheme regressions; density tests; browser story | ASCII, emoji, flag, and ZWJ labels; truncated wide/narrow plots | A visible label splits a grapheme or equal perceived-width labels retain different ticks. | `audit:ChartAxis/i18n-testing` |
195
+ | FR7 | `ChartAxis.test.tsx`; Axes & Grids Storybook fixture | defaults; line requested; ticks requested | Tick marks lose their edge line or line defaults change. | `audit:ChartAxis/behavior` |
196
+ | FR9 | source review; browser evidence remains pending | positive-only, negative-only, mixed-sign, y-band | The mixed-sign zero line moves to the edge or a categorical axis crosses the plot. | `audit:ChartAxis/behavior-design` |
197
+ | FR10 | streaming stories; exact-head browser evidence remains pending | animated, immediate, entering/leaving tick | Disabling animation retains a transition or a moving label jumps to stale geometry. | `audit:ChartAxis/design-testing` |
198
+ | AR2, AR3 | Chart accessibility tests; package axe; accessibility-tree review | title/subtitle, small-data table, nested axis groups | Chart loses its alternative or an unreviewed nested semantic claim is treated as fact. | `audit:ChartAxis/accessibility` |
199
+
200
+ ## Decision log
201
+
202
+ None. This draft records observed behavior and one objective Unicode correction; it does not approve new ChartAxis policy.
203
+
204
+ ## Open questions
205
+
206
+ - **OQ1 — Axis semantics.** Should ChartAxis remain purely visual content within Chart's single named image, or expose each axis as an independently localized accessibility group? (`human-api`)
207
+
208
+ ## Content boundary
209
+
210
+ This file does not duplicate consumer prop tables/examples, current audit results,
211
+ implementation steps, or shared system rules. It links to their owners.
@@ -4,8 +4,8 @@
4
4
  * @file ChartAxis.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, Chart, ChartAxis, bar mark
6
6
  * @output Functional tests for axis rendering — group semantics, band/linear
7
- * tick labels, default edge-line rules, custom formatting, truncation,
8
- * and label capping
7
+ * tick labels, physical edge placement, default edge-line rules,
8
+ * custom formatting, grapheme-safe truncation and density, and label capping
9
9
  * @position Colocated test for ChartAxis.tsx (issue #4295 viz coverage)
10
10
  */
11
11
 
@@ -88,6 +88,52 @@ describe('ChartAxis bottom (band scale)', () => {
88
88
  expect(within(axis).queryByText('Feb')).not.toBeInTheDocument();
89
89
  });
90
90
 
91
+ it('automatically reduces categorical labels as the plot narrows', () => {
92
+ const denseData = Array.from({length: 20}, (_, index) => ({
93
+ month: `Period ${index + 1}`,
94
+ sales: index,
95
+ }));
96
+ renderChart(<ChartAxis position="bottom" />, denseData);
97
+ const axis = screen.getByRole('group', {name: 'bottom axis'});
98
+ const wideLabelCount = axis.querySelectorAll('text').length;
99
+
100
+ reportWidth(120);
101
+
102
+ const narrowLabelCount = axis.querySelectorAll('text').length;
103
+ expect(narrowLabelCount).toBeGreaterThan(0);
104
+ expect(narrowLabelCount).toBeLessThan(wideLabelCount);
105
+ });
106
+
107
+ it('keeps the same auto-thinned labels after ASCII and Unicode truncation', () => {
108
+ const asciiData = Array.from({length: 9}, (_, index) => ({
109
+ month: `${index + 1}AB`,
110
+ sales: index,
111
+ }));
112
+ const clusters = ['👨‍👩‍👧‍👦', '🇯🇵', '👩🏽‍🚀'];
113
+ const unicodeData = Array.from({length: 9}, (_, index) => ({
114
+ month: `${index + 1}${clusters[index % clusters.length]}X`,
115
+ sales: index,
116
+ }));
117
+
118
+ const retainedIndexes = (data: typeof asciiData) => {
119
+ const {unmount} = renderChart(
120
+ <ChartAxis position="bottom" truncate={2} />,
121
+ data,
122
+ );
123
+ reportWidth(300);
124
+ const indexes = Array.from(
125
+ screen
126
+ .getByRole('group', {name: 'bottom axis'})
127
+ .querySelectorAll('text'),
128
+ label => Number(label.textContent?.match(/^(\d+)/)?.[1]),
129
+ );
130
+ unmount();
131
+ return indexes;
132
+ };
133
+
134
+ expect(retainedIndexes(unicodeData)).toEqual(retainedIndexes(asciiData));
135
+ });
136
+
91
137
  it('truncates long category labels with an ellipsis', () => {
92
138
  renderChart(<ChartAxis position="bottom" truncate={4} />, [
93
139
  {month: 'January', sales: 4},
@@ -98,6 +144,15 @@ describe('ChartAxis bottom (band scale)', () => {
98
144
  // Short labels are left alone.
99
145
  expect(within(axis).getByText('Feb')).toBeInTheDocument();
100
146
  });
147
+
148
+ it('preserves whole grapheme clusters when truncating labels', () => {
149
+ const family = '👨‍👩‍👧‍👦';
150
+ renderChart(<ChartAxis position="bottom" truncate={1} />, [
151
+ {month: `${family}Q`, sales: 4},
152
+ ]);
153
+ const axis = screen.getByRole('group', {name: 'bottom axis'});
154
+ expect(within(axis).getByText(`${family}…`)).toBeInTheDocument();
155
+ });
101
156
  });
102
157
 
103
158
  describe('ChartAxis left (linear scale)', () => {
@@ -129,3 +184,26 @@ describe('ChartAxis left (linear scale)', () => {
129
184
  expect(lines).toHaveLength(7);
130
185
  });
131
186
  });
187
+
188
+ describe('ChartAxis top and right positions', () => {
189
+ it('places both axes on their physical plot edges with outward labels', () => {
190
+ renderChart(
191
+ <>
192
+ <ChartAxis position="top" showTicks />
193
+ <ChartAxis position="right" showTicks />
194
+ </>,
195
+ );
196
+
197
+ const top = screen.getByRole('group', {name: 'top axis'});
198
+ expect(top).not.toHaveAttribute('transform');
199
+ expect(within(top).getByText('Jan')).toHaveAttribute('y', '-10');
200
+
201
+ const right = screen.getByRole('group', {name: 'right axis'});
202
+ expect(right).toHaveAttribute('transform', 'translate(528,0)');
203
+ expect(within(right).getByText('0')).toHaveAttribute('x', '10');
204
+ expect(within(right).getByText('0')).toHaveAttribute(
205
+ 'text-anchor',
206
+ 'start',
207
+ );
208
+ });
209
+ });