Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,16 @@ export type ExposuresSummary = {
timeseries: ExposuresTimeseries
}

export type ConversionsTimeseriesPoint = {
bucket: string
converted_identities: Record<string, number>
}

export type ConversionsTimeseries = {
granularity: ExposureGranularity
points: ConversionsTimeseriesPoint[]
}

export type ExperimentExposures = {
as_of: string | null
last_error_at: string | null
Expand Down Expand Up @@ -760,11 +770,19 @@ export type BayesianMetricResult = {
metric_id: number
variants: Record<string, VariantStats>
inference: Record<string, Inference | null>
// Occurrence metrics only; null for value metrics. Absent from payloads
// stored before the backend shipped it (finalised experiments never gain it).
conversions_timeseries?: ConversionsTimeseries | null
}

export type BayesianResultsSummary = {
srm_p_value: number | null
metrics: BayesianMetricResult[]
// Denominator for the conversion-rate charts, same warehouse run as the
// metrics. Exposures bucket by first exposure and conversions by first
// conversion, so only running totals may be divided — a per-bucket division
// can exceed 100%. Absent from payloads stored before the backend shipped it.
exposures_timeseries?: ExposuresTimeseries
}

export enum TagStrategy {
Expand Down
104 changes: 98 additions & 6 deletions frontend/documentation/components/BarChart.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

// ============================================================================
Expand Down Expand Up @@ -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<string, number> = {
control: 0.12,
variant_a: 0.21,
}

const buildPartOfWholeData = (): ChartDataPoint[] => {
const variants = Object.keys(CONVERSION_SHARE)
const running: Record<string, number> = { 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
// ============================================================================
Expand Down Expand Up @@ -80,8 +127,7 @@ export const WithLabelledBuckets: Story = {
</div>
<BarChart
data={data}
series={filteredLabels}
colorMap={colorMap}
series={toBarSeries(filteredLabels, colorMap)}
xAxisInterval={2}
showLegend
/>
Expand All @@ -105,8 +151,7 @@ export const WithoutLabels: Story = {
</p>
<BarChart
data={data}
series={labels}
colorMap={colorMap}
series={toBarSeries(labels, colorMap)}
xAxisInterval={2}
showLegend
/>
Expand All @@ -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 (
<div className='mx-auto' style={{ maxWidth: 900 }}>
<p className='text-secondary fs-small mb-3'>
Part-of-whole stacks: cumulative exposures per variant with the
converted share filled in.
</p>
<BarChart
data={data}
series={series}
xAxisInterval={2}
showLegend
tooltip={{
formatValue: (value, seriesKey, label) => {
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,
)}%)`
},
}}
/>
</div>
)
},
],
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: [
() => {
Expand All @@ -130,8 +223,7 @@ export const SingleSeries: Story = {
</p>
<BarChart
data={data}
series={labels}
colorMap={colorMap}
series={toBarSeries(labels, colorMap)}
xAxisInterval={2}
showLegend
/>
Expand Down
23 changes: 23 additions & 0 deletions frontend/documentation/components/ColorSwatch.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<div className='d-flex flex-column gap-1 fs-small'>
<div className='d-flex align-items-center gap-2'>
<ColorSwatch color={colorChart1} />
<span className='text-default'>Converted</span>
</div>
<div className='d-flex align-items-center gap-2'>
<ColorSwatch color={colorChart1} opacity={0.25} />
<span className='text-default'>Exposures</span>
</div>
</div>
),
}

export const Palette: Story = {
parameters: {
docs: {
Expand Down
7 changes: 7 additions & 0 deletions frontend/web/components/ColorSwatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ColorSwatchSize, number> = {
Expand All @@ -25,6 +30,7 @@ const SHAPE_CLASS: Record<ColorSwatchShape, string> = {
const ColorSwatch: FC<ColorSwatchProps> = ({
className,
color,
opacity,
shape = 'square',
size = 'md',
}) => (
Expand All @@ -38,6 +44,7 @@ const ColorSwatch: FC<ColorSwatchProps> = ({
style={{
backgroundColor: color,
height: SIZE_MAP[size],
opacity,
width: SIZE_MAP[size],
}}
/>
Expand Down
105 changes: 81 additions & 24 deletions frontend/web/components/charts/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,45 +10,94 @@ 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'
Comment on lines 14 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace the relative chart-module imports.

Lines 14-15 use relative paths. Use components/charts/ChartTooltip and components/charts/types instead.

As per coding guidelines, “Use only common/, components/, and project/ import paths; do not use relative imports.”

Proposed fix
-import ChartTooltip from './ChartTooltip'
-import { BarSeries, ChartDataPoint } from './types'
+import ChartTooltip from 'components/charts/ChartTooltip'
+import { BarSeries, ChartDataPoint } from 'components/charts/types'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import ChartTooltip from './ChartTooltip'
import { ChartDataPoint } from './types'
import { BarSeries, ChartDataPoint } from './types'
import ChartTooltip from 'components/charts/ChartTooltip'
import { BarSeries, ChartDataPoint } from 'components/charts/types'

Source: Coding guidelines


// 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<string, string>
xAxisInterval?: number
/**
* Render recharts' built-in `<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 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<string, string>
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
tooltip?: BarChartTooltipProps
}

type BarChartLegendProps = {
series: BarSeries[]
// Injected by recharts' <Legend content={...}>.
payload?: { value?: string | number; color?: string }[]
}

// recharts' own legend swatch ignores fillOpacity, so a chart with faded
// series needs this to keep the key and the bars looking the same.
const FadedSwatchLegend: FC<BarChartLegendProps> = ({ payload, series }) => (
<div className='d-flex justify-content-center flex-wrap gap-3'>
{payload?.map((entry) => {
const key = String(entry.value)
const bar = series.find((s) => s.key === key)
const colour = bar?.colour ?? entry.color ?? ''
return (
<span className='d-flex align-items-center gap-1' key={key}>
<ColorSwatch color={colour} opacity={bar?.opacity} size='sm' />
<span className='fs-captionSmall' style={{ color: colour }}>
{bar?.label ?? key}
</span>
</span>
)
})}
</div>
)

const BarChart: FC<BarChartProps> = ({
barSize,
colorMap,
data,
height = 400,
series,
seriesLabels,
showLegend = false,
tooltip,
verticalGrid = true,
xAxisInterval = 0,
}) => {
const labels = series.reduce<Record<string, string>>((acc, s) => {
acc[s.key] = s.label
return acc
}, {})
const hasFadedSeries = series.some((s) => s.opacity !== undefined)
return (
<ResponsiveContainer height={400} width='100%'>
<ResponsiveContainer height={height} width='100%'>
<RawBarChart data={data}>
<CartesianGrid
strokeDasharray='3 5'
Expand All @@ -75,22 +124,30 @@ const BarChart: FC<BarChartProps> = ({
/>
<Tooltip
cursor={{ fill: 'transparent' }}
content={<ChartTooltip seriesLabels={seriesLabels} />}
content={
<ChartTooltip
hideTotal={tooltip?.hideTotal ?? !!tooltip?.formatValue}
seriesLabels={labels}
valueFormatter={tooltip?.formatValue}
/>
}
/>
{showLegend && (
<Legend
wrapperStyle={{ paddingTop: 16 }}
formatter={(value) =>
seriesLabels?.[String(value)] ?? String(value)
formatter={(value) => labels[String(value)] ?? String(value)}
content={
hasFadedSeries ? <FadedSwatchLegend series={series} /> : undefined
}
/>
)}
{series.map((label, index) => (
{series.map((s, index) => (
<Bar
key={label}
dataKey={label}
stackId='series'
fill={colorMap[label]}
key={s.key}
dataKey={s.key}
stackId={s.stackId ?? DEFAULT_STACK_ID}
fill={s.colour}
fillOpacity={s.opacity}
barSize={barSize}
animationBegin={index * 80}
animationDuration={600}
Expand Down
Loading
Loading