{"spec_id":"curve-power-duration","library":"d3","language":"javascript","code":"// anyplot.ai\n// curve-power-duration: Mean-Maximal Power Duration Curve\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-13\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 100, right: 120, bottom: 90, left: 120 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: synthetic well-trained cyclist, CP = 280 W, W' = 20 000 J ---\n// Empirical MMP peaks near 1100 W at 1 s (neuromuscular ceiling); the simple\n// CP model P = CP + W'/t diverges to ~20 000 W at 1 s and is shown clipped.\nconst CP = 280;\nconst Wp = 20000;\n\nfunction logspace(a, b, n) {\n  const la = Math.log10(a), lb = Math.log10(b);\n  return Array.from({ length: n }, (_, i) => Math.pow(10, la + (lb - la) * i / (n - 1)));\n}\n\nconst durations = logspace(1, 18000, 40);\n\n// Deterministic LCG for reproducible noise\nlet lcgState = 42;\nfunction lcg() {\n  lcgState = (lcgState * 1664525 + 1013904223) & 0xffffffff;\n  return (lcgState >>> 0) / 4294967295;\n}\n\n// Physiological empirical model: neuro-muscular ceiling fading into CP model\n// - Short efforts (<~60 s): dominated by neuro peak (~1100 W at 1 s)\n// - Long efforts (>~120 s): converges to CP + W'/t\nfunction empiricalBase(dur) {\n  const cpModel = CP + Wp / dur;\n  const neuro = 1100 * Math.pow(dur, -0.08);   // power-law decay from neuromuscular peak\n  const blend = 1 / (1 + Math.exp((dur - 60) / 15));  // sigmoid: 1 at short, 0 at long\n  return neuro * blend + cpModel * (1 - blend);\n}\n\nconst rawPower = durations.map(dur => empiricalBase(dur) * (1 + (lcg() - 0.5) * 0.04));\nconst empiricalPower = rawPower.slice();\nfor (let i = 1; i < empiricalPower.length; i++) {\n  empiricalPower[i] = Math.min(empiricalPower[i], empiricalPower[i - 1]);\n}\n\n// Smooth CP model line (more points)\nconst modelDurs = logspace(1, 18000, 300);\nconst modelPow = modelDurs.map(dur => CP + Wp / dur);\n\n// --- SVG setup ---\nconst svg = d3.select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\nconst g = svg.append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales ---\nconst xScale = d3.scaleLog().domain([1, 18000]).range([0, iw]);\nconst yMax = Math.ceil(d3.max(empiricalPower) / 100) * 100 + 100;\nconst yScale = d3.scaleLinear().domain([0, yMax]).range([ih, 0]);\n\n// --- Clip path so the diverging model line stays inside the plot area ---\nsvg.append(\"defs\").append(\"clipPath\").attr(\"id\", \"plot-clip\")\n  .append(\"rect\").attr(\"width\", iw).attr(\"height\", ih);\n\n// --- Y-axis grid ---\ng.selectAll(\".grid-y\").data(yScale.ticks(6)).join(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", d => yScale(d)).attr(\"y2\", d => yScale(d))\n  .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\n// --- Reference duration markers ---\nconst refs = [\n  { dur: 5, line1: \"5 s\", line2: null },\n  { dur: 60, line1: \"1 min\", line2: null },\n  { dur: 300, line1: \"5 min\", line2: null },\n  { dur: 1200, line1: \"20 min\", line2: \"(FTP)\" },\n];\n\nrefs.forEach(ref => {\n  const rx = xScale(ref.dur);\n  g.append(\"line\")\n    .attr(\"x1\", rx).attr(\"x2\", rx)\n    .attr(\"y1\", 0).attr(\"y2\", ih)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1)\n    .attr(\"stroke-dasharray\", \"4,4\")\n    .attr(\"opacity\", 0.5);\n  g.append(\"text\")\n    .attr(\"x\", rx).attr(\"y\", 18)\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .text(ref.line1);\n  if (ref.line2) {\n    g.append(\"text\")\n      .attr(\"x\", rx).attr(\"y\", 32)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"fill\", t.inkSoft)\n      .style(\"font-size\", \"13px\")\n      .text(ref.line2);\n  }\n});\n\n// --- CP horizontal asymptote ---\ng.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", yScale(CP)).attr(\"y2\", yScale(CP))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"6,4\")\n  .attr(\"opacity\", 0.65);\ng.append(\"text\")\n  .attr(\"x\", iw + 6).attr(\"y\", yScale(CP) + 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(`CP = ${CP} W`);\n\n// --- Model fit line (dashed, Imprint palette[1]) — clipped to plot area ---\nconst modelLine = d3.line()\n  .x(d => xScale(d.dur))\n  .y(d => yScale(d.pow))\n  .curve(d3.curveCatmullRom.alpha(0.5));\n\ng.append(\"path\")\n  .datum(modelDurs.map((dur, i) => ({ dur, pow: modelPow[i] })))\n  .attr(\"clip-path\", \"url(#plot-clip)\")\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 2.5)\n  .attr(\"stroke-dasharray\", \"10,6\")\n  .attr(\"d\", modelLine);\n\n// --- Empirical MMP curve (solid, Imprint palette[0] — always first series) ---\nconst empirLine = d3.line()\n  .x((d, i) => xScale(durations[i]))\n  .y(d => yScale(d))\n  .curve(d3.curveCatmullRom.alpha(0.5));\n\ng.append(\"path\")\n  .datum(empiricalPower)\n  .attr(\"clip-path\", \"url(#plot-clip)\")\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 3.5)\n  .attr(\"d\", empirLine);\n\n// Dots on empirical curve\ng.selectAll(\".dot\").data(empiricalPower).join(\"circle\")\n  .attr(\"clip-path\", \"url(#plot-clip)\")\n  .attr(\"cx\", (d, i) => xScale(durations[i]))\n  .attr(\"cy\", d => yScale(d))\n  .attr(\"r\", 4)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.5);\n\n// --- X-axis (log scale, human-readable tick labels) ---\nconst xTickVals = [1, 5, 15, 30, 60, 300, 600, 1200, 3600, 10800, 18000];\nconst xTickFmt = s => {\n  if (s < 60) return `${s}s`;\n  if (s < 3600) return `${s / 60}min`;\n  return `${s / 3600}h`;\n};\n\nconst xAxis = g.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(xScale).tickValues(xTickVals).tickFormat(xTickFmt));\nxAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nxAxis.selectAll(\"line\").attr(\"stroke\", t.grid);\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// --- Y-axis ---\nconst yAxis = g.append(\"g\")\n  .call(d3.axisLeft(yScale).ticks(6).tickFormat(d => `${Math.round(d)} W`));\nyAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nyAxis.selectAll(\"line\").attr(\"stroke\", t.grid);\nyAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// --- Axis labels ---\nsvg.append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 20)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Duration (log scale)\");\n\nsvg.append(\"text\")\n  .attr(\"transform\", `translate(28, ${margin.top + ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Mean-Maximal Power (W)\");\n\n// --- Legend ---\nconst legX = iw - 188;\nconst legY = ih - 82;\n\ng.append(\"rect\")\n  .attr(\"x\", legX - 10).attr(\"y\", legY - 10)\n  .attr(\"width\", 200).attr(\"height\", 68)\n  .attr(\"fill\", t.elevatedBg).attr(\"rx\", 4)\n  .attr(\"opacity\", 0.9);\n\ng.append(\"line\")\n  .attr(\"x1\", legX).attr(\"x2\", legX + 28)\n  .attr(\"y1\", legY + 10).attr(\"y2\", legY + 10)\n  .attr(\"stroke\", t.palette[0]).attr(\"stroke-width\", 3.5);\ng.append(\"circle\")\n  .attr(\"cx\", legX + 14).attr(\"cy\", legY + 10).attr(\"r\", 4)\n  .attr(\"fill\", t.palette[0]).attr(\"stroke\", t.pageBg).attr(\"stroke-width\", 1.5);\ng.append(\"text\")\n  .attr(\"x\", legX + 36).attr(\"y\", legY + 15)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"13px\")\n  .text(\"Empirical MMP\");\n\ng.append(\"line\")\n  .attr(\"x1\", legX).attr(\"x2\", legX + 28)\n  .attr(\"y1\", legY + 40).attr(\"y2\", legY + 40)\n  .attr(\"stroke\", t.palette[1]).attr(\"stroke-width\", 2.5)\n  .attr(\"stroke-dasharray\", \"8,5\");\ng.append(\"text\")\n  .attr(\"x\", legX + 36).attr(\"y\", legY + 45)\n  .attr(\"fill\", t.ink).style(\"font-size\", \"13px\")\n  .text(\"CP model fit\");\n\n// --- Title ---\nsvg.append(\"text\")\n  .attr(\"x\", width / 2).attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"curve-power-duration · javascript · d3 · anyplot.ai\");\n"}