{"spec_id":"curve-power-duration","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// curve-power-duration: Mean-Maximal Power Duration Curve\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 92/100 | Created: 2026-06-13\n\n//# anyplot-orientation: landscape\n\nconst tokens = window.ANYPLOT_TOKENS;\n\n// --- Data -------------------------------------------------------------------\nconst CP = 280;         // Critical power (W) — aerobic asymptote\nconst W_PRIME = 20000;  // Anaerobic work capacity (J)\n\n// Key empirical data for a well-trained cyclist (CP ≈ 280 W, W′ ≈ 20 000 J)\n// Monotonically non-increasing mean-maximal power at each duration\nconst KEY_PD = [\n  [1, 1100], [2, 960], [5, 840], [10, 720],\n  [20, 610], [30, 540], [45, 480], [60, 430],\n  [90, 390], [120, 365], [180, 340], [240, 325],\n  [300, 316], [420, 308], [600, 302], [900, 297],\n  [1200, 294], [1800, 290], [2700, 287], [3600, 284],\n  [5400, 282], [7200, 281], [10800, 279], [14400, 278], [18000, 276],\n];\n\n// Interpolate empirical power at duration t using log-space linear interpolation\nfunction empPower(t) {\n  const logT = Math.log10(t);\n  for (let i = 0; i < KEY_PD.length - 1; i++) {\n    const [t0, p0] = KEY_PD[i];\n    const [t1, p1] = KEY_PD[i + 1];\n    if (t >= t0 && t <= t1) {\n      const alpha = (logT - Math.log10(t0)) / (Math.log10(t1) - Math.log10(t0));\n      return p0 + (p1 - p0) * alpha;\n    }\n  }\n  return t < KEY_PD[0][0] ? KEY_PD[0][1] : KEY_PD[KEY_PD.length - 1][1];\n}\n\n// Generate n log-spaced values from lo to hi\nfunction logspace(n, lo, hi) {\n  return Array.from({ length: n }, (_, i) =>\n    Math.pow(10, Math.log10(lo) + (Math.log10(hi) - Math.log10(lo)) * i / (n - 1))\n  );\n}\n\n// 45 log-spaced durations for the empirical curve\nconst empiricalData = logspace(45, 1, 18000).map(d => ({ x: d, y: empPower(d) }));\n\n// CP model P(t) = CP + W′/t — starts at t ≈ 22 s where it enters the visible y range\nconst modelData = logspace(150, 22, 18000).map(d => ({ x: d, y: CP + W_PRIME / d }));\n\n// Reference durations for vertical annotation markers\nconst REF_MARKS = [\n  { t: 5,    label: '5 s sprint' },\n  { t: 60,   label: '1 min'      },\n  { t: 300,  label: '5 min'      },\n  { t: 1200, label: '20 min'     },\n];\n\n// --- Mount ------------------------------------------------------------------\nconst canvas = document.createElement('canvas');\ndocument.getElementById('container').appendChild(canvas);\n\n// --- Annotation plugin: reference lines + CP asymptote ---------------------\nconst annotPlugin = {\n  id: 'pdAnnotations',\n  afterDraw(chart) {\n    const ctx = chart.ctx;\n    const { left, right, top, bottom } = chart.chartArea;\n    const xScale = chart.scales.x;\n    const yScale = chart.scales.y;\n\n    // Vertical dashed reference duration lines with rotated labels\n    REF_MARKS.forEach(({ t: tVal, label }) => {\n      const px = xScale.getPixelForValue(tVal);\n      if (px < left || px > right) return;\n\n      ctx.save();\n      ctx.strokeStyle = tokens.inkSoft;\n      ctx.lineWidth = 1.5;\n      ctx.setLineDash([5, 4]);\n      ctx.globalAlpha = 0.45;\n      ctx.beginPath();\n      ctx.moveTo(px, top);\n      ctx.lineTo(px, bottom);\n      ctx.stroke();\n      ctx.restore();\n\n      // Rotated label drawn inside the chart area near the top\n      ctx.save();\n      ctx.translate(px, top + 8);\n      ctx.rotate(-Math.PI / 2);\n      ctx.font = '13px \"Inter\", \"Helvetica Neue\", sans-serif';\n      ctx.fillStyle = tokens.inkSoft;\n      ctx.textAlign = 'left';\n      ctx.textBaseline = 'middle';\n      ctx.globalAlpha = 0.75;\n      ctx.fillText(label, 0, 0);\n      ctx.restore();\n    });\n\n    // Horizontal CP asymptote at 280 W\n    const cpPy = yScale.getPixelForValue(CP);\n    ctx.save();\n    ctx.strokeStyle = tokens.inkSoft;\n    ctx.lineWidth = 1.5;\n    ctx.setLineDash([8, 5]);\n    ctx.globalAlpha = 0.4;\n    ctx.beginPath();\n    ctx.moveTo(left, cpPy);\n    ctx.lineTo(right, cpPy);\n    ctx.stroke();\n\n    ctx.globalAlpha = 0.65;\n    ctx.font = '13px \"Inter\", \"Helvetica Neue\", sans-serif';\n    ctx.fillStyle = tokens.inkSoft;\n    ctx.textAlign = 'left';\n    ctx.textBaseline = 'bottom';\n    ctx.fillText(`CP = ${CP} W`, left + 6, cpPy - 3);\n    ctx.restore();\n  },\n};\n\n// --- Chart ------------------------------------------------------------------\nnew Chart(canvas, {\n  type: 'scatter',\n  data: {\n    datasets: [\n      {\n        label: 'Mean-maximal power (empirical)',\n        data: empiricalData,\n        showLine: true,\n        fill: false,\n        borderColor: tokens.palette[0],\n        backgroundColor: tokens.palette[0],\n        borderWidth: 3,\n        pointRadius: 3.5,\n        pointHoverRadius: 6,\n        pointBackgroundColor: tokens.palette[0],\n        pointBorderColor: tokens.pageBg,\n        pointBorderWidth: 1.5,\n        cubicInterpolationMode: 'monotone',\n      },\n      {\n        label: 'CP model: P(t) = CP + W′/t',\n        data: modelData,\n        showLine: true,\n        fill: false,\n        borderColor: tokens.palette[1],\n        backgroundColor: tokens.palette[1],\n        borderWidth: 2.5,\n        borderDash: [10, 6],\n        pointRadius: 0,\n        cubicInterpolationMode: 'monotone',\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: {\n      padding: { top: 12, right: 40, bottom: 8, left: 8 },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: 'curve-power-duration · javascript · chartjs · anyplot.ai',\n        color: tokens.ink,\n        font: { size: 22, weight: '500' },\n        padding: { top: 4, bottom: 16 },\n      },\n      legend: {\n        labels: {\n          color: tokens.ink,\n          font: { size: 14 },\n          boxWidth: 28,\n          padding: 16,\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: 'logarithmic',\n        min: 1,\n        max: 18000,\n        title: {\n          display: true,\n          text: 'Duration',\n          color: tokens.ink,\n          font: { size: 15, weight: '500' },\n          padding: { top: 8 },\n        },\n        afterBuildTicks(axis) {\n          axis.ticks = [1, 5, 10, 30, 60, 300, 600, 1200, 3600, 7200, 18000]\n            .map(v => ({ value: v }));\n        },\n        ticks: {\n          color: tokens.inkSoft,\n          font: { size: 13 },\n          callback(value) {\n            const MAP = {\n              1: '1 s', 5: '5 s', 10: '10 s', 30: '30 s',\n              60: '1 min', 300: '5 min', 600: '10 min',\n              1200: '20 min', 3600: '1 h', 7200: '2 h',\n              18000: '5 h',\n            };\n            return MAP[Math.round(value)] || null;\n          },\n        },\n        border: { display: false },\n        grid: { display: false },\n      },\n      y: {\n        type: 'linear',\n        min: 220,\n        max: 1180,\n        title: {\n          display: true,\n          text: 'Power (W)',\n          color: tokens.ink,\n          font: { size: 15, weight: '500' },\n          padding: { right: 8 },\n        },\n        ticks: {\n          color: tokens.inkSoft,\n          font: { size: 13 },\n          stepSize: 100,\n          callback(value) {\n            return `${value} W`;\n          },\n        },\n        border: { display: false },\n        grid: { color: tokens.grid },\n      },\n    },\n  },\n  plugins: [annotPlugin],\n});\n"}