{"spec_id":"spirometry-flow-volume","library":"muix","language":"javascript","code":"// anyplot.ai\n// spirometry-flow-volume: Spirometry Flow-Volume Loop\n// Library: muix 7.29.1 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-17\n//# anyplot-orientation: landscape\n// anyplot.ai\n// spirometry-flow-volume: Spirometry Flow-Volume Loop\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-06-17\nimport { LineChart } from \"@mui/x-charts/LineChart\";\nimport { useXScale, useYScale, useDrawingArea } from \"@mui/x-charts/hooks\";\nimport Box from \"@mui/material/Box\";\nimport Typography from \"@mui/material/Typography\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ---------------------------------------\n// Pulmonary function test: forced expiration + inspiration traced as flow (L/s)\n// against exhaled volume (L). One closed parametric loop per test. The measured\n// curve shows a mild obstructive pattern (reduced PEF, scooped descending limb);\n// the predicted-normal envelope is overlaid dashed for comparison.\nconst N = 140;\n\n// Build the expiratory limb: volume 0 -> FVC. Flow rises steeply to PEF at a\n// small volume, then declines toward zero. `scoop` > 1 bows the descending limb\n// toward the origin (the classic obstructive coving).\nfunction expiratoryLimb(fvc, pef, vPeak, scoop) {\n  const pts = [];\n  for (let i = 0; i < N; i++) {\n    const v = (fvc * i) / (N - 1);\n    const flow =\n      v <= vPeak\n        ? pef * Math.pow(v / vPeak, 0.55)\n        : pef * Math.pow((fvc - v) / (fvc - vPeak), scoop);\n    pts.push([v, flow]);\n  }\n  return pts;\n}\n\n// Build the inspiratory limb: volume FVC -> 0, flow negative, symmetric U-shape.\nfunction inspiratoryLimb(fvc, pif) {\n  const pts = [];\n  for (let i = 0; i < N; i++) {\n    const v = fvc * (1 - i / (N - 1));\n    pts.push([v, -pif * Math.sin(Math.PI * (v / fvc))]);\n  }\n  return pts;\n}\n\n// Measured patient loop (mild obstruction) and the predicted-normal reference.\nconst MEASURED = { fvc: 5.0, pef: 9.5, vPeak: 0.35, pif: 5.9, fev1: 3.8 };\nconst PREDICTED = { fvc: 5.6, pef: 10.6, vPeak: 0.3, pif: 6.6, fev1: 4.5 };\n\nconst measuredLoop = [\n  ...expiratoryLimb(MEASURED.fvc, MEASURED.pef, MEASURED.vPeak, 1.35),\n  ...inspiratoryLimb(MEASURED.fvc, MEASURED.pif),\n];\nconst predictedLoop = [\n  ...expiratoryLimb(PREDICTED.fvc, PREDICTED.pef, PREDICTED.vPeak, 1.0),\n  ...inspiratoryLimb(PREDICTED.fvc, PREDICTED.pif),\n];\n\nconst measuredVolume = measuredLoop.map((p) => p[0]);\nconst measuredFlow = measuredLoop.map((p) => p[1]);\n\nconst ratio = Math.round((MEASURED.fev1 / MEASURED.fvc) * 100);\n\n// --- Overlay: predicted loop, zero-flow axis, PEF marker, clinical panel -----\nfunction toPath(points, xs, ys) {\n  return (\n    points.map(([v, f], i) => `${i ? \"L\" : \"M\"}${xs(v)},${ys(f)}`).join(\" \") + \" Z\"\n  );\n}\n\nfunction ClinicalOverlay() {\n  const xs = useXScale();\n  const ys = useYScale();\n  const { left, top, width } = useDrawingArea();\n  if (!xs || !ys) return null;\n\n  const zeroY = ys(0);\n  const pefX = xs(MEASURED.vPeak);\n  const pefY = ys(MEASURED.pef);\n\n  // Info / legend panel anchored top-right (the declining tail leaves it clear).\n  const panelW = 268;\n  const panelH = 196;\n  const px = left + width - panelW - 18;\n  const py = top + 14;\n  const rows = [\n    [\"PEF\", `${MEASURED.pef.toFixed(1)} L/s`],\n    [\"FEV1\", `${MEASURED.fev1.toFixed(2)} L`],\n    [\"FVC\", `${MEASURED.fvc.toFixed(2)} L`],\n    [\"FEV1/FVC\", `${ratio} %`],\n  ];\n\n  return (\n    <g>\n      {/* Zero-flow reference line separating expiration (+) from inspiration (−) */}\n      <line\n        x1={left}\n        x2={left + width}\n        y1={zeroY}\n        y2={zeroY}\n        stroke={t.inkSoft}\n        strokeWidth={1.5}\n        opacity={0.6}\n      />\n      <text x={left + 10} y={zeroY - 12} fontSize={13} fill={t.inkSoft} opacity={0.8}>\n        Expiration ▲\n      </text>\n      <text x={left + 10} y={zeroY + 22} fontSize={13} fill={t.inkSoft} opacity={0.8}>\n        Inspiration ▼\n      </text>\n\n      {/* Predicted-normal envelope (dashed reference) */}\n      <path\n        d={toPath(predictedLoop, xs, ys)}\n        fill=\"none\"\n        stroke={t.inkSoft}\n        strokeWidth={2.5}\n        strokeDasharray=\"9 6\"\n        opacity={0.85}\n      />\n\n      {/* Peak Expiratory Flow marker on the measured loop */}\n      <circle cx={pefX} cy={pefY} r={9} fill={t.palette[0]} stroke={t.pageBg} strokeWidth={2.5} />\n      <line\n        x1={pefX}\n        y1={pefY}\n        x2={pefX + 40}\n        y2={pefY - 30}\n        stroke={t.ink}\n        strokeWidth={1.5}\n      />\n      <text x={pefX + 46} y={pefY - 28} fontSize={14} fontWeight=\"600\" fill={t.ink}>\n        PEF\n      </text>\n\n      {/* Clinical values + legend panel */}\n      <rect\n        x={px}\n        y={py}\n        width={panelW}\n        height={panelH}\n        rx={8}\n        fill={t.elevatedBg}\n        stroke={t.grid}\n        strokeWidth={1}\n      />\n      {/* Legend: measured (solid green) + predicted (dashed neutral) */}\n      <line x1={px + 18} y1={py + 26} x2={px + 50} y2={py + 26} stroke={t.palette[0]} strokeWidth={3.5} />\n      <text x={px + 60} y={py + 31} fontSize={14} fill={t.ink}>\n        Measured\n      </text>\n      <line\n        x1={px + 18}\n        y1={py + 50}\n        x2={px + 50}\n        y2={py + 50}\n        stroke={t.inkSoft}\n        strokeWidth={2.5}\n        strokeDasharray=\"9 6\"\n      />\n      <text x={px + 60} y={py + 55} fontSize={14} fill={t.ink}>\n        Predicted normal\n      </text>\n      <line x1={px + 16} y1={py + 68} x2={px + panelW - 16} y2={py + 68} stroke={t.grid} strokeWidth={1} />\n      {/* Numeric clinical values */}\n      {rows.map(([k, v], i) => (\n        <g key={k}>\n          <text x={px + 18} y={py + 94 + i * 27} fontSize={14} fill={t.inkSoft}>\n            {k}\n          </text>\n          <text\n            x={px + panelW - 18}\n            y={py + 94 + i * 27}\n            fontSize={14}\n            fontWeight=\"600\"\n            textAnchor=\"end\"\n            fill={t.ink}\n          >\n            {v}\n          </text>\n        </g>\n      ))}\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nconst TITLE_H = 56;\n\nexport default function Chart() {\n  const W = window.ANYPLOT_SIZE.width;\n  const H = window.ANYPLOT_SIZE.height;\n\n  return (\n    <Box sx={{ width: W, height: H, bgcolor: t.pageBg, display: \"flex\", flexDirection: \"column\" }}>\n      <Typography\n        sx={{\n          color: t.ink,\n          fontSize: 21,\n          fontWeight: 500,\n          textAlign: \"center\",\n          height: TITLE_H,\n          lineHeight: `${TITLE_H}px`,\n          flexShrink: 0,\n        }}\n      >\n        spirometry-flow-volume · javascript · muix · anyplot.ai\n      </Typography>\n      <LineChart\n        width={W}\n        height={H - TITLE_H}\n        skipAnimation\n        margin={{ top: 28, right: 44, bottom: 78, left: 96 }}\n        grid={{ horizontal: true, vertical: true }}\n        xAxis={[\n          {\n            data: measuredVolume,\n            scaleType: \"linear\",\n            min: 0,\n            max: 6,\n            tickMinStep: 1,\n            label: \"Volume (L)\",\n            labelStyle: { fontSize: 16, fill: t.ink },\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n          },\n        ]}\n        yAxis={[\n          {\n            min: -8,\n            max: 11,\n            tickMinStep: 2,\n            label: \"Flow (L/s)\",\n            labelStyle: { fontSize: 16, fill: t.ink },\n            tickLabelStyle: { fontSize: 14, fill: t.inkSoft },\n          },\n        ]}\n        series={[\n          {\n            data: measuredFlow,\n            color: t.palette[0],\n            label: \"Measured\",\n            showMark: false,\n            curve: \"linear\",\n          },\n        ]}\n        slotProps={{ legend: { hidden: true } }}\n        sx={{\n          \"& .MuiLineElement-root\": { strokeWidth: 3 },\n          \"& .MuiChartsAxis-line\": { stroke: t.inkSoft, strokeWidth: 1 },\n          \"& .MuiChartsAxis-tick\": { stroke: t.inkSoft },\n          \"& .MuiChartsGrid-line\": { stroke: t.grid },\n        }}\n      >\n        <ClinicalOverlay />\n      </LineChart>\n    </Box>\n  );\n}\n"}