diff --git a/src/components/charts/AreaGraph/AreaGraph.stories.tsx b/src/components/charts/AreaGraph/AreaGraph.stories.tsx index f548ee13..5e071b75 100644 --- a/src/components/charts/AreaGraph/AreaGraph.stories.tsx +++ b/src/components/charts/AreaGraph/AreaGraph.stories.tsx @@ -143,6 +143,65 @@ export const Stacked: Story = { }, }; +const weekdayDataSeries = [ + { + // x positions are integer indices; xTickText supplies the day labels + x: [0, 1, 2, 3, 4, 5, 6], + y: [120, 130, 100, 110, 140, 80, 60], + name: "Throughput", + }, +]; + +export const CategoricalXLabels: Story = { + name: "Categorical X Labels", + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "SW-T5458" }, + docs: { + description: { + story: + "Use `xTickText` to display categorical x-axis labels (e.g. days of the week). The numeric `x` values still drive area positioning, but the rendered tick labels match `xTickText` in order.", + }, + }, + }, + args: { + dataSeries: weekdayDataSeries, + xTickText: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + title: "Throughput by Weekday", + xTitle: "Day", + yTitle: "Files", + width: 1000, + height: 600, + variant: "normal", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Throughput by Weekday")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("X-axis ticks show categorical labels, not integers", async () => { + const tickLabels = [ + ...canvasElement.querySelectorAll(".xtick text"), + ].map((node) => node.textContent); + expect(tickLabels).toEqual([ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + ]); + }); + }, +}; + export const CustomRange: Story = { name: "Custom Range", parameters: { diff --git a/src/components/charts/AreaGraph/AreaGraph.tsx b/src/components/charts/AreaGraph/AreaGraph.tsx index d251ed11..f5d32df4 100644 --- a/src/components/charts/AreaGraph/AreaGraph.tsx +++ b/src/components/charts/AreaGraph/AreaGraph.tsx @@ -27,6 +27,13 @@ interface AreaGraphProps { xTitle?: string; yTitle?: string; title?: string; + /** + * Categorical labels for the x-axis ticks. When provided, the x data values + * still drive area positioning but the displayed tick labels match these + * strings in order (e.g. ["Mon", "Tue", …]). Should align 1:1 with the + * unique, ordered x values across all series. + */ + xTickText?: string[]; } const AreaGraph: React.FC = ({ @@ -39,6 +46,7 @@ const AreaGraph: React.FC = ({ xTitle = "Columns", yTitle = "Rows", title = "Area Graph", + xTickText, }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -125,6 +133,18 @@ const AreaGraph: React.FC = ({ return ticks; }, [effectiveYRange]); + // When categorical labels are supplied, ticks must sit on the actual data + // x-positions rather than the computed nice-step values above. Sorted + // ascending so labels map deterministically to x regardless of series order. + const xDataValues = useMemo( + () => [...new Set(dataSeries.flatMap((s) => s.x))].sort((a, b) => a - b), + [dataSeries], + ); + + // Only apply categorical labels when they align 1:1 with the tick positions; + // a mismatch would silently mis-label ticks, so fall back to numeric ticks. + const useCategoricalX = !!xTickText && xTickText.length === xDataValues.length; + const tickOptions = useMemo( () => ({ tickcolor: theme.tickColor, @@ -258,7 +278,8 @@ const AreaGraph: React.FC = ({ range: xRange, autorange: !xRange, tickmode: "array" as const, - tickvals: xTicks, + tickvals: useCategoricalX ? xDataValues : xTicks, + ...(useCategoricalX ? { ticktext: xTickText } : {}), showgrid: true, ...tickOptions, }, @@ -316,7 +337,7 @@ const AreaGraph: React.FC = ({ Plotly.purge(plotElement); } }; - }, [dataSeries, width, height, xRange, yRange, effectiveXRange, effectiveYRange, variant, xTitle, yTitle, titleOptions, tickOptions, xTicks, yTicks, theme, bindTooltip]); + }, [dataSeries, width, height, xRange, yRange, effectiveXRange, effectiveYRange, variant, xTitle, yTitle, titleOptions, tickOptions, xTicks, yTicks, xDataValues, useCategoricalX, xTickText, theme, bindTooltip]); return (
diff --git a/src/components/charts/BarGraph/BarGraph.stories.tsx b/src/components/charts/BarGraph/BarGraph.stories.tsx index 467a471d..94c141b0 100644 --- a/src/components/charts/BarGraph/BarGraph.stories.tsx +++ b/src/components/charts/BarGraph/BarGraph.stories.tsx @@ -51,6 +51,26 @@ const generateGroupedBarData = (): BarDataSeries[] => { ]; }; +const generatePipelineRunsByStatus = (): BarDataSeries[] => { + // x positions are integer indices; xTickText supplies the day labels + const x = [0, 1, 2, 3, 4, 5, 6]; + + return [ + { + name: "Success", + x, + y: [42, 51, 38, 60, 55, 12, 8], + color: "#29A634", + }, + { + name: "Failed", + x, + y: [3, 5, 2, 7, 4, 1, 0], + color: "#CD4246", + }, + ]; +}; + const generateStackedBarData = (): BarDataSeries[] => { const x = [200, 300, 400, 500, 600, 700, 800, 900, 1000]; @@ -171,6 +191,61 @@ export const StackedBars: Story = { }, }; +export const CategoricalXLabels: Story = { + name: "Categorical X Labels", + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "SW-T5459" }, + docs: { + description: { + story: + "Use `xTickText` to display categorical x-axis labels (e.g. days of the week). The numeric `x` values still drive bar positioning, but the rendered tick labels match `xTickText` in order.", + }, + }, + }, + args: { + dataSeries: generatePipelineRunsByStatus(), + xTickText: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + variant: "group", + title: "Pipeline Runs by Status", + xTitle: "Day", + yTitle: "Runs", + width: 1000, + height: 600, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Pipeline Runs by Status")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("X-axis ticks show categorical labels, not integers", async () => { + const tickLabels = [ + ...canvasElement.querySelectorAll(".xtick text"), + ].map((node) => node.textContent); + expect(tickLabels).toEqual([ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + ]); + }); + + await step("Legend shows all series names", async () => { + expect(canvas.getByText("Success")).toBeInTheDocument(); + expect(canvas.getByText("Failed")).toBeInTheDocument(); + }); + }, +}; + export const CustomStyling: Story = { name: "Custom Styling", parameters: { diff --git a/src/components/charts/BarGraph/BarGraph.tsx b/src/components/charts/BarGraph/BarGraph.tsx index 54aec4cf..8786f74e 100644 --- a/src/components/charts/BarGraph/BarGraph.tsx +++ b/src/components/charts/BarGraph/BarGraph.tsx @@ -32,6 +32,13 @@ interface BarGraphProps { yTitle?: string; title?: string; barWidth?: number; + /** + * Categorical labels for the x-axis ticks. When provided, the x data values + * still drive bar positioning but the displayed tick labels match these + * strings in order (e.g. ["Mon", "Tue", …]). Should align 1:1 with the + * unique, ordered x values across all series. + */ + xTickText?: string[]; } const BarGraph: React.FC = ({ @@ -45,6 +52,7 @@ const BarGraph: React.FC = ({ yTitle = "Rows", title = "Bar Graph", barWidth = 24, + xTickText, }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -84,10 +92,14 @@ const BarGraph: React.FC = ({ ); const xTicks = useMemo( - () => [...new Set(dataSeries.flatMap((s) => s.x))], + () => [...new Set(dataSeries.flatMap((s) => s.x))].sort((a, b) => a - b), [dataSeries], ); + // Only apply categorical labels when they align 1:1 with the tick positions; + // a mismatch would silently mis-label ticks, so fall back to numeric ticks. + const useCategoricalX = !!xTickText && xTickText.length === xTicks.length; + const yTicks = useMemo(() => { const range = effectiveYRange[1] - effectiveYRange[0]; let step = Math.pow(10, Math.floor(Math.log10(range))); @@ -188,6 +200,7 @@ const BarGraph: React.FC = ({ autorange: !xRange, tickmode: "array" as const, tickvals: xTicks, + ...(useCategoricalX ? { ticktext: xTickText } : {}), showgrid: true, ...tickOptions, }, @@ -244,7 +257,7 @@ const BarGraph: React.FC = ({ Plotly.purge(plotElement); } }; - }, [dataSeries, width, height, xRange, yRange, xTitle, yTitle, title, barWidth, barMode, tickOptions, xTicks, yTicks, theme, bindTooltip]); + }, [dataSeries, width, height, xRange, yRange, xTitle, yTitle, title, barWidth, barMode, tickOptions, xTicks, yTicks, useCategoricalX, xTickText, theme, bindTooltip]); return (
diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx index 929c76af..7e840f8d 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx @@ -773,6 +773,9 @@ export const ContinuousColorMapping: Story = { }); }); }, + parameters: { + zephyr: { testCaseId: "SW-T5412" }, + }, }; const EVENT_DATA: ScatterPoint[] = Array.from({ length: 6 }, (_, i) => ({ @@ -858,6 +861,9 @@ export const SelectionEvents: Story = { }); }); }, + parameters: { + zephyr: { testCaseId: "SW-T5413" }, + }, }; /** @@ -917,4 +923,7 @@ export const ThemedTooltip: Story = { }); }); }, + parameters: { + zephyr: { testCaseId: "SW-T5414" }, + }, }; diff --git a/src/components/charts/LineGraph/LineGraph.stories.tsx b/src/components/charts/LineGraph/LineGraph.stories.tsx index 8b927185..0efc8a54 100644 --- a/src/components/charts/LineGraph/LineGraph.stories.tsx +++ b/src/components/charts/LineGraph/LineGraph.stories.tsx @@ -254,6 +254,26 @@ const generateDemoDataWithErrorBars = (): LineDataSeries[] => { })); }; +const generateWeekdayData = (): LineDataSeries[] => { + // x positions are integer indices; xTickText supplies the day labels + const x = [0, 1, 2, 3, 4, 5, 6]; + + return [ + { + name: "Instrument A", + symbol: "circle", + x, + y: [12, 18, 15, 22, 19, 8, 5], + }, + { + name: "Instrument B", + symbol: "square", + x, + y: [20, 24, 21, 28, 26, 14, 10], + }, + ]; +}; + const meta: Meta = { title: "Charts/Line Graph", component: LineGraph, @@ -298,6 +318,59 @@ export const Basic: Story = { }, }; +export const CategoricalXLabels: Story = { + name: "Categorical X Labels", + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "SW-T5460" }, + docs: { + description: { + story: + "Use `xTickText` to display categorical x-axis labels (e.g. days of the week). The numeric `x` values still drive line positioning, but the rendered tick labels match `xTickText` in order.", + }, + }, + }, + args: { + dataSeries: generateWeekdayData(), + xTickText: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + variant: "lines+markers", + title: "Runs per Weekday", + xTitle: "Day", + yTitle: "Runs", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Runs per Weekday")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("X-axis ticks show categorical labels, not integers", async () => { + const tickLabels = [ + ...canvasElement.querySelectorAll(".xtick text"), + ].map((node) => node.textContent); + expect(tickLabels).toEqual([ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + ]); + }); + + await step("Legend shows series names", async () => { + expect(canvas.getByText("Instrument A")).toBeInTheDocument(); + expect(canvas.getByText("Instrument B")).toBeInTheDocument(); + }); + }, +}; + export const WithMarkers: Story = { name: "With Markers", parameters: { diff --git a/src/components/charts/LineGraph/LineGraph.tsx b/src/components/charts/LineGraph/LineGraph.tsx index 390e8182..6f359143 100644 --- a/src/components/charts/LineGraph/LineGraph.tsx +++ b/src/components/charts/LineGraph/LineGraph.tsx @@ -180,6 +180,13 @@ type LineGraphProps = { xTitle?: string; yTitle?: string; title?: string; + /** + * Categorical labels for the x-axis ticks. When provided, the x data values + * still drive line positioning but the displayed tick labels match these + * strings in order (e.g. ["Mon", "Tue", …]). Should align 1:1 with the + * unique, ordered x values across all series. + */ + xTickText?: string[]; }; const LineGraph: React.FC = ({ @@ -192,6 +199,7 @@ const LineGraph: React.FC = ({ xTitle = "Columns", yTitle = "Rows", title = "Line Graph", + xTickText, }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -247,10 +255,14 @@ const LineGraph: React.FC = ({ }, [effectiveYRange]); const xTicks = useMemo( - () => [...new Set(dataSeries.flatMap((s) => s.x))], + () => [...new Set(dataSeries.flatMap((s) => s.x))].sort((a, b) => a - b), [dataSeries], ); + // Only apply categorical labels when they align 1:1 with the tick positions; + // a mismatch would silently mis-label ticks, so fall back to numeric ticks. + const useCategoricalX = !!xTickText && xTickText.length === xTicks.length; + const mode = useMemo((): "lines" | "lines+markers" => { switch (variant) { case "lines+markers": @@ -353,7 +365,7 @@ const LineGraph: React.FC = ({ autorange: !xRange, tickmode: "array" as const, tickvals: xTicks, - ticktext: xTicks.map(String), + ticktext: useCategoricalX ? xTickText : xTicks.map(String), showgrid: true, ...tickOptions, }, @@ -409,7 +421,7 @@ const LineGraph: React.FC = ({ Plotly.purge(plotElement); } }; - }, [dataSeries, width, height, xRange, yRange, xTitle, yTitle, title, mode, tickOptions, xTicks, yTicks, effectiveYRange, variant, theme, bindTooltip]); + }, [dataSeries, width, height, xRange, yRange, xTitle, yTitle, title, mode, tickOptions, xTicks, yTicks, useCategoricalX, xTickText, effectiveYRange, variant, theme, bindTooltip]); return (