{"spec_id":"scatter-regression-lowess","library":"echarts","language":"javascript","code":"// anyplot.ai\n// scatter-regression-lowess: Scatter Plot with LOWESS Regression\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Enzyme reaction rate vs. temperature: rate climbs as the enzyme warms toward\n// its optimum, then collapses past ~38 C as the protein denatures — a\n// non-monotonic curve no single polynomial captures cleanly, which is exactly\n// what LOWESS is good at tracing.\nlet seed = 20260909;\nfunction rand() {\n  // Small fixed-seed LCG — Math.random() is not reproducible across runs.\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\nfunction randNormal() {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst n = 160;\nconst optimum = 38;\nconst width = 7;\nconst peak = 95;\nconst temperatures = [];\nconst reactionRates = [];\nfor (let i = 0; i < n; i++) {\n  const temp = 5 + rand() * 45;\n  const gaussian = peak * Math.exp(-((temp - optimum) ** 2) / (2 * width * width));\n  const denatureDrop = temp > optimum ? (temp - optimum) * 1.4 : 0;\n  const rate = Math.max(2, gaussian - denatureDrop + randNormal() * 6);\n  temperatures.push(temp);\n  reactionRates.push(rate);\n}\n\n// --- LOWESS (locally weighted linear regression, single pass) --------------\nconst order = temperatures\n  .map((temp, i) => i)\n  .sort((a, b) => temperatures[a] - temperatures[b]);\nconst xSorted = order.map((i) => temperatures[i]);\nconst ySorted = order.map((i) => reactionRates[i]);\n\nconst frac = 0.35;\nconst windowSize = Math.max(4, Math.round(frac * n));\n\nfunction tricube(u) {\n  return u < 1 ? (1 - u ** 3) ** 3 : 0;\n}\n\n// Tricube weights for the local window around xSorted[i], reused for both the\n// regression fit and the local-variance confidence band below.\nfunction weightsAt(i) {\n  const x0 = xSorted[i];\n  const distances = xSorted.map((xj) => Math.abs(xj - x0));\n  const bandwidth = [...distances].sort((a, b) => a - b)[windowSize - 1] || 1e-6;\n  return distances.map((d) => tricube(d / bandwidth));\n}\n\nconst lowessCurve = xSorted.map((x0, i) => {\n  const w = weightsAt(i);\n  let s0 = 0, s1 = 0, s2 = 0, sy = 0, sxy = 0;\n  for (let j = 0; j < n; j++) {\n    if (w[j] <= 0) continue;\n    const xj = xSorted[j], yj = ySorted[j];\n    s0 += w[j];\n    s1 += w[j] * xj;\n    s2 += w[j] * xj * xj;\n    sy += w[j] * yj;\n    sxy += w[j] * xj * yj;\n  }\n  const denom = s0 * s2 - s1 * s1;\n  const slope = denom !== 0 ? (s0 * sxy - s1 * sy) / denom : 0;\n  const intercept = (sy - slope * s1) / s0;\n  return [x0, intercept + slope * x0];\n});\n\n// Local confidence band: weighted RMS of the fit residuals within the same\n// window used for the regression, clamped at 0 (reaction rate can't go negative).\nconst residuals = ySorted.map((y, i) => y - lowessCurve[i][1]);\nconst localStd = xSorted.map((_, i) => {\n  const w = weightsAt(i);\n  let sw = 0, swr2 = 0;\n  for (let j = 0; j < n; j++) {\n    if (w[j] <= 0) continue;\n    sw += w[j];\n    swr2 += w[j] * residuals[j] * residuals[j];\n  }\n  return Math.sqrt(swr2 / sw);\n});\nconst bandLower = lowessCurve.map((p, i) => Math.max(0, p[1] - localStd[i]));\nconst bandUpper = lowessCurve.map((p, i) => p[1] + localStd[i]);\n\nlet peakIdx = 0;\nfor (let i = 1; i < lowessCurve.length; i++) {\n  if (lowessCurve[i][1] > lowessCurve[peakIdx][1]) peakIdx = i;\n}\nconst peakTemp = lowessCurve[peakIdx][0];\nconst peakRate = lowessCurve[peakIdx][1];\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------\nconst title = \"scatter-regression-lowess · javascript · echarts · anyplot.ai\";\nconst titleFontSize = title.length > 67 ? Math.max(16, Math.round(22 * (67 / title.length))) : 22;\n\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: title,\n    left: \"center\",\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 500 },\n  },\n  grid: { left: 90, right: 60, top: 100, bottom: 90 },\n  legend: {\n    data: [\"Reaction rate\", \"LOWESS fit\", \"Confidence band\"],\n    top: 50,\n    textStyle: { color: t.inkSoft, fontSize: 16 },\n  },\n  tooltip: {\n    trigger: \"item\",\n    backgroundColor: t.elevatedBg,\n    borderColor: t.grid,\n    textStyle: { color: t.ink },\n    formatter: (params) => {\n      if (params.seriesName === \"Reaction rate\" || params.seriesName === \"LOWESS fit\") {\n        const label = params.seriesName === \"LOWESS fit\" ? \"LOWESS fit<br/>\" : \"\";\n        return `${label}Temp: ${params.value[0].toFixed(1)}°C<br/>Rate: ${params.value[1].toFixed(1)} µmol/min`;\n      }\n      return \"\";\n    },\n  },\n  xAxis: {\n    type: \"value\",\n    name: \"Temperature (°C)\",\n    nameLocation: \"middle\",\n    nameGap: 40,\n    nameTextStyle: { color: t.ink, fontSize: 18 },\n    min: 0,\n    max: 55,\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { lineStyle: { color: t.inkSoft } },\n    splitLine: { lineStyle: { color: t.grid, type: \"dashed\" } },\n  },\n  yAxis: {\n    type: \"value\",\n    name: \"Reaction Rate (µmol/min)\",\n    nameLocation: \"middle\",\n    nameGap: 60,\n    nameTextStyle: { color: t.ink, fontSize: 18 },\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { lineStyle: { color: t.inkSoft } },\n    splitLine: { lineStyle: { color: t.grid, type: \"dashed\" } },\n  },\n  series: [\n    {\n      // Invisible stacking base for the confidence band below; kept out of\n      // the legend and out of interaction.\n      type: \"line\",\n      data: xSorted.map((x, i) => [x, bandLower[i]]),\n      stack: \"confidence\",\n      symbol: \"none\",\n      lineStyle: { opacity: 0 },\n      areaStyle: { opacity: 0 },\n      silent: true,\n      tooltip: { show: false },\n      z: 1,\n    },\n    {\n      name: \"Confidence band\",\n      type: \"line\",\n      data: xSorted.map((x, i) => [x, bandUpper[i] - bandLower[i]]),\n      stack: \"confidence\",\n      symbol: \"none\",\n      lineStyle: { opacity: 0 },\n      areaStyle: { color: t.palette[2], opacity: 0.15 },\n      itemStyle: { color: t.palette[2] },\n      silent: true,\n      tooltip: { show: false },\n      z: 1,\n    },\n    {\n      name: \"Reaction rate\",\n      type: \"scatter\",\n      data: temperatures.map((temp, i) => [temp, reactionRates[i]]),\n      symbolSize: 16,\n      itemStyle: { color: t.palette[0], opacity: 0.6 },\n      z: 2,\n    },\n    {\n      name: \"LOWESS fit\",\n      type: \"line\",\n      data: lowessCurve,\n      showSymbol: false,\n      smooth: false,\n      lineStyle: {\n        color: t.palette[2],\n        width: 5,\n        shadowColor: t.palette[2],\n        shadowBlur: 10,\n      },\n      z: 10,\n      markLine: {\n        silent: true,\n        symbol: \"none\",\n        lineStyle: { color: t.inkSoft, type: \"dashed\", width: 1.5 },\n        label: {\n          color: t.inkSoft,\n          fontSize: 13,\n          formatter: () => `Optimum ≈ ${peakTemp.toFixed(0)}°C`,\n          position: \"insideEndTop\",\n        },\n        data: [{ xAxis: peakTemp }],\n      },\n      markPoint: {\n        symbol: \"circle\",\n        symbolSize: 14,\n        itemStyle: { color: t.palette[2], borderColor: t.pageBg, borderWidth: 2 },\n        label: { show: false },\n        data: [{ coord: [peakTemp, peakRate] }],\n      },\n    },\n  ],\n});\n"}