diff --git a/src/components/charts/AreaGraph/AreaGraph.stories.tsx b/src/components/charts/AreaGraph/AreaGraph.stories.tsx index c79ab82c..a51e9ed7 100644 --- a/src/components/charts/AreaGraph/AreaGraph.stories.tsx +++ b/src/components/charts/AreaGraph/AreaGraph.stories.tsx @@ -161,6 +161,60 @@ export const Stacked: Story = { }, }; +export const CompactDashboardTile: Story = { + name: "Compact (Dashboard Tile)", + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + args: { + // Idiomatic dashboard-tile usage: omit width/height so the chart fills its + // tile (responsive), and set density="compact" to shrink the chrome. + dataSeries: sampleDataSeries, + title: "Yield", + xTitle: "Run", + yTitle: "g/L", + variant: "normal", + density: "compact", + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Compact title is displayed", async () => { + // Responsive fill renders after the ResizeObserver measures the tile. + await waitFor(() => { + expect(canvas.getByText("Yield")).toBeInTheDocument(); + }); + }); + + await step("Chart fills the tile and its plot area exceeds 65%", async () => { + await waitFor(() => { + const plot = canvasElement.querySelector(".js-plotly-plot") as HTMLElement; + expect(plot).toBeInTheDocument(); + const drag = canvasElement.querySelector(".nsewdrag"); + const w = parseFloat(drag?.getAttribute("width") ?? "0"); + const h = parseFloat(drag?.getAttribute("height") ?? "0"); + const canvasArea = plot.clientWidth * plot.clientHeight; + expect(canvasArea).toBeGreaterThan(0); + expect((w * h) / canvasArea).toBeGreaterThan(0.65); + }); + }); + }, +}; + const weekdayDataSeries = [ { // x positions are integer indices; xTickText supplies the day labels diff --git a/src/components/charts/AreaGraph/AreaGraph.tsx b/src/components/charts/AreaGraph/AreaGraph.tsx index 48d6dc20..df1ad09f 100644 --- a/src/components/charts/AreaGraph/AreaGraph.tsx +++ b/src/components/charts/AreaGraph/AreaGraph.tsx @@ -6,6 +6,12 @@ import { chartTooltipLines, useChartTooltip } from "../ChartTooltip"; import { useElementSize } from "@/hooks/use-element-size"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; import { cn } from "@/lib/utils"; +import { + COMPACT_AXIS_TITLE_STANDOFF, + COMPACT_CHART_MARGIN, + chartDensityTokens, + type ChartDensity, +} from "@/utils/chartDensity"; import { seriesColor } from "@/utils/colors"; interface AreaDataSeries { @@ -19,6 +25,9 @@ interface AreaDataSeries { type AreaGraphVariant = "normal" | "stacked"; +/** Comfortable-density gap between each axis title and its ticks */ +const COMFORTABLE_AXIS_TITLE_STANDOFF = 15; + /** Top margin reserving room for the 32px title; reduced when no title is set */ const TITLE_MARGIN_TOP = 80; const NO_TITLE_MARGIN_TOP = 40; @@ -41,6 +50,8 @@ interface AreaGraphProps { xTitle?: string; yTitle?: string; title?: string; + /** Sizing preset; `"compact"` shrinks fonts and margins for dashboard tiles */ + density?: ChartDensity; /** * Categorical labels for the x-axis ticks. When provided, the x data values * still drive area positioning but the displayed tick labels match these @@ -60,6 +71,7 @@ const AreaGraph: React.FC = ({ xTitle, yTitle, title, + density = "comfortable", xTickText, }) => { const plotRef = useRef(null); @@ -169,6 +181,9 @@ const AreaGraph: React.FC = ({ return ticks; }, [effectiveYRange]); + const tokens = useMemo(() => chartDensityTokens(density), [density]); + const compact = density === "compact"; + // 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. @@ -188,7 +203,7 @@ const AreaGraph: React.FC = ({ tickwidth: 1, ticks: "outside" as const, tickfont: { - size: 16, + size: tokens.tickFontSize, color: theme.textColor, family: "Inter, sans-serif", weight: 400, @@ -198,7 +213,7 @@ const AreaGraph: React.FC = ({ position: 0, zeroline: false, }), - [theme], + [theme, tokens.tickFontSize], ); const titleOptions = useMemo( @@ -211,7 +226,7 @@ const AreaGraph: React.FC = ({ xanchor: "center" as const, yanchor: "top" as const, font: { - size: 32, + size: tokens.titleFontSize, weight: 600, family: "Inter, sans-serif", color: theme.textColor, @@ -220,7 +235,7 @@ const AreaGraph: React.FC = ({ }, } : undefined, - [title, theme], + [title, theme, tokens.titleFontSize], ); useEffect(() => { @@ -283,15 +298,17 @@ const AreaGraph: React.FC = ({ width: sizeRef.current.width, height: sizeRef.current.height, ...(titleOptions ? { title: titleOptions } : {}), - margin: { - l: 80, - r: 40, - // Reserve room for tick labels, the x-axis title, and the - // container-anchored bottom legend stacked beneath them. - b: 96, - t: title ? TITLE_MARGIN_TOP : NO_TITLE_MARGIN_TOP, - pad: 0, - }, + margin: compact + ? COMPACT_CHART_MARGIN + : { + l: 80, + r: 40, + // Reserve room for tick labels, the x-axis title, and the + // container-anchored bottom legend stacked beneath them. + b: 96, + t: title ? TITLE_MARGIN_TOP : NO_TITLE_MARGIN_TOP, + pad: 0, + }, paper_bgcolor: theme.paperBg, plot_bgcolor: theme.plotBg, font: { @@ -302,12 +319,12 @@ const AreaGraph: React.FC = ({ title: { text: xTitle, font: { - size: 16, + size: tokens.axisTitleFontSize, color: theme.textSecondary, family: "Inter, sans-serif", weight: 400, }, - standoff: 15, + standoff: compact ? COMPACT_AXIS_TITLE_STANDOFF : COMFORTABLE_AXIS_TITLE_STANDOFF, }, gridcolor: theme.gridColor, range: xRange, @@ -318,19 +335,19 @@ const AreaGraph: React.FC = ({ showgrid: true, // Reserve space for tick labels + the axis title so the bottom legend // can't overlap them at small sizes (SW-2157). - automargin: true, + automargin: !compact, ...tickOptions, }, yaxis: { title: { text: yTitle, font: { - size: 16, + size: tokens.axisTitleFontSize, color: theme.textSecondary, family: "Inter, sans-serif", weight: 400, }, - standoff: 15, + standoff: compact ? COMPACT_AXIS_TITLE_STANDOFF : COMFORTABLE_AXIS_TITLE_STANDOFF, }, gridcolor: theme.gridColor, range: yRange, @@ -338,7 +355,7 @@ const AreaGraph: React.FC = ({ tickmode: "array" as const, tickvals: yTicks, showgrid: true, - automargin: true, + automargin: !compact, ...tickOptions, }, legend: { @@ -359,7 +376,9 @@ const AreaGraph: React.FC = ({ lineheight: 18, }, }, - showlegend: true, + // Compact tiles are too short to fit a legend below the plot without + // Plotly's automargin eating most of the data area + showlegend: !compact, }; const config = { @@ -385,7 +404,7 @@ const AreaGraph: React.FC = ({ plotInitedRef.current = false; } }; - }, [dataSeries, hasSize, xRange, yRange, effectiveXRange, effectiveYRange, variant, xTitle, yTitle, title, titleOptions, tickOptions, xTicks, yTicks, xDataValues, useCategoricalX, xTickText, theme, bindTooltip]); + }, [dataSeries, hasSize, xRange, yRange, effectiveXRange, effectiveYRange, variant, xTitle, yTitle, title, titleOptions, tickOptions, xTicks, yTicks, xDataValues, useCategoricalX, xTickText, theme, bindTooltip, compact, tokens]); // Resize in place when the measured/overridden size changes — far cheaper // than recreating the plot (and it preserves tooltip/event bindings). diff --git a/src/components/charts/BarGraph/BarGraph.stories.tsx b/src/components/charts/BarGraph/BarGraph.stories.tsx index 584a4725..ef7dc075 100644 --- a/src/components/charts/BarGraph/BarGraph.stories.tsx +++ b/src/components/charts/BarGraph/BarGraph.stories.tsx @@ -320,6 +320,59 @@ export const CustomStyling: Story = { }, }; +export const CompactDashboardTile: Story = { + name: "Compact (Dashboard Tile)", + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + args: { + // Idiomatic dashboard-tile usage: omit width/height so the chart fills its + // tile (responsive), and set density="compact" to shrink the chrome. + dataSeries: generateBasicData(), + title: "Throughput", + xTitle: "Batch", + yTitle: "Units", + density: "compact", + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Compact title is displayed", async () => { + // Responsive fill renders after the ResizeObserver measures the tile. + await waitFor(() => { + expect(canvas.getByText("Throughput")).toBeInTheDocument(); + }); + }); + + await step("Chart fills the tile and its plot area exceeds 65%", async () => { + await waitFor(() => { + const plot = canvasElement.querySelector(".js-plotly-plot") as HTMLElement; + expect(plot).toBeInTheDocument(); + const drag = canvasElement.querySelector(".nsewdrag"); + const w = parseFloat(drag?.getAttribute("width") ?? "0"); + const h = parseFloat(drag?.getAttribute("height") ?? "0"); + const canvasArea = plot.clientWidth * plot.clientHeight; + expect(canvasArea).toBeGreaterThan(0); + expect((w * h) / canvasArea).toBeGreaterThan(0.65); + }); + }); + }, +}; + export const ContainerFilled: Story = { name: "Container Filled (responsive)", parameters: { diff --git a/src/components/charts/BarGraph/BarGraph.tsx b/src/components/charts/BarGraph/BarGraph.tsx index d09ce861..a302086c 100644 --- a/src/components/charts/BarGraph/BarGraph.tsx +++ b/src/components/charts/BarGraph/BarGraph.tsx @@ -6,6 +6,12 @@ import { useChartTooltip } from "../ChartTooltip"; import { useElementSize } from "@/hooks/use-element-size"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; import { cn } from "@/lib/utils"; +import { + COMPACT_AXIS_TITLE_STANDOFF, + COMPACT_CHART_MARGIN, + chartDensityTokens, + type ChartDensity, +} from "@/utils/chartDensity"; import { seriesColor } from "@/utils/colors"; interface BarDataSeries { @@ -23,6 +29,10 @@ interface BarDataSeries { type BarGraphVariant = "group" | "stack" | "overlay"; +/** Comfortable-density gaps between each axis title and its ticks */ +const COMFORTABLE_X_AXIS_TITLE_STANDOFF = 32; +const COMFORTABLE_Y_AXIS_TITLE_STANDOFF = 30; + /** Top margin reserving room for the 32px title; reduced when no title is set */ const TITLE_MARGIN_TOP = 60; const NO_TITLE_MARGIN_TOP = 30; @@ -46,6 +56,8 @@ interface BarGraphProps { yTitle?: string; title?: string; barWidth?: number; + /** Sizing preset; `"compact"` shrinks fonts and margins for dashboard tiles */ + density?: ChartDensity; /** * Categorical labels for the x-axis ticks. When provided, the x data values * still drive bar positioning but the displayed tick labels match these @@ -66,6 +78,7 @@ const BarGraph: React.FC = ({ yTitle, title, barWidth = 24, + density = "comfortable", xTickText, }) => { const plotRef = useRef(null); @@ -159,6 +172,9 @@ const BarGraph: React.FC = ({ } }, [variant]); + const tokens = useMemo(() => chartDensityTokens(density), [density]); + const compact = density === "compact"; + const tickOptions = useMemo( () => ({ tickcolor: theme.tickColor, @@ -166,7 +182,7 @@ const BarGraph: React.FC = ({ tickwidth: 1, ticks: "outside" as const, tickfont: { - size: 16, + size: tokens.tickFontSize, color: theme.textColor, family: "Inter, sans-serif", weight: 400, @@ -176,7 +192,7 @@ const BarGraph: React.FC = ({ position: 0, zeroline: false, }), - [theme], + [theme, tokens.tickFontSize], ); useEffect(() => { @@ -201,7 +217,7 @@ const BarGraph: React.FC = ({ title: { text: title, font: { - size: 32, + size: tokens.titleFontSize, family: "Inter, sans-serif", color: theme.textColor, }, @@ -210,15 +226,17 @@ const BarGraph: React.FC = ({ : {}), width: sizeRef.current.width, height: sizeRef.current.height, - margin: { - l: 80, - r: 30, - // Reserve room for tick labels, the x-axis title, and the - // container-anchored bottom legend stacked beneath them. - b: 96, - t: title ? TITLE_MARGIN_TOP : NO_TITLE_MARGIN_TOP, - pad: 0, - }, + margin: compact + ? COMPACT_CHART_MARGIN + : { + l: 80, + r: 30, + // Reserve room for tick labels, the x-axis title, and the + // container-anchored bottom legend stacked beneath them. + b: 96, + t: title ? TITLE_MARGIN_TOP : NO_TITLE_MARGIN_TOP, + pad: 0, + }, paper_bgcolor: theme.paperBg, plot_bgcolor: theme.plotBg, font: { @@ -231,12 +249,12 @@ const BarGraph: React.FC = ({ title: { text: xTitle, font: { - size: 16, + size: tokens.axisTitleFontSize, color: theme.textSecondary, family: "Inter, sans-serif", weight: 400, }, - standoff: 32, + standoff: compact ? COMPACT_AXIS_TITLE_STANDOFF : COMFORTABLE_X_AXIS_TITLE_STANDOFF, }, gridcolor: theme.gridColor, range: xRange, @@ -247,19 +265,19 @@ const BarGraph: React.FC = ({ showgrid: true, // Reserve space for tick labels + the axis title so the bottom legend // can't overlap them at small sizes (SW-2157). - automargin: true, + automargin: !compact, ...tickOptions, }, yaxis: { title: { text: yTitle, font: { - size: 16, + size: tokens.axisTitleFontSize, color: theme.textSecondary, family: "Inter, sans-serif", weight: 400, }, - standoff: 30, + standoff: compact ? COMPACT_AXIS_TITLE_STANDOFF : COMFORTABLE_Y_AXIS_TITLE_STANDOFF, }, gridcolor: theme.gridColor, range: yRange, @@ -267,7 +285,7 @@ const BarGraph: React.FC = ({ tickmode: "array" as const, tickvals: yTicks, showgrid: true, - automargin: true, + automargin: !compact, ...tickOptions, }, legend: { @@ -287,7 +305,9 @@ const BarGraph: React.FC = ({ weight: 500, }, }, - showlegend: dataSeries.length > 1, + // Compact tiles are too short to fit a legend below the plot without + // Plotly's automargin eating most of the data area + showlegend: !compact && dataSeries.length > 1, }; const config = { @@ -313,7 +333,7 @@ const BarGraph: React.FC = ({ plotInitedRef.current = false; } }; - }, [dataSeries, hasSize, xRange, yRange, xTitle, yTitle, title, barWidth, barMode, tickOptions, xTicks, yTicks, useCategoricalX, xTickText, theme, bindTooltip]); + }, [dataSeries, hasSize, xRange, yRange, xTitle, yTitle, title, barWidth, barMode, tickOptions, xTicks, yTicks, useCategoricalX, xTickText, theme, bindTooltip, compact, tokens]); // Resize in place when the measured/overridden size changes — cheaper than // recreating the plot, and it preserves tooltip/event bindings. diff --git a/src/components/charts/LineGraph/LineGraph.stories.tsx b/src/components/charts/LineGraph/LineGraph.stories.tsx index 3aaa9676..72f7bf1e 100644 --- a/src/components/charts/LineGraph/LineGraph.stories.tsx +++ b/src/components/charts/LineGraph/LineGraph.stories.tsx @@ -881,6 +881,60 @@ export const LineGraphStartingFromZero: Story = { }, }; +export const CompactDashboardTile: Story = { + name: "Compact (Dashboard Tile)", + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + args: { + // Idiomatic dashboard-tile usage: omit width/height so the chart fills its + // tile (responsive), and set density="compact" to shrink the chrome. + dataSeries: generateBasicDemoData(), + title: "Temperature", + xTitle: "Time", + yTitle: "°C", + variant: "lines", + density: "compact", + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Compact title is displayed", async () => { + // Responsive fill renders after the ResizeObserver measures the tile. + await waitFor(() => { + expect(canvas.getByText("Temperature")).toBeInTheDocument(); + }); + }); + + await step("Chart fills the tile and its plot area exceeds 65%", async () => { + await waitFor(() => { + const plot = canvasElement.querySelector(".js-plotly-plot") as HTMLElement; + expect(plot).toBeInTheDocument(); + const drag = canvasElement.querySelector(".nsewdrag"); + const w = parseFloat(drag?.getAttribute("width") ?? "0"); + const h = parseFloat(drag?.getAttribute("height") ?? "0"); + const canvasArea = plot.clientWidth * plot.clientHeight; + expect(canvasArea).toBeGreaterThan(0); + expect((w * h) / canvasArea).toBeGreaterThan(0.65); + }); + }); + }, +}; + export const NoTitle: Story = { name: "No Title", parameters: { diff --git a/src/components/charts/LineGraph/LineGraph.tsx b/src/components/charts/LineGraph/LineGraph.tsx index 2abcb136..970a2a37 100644 --- a/src/components/charts/LineGraph/LineGraph.tsx +++ b/src/components/charts/LineGraph/LineGraph.tsx @@ -6,6 +6,12 @@ import { useChartTooltip } from "../ChartTooltip"; import { useElementSize } from "@/hooks/use-element-size"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; import { cn } from "@/lib/utils"; +import { + COMPACT_AXIS_TITLE_STANDOFF, + COMPACT_CHART_MARGIN, + chartDensityTokens, + type ChartDensity, +} from "@/utils/chartDensity"; import { seriesColor } from "@/utils/colors"; type MarkerSymbol = @@ -172,6 +178,10 @@ interface LineDataSeries { type LineGraphVariant = "lines" | "lines+markers" | "lines+markers+error_bars"; +/** Comfortable-density gaps between each axis title and its ticks */ +const COMFORTABLE_X_AXIS_TITLE_STANDOFF = 32; +const COMFORTABLE_Y_AXIS_TITLE_STANDOFF = 30; + /** Top margin reserving room for the 32px title; reduced when no title is set */ const TITLE_MARGIN_TOP = 60; const NO_TITLE_MARGIN_TOP = 30; @@ -194,6 +204,8 @@ type LineGraphProps = { xTitle?: string; yTitle?: string; title?: string; + /** Sizing preset; `"compact"` shrinks fonts and margins for dashboard tiles */ + density?: ChartDensity; /** * Categorical labels for the x-axis ticks. When provided, the x data values * still drive line positioning but the displayed tick labels match these @@ -213,6 +225,7 @@ const LineGraph: React.FC = ({ xTitle, yTitle, title, + density = "comfortable", xTickText, }) => { const plotRef = useRef(null); @@ -304,6 +317,9 @@ const LineGraph: React.FC = ({ } }, [variant]); + const tokens = useMemo(() => chartDensityTokens(density), [density]); + const compact = density === "compact"; + const tickOptions = useMemo( () => ({ tickcolor: theme.tickColor, @@ -311,7 +327,7 @@ const LineGraph: React.FC = ({ tickwidth: 1, ticks: "outside" as const, tickfont: { - size: 16, + size: tokens.tickFontSize, color: theme.textColor, family: "Inter, sans-serif", weight: 400, @@ -321,7 +337,7 @@ const LineGraph: React.FC = ({ position: 0, zeroline: false, }), - [theme], + [theme, tokens.tickFontSize], ); useEffect(() => { @@ -368,7 +384,7 @@ const LineGraph: React.FC = ({ title: { text: title, font: { - size: 32, + size: tokens.titleFontSize, family: "Inter, sans-serif", color: theme.textColor, }, @@ -377,15 +393,17 @@ const LineGraph: React.FC = ({ : {}), width: sizeRef.current.width, height: sizeRef.current.height, - margin: { - l: 80, - r: 30, - // Reserve room for tick labels, the x-axis title, and the - // container-anchored bottom legend stacked beneath them. - b: 96, - t: title ? TITLE_MARGIN_TOP : NO_TITLE_MARGIN_TOP, - pad: 10, - }, + margin: compact + ? COMPACT_CHART_MARGIN + : { + l: 80, + r: 30, + // Reserve room for tick labels, the x-axis title, and the + // container-anchored bottom legend stacked beneath them. + b: 96, + t: title ? TITLE_MARGIN_TOP : NO_TITLE_MARGIN_TOP, + pad: 10, + }, paper_bgcolor: theme.paperBg, plot_bgcolor: theme.plotBg, font: { @@ -396,12 +414,12 @@ const LineGraph: React.FC = ({ title: { text: xTitle, font: { - size: 16, + size: tokens.axisTitleFontSize, color: theme.textSecondary, family: "Inter, sans-serif", weight: 400, }, - standoff: 32, + standoff: compact ? COMPACT_AXIS_TITLE_STANDOFF : COMFORTABLE_X_AXIS_TITLE_STANDOFF, }, gridcolor: theme.gridColor, range: xRange, @@ -412,19 +430,19 @@ const LineGraph: React.FC = ({ showgrid: true, // Reserve space for tick labels + the axis title so the bottom legend // can't overlap them at small sizes (SW-2157). - automargin: true, + automargin: !compact, ...tickOptions, }, yaxis: { title: { text: yTitle, font: { - size: 16, + size: tokens.axisTitleFontSize, color: theme.textSecondary, family: "Inter, sans-serif", weight: 400, }, - standoff: 30, + standoff: compact ? COMPACT_AXIS_TITLE_STANDOFF : COMFORTABLE_Y_AXIS_TITLE_STANDOFF, }, gridcolor: theme.gridColor, range: yRange, @@ -432,7 +450,7 @@ const LineGraph: React.FC = ({ tickmode: "array" as const, tickvals: yTicks, showgrid: true, - automargin: true, + automargin: !compact, ...tickOptions, }, legend: { @@ -452,7 +470,9 @@ const LineGraph: React.FC = ({ weight: 500, }, }, - showlegend: true, + // Compact tiles are too short to fit a legend below the plot without + // Plotly's automargin eating most of the data area + showlegend: !compact, }; const config = { @@ -477,7 +497,7 @@ const LineGraph: React.FC = ({ plotInitedRef.current = false; } }; - }, [dataSeries, hasSize, xRange, yRange, xTitle, yTitle, title, mode, tickOptions, xTicks, yTicks, useCategoricalX, xTickText, effectiveYRange, variant, theme, bindTooltip]); + }, [dataSeries, hasSize, xRange, yRange, xTitle, yTitle, title, mode, tickOptions, xTicks, yTicks, useCategoricalX, xTickText, effectiveYRange, variant, theme, bindTooltip, compact, tokens]); // Resize in place when the measured/overridden size changes — cheaper than // recreating the plot, and it preserves tooltip/event bindings. diff --git a/src/components/charts/PieChart/PieChart.stories.tsx b/src/components/charts/PieChart/PieChart.stories.tsx index 41020fda..22bcf3a6 100644 --- a/src/components/charts/PieChart/PieChart.stories.tsx +++ b/src/components/charts/PieChart/PieChart.stories.tsx @@ -264,6 +264,65 @@ export const WithRotation: Story = { }, }; +export const CompactDashboardTile: Story = { + name: "Compact (Dashboard Tile)", + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + args: { + dataSeries: { + labels: ["pH", "Temperature", "Dissolved Oxygen", "Cell Density", "Viability"], + values: [12, 23, 35, 18, 12], + name: "Bioreactor Parameters", + }, + title: "Parameters", + // `width` matches the tile; `height` is the plot height — the HTML title and + // legend stack above/below it, so plot(200) + title + legend ≈ the 260 tile. + width: 600, + height: 200, + textInfo: "percent", + hole: 0, + rotation: 0, + density: "compact", + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Compact title renders at the reduced size", async () => { + const heading = canvas.getByText("Parameters"); + expect(heading).toBeInTheDocument(); + expect(heading.style.fontSize).toBe("14px"); + }); + + await step("Pie layer and slices render", async () => { + expect(canvasElement.querySelector(".pielayer")).toBeInTheDocument(); + const slices = canvasElement.querySelectorAll(".pielayer .slice"); + expect(slices.length).toBe(5); + }); + + await step("Legend shows parameter labels", async () => { + const legendRoot = canvasElement.querySelector("[data-slot='pie-legend']"); + expect(legendRoot).toBeTruthy(); + const legend = within(legendRoot as HTMLElement); + expect(legend.getByText("pH")).toBeInTheDocument(); + }); + }, +}; + export const NoTitle: Story = { name: "No Title", parameters: { diff --git a/src/components/charts/PieChart/PieChart.tsx b/src/components/charts/PieChart/PieChart.tsx index 1d199bab..c2a4fa90 100644 --- a/src/components/charts/PieChart/PieChart.tsx +++ b/src/components/charts/PieChart/PieChart.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useMemo } from "react"; import { useChartTooltip } from "../ChartTooltip"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; +import { chartDensityTokens, type ChartDensity } from "@/utils/chartDensity"; import { CHART_COLORS } from "@/utils/colors"; interface PieDataSeries { @@ -31,6 +32,8 @@ type PieChartProps = { textInfo?: PieTextInfo; hole?: number; rotation?: number; + /** Sizing preset; `"compact"` shrinks the title and margins for dashboard tiles */ + density?: ChartDensity; }; const DEFAULT_COLORS = CHART_COLORS; @@ -43,9 +46,11 @@ const PieChart: React.FC = ({ textInfo = "percent", hole = 0, rotation = 0, + density = "comfortable", }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); + const compact = density === "compact"; // Slices are large hover targets, so the tooltip tracks the cursor (clamped // to the viewport) rather than pinning to the slice centroid const { bindTooltip, tooltipElement } = useChartTooltip({ followCursor: true }); @@ -102,7 +107,8 @@ const PieChart: React.FC = ({ color: theme.textColor, }, showlegend: false, - margin: { l: 40, r: 40, b: 40, t: 40 }, + // Pie has no axes, so compact only needs to trim whitespace around the slices + margin: compact ? { l: 8, r: 8, b: 8, t: 8 } : { l: 40, r: 40, b: 40, t: 40 }, paper_bgcolor: theme.paperBg, plot_bgcolor: theme.plotBg, }; @@ -124,7 +130,7 @@ const PieChart: React.FC = ({ Plotly.purge(plotElement); } }; - }, [colors, dataSeries.labels, dataSeries.name, dataSeries.values, width, height, textInfo, hole, rotation, theme, bindTooltip]); + }, [colors, dataSeries.labels, dataSeries.name, dataSeries.values, width, height, textInfo, hole, rotation, theme, bindTooltip, compact]); const PieChartLegend: React.FC<{ labels: string[]; colors: string[] }> = ({ labels, @@ -170,7 +176,12 @@ const PieChart: React.FC = ({
{title && (
-

{title}

+

+ {title} +

)}
(density === "compact" ? COMPACT : COMFORTABLE);