-
Notifications
You must be signed in to change notification settings - Fork 561
feat: experiment conversion rate chart #8463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,20 +35,100 @@ type BarChartProps = { | |
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems to have no caller for this one, and stackMap overrides it anyway. Same for yAxis on line 57. Do you mind checking it ? |
||
| /** | ||
| * 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<string, string> | ||
| /** | ||
| * dataKey → fill opacity (0–1). Colours are CSS `var()` strings, so | ||
| * transparency must come from SVG fill-opacity, not an alpha channel. | ||
| */ | ||
| opacityMap?: Record<string, number> | ||
| /** 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 | ||
| } | ||
|
|
||
| type FadedSwatchLegendProps = { | ||
| opacityMap: Record<string, number> | ||
| seriesLabels?: Record<string, string> | ||
| // Injected by recharts' <Legend content={...}>. | ||
| payload?: { value?: string | number; color?: string }[] | ||
| } | ||
|
|
||
| const FadedSwatchLegend: FC<FadedSwatchLegendProps> = ({ | ||
| opacityMap, | ||
| payload, | ||
| seriesLabels, | ||
| }) => ( | ||
| <div className='d-flex justify-content-center flex-wrap gap-3'> | ||
| {payload?.map((entry) => { | ||
| const key = String(entry.value) | ||
| return ( | ||
| <span className='d-flex align-items-center gap-1' key={key}> | ||
| <span | ||
| style={{ | ||
| backgroundColor: entry.color, | ||
| display: 'inline-block', | ||
| height: 10, | ||
| opacity: opacityMap[key] ?? 1, | ||
| width: 10, | ||
| }} | ||
| /> | ||
| <span style={{ color: entry.color, fontSize: 12 }}> | ||
| {seriesLabels?.[key] ?? key} | ||
| </span> | ||
| </span> | ||
| ) | ||
| })} | ||
| </div> | ||
| ) | ||
|
|
||
| const BarChart: FC<BarChartProps> = ({ | ||
| barSize, | ||
| colorMap, | ||
| data, | ||
| grouped = false, | ||
| height = 400, | ||
| opacityMap, | ||
| series, | ||
| seriesLabels, | ||
| showLegend = false, | ||
| stackMap, | ||
| tooltipHideTotal, | ||
| tooltipValueFormatter, | ||
| verticalGrid = true, | ||
| xAxisInterval = 0, | ||
| yAxis, | ||
| }) => { | ||
| const defaultStackId = grouped ? undefined : 'series' | ||
| return ( | ||
| <ResponsiveContainer height={400} width='100%'> | ||
| <ResponsiveContainer height={height} width='100%'> | ||
| <RawBarChart data={data}> | ||
| <CartesianGrid | ||
| strokeDasharray='3 5' | ||
|
|
@@ -69,28 +149,48 @@ const BarChart: FC<BarChartProps> = ({ | |
| <YAxis | ||
| tick={{ fill: colorTextSecondary, fontSize: 11 }} | ||
| axisLine={{ stroke: colorTextSecondary }} | ||
| tickFormatter={(value) => | ||
| value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value | ||
| domain={yAxis?.domain} | ||
| tickFormatter={ | ||
| yAxis?.tickFormatter ?? | ||
| ((value) => | ||
| value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value) | ||
| } | ||
| /> | ||
| <Tooltip | ||
| cursor={{ fill: 'transparent' }} | ||
| content={<ChartTooltip seriesLabels={seriesLabels} />} | ||
| content={ | ||
| <ChartTooltip | ||
| hideTotal={tooltipHideTotal} | ||
| seriesLabels={seriesLabels} | ||
| valueFormatter={tooltipValueFormatter} | ||
| /> | ||
| } | ||
| /> | ||
| {showLegend && ( | ||
| <Legend | ||
| wrapperStyle={{ paddingTop: 16 }} | ||
| formatter={(value) => | ||
| seriesLabels?.[String(value)] ?? String(value) | ||
| } | ||
| content={ | ||
| // The default legend swatch ignores fillOpacity, so faded | ||
| // series need their own renderer to match the bars. | ||
| opacityMap ? ( | ||
| <FadedSwatchLegend | ||
| opacityMap={opacityMap} | ||
| seriesLabels={seriesLabels} | ||
| /> | ||
| ) : undefined | ||
| } | ||
| /> | ||
| )} | ||
| {series.map((label, index) => ( | ||
| <Bar | ||
| key={label} | ||
| dataKey={label} | ||
| stackId='series' | ||
| stackId={stackMap ? stackMap[label] : defaultStackId} | ||
| fill={colorMap[label]} | ||
| fillOpacity={opacityMap?.[label]} | ||
| barSize={barSize} | ||
| animationBegin={index * 80} | ||
| animationDuration={600} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { FC, useCallback, useMemo, useState } from 'react' | ||
| import moment from 'moment' | ||
| import { BarChart } from 'components/charts' | ||
| import ContentCard from 'components/base/grid/ContentCard' | ||
| import InlinePillToggle from 'components/base/forms/InlinePillToggle' | ||
| import { BayesianResultsSummary, Experiment } from 'common/types/responses' | ||
| import { getPrimaryMetric } from 'components/experiments/constants' | ||
| import { | ||
| getMetricResult, | ||
| getVariantIdentities, | ||
| } from 'components/experiments/results/derive' | ||
| import { | ||
| ConversionStackMode, | ||
| REST_SUFFIX, | ||
| buildConversionRateChartData, | ||
| buildConversionStackChartData, | ||
| } from 'components/experiments/results/deriveConversionRate' | ||
|
|
||
| type ExperimentConversionRateCardProps = { | ||
| experiment: Experiment | ||
| results?: BayesianResultsSummary | ||
| asOf: string | null | ||
| } | ||
|
|
||
| const ExperimentConversionRateCard: FC<ExperimentConversionRateCardProps> = ({ | ||
| asOf, | ||
| experiment, | ||
| results, | ||
| }) => { | ||
| const [mode, setMode] = useState<ConversionStackMode>('cumulative') | ||
| const metric = getPrimaryMetric(experiment) | ||
| const identities = useMemo( | ||
| () => getVariantIdentities(experiment.feature), | ||
| [experiment.feature], | ||
| ) | ||
| const chart = useMemo( | ||
| () => | ||
| metric && results | ||
| ? buildConversionStackChartData( | ||
| results, | ||
| metric.metric, | ||
| identities, | ||
| mode, | ||
| ) | ||
| : null, | ||
| [metric, results, identities, mode], | ||
| ) | ||
| // Running counts and rates, for the cumulative tooltip ("x of y (z%)"). | ||
| const rateChart = useMemo( | ||
| () => | ||
| metric && results | ||
| ? buildConversionRateChartData(results, metric.metric, identities) | ||
| : null, | ||
| [metric, results, identities], | ||
| ) | ||
|
|
||
| const formatTooltipValue = useCallback( | ||
| (value: number, seriesKey: string, label: string) => { | ||
| if (mode === 'daily') return value.toLocaleString() | ||
| if (seriesKey.endsWith(REST_SUFFIX)) { | ||
| // The faded segment is labelled "exposures", so report the full bar | ||
| // total rather than the plotted remainder (exposures − conversions). | ||
| const variantKey = seriesKey.slice(0, -REST_SUFFIX.length) | ||
| const counts = rateChart?.countsByDay[label]?.[variantKey] | ||
| return (counts?.exposed ?? value).toLocaleString() | ||
| } | ||
| const counts = rateChart?.countsByDay[label]?.[seriesKey] | ||
| if (!counts) return value.toLocaleString() | ||
| const rate = rateChart?.points.find((p) => p.day === label)?.[seriesKey] | ||
| return `${counts.converted.toLocaleString()} of ${counts.exposed.toLocaleString()}${ | ||
| typeof rate === 'number' ? ` (${rate}%)` : '' | ||
| }` | ||
| }, | ||
| [mode, rateChart], | ||
| ) | ||
|
|
||
| // Hidden entirely when no rate can be charted: value metrics, and | ||
| // payloads stored before the backend shipped the timeseries. | ||
| if (!metric || !results || !chart) return null | ||
|
|
||
| const conversions = getMetricResult( | ||
| results, | ||
| metric.metric, | ||
| )?.conversions_timeseries | ||
| const hasConversions = !!conversions && conversions.points.length > 0 | ||
|
|
||
| return ( | ||
| <ContentCard | ||
| action={ | ||
| hasConversions ? ( | ||
| // Single metric today — disabled until multi-metric ships. | ||
| <div style={{ minWidth: 180 }}> | ||
| <Select | ||
| isDisabled | ||
| size='select-sm' | ||
| value={{ label: metric.metric_name, value: metric.metric }} | ||
| options={[{ label: metric.metric_name, value: metric.metric }]} | ||
| /> | ||
| </div> | ||
| ) : undefined | ||
| } | ||
| className='experiment-results__conversion-rate-card' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you mind checking if this class is defined anywhere? I couldn't find a rule for it, and ContentCard has background='white' if that's the surface you were after. |
||
| title='Conversion rate over time' | ||
| > | ||
| {hasConversions ? ( | ||
| <> | ||
| {/* mt-n2 halves the card's 16px child gap after the title. */} | ||
| <div className='d-flex mt-n2'> | ||
| <InlinePillToggle<ConversionStackMode> | ||
| size='small' | ||
| options={[ | ||
| { label: 'Cumulative', value: 'cumulative' }, | ||
| { label: 'Daily', value: 'daily' }, | ||
| ]} | ||
| value={mode} | ||
| onChange={setMode} | ||
| /> | ||
| </div> | ||
| <BarChart | ||
| colorMap={chart.colorMap} | ||
| data={chart.points} | ||
| height={260} | ||
| opacityMap={chart.opacityMap} | ||
| series={chart.series} | ||
| seriesLabels={chart.seriesLabels} | ||
| showLegend | ||
| stackMap={chart.stackMap} | ||
| tooltipHideTotal | ||
| tooltipValueFormatter={formatTooltipValue} | ||
| /> | ||
| <span className='text-muted fs-caption'> | ||
| {asOf | ||
| ? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC` | ||
| : ''} | ||
| </span> | ||
| </> | ||
| ) : ( | ||
| <div className='text-muted text-center py-5'> | ||
| No conversions recorded yet. | ||
| </div> | ||
| )} | ||
| </ContentCard> | ||
| ) | ||
| } | ||
|
|
||
| export default ExperimentConversionRateCard | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from './ExperimentConversionRateCard' |
Uh oh!
There was an error while loading. Please reload this page.