{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hex-chart",
  "type": "registry:component",
  "title": "Hex Chart",
  "description": "A whole broken into parts, counted out in hexagonal cells.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/panelui/hex-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/hex-chart/index.tsx",
      "content": "'use client';\n\n/**\n * HexChart — a whole broken into parts, counted out in cells.\n *\n * ```tsx\n * <HexChart data={attribution}>\n *   <HexChart.Header title=\"Attributed revenue\" value=\"$6,750\" />\n *   <HexChart.Cells />\n *   <HexChart.Tooltip />\n *   <HexChart.Legend />\n * </HexChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's HexChart, so a chart written for one\n * reads the same in the other. What differs is what the web changes: a series is read\n * by hovering its cells as well as by pressing them, a press pins the reading, and the\n * arrow keys walk the series.\n *\n * ## The field\n *\n * The cells are pointy-top hexagons on an offset grid. The unfilled cells are drawn too,\n * because the field is the denominator made visible. `shape` decides how the filled ones\n * are arranged: `grid` is reading order, which a reader can count off, and `blob` grows\n * the series out from the middle, which shows the shape of the split at a glance.\n *\n * ## Whole cells\n *\n * Every cell is a whole unit, so a series worth half a cell either rounds up to a full\n * one or vanishes. Cells are handed out by largest remainder, so the count always\n * spends exactly the cells available.\n */\nimport { defineChart, hexagon, type ChartMotionContext } from '@tanstack/charts';\nimport { motion } from '@tanstack/charts/motion';\nimport { Chart } from '@tanstack/charts/react/core';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport { hexAt, hexPlan, type HexCell, type HexPlan, type HexShape } from './hex-layout';\n\nexport type { HexShape } from './hex-layout';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type HexChartStatus = 'loading' | 'ready';\n\n/** One slice of the whole. */\nexport interface HexDatum {\n  /** The series' name, in the legend and to a screen reader. */\n  label: string;\n  /** How much of the whole it holds. Cells are shared out in proportion. */\n  value: number;\n  /** A theme role or any CSS colour. Defaults to the chart ramp, in data order. */\n  color?: string;\n}\n\nconst ROLES = new Set(['primary', 'info', 'success', 'warning', 'destructive', 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5']);\n\n/** A theme role by name, or anything else as the CSS colour it already is. */\nconst colorOf = (color: string) => (ROLES.has(color) ? `var(--${color})` : color);\n\nconst hexChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-1 text-foreground',\n    plot: 'relative col-start-1 row-start-2 w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring',\n    chart: 'absolute! inset-0 [&_path]:origin-center [&_path]:[transform-box:fill-box]',\n    label:\n      'pointer-events-none absolute col-start-1 row-start-2 flex items-center gap-1.5 self-start justify-self-start rounded-lg border border-border bg-popover px-2 py-1 text-xs whitespace-nowrap text-popover-foreground shadow-md',\n    labelDetail: 'text-muted-foreground',\n    swatch: 'size-2 shrink-0 rounded-full',\n    legend: 'col-start-1 row-start-3 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1.5 pt-3',\n    legendItem:\n      'flex max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-xs outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring',\n    legendName: 'truncate text-muted-foreground',\n    legendValue: 'font-medium tabular-nums',\n    header: 'col-start-1 row-start-1 flex items-start justify-between gap-3 pb-3',\n    headerText: 'flex min-w-0 flex-1 flex-col gap-0.5',\n    headerMuted: 'text-xs text-muted-foreground',\n    headerValue: 'text-xl font-bold tabular-nums tracking-tight',\n    headerTrailing: 'flex max-w-[55%] shrink flex-wrap items-center justify-end gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground',\n    headerKey: 'flex items-center gap-1.5',\n  },\n});\n\n/** How much of the reveal goes to staggering the cells' start times. */\nconst STAGGER_SHARE = 0.6;\n\n/** Clearance between the label and the cells it is naming. */\nconst LABEL_GAP = 6;\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'],\n};\n\nconst COMPACT = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\n\n/**\n * Grows the filled cells in from the start of the fill order, all landing inside the\n * one duration. The field stays put, because the total did not change. The renderer\n * gives a hexagon no entrance of its own on a chart's first paint.\n */\nfunction landCells(container: Element, duration: number) {\n  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n  const cells = [...container.querySelectorAll('svg [data-ts-key*=\":hex:\"]')];\n  const orderOf = (cell: Element) => Number(/:hex:(\\d+):/.exec(cell.getAttribute('data-ts-key') ?? '')?.[1] ?? 0);\n  const last = Math.max(1, ...cells.map(orderOf));\n  cells.forEach((cell) => {\n    cell.animate([{ transform: 'scale(0)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }], {\n      duration: duration * (1 - STAGGER_SHARE),\n      delay: (orderOf(cell) / last) * duration * STAGGER_SHARE,\n      easing: 'ease-out',\n      fill: 'backwards',\n    });\n  });\n}\n\n/** What the parts have asked for. */\ninterface Slots {\n  cells?: { emptyColor: string; dimOpacity: number };\n  skeleton?: { fill: string };\n  tooltip?: true;\n}\n\ninterface HexChartContextValue {\n  data: readonly HexDatum[];\n  total: number;\n  colors: readonly string[];\n  plan: HexPlan;\n  box: { width: number; height: number };\n  loading: boolean;\n  activeIndex: number;\n  /** Selects a series as a press does: the same one again clears it. */\n  press: (index: number) => void;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n}\n\nconst HexChartContext = createContext<HexChartContextValue | null>(null);\n\nfunction useChart(component: string): HexChartContextValue {\n  const context = useContext(HexChartContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <HexChart>`);\n  }\n  return context;\n}\n\nconst percentOf = (value: number, total: number) => (total > 0 ? Math.round((Math.max(0, value) / total) * 100) : 0);\n\nexport interface HexChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** The slices of the whole. */\n  data: readonly HexDatum[];\n  /**\n   * Cells across the field. Rows follow from the width, the height and this, and the\n   * size of one cell follows from all three.\n   */\n  columns?: number;\n  /** The field's width ÷ height. */\n  aspectRatio?: number;\n  /**\n   * How much of the field a `blob` fills, from 0 to 1. A `grid` always fills all of it.\n   * The rest is left as the unfilled cells around the shape.\n   */\n  density?: number;\n  /** `blob` grows from the middle and shows the split at a glance. `grid` is reading order, for counting. */\n  shape?: HexShape;\n  /** Space between cells, as a share of a cell's radius. */\n  cellGap?: number;\n  /** Milliseconds for the series to fill in. `0` draws them at once. */\n  animationDuration?: number;\n  /** `loading` shows the field with nothing divided up yet. Add a `HexChart.Skeleton` to tint it. */\n  status?: HexChartStatus;\n  /** The series being read, by index, or -1 for none. Leave unset to let the chart keep it. */\n  activeIndex?: number;\n  /** The series being read, by index, and -1 when there is none. Reading needs `HexChart.Tooltip`. */\n  onActiveIndexChange?: (index: number) => void;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /** What a screen reader says for one series. Defaults to its name and share. */\n  labelForDatum?: (series: HexDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the fill, for a \"replay\" control. */\nexport interface HexChartHandle {\n  replay: () => void;\n}\n\nconst HexChartRoot = forwardRef<HexChartHandle, HexChartProps & RenderProp<'div'>>(function HexChartRoot(\n  {\n    data,\n    columns = 21,\n    aspectRatio = 1.6,\n    density = 0.55,\n    shape = 'blob',\n    cellGap = 0.14,\n    animationDuration = 620,\n    status = 'ready',\n    activeIndex: activeIndexProp,\n    onActiveIndexChange,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slotClasses = hexChartVariants();\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  const [slots, setSlots] = useState<Slots>({});\n  const [internalActive, setInternalActive] = useState(-1);\n  const [revision, setRevision] = useState(0);\n  const pinned = useRef(false);\n  const landed = useRef('');\n\n  useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const read = () => setWidth(node.clientWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const setSlot = useCallback<HexChartContextValue['setSlot']>(\n    (slot, value) => setSlots((current) => ({ ...current, [slot]: value })),\n    []\n  );\n\n  useImperativeHandle(ref, () => ({ replay: () => setRevision((current) => current + 1) }), []);\n\n  const loading = status === 'loading';\n  const activeIndex = activeIndexProp ?? internalActive;\n  const box = useMemo(() => ({ width, height: aspectRatio > 0 ? width / aspectRatio : 0 }), [width, aspectRatio]);\n  const values = data.map(({ value }) => value).join();\n  const plan = useMemo(\n    () => hexPlan(values ? values.split(',').map(Number) : [], box, { columns, shape, density, loading }),\n    [values, box, columns, shape, density, loading]\n  );\n  const total = data.reduce((sum, { value }) => sum + Math.max(0, value), 0);\n  const colorKey = data.map(({ color }) => color ?? '').join();\n  const colors = useMemo(\n    () => data.map(({ color }, index) => (color ? colorOf(color) : `var(--chart-${(index % 5) + 1})`)),\n    [colorKey, data.length]\n  );\n\n  const activeRef = useRef(activeIndex);\n  const onChangeRef = useRef(onActiveIndexChange);\n  // After commit, so a render React throws away leaves no reading `setActive` would skip.\n  // Nothing active is nothing pinned, including when a parent clears a controlled selection.\n  useLayoutEffect(() => {\n    activeRef.current = activeIndex;\n    onChangeRef.current = onActiveIndexChange;\n    if (activeIndex < 0) pinned.current = false;\n  });\n  const setActive = useCallback((index: number) => {\n    if (activeRef.current === index) return;\n    activeRef.current = index;\n    setInternalActive(index);\n    onChangeRef.current?.(index);\n  }, []);\n\n  const press = useCallback(\n    (index: number) => {\n      const clearing = index < 0 || (activeRef.current === index && pinned.current);\n      pinned.current = !clearing;\n      setActive(clearing ? -1 : index);\n    },\n    [setActive]\n  );\n\n  const { cells, skeleton, tooltip } = slots;\n\n  const definition = useMemo(() => {\n    const radius = plan.metrics.radius * (1 - Math.max(0, Math.min(cellGap, 0.5)));\n    const owned = (owner: number) => plan.cells.filter((cell) => cell.owner === owner);\n    // The filled cells carry their place in the fill order in their key, for the entrance.\n    const series = colors.map((fill, index) =>\n      hexagon(owned(index), {\n        id: `hex-${index}`,\n        key: (cell: HexCell) => `hex:${cell.order}:${cell.column}:${cell.row}`,\n        x: 'x',\n        y: 'y',\n        r: radius,\n        fill,\n        fillOpacity: activeIndex >= 0 && activeIndex !== index ? cells?.dimOpacity : 1,\n      })\n    );\n    const field = hexagon(owned(-1), {\n      id: 'hex-field',\n      key: (cell: HexCell) => `field:${cell.column}:${cell.row}`,\n      x: 'x',\n      y: 'y',\n      r: radius,\n      fill: loading ? (skeleton?.fill ?? 'var(--muted)') : (cells?.emptyColor ?? 'var(--muted)'),\n      motion: false,\n    });\n\n    return defineChart({\n      theme: THEME,\n      marks: !loading && cells ? [field, ...series] : loading ? [field] : [],\n      scales: {\n        x: { scale: scaleLinear([0, box.width], [0, 1]), axis: false as const, grid: false as const },\n        y: { scale: scaleLinear([box.height, 0], [0, 1]), axis: false as const, grid: false as const },\n      },\n      guides: false as const,\n      margin: 0,\n      focusRing: false,\n      keyboard: false,\n      pointer: false,\n      motion: (context: ChartMotionContext<HexCell>) =>\n        context.phase === 'enter' ? (false as const) : { transition: { type: 'tween' as const, duration: 180, easing: 'ease-out' as const } },\n    });\n  }, [plan, box, colors, activeIndex, cells, skeleton, loading, cellGap]);\n\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 180, easing: 'ease-out' } }), []);\n\n  // A remounted chart has no reading to keep. The first mount is not a remount, and\n  // must leave a controlled `activeIndex` alone.\n  const paint = `${revision}:${status}`;\n  const lastPaint = useRef(paint);\n  useEffect(() => {\n    if (lastPaint.current === paint) return;\n    lastPaint.current = paint;\n    pinned.current = false;\n    setActive(-1);\n  }, [paint, setActive]);\n\n  const ownerAt = (event: ReactMouseEvent<HTMLDivElement>) => {\n    const bounds = event.currentTarget.getBoundingClientRect();\n    const cell = hexAt(event.clientX - bounds.left, event.clientY - bounds.top, plan);\n    return cell ? (plan.cells[cell.row * plan.columns + cell.column]?.owner ?? -1) : -1;\n  };\n  const reads = tooltip === true && !loading;\n\n  const summary = label ?? `Hex chart, ${data.length} series`;\n  const spoken = (series: HexDatum, index: number) =>\n    labelForDatum?.(series, index) ?? `${series.label}, ${percentOf(series.value, total)} percent`;\n\n  const context = useMemo<HexChartContextValue>(\n    () => ({ data, total, colors, plan, box, loading, activeIndex, press, setSlot }),\n    [data, total, colors, plan, box, loading, activeIndex, press, setSlot]\n  );\n\n  const active = activeIndex >= 0 ? data[activeIndex] : undefined;\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'hex-chart',\n        className: slotClasses.root({ className }),\n        children: (\n          <>\n            {loading ? null : (\n              <ul aria-label={summary} className=\"sr-only\">\n                {data.map((series, index) => (\n                  <li key={series.label}>{spoken(series, index)}</li>\n                ))}\n              </ul>\n            )}\n            <div role=\"status\" className=\"sr-only\">\n              {active ? spoken(active, activeIndex) : ''}\n            </div>\n            <div\n              ref={plotRef}\n              data-slot=\"hex-chart-plot\"\n              role=\"group\"\n              aria-label={summary}\n              tabIndex={reads ? 0 : undefined}\n              className={slotClasses.plot()}\n              style={{ aspectRatio }}\n              onPointerMove={(event) => {\n                if (reads && event.pointerType === 'mouse' && !pinned.current) setActive(ownerAt(event));\n              }}\n              onPointerLeave={() => {\n                if (!pinned.current) setActive(-1);\n              }}\n              // A click rather than a pointer down, so that a scroll starting on the plot\n              // does not pin the series under it.\n              onClick={(event) => {\n                if (reads) press(ownerAt(event));\n              }}\n              onKeyDown={(event) => {\n                if (!reads || !data.length) return;\n                if (event.key === 'Escape') return press(-1);\n                const step = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 0;\n                if (!step) return;\n                event.preventDefault();\n                pinned.current = true;\n                setActive(activeRef.current < 0 ? (step > 0 ? 0 : data.length - 1) : (activeRef.current + step + data.length) % data.length);\n              }}\n            >\n              {width > 0 && box.height > 0 ? (\n                <Chart\n                  // An entrance only plays on a chart's first paint, so the cells\n                  // arriving after a load, or a replay, are a new chart.\n                  key={paint}\n                  definition={definition}\n                  renderer={renderer}\n                  ariaLabel={summary}\n                  aspectRatio={aspectRatio}\n                  initialWidth={width}\n                  className={slotClasses.chart()}\n                  onRender={({ container, scene }) => {\n                    if (loading || landed.current === paint || scene.points.length === 0) return;\n                    landed.current = paint;\n                    // The chart swaps its surface right after this call, and a frame\n                    // callback still runs ahead of the first paint.\n                    requestAnimationFrame(() => landCells(container, animationDuration));\n                  }}\n                />\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <HexChartContext.Provider value={context}>{element}</HexChartContext.Provider>;\n});\nHexChartRoot.displayName = 'HexChart';\n\nexport interface HexChartCellsProps {\n  /** Colour of the cells no series took, a theme role or any CSS colour. Defaults to the muted surface. */\n  emptyColor?: string;\n  /** Opacity of the series that are not being read, once one is. */\n  dimOpacity?: number;\n}\n\n/**\n * The honeycomb: the unfilled field and the series over it. One part rather than one\n * per series, because one cell has to mean the same thing wherever it appears.\n */\nfunction HexChartCells({\n  emptyColor,\n  dimOpacity = 0.25,\n}: HexChartCellsProps) {\n  const { setSlot } = useChart('HexChart.Cells');\n  const resolved = emptyColor ? colorOf(emptyColor) : 'var(--muted)';\n  useEffect(() => {\n    setSlot('cells', { emptyColor: resolved, dimOpacity });\n    return () => setSlot('cells', undefined);\n  }, [resolved, dimOpacity, setSlot]);\n  return null;\n}\nHexChartCells.displayName = 'HexChart.Cells';\n\nexport interface HexChartSkeletonProps {\n  /** A theme role or any CSS colour. Defaults to the muted surface. */\n  color?: string;\n}\n\n/**\n * The loading state: the field, with nothing divided up yet. Undivided on purpose,\n * because placeholder shares would be a made-up split.\n */\nfunction HexChartSkeleton({ color }: HexChartSkeletonProps) {\n  const { setSlot } = useChart('HexChart.Skeleton');\n  const fill = color ? colorOf(color) : 'var(--muted)';\n  useEffect(() => {\n    setSlot('skeleton', { fill });\n    return () => setSlot('skeleton', undefined);\n  }, [fill, setSlot]);\n  return null;\n}\nHexChartSkeleton.displayName = 'HexChart.Skeleton';\n\nexport interface HexChartTooltipProps {\n  /** Format the series' value. Defaults to a compact number. */\n  formatValue?: (value: number, series: HexDatum) => string;\n  /** Show the count of cells beside the share. */\n  showCells?: boolean;\n  /** Added to the readout's surface. */\n  className?: string;\n}\n\n/**\n * What makes the honeycomb readable, and the label that names the series. Hovering a\n * cell reads its series, a press pins it, pressing it again or an unfilled cell clears\n * it, and the arrow keys walk the series. The label hangs above the series so the cells\n * being read are never under it.\n */\nfunction HexChartTooltip({\n  formatValue,\n  showCells = false,\n  className,\n}: HexChartTooltipProps) {\n  const { data, total, colors, plan, box, loading, activeIndex, setSlot } = useChart('HexChart.Tooltip');\n  const slots = hexChartVariants();\n  const node = useRef<HTMLDivElement>(null);\n  const [size, setSize] = useState<{ width: number; height: number } | null>(null);\n\n  useEffect(() => {\n    setSlot('tooltip', true);\n    return () => setSlot('tooltip', undefined);\n  }, [setSlot]);\n\n  const series = activeIndex >= 0 ? data[activeIndex] : undefined;\n  const value = series ? (formatValue ? formatValue(series.value, series) : COMPACT.format(series.value)) : '';\n  const percent = series ? percentOf(series.value, total) : 0;\n  const detail = showCells ? `${percent}% · ${plan.counts[activeIndex] ?? 0} cells` : `${percent}%`;\n\n  // Sized by its contents and placed once it knows its size, because a currency total\n  // can be half again as wide as a compact one.\n  useLayoutEffect(() => {\n    if (!node.current) return;\n    const { offsetWidth, offsetHeight } = node.current;\n    setSize((current) =>\n      current?.width === offsetWidth && current?.height === offsetHeight ? current : { width: offsetWidth, height: offsetHeight }\n    );\n  }, [value, detail]);\n\n  const anchor = plan.anchors[activeIndex];\n  if (loading || !series || !anchor) return null;\n  const width = size?.width ?? 0;\n  const height = size?.height ?? 0;\n  return (\n    <div\n      ref={node}\n      aria-hidden\n      data-slot=\"hex-chart-tooltip\"\n      className={slots.label({ className })}\n      style={{\n        left: Math.max(0, Math.min(box.width - width, anchor.x - width / 2)),\n        top: Math.max(0, Math.min(box.height - height, anchor.y - LABEL_GAP - height)),\n        visibility: size ? 'visible' : 'hidden',\n      }}\n    >\n      <span className={slots.swatch()} style={{ background: colors[activeIndex] }} />\n      <span className=\"font-medium\">{value}</span>\n      <span className={slots.labelDetail()}>{detail}</span>\n    </div>\n  );\n}\nHexChartTooltip.displayName = 'HexChart.Tooltip';\n\nexport interface HexChartLegendProps extends ComponentProps<'div'> {\n  /** Show each series' share of the whole beside its name. */\n  showValue?: boolean;\n}\n\n/**\n * A swatch, a name and a share per series, under the chart. Each is a button that\n * selects its series, and usually the easier target: a series worth a couple of percent\n * is a handful of cells.\n */\nfunction HexChartLegend({\n  showValue = true,\n  className,\n  render,\n  ...props\n}: HexChartLegendProps & RenderProp<'div'>) {\n  const { data, total, colors, activeIndex, press } = useChart('HexChart.Legend');\n  const slots = hexChartVariants();\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'hex-chart-legend',\n        className: slots.legend({ className }),\n        children: data.map((series, index) => {\n          const percent = percentOf(series.value, total);\n          return (\n            <button\n              key={series.label}\n              type=\"button\"\n              aria-pressed={activeIndex === index}\n              aria-label={`${series.label}, ${percent} percent`}\n              className={slots.legendItem()}\n              style={{ opacity: activeIndex >= 0 && activeIndex !== index ? 0.4 : 1 }}\n              onClick={() => press(index)}\n            >\n              <span className={slots.swatch()} style={{ background: colors[index] }} />\n              <span className={slots.legendName()}>{series.label}</span>\n              {showValue ? <span className={slots.legendValue()}>{percent}%</span> : null}\n            </button>\n          );\n        }),\n      },\n      props\n    ),\n  });\n  return data.length ? element : null;\n}\nHexChartLegend.displayName = 'HexChart.Legend';\n\nexport interface HexChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the chart is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a comparison, a caveat. */\n  caption?: ReactNode;\n  /** Prettier names for the series, keyed by their `label`. */\n  labels?: Record<string, string>;\n  /**\n   * Draw a swatch and a name per series along the trailing edge. For two or three short\n   * names. Past that use `HexChart.Legend`, which runs across the full width.\n   */\n  legend?: boolean;\n  /** Trailing slot: a control, a badge, a range picker. Wins over `legend`. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the honeycomb. The value is not derived here even though there is a\n * total, because a total of 6750 is a count, a currency or a percentage depending on\n * what was counted, and only the caller knows which.\n */\nfunction HexChartHeader({\n  title,\n  value,\n  caption,\n  labels,\n  legend = false,\n  className,\n  children,\n  render,\n  ...props\n}: HexChartHeaderProps & RenderProp<'div'>) {\n  const { data, colors } = useChart('HexChart.Header');\n  const slots = hexChartVariants();\n  const trailing =\n    children ??\n    (legend && data.length\n      ? data.map((series, index) => (\n          <span key={series.label} className={slots.headerKey()}>\n            <span className={slots.swatch()} style={{ background: colors[index] }} />\n            {labels?.[series.label] ?? series.label}\n          </span>\n        ))\n      : null);\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'hex-chart-header',\n        className: slots.header({ className }),\n        children: (\n          <>\n            <div className={slots.headerText()}>\n              {title ? <div className={slots.headerMuted()}>{title}</div> : null}\n              {value !== undefined && value !== null ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {trailing ? <div className={slots.headerTrailing()}>{trailing}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nHexChartHeader.displayName = 'HexChart.Header';\n\nexport const HexChart = Object.assign(HexChartRoot, {\n  Header: HexChartHeader,\n  Cells: HexChartCells,\n  Tooltip: HexChartTooltip,\n  Legend: HexChartLegend,\n  Skeleton: HexChartSkeleton,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport { HexChartHeader, HexChartCells, HexChartTooltip, HexChartLegend, HexChartSkeleton };\n"
    },
    {
      "path": "components/panelui/hex-chart/hex-layout.ts",
      "type": "registry:component",
      "target": "@components/panelui/hex-chart/hex-layout.ts",
      "content": "/**\n * The geometry behind HexChart: how big the cells are, how many fit, which series owns\n * each one, and which cell a point lands on.\n */\n\nexport type HexShape = 'blob' | 'grid';\n\nexport interface HexMetrics {\n  radius: number;\n  height: number;\n  stepX: number;\n  stepY: number;\n}\n\n/** One cell of the field, placed. `owner` is the index of its series, or -1 when unfilled. */\nexport interface HexCell {\n  column: number;\n  row: number;\n  x: number;\n  y: number;\n  owner: number;\n  /** Its place in the fill order among the filled cells, for the entrance. Unfilled cells are -1. */\n  order: number;\n}\n\nexport interface HexPlan {\n  metrics: HexMetrics;\n  columns: number;\n  rows: number;\n  left: number;\n  top: number;\n  cells: HexCell[];\n  /** Cells each series took, by series. */\n  counts: number[];\n  /** The top middle of each series' cells, where a label is hung from. */\n  anchors: { x: number; y: number }[];\n}\n\n/** How far off its ring a cell may be nudged, in field widths. About one cell. */\nconst BLOB_WOBBLE = 0.09;\n\nexport function hexMetrics(radius: number): HexMetrics {\n  const width = Math.sqrt(3) * radius;\n  return { radius, height: radius * 2, stepX: width, stepY: radius * 1.5 };\n}\n\n/** The cell radius that fits `columns` across `width`, leaving room for the offset rows' half cell. */\nexport function hexRadiusFor(width: number, columns: number): number {\n  if (width <= 0 || columns <= 0) return 0;\n  return width / (columns + 0.5) / Math.sqrt(3);\n}\n\nexport function hexRowsFor(height: number, metrics: HexMetrics): number {\n  if (height <= 0 || metrics.stepY <= 0) return 0;\n  return Math.max(1, Math.floor((height - metrics.height / 4) / metrics.stepY));\n}\n\n/** The centre of a cell, with odd rows nested half a cell right. */\nexport function hexCenter(column: number, row: number, metrics: HexMetrics, left: number, top: number) {\n  return {\n    x: left + metrics.stepX / 2 + (row % 2 === 1 ? metrics.stepX / 2 : 0) + column * metrics.stepX,\n    y: top + metrics.radius + row * metrics.stepY,\n  };\n}\n\n/** A number in 0…1 from a pair of coordinates, the same one every time, so the same data draws the same edge. */\nfunction hashUnit(a: number, b: number): number {\n  let h = Math.imul(a + 1, 374761393) ^ Math.imul(b + 1, 668265263);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;\n}\n\n/**\n * How many cells each value gets out of `budget`, by largest remainder: rounding each\n * share on its own does not add up, and a spare cell is a cell of some colour that\n * nothing in the data accounts for.\n */\nexport function shareCounts(values: readonly number[], budget: number): number[] {\n  const total = values.reduce((sum, value) => sum + Math.max(0, value), 0);\n  if (total <= 0 || budget <= 0) return values.map(() => 0);\n  const exact = values.map((value) => (Math.max(0, value) / total) * budget);\n  const counts = exact.map(Math.floor);\n  const spare = budget - counts.reduce((sum, count) => sum + count, 0);\n  exact\n    .map((value, index) => ({ index, fraction: value - Math.floor(value) }))\n    .sort((a, b) => b.fraction - a.fraction || a.index - b.index)\n    .slice(0, spare)\n    .forEach(({ index }) => {\n      counts[index]! += 1;\n    });\n  return counts;\n}\n\n/**\n * Every cell of the field, in the order the series fill it. `grid` is reading order,\n * which a reader can count off. `blob` grows out from the middle in the field's own\n * proportions, with each cell's distance nudged by a hash of its coordinates, so the\n * boundaries come out ragged and the same every time.\n */\nexport function hexFillOrder(columns: number, rows: number, shape: HexShape) {\n  const cells: { column: number; row: number }[] = [];\n  for (let row = 0; row < rows; row += 1) {\n    for (let column = 0; column < columns; column += 1) cells.push({ column, row });\n  }\n  if (shape === 'grid' || columns < 2 || rows < 2) return cells;\n  const midColumn = (columns - 1) / 2;\n  const midRow = (rows - 1) / 2;\n  return cells\n    .map((cell) => {\n      // Odd rows sit half a cell right, and a distance measured without that leans the blob.\n      const dx = (cell.column + (cell.row % 2 === 1 ? 0.5 : 0) - midColumn) / columns;\n      const dy = (cell.row - midRow) / rows;\n      return { cell, key: Math.sqrt(dx * dx + dy * dy) + (hashUnit(cell.column, cell.row) - 0.5) * BLOB_WOBBLE };\n    })\n    .sort((a, b) => a.key - b.key)\n    .map(({ cell }) => cell);\n}\n\n/** Lays the field out in a box and hands its cells to the series. */\nexport function hexPlan(\n  values: readonly number[],\n  box: { width: number; height: number },\n  options: { columns: number; shape: HexShape; density: number; loading: boolean }\n): HexPlan {\n  const columns = Math.max(1, Math.round(options.columns));\n  // A plot shorter than one cell gets smaller cells: the field would otherwise spill out of it.\n  const metrics = hexMetrics(Math.min(hexRadiusFor(box.width, columns), box.height / 2));\n  const rows = hexRowsFor(box.height, metrics);\n  const fieldWidth = metrics.stepX * (columns + 0.5);\n  const fieldHeight = rows > 0 ? (rows - 1) * metrics.stepY + metrics.height : 0;\n  const left = (box.width - fieldWidth) / 2;\n  const top = (box.height - fieldHeight) / 2;\n  const capacity = columns * rows;\n\n  // Loading has no split to show yet, so nothing is taken.\n  const budget = options.loading\n    ? 0\n    : options.shape === 'grid'\n      ? capacity\n      : Math.max(0, Math.min(capacity, Math.round(capacity * options.density)));\n  const counts = shareCounts(values, budget);\n\n  // Smallest first for a blob, so the hardest series to find gets the middle. Data order\n  // for a grid, because reordering it would break the count.\n  const sequence = values.map((_unused, index) => index);\n  if (options.shape === 'blob') sequence.sort((a, b) => counts[a]! - counts[b]! || a - b);\n\n  const fill = hexFillOrder(columns, rows, options.shape);\n  const claimed = new Map<string, { owner: number; order: number }>();\n  let cursor = 0;\n  for (const owner of sequence) {\n    for (let taken = 0; taken < counts[owner]!; taken += 1) {\n      const cell = fill[cursor];\n      if (!cell) break;\n      claimed.set(`${cell.column}:${cell.row}`, { owner, order: cursor });\n      cursor += 1;\n    }\n  }\n\n  const cells: HexCell[] = [];\n  for (let row = 0; row < rows; row += 1) {\n    for (let column = 0; column < columns; column += 1) {\n      const claim = claimed.get(`${column}:${row}`);\n      cells.push({ column, row, ...hexCenter(column, row, metrics, left, top), owner: claim?.owner ?? -1, order: claim?.order ?? -1 });\n    }\n  }\n\n  const anchors = values.map((_unused, index) => {\n    const own = cells.filter(({ owner }) => owner === index);\n    if (!own.length) return { x: left + fieldWidth / 2, y: top };\n    return {\n      x: own.reduce((sum, { x }) => sum + x, 0) / own.length,\n      y: Math.min(...own.map(({ y }) => y)) - metrics.radius,\n    };\n  });\n\n  return { metrics, columns, rows, left, top, cells, counts, anchors };\n}\n\n/** The cell under a point, or null outside the field. Only three rows can be near any y. */\nexport function hexAt(x: number, y: number, plan: Pick<HexPlan, 'metrics' | 'left' | 'top' | 'columns' | 'rows'>) {\n  const { metrics, left, top, columns, rows } = plan;\n  if (metrics.radius <= 0) return null;\n  const nearRow = Math.round((y - top - metrics.radius) / metrics.stepY);\n  let best: { column: number; row: number } | null = null;\n  let bestDistance = Infinity;\n  for (let row = nearRow - 1; row <= nearRow + 1; row += 1) {\n    if (row < 0 || row >= rows) continue;\n    const offset = row % 2 === 1 ? metrics.stepX / 2 : 0;\n    const column = Math.round((x - left - metrics.stepX / 2 - offset) / metrics.stepX);\n    if (column < 0 || column >= columns) continue;\n    const centre = hexCenter(column, row, metrics, left, top);\n    const distance = (x - centre.x) ** 2 + (y - centre.y) ** 2;\n    if (distance < bestDistance) {\n      bestDistance = distance;\n      best = { column, row };\n    }\n  }\n  return bestDistance <= metrics.radius ** 2 ? best : null;\n}\n"
    }
  ]
}
