{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ecg-trace",
  "title": "ECG trace",
  "description": "A recorded or live ECG rhythm strip on standard paper, with labelled P, QRS and T waves. Not for diagnostic use.",
  "dependencies": [
    "@base-ui/react@^1.8.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.5.0"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/base-nova/ecg-trace/ecg-trace.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\n\nimport { Button } from \"@/components/ui/button\"\n\nexport type EcgWave = \"P\" | \"QRS\" | \"T\"\n/** A labelled wave, at its peak, in seconds from the first sample. */\nexport type EcgAnnotation = { wave: EcgWave; time: number }\n/** Live samples in millivolts, delivered in chunks as they arrive. */\nexport type EcgStream = {\n  subscribe(listener: (samples: readonly number[]) => void): () => void\n}\nexport type EcgTraceProps = {\n  /** A recorded trace in millivolts. Ignored when `stream` is set. */\n  samples?: readonly number[]\n  /** A live source, drawn as a monitor sweep. */\n  stream?: EcgStream\n  /** Samples per second. Defaults to 250. */\n  sampleRate?: number\n  /**\n   * Seconds shown across the strip. Defaults to the whole recording, or 6\n   * seconds for a stream.\n   */\n  duration?: number\n  /** Paper speed in mm/s. Defaults to the standard 25. */\n  speed?: number\n  /** Gain in mm/mV. Defaults to the standard 10. */\n  gain?: number\n  /** Lowest and highest millivolts shown. Defaults to -1.5 to 2. */\n  range?: readonly [number, number]\n  /** The lead name shown in the caption. Defaults to II. */\n  lead?: string\n  /**\n   * Wave labels. Detected from the signal unless given; pass false to hide\n   * them. Streams are always detected.\n   */\n  annotations?: readonly EcgAnnotation[] | false\n  /** Draws the 1 mV calibration pulse before the trace. Defaults to true. */\n  calibration?: boolean\n  /** The accessible name of the strip. Defaults to \"ECG\". */\n  label?: string\n  className?: string\n}\n\nconst calibrationWidth = 10 // mm, two large boxes\nconst sweepGap = 0.2 // seconds blanked ahead of a live write head\nconst minPxPerMm = 3 // below this the strip scrolls instead of shrinking\n\n// A regular synthetic beat: P, Q, R, S and T as Gaussians in mV, with times\n// in seconds from the R peak. T moves with the RR interval, as QT does.\nfunction beat(offset: number, rr: number) {\n  const waves = [\n    [0.15, -0.16, 0.025],\n    [-0.12, -0.025, 0.01],\n    [1.2, 0, 0.012],\n    [-0.3, 0.03, 0.012],\n    [0.32, 0.3 * Math.sqrt(rr), 0.045],\n  ] as const\n  let value = 0\n  for (const [amplitude, center, width] of waves)\n    value += amplitude * Math.exp(-((offset - center) ** 2) / (2 * width ** 2))\n  return value\n}\n\n/** The synthetic rhythm in mV at `time` seconds. The first R peak is at 0.3 s. */\nexport function syntheticEcgValue(time: number, heartRate = 72) {\n  const rr = 60 / heartRate\n  const index = Math.round((time - 0.3) / rr)\n  let value = 0\n  for (let k = index - 1; k <= index + 1; k++)\n    if (k >= 0) value += beat(time - 0.3 - k * rr, rr)\n  return value\n}\n\n/**\n * An original, synthetic sinus rhythm for demos and tests. It is not a\n * recording and models no pathology.\n */\nexport function syntheticEcg({\n  heartRate = 72,\n  duration = 10,\n  sampleRate = 250,\n}: { heartRate?: number; duration?: number; sampleRate?: number } = {}) {\n  return Array.from({ length: Math.round(duration * sampleRate) }, (_, i) =>\n    syntheticEcgValue(i / sampleRate, heartRate)\n  )\n}\n\n/**\n * A live stream of the synthetic rhythm, paced by the clock. Each subscriber\n * gets its own trace starting at time 0.\n */\nexport function syntheticEcgStream({\n  heartRate = 72,\n  sampleRate = 250,\n  interval = 40,\n}: {\n  heartRate?: number\n  sampleRate?: number\n  /** Milliseconds between chunks. */\n  interval?: number\n} = {}): EcgStream {\n  return {\n    subscribe(listener) {\n      const start = Date.now()\n      let sent = 0\n      const timer = setInterval(() => {\n        const due = Math.floor(((Date.now() - start) / 1000) * sampleRate)\n        if (due <= sent) return\n        const chunk: number[] = []\n        for (; sent < due; sent++)\n          chunk.push(syntheticEcgValue(sent / sampleRate, heartRate))\n        listener(chunk)\n      }, interval)\n      return () => clearInterval(timer)\n    },\n  }\n}\n\nfunction median(values: readonly number[]) {\n  if (!values.length) return 0\n  const sorted = [...values].sort((a, b) => a - b)\n  const middle = sorted.length >> 1\n  return sorted.length % 2\n    ? sorted[middle]!\n    : (sorted[middle - 1]! + sorted[middle]!) / 2\n}\n\n// The index of the largest deviation from baseline in [from, to).\nfunction peak(\n  samples: readonly number[],\n  baseline: number,\n  from: number,\n  to: number\n) {\n  let best = -1\n  let size = 0\n  for (let i = Math.max(0, from); i < Math.min(samples.length, to); i++) {\n    const deviation = Math.abs(samples[i]! - baseline)\n    if (deviation > size) [best, size] = [i, deviation]\n  }\n  return { index: best, size }\n}\n\n/**\n * Finds P, QRS and T peaks with a simple slope and amplitude heuristic tuned\n * for clean sinus rhythms. It is a labelling aid, not a clinical detector:\n * pass your own annotations for real recordings.\n */\nexport function detectEcgWaves(\n  samples: readonly number[],\n  sampleRate = 250\n): EcgAnnotation[] {\n  const at = (seconds: number) => Math.round(seconds * sampleRate)\n  const baseline = median(samples)\n  // QRS complexes are where the signal is steepest.\n  const slope = samples.map((_, i) =>\n    i && i < samples.length - 1 ? (samples[i + 1]! - samples[i - 1]!) ** 2 : 0\n  )\n  const threshold = 0.3 * slope.reduce((a, b) => Math.max(a, b), 0)\n  const peaks: number[] = []\n  for (let i = 0; i < samples.length; i++) {\n    if (slope[i]! <= threshold || !threshold) continue\n    const r = peak(samples, baseline, i - at(0.05), i + at(0.05))\n    if (r.size < 0.25) continue\n    const last = peaks.at(-1)\n    if (last !== undefined && r.index - last < at(0.25)) {\n      if (r.size > Math.abs(samples[last]! - baseline))\n        peaks[peaks.length - 1] = r.index\n    } else peaks.push(r.index)\n    i += at(0.05)\n  }\n  const annotations: EcgAnnotation[] = []\n  // Where the previous beat's T wave ends; a P wave starts after it.\n  let previous = -Infinity\n  peaks.forEach((r, n) => {\n    // Skip complexes cut off by either end of the data.\n    if (r < at(0.05) || r + at(0.08) >= samples.length) return\n    const start = Math.max(r - at(0.3), previous)\n    const p = peak(samples, baseline, start, r - at(0.08))\n    if (r - at(0.3) >= 0 && p.size > 0.05)\n      annotations.push({ wave: \"P\", time: p.index / sampleRate })\n    annotations.push({ wave: \"QRS\", time: r / sampleRate })\n    const end = Math.min(r + at(0.5), (peaks[n + 1] ?? Infinity) - at(0.2))\n    const t = peak(samples, baseline, r + at(0.12), end)\n    if (end <= samples.length && t.size > 0.05) {\n      annotations.push({ wave: \"T\", time: t.index / sampleRate })\n      previous = t.index + at(0.1)\n    } else previous = r + at(0.12)\n  })\n  return annotations\n}\n\n/** The median heart rate in beats per minute, or null under two beats. */\nexport function ecgHeartRate(annotations: readonly EcgAnnotation[]) {\n  const beats = annotations\n    .filter(({ wave }) => wave === \"QRS\")\n    .map(({ time }) => time)\n    .sort((a, b) => a - b)\n  const intervals = beats.slice(1).map((time, i) => time - beats[i]!)\n  return intervals.length ? Math.round(60 / median(intervals)) : null\n}\n\n// Grid lines as one path each: every 1 mm and every 5 mm. ECG paper is red;\n// set --ecg-grid in your theme to change it without editing this file.\nfunction gridPath(width: number, height: number, step: number) {\n  let d = \"\"\n  for (let x = 0; x <= width + 1e-6; x += step) d += `M${x} 0V${height}`\n  for (let y = 0; y <= height + 1e-6; y += step) d += `M0 ${y}H${width}`\n  return d\n}\n\ntype Point = { position: number; value: number } | null\n\nfunction tracePath(\n  points: readonly Point[],\n  x: (position: number) => number,\n  y: (value: number) => number\n) {\n  let d = \"\"\n  let pen = false\n  for (const point of points) {\n    if (!point || !Number.isFinite(point.value)) {\n      pen = false\n      continue\n    }\n    d += `${pen ? \"L\" : \"M\"}${x(point.position).toFixed(2)} ${y(point.value).toFixed(2)}`\n    pen = true\n  }\n  return d\n}\n\n// A live sweep: a fixed window that the write head overwrites in place.\ntype Sweep = { values: Float32Array; head: number; stream: EcgStream }\n\nexport function EcgTrace({\n  samples,\n  stream,\n  sampleRate = 250,\n  duration,\n  speed = 25,\n  gain = 10,\n  range = [-1.5, 2],\n  lead = \"II\",\n  annotations,\n  calibration = true,\n  label = \"ECG\",\n  className,\n}: EcgTraceProps) {\n  const seconds =\n    duration ?? (stream ? 6 : (samples?.length ?? 0) / sampleRate || 10)\n  const length = Math.max(1, Math.round(seconds * sampleRate))\n  const [paused, setPaused] = React.useState(false)\n  const [live, setLive] = React.useState<{\n    values: Float32Array\n    head: number\n  } | null>(null)\n  const sweep = React.useRef<Sweep | null>(null)\n\n  React.useEffect(() => {\n    if (!stream) return\n    if (\n      sweep.current?.stream !== stream ||\n      sweep.current.values.length !== length\n    )\n      sweep.current = {\n        values: new Float32Array(length).fill(NaN),\n        head: 0,\n        stream,\n      }\n    if (paused) return\n    const current = sweep.current\n    let frame = 0\n    const unsubscribe = stream.subscribe((chunk) => {\n      for (const value of chunk)\n        current.values[current.head++ % current.values.length] = value\n      frame ||= requestAnimationFrame(() => {\n        frame = 0\n        setLive({ values: current.values.slice(), head: current.head })\n      })\n    })\n    return () => {\n      unsubscribe()\n      cancelAnimationFrame(frame)\n    }\n  }, [stream, length, paused])\n\n  const width = (calibration ? calibrationWidth : 0) + seconds * speed\n  const height = (range[1] - range[0]) * gain\n  // Strip positions are sample indexes; x and y are millimetres.\n  const x = (position: number) =>\n    (calibration ? calibrationWidth : 0) + (position / sampleRate) * speed\n  const y = (value: number) =>\n    Math.min(height, Math.max(0, (range[1] - value) * gain))\n\n  // Time-ordered data, and where each sample sits on the strip in samples.\n  const { history, points, place } = React.useMemo(() => {\n    if (!stream) {\n      const history = (samples ?? []).slice(0, length)\n      return {\n        history,\n        points: history.map((value, position) => ({ position, value })),\n        place: (index: number) => index,\n      }\n    }\n    if (!live) return { history: [], points: [], place: (i: number) => i }\n    const { values, head } = live\n    const gap = Math.min(values.length - 1, Math.round(sweepGap * sampleRate))\n    const kept = Math.min(head, values.length - gap)\n    const first = head - kept\n    const history = Array.from(\n      { length: kept },\n      (_, i) => values[(first + i) % values.length]!\n    )\n    const points: Point[] = Array.from(values, (value, position) => {\n      // Positions just ahead of the head are blank, like a monitor's erase bar.\n      const ahead =\n        (position - (head % values.length) + values.length) % values.length\n      return ahead < gap || Number.isNaN(value) ? null : { position, value }\n    })\n    return {\n      history,\n      points,\n      place: (index: number) => (first + index) % values.length,\n    }\n  }, [stream, live, samples, length, sampleRate])\n\n  const detected = React.useMemo(\n    () =>\n      stream || annotations === undefined\n        ? detectEcgWaves(history, sampleRate)\n        : annotations || [],\n    [stream, annotations, history, sampleRate]\n  )\n  const rate = ecgHeartRate(detected)\n  const baseline = median(history)\n  const labels =\n    annotations === false\n      ? []\n      : detected.flatMap(({ wave, time }) => {\n          const index = Math.round(time * sampleRate)\n          const value = history[index]\n          if (value === undefined || index >= length) return []\n          const position = place(index)\n          const above = value >= baseline\n          return [\n            {\n              key: `${wave}-${position}`,\n              wave,\n              x: x(position),\n              y: above\n                ? Math.max(4, y(value) - 1.5)\n                : Math.min(height - 1, y(value) + 4),\n            },\n          ]\n        })\n\n  const summary = `${label}, lead ${lead}, ${Math.round(seconds * 10) / 10} seconds at ${speed} mm/s and ${gain} mm/mV, ${\n    rate === null ? \"heart rate not measured\" : `about ${rate} beats per minute`\n  }${stream ? (paused ? \", paused\" : \", live\") : \"\"}`\n\n  return (\n    <figure\n      data-slot=\"ecg-trace\"\n      className={cn(\"flex min-w-0 flex-col gap-2\", className)}\n    >\n      <figcaption className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-sm\">\n        <span>\n          <span className=\"font-medium\">Lead {lead}</span>\n          <span className=\"text-muted-foreground\">\n            {\" \"}\n            · {speed} mm/s · {gain} mm/mV\n          </span>\n        </span>\n        <span className=\"flex items-center gap-3\">\n          <span className=\"tabular-nums\">\n            HR {rate ?? \"--\"}\n            <span className=\"text-muted-foreground\"> bpm</span>\n          </span>\n          {stream && (\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={() => setPaused((value) => !value)}\n            >\n              {paused ? \"Resume\" : \"Pause\"}\n            </Button>\n          )}\n        </span>\n      </figcaption>\n      <div\n        role=\"region\"\n        aria-label={`${label} strip`}\n        tabIndex={0}\n        className=\"overflow-x-auto rounded-lg border bg-background outline-none focus-visible:ring-3 focus-visible:ring-ring/50\"\n      >\n        <svg\n          role=\"img\"\n          aria-label={summary}\n          viewBox={`0 0 ${width} ${height}`}\n          className=\"block h-auto w-full\"\n          style={{ minWidth: width * minPxPerMm }}\n        >\n          <path\n            d={gridPath(width, height, 1)}\n            data-slot=\"ecg-trace-grid\"\n            className=\"stroke-[color:var(--ecg-grid,var(--color-red-500))]/15\"\n            strokeWidth={0.5}\n            vectorEffect=\"non-scaling-stroke\"\n          />\n          <path\n            d={gridPath(width, height, 5)}\n            data-slot=\"ecg-trace-grid\"\n            className=\"stroke-[color:var(--ecg-grid,var(--color-red-500))]/35\"\n            strokeWidth={1}\n            vectorEffect=\"non-scaling-stroke\"\n          />\n          {calibration && (\n            <path\n              d={`M1 ${y(0)}H3V${y(1)}H8V${y(0)}H9`}\n              fill=\"none\"\n              className=\"stroke-foreground\"\n              strokeWidth={1.5}\n              strokeLinejoin=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          )}\n          <path\n            data-slot=\"ecg-trace-line\"\n            d={tracePath(points, x, y)}\n            fill=\"none\"\n            className=\"stroke-foreground\"\n            strokeWidth={1.5}\n            strokeLinejoin=\"round\"\n            strokeLinecap=\"round\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n          {labels.map(({ key, wave, x, y }) => (\n            <text\n              key={key}\n              x={x}\n              y={y}\n              textAnchor=\"middle\"\n              fontSize={3}\n              className=\"fill-muted-foreground font-medium\"\n              aria-hidden=\"true\"\n            >\n              {wave}\n            </text>\n          ))}\n        </svg>\n      </div>\n      <p className=\"text-xs text-muted-foreground\">Not for diagnostic use.</p>\n    </figure>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "ECG trace docs: https://organui.com/docs/components/ecg-trace",
  "categories": [
    "clinical"
  ],
  "type": "registry:ui"
}