Beer-Lambert Calibration Curve — D3.js

A calibration curve plotting absorbance versus concentration following Beer-Lambert law (A = εlc). Measured calibration standards are shown as scatter points with a linear regression fit line. The regression equation (y = mx + b) and R² value are displayed on the plot. An example unknown sample is marked with dashed lines extending to both axes, demonstrating how the curve is used to determine concentration from a measured absorbance. This plot is fundamental in analytical chemistry for quantitative spectrophotometric analysis.

Beer-Lambert Calibration Curve rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// calibration-beer-lambert: Beer-Lambert Calibration Curve
// Library: d3 7.9.0 | JavaScript 22.22.3
// Quality: 89/100 | Created: 2026-06-03

const t = window.ANYPLOT_TOKENS;
const theme = window.ANYPLOT_THEME;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 80, right: 80, bottom: 90, left: 100 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Data: UV-Vis spectrophotometry calibration standards (blank + 7 levels) ---
const calibData = [
  { conc: 0,  abs: 0.002 },
  { conc: 2,  abs: 0.093 },
  { conc: 4,  abs: 0.181 },
  { conc: 6,  abs: 0.278 },
  { conc: 8,  abs: 0.358 },
  { conc: 10, abs: 0.456 },
  { conc: 12, abs: 0.541 },
  { conc: 15, abs: 0.681 },
];
const unknownAbs = 0.320;

// --- Linear regression (ordinary least squares) ---
const n     = calibData.length;
const sumX  = calibData.reduce((s, d) => s + d.conc, 0);
const sumY  = calibData.reduce((s, d) => s + d.abs,  0);
const sumXY = calibData.reduce((s, d) => s + d.conc * d.abs, 0);
const sumX2 = calibData.reduce((s, d) => s + d.conc * d.conc, 0);
const xMean = sumX / n;
const yMean = sumY / n;
const Sxx       = sumX2 - (sumX * sumX) / n;
const slope     = (sumXY - (sumX * sumY) / n) / Sxx;
const intercept = yMean - slope * xMean;

const ssTot = calibData.reduce((s, d) => s + (d.abs - yMean) ** 2, 0);
const ssRes = calibData.reduce((s, d) => s + (d.abs - (slope * d.conc + intercept)) ** 2, 0);
const r2    = 1 - ssRes / ssTot;

// 95% prediction interval — t-critical for df = n − 2 = 6 is 2.447
const se    = Math.sqrt(ssRes / (n - 2));
const tCrit = 2.447;
const unknownConc = (unknownAbs - intercept) / slope;

// Band opacity adapts to theme — dark background needs stronger fill for visibility
const bandOpacity = theme === "dark" ? 0.25 : 0.15;

// --- Scales ---
const xScale = d3.scaleLinear().domain([-0.5, 16.5]).range([0, iw]);
const yScale = d3.scaleLinear().domain([-0.03, 0.76]).range([ih, 0]);

// --- Prediction band data (sampled at fine intervals) ---
const bandPts = d3.range(-0.4, 16.15, 0.1).map(x => {
  const yHat = slope * x + intercept;
  const hw   = tCrit * se * Math.sqrt(1 + 1 / n + (x - xMean) ** 2 / Sxx);
  return { x, lo: yHat - hw, hi: yHat + hw };
});

// --- SVG ---
const svg = d3.select("#container")
  .append("svg")
  .attr("width", width)
  .attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Y-axis gridlines (behind everything) — D3 data join ---
g.selectAll(".ygrid")
  .data([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7])
  .join("line")
  .attr("class", "ygrid")
  .attr("x1", 0).attr("x2", iw)
  .attr("y1", v => yScale(v)).attr("y2", v => yScale(v))
  .attr("stroke", t.grid).attr("stroke-width", 1);

// --- Prediction interval band ---
g.append("path")
  .datum(bandPts)
  .attr("d", d3.area()
    .x(d => xScale(d.x))
    .y0(d => yScale(d.lo))
    .y1(d => yScale(d.hi)))
  .attr("fill", t.palette[2])
  .attr("opacity", bandOpacity);

// --- Regression line (d3.line path generator — more idiomatic than SVG <line>) ---
g.append("path")
  .datum([[-0.4, slope * -0.4 + intercept], [16.0, slope * 16.0 + intercept]])
  .attr("d", d3.line().x(d => xScale(d[0])).y(d => yScale(d[1])))
  .attr("stroke", t.palette[2])
  .attr("stroke-width", 2.5)
  .attr("fill", "none");

// --- Unknown sample dashed guide lines ---
// Horizontal: y-axis edge → unknown point (shows measured absorbance)
g.append("line")
  .attr("x1", 0).attr("y1", yScale(unknownAbs))
  .attr("x2", xScale(unknownConc)).attr("y2", yScale(unknownAbs))
  .attr("stroke", t.palette[4]).attr("stroke-width", 1.5)
  .attr("stroke-dasharray", "8,5");

// Vertical: unknown point → x-axis (shows determined concentration)
g.append("line")
  .attr("x1", xScale(unknownConc)).attr("y1", yScale(unknownAbs))
  .attr("x2", xScale(unknownConc)).attr("y2", ih)
  .attr("stroke", t.palette[4]).attr("stroke-width", 1.5)
  .attr("stroke-dasharray", "8,5");

// --- Calibration data points ---
g.selectAll(".std")
  .data(calibData)
  .join("circle")
  .attr("class", "std")
  .attr("cx", d => xScale(d.conc))
  .attr("cy", d => yScale(d.abs))
  .attr("r", 8)
  .attr("fill", t.palette[0])
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 2);

// --- Unknown sample point ---
g.append("circle")
  .attr("cx", xScale(unknownConc))
  .attr("cy", yScale(unknownAbs))
  .attr("r", 8)
  .attr("fill", t.palette[4])
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 2);

// --- Axes ---
const xAx = g.append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(d3.axisBottom(xScale).tickValues([0, 2, 4, 6, 8, 10, 12, 14]));
const yAx = g.append("g")
  .call(d3.axisLeft(yScale).tickValues([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]));

for (const ax of [xAx, yAx]) {
  ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
  ax.selectAll(".tick line").attr("stroke", t.inkSoft);
  ax.select(".domain").attr("stroke", t.inkSoft);
}

// --- Axis labels ---
g.append("text")
  .attr("x", iw / 2).attr("y", ih + 65)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Concentration (mg/L)");

g.append("text")
  .attr("transform", "rotate(-90)")
  .attr("x", -(ih / 2)).attr("y", -75)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Absorbance");

// --- Equation annotation box (upper-left, above data region) ---
const eqSign = intercept >= 0 ? "+" : "−";
const eqAbsInt = Math.abs(intercept).toFixed(4);
const ax0 = 18, ay0 = 18;
g.append("rect")
  .attr("x", ax0).attr("y", ay0)
  .attr("width", 222).attr("height", 62)
  .attr("fill", t.elevatedBg).attr("rx", 4).attr("opacity", 0.92);
g.append("text")
  .attr("x", ax0 + 10).attr("y", ay0 + 23)
  .attr("fill", t.ink).style("font-size", "14px")
  .text(`y = ${slope.toFixed(4)}x ${eqSign} ${eqAbsInt}`);
g.append("text")
  .attr("x", ax0 + 10).attr("y", ay0 + 46)
  .attr("fill", t.ink).style("font-size", "14px")
  .text(`R² = ${r2.toFixed(4)}`);

// --- Legend (lower-right, clear of data) ---
const lx = iw - 230, ly = ih - 118;
const legendItems = [
  { type: "dot",  color: t.palette[0], label: "Calibration standards" },
  { type: "line", color: t.palette[2], label: "Linear fit" },
  { type: "band", color: t.palette[2], label: "95% prediction interval" },
  { type: "dot",  color: t.palette[4], label: "Unknown sample" },
];

// Subtle legend background for visual polish
g.append("rect")
  .attr("x", lx - 10).attr("y", ly - 10)
  .attr("width", 244).attr("height", legendItems.length * 26 + 18)
  .attr("fill", t.elevatedBg).attr("rx", 4).attr("opacity", 0.85);

legendItems.forEach((item, i) => {
  const iy = ly + i * 26;
  if (item.type === "dot") {
    g.append("circle")
      .attr("cx", lx + 10).attr("cy", iy + 6).attr("r", 6)
      .attr("fill", item.color).attr("stroke", t.pageBg).attr("stroke-width", 2);
  } else if (item.type === "line") {
    g.append("line")
      .attr("x1", lx).attr("y1", iy + 6)
      .attr("x2", lx + 20).attr("y2", iy + 6)
      .attr("stroke", item.color).attr("stroke-width", 2.5);
  } else {
    // Band swatch: elevated opacity + outline stroke for dark-theme readability
    g.append("rect")
      .attr("x", lx).attr("y", iy)
      .attr("width", 20).attr("height", 12)
      .attr("fill", item.color).attr("opacity", 0.5);
    g.append("rect")
      .attr("x", lx).attr("y", iy)
      .attr("width", 20).attr("height", 12)
      .attr("fill", "none")
      .attr("stroke", item.color).attr("stroke-width", 1).attr("opacity", 0.85);
  }
  g.append("text")
    .attr("x", lx + 26).attr("y", iy + 11)
    .attr("fill", t.inkSoft).style("font-size", "13px")
    .text(item.label);
});

// --- Title ---
svg.append("text")
  .attr("x", width / 2).attr("y", 48)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "22px").style("font-weight", "600")
  .text("calibration-beer-lambert · javascript · d3 · anyplot.ai");

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/calibration-beer-lambert/d3/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "calibration-beer-lambert",
  "language": "javascript",
  "library": "d3",
  "page": "https://anyplot.ai/calibration-beer-lambert/javascript/d3",
  "hub": "https://anyplot.ai/calibration-beer-lambert",
  "code_json": "https://api.anyplot.ai/specs/calibration-beer-lambert/d3/code",
  "spec_json": "https://api.anyplot.ai/specs/calibration-beer-lambert",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/calibration-beer-lambert/javascript/d3/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/calibration-beer-lambert/javascript/d3/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/calibration-beer-lambert/javascript/d3/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/calibration-beer-lambert/javascript/d3/plot-dark.html",
  "quality_score": 89.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Beer-Lambert Calibration Curve on anyplot.ai.

Other implementations