{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart",
  "title": "Chart",
  "description": "Renders a TanStack Charts definition in the theme's series palette, with a tooltip and a legend that toggles series.",
  "dependencies": [
    "@stylexjs/stylex",
    "@tanstack/charts@^1.1.0"
  ],
  "registryDependencies": [
    "https://constella.aayush.cv/r/base.json",
    "https://constella.aayush.cv/r/toggle-group.json"
  ],
  "files": [
    {
      "path": "src/components/tanstack/chart.tsx",
      "content": "\"use client\";\n\nimport * as stylex from \"@stylexjs/stylex\";\nimport type {\n  ChartBuildContext,\n  ChartPositionScaleOptions,\n  ChartTooltipContent as ChartTooltipModel,\n  ConfiguredScaleLike,\n  ChartValue,\n  DomChartDefinition,\n} from \"@tanstack/charts\";\nimport { Chart as ChartPrimitive } from \"@tanstack/charts/react/tooltip\";\nimport type {\n  ChartProps as ChartPrimitiveProps,\n  ChartTooltipBodyRenderContext,\n} from \"@tanstack/charts/react/tooltip\";\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ComponentProps } from \"react\";\n\nimport { ChartIndicator } from \"@/components/tanstack/chart-legend\";\nimport type {\n  ChartConfig,\n  ChartIndicatorVariant,\n} from \"@/components/tanstack/chart-legend\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport { typeStyles } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  durations,\n  easings,\n  fonts,\n  fontWeights,\n  radii,\n  shadows,\n  sizes,\n  space,\n} from \"@/lib/constella/tokens.stylex\";\n\ntype Styled<T> = Omit<T, \"className\" | \"style\"> & {\n  sx?: Sx;\n};\n\n/**\n * An axis's `ticks.format` for counts: `1.2M`, not `1200000`, and no `0.2` steps on whole\n * numbers. Takes the locale, as the server's default can differ from the reader's.\n */\nconst chartTickFormat = (locale: Intl.LocalesArgument) => {\n  const compact = new Intl.NumberFormat(locale, {\n    maximumFractionDigits: 1,\n    notation: \"compact\",\n  });\n  return (value: number) =>\n    Number.isInteger(value) ? compact.format(value) : \"\";\n};\n\nconst styles = stylex.create({\n  root: {\n    // Axis labels, ticks and grid lines are drawn in `currentColor`.\n    color: colors.textMuted,\n    fontVariantNumeric: \"tabular-nums\",\n    minWidth: 0,\n  },\n  // A popover's surface: it holds figures to read, not a one-line hint.\n  // Rows share its columns (indicator, label, value), so one without an indicator still lines up.\n  tooltip: {\n    backgroundColor: colors.raised,\n    borderStartStartRadius: radii.md,\n    borderStartEndRadius: radii.md,\n    borderEndStartRadius: radii.md,\n    borderEndEndRadius: radii.md,\n    boxShadow: shadows.lg,\n    color: colors.textPrimary,\n    columnGap: space.xs,\n    display: \"grid\",\n    fontWeight: fontWeights.regular,\n    gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n    maxWidth: sizes.tooltip,\n    paddingBlock: space.popup,\n    paddingInlineEnd: space.popup,\n    paddingInlineStart: space.popup,\n    rowGap: space.xxxs,\n    whiteSpace: \"pre-line\",\n  },\n  title: {\n    alignItems: \"center\",\n    columnGap: space.xs,\n    display: \"flex\",\n    fontWeight: fontWeights.medium,\n    gridColumnEnd: -1,\n    gridColumnStart: 1,\n    marginBlockEnd: space.xxs,\n  },\n  row: {\n    alignItems: \"center\",\n    display: \"grid\",\n    gridColumnEnd: -1,\n    gridColumnStart: 1,\n    gridTemplateColumns: \"subgrid\",\n  },\n  // The series under the pointer reads a step stronger than its neighbors.\n  label: {\n    color: {\n      default: colors.textSecondary,\n      \":is([data-active] > *)\": colors.textPrimary,\n    },\n    gridColumnStart: 2,\n    minWidth: 0,\n  },\n  value: {\n    fontFamily: fonts.mono,\n    fontWeight: fontWeights.medium,\n    gridColumnStart: 3,\n    paddingInlineStart: space.md,\n    textAlign: \"end\",\n  },\n});\n\nfunction ChartTooltip({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"chart-tooltip\"\n      {...props}\n      {...stylex.props([typeStyles.textSm, styles.tooltip], sx)}\n    />\n  );\n}\n\nfunction ChartTooltipTitle({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"chart-tooltip-title\"\n      {...props}\n      {...stylex.props(styles.title, sx)}\n    />\n  );\n}\n\nfunction ChartTooltipRow({\n  active = false,\n  sx,\n  ...props\n}: Styled<ComponentProps<\"div\">> & {\n  /** The point under the pointer or keyboard, among the rows of its group. */\n  active?: boolean;\n}) {\n  return (\n    <div\n      data-active={active ? \"\" : undefined}\n      data-slot=\"chart-tooltip-row\"\n      {...props}\n      {...stylex.props(styles.row, sx)}\n    />\n  );\n}\n\nfunction ChartTooltipLabel({ sx, ...props }: Styled<ComponentProps<\"span\">>) {\n  return (\n    <span\n      data-slot=\"chart-tooltip-label\"\n      {...props}\n      {...stylex.props(styles.label, sx)}\n    />\n  );\n}\n\nfunction ChartTooltipValue({ sx, ...props }: Styled<ComponentProps<\"span\">>) {\n  return (\n    <span\n      data-slot=\"chart-tooltip-value\"\n      {...props}\n      {...stylex.props(styles.value, sx)}\n    />\n  );\n}\n\n/** Renders TanStack Charts' tooltip content as a `ChartTooltip`, naming each series by `config`. */\nfunction ChartTooltipContent({\n  config,\n  content,\n  hideLabel = false,\n  indicator = \"line\",\n  sx,\n}: {\n  config?: ChartConfig;\n  content: ChartTooltipModel | string;\n  /** Leaves out the title, the shared x value. */\n  hideLabel?: boolean;\n  indicator?: ChartIndicatorVariant;\n  sx?: Sx;\n}) {\n  // oxlint-disable-next-line anti-slop/no-runtime-typeof -- TanStack Charts hands plain text when the definition sets `format`\n  if (typeof content === \"string\") {\n    return <ChartTooltip sx={sx}>{content}</ChartTooltip>;\n  }\n  return (\n    <ChartTooltip sx={sx}>\n      {hideLabel ||\n      content.title === undefined ||\n      content.title === \"\" ? null : (\n        <ChartTooltipTitle>\n          {content.color === undefined ? null : (\n            <ChartIndicator color={content.color} variant={indicator} />\n          )}\n          {content.title}\n        </ChartTooltipTitle>\n      )}\n      {content.rows.map((row, index) => (\n        // Two marks on one series give two rows with its label.\n        <ChartTooltipRow active={row.active} key={`${row.label}-${index}`}>\n          {row.color === undefined ? null : (\n            <ChartIndicator color={row.color} variant={indicator} />\n          )}\n          <ChartTooltipLabel>\n            {config?.[row.label]?.label ?? row.label}\n          </ChartTooltipLabel>\n          <ChartTooltipValue>{row.value}</ChartTooltipValue>\n        </ChartTooltipRow>\n      ))}\n    </ChartTooltip>\n  );\n}\n\nconst tweenFor = Number(durations.chart.slice(0, -\"ms\".length));\n\n/**\n * Eases each value toward `target` over `durations.chart`, or jumps when `animate` is false or\n * motion is reduced. Memoize `target`: a new object restarts the tween. Keep one chart's values in\n * one tween, or in tweens that share `animate`, so its axis and marks move together.\n */\nfunction useChartTween<T extends Record<string, number>>(\n  target: T,\n  animate: boolean\n): T {\n  const [current, setCurrent] = useState(target);\n  const painted = useRef(target);\n  // Before paint, so a jump lands in the same frame as the axis it belongs to.\n  useLayoutEffect(() => {\n    const from = painted.current;\n    const duration =\n      animate && !matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n        ? tweenFor\n        : 0;\n    // A real animation keeps time, so the tween takes the `out` curve and follows DevTools' playback speed.\n    const clock = document.documentElement.animate(null, {\n      duration,\n      easing: easings.out,\n      fill: \"forwards\",\n    });\n    const paint = () => {\n      const eased = clock.effect?.getComputedTiming().progress ?? 1;\n      const next = {\n        ...target,\n        ...Object.fromEntries(\n          Object.entries(target).map(([key, value]) => {\n            const begin = from[key] ?? 0;\n            return [key, begin + (value - begin) * eased];\n          })\n        ),\n      };\n      painted.current = next;\n      setCurrent(next);\n    };\n    if (duration === 0) {\n      paint();\n    }\n    let frame = requestAnimationFrame(function step() {\n      paint();\n      if (clock.playState !== \"finished\") {\n        frame = requestAnimationFrame(step);\n      }\n    });\n    return () => {\n      cancelAnimationFrame(frame);\n      clock.cancel();\n    };\n  }, [animate, target]);\n  return current;\n}\n\n/**\n * A `group({ scale })` band scale where each series takes `weight` (0 to 1) of a slot, so a slot\n * eases open or shut with `useChartTween`. Every bar keeps the full bandwidth and is pulled inside\n * the group: put a closing series' rows first, and it paints under the bars that grow over it.\n */\nconst chartGroupScale = (\n  weights: readonly (readonly [string, number])[],\n  padding = 0\n): ConfiguredScaleLike<string> => {\n  let [from, to] = [0, 1];\n  // At least one slot wide, so a last series closing doesn't stretch its bars.\n  const total = Math.max(\n    1,\n    weights.reduce((sum, [, weight]) => sum + weight, 0)\n  );\n  const step = () => (to - from) / (total + padding);\n  const scale = (key: string) => {\n    const index = weights.findIndex(([member]) => member === key);\n    // Not a member: NaN, which TanStack reports as a key outside the group's domain.\n    if (index === -1) {\n      return Number.NaN;\n    }\n    const before = weights\n      .slice(0, index)\n      .reduce((sum, [, weight]) => sum + weight, 0);\n    const first = from + step() * padding;\n    return Math.min(first + step() * before, first + step() * (total - 1));\n  };\n  const band = Object.assign(scale, {\n    bandwidth: () => step() * (1 - padding),\n    copy: () => chartGroupScale(weights, padding).range([from, to]),\n    domain: () => weights.map(([member]) => member),\n    // TanStack sets the range in place and reads the bandwidth after.\n    range: (values: Iterable<number>) => {\n      [from = 0, to = 1] = values;\n      return band;\n    },\n  });\n  return band;\n};\n\ntype ChartScales = Readonly<Record<string, ChartPositionScaleOptions | null>>;\n\n// captionLg's 12, set on the axes, not in CSS: TanStack measures labels by this number for margins and thinning.\nconst tickLabelSize = 12;\n\nconst withTickLabelSize = <TSpec extends { scales?: ChartScales }>(\n  spec: TSpec\n): TSpec =>\n  spec.scales === undefined\n    ? spec\n    : {\n        ...spec,\n        scales: Object.fromEntries(\n          Object.entries(spec.scales).map(([id, options]) => [\n            id,\n            options === null ||\n            options.axis === false ||\n            options.axis?.tickLabels === false\n              ? options\n              : {\n                  ...options,\n                  axis: {\n                    ...options.axis,\n                    tickLabels: {\n                      fontSize: tickLabelSize,\n                      ...options.axis?.tickLabels,\n                    },\n                  },\n                },\n          ])\n        ),\n      };\n\nconst withChartDefaults = <\n  TDatum,\n  TXValue extends ChartValue,\n  TYValue extends ChartValue,\n>(\n  definition: DomChartDefinition<TDatum, TXValue, TYValue>\n): DomChartDefinition<TDatum, TXValue, TYValue> =>\n  \"chart\" in definition\n    ? {\n        ...definition,\n        chart: (context: ChartBuildContext) =>\n          withTickLabelSize(definition.chart(context)),\n      }\n    : withTickLabelSize(definition);\n\n/** Renders a TanStack Charts definition in the theme's palette, with a `ChartTooltipContent` unless `renderTooltipBody` replaces it. Axis labels default to 12px. */\nfunction Chart<\n  TDatum,\n  TXValue extends ChartValue = ChartValue,\n  TYValue extends ChartValue = ChartValue,\n>({\n  animate = false,\n  config,\n  onRender,\n  renderTooltipBody,\n  sx,\n  ...props\n}: Styled<ChartPrimitiveProps<TDatum, TXValue, TYValue>> & {\n  /** Axis labels that arrive, move or change with this definition fade in; set it for a click, not a key press. */\n  animate?: boolean;\n  /** Names the series in the default tooltip. */\n  config?: ChartConfig;\n}) {\n  // Where each axis label was painted, so only the ones a change adds or moves fade in; a resize\n  // moves labels too, so only a new definition counts.\n  const placed = useRef(\n    new WeakMap<Element, { text: string | null; x: number; y: number }>()\n  );\n  const painted = useRef(props.definition);\n  const definition = useMemo(\n    () => withChartDefaults(props.definition),\n    [props.definition]\n  );\n  const renderConfigTooltip = ({ content }: ChartTooltipBodyRenderContext) => (\n    <ChartTooltipContent config={config} content={content} />\n  );\n  const fadeLabels: typeof onRender = (context) => {\n    const changed = painted.current !== props.definition;\n    painted.current = props.definition;\n    for (const label of context.svg.querySelectorAll(\".ts-chart__axes text\")) {\n      const at = {\n        text: label.textContent,\n        x: Number(label.getAttribute(\"x\")),\n        y: Number(label.getAttribute(\"y\")),\n      };\n      const was = placed.current.get(label);\n      // A label width change nudges the plot under a pixel; that isn't a move.\n      const moved =\n        was === undefined ||\n        was.text !== at.text ||\n        Math.hypot(at.x - was.x, at.y - was.y) >= 1;\n      if (animate && changed && moved) {\n        // From partway, never from nothing: the old label is already gone.\n        label.animate([{ opacity: 0.4 }, { opacity: 1 }], {\n          duration: tweenFor,\n          easing: easings.crossfade,\n        });\n      }\n      placed.current.set(label, at);\n    }\n    onRender?.(context);\n  };\n  return (\n    <div\n      data-slot=\"chart\"\n      {...stylex.props([typeStyles.captionLg, styles.root], sx)}\n    >\n      <ChartPrimitive\n        onRender={fadeLabels}\n        renderTooltipBody={renderTooltipBody ?? renderConfigTooltip}\n        {...props}\n        definition={definition}\n      />\n    </div>\n  );\n}\n\nexport {\n  Chart,\n  ChartTooltip,\n  ChartTooltipContent,\n  ChartTooltipLabel,\n  ChartTooltipRow,\n  ChartTooltipTitle,\n  ChartTooltipValue,\n  chartTickFormat,\n  chartGroupScale,\n  useChartTween,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/tanstack/chart-legend.tsx",
      "content": "\"use client\";\n\nimport * as stylex from \"@stylexjs/stylex\";\nimport type { ComponentProps, ReactNode } from \"react\";\n\nimport {\n  ToggleGroup,\n  ToggleGroupItem,\n} from \"@/components/primitives/toggle-group\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  durations,\n  easings,\n  fontWeights,\n  media,\n  radii,\n  sizes,\n  space,\n  strokes,\n} from \"@/lib/constella/tokens.stylex\";\n\ntype Styled<T> = Omit<T, \"className\" | \"style\"> & {\n  sx?: Sx;\n};\n\n/** Each series by its value in the data: its name, and its paint when not the next in the palette. */\ntype ChartConfig = Record<string, { color?: string; label?: ReactNode }>;\n\nconst palette = [\n  colors.chart1,\n  colors.chart2,\n  colors.chart3,\n  colors.chart4,\n  colors.chart5,\n  colors.chart6,\n];\n\n// The seventh is `chartFold`'s \"Other\", so it takes muted ink rather than a reused hue.\nconst chartSeries = (config: ChartConfig) =>\n  Object.entries(config).map(([key, series], index) => ({\n    color: series.color ?? palette[index] ?? colors.textMuted,\n    key,\n    label: series.label ?? key,\n  }));\n\n/**\n * Folds every series past six into one `other`, summing the rows that share their other fields, so\n * no hue repeats. `series` and `value` name the rows' series and number fields.\n */\nconst chartFold = <TRow extends Record<string, unknown>>(\n  config: ChartConfig,\n  rows: readonly TRow[],\n  {\n    label = \"Other\",\n    series,\n    value,\n  }: { label?: ReactNode; series: keyof TRow; value: keyof TRow }\n) => {\n  const keys = Object.keys(config);\n  if (keys.length <= palette.length) {\n    return { config, rows };\n  }\n  const kept = new Set(keys.slice(0, palette.length));\n  const folded: TRow[] = [];\n  const at = new Map<string, number>();\n  for (const row of rows) {\n    if (kept.has(String(row[series]))) {\n      folded.push(row);\n      continue;\n    }\n    const by = JSON.stringify(\n      Object.entries(row).filter(\n        ([field]) => field !== series && field !== value\n      )\n    );\n    const index = at.get(by);\n    if (index === undefined) {\n      at.set(by, folded.length);\n      folded.push({ ...row, [series]: \"other\", [value]: Number(row[value]) });\n    } else {\n      const sum = folded[index];\n      folded[index] = {\n        ...sum,\n        [value]: Number(sum[value]) + Number(row[value]),\n      };\n    }\n  }\n  return {\n    config: {\n      ...Object.fromEntries(\n        keys.slice(0, palette.length).map((key) => [key, config[key]])\n      ),\n      other: { label },\n    },\n    rows: folded,\n  };\n};\n\n/** The definition's `color`: every series in `config` keeps its paint, shown or hidden. */\nconst chartColor = (config: ChartConfig) => {\n  const series = chartSeries(config);\n  return {\n    domain: series.map(({ key }) => key),\n    range: series.map(({ color }) => color),\n  };\n};\n\nconst hidden = \":is([data-slot='chart-legend-item']:not([data-pressed]) > *)\";\n\nconst styles = stylex.create({\n  // Hollow in a hidden legend entry, so its state isn't color alone.\n  indicator: (color: string) => ({\n    backgroundColor: { default: color, [hidden]: \"transparent\" },\n    borderStartStartRadius: radii.full,\n    borderStartEndRadius: radii.full,\n    borderEndStartRadius: radii.full,\n    borderEndEndRadius: radii.full,\n    boxShadow: `inset 0 0 0 ${strokes.border} ${color}`,\n    flexShrink: 0,\n    gridColumnStart: 1,\n    transitionDuration: durations.hover,\n    transitionProperty: \"background-color\",\n    transitionTimingFunction: \"ease\",\n  }),\n  dot: {\n    height: sizes.iconXxs,\n    width: sizes.iconXxs,\n  },\n  // A short bar beside a row of figures.\n  line: {\n    height: sizes.iconSm,\n    width: strokes.track,\n  },\n  legend: {\n    flexWrap: \"wrap\",\n    // Back by an item's start padding and border, so each row's first dot lines up with the text above.\n    marginInlineStart: `calc(-1 * (${space.xs} - ${space.xxxs} + ${strokes.border}))`,\n  },\n  // Every entry starts shown, so shown stays quiet and hidden is the state that reads.\n  legendItem: {\n    backgroundColor: {\n      default: \"transparent\",\n      [media.hover]: { default: \"transparent\", \":hover\": colors.fillSubtle },\n      \":active\": colors.fillSubtle,\n    },\n    color: {\n      default: colors.textMuted,\n      \":is([data-pressed])\": colors.textPrimary,\n    },\n    fontWeight: fontWeights.regular,\n    // Button's transitions (button.tsx `base`, kept in step by hand: StyleX can't import them),\n    // plus the label's color, so hiding a series fades like its fill.\n    transitionDuration: `${durations.press}, ${durations.hover}, ${durations.hover}, ${durations.hover}, ${durations.hover}`,\n    transitionProperty:\n      \"transform, background-color, box-shadow, opacity, color\",\n    transitionTimingFunction: `${easings.out}, ease, ease, ease, ease`,\n  },\n});\n\nconst chartIndicatorVariants = [\"dot\", \"line\"] as const;\n\ntype ChartIndicatorVariant = (typeof chartIndicatorVariants)[number];\n\n/** A series' key, in its chart paint like `colors.chart1`. */\nfunction ChartIndicator({\n  color,\n  sx,\n  variant = \"dot\",\n  ...props\n}: Styled<ComponentProps<\"span\">> & {\n  color: string;\n  variant?: ChartIndicatorVariant;\n}) {\n  return (\n    <span\n      data-icon=\"inline-start\"\n      data-slot=\"chart-indicator\"\n      data-variant={variant}\n      {...props}\n      {...stylex.props(styles.indicator(color), styles[variant], sx)}\n    />\n  );\n}\n\n/** The series a chart shows, each a toggle; `value` lists the shown ones. */\nfunction ChartLegend({\n  sx,\n  ...props\n}: Omit<ComponentProps<typeof ToggleGroup>, \"multiple\">) {\n  return (\n    <ToggleGroup\n      data-slot=\"chart-legend\"\n      multiple\n      size=\"sm\"\n      spacing={1}\n      {...props}\n      sx={[styles.legend, sx]}\n    />\n  );\n}\n\nfunction ChartLegendItem({\n  sx,\n  ...props\n}: ComponentProps<typeof ToggleGroupItem>) {\n  return (\n    <ToggleGroupItem\n      data-slot=\"chart-legend-item\"\n      {...props}\n      sx={[styles.legendItem, sx]}\n    />\n  );\n}\n\n/** Renders a `ChartLegend` with an item per series in `config`, in its order. */\nfunction ChartLegendContent({\n  config,\n  ...props\n}: Omit<ComponentProps<typeof ChartLegend>, \"children\"> & {\n  config: ChartConfig;\n}) {\n  return (\n    <ChartLegend {...props}>\n      {chartSeries(config).map(({ color, key, label }) => (\n        <ChartLegendItem key={key} value={key}>\n          <ChartIndicator color={color} />\n          {label}\n        </ChartLegendItem>\n      ))}\n    </ChartLegend>\n  );\n}\n\nexport {\n  ChartIndicator,\n  ChartLegend,\n  ChartLegendContent,\n  ChartLegendItem,\n  chartColor,\n  chartFold,\n  chartIndicatorVariants,\n};\nexport type { ChartConfig, ChartIndicatorVariant };\n",
      "type": "registry:component"
    }
  ],
  "docs": "Usage: https://constella.aayush.cv/docs/components/chart/index.md",
  "type": "registry:ui"
}