Skip to content

feat: add dual y-axis support to CartesianChart#182

Open
abenitesDC wants to merge 1 commit intocloudscape-design:mainfrom
abenitesDC:feat/cartesian-dual-axis
Open

feat: add dual y-axis support to CartesianChart#182
abenitesDC wants to merge 1 commit intocloudscape-design:mainfrom
abenitesDC:feat/cartesian-dual-axis

Conversation

@abenitesDC
Copy link
Copy Markdown

Summary

  • Add yAxis?: number to cartesian series types so series can reference which Y axis they belong to
  • Accept yAxis as a single object or [primary, secondary] tuple in CartesianChartProps
  • Auto-set opposite: true on the secondary axis when using the tuple form
  • Pass yAxis through all series transform functions (line, column, scatter, threshold, error bar)
  • All changes are backward compatible — existing single-axis usage is unaffected

Test plan

  • TypeScript compilation passes (tsc --noEmit)
  • All 12 cartesian-chart-series unit tests pass (9 existing + 3 new)
  • Documenter snapshots updated
  • Prettier and ESLint pass with no new warnings
  • Demo page added at 01-cartesian-chart/dual-axis-chart

@abenitesDC abenitesDC requested a review from a team as a code owner March 9, 2026 07:19
@abenitesDC abenitesDC requested review from gethinwebster and removed request for a team March 9, 2026 07:19
*
* Use a single object for a single y axis, or a tuple of two objects for a dual-axis chart.
* When using a tuple, the second axis is automatically rendered on the opposite (right) side.
* Series can reference the secondary axis by setting `yAxis: 1`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In highcharts, one can also reference axis by a given axis ID - should we add support for it as well?

let yAxis = { id: 'percentage' }

let series = { type: 'line', data: [...], yAxis: 'percentage' }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, we can consider making by-id reference the only supported one, to lesser a mistake risk

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aligned, will support series id


type BaseCartesianSeriesOptions = BaseSeriesOptions;
interface BaseCartesianSeriesOptions extends BaseSeriesOptions {
yAxis?: number;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XThresholdSeriesOptions should not have yAxis

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, removed it

function transformYAxisOptions(
axis?: CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions],
): CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions] {
if (Array.isArray(axis)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an existing castArray util, can we use it here instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

castArray always returns an array (or undefined). On the other hand, this Y-axis transform function needs to return either a single value, or an array depending on the Y-axis value passed in. So it cannot be used directly to replace these 4 lines.

However, if you are asking to use castArray to remove the isArray check, then IMO it would be more verbose, and would not recommend it compared to the proposed change. Thoughts?

function transformYAxisOptions(
  axis?: CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions],
): CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions] {
  // castArray wraps a single value into [value], passes arrays through, and returns undefined for falsy input.
  const axes = castArray(axis);

  // Only true when axis is undefined, since castArray returns undefined for falsy values.
  if (!axes) {
    return transformSingleYAxisOptions(axis);
  }

  const result = axes.map(transformSingleYAxisOptions);

  // A single YAxisOptions was wrapped into a one-element array by castArray,
  // so we need to unwrap it back to preserve the original single-value return type.
  // For the tuple case (length 2), we need a type assertion since .map() returns a generic array.
  return axes.length === 1
    ? result[0]
    : (result as [CartesianChartProps.YAxisOptions, CartesianChartProps.YAxisOptions]);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense - let's keep the less verbose variant.

@vamgan
Copy link
Copy Markdown

vamgan commented Mar 18, 2026

We wanted something like this! Thanks for implementing it.

dashStyle: s.dashStyle ?? Styles.thresholdSeries.dashStyle,
};
return { type: "line", id: s.id, name: s.name, data, custom, enableMouseTracking, ...style, ...shared };
return {
Copy link
Copy Markdown
Member

@pan-kot pan-kot Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can improve clarity / consistency with the above code by doing:

const yAxis = s.type === "y-threshold" ? s.yAxis : undefined;
return { type: "line", ..., yAxis }

id: s.id,
name: s.name,
color: s.color,
...(s.type === "y-threshold" && { yAxis: s.yAxis }),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here - I recommend to compute the yAxis in a separate line for better structure

subItems?: ReadonlyArray<{ key: React.ReactNode; value: React.ReactNode }>;
}

export interface AreaSeriesOptions extends BaseCartesianLineLikeOptions {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the names the same - w/o introducing the YAxis as it makes it more confusing. It is best to annotate with code comments why do we not treat the XThreshold as line-like.

/**
* Finds chart's secondary legend when using dual-axis charts.
*/
public findSecondaryLegend(): null | BaseChartLegendWrapper {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The findLegend and findYAxisTitle belong to the BaseChartWrapper - so we should target that one, not CartesianChartWrapper.
  2. It is best to make the methods more precise. The "secondary" term is not represented in the API - we use IDs instead. We can parametrise findLegend and findYAxisTitle with { axisId?: string } - to target specific axis/legend.

Copy link
Copy Markdown
Author

@abenitesDC abenitesDC Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall comment, if we use axis id, then the chart component would need to add a data-axisid so that the test utils can find the correct axis. That involves refactoring the core classes. Plus, we are only enforcing axis id if we use dual axis. So, we'd need to keep the 1-axis logic as part of this refactoring. I think searching by axis id be more ideal, but will increase the code logic for a maximum of 2 y-axis.

// Example
// tests-utils/.../base.ts

// current
public findLegend(): null | BaseChartLegendWrapper {
    return this.findComponent(`.${BaseChartLegendWrapper.rootSelector}`, BaseChartLegendWrapper);
  }

// proposed
  public findLegend({ axisId }: { axisId?: string } = {}): null | BaseChartLegendWrapper {
    const selector = axisId
      ? `.${BaseChartLegendWrapper.rootSelector}[data-axisid="${axisId}"]`
      : `.${BaseChartLegendWrapper.rootSelector}`;
    return this.findComponent(selector, BaseChartLegendWrapper);
  }

On the other hand, we can still accept axis ID and change the test utilities to search for the existing legend and title primary and secondary CSS classes, without refactoring any core component. The drawback here is that in tests we are constrained to either use y-axis IDs only primary and secondary (so that they be matched to the existing css classes).

The last alternative would be to not have the axis ID param in the test utils, and instead have fixed methods findSecondaryXXX that internally will map to the respective css classes (similar to current PR changes).

what are your thoughts?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed in DM - we can attached test-id for the legends and use a custom class name with an ID as a workaround to find axes.

Allow CartesianChart to render two Y axes by passing a tuple to the
yAxis prop. The second axis is automatically set to opposite (right side).
Series can reference the secondary axis with yAxis: 1.

Changes:
- Add yAxis?: number to BaseCartesianSeriesOptions
- Accept yAxis as single or [primary, secondary] tuple in CartesianChartProps
- Pass yAxis through all series transform functions
- Auto-set opposite: true on the secondary axis
- Add unit tests and demo page
@abenitesDC abenitesDC force-pushed the feat/cartesian-dual-axis branch from 95b98d6 to 5d96606 Compare March 27, 2026 21:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants