diff --git a/frontend/documentation/components/BarChart.stories.tsx b/frontend/documentation/components/BarChart.stories.tsx
index b0549afed5dc..9992ba24099c 100644
--- a/frontend/documentation/components/BarChart.stories.tsx
+++ b/frontend/documentation/components/BarChart.stories.tsx
@@ -3,6 +3,8 @@ import type { Meta, StoryObj } from 'storybook'
import BarChart from 'components/charts/BarChart'
import { MultiSelect } from 'components/base/select/multi-select'
import { buildChartColorMap } from 'components/charts/buildChartColorMap'
+import { toBarSeries } from 'components/charts/toBarSeries'
+import type { BarSeries, ChartDataPoint } from 'components/charts/types'
import { generateChartFakeData } from './_chartFakeData'
// ============================================================================
@@ -35,6 +37,51 @@ const generateFakeData = (days: number, labels: string[]) =>
weekendDip: 0.4,
})
+// Cumulative exposures per variant with a fixed share converted, the shape the
+// experiment conversion chart plots: the faded segment is the remainder, so the
+// full bar is the denominator and the solid part is the numerator.
+const CONVERSION_SHARE: Record = {
+ control: 0.12,
+ variant_a: 0.21,
+}
+
+const buildPartOfWholeData = (): ChartDataPoint[] => {
+ const variants = Object.keys(CONVERSION_SHARE)
+ const running: Record = { control: 0, variant_a: 0 }
+ return generateChartFakeData({
+ days: 14,
+ defaultBase: 300,
+ labels: variants,
+ variance: 0.6,
+ }).map((point) => {
+ const stacked: ChartDataPoint = { day: point.day }
+ variants.forEach((key) => {
+ running[key] += Number(point[key])
+ const converted = Math.round(running[key] * CONVERSION_SHARE[key])
+ stacked[key] = converted
+ stacked[`${key}-rest`] = running[key] - converted
+ })
+ return stacked
+ })
+}
+
+const buildPartOfWholeSeries = (): BarSeries[] => {
+ const colours = buildChartColorMap(Object.keys(CONVERSION_SHARE))
+ return [
+ { key: 'control', label: 'Control converted', name: 'Control' },
+ { key: 'variant_a', label: 'Variant A converted', name: 'Variant A' },
+ ].flatMap(({ key, label, name }) => [
+ { colour: colours[key], key, label, stackId: key },
+ {
+ colour: colours[key],
+ key: `${key}-rest`,
+ label: `${name} exposures`,
+ opacity: 0.25,
+ stackId: key,
+ },
+ ])
+}
+
// ============================================================================
// Stories
// ============================================================================
@@ -80,8 +127,7 @@ export const WithLabelledBuckets: Story = {
@@ -105,8 +151,7 @@ export const WithoutLabels: Story = {
@@ -116,6 +161,54 @@ export const WithoutLabels: Story = {
],
}
+export const PartOfWholeStacks: Story = {
+ decorators: [
+ () => {
+ const data = useMemo(() => buildPartOfWholeData(), [])
+ const series = useMemo(() => buildPartOfWholeSeries(), [])
+ const counts = (label: string, key: string) => {
+ const point = data.find((p) => p.day === label)
+ const converted = Number(point?.[key.replace('-rest', '')] ?? 0)
+ const rest = Number(point?.[`${key.replace('-rest', '')}-rest`] ?? 0)
+ return { converted, exposed: converted + rest }
+ }
+
+ return (
+
+
+ Part-of-whole stacks: cumulative exposures per variant with the
+ converted share filled in.
+
+
{
+ const { converted, exposed } = counts(label, seriesKey)
+ if (seriesKey.endsWith('-rest')) return exposed.toLocaleString()
+ const rate = exposed ? (converted / exposed) * 100 : 0
+ return `${converted.toLocaleString()} of ${exposed.toLocaleString()} (${rate.toFixed(
+ 1,
+ )}%)`
+ },
+ }}
+ />
+
+ )
+ },
+ ],
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Series sharing a `stackId` stack into one bar; distinct ids sit side by side. Giving the remainder segment an `opacity` fades it, and the legend swatch fades with it (recharts' own legend swatch ignores `fillOpacity`, so the chart renders its own key). `tooltip.formatValue` reports the pair, and a formatted value hides the total row by default since it is no longer additive.",
+ },
+ },
+ },
+}
+
export const SingleSeries: Story = {
decorators: [
() => {
@@ -130,8 +223,7 @@ export const SingleSeries: Story = {
diff --git a/frontend/documentation/components/ColorSwatch.stories.tsx b/frontend/documentation/components/ColorSwatch.stories.tsx
index a3f78aac4c49..4fb76c2d3bf8 100644
--- a/frontend/documentation/components/ColorSwatch.stories.tsx
+++ b/frontend/documentation/components/ColorSwatch.stories.tsx
@@ -115,6 +115,29 @@ export const Shapes: Story = {
),
}
+export const Faded: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ '`opacity` fades the swatch to match a series drawn with an SVG `fill-opacity`, such as the remainder segment of a part-of-whole bar. Colours can be CSS `var()` strings, so transparency cannot come from an alpha channel.',
+ },
+ },
+ },
+ render: () => (
+
+
+
+ Converted
+
+
+
+ Exposures
+
+
+ ),
+}
+
export const Palette: Story = {
parameters: {
docs: {
diff --git a/frontend/web/components/ColorSwatch.tsx b/frontend/web/components/ColorSwatch.tsx
index 3b2ffa5e4e10..813cb9cbbffe 100644
--- a/frontend/web/components/ColorSwatch.tsx
+++ b/frontend/web/components/ColorSwatch.tsx
@@ -9,6 +9,11 @@ type ColorSwatchProps = {
size?: ColorSwatchSize
shape?: ColorSwatchShape
className?: string
+ /**
+ * Fade the swatch, for series drawn with an SVG `fill-opacity`. Colours can
+ * be CSS `var()` strings, so transparency can't come from an alpha channel.
+ */
+ opacity?: number
}
const SIZE_MAP: Record = {
@@ -25,6 +30,7 @@ const SHAPE_CLASS: Record = {
const ColorSwatch: FC = ({
className,
color,
+ opacity,
shape = 'square',
size = 'md',
}) => (
@@ -38,6 +44,7 @@ const ColorSwatch: FC = ({
style={{
backgroundColor: color,
height: SIZE_MAP[size],
+ opacity,
width: SIZE_MAP[size],
}}
/>
diff --git a/frontend/web/components/charts/BarChart.tsx b/frontend/web/components/charts/BarChart.tsx
index ccf00e395b3f..7ca1bb8e7804 100644
--- a/frontend/web/components/charts/BarChart.tsx
+++ b/frontend/web/components/charts/BarChart.tsx
@@ -10,98 +10,70 @@ import {
YAxis,
} from 'recharts'
import { colorTextSecondary } from 'common/theme/tokens'
+import ColorSwatch from 'components/ColorSwatch'
import ChartTooltip from './ChartTooltip'
-import { ChartDataPoint } from './types'
+import { BarSeries, ChartDataPoint } from './types'
+
+// Series with no stack id of their own share this one, so the default shape is
+// a single stacked bar per x value.
+const DEFAULT_STACK_ID = 'series'
+
+type BarChartTooltipProps = {
+ /**
+ * Per-entry value renderer, e.g. `"120 of 1,450 (8.3%)"`. Skipped for
+ * missing or non-numeric values, which render blank.
+ */
+ formatValue?: (value: number, seriesKey: string, label: string) => string
+ /**
+ * Hide the total row. A formatted value is usually not additive (a
+ * percentage, an "x of y"), so `formatValue` hides the total by default;
+ * pass `false` to keep it.
+ */
+ hideTotal?: boolean
+}
type BarChartProps = {
data: ChartDataPoint[]
- series: string[]
- colorMap: Record
- xAxisInterval?: number
/**
- * Render recharts' built-in `` below the chart. Default `false` —
- * most consumers already expose a coloured filter UI (tags / MultiSelect)
- * that serves the same purpose, so a second legend is redundant and can
- * display raw dataKeys (e.g. numeric env IDs) that are meaningless to users.
+ * One entry per bar series, in render order. `key` is the dataKey to read
+ * from each `data` point; `stackId` and `opacity` shape how it draws.
*/
- showLegend?: boolean
+ series: BarSeries[]
+ xAxisInterval?: number
/**
- * Optional dataKey → display name map, threaded through to the tooltip (and
- * the legend when enabled). Use this when dataKeys are opaque identifiers
- * (e.g. numeric env ids) that need a human-readable label on display.
+ * Render a legend below the chart. Default `false` — most consumers already
+ * expose a coloured filter UI (tags / MultiSelect) that serves the same
+ * purpose, so a second legend is redundant.
*/
- seriesLabels?: Record
+ showLegend?: boolean
/** Fixed bar width in pixels. Default: recharts auto-sizes by available space. */
barSize?: number
/** Render vertical grid lines (one per x tick). Default `true`. */
verticalGrid?: boolean
/** Chart height in pixels. Default 400. */
height?: number
- /**
- * Render series side by side instead of stacked. Required for non-additive
- * values (rates, percentages) where stacking would be meaningless.
- */
- grouped?: boolean
- /**
- * dataKey → stack id, for part-of-whole bars: series sharing a stack id
- * stack together, distinct ids sit side by side (e.g. converted/remainder
- * segments stacked per variant, variants grouped). Overrides `grouped`.
- */
- stackMap?: Record
- /**
- * dataKey → fill opacity (0–1). Colours are CSS `var()` strings, so
- * transparency must come from SVG fill-opacity, not an alpha channel.
- */
- opacityMap?: Record
- /** Left axis overrides, e.g. a `%` tick formatter. */
- yAxis?: {
- tickFormatter?: (value: number) => string
- domain?: [number, number]
- }
- /**
- * Per-entry tooltip value renderer, threaded to ChartTooltip. Skipped for
- * missing or non-numeric values, which render blank.
- */
- tooltipValueFormatter?: (
- value: number,
- seriesKey: string,
- label: string,
- ) => string
- /**
- * Hide the tooltip's total row — required when `tooltipValueFormatter`
- * renders a non-additive unit such as a percentage.
- */
- tooltipHideTotal?: boolean
+ tooltip?: BarChartTooltipProps
}
-type FadedSwatchLegendProps = {
- opacityMap: Record
- seriesLabels?: Record
+type BarChartLegendProps = {
+ series: BarSeries[]
// Injected by recharts'