1 GitHub star = 1 new component.

Star the repo
Charts & Stats chartsvgzero-dependencybox-plotdistributionstatisticsquartiles

Box Plot Chart

Distribution summary per group with median, IQR box, whiskers and outliers computed from raw data.

Live Preview

Response time by region

Distribution of p50 latency, ms

0ms 50ms 100ms 150ms 200ms 250ms us-east us-west eu-central ap-south sa-east
View data
GroupMinQ1MedianQ3MaxOutliers
us-east54ms80ms89ms99ms110ms2
us-west57ms85ms97ms103ms126ms2
eu-central45ms61ms69ms76ms96ms2
ap-south73ms124ms139ms159ms191ms0
sa-east115ms146ms156ms174ms206ms0

Usage

Copy and paste this code into your Astro page:

astro
---
import { BoxPlotChart } from 'astro-component-kit/charts';
---

<BoxPlotChart
  groups={[
    { label: 'us-east', values: [62, 70, 81, 85, 90, 120] },
    { label: 'eu-west', values: [55, 60, 66, 71, 79, 160] },
  ]}
  suffix="ms"
/>
--- import { BoxPlotChart } from 'astro-component-kit/charts'; --- <BoxPlotChart groups={[ { label: 'us-east', values: [62, 70, 81, 85, 90, 120] }, { label: 'eu-west', values: [55, 60, 66, 71, 79, 160] }, ]} suffix="ms" />

Manual Installation

If you are not using the npm package, create a new file and paste the following code: src/components/lib/BoxPlotChart.astro:

astro
---
/**
 * BoxPlotChart — Distribution summary per group: median, interquartile box,
 * 1.5×IQR whiskers and outlier dots. Computes stats from raw values.
 *
 * @param {Array<{label: string, values: number[]}>} groups - Raw observations per group.
 * @param {string} suffix - Value suffix.
 * @param {string} color - Box color.
 * @param {string} variant - Card style (glass|solid|ghost).
 * @param {string} size - Density (sm|md|lg).
 */
import ChartFrame from './ChartFrame.astro';
import { scaleLinear, niceTicks, quantile } from './chart-utils';

interface Props {
  title?: string;
  subtitle?: string;
  groups?: Array<{ label: string; values: number[] }>;
  suffix?: string;
  height?: number;
  color?: string;
  variant?: 'glass' | 'solid' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  className?: string;
}

const rand = (seed: number, n: number, mean: number, spread: number) =>
  Array.from({ length: n }, (_, i) => {
    const a = Math.sin((i + 1) * seed) * 9999;
    const b = Math.sin((i + 3) * seed * 1.7) * 9999;
    return Math.round(mean + ((a - Math.floor(a)) + (b - Math.floor(b)) - 1) * spread);
  });

const {
  title = 'Response time by region',
  subtitle = 'Distribution of p50 latency, ms',
  groups = [
    { label: 'us-east', values: [...rand(1.3, 40, 82, 40), 190] },
    { label: 'us-west', values: rand(2.7, 40, 95, 45) },
    { label: 'eu-central', values: [...rand(4.1, 40, 70, 30), 160, 172] },
    { label: 'ap-south', values: rand(5.9, 40, 140, 70) },
    { label: 'sa-east', values: rand(8.3, 40, 160, 55) },
  ],
  suffix = 'ms',
  height = 290,
  color = 'var(--chart-1, #3987e5)',
  variant = 'glass',
  size = 'md',
  className = '',
} = Astro.props;

const stats = groups.map((g) => {
  const s = [...g.values].sort((a, b) => a - b);
  const q1 = quantile(s, 0.25);
  const med = quantile(s, 0.5);
  const q3 = quantile(s, 0.75);
  const iqr = q3 - q1;
  const lo = s.find((v) => v >= q1 - 1.5 * iqr) ?? s[0];
  const hi = [...s].reverse().find((v) => v <= q3 + 1.5 * iqr) ?? s[s.length - 1];
  const outliers = s.filter((v) => v < lo || v > hi);
  return { label: g.label, q1, med, q3, lo, hi, outliers, n: s.length };
});

const W = 640;
const H = height;
const m = { t: 14, r: 12, b: 30, l: 48 };
const allV = groups.flatMap((g) => g.values);
const ticks = niceTicks(Math.min(...allV), Math.max(...allV), 4);
const y = scaleLinear(ticks[0], ticks[ticks.length - 1], H - m.b, m.t);
const band = (W - m.l - m.r) / groups.length;
const bw = Math.min(56, band * 0.45);
const f = (v: number) => `${Math.round(v)}${suffix}`;
---

<ChartFrame
  title={title}
  subtitle={subtitle}
  variant={variant}
  size={size}
  className={className}
  table={{ headers: ['Group', 'Min', 'Q1', 'Median', 'Q3', 'Max', 'Outliers'], rows: stats.map((s) => [s.label, f(s.lo), f(s.q1), f(s.med), f(s.q3), f(s.hi), s.outliers.length]) }}
>
  <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: box plot of ${groups.length} groups`}>
    {ticks.map((t) => (
      <g>
        <line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} />
        <text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{f(t)}</text>
      </g>
    ))}
    {stats.map((s, i) => {
      const cx = m.l + i * band + band / 2;
      return (
        <g class="cf-mark cf-dim" tabindex="0" role="img"
          aria-label={`${s.label}: median ${f(s.med)}, IQR ${f(s.q1)}–${f(s.q3)}, range ${f(s.lo)}–${f(s.hi)}, ${s.outliers.length} outliers`}
          data-tip-title={s.label} data-tip={`Max: ${f(s.hi)}|Q3: ${f(s.q3)}|${color}::Median: ${f(s.med)}|Q1: ${f(s.q1)}|Min: ${f(s.lo)}${s.outliers.length ? `|Outliers: ${s.outliers.length}` : ''}`}>
          <rect x={cx - band / 2} y={m.t} width={band} height={H - m.t - m.b} fill="transparent" />
          <line x1={cx} x2={cx} y1={y(s.hi)} y2={y(s.q3)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" />
          <line x1={cx} x2={cx} y1={y(s.q1)} y2={y(s.lo)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" />
          <line x1={cx - bw / 4} x2={cx + bw / 4} y1={y(s.hi)} y2={y(s.hi)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" />
          <line x1={cx - bw / 4} x2={cx + bw / 4} y1={y(s.lo)} y2={y(s.lo)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" />
          <rect x={cx - bw / 2} y={y(s.q3)} width={bw} height={Math.max(1, y(s.q1) - y(s.q3))} rx="4" fill={color} fill-opacity="0.3" stroke={color} stroke-width="1.5" />
          <line x1={cx - bw / 2} x2={cx + bw / 2} y1={y(s.med)} y2={y(s.med)} stroke="var(--c-text-1, #fff)" stroke-width="2.5" />
          {s.outliers.map((o) => <circle cx={cx} cy={y(o)} r="3.5" fill="none" stroke={color} stroke-width="1.5" />)}
          <text class="cf-label" x={cx} y={H - 10} text-anchor="middle">{s.label}</text>
        </g>
      );
    })}
  </svg>
</ChartFrame>
--- /** * BoxPlotChart — Distribution summary per group: median, interquartile box, * 1.5×IQR whiskers and outlier dots. Computes stats from raw values. * * @param {Array<{label: string, values: number[]}>} groups - Raw observations per group. * @param {string} suffix - Value suffix. * @param {string} color - Box color. * @param {string} variant - Card style (glass|solid|ghost). * @param {string} size - Density (sm|md|lg). */ import ChartFrame from './ChartFrame.astro'; import { scaleLinear, niceTicks, quantile } from './chart-utils'; interface Props { title?: string; subtitle?: string; groups?: Array<{ label: string; values: number[] }>; suffix?: string; height?: number; color?: string; variant?: 'glass' | 'solid' | 'ghost'; size?: 'sm' | 'md' | 'lg'; className?: string; } const rand = (seed: number, n: number, mean: number, spread: number) => Array.from({ length: n }, (_, i) => { const a = Math.sin((i + 1) * seed) * 9999; const b = Math.sin((i + 3) * seed * 1.7) * 9999; return Math.round(mean + ((a - Math.floor(a)) + (b - Math.floor(b)) - 1) * spread); }); const { title = 'Response time by region', subtitle = 'Distribution of p50 latency, ms', groups = [ { label: 'us-east', values: [...rand(1.3, 40, 82, 40), 190] }, { label: 'us-west', values: rand(2.7, 40, 95, 45) }, { label: 'eu-central', values: [...rand(4.1, 40, 70, 30), 160, 172] }, { label: 'ap-south', values: rand(5.9, 40, 140, 70) }, { label: 'sa-east', values: rand(8.3, 40, 160, 55) }, ], suffix = 'ms', height = 290, color = 'var(--chart-1, #3987e5)', variant = 'glass', size = 'md', className = '', } = Astro.props; const stats = groups.map((g) => { const s = [...g.values].sort((a, b) => a - b); const q1 = quantile(s, 0.25); const med = quantile(s, 0.5); const q3 = quantile(s, 0.75); const iqr = q3 - q1; const lo = s.find((v) => v >= q1 - 1.5 * iqr) ?? s[0]; const hi = [...s].reverse().find((v) => v <= q3 + 1.5 * iqr) ?? s[s.length - 1]; const outliers = s.filter((v) => v < lo || v > hi); return { label: g.label, q1, med, q3, lo, hi, outliers, n: s.length }; }); const W = 640; const H = height; const m = { t: 14, r: 12, b: 30, l: 48 }; const allV = groups.flatMap((g) => g.values); const ticks = niceTicks(Math.min(...allV), Math.max(...allV), 4); const y = scaleLinear(ticks[0], ticks[ticks.length - 1], H - m.b, m.t); const band = (W - m.l - m.r) / groups.length; const bw = Math.min(56, band * 0.45); const f = (v: number) => `${Math.round(v)}${suffix}`; --- <ChartFrame title={title} subtitle={subtitle} variant={variant} size={size} className={className} table={{ headers: ['Group', 'Min', 'Q1', 'Median', 'Q3', 'Max', 'Outliers'], rows: stats.map((s) => [s.label, f(s.lo), f(s.q1), f(s.med), f(s.q3), f(s.hi), s.outliers.length]) }} > <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: box plot of ${groups.length} groups`}> {ticks.map((t) => ( <g> <line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} /> <text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{f(t)}</text> </g> ))} {stats.map((s, i) => { const cx = m.l + i * band + band / 2; return ( <g class="cf-mark cf-dim" tabindex="0" role="img" aria-label={`${s.label}: median ${f(s.med)}, IQR ${f(s.q1)}–${f(s.q3)}, range ${f(s.lo)}–${f(s.hi)}, ${s.outliers.length} outliers`} data-tip-title={s.label} data-tip={`Max: ${f(s.hi)}|Q3: ${f(s.q3)}|${color}::Median: ${f(s.med)}|Q1: ${f(s.q1)}|Min: ${f(s.lo)}${s.outliers.length ? `|Outliers: ${s.outliers.length}` : ''}`}> <rect x={cx - band / 2} y={m.t} width={band} height={H - m.t - m.b} fill="transparent" /> <line x1={cx} x2={cx} y1={y(s.hi)} y2={y(s.q3)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" /> <line x1={cx} x2={cx} y1={y(s.q1)} y2={y(s.lo)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" /> <line x1={cx - bw / 4} x2={cx + bw / 4} y1={y(s.hi)} y2={y(s.hi)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" /> <line x1={cx - bw / 4} x2={cx + bw / 4} y1={y(s.lo)} y2={y(s.lo)} stroke="var(--c-text-2, #a3a3a3)" stroke-width="1.5" /> <rect x={cx - bw / 2} y={y(s.q3)} width={bw} height={Math.max(1, y(s.q1) - y(s.q3))} rx="4" fill={color} fill-opacity="0.3" stroke={color} stroke-width="1.5" /> <line x1={cx - bw / 2} x2={cx + bw / 2} y1={y(s.med)} y2={y(s.med)} stroke="var(--c-text-1, #fff)" stroke-width="2.5" /> {s.outliers.map((o) => <circle cx={cx} cy={y(o)} r="3.5" fill="none" stroke={color} stroke-width="1.5" />)} <text class="cf-label" x={cx} y={H - 10} text-anchor="middle">{s.label}</text> </g> ); })} </svg> </ChartFrame>

Quick Info

Category
Charts & Stats
Filename
BoxPlotChart.astro
Dependencies
apexcharts (peer)
Tags
chartsvgzero-dependencybox-plotdistributionstatisticsquartiles