{"spec_id":"spirometry-flow-volume","library":"d3","language":"javascript","code":"// anyplot.ai\n// spirometry-flow-volume: Spirometry Flow-Volume Loop\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 93/100 | Created: 2026-06-17\n//# anyplot-orientation: landscape\n// anyplot.ai\n// spirometry-flow-volume: Spirometry Flow-Volume Loop\n// Library: d3 7.9.0 | JavaScript 22\n// Quality: pending | Created: 2026-06-17\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 86, right: 64, bottom: 78, left: 92 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic) ---------------------------------------\n// A forced expiration/inspiration flow-volume loop. The expiratory limb rises\n// sharply to Peak Expiratory Flow (PEF) then declines roughly linearly; the\n// inspiratory limb is a symmetric U below the zero-flow line. We build a\n// measured loop and a predicted-normal reference loop for comparison.\nlet seed = 20260617;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\n\nfunction flowVolumeLoop(fvc, pef, pif, vPef, jitter) {\n  const N = 180;\n  const pts = [];\n  // Expiratory limb: volume 0 -> FVC (sharp rise to PEF, linear-ish decline).\n  for (let i = 0; i <= N; i++) {\n    const v = (fvc * i) / N;\n    let f;\n    if (v < vPef) f = pef * (v / vPef);\n    else f = pef * Math.pow(1 - (v - vPef) / (fvc - vPef), 1.15);\n    f += (rand() - 0.5) * jitter;\n    pts.push({ volume: v, flow: Math.max(f, 0) });\n  }\n  // Inspiratory limb: volume FVC -> 0 (symmetric U, negative flow).\n  for (let i = 1; i <= N; i++) {\n    const v = fvc - (fvc * i) / N;\n    let f = -pif * Math.sin((Math.PI * v) / fvc);\n    f += (rand() - 0.5) * jitter;\n    pts.push({ volume: v, flow: Math.min(f, 0) });\n  }\n  return pts;\n}\n\nconst measured = flowVolumeLoop(4.8, 9.4, 8.2, 0.42, 0.22);\nconst predicted = flowVolumeLoop(5.3, 10.4, 9.0, 0.4, 0.0);\n\n// Clinical summary values (measured).\nconst clinical = [\n  { label: \"PEF\", value: \"9.4 L/s\" },\n  { label: \"FVC\", value: \"4.8 L\" },\n  { label: \"FEV₁\", value: \"3.9 L\" },\n  { label: \"FEV₁/FVC\", value: \"81%\" },\n];\nconst pefPoint = measured.reduce((a, b) => (b.flow > a.flow ? b : a));\n\n// --- SVG mount --------------------------------------------------------------\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\nconst g = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Scales -----------------------------------------------------------------\nconst x = d3.scaleLinear().domain([0, 5.6]).range([0, iw]);\nconst y = d3.scaleLinear().domain([-10, 12]).nice().range([ih, 0]);\n\nconst line = d3\n  .line()\n  .x((d) => x(d.volume))\n  .y((d) => y(d.flow))\n  .curve(d3.curveCatmullRom.alpha(0.5));\n\n// --- Gridlines --------------------------------------------------------------\ng.append(\"g\")\n  .selectAll(\"line.gx\")\n  .data(x.ticks(8))\n  .join(\"line\")\n  .attr(\"x1\", (d) => x(d))\n  .attr(\"x2\", (d) => x(d))\n  .attr(\"y1\", 0)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\ng.append(\"g\")\n  .selectAll(\"line.gy\")\n  .data(y.ticks(8))\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// Zero-flow reference line (separates expiratory from inspiratory limb).\ng.append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", y(0))\n  .attr(\"y2\", y(0))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"2 6\")\n  .attr(\"opacity\", 0.7);\n\n// --- Axes -------------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(8).tickSizeOuter(0));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(8).tickSizeOuter(0));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Predicted normal loop (dashed reference) -------------------------------\ng.append(\"path\")\n  .datum(predicted)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[2])\n  .attr(\"stroke-width\", 2.6)\n  .attr(\"stroke-dasharray\", \"10 8\")\n  .attr(\"stroke-linejoin\", \"round\")\n  .attr(\"opacity\", 0.95)\n  .attr(\"d\", line);\n\n// --- Measured loop (solid, brand green) -------------------------------------\ng.append(\"path\")\n  .datum(measured)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"fill-opacity\", 0.08)\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 3.6)\n  .attr(\"stroke-linejoin\", \"round\")\n  .attr(\"d\", line);\n\n// --- PEF marker -------------------------------------------------------------\ng.append(\"circle\")\n  .attr(\"cx\", x(pefPoint.volume))\n  .attr(\"cy\", y(pefPoint.flow))\n  .attr(\"r\", 7)\n  .attr(\"fill\", t.palette[0])\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2.5);\ng.append(\"text\")\n  .attr(\"x\", x(pefPoint.volume) + 14)\n  .attr(\"y\", y(pefPoint.flow) + 5)\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"PEF 9.4 L/s\");\n\n// --- Axis labels ------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + iw / 2)\n  .attr(\"y\", height - 24)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"Volume (L)\");\nsvg\n  .append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -(margin.top + ih / 2))\n  .attr(\"y\", 26)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"Flow (L/s)\");\n\n// Expiration / Inspiration limb hints along the y-axis.\ng.append(\"text\")\n  .attr(\"x\", 10)\n  .attr(\"y\", y(11))\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-style\", \"italic\")\n  .text(\"Expiration ↑\");\ng.append(\"text\")\n  .attr(\"x\", 10)\n  .attr(\"y\", y(-9.2))\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-style\", \"italic\")\n  .text(\"Inspiration ↓\");\n\n// --- Legend + clinical callout (upper-right, away from the curves) ----------\nconst legend = g.append(\"g\").attr(\"transform\", `translate(${iw - 250},${8})`);\nlegend\n  .append(\"rect\")\n  .attr(\"x\", -16)\n  .attr(\"y\", -14)\n  .attr(\"width\", 266)\n  .attr(\"height\", 178)\n  .attr(\"rx\", 8)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\nconst entries = [\n  { color: t.palette[0], dash: null, text: \"Measured\" },\n  { color: t.palette[2], dash: \"10 7\", text: \"Predicted normal\" },\n];\nentries.forEach((e, i) => {\n  const yy = i * 28;\n  legend\n    .append(\"line\")\n    .attr(\"x1\", 0)\n    .attr(\"x2\", 36)\n    .attr(\"y1\", yy)\n    .attr(\"y2\", yy)\n    .attr(\"stroke\", e.color)\n    .attr(\"stroke-width\", 3.6)\n    .attr(\"stroke-dasharray\", e.dash);\n  legend\n    .append(\"text\")\n    .attr(\"x\", 46)\n    .attr(\"y\", yy + 5)\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"16px\")\n    .text(e.text);\n});\n\nlegend\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 234)\n  .attr(\"y1\", 50)\n  .attr(\"y2\", 50)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\nclinical.forEach((c, i) => {\n  const yy = 72 + i * 24;\n  legend\n    .append(\"text\")\n    .attr(\"x\", 0)\n    .attr(\"y\", yy)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"15px\")\n    .text(c.label);\n  legend\n    .append(\"text\")\n    .attr(\"x\", 234)\n    .attr(\"y\", yy)\n    .attr(\"text-anchor\", \"end\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"15px\")\n    .style(\"font-weight\", \"600\")\n    .text(c.value);\n});\n\n// --- Title ------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"spirometry-flow-volume · javascript · d3 · anyplot.ai\");\n"}