{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sparkline","title":"Sparkline","description":"Show the trend of one or several measures in a small chart without axes. Search terms: spark chart, spark line, spark area, mini chart, inline chart, metric.","registryDependencies":["@siteplane/base","@siteplane/chart","@siteplane/motion"],"files":[{"path":"packages/ui/src/components/sparkline/sparkline.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { useReducedMotionPreference } from \"../../system/motion/use-reduced-motion-preference\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport {\n  areaGradientStops,\n  naturalPath,\n  morphPoints,\n  morphSamples,\n  sampleCurve,\n  type ChartPoint,\n} from \"../chart/chart-curve\";\nimport { valueExtent } from \"../chart/chart-scales\";\nimport { useTweenedValues } from \"../motion\";\n\nexport type SparklineVariant = \"line\" | \"area\" | \"fill\";\n\nexport type SparklineProps = Omit<\n  React.ComponentPropsWithoutRef<\"svg\">,\n  | \"aria-label\"\n  | \"children\"\n  | \"color\"\n  | \"height\"\n  | \"preserveAspectRatio\"\n  | \"role\"\n  | \"viewBox\"\n> & {\n  /**\n   * One series of numbers, or several series that share one scale. A value that\n   * is not a finite number is left out and its neighbours are joined.\n   */\n  data: readonly number[] | readonly (readonly number[])[];\n  /** The accessible name of the sparkline; it is also its native title. */\n  label: string;\n  /**\n   * `line` is the stroke alone. `area` is the stroke over a fill that fades out\n   * towards the baseline. `fill` is that fill alone, which reads best with\n   * several overlapping series.\n   */\n  variant?: SparklineVariant;\n  /**\n   * A CSS colour or token for the marks. The colour of the surrounding text by\n   * default (`currentColor`), so a parent's text colour sets it.\n   */\n  color?: string;\n  /** The height in px. The width follows the room the sparkline is given. */\n  height?: number;\n  /**\n   * Whether the scale takes in 0, so that the height of the line shows the size\n   * of the values. `false` spans the scale from the lowest value to the\n   * highest, so that a small change still shows its shape; a figure beside the\n   * sparkline then tells its size. A series that is the same all along sits in\n   * the middle of the box. `true` by default.\n   */\n  includeZero?: boolean;\n};\n\n// The sparkline is drawn in its own units and stretches to its box: the highest\n// value sits 8 units below the top and the baseline 4 above the bottom, so the\n// stroke is never cut off.\nconst plotWidth = 300;\nconst plotHeight = 80;\nconst plotTop = 8;\nconst plotBottom = 76;\nconst noHeights: readonly number[] = [];\n// A point on its own is a dot 6 px across, as a dot of the Line Chart is.\nconst dotSize = 6;\n\n// A list of numbers is one series; a list of lists is several.\nfunction seriesOf(\n  data: SparklineProps[\"data\"],\n): readonly (readonly number[])[] {\n  return data.length > 0 && Array.isArray(data[0])\n    ? (data as readonly (readonly number[])[])\n    : [data as readonly number[]];\n}\n\n/**\n * A small chart of a trend, without axes or tooltip: a line, a line with a fill\n * under it, or the fill alone, for one series or several on one scale. New data\n * glides in: the curves morph into each other, whatever their length.\n */\nexport function Sparkline({\n  data,\n  label,\n  variant = \"line\",\n  color,\n  height = 32,\n  includeZero = true,\n  className,\n  ...props\n}: SparklineProps): React.ReactElement {\n  const gradient = React.useId();\n  const chart = React.useRef<SVGSVGElement>(null);\n  const reduced = useReducedMotionPreference();\n  const series = seriesOf(data);\n  const values = series.flat().filter(Number.isFinite);\n  // The smooth curve can swing a little past its points, so the range takes in\n  // where it goes as well, and the line stays inside its box.\n  const reach = series.flatMap((own) => {\n    const finite = own.filter(Number.isFinite);\n    return finite.length > 2\n      ? sampleCurve(\n          finite.map((value, i): ChartPoint => [i, value]),\n          morphSamples,\n        )\n      : [];\n  });\n  // One scale for every series, the charts' value extent: from 0, or from the\n  // lowest value with includeZero false, to the highest. It always has a span,\n  // so a series that is the same all along sits in the middle without 0.\n  const [min, max] = valueExtent([...values, ...reach], { includeZero });\n  const range = max - min;\n  const curves = series.map((own) =>\n    own\n      .filter(Number.isFinite)\n      .map((value, i, all): ChartPoint => [\n        all.length === 1 ? plotWidth / 2 : (i / (all.length - 1)) * plotWidth,\n        plotBottom - ((value - min) / range) * (plotBottom - plotTop),\n      ]),\n  );\n  // New data glides in: every curve is put into the same number of places, so\n  // a series of another length morphs into the old one. A curve that is only a\n  // point or nothing has no line to morph, and a change in the number of\n  // series shows at once. At rest the real points are drawn.\n  const morphs =\n    curves.length > 0 && curves.every((points) => points.length > 1);\n  const target = morphs\n    ? curves.flatMap((points) => sampleCurve(points, morphSamples))\n    : noHeights;\n  const heights = useTweenedValues(target, { container: chart });\n  const moving = morphs && heights !== target;\n  const filled = variant !== \"line\";\n  // A fill reaches from its line to the 0 line, so a series below 0 hangs down\n  // from it and one that crosses it fills towards it from both sides. Where 0\n  // is below the scale, as for a series above 0, the fill reaches the bottom of\n  // the box; where it is above, the top.\n  const zero =\n    min >= 0\n      ? plotHeight\n      : max <= 0\n        ? 0\n        : plotBottom - ((0 - min) / range) * (plotBottom - plotTop);\n  return (\n    <svg\n      data-slot=\"sparkline\"\n      ref={chart}\n      role=\"img\"\n      aria-label={label}\n      viewBox={`0 0 ${plotWidth} ${plotHeight}`}\n      preserveAspectRatio=\"none\"\n      height={height}\n      color={color}\n      className={cn(\n        slotClass(\"sparkline\", props),\n        \"w-full overflow-visible\",\n        !reduced &&\n          \"transition-colors duration-(--motion-duration-value) ease-(--motion-ease-standard)\",\n        className,\n      )}\n      {...props}\n    >\n      <title>{label}</title>\n      {curves.map((real, index) => {\n        const points = moving\n          ? morphPoints(\n              heights.slice(index * morphSamples, (index + 1) * morphSamples),\n              plotWidth,\n            )\n          : real;\n        if (!points.length) return null;\n        // A point on its own has no line and no fill: it is a dot, as in the\n        // Line Chart. A round cap on a stroke of no length draws it, and the\n        // stroke does not scale with the box, so it stays round.\n        if (points.length === 1) {\n          const [x, y] = points[0]!;\n          return (\n            <path\n              d={`M${x},${y} h0`}\n              className=\"sp-sparkline-dot\"\n              data-slot=\"sparkline-dot\"\n              fill=\"none\"\n              key={index}\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeWidth={dotSize}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          );\n        }\n        const line = naturalPath(points);\n        const first = points[0]!;\n        const last = points[points.length - 1]!;\n        // Overlapping fills fade out through their gradients, strongest away\n        // from the 0 line, as the Area Chart's do. In dark mode the bright\n        // tone is weakened so that they do not read as a block.\n        return (\n          <g key={index}>\n            {filled && (\n              <>\n                <defs>\n                  <linearGradient\n                    id={`${gradient}-${index}`}\n                    x1=\"0\"\n                    y1=\"0\"\n                    x2=\"0\"\n                    y2=\"1\"\n                  >\n                    {areaGradientStops(\n                      [...points.map(([, y]) => y), zero],\n                      zero,\n                    ).map((stop) => (\n                      <stop\n                        key={stop.offset}\n                        offset={stop.offset}\n                        stopColor=\"currentColor\"\n                        stopOpacity={stop.opacity}\n                      />\n                    ))}\n                  </linearGradient>\n                </defs>\n                <path\n                  d={`${line} L${last[0]},${zero} L${first[0]},${zero} Z`}\n                  fill={`url(#${gradient}-${index})`}\n                  className={\n                    variant === \"fill\" ? \"dark:opacity-60\" : \"opacity-80\"\n                  }\n                />\n              </>\n            )}\n            {variant !== \"fill\" && (\n              <path\n                d={line}\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                vectorEffect=\"non-scaling-stroke\"\n              />\n            )}\n          </g>\n        );\n      })}\n    </svg>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/sparkline/sparkline.tsx"},{"path":"packages/ui/src/components/sparkline/index.ts","content":"export * from \"./sparkline\";\n","type":"registry:ui","target":"~/src/siteplane/components/sparkline/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/sparkline.md","categories":["Charts"],"meta":{"avoidWhen":["Avoid when readers need exact values or several measures on a shared axis; use a Line Chart or a Table.","Avoid when a gap in the data must stay visible: a value that is not a number is left out and its neighbors are joined."],"description":"Show the trend of one or several measures in a small chart without axes.","knownLimitations":["There are no axes, labels, tooltip or hover values: the sparkline shows the shape of the data only. Name it with label and show the figure next to it.","All series share one scale. It includes 0 by default, so a series that varies little around a large value looks flat. With includeZero={false} it spans from the lowest value to the highest, so a small change shows its shape but not its size: put the figure beside the sparkline.","A value that is not a finite number is left out and its neighbors are joined; no gap is drawn.","All series are drawn in one color, the color of the surrounding text unless color is given, so several series differ by their shape and overlap only.","The width follows the room the sparkline is given: give it a width such as w-24 in a table cell or beside text. The stroke stays 2 px at any size.","New data morphs into the old curves whatever their length, unless a series is one point or none, or the number of series changes, which show at once."],"providerNotes":["The morph and the color change use the installed Motion component and its tokens. SiteplaneMotionProvider preset=\"reduced\" and the system setting show them at once."],"releaseStatus":"stable","searchTerms":["sparkline","spark chart","spark line","spark area","trend","mini chart","inline chart","chart","metric"],"useWhen":["Use when a figure, a table row or a card should show at a glance how a measure has moved, next to its value.","Use when a trend should take the color of its context, such as green for a rise and red for a fall, through the text color of a parent."]}}