{"spec_id":"calibration-beer-lambert","library":"d3","language":"javascript","code":"// anyplot.ai\n// calibration-beer-lambert: Beer-Lambert Calibration Curve\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 89/100 | Created: 2026-06-03\n\nconst t = window.ANYPLOT_TOKENS;\nconst theme = window.ANYPLOT_THEME;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 80, right: 80, bottom: 90, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: UV-Vis spectrophotometry calibration standards (blank + 7 levels) ---\nconst calibData = [\n  { conc: 0,  abs: 0.002 },\n  { conc: 2,  abs: 0.093 },\n  { conc: 4,  abs: 0.181 },\n  { conc: 6,  abs: 0.278 },\n  { conc: 8,  abs: 0.358 },\n  { conc: 10, abs: 0.456 },\n  { conc: 12, abs: 0.541 },\n  { conc: 15, abs: 0.681 },\n];\nconst unknownAbs = 0.320;\n\n// --- Linear regression (ordinary least squares) ---\nconst n     = calibData.length;\nconst sumX  = calibData.reduce((s, d) => s + d.conc, 0);\nconst sumY  = calibData.reduce((s, d) => s + d.abs,  0);\nconst sumXY = calibData.reduce((s, d) => s + d.conc * d.abs, 0);\nconst sumX2 = calibData.reduce((s, d) => s + d.conc * d.conc, 0);\nconst xMean = sumX / n;\nconst yMean = sumY / n;\nconst Sxx       = sumX2 - (sumX * sumX) / n;\nconst slope     = (sumXY - (sumX * sumY) / n) / Sxx;\nconst intercept = yMean - slope * xMean;\n\nconst ssTot = calibData.reduce((s, d) => s + (d.abs - yMean) ** 2, 0);\nconst ssRes = calibData.reduce((s, d) => s + (d.abs - (slope * d.conc + intercept)) ** 2, 0);\nconst r2    = 1 - ssRes / ssTot;\n\n// 95% prediction interval — t-critical for df = n − 2 = 6 is 2.447\nconst se    = Math.sqrt(ssRes / (n - 2));\nconst tCrit = 2.447;\nconst unknownConc = (unknownAbs - intercept) / slope;\n\n// Band opacity adapts to theme — dark background needs stronger fill for visibility\nconst bandOpacity = theme === \"dark\" ? 0.25 : 0.15;\n\n// --- Scales ---\nconst xScale = d3.scaleLinear().domain([-0.5, 16.5]).range([0, iw]);\nconst yScale = d3.scaleLinear().domain([-0.03, 0.76]).range([ih, 0]);\n\n// --- Prediction band data (sampled at fine intervals) ---\nconst bandPts = d3.range(-0.4, 16.15, 0.1).map(x => {\n  const yHat = slope * x + intercept;\n  const hw   = tCrit * se * Math.sqrt(1 + 1 / n + (x - xMean) ** 2 / Sxx);\n  return { x, lo: yHat - hw, hi: yHat + hw };\n});\n\n// --- SVG ---\nconst svg = d3.select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Y-axis gridlines (behind everything) — D3 data join ---\ng.selectAll(\".ygrid\")\n  .data([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7])\n  .join(\"line\")\n  .attr(\"class\", \"ygrid\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", v => yScale(v)).attr(\"y2\", v => yScale(v))\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\n// --- Prediction interval band ---\ng.append(\"path\")\n  .datum(bandPts)\n  .attr(\"d\", d3.area()\n    .x(d => xScale(d.x))\n    .y0(d => yScale(d.lo))\n    .y1(d => yScale(d.hi)))\n  .attr(\"fill\", t.palette[2])\n  .attr(\"opacity\", bandOpacity);\n\n// --- Regression line (d3.line path generator — more idiomatic than SVG <line>) ---\ng.append(\"path\")\n  .datum([[-0.4, slope * -0.4 + intercept], [16.0, slope * 16.0 + intercept]])\n  .attr(\"d\", d3.line().x(d => xScale(d[0])).y(d => yScale(d[1])))\n  .attr(\"stroke\", t.palette[2])\n  .attr(\"stroke-width\", 2.5)\n  .attr(\"fill\", \"none\");\n\n// --- Unknown sample dashed guide lines ---\n// Horizontal: y-axis edge → unknown point (shows measured absorbance)\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"y1\", yScale(unknownAbs))\n  .attr(\"x2\", xScale(unknownConc)).attr(\"y2\", yScale(unknownAbs))\n  .attr(\"stroke\", t.palette[4]).attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"8,5\");\n\n// Vertical: unknown point → x-axis (shows determined concentration)\ng.append(\"line\")\n  .attr(\"x1\", xScale(unknownConc)).attr(\"y1\", yScale(unknownAbs))\n  .attr(\"x2\", xScale(unknownConc)).attr(\"y2\", ih)\n  .attr(\"stroke\", t.palette[4]).attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"8,5\");\n\n// --- Calibration data points ---\ng.selectAll(\".std\")\n  .data(calibData)\n  .join(\"circle\")\n  .attr(\"class\", \"std\")\n  .attr(\"cx\", d => xScale(d.conc))\n  .attr(\"cy\", d => yScale(d.abs))\n  .attr(\"r\", 8)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\n// --- Unknown sample point ---\ng.append(\"circle\")\n  .attr(\"cx\", xScale(unknownConc))\n  .attr(\"cy\", yScale(unknownAbs))\n  .attr(\"r\", 8)\n  .attr(\"fill\", t.palette[4])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\n// --- Axes ---\nconst xAx = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(xScale).tickValues([0, 2, 4, 6, 8, 10, 12, 14]));\nconst yAx = g.append(\"g\")\n  .call(d3.axisLeft(yScale).tickValues([0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]));\n\nfor (const ax of [xAx, yAx]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\".tick line\").attr(\"stroke\", t.inkSoft);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Axis labels ---\ng.append(\"text\")\n  .attr(\"x\", iw / 2).attr(\"y\", ih + 65)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Concentration (mg/L)\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(ih / 2)).attr(\"y\", -75)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Absorbance\");\n\n// --- Equation annotation box (upper-left, above data region) ---\nconst eqSign = intercept >= 0 ? \"+\" : \"−\";\nconst eqAbsInt = Math.abs(intercept).toFixed(4);\nconst ax0 = 18, ay0 = 18;\ng.append(\"rect\")\n  .attr(\"x\", ax0).attr(\"y\", ay0)\n  .attr(\"width\", 222).attr(\"height\", 62)\n  .attr(\"fill\", t.elevatedBg).attr(\"rx\", 4).attr(\"opacity\", 0.92);\ng.append(\"text\")\n  .attr(\"x\", ax0 + 10).attr(\"y\", ay0 + 23)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"14px\")\n  .text(`y = ${slope.toFixed(4)}x ${eqSign} ${eqAbsInt}`);\ng.append(\"text\")\n  .attr(\"x\", ax0 + 10).attr(\"y\", ay0 + 46)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"14px\")\n  .text(`R² = ${r2.toFixed(4)}`);\n\n// --- Legend (lower-right, clear of data) ---\nconst lx = iw - 230, ly = ih - 118;\nconst legendItems = [\n  { type: \"dot\",  color: t.palette[0], label: \"Calibration standards\" },\n  { type: \"line\", color: t.palette[2], label: \"Linear fit\" },\n  { type: \"band\", color: t.palette[2], label: \"95% prediction interval\" },\n  { type: \"dot\",  color: t.palette[4], label: \"Unknown sample\" },\n];\n\n// Subtle legend background for visual polish\ng.append(\"rect\")\n  .attr(\"x\", lx - 10).attr(\"y\", ly - 10)\n  .attr(\"width\", 244).attr(\"height\", legendItems.length * 26 + 18)\n  .attr(\"fill\", t.elevatedBg).attr(\"rx\", 4).attr(\"opacity\", 0.85);\n\nlegendItems.forEach((item, i) => {\n  const iy = ly + i * 26;\n  if (item.type === \"dot\") {\n    g.append(\"circle\")\n      .attr(\"cx\", lx + 10).attr(\"cy\", iy + 6).attr(\"r\", 6)\n      .attr(\"fill\", item.color).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 2);\n  } else if (item.type === \"line\") {\n    g.append(\"line\")\n      .attr(\"x1\", lx).attr(\"y1\", iy + 6)\n      .attr(\"x2\", lx + 20).attr(\"y2\", iy + 6)\n      .attr(\"stroke\", item.color).attr(\"stroke-width\", 2.5);\n  } else {\n    // Band swatch: elevated opacity + outline stroke for dark-theme readability\n    g.append(\"rect\")\n      .attr(\"x\", lx).attr(\"y\", iy)\n      .attr(\"width\", 20).attr(\"height\", 12)\n      .attr(\"fill\", item.color).attr(\"opacity\", 0.5);\n    g.append(\"rect\")\n      .attr(\"x\", lx).attr(\"y\", iy)\n      .attr(\"width\", 20).attr(\"height\", 12)\n      .attr(\"fill\", \"none\")\n      .attr(\"stroke\", item.color).attr(\"stroke-width\", 1).attr(\"opacity\", 0.85);\n  }\n  g.append(\"text\")\n    .attr(\"x\", lx + 26).attr(\"y\", iy + 11)\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\")\n    .text(item.label);\n});\n\n// --- Title ---\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 48)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\").style(\"font-weight\", \"600\")\n  .text(\"calibration-beer-lambert · javascript · d3 · anyplot.ai\");\n"}