{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "growth-chart",
  "title": "Growth chart",
  "description": "Child growth percentile curves for weight, length/height and head circumference from reference tables you supply, with plotted measurements, percentiles and z-scores.",
  "dependencies": [
    "cn@^0.5.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/base-nova/growth-chart/growth-chart.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\n\nexport type GrowthMeasure = \"weight\" | \"length\" | \"head\"\nexport type GrowthSex = \"female\" | \"male\"\n/** LMS parameters: Box-Cox power, median and coefficient of variation. */\nexport type GrowthLms = readonly [l: number, m: number, s: number]\n/** LMS parameters by completed month, starting at `start` months. */\nexport type GrowthTable = { start: number; lms: readonly GrowthLms[] }\nexport type GrowthStandard = Readonly<Record<GrowthSex, GrowthTable>>\n/**\n * The reference tables the chart draws. You supply and license them: see the\n * docs for where to get WHO or CDC tables. `length` is recumbent\n * length, used below 24 months; `height` is standing height, used from 24.\n */\nexport type GrowthStandards = Readonly<{\n  weight?: GrowthStandard\n  length?: GrowthStandard\n  height?: GrowthStandard\n  head?: GrowthStandard\n}>\nexport type GrowthMeasurement = {\n  /** Age in months. Use `ageInMonths` to convert dates. */\n  ageMonths: number\n  /** Kilograms for weight, centimetres for length and head circumference. */\n  value: number\n}\nexport type GrowthChartProps = {\n  standards: GrowthStandards\n  measure: GrowthMeasure\n  sex: GrowthSex\n  measurements?: readonly GrowthMeasurement[]\n  /** Percentile curves to draw, between 0 and 100. Defaults to WHO's set. */\n  percentiles?: readonly number[]\n  /** Age range shown, in months. Defaults to 0 to 24. */\n  ageRange?: readonly [number, number]\n  label?: string\n  className?: string\n}\n\nexport const growthMeasures: Record<\n  GrowthMeasure,\n  { label: string; unit: string }\n> = {\n  weight: { label: \"Weight-for-age\", unit: \"kg\" },\n  length: { label: \"Length/height-for-age\", unit: \"cm\" },\n  head: { label: \"Head circumference-for-age\", unit: \"cm\" },\n}\nconst defaultPercentiles = [3, 15, 50, 85, 97]\nconst daysPerMonth = 30.4375\n\n/** Age in months between two dates, as the WHO standards count it. */\nexport function ageInMonths(birth: Date, measured: Date) {\n  return (measured.getTime() - birth.getTime()) / 86_400_000 / daysPerMonth\n}\n\nfunction table(\n  standards: GrowthStandards,\n  measure: GrowthMeasure,\n  ageMonths: number\n) {\n  if (measure !== \"length\") return standards[measure]\n  // Length is measured lying down before 24 months and height standing from\n  // 24, so WHO's curve steps down there by about 0.7 cm.\n  return ageMonths < 24\n    ? (standards.length ?? standards.height)\n    : (standards.height ?? standards.length)\n}\n\n/**\n * The LMS parameters at an age in months, interpolated linearly between\n * months. Returns null where the standards have no table or row for it.\n */\nexport function growthLms(\n  standards: GrowthStandards,\n  measure: GrowthMeasure,\n  sex: GrowthSex,\n  ageMonths: number\n): GrowthLms | null {\n  const found = table(standards, measure, ageMonths)?.[sex]\n  if (!found || found.lms.length < 2) return null\n  const position = ageMonths - found.start\n  if (!(position >= 0 && position <= found.lms.length - 1)) return null\n  const lower = Math.min(Math.floor(position), found.lms.length - 2)\n  const t = position - lower\n  const a = found.lms[lower]!\n  const b = found.lms[lower + 1]!\n  return [\n    a[0] + (b[0] - a[0]) * t,\n    a[1] + (b[1] - a[1]) * t,\n    a[2] + (b[2] - a[2]) * t,\n  ]\n}\n\nfunction lmsValue([l, m, s]: GrowthLms, z: number) {\n  return l === 0 ? m * Math.exp(s * z) : m * (1 + l * s * z) ** (1 / l)\n}\n\n/**\n * The z-score of a measurement. Weight beyond ±3 SD uses WHO's restricted\n * extrapolation, which keeps the skewed tail from compressing extreme values.\n * Returns null where the standards do not cover the age, or for a\n * non-positive value.\n */\nexport function growthZScore(\n  standards: GrowthStandards,\n  measure: GrowthMeasure,\n  sex: GrowthSex,\n  ageMonths: number,\n  value: number\n) {\n  const lms = growthLms(standards, measure, sex, ageMonths)\n  if (!lms || !(value > 0)) return null\n  const [l, m, s] = lms\n  const z = l === 0 ? Math.log(value / m) / s : ((value / m) ** l - 1) / (l * s)\n  if (measure !== \"weight\" || Math.abs(z) <= 3) return z\n  const sd3 = lmsValue(lms, 3 * Math.sign(z))\n  const sd2 = lmsValue(lms, 2 * Math.sign(z))\n  return 3 * Math.sign(z) + (value - sd3) / Math.abs(sd3 - sd2)\n}\n\n/** The standard normal cumulative distribution, as a percentile. */\nexport function zToPercentile(z: number) {\n  // Abramowitz and Stegun 7.1.26, accurate to 1.5e-7.\n  const x = Math.abs(z) / Math.SQRT2\n  const t = 1 / (1 + 0.3275911 * x)\n  const erf =\n    1 -\n    ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) *\n      t +\n      0.254829592) *\n      t *\n      Math.exp(-x * x)\n  return 50 * (1 + Math.sign(z) * erf)\n}\n\n/** The z-score of a percentile between 0 and 100, exclusive. */\nexport function percentileToZ(percentile: number) {\n  let low = -8\n  let high = 8\n  for (let i = 0; i < 60; i++) {\n    const mid = (low + high) / 2\n    if (zToPercentile(mid) < percentile) low = mid\n    else high = mid\n  }\n  return (low + high) / 2\n}\n\n/** The measurement at a percentile and age, or null where not covered. */\nexport function growthValueAt(\n  standards: GrowthStandards,\n  measure: GrowthMeasure,\n  sex: GrowthSex,\n  ageMonths: number,\n  percentile: number\n) {\n  const lms = growthLms(standards, measure, sex, ageMonths)\n  return lms ? lmsValue(lms, percentileToZ(percentile)) : null\n}\n\n/** Formats a percentile as \"34th\", \"<0.1st\" or \">99.9th\". */\nexport function formatPercentile(percentile: number) {\n  if (percentile < 0.1) return \"<0.1st\"\n  if (percentile > 99.9) return \">99.9th\"\n  const rounded =\n    percentile < 1 || percentile > 99\n      ? Math.round(percentile * 10) / 10\n      : Math.round(percentile)\n  const whole = Math.floor(rounded)\n  const suffix =\n    rounded !== whole || (whole % 100 >= 11 && whole % 100 <= 13)\n      ? \"th\"\n      : ([\"th\", \"st\", \"nd\", \"rd\"][whole % 10] ?? \"th\")\n  return `${rounded}${suffix}`\n}\n\nexport function formatAge(ageMonths: number) {\n  const months = Math.round(ageMonths * 10) / 10\n  if (months < 24) return `${months} ${months === 1 ? \"month\" : \"months\"}`\n  const years = Math.floor(months / 12)\n  const rest = Math.round((months - years * 12) * 10) / 10\n  return rest ? `${years} y ${rest} mo` : `${years} years`\n}\n\ntype AnthroRow = { sex: GrowthSex; age: number; loh: string; lms: GrowthLms }\n\nfunction anthroRows(text: string): AnthroRow[] {\n  const [header = \"\", ...lines] = text.trim().split(/\\r?\\n/)\n  const columns = header.split(\"\\t\")\n  const at = (cells: string[], name: string) => cells[columns.indexOf(name)]\n  return lines.map((line) => {\n    const cells = line.split(\"\\t\")\n    return {\n      sex: at(cells, \"sex\") === \"2\" ? \"female\" : \"male\",\n      age: Number(at(cells, \"age\")),\n      loh: at(cells, \"loh\") ?? \"\",\n      lms: [\n        Number(at(cells, \"l\")),\n        Number(at(cells, \"m\")),\n        Number(at(cells, \"s\")),\n      ],\n    }\n  })\n}\n\n// Samples daily rows at each month (30.4375 days), as WHO's monthly tables\n// do, extrapolating from the nearest two days at the ends of a segment.\nfunction monthly(rows: AnthroRow[], sex: GrowthSex, from: number): GrowthTable {\n  const byAge = new Map<number, GrowthLms>()\n  for (const row of rows) if (row.sex === sex) byAge.set(row.age, row.lms)\n  const ages = [...byAge.keys()]\n  const first = Math.min(...ages)\n  const last = Math.max(...ages)\n  const lms: GrowthLms[] = []\n  // Month 24 falls half a day past the last length row.\n  for (let month = from; month * daysPerMonth <= last + 0.5; month++) {\n    const day = month * daysPerMonth\n    const lower = Math.min(Math.max(Math.floor(day), first), last - 1)\n    const a = byAge.get(lower)\n    const b = byAge.get(lower + 1)\n    if (!a || !b) break\n    const t = day - lower\n    lms.push([\n      a[0] + (b[0] - a[0]) * t,\n      a[1] + (b[1] - a[1]) * t,\n      a[2] + (b[2] - a[2]) * t,\n    ])\n  }\n  return { start: from, lms }\n}\n\nfunction standard(rows: AnthroRow[], from = 0): GrowthStandard | undefined {\n  if (!rows.length) return undefined\n  return {\n    female: monthly(rows, \"female\", from),\n    male: monthly(rows, \"male\", from),\n  }\n}\n\n/**\n * Builds monthly standards from tables you supply in the layout of WHO's anthro\n * package: tab-separated sex (1 male, 2 female), age in days, l, m, s and, for\n * length, loh. The length table splits into `length` (rows marked L) and\n * `height` (rows marked H, from 24 months). No tables ship with this file.\n */\nexport function growthStandardsFromAnthro(tables: {\n  weight?: string\n  length?: string\n  head?: string\n}): GrowthStandards {\n  const length = tables.length ? anthroRows(tables.length) : []\n  return {\n    weight: tables.weight ? standard(anthroRows(tables.weight)) : undefined,\n    length: standard(length.filter(({ loh }) => loh === \"L\")),\n    height: standard(\n      length.filter(({ loh }) => loh === \"H\"),\n      24\n    ),\n    head: tables.head ? standard(anthroRows(tables.head)) : undefined,\n  }\n}\n\nconst number = new Intl.NumberFormat(\"en\", { maximumFractionDigits: 2 })\n\nfunction niceStep(span: number, count: number) {\n  const raw = span / count\n  const power = 10 ** Math.floor(Math.log10(raw))\n  const step = [1, 2, 5, 10].find((f) => f * power >= raw)!\n  return step * power\n}\n\nfunction useWidth(fallback: number) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const [width, setWidth] = React.useState(fallback)\n  React.useLayoutEffect(() => {\n    const element = ref.current\n    if (!element) return\n    const observer = new ResizeObserver(([entry]) => {\n      if (entry) setWidth(Math.round(entry.contentRect.width))\n    })\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [])\n  return [ref, width] as const\n}\n\nconst margin = { top: 12, right: 40, bottom: 40, left: 44 }\n\nexport function GrowthChart({\n  standards,\n  measure,\n  sex,\n  measurements = [],\n  percentiles = defaultPercentiles,\n  ageRange = [0, 24],\n  label,\n  className,\n}: GrowthChartProps) {\n  const id = React.useId()\n  const [ref, width] = useWidth(640)\n  const [active, setActive] = React.useState<number | null>(null)\n  const { unit } = growthMeasures[measure]\n  const title =\n    label ??\n    `${growthMeasures[measure].label}, ${sex === \"female\" ? \"girls\" : \"boys\"}`\n  const from = Math.max(0, ageRange[0])\n  const to = Math.max(from + 1, ageRange[1])\n  const height = Math.round(Math.min(Math.max(width * 0.62, 260), 420))\n  const plotWidth = Math.max(width - margin.left - margin.right, 1)\n  const plotHeight = height - margin.top - margin.bottom\n  const sorted = [...percentiles]\n    .filter((p) => p > 0 && p < 100)\n    .sort((a, b) => a - b)\n\n  // Length switches to height at 24 months, so each curve has a segment on\n  // either side of it.\n  const segments: [number, number][] =\n    measure !== \"length\" || from >= 24\n      ? [[from, to]]\n      : to > 24\n        ? [\n            [from, 24 - 1e-9],\n            [24, to],\n          ]\n        : // A range ending by 24 months stays on the length standard.\n          [[from, Math.min(to, 24 - 1e-9)]]\n  const samples = segments.map(([start, end]) => {\n    const ages = [start]\n    for (let age = Math.floor(start) + 1; age < end; age++) ages.push(age)\n    ages.push(end)\n    return ages\n  })\n  const curves = sorted.map((percentile) =>\n    samples\n      .map((ages) =>\n        ages.flatMap((age) => {\n          const value = growthValueAt(standards, measure, sex, age, percentile)\n          return value === null ? [] : [{ age, value }]\n        })\n      )\n      .filter((line) => line.length > 1)\n  )\n  const points = measurements\n    .map((measurement, index) => ({\n      ...measurement,\n      index,\n      z: growthZScore(\n        standards,\n        measure,\n        sex,\n        measurement.ageMonths,\n        measurement.value\n      ),\n    }))\n    .filter(\n      ({ ageMonths, z }) => z !== null && ageMonths >= from && ageMonths <= to\n    )\n    .sort((a, b) => a.ageMonths - b.ageMonths)\n\n  const values = [\n    ...curves.flat(2).map(({ value }) => value),\n    ...points.map(({ value }) => value),\n  ]\n  const low = Math.min(...values)\n  const high = Math.max(...values)\n  const step = niceStep(high - low, Math.max(3, Math.round(plotHeight / 48)))\n  const yMin = Math.floor((low - (high - low) * 0.04) / step) * step\n  const yMax = Math.ceil((high + (high - low) * 0.04) / step) * step\n  const x = (age: number) =>\n    margin.left + ((age - from) / (to - from)) * plotWidth\n  const y = (value: number) =>\n    margin.top + (1 - (value - yMin) / (yMax - yMin)) * plotHeight\n  // Months read best in steps that divide a year.\n  const xRaw = (to - from) / Math.max(2, Math.round(plotWidth / 56))\n  const xStep = [1, 2, 3, 6, 12].find((step) => step >= xRaw) ?? 12\n  const xTicks: number[] = []\n  for (let age = Math.ceil(from / xStep) * xStep; age <= to; age += xStep)\n    xTicks.push(age)\n  const yTicks: number[] = []\n  for (let value = yMin; value <= yMax + step / 2; value += step)\n    yTicks.push(Number(value.toFixed(6)))\n  const path = (line: { age: number; value: number }[]) =>\n    line\n      .map(\n        ({ age, value }, i) =>\n          `${i ? \"L\" : \"M\"}${x(age).toFixed(1)},${y(value).toFixed(1)}`\n      )\n      .join(\"\")\n  // Bands pair the outermost curves inwards, such as 3rd–97th and 15th–85th.\n  const bands = sorted.slice(0, Math.floor(sorted.length / 2)).map((_, i) =>\n    curves[i]!.map((lower, segment) => {\n      const upper = curves[sorted.length - 1 - i]![segment]\n      if (!upper) return \"\"\n      return `${path(lower)}${path([...upper].reverse()).replace(\"M\", \"L\")}Z`\n    })\n  )\n  const describe = (point: (typeof points)[number]) =>\n    `${formatAge(point.ageMonths)}: ${number.format(point.value)} ${unit}, ${formatPercentile(zToPercentile(point.z!))} percentile`\n  const current = points.find(({ index }) => index === active)\n  // Without curves there is nothing to read measurements against.\n  if (!curves[0]?.length)\n    return (\n      <figure className={cn(\"flex flex-col gap-3\", className)}>\n        <figcaption className=\"text-sm font-medium\">{title}</figcaption>\n        <p className=\"rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground\">\n          No growth standard covers this measure and age range.\n        </p>\n      </figure>\n    )\n\n  return (\n    <figure className={cn(\"flex flex-col gap-3\", className)}>\n      <figcaption id={`${id}-title`} className=\"text-sm font-medium\">\n        {title}\n      </figcaption>\n      <div ref={ref} className=\"relative w-full\">\n        <svg\n          role=\"group\"\n          aria-labelledby={`${id}-title`}\n          aria-describedby={`${id}-description`}\n          width={width}\n          height={height}\n          viewBox={`0 0 ${width} ${height}`}\n          className=\"block overflow-visible text-[11px] select-none\"\n          onPointerLeave={() => setActive(null)}\n        >\n          <desc id={`${id}-description`}>\n            Percentile curves from {formatAge(from)} to {formatAge(to)} with{\" \"}\n            {points.length}{\" \"}\n            {points.length === 1 ? \"measurement\" : \"measurements\"}. The table\n            after the chart lists every measurement.\n          </desc>\n          <g aria-hidden=\"true\">\n            {yTicks.map((value) => (\n              <g key={value}>\n                <line\n                  x1={margin.left}\n                  x2={margin.left + plotWidth}\n                  y1={y(value)}\n                  y2={y(value)}\n                  className=\"stroke-border\"\n                />\n                <text\n                  x={margin.left - 8}\n                  y={y(value)}\n                  dy=\"0.32em\"\n                  textAnchor=\"end\"\n                  className=\"fill-muted-foreground tabular-nums\"\n                >\n                  {number.format(value)}\n                </text>\n              </g>\n            ))}\n            {xTicks.map((age) => (\n              <g key={age}>\n                <line\n                  x1={x(age)}\n                  x2={x(age)}\n                  y1={margin.top}\n                  y2={margin.top + plotHeight}\n                  className=\"stroke-border opacity-60\"\n                />\n                <text\n                  x={x(age)}\n                  y={margin.top + plotHeight + 16}\n                  textAnchor=\"middle\"\n                  className=\"fill-muted-foreground tabular-nums\"\n                >\n                  {age}\n                </text>\n              </g>\n            ))}\n            <text\n              x={margin.left + plotWidth / 2}\n              y={height - 4}\n              textAnchor=\"middle\"\n              className=\"fill-muted-foreground\"\n            >\n              Age (months)\n            </text>\n            <text\n              transform={`translate(10 ${margin.top + plotHeight / 2}) rotate(-90)`}\n              textAnchor=\"middle\"\n              className=\"fill-muted-foreground\"\n            >\n              {unit}\n            </text>\n            {bands.flat().map((d, i) => (\n              <path key={i} d={d} className=\"fill-primary opacity-[0.07]\" />\n            ))}\n            {curves.map((curve, i) =>\n              curve.map((line, segment) => (\n                <path\n                  key={`${i}-${segment}`}\n                  d={path(line)}\n                  fill=\"none\"\n                  strokeWidth={sorted[i] === 50 ? 1.75 : 1}\n                  className=\"stroke-muted-foreground\"\n                />\n              ))\n            )}\n            {curves.map((curve, i) => {\n              const end = curve.at(-1)!.at(-1)!\n              return (\n                <text\n                  key={sorted[i]}\n                  x={margin.left + plotWidth + 6}\n                  y={y(end.value)}\n                  dy=\"0.32em\"\n                  className=\"fill-muted-foreground tabular-nums\"\n                >\n                  {formatPercentile(sorted[i]!)}\n                </text>\n              )\n            })}\n            {points.length > 1 && (\n              <path\n                d={path(\n                  points.map(({ ageMonths, value }) => ({\n                    age: ageMonths,\n                    value,\n                  }))\n                )}\n                fill=\"none\"\n                strokeWidth={2}\n                className=\"stroke-primary\"\n              />\n            )}\n            {current && (\n              <line\n                x1={x(current.ageMonths)}\n                x2={x(current.ageMonths)}\n                y1={margin.top}\n                y2={margin.top + plotHeight}\n                className=\"stroke-primary opacity-40\"\n                strokeDasharray=\"3 3\"\n              />\n            )}\n          </g>\n          {points.map((point) => (\n            <circle\n              key={point.index}\n              cx={x(point.ageMonths)}\n              cy={y(point.value)}\n              r={active === point.index ? 6 : 4.5}\n              tabIndex={0}\n              role=\"img\"\n              aria-label={describe(point)}\n              strokeWidth={2}\n              className=\"cursor-default fill-primary stroke-background outline-none focus-visible:stroke-ring focus-visible:stroke-[3]\"\n              onPointerEnter={() => setActive(point.index)}\n              onFocus={() => setActive(point.index)}\n              onBlur={() => setActive(null)}\n            />\n          ))}\n        </svg>\n        {current && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute z-10 w-max -translate-x-1/2 -translate-y-full rounded-md border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md\"\n            style={{\n              left: Math.min(\n                Math.max(x(current.ageMonths), 80),\n                Math.max(width - 80, 80)\n              ),\n              top: y(current.value) - 10,\n            }}\n          >\n            <p className=\"font-medium\">\n              {number.format(current.value)} {unit}\n            </p>\n            <p className=\"text-muted-foreground\">\n              {formatAge(current.ageMonths)} ·{\" \"}\n              {formatPercentile(zToPercentile(current.z!))} percentile\n            </p>\n            <p className=\"text-muted-foreground\">\n              z-score {current.z! >= 0 ? \"+\" : \"−\"}\n              {Math.abs(current.z!).toFixed(2)}\n            </p>\n          </div>\n        )}\n      </div>\n      <table className=\"sr-only\">\n        <caption>{title}: measurements</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">Age</th>\n            <th scope=\"col\">Value ({unit})</th>\n            <th scope=\"col\">Percentile</th>\n            <th scope=\"col\">Z-score</th>\n          </tr>\n        </thead>\n        <tbody>\n          {points.map((point) => (\n            <tr key={point.index}>\n              <td>{formatAge(point.ageMonths)}</td>\n              <td>{number.format(point.value)}</td>\n              <td>{formatPercentile(zToPercentile(point.z!))}</td>\n              <td>{point.z!.toFixed(2)}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </figure>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "Growth chart docs: https://organui.com/docs/components/growth-chart. The item ships no growth data and no loader: you pass the reference tables. The docs say where to get them and under what terms. The WHO Child Growth Standards are © World Health Organization, distributed in WHO's anthro package under GPL-3 (https://www.gnu.org/licenses/gpl-3.0.html), and WHO requires permission for commercial use (https://www.who.int/about/policies/publishing/copyright).",
  "categories": [
    "clinical"
  ],
  "type": "registry:ui"
}