176 lines · 3 files · 4.6 kB
cases/125-sales-funnel/data.ts28 lines · dependency
cases/125-sales-funnel/data.ts
export interface FunnelStage {
id: string
label: string
value: number
}
const revisions: readonly (readonly FunnelStage[])[] = [
[
{ id: 'visitors', label: 'Visitors', value: 12_400 },
{ id: 'leads', label: 'Leads', value: 6_800 },
{ id: 'qualified', label: 'Qualified', value: 3_200 },
{ id: 'proposals', label: 'Proposals', value: 1_500 },
{ id: 'closed', label: 'Closed', value: 620 },
],
[
{ id: 'visitors', label: 'Visitors', value: 12_400 },
{ id: 'leads', label: 'Leads', value: 7_100 },
{ id: 'qualified', label: 'Qualified', value: 3_500 },
{ id: 'proposals', label: 'Proposals', value: 1_720 },
{ id: 'closed', label: 'Closed', value: 690 },
],
]
export function funnelStagesForRevision(
revision: number,
): readonly FunnelStage[] {
return revisions[revision % revisions.length] ?? revisions[0] ?? []
}cases/125-sales-funnel/example.tsx78 lines · entry
cases/125-sales-funnel/example.tsx
import { Chart } from '@tanstack/charts/react/tooltip'
import { tooltip as exampleTooltip } from '@tanstack/charts/tooltip'
import { areaX, defineChart, text } from '@tanstack/charts'
import { scaleLinear } from 'd3-scale'
import { funnelStagesForRevision } from './data'
import { funnelLayout } from './model'
const colors = ['#1e3a8a', '#1d4ed8', '#2563eb', '#3b82f6', '#60a5fa']
const firstStageValue = funnelStagesForRevision(0)[0]?.value ?? 1
export const createExampleChart = (input: ChartOptions) => {
const stages = funnelStagesForRevision(input.revision)
const layout = funnelLayout(stages)
return defineChart(
{
marks: [
areaX(layout.points, {
id: 'funnel-stages',
x1: 'x1',
x2: 'x2',
y: 'y',
z: 'id',
color: 'id',
key: (point) => `${point.id}:${point.boundary}`,
fillOpacity: 1,
}),
text(layout.labels, {
id: 'funnel-labels',
x: 'x',
y: 'y',
text: 'text',
key: 'id',
anchor: 'start',
fontSize: input.preview === true ? 8 : input.width < 400 ? 10 : 12,
fontWeight: 600,
}),
],
scales: {
x: { scale: scaleLinear().domain(layout.xDomain), axis: false },
y: { scale: scaleLinear().domain(layout.yDomain), axis: false },
},
color: { domain: stages.map((stage) => stage.id), range: colors },
margin: 12,
},
{
keyboard: true,
tooltip: {
use: exampleTooltip,
...{
format: ({ datum }) =>
`${datum.label} · ${datum.value.toLocaleString('en-US')} · ${Math.round(
(datum.value / firstStageValue) * 100,
)}% of visitors`,
},
},
},
)
}
export interface ChartOptions {
width: number
revision: number
preview?: boolean
}
export const exampleAriaLabel = 'Sales conversion funnel'
export const chart = createExampleChart({
width: 640,
revision: 0,
preview: false,
})
export default function Example() {
return <Chart ariaLabel={exampleAriaLabel} definition={chart} height={480} />
}cases/125-sales-funnel/model.ts70 lines · dependency
cases/125-sales-funnel/model.ts
import type { FunnelStage } from './data'
export interface FunnelPoint extends FunnelStage {
boundary: 'start' | 'end'
y: number
x1: number
x2: number
}
export interface FunnelLabel extends FunnelStage {
x: number
y: number
text: string
}
export interface FunnelLayout {
points: readonly FunnelPoint[]
labels: readonly FunnelLabel[]
xDomain: readonly [number, number]
yDomain: readonly [number, number]
}
const segmentInset = 0.035
const finalWidthRatio = 0.72
export function funnelLayout(stages: readonly FunnelStage[]): FunnelLayout {
const maximum = Math.max(0, ...stages.map((stage) => stage.value))
const points = stages.flatMap((stage, index) => {
const nextValue = stages[index + 1]?.value ?? stage.value * finalWidthRatio
return [
funnelPoint(stage, 'start', index + segmentInset, stage.value),
funnelPoint(stage, 'end', index + 1 - segmentInset, nextValue),
]
})
const labels = stages.map((stage, index) => ({
...stage,
x: maximum * 0.56,
y: index + 0.5,
text: `${stage.label} · ${compactNumber(stage.value)}`,
}))
return {
points,
labels,
xDomain: [-maximum / 2, maximum * 0.96],
yDomain: [stages.length, 0],
}
}
function funnelPoint(
stage: FunnelStage,
boundary: FunnelPoint['boundary'],
y: number,
width: number,
): FunnelPoint {
return {
...stage,
boundary,
y,
x1: -width / 2,
x2: width / 2,
}
}
function compactNumber(value: number): string {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value)
}