{"spec_id":"scatter-regression-lowess","library":"d3","language":"javascript","code":"// anyplot.ai\n// scatter-regression-lowess: Scatter Plot with LOWESS Regression\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 85/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 130, right: 50, bottom: 90, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: nitrogen fertilization rate vs. grain yield ----------------------\n// Deterministic LCG so the \"random\" noise is reproducible across runs.\nfunction makeRng(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (Math.imul(1664525, state) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = makeRng(42);\nfunction randNormal() {\n  const u1 = Math.max(rng(), 1e-9);\n  const u2 = rng();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// True agronomic response: yield rises with applied nitrogen, then plateaus\n// and slightly declines past the optimal rate (over-fertilization penalty).\nfunction trueYield(nitrogenRate) {\n  return (\n    2.0 +\n    6.5 * (1 - Math.exp(-nitrogenRate / 70)) -\n    0.00004 * nitrogenRate * nitrogenRate\n  );\n}\n\nconst pointCount = 140;\nconst nitrogenRate = Array.from({ length: pointCount }, () => rng() * 280);\nconst grainYield = nitrogenRate.map(\n  (rate) => Math.max(0.2, trueYield(rate) + randNormal() * 0.65),\n);\n\n// --- LOWESS: tricube-weighted local linear regression ------------------------\nfunction lowess(xs, ys, frac) {\n  const n = xs.length;\n  const k = Math.max(2, Math.round(frac * n));\n  return xs.map((xi) => {\n    const dists = xs.map((x) => Math.abs(x - xi));\n    const bandwidth = [...dists].sort((a, b) => a - b)[k - 1] || 1e-9;\n    const weights = dists.map((d) => {\n      const u = Math.min(d / bandwidth, 1);\n      return Math.pow(1 - Math.pow(u, 3), 3);\n    });\n\n    let sw = 0, swx = 0, swy = 0, swxy = 0, swxx = 0;\n    for (let j = 0; j < n; j++) {\n      const w = weights[j];\n      sw += w;\n      swx += w * xs[j];\n      swy += w * ys[j];\n      swxy += w * xs[j] * ys[j];\n      swxx += w * xs[j] * xs[j];\n    }\n    const denom = sw * swxx - swx * swx;\n    const slope = Math.abs(denom) < 1e-9 ? 0 : (sw * swxy - swx * swy) / denom;\n    const intercept = (swy - slope * swx) / sw;\n    return intercept + slope * xi;\n  });\n}\n\nconst smoothingFraction = 0.35;\nconst lowessFit = lowess(nitrogenRate, grainYield, smoothingFraction);\nconst fitOrder = d3.range(pointCount).sort((a, b) => nitrogenRate[a] - nitrogenRate[b]);\nconst fitCurve = fitOrder.map((i) => ({ x: nitrogenRate[i], y: lowessFit[i] }));\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales ---------------------------------------------------------------\nconst x = d3.scaleLinear().domain([0, 280]).nice().range([0, iw]);\nconst y = d3.scaleLinear().domain([0, d3.max(grainYield) * 1.08]).nice().range([ih, 0]);\n\n// --- Gridlines (y-axis only, for a subtler chrome) ---------------------------\ng.append(\"g\")\n  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(\"\"))\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\ng.selectAll(\".domain\").remove();\n\n// --- Axes -------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x).ticks(7));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6));\nfor (const axis of [xAxis, yAxis]) {\n  axis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  axis.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  axis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Axis labels --------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2).attr(\"y\", ih + 60)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"18px\")\n  .text(\"Nitrogen Applied (kg/ha)\");\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2).attr(\"y\", -70)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"18px\")\n  .text(\"Grain Yield (t/ha)\");\n\n// --- Scatter points -----------------------------------------------------------\ng.selectAll(\"circle\")\n  .data(d3.range(pointCount))\n  .join(\"circle\")\n  .attr(\"cx\", (i) => x(nitrogenRate[i]))\n  .attr(\"cy\", (i) => y(grainYield[i]))\n  .attr(\"r\", 6)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.55)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1);\n\n// --- LOWESS curve ---------------------------------------------------------\nconst line = d3.line().x((d) => x(d.x)).y((d) => y(d.y)).curve(d3.curveMonotoneX);\ng.append(\"path\")\n  .datum(fitCurve)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 4)\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"d\", line);\n\n// --- Optimal-rate annotation (data storytelling focal point) ----------------\nconst peak = fitCurve.reduce((best, d) => (d.y > best.y ? d : best), fitCurve[0]);\ng.append(\"line\")\n  .attr(\"x1\", x(peak.x)).attr(\"x2\", x(peak.x))\n  .attr(\"y1\", y(peak.y)).attr(\"y2\", ih)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"4,4\");\ng.append(\"circle\")\n  .attr(\"cx\", x(peak.x)).attr(\"cy\", y(peak.y))\n  .attr(\"r\", 7)\n  .attr(\"fill\", t.pageBg)\n  .attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 2.5);\ng.append(\"text\")\n  .attr(\"x\", x(peak.x)).attr(\"y\", y(peak.y) - 18)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", \"14px\").style(\"font-weight\", \"600\")\n  .text(`Optimal ≈ ${Math.round(peak.x)} kg/ha`);\n\n// --- Legend -----------------------------------------------------------------\nconst legend = g.append(\"g\").attr(\"transform\", `translate(${iw - 330},0)`);\nlegend.append(\"circle\").attr(\"cx\", 8).attr(\"cy\", 0).attr(\"r\", 6)\n  .attr(\"fill\", t.palette[0]).attr(\"fill-opacity\", 0.55).attr(\"stroke\", t.pageBg);\nlegend.append(\"text\").attr(\"x\", 24).attr(\"y\", 5)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(\"Field observations\");\nlegend.append(\"line\").attr(\"x1\", 0).attr(\"x2\", 16).attr(\"y1\", 28).attr(\"y2\", 28)\n  .attr(\"stroke\", t.palette[1]).attr(\"stroke-width\", 4).attr(\"stroke-linecap\", \"round\");\nlegend.append(\"text\").attr(\"x\", 24).attr(\"y\", 33)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\")\n  .text(`LOWESS fit (frac = ${smoothingFraction})`);\n\n// --- Title --------------------------------------------------------------------\nconst titleText = \"Nitrogen Fertilization Response · scatter-regression-lowess · javascript · d3 · anyplot.ai\";\nconst titleRatio = titleText.length > 67 ? 67 / titleText.length : 1;\nconst titleFontSize = Math.max(14, Math.round(22 * titleRatio));\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink).style(\"font-size\", `${titleFontSize}px`).style(\"font-weight\", \"600\")\n  .text(titleText);\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 82)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\")\n  .text(\"Locally weighted regression smooths a noisy, non-monotonic yield response\");\n"}