Box plot

latency distribution

Latency distribution. The chart that shows the tail a mean hides.

Box plot — latency distribution: rendered example
Rendered by Gnomon from the source below. No edits.

The source

112 lines of D3 JavaScript, and uses only the bundled D3 build. Copy it, or open the template inside Gnomon and render it as it is.

// Box plot — median, quartiles, 1.5×IQR whiskers, outliers plotted.
// Compact enough to line up a dozen services side by side, which is what a
// mean-and-error-bar chart cannot honestly do.

const rng = d3.randomLcg(31);
const normal = d3.randomNormal.source(rng);
const services = [
  { name: 'gateway',   mu: 45,  sigma: 12 },
  { name: 'orders',    mu: 120, sigma: 40 },
  { name: 'catalogue', mu: 80,  sigma: 25 },
  { name: 'payments',  mu: 210, sigma: 90 },
  { name: 'identity',  mu: 60,  sigma: 18 },
  { name: 'search',    mu: 150, sigma: 55 },
];

const groups = services.map(s => {
  const draw = normal(s.mu, s.sigma);
  const values = d3.range(60).map(() => Math.max(5, draw())).sort(d3.ascending);
  const q1 = d3.quantile(values, 0.25);
  const median = d3.quantile(values, 0.5);
  const q3 = d3.quantile(values, 0.75);
  const iqr = q3 - q1;
  const lo = Math.max(values[0], q1 - 1.5 * iqr);
  const hi = Math.min(values[values.length - 1], q3 + 1.5 * iqr);
  return {
    name: s.name, q1, median, q3, lo, hi,
    outliers: values.filter(v => v < lo || v > hi),
  };
});

const margin = { top: 44, right: 30, bottom: 44, left: 60 };
const x = d3.scaleBand()
    .domain(groups.map(g => g.name))
    .range([margin.left, width - margin.right])
    .padding(0.4);
const y = d3.scaleLinear()
    .domain([0, d3.max(groups, g => Math.max(g.hi, d3.max(g.outliers) ?? 0))]).nice()
    .range([height - margin.bottom, margin.top]);
const color = d3.scaleOrdinal(groups.map(g => g.name), theme.palette);

const svg = d3.select(container).append('svg')
    .attr('viewBox', [0, 0, width, height])
    .attr('width', width)
    .attr('height', height)
    .attr('font-family', 'system-ui, sans-serif')
    .attr('font-size', 11);

svg.append('g')
    .attr('transform', `translate(${margin.left},0)`)
    .call(d3.axisLeft(y).ticks(6))
    .call(g => g.select('.domain').remove())
    .call(g => g.selectAll('text').attr('fill', theme.muted))
    .call(g => g.selectAll('line')
        .attr('stroke', theme.muted).attr('stroke-opacity', 0.25)
        .attr('x2', width - margin.left - margin.right));

svg.append('g')
    .attr('transform', `translate(0,${height - margin.bottom})`)
    .call(d3.axisBottom(x))
    .call(g => g.select('.domain').attr('stroke', theme.muted))
    .call(g => g.selectAll('text').attr('fill', theme.foreground))
    .call(g => g.selectAll('line').attr('stroke', theme.muted));

const g = svg.selectAll('g.box')
  .data(groups)
  .join('g')
    .attr('class', 'box')
    .attr('transform', d => `translate(${x(d.name) + x.bandwidth() / 2},0)`);

g.append('line')
    .attr('y1', d => y(d.lo)).attr('y2', d => y(d.hi))
    .attr('stroke', theme.muted).attr('stroke-width', 1.5);

for (const key of ['lo', 'hi']) {
  g.append('line')
      .attr('x1', -x.bandwidth() / 4).attr('x2', x.bandwidth() / 4)
      .attr('y1', d => y(d[key])).attr('y2', d => y(d[key]))
      .attr('stroke', theme.muted).attr('stroke-width', 1.5);
}

g.append('rect')
    .attr('x', -x.bandwidth() / 2)
    .attr('width', x.bandwidth())
    .attr('y', d => y(d.q3))
    .attr('height', d => Math.max(1, y(d.q1) - y(d.q3)))
    .attr('rx', 2)
    .attr('fill', d => color(d.name))
    .attr('fill-opacity', 0.55)
    .attr('stroke', d => color(d.name));

g.append('line')
    .attr('x1', -x.bandwidth() / 2).attr('x2', x.bandwidth() / 2)
    .attr('y1', d => y(d.median)).attr('y2', d => y(d.median))
    .attr('stroke', theme.mode === 'dark' ? '#ffffff' : '#1a1a1a')
    .attr('stroke-width', 2);

g.each(function (d) {
  d3.select(this).selectAll('circle')
    .data(d.outliers)
    .join('circle')
      .attr('cy', v => y(v))
      .attr('r', 2.5)
      .attr('fill', theme.palette[2]);
});

svg.append('text')
    .attr('x', margin.left)
    .attr('y', 22)
    .attr('fill', theme.foreground)
    .attr('font-size', 13)
    .attr('font-weight', 600)
    .text('Request latency by service (ms)');

Render this offline

This template ships in Gnomon and renders on your machine, with no account and nothing sent to a server. The browser editor is free and needs no install.

Get GnomonOpen the browser editor

Others in D3 visualisations