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);