{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spectrum-plot",
  "type": "registry:component",
  "title": "Spectrum plot",
  "description": "A supplied numeric spectrum with labelled axes, caller-provided peak annotations and a complete paginated data table.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/spectrum-plot.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { Button } from \"./button\";\n\n\nexport interface SpectrumPoint { x: number; y: number }\nexport interface SpectrumPeak extends SpectrumPoint { id: string; label: string }\nexport interface SpectrumPlotProps extends React.HTMLAttributes<HTMLElement> {\n  label: string;\n  /** Up to 4096 supplied points, connected in the supplied order without resampling. */\n  points: readonly SpectrumPoint[];\n  xLabel: string;\n  yLabel: string;\n  xUnit?: string;\n  yUnit?: string;\n  xDomain?: readonly [number, number];\n  yDomain?: readonly [number, number];\n  /** Up to 128 supplied annotations. The component never detects peaks. */\n  peaks?: readonly SpectrumPeak[];\n  description?: React.ReactNode;\n}\n\nconst styles = stylex.create({\n  root: { display: \"grid\", gap: \"0.75rem\", minWidth: 0, margin: 0, color: vlak.ink, lineHeight: 1.45, overflowWrap: \"anywhere\" },\n  label: { fontSize: \"0.875rem\", fontWeight: 600 },\n  description: { margin: 0, fontSize: \"0.75rem\", color: vlak.gray, maxWidth: \"66ch\" },\n  scroll: { overflowX: \"auto\", maxWidth: \"100%\", minWidth: 0, overscrollBehaviorX: \"contain\", outlineWidth: { default: null, \":focus-visible\": 2 }, outlineStyle: { default: null, \":focus-visible\": \"solid\" }, outlineColor: vlak.ink, outlineOffset: 2 },\n  svg: { display: \"block\", width: \"100%\", minWidth: \"24rem\", height: \"auto\", direction: \"ltr\", color: { default: vlak.ink, [mq.forcedColors]: \"CanvasText\" } },\n  axis: { fill: \"none\", stroke: \"currentColor\", strokeWidth: 1 },\n  grid: { fill: \"none\", stroke: vlak.divider, strokeWidth: 1 },\n  trace: { fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.5, strokeLinejoin: \"round\" },\n  point: { fill: \"currentColor\", stroke: \"currentColor\", strokeWidth: 1 },\n  annotation: { fill: \"currentColor\", fontSize: 20, fontFamily: \"inherit\" },\n  ticks: { fill: \"currentColor\", fontSize: 20, fontVariantNumeric: \"tabular-nums\", fontFamily: \"inherit\" },\n  xLabel: { margin: 0, textAlign: \"center\", fontSize: \"0.75rem\" },\n  peaks: { margin: 0, paddingInlineStart: \"1.5rem\", fontSize: \"0.75rem\" },\n  summary: { minHeight: vlak.hit, minWidth: vlak.hit, display: \"list-item\", alignContent: \"center\", cursor: \"pointer\", fontSize: \"0.875rem\", outlineWidth: { default: null, \":focus-visible\": 2 }, outlineStyle: { default: null, \":focus-visible\": \"solid\" }, outlineColor: vlak.ink, outlineOffset: 2 },\n  table: { width: \"100%\", borderCollapse: \"collapse\", fontSize: \"0.75rem\", fontVariantNumeric: \"tabular-nums\" },\n  cell: { padding: \"0.5rem\", textAlign: \"start\", borderBottomWidth: vlak.hairline, borderBottomStyle: \"solid\", borderBottomColor: vlak.divider, fontWeight: 400 },\n  controls: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: \"0.5rem\", paddingBlock: \"0.5rem\", fontSize: \"0.75rem\" },\n  button: { flexShrink: 0 },\n});\n\nfunction domainFor(values: readonly number[], supplied?: readonly [number, number]): readonly [number, number] {\n  if (supplied) return supplied;\n  let minimum = values[0] ?? 0;\n  let maximum = minimum;\n  for (const value of values) { minimum = Math.min(minimum, value); maximum = Math.max(maximum, value); }\n  if (minimum !== maximum) return [minimum, maximum];\n  const pad = Math.abs(minimum) * 0.05 || 1;\n  const lower = minimum - pad;\n  const upper = maximum + pad;\n  return [Number.isFinite(lower) ? lower : minimum, Number.isFinite(upper) ? upper : maximum];\n}\nfunction fraction(value: number, domain: readonly [number, number]) {\n  const scale = Math.max(Math.abs(domain[0]), Math.abs(domain[1]), 1);\n  return (value / scale - domain[0] / scale) / (domain[1] / scale - domain[0] / scale);\n}\nconst finitePoint = (point: SpectrumPoint) => Number.isFinite(point.x) && Number.isFinite(point.y);\n\n/** A supplied spectrum and peak annotations, with a paginated complete data table. */\nexport const SpectrumPlot = React.forwardRef<HTMLElement, SpectrumPlotProps>(function SpectrumPlot({ label, points, xLabel, yLabel, xUnit, yUnit, xDomain, yDomain, peaks = [], description, className, style, children, ...props }, ref) {\n  const [page, setPage] = React.useState(0);\n  const bounded = points.length <= 4096 && peaks.length <= 128;\n  const finite = bounded && points.every(finitePoint) && peaks.every(finitePoint);\n  const horizontal = domainFor(finite ? points.map(point => point.x) : [], xDomain);\n  const vertical = domainFor(finite ? points.map(point => point.y) : [], yDomain);\n  const validDomain = [horizontal, vertical].every(domain => Number.isFinite(domain[0]) && Number.isFinite(domain[1]) && domain[1] > domain[0]);\n  const inside = (point: SpectrumPoint) => point.x >= horizontal[0] && point.x <= horizontal[1] && point.y >= vertical[0] && point.y <= vertical[1];\n  const problem = !bounded ? \"Spectrum unavailable: supply at most 4096 points and 128 annotations\" : !points.length ? \"No spectrum points supplied\" : !finite ? \"Spectrum unavailable: a point or annotation is non-finite\" : !validDomain ? \"Spectrum unavailable: axis bounds must be finite and increasing\" : !points.every(inside) || !peaks.every(inside) ? \"Spectrum unavailable: data or annotations fall outside the axis bounds\" : null;\n  const x = (value: number) => 128 + fraction(value, horizontal) * 484;\n  const y = (value: number) => 216 - fraction(value, vertical) * 184;\n  const axisName = (name: string, unit?: string) => unit ? `${name} (${unit})` : name;\n  const xTitle = axisName(xLabel, xUnit);\n  const yTitle = axisName(yLabel, yUnit);\n  const tickFractions = Math.max(horizontal[0].toPrecision(4).length, horizontal[1].toPrecision(4).length) > 7 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];\n  const pageCount = Math.ceil(points.length / 50);\n  const currentPage = Math.min(page, Math.max(0, pageCount - 1));\n  const visible = bounded ? points.slice(currentPage * 50, (currentPage + 1) * 50) : [];\n  const root = rs([\"rs-spectrum-plot\", className], styles.root);\n  const heading = rs([\"rs-spectrum-plot-label\"], styles.label);\n  const copy = rs([\"rs-spectrum-plot-description\"], styles.description);\n  const scroll = rs([\"rs-spectrum-plot-scroll\"], styles.scroll);\n  const svg = rs([\"rs-spectrum-plot-svg\"], styles.svg);\n  const axis = rs([\"rs-spectrum-plot-axis\"], styles.axis);\n  const grid = rs([\"rs-spectrum-plot-grid\"], styles.grid);\n  const trace = rs([\"rs-spectrum-plot-trace\"], styles.trace);\n  const pointStyle = rs([\"rs-spectrum-plot-point\"], styles.point);\n  const annotation = rs([\"rs-spectrum-plot-annotation\"], styles.annotation);\n  const ticks = rs([\"rs-spectrum-plot-ticks\"], styles.ticks);\n  const xLabelStyle = rs([\"rs-spectrum-plot-x-label\"], styles.xLabel);\n  const peakList = rs([\"rs-spectrum-plot-peaks\"], styles.peaks);\n  const summary = rs([\"rs-spectrum-plot-summary\"], styles.summary);\n  const table = rs([\"rs-spectrum-plot-table\"], styles.table);\n  const cell = rs([\"rs-spectrum-plot-cell\"], styles.cell);\n  const controls = rs([\"rs-spectrum-plot-controls\"], styles.controls);\n  const button = rs([\"rs-spectrum-plot-button\"], styles.button);\n  return <figure {...props} ref={ref} className={root.className} style={{ ...root.style, ...style }}>\n    <figcaption {...heading}>{label}</figcaption>\n    {description != null && <div {...copy}>{description}</div>}\n    {problem ? <p {...copy}>{problem}</p> : <>\n      <p {...copy}>{yTitle}</p>\n      <div {...scroll} role=\"region\" aria-label={`${label} plot`} tabIndex={0}><svg {...svg} viewBox=\"0 0 640 272\" aria-hidden=\"true\" focusable=\"false\">\n        <path {...axis} d=\"M128 32V216H612\" />\n        {tickFractions.map((ratio, index) => {\n          const xv = horizontal[0] * (1 - ratio) + horizontal[1] * ratio;\n          const yv = vertical[0] * (1 - ratio) + vertical[1] * ratio;\n          return <g key={index}><path {...grid} d={`M128 ${216 - ratio * 184}H612`} /><text {...ticks} x={128 + ratio * 484} y={244} textAnchor={ratio === 0 ? \"start\" : ratio === 1 ? \"end\" : \"middle\"}>{xv.toPrecision(4)}</text><text {...ticks} x={118} y={223 - ratio * 184} textAnchor=\"end\">{yv.toPrecision(4)}</text></g>;\n        })}\n        {points.length === 1 ? <circle {...pointStyle} cx={x(points[0]!.x)} cy={y(points[0]!.y)} r={3} /> : <polyline {...trace} points={points.map(point => `${x(point.x)},${y(point.y)}`).join(\" \")} />}\n        {peaks.map((peak, index) => <g key={peak.id}><circle {...pointStyle} cx={x(peak.x)} cy={y(peak.y)} r={3} /><text {...annotation} x={x(peak.x)} y={y(peak.y) - 8} textAnchor=\"middle\">{index + 1}</text></g>)}\n      </svg></div><p {...xLabelStyle}>{xTitle}</p>\n    </>}\n    {bounded && peaks.length > 0 && <ol {...peakList} aria-label=\"Supplied peak annotations\">{peaks.map(peak => <li key={peak.id}>{peak.label}: {xTitle} {Number.isFinite(peak.x) ? peak.x : \"Unavailable\"}; {yTitle} {Number.isFinite(peak.y) ? peak.y : \"Unavailable\"}</li>)}</ol>}\n    {bounded && points.length > 0 && <details><summary {...summary}>Data table, {points.length} points</summary>\n      <div {...controls}><Button variant=\"ghost\" size=\"sm\" {...button} type=\"button\" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous rows</Button><span role=\"status\">Rows {currentPage * 50 + 1}–{Math.min(points.length, (currentPage + 1) * 50)} of {points.length}</span><Button variant=\"ghost\" size=\"sm\" {...button} type=\"button\" disabled={currentPage + 1 >= pageCount} onClick={() => setPage(currentPage + 1)}>Next rows</Button></div>\n      <div {...scroll}><table {...table}><caption>{label}, supplied data</caption><thead><tr><th {...cell} scope=\"col\">Point</th><th {...cell} scope=\"col\">{xTitle}</th><th {...cell} scope=\"col\">{yTitle}</th></tr></thead><tbody>{visible.map((point, index) => <tr key={currentPage * 50 + index}><th {...cell} scope=\"row\">{currentPage * 50 + index + 1}</th><td {...cell}>{Number.isFinite(point.x) ? point.x : \"Unavailable\"}</td><td {...cell}>{Number.isFinite(point.y) ? point.y : \"Unavailable\"}</td></tr>)}</tbody></table></div>\n    </details>}\n    {children}\n  </figure>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/spectrum-plot.tsx"
    },
    {
      "path": "vlak/styles/spectrum-plot.css",
      "content": "/* ── spectrum-plot: generated from packages/react/src/components/spectrum-plot.tsx ── */\n.rs-spectrum-plot{display:grid;gap:0.75rem;min-width:0;margin:0;color:var(--text);line-height:1.45;overflow-wrap:anywhere}\n.rs-spectrum-plot-label{font-size:0.875rem;font-weight:600}\n.rs-spectrum-plot-description{margin:0;font-size:0.75rem;color:var(--text-secondary);max-width:66ch}\n.rs-spectrum-plot-scroll{overflow-x:auto;max-width:100%;min-width:0;overscroll-behavior-x:contain;outline-color:var(--text);outline-offset:2px}\n.rs-spectrum-plot-scroll:focus-visible{outline-width:2px;outline-style:solid}\n.rs-spectrum-plot-svg{display:block;width:100%;min-width:24rem;height:auto;direction:ltr;color:var(--text)}\n@media (forced-colors: active){.rs-spectrum-plot-svg{color:CanvasText}}\n.rs-spectrum-plot-axis{fill:none;stroke:currentColor;stroke-width:1}\n.rs-spectrum-plot-grid{fill:none;stroke:var(--divider);stroke-width:1}\n.rs-spectrum-plot-trace{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round}\n.rs-spectrum-plot-point{fill:currentColor;stroke:currentColor;stroke-width:1}\n.rs-spectrum-plot-annotation{fill:currentColor;font-size:20px;font-family:inherit}\n.rs-spectrum-plot-ticks{fill:currentColor;font-size:20px;font-variant-numeric:tabular-nums;font-family:inherit}\n.rs-spectrum-plot-x-label{margin:0;text-align:center;font-size:0.75rem}\n.rs-spectrum-plot-peaks{margin:0;padding-inline-start:1.5rem;font-size:0.75rem}\n.rs-spectrum-plot-summary{min-height:var(--hit);min-width:var(--hit);display:list-item;align-content:center;cursor:pointer;font-size:0.875rem;outline-color:var(--text);outline-offset:2px}\n.rs-spectrum-plot-summary:focus-visible{outline-width:2px;outline-style:solid}\n.rs-spectrum-plot-table{width:100%;border-collapse:collapse;font-size:0.75rem;font-variant-numeric:tabular-nums}\n.rs-spectrum-plot-cell{padding:0.5rem;text-align:start;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:var(--divider);font-weight:400}\n.rs-spectrum-plot-controls{display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;padding-block:0.5rem;font-size:0.75rem}\n.rs-spectrum-plot-button{flex-shrink:0}\n",
      "type": "registry:file",
      "target": "styles/vlak/spectrum-plot.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "science",
      "classes": [
        "rs-spectrum-plot",
        "rs-spectrum-plot-label",
        "rs-spectrum-plot-description",
        "rs-spectrum-plot-scroll",
        "rs-spectrum-plot-svg",
        "rs-spectrum-plot-axis",
        "rs-spectrum-plot-grid",
        "rs-spectrum-plot-trace",
        "rs-spectrum-plot-point",
        "rs-spectrum-plot-annotation",
        "rs-spectrum-plot-ticks",
        "rs-spectrum-plot-x-label",
        "rs-spectrum-plot-peaks",
        "rs-spectrum-plot-summary",
        "rs-spectrum-plot-table",
        "rs-spectrum-plot-cell",
        "rs-spectrum-plot-controls",
        "rs-spectrum-plot-button"
      ],
      "snippet": "<figure class=\"rs-spectrum-plot\"><figcaption class=\"rs-spectrum-plot-label\">Supplied spectrum</figcaption><table class=\"rs-spectrum-plot-table\"><caption>Supplied numeric data</caption><thead><tr><th class=\"rs-spectrum-plot-cell\" scope=\"col\">Wavelength (nm)</th><th class=\"rs-spectrum-plot-cell\" scope=\"col\">Signal (counts)</th></tr></thead><tbody><tr><td class=\"rs-spectrum-plot-cell\">400</td><td class=\"rs-spectrum-plot-cell\">1</td></tr><tr><td class=\"rs-spectrum-plot-cell\">500</td><td class=\"rs-spectrum-plot-cell\">15</td></tr></tbody></table></figure>",
      "cssOnly": false,
      "registryDependencies": [
        "button",
        "vlak-lib"
      ],
      "aliases": [
        "Spectral plot",
        "Spectroscopy chart",
        "Frequency spectrum",
        "Signal spectrum",
        "Peak annotations"
      ],
      "example": "import { SpectrumPlot } from \"@noorddev/vlak-react\";\n\n<SpectrumPlot label=\"Supplied spectrum\" description=\"Synthetic example data\" xLabel=\"Wavelength\" xUnit=\"nm\" yLabel=\"Signal\" yUnit=\"counts\" points={[{ x: 400, y: 1 }, { x: 440, y: 2 }, { x: 480, y: 9 }, { x: 500, y: 15 }, { x: 520, y: 4 }, { x: 560, y: 2 }, { x: 600, y: 1 }]} peaks={[{ id: \"annotation\", label: \"Marked feature\", x: 500, y: 15 }]} />",
      "usage": {
        "use": [
          "Display supplied x/y spectra in acquisition order without smoothing, peak detection or resampling.",
          "Supply axis labels and units; optional finite increasing domains must contain every point and annotation.",
          "Up to 4096 points and 128 annotations; larger inputs receive a visible limit message without partial plotting.",
          "Use the 50-row data pages to inspect every supplied point at its original numeric precision."
        ],
        "avoid": [
          "Interpreting a marked feature as an identified substance or validated scientific finding.",
          "Using rounded axis tick labels as the original data, or relying on this display for signal processing."
        ]
      },
      "keyboard": [
        {
          "keys": "Tab",
          "does": "Focuses the plot's scroll region, native data disclosure and enabled pagination controls when open."
        },
        {
          "keys": "Arrow keys",
          "does": "Scrolls the focused plot region when it exceeds the available width."
        },
        {
          "keys": "Enter, Space",
          "does": "Toggles the focused disclosure or activates the focused page button."
        }
      ],
      "a11y": [
        "A figure caption names the spectrum. The decorative chart is hidden from assistive technology; exact point values remain in a captioned table.",
        "Every supplied peak has a numbered marker and a visible textual coordinate description; no peaks are inferred.",
        "Empty, non-finite or out-of-domain data is explicitly reported. Invalid rows remain visible in the table as unavailable values.",
        "A single point has a visible marker, and constant axes receive display padding without changing the data.",
        "Pagination bounds table rendering to 50 rows. Controls have 44px targets, visible focus and a polite row-range announcement.",
        "The figure ref and native attributes pass through; large data is rejected before scanning or rendering it."
      ]
    }
  }
}
