{"spec_id":"spirometry-flow-volume","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// spirometry-flow-volume: Spirometry Flow-Volume Loop\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-17\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Theme-adaptive chrome (data colours stay constant; only chrome flips)\nconst PAGE = t.pageBg;\nconst ELEV = t.elevatedBg;\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst GRID = t.grid;\n\n// Imprint palette — measured loop = brand green (first series)\nconst MEASURED = t.palette[0]; // #009E73\nconst PREDICTED = INK_SOFT; // dashed neutral reference overlay\n\n// --- Data (in-memory, deterministic) ---------------------------------------\n// A forced-expiration/inspiration flow-volume loop. Volume (L) on x, flow (L/s)\n// on y: the expiratory limb rises sharply to Peak Expiratory Flow then declines\n// roughly linearly; the inspiratory limb is a symmetric half-ellipse below zero.\nconst buildLoop = ({ fvc, pef, vPef, pif, n = 170 }) => {\n  const expiratory = [];\n  for (let i = 0; i <= n; i++) {\n    const v = (fvc * i) / n;\n    // Sharp rise to PEF (sqrt edge), then near-linear effort-independent decline\n    const flow =\n      v < vPef ? pef * Math.sqrt(v / vPef) : (pef * (fvc - v)) / (fvc - vPef);\n    expiratory.push({ x: v, y: Math.max(0, flow) });\n  }\n  const inspiratory = [];\n  for (let i = 0; i <= n; i++) {\n    const v = fvc * (1 - i / n); // return from FVC back to 0\n    const half = fvc / 2;\n    const flow = -pif * Math.sqrt(Math.max(0, 1 - ((v - half) / half) ** 2));\n    inspiratory.push({ x: v, y: flow });\n  }\n  return expiratory.concat(inspiratory); // closed loop in draw order\n};\n\n// Predicted normal reference and a measured patient with mildly reduced flows.\nconst predicted = { fvc: 5.2, pef: 10.2, vPef: 0.5, pif: 6.4, fev1: 4.2 };\nconst measured = { fvc: 4.6, pef: 8.6, vPef: 0.6, pif: 5.5, fev1: 3.8 };\n\nconst measuredLoop = buildLoop(measured);\nconst predictedLoop = buildLoop(predicted);\nconst pefPoint = [{ x: measured.vPef, y: measured.pef }];\n\n// --- Mount -----------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Annotation plugin: zero-flow rule, PEF marker label, clinical values ---\nconst annotate = {\n  id: \"annotate\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    ctx.save();\n\n    // PEF marker label\n    const px = scales.x.getPixelForValue(measured.vPef);\n    const py = scales.y.getPixelForValue(measured.pef);\n    ctx.font = '700 16px -apple-system, \"Segoe UI\", Roboto, sans-serif';\n    ctx.fillStyle = INK;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    ctx.fillText(\"PEF\", px + 14, py - 2);\n\n    // Clinical-values card (top-right corner of the plot area)\n    const rows = [\n      [\"\", \"Meas.\", \"Pred.\"],\n      [\"FVC (L)\", measured.fvc.toFixed(1), predicted.fvc.toFixed(1)],\n      [\"FEV₁ (L)\", measured.fev1.toFixed(1), predicted.fev1.toFixed(1)],\n      [\"PEF (L/s)\", measured.pef.toFixed(1), predicted.pef.toFixed(1)],\n    ];\n    const padX = 16;\n    const padY = 14;\n    const lineH = 26;\n    const colW = [110, 64, 64];\n    const cardW = padX * 2 + colW[0] + colW[1] + colW[2];\n    const cardH = padY * 2 + lineH * rows.length;\n    const cardX = chartArea.right - cardW - 18;\n    const cardY = chartArea.top + 16;\n\n    ctx.fillStyle = ELEV;\n    ctx.strokeStyle = GRID;\n    ctx.lineWidth = 1.5;\n    ctx.beginPath();\n    ctx.roundRect(cardX, cardY, cardW, cardH, 10);\n    ctx.fill();\n    ctx.stroke();\n\n    ctx.textBaseline = \"middle\";\n    rows.forEach((row, r) => {\n      const ty = cardY + padY + lineH * r + lineH / 2;\n      const header = r === 0;\n      row.forEach((cell, c) => {\n        ctx.font = header\n          ? '700 15px -apple-system, \"Segoe UI\", Roboto, sans-serif'\n          : '500 16px -apple-system, \"Segoe UI\", Roboto, sans-serif';\n        ctx.fillStyle = header || c === 0 ? INK : c === 1 ? MEASURED : INK_SOFT;\n        let tx = cardX + padX;\n        for (let k = 0; k < c; k++) tx += colW[k];\n        ctx.textAlign = c === 0 ? \"left\" : \"right\";\n        const anchor = c === 0 ? tx : tx + colW[c] - 8;\n        ctx.fillText(cell, anchor, ty);\n      });\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Chart -----------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"Measured\",\n        data: measuredLoop,\n        showLine: true,\n        borderColor: MEASURED,\n        backgroundColor: MEASURED,\n        borderWidth: 3.5,\n        pointRadius: 0,\n        tension: 0.15,\n        order: 1,\n      },\n      {\n        label: \"Predicted normal\",\n        data: predictedLoop,\n        showLine: true,\n        borderColor: PREDICTED,\n        backgroundColor: PREDICTED,\n        borderWidth: 2.5,\n        borderDash: [10, 7],\n        pointRadius: 0,\n        tension: 0.15,\n        order: 2,\n      },\n      {\n        label: \"PEF\",\n        data: pefPoint,\n        showLine: false,\n        backgroundColor: MEASURED,\n        borderColor: PAGE,\n        borderWidth: 2,\n        pointRadius: 7,\n        pointHoverRadius: 7,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 6, right: 12, bottom: 6, left: 6 } },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: 0,\n        max: 5.6,\n        title: {\n          display: true,\n          text: \"Volume (L)\",\n          color: INK,\n          font: { size: 17 },\n        },\n        ticks: { color: INK_SOFT, font: { size: 14 } },\n        grid: { color: GRID },\n      },\n      y: {\n        type: \"linear\",\n        min: -7.5,\n        max: 11,\n        title: {\n          display: true,\n          text: \"Flow (L/s)\",\n          color: INK,\n          font: { size: 17 },\n        },\n        ticks: { color: INK_SOFT, font: { size: 14 } },\n        grid: {\n          color: (c) => (c.tick.value === 0 ? INK_SOFT : GRID),\n          lineWidth: (c) => (c.tick.value === 0 ? 2 : 1),\n        },\n      },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"spirometry-flow-volume · javascript · chartjs · anyplot.ai\",\n        color: INK,\n        font: { size: 22, weight: \"600\" },\n        padding: { top: 4, bottom: 2 },\n      },\n      subtitle: {\n        display: true,\n        text: \"Forced expiration (above) and inspiration (below) · measured vs predicted normal\",\n        color: INK_SOFT,\n        font: { size: 14 },\n        padding: { bottom: 12 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: INK,\n          font: { size: 16 },\n          padding: 18,\n          boxWidth: 28,\n          usePointStyle: false,\n          filter: (item) => item.text !== \"PEF\",\n        },\n      },\n      tooltip: { enabled: false },\n    },\n  },\n  plugins: [annotate],\n});\n"}