{"spec_id":"spirometry-flow-volume","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// spirometry-flow-volume: Spirometry Flow-Volume Loop\n// Library: highcharts 12.6.0 | JavaScript 22.22.3\n// Quality: 92/100 | Created: 2026-06-17\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst dark = window.ANYPLOT_THEME === \"dark\";\n\n// Theme-adaptive chrome (Imprint palette + tokens)\nconst BRAND = t.palette[0]; // #009E73 — measured loop, ALWAYS first series\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst RULE = t.grid;\nconst ELEVATED_BG = t.elevatedBg;\nconst MUTED = dark ? \"#A8A79F\" : \"#6B6A63\"; // predicted-normal reference overlay\n\n// --- Data (in-memory, deterministic clinical spirometry curves) -------------\n// A healthy adult forced expiration/inspiration manoeuvre and the predicted\n// normal reference. Volume (L) on x, flow (L/s) on y; the expiratory limb is\n// positive (sharp rise to PEF, near-linear decline), the inspiratory limb is a\n// symmetric U below the zero-flow line. Both limbs are stored ascending in\n// volume so Highcharts draws each as a sorted line; the two limbs share their\n// endpoints (0,0) and (FVC,0), closing the loop.\nconst round = (v, d) => Math.round(v * 10 ** d) / 10 ** d;\n\nfunction expiratoryLimb(fvc, pef, vpef, n) {\n  const pts = [];\n  for (let i = 0; i <= n; i++) {\n    const v = (fvc * i) / n;\n    let f;\n    if (v <= vpef) {\n      const r = v / vpef; // steep ease-out rise to Peak Expiratory Flow\n      f = pef * (1 - (1 - r) * (1 - r));\n    } else {\n      const r = Math.max((fvc - v) / (fvc - vpef), 0); // slightly convex decline\n      f = pef * Math.pow(r, 0.85);\n    }\n    pts.push([round(v, 3), round(f, 3)]);\n  }\n  return pts;\n}\n\nfunction inspiratoryLimb(fvc, pif, n) {\n  const pts = [];\n  for (let i = 0; i <= n; i++) {\n    const v = (fvc * i) / n;\n    const x = (v - fvc / 2) / (fvc / 2); // -1..1 → symmetric semicircle\n    const f = -pif * Math.sqrt(Math.max(0, 1 - x * x));\n    pts.push([round(v, 3), round(f, 3)]);\n  }\n  return pts;\n}\n\n// Measured manoeuvre (mild values within normal range)\nconst MEAS = { fvc: 4.8, pef: 9.4, vpef: 0.45, pif: 7.5, fev1: 3.9 };\n// Predicted normal reference\nconst PRED = { fvc: 5.1, pef: 10.2, vpef: 0.5, pif: 8.0 };\n\nconst measExp = expiratoryLimb(MEAS.fvc, MEAS.pef, MEAS.vpef, 120);\nconst measInsp = inspiratoryLimb(MEAS.fvc, MEAS.pif, 120);\nconst predExp = expiratoryLimb(PRED.fvc, PRED.pef, PRED.vpef, 120);\nconst predInsp = inspiratoryLimb(PRED.fvc, PRED.pif, 120);\n\nconst ratio = Math.round((MEAS.fev1 / MEAS.fvc) * 100);\n\n// --- Chart ------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"line\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    spacingRight: 28,\n    events: {\n      // Clinical-values callout box (core SVGRenderer — no annotations module).\n      load: function () {\n        const chart = this;\n        const lines = [\n          `FEV₁  =  ${MEAS.fev1.toFixed(1)} L`,\n          `FVC   =  ${MEAS.fvc.toFixed(1)} L`,\n          `PEF   =  ${MEAS.pef.toFixed(1)} L/s`,\n          `FEV₁/FVC  =  ${ratio} %`,\n        ];\n        const box = chart.renderer\n          .label(lines.join(\"<br/>\"), 0, 0, \"rect\")\n          .css({ color: INK, fontSize: \"15px\", lineHeight: \"20px\" })\n          .attr({\n            fill: ELEVATED_BG,\n            stroke: INK_SOFT,\n            \"stroke-width\": 1,\n            padding: 14,\n            r: 6,\n            zIndex: 7,\n          })\n          .add();\n        const bb = box.getBBox();\n        box.attr({\n          x: chart.plotLeft + chart.plotWidth - bb.width - 22,\n          y: chart.plotTop + 18,\n        });\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"spirometry-flow-volume · javascript · highcharts · anyplot.ai\",\n    style: { color: INK, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Forced expiratory & inspiratory flow versus lung volume — measured vs predicted normal\",\n    style: { color: INK_SOFT, fontSize: \"14px\" },\n  },\n  xAxis: {\n    title: { text: \"Volume (L)\", style: { color: INK_SOFT, fontSize: \"16px\" } },\n    min: 0,\n    max: 5.4,\n    tickInterval: 1,\n    lineColor: INK_SOFT,\n    tickColor: INK_SOFT,\n    gridLineColor: RULE,\n    gridLineWidth: 1,\n    labels: { style: { color: INK_SOFT, fontSize: \"14px\" } },\n  },\n  yAxis: {\n    title: { text: \"Flow (L/s)\", style: { color: INK_SOFT, fontSize: \"16px\" } },\n    min: -9,\n    max: 11,\n    tickInterval: 2,\n    lineColor: INK_SOFT,\n    gridLineColor: RULE,\n    gridLineWidth: 1,\n    labels: { style: { color: INK_SOFT, fontSize: \"14px\" } },\n    plotLines: [\n      {\n        value: 0,\n        color: INK_SOFT,\n        width: 1.5,\n        zIndex: 3,\n        label: {\n          text: \"Expiration ▲   ·   Inspiration ▼\",\n          align: \"left\",\n          x: 8,\n          y: -8,\n          style: { color: INK_SOFT, fontSize: \"13px\" },\n        },\n      },\n    ],\n  },\n  legend: {\n    itemStyle: { color: INK_SOFT, fontSize: \"14px\" },\n    itemHoverStyle: { color: INK },\n    symbolWidth: 34,\n  },\n  tooltip: {\n    backgroundColor: ELEVATED_BG,\n    borderColor: INK_SOFT,\n    style: { color: INK, fontSize: \"13px\" },\n    headerFormat: \"\",\n    pointFormat: \"Vol {point.x:.2f} L · Flow {point.y:.2f} L/s\",\n  },\n  plotOptions: {\n    series: {\n      animation: false,\n      marker: { enabled: false },\n      states: { hover: { lineWidthPlus: 0 }, inactive: { opacity: 1 } },\n    },\n  },\n  series: [\n    {\n      name: \"Measured\",\n      color: BRAND,\n      lineWidth: 3.5,\n      zIndex: 4,\n      data: measExp,\n    },\n    {\n      name: \"Measured (inspiratory)\",\n      color: BRAND,\n      lineWidth: 3.5,\n      zIndex: 4,\n      linkedTo: \":previous\",\n      data: measInsp,\n    },\n    {\n      name: \"Predicted normal\",\n      color: MUTED,\n      dashStyle: \"Dash\",\n      lineWidth: 2.5,\n      zIndex: 2,\n      data: predExp,\n    },\n    {\n      name: \"Predicted normal (inspiratory)\",\n      color: MUTED,\n      dashStyle: \"Dash\",\n      lineWidth: 2.5,\n      zIndex: 2,\n      linkedTo: \":previous\",\n      data: predInsp,\n    },\n    {\n      name: \"PEF\",\n      type: \"scatter\",\n      color: BRAND,\n      zIndex: 6,\n      showInLegend: false,\n      enableMouseTracking: false,\n      marker: {\n        enabled: true,\n        radius: 7,\n        symbol: \"circle\",\n        fillColor: BRAND,\n        lineColor: INK,\n        lineWidth: 2,\n      },\n      dataLabels: {\n        enabled: true,\n        align: \"left\",\n        x: 16,\n        y: -6,\n        format: \"PEF · {point.y:.1f} L/s\",\n        style: {\n          color: INK,\n          fontSize: \"14px\",\n          fontWeight: \"600\",\n          textOutline: \"none\",\n        },\n      },\n      data: [[MEAS.vpef, MEAS.pef]],\n    },\n  ],\n});\n"}