From 941fcb94ad6511641cec188798b3ddea49343c6c Mon Sep 17 00:00:00 2001
From: Talisson Costa
Date: Fri, 4 Sep 2026 14:34:52 -0300
Subject: [PATCH 1/2] feat(charts): let ColorSwatch fade
Series drawn with an SVG fill-opacity had no matching swatch: colours are
CSS var() strings, so a className can't carry a per-series alpha. Adds an
optional opacity, and a story alongside the other prop variants.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/ColorSwatch.stories.tsx | 23 +++++++++++++++++++
frontend/web/components/ColorSwatch.tsx | 7 ++++++
2 files changed, 30 insertions(+)
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],
}}
/>
From 669a20881c80a47fba78c66c87d20a59e184caf5 Mon Sep 17 00:00:00 2001
From: Talisson Costa
Date: Fri, 4 Sep 2026 14:35:04 -0300
Subject: [PATCH 2/2] refactor(charts): describe bar series as objects, not
parallel maps
BarChart took four maps keyed by dataKey (colours, labels, stack ids, fill
opacities) with nothing tying the keys together, so a missing entry silently
dropped a bar's colour or stack. One BarSeries per series replaces all four,
and the legend now reads the same array the bars do, so a faded swatch can't
drift from its faded bar.
Two props go with it. `grouped` was unreachable: side by side is distinct
stack ids, which is what the conversion chart's daily mode already did. The
tooltip's formatter and its total flag become one object, with a formatted
value hiding the non-additive total by default rather than asking every
caller to remember.
Existing charts keep their shared default stack, so nothing moves visually.
toBarSeries bridges callers still holding maps from buildChartColorMap or
useEnvChartProps, which LineChart continues to read.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/BarChart.stories.tsx | 104 +++++++++++-
frontend/web/components/charts/BarChart.tsx | 159 +++++++-----------
frontend/web/components/charts/index.ts | 3 +-
frontend/web/components/charts/toBarSeries.ts | 17 ++
frontend/web/components/charts/types.ts | 19 +++
.../ExperimentConversionRateCard.tsx | 7 +-
.../__tests__/deriveConversionRate.test.ts | 33 ++--
.../results/deriveConversionRate.ts | 52 +++---
.../FeatureNavTab/FeatureAnalytics.tsx | 12 +-
.../usage/OrganisationUsage.container.tsx | 5 +-
.../usage/components/SingleSDKLabelsChart.tsx | 4 +-
.../UsageOverTime/UsageOverTime.tsx | 6 +-
12 files changed, 254 insertions(+), 167 deletions(-)
create mode 100644 frontend/web/components/charts/toBarSeries.ts
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/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'