TanStack
Catalog

Sales conversion funnel

composition

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)
}