{"spec_id":"ecdf-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// ecdf-basic: Basic ECDF Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.0\n// Quality: 88/100 | Created: 2026-06-25\n\nconst t = window.ANYPLOT_TOKENS;\n\n// LCG for reproducible pseudo-random numbers (no seeded RNG in the browser)\nlet seed = 42;\nfunction lcg() {\n  seed = (seed * 1664525 + 1013904223) & 0xffffffff;\n  return (seed >>> 0) / 0xffffffff;\n}\nfunction randNormal(mean, std) {\n  const u1 = lcg() + 1e-10;\n  const u2 = lcg();\n  return mean + std * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// Marathon finish times (minutes) — novice vs experienced runners\nconst N = 200;\nconst noviceTimes = Array.from({ length: N }, () => randNormal(265, 35));\nconst experiencedTimes = Array.from({ length: N }, () => randNormal(225, 25));\n\n// ECDF: sort values and compute cumulative proportions (i+1)/n\nfunction computeECDF(values) {\n  const sorted = [...values].sort((a, b) => a - b);\n  const range = sorted[sorted.length - 1] - sorted[0];\n  const ecdf = sorted.map((x, i) => ({ x, y: (i + 1) / sorted.length }));\n  ecdf.unshift({ x: sorted[0] - range * 0.03, y: 0 });\n  return ecdf;\n}\n\nconst noviceECDF = computeECDF(noviceTimes);\nconst experiencedECDF = computeECDF(experiencedTimes);\n\n// Median: x where ECDF first reaches 0.5\nfunction findMedian(ecdf) {\n  const pt = ecdf.find((p) => p.y >= 0.5);\n  return pt ? Math.round(pt.x) : null;\n}\nconst noviceMedian = findMedian(noviceECDF);\nconst experiencedMedian = findMedian(experiencedECDF);\n\n// Mount\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// Fill canvas with theme surface color before all drawing\nconst bgPlugin = {\n  id: \"bg\",\n  beforeDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.fillStyle = t.pageBg;\n    ctx.fillRect(0, 0, chart.width, chart.height);\n    ctx.restore();\n  },\n};\n\n// Draw median reference line at y=0.5 with per-group callouts\nconst medianPlugin = {\n  id: \"medianRef\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    if (!chartArea) return;\n    const y50 = scales.y.getPixelForValue(0.5);\n    ctx.save();\n\n    // Dashed horizontal reference line across full chart area\n    ctx.beginPath();\n    ctx.setLineDash([10, 6]);\n    ctx.lineWidth = 1.5;\n    ctx.strokeStyle = t.inkSoft;\n    ctx.moveTo(chartArea.left, y50);\n    ctx.lineTo(chartArea.right, y50);\n    ctx.stroke();\n    ctx.setLineDash([]);\n\n    // \"median\" label inside the chart above the reference line on the left\n    ctx.font = \"12px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"bottom\";\n    ctx.fillText(\"median\", chartArea.left + 6, y50 - 4);\n\n    // Per-group callouts: dot at crossing + subtle vertical drop + minute label\n    [\n      { value: noviceMedian, color: t.palette[0] },\n      { value: experiencedMedian, color: t.palette[1] },\n    ].forEach(({ value, color }) => {\n      if (value == null) return;\n      const xPx = scales.x.getPixelForValue(value);\n\n      // Subtle vertical drop line from reference line to x-axis\n      ctx.beginPath();\n      ctx.setLineDash([4, 4]);\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = color;\n      ctx.globalAlpha = 0.4;\n      ctx.moveTo(xPx, y50);\n      ctx.lineTo(xPx, chartArea.bottom);\n      ctx.stroke();\n      ctx.setLineDash([]);\n      ctx.globalAlpha = 1;\n\n      // Filled dot at the median crossing\n      ctx.beginPath();\n      ctx.arc(xPx, y50, 5, 0, 2 * Math.PI);\n      ctx.fillStyle = color;\n      ctx.fill();\n\n      // Minute label above the dot\n      ctx.font = \"bold 12px sans-serif\";\n      ctx.fillStyle = color;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"bottom\";\n      ctx.fillText(`${value} min`, xPx, y50 - 10);\n    });\n\n    ctx.restore();\n  },\n};\n\nnew Chart(canvas, {\n  type: \"line\",\n  plugins: [bgPlugin, medianPlugin],\n  data: {\n    datasets: [\n      {\n        label: \"Novice Runners\",\n        data: noviceECDF,\n        borderColor: t.palette[0],\n        backgroundColor: \"transparent\",\n        borderWidth: 3,\n        stepped: \"before\",\n        pointRadius: 0,\n      },\n      {\n        label: \"Experienced Runners\",\n        data: experiencedECDF,\n        borderColor: t.palette[1],\n        backgroundColor: \"transparent\",\n        borderWidth: 3,\n        borderDash: [10, 4],\n        stepped: \"before\",\n        pointRadius: 0,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"ecdf-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n        padding: { bottom: 20 },\n      },\n      legend: {\n        labels: { color: t.ink, font: { size: 16 }, boxWidth: 20 },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        border: { display: false },\n        title: {\n          display: true,\n          text: \"Finish Time (minutes)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { display: false },\n      },\n      y: {\n        min: 0,\n        max: 1,\n        border: { display: false },\n        title: {\n          display: true,\n          text: \"Cumulative Proportion\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (v) => v.toFixed(1),\n        },\n        grid: { color: t.grid },\n      },\n    },\n  },\n});\n"}