{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "x-axis",
  "type": "registry:component",
  "title": "X Axis",
  "description": "X-axis component for time-series charts",
  "registryDependencies": [
    "@bklit/chart-context",
    "@bklit/utils"
  ],
  "files": [
    {
      "path": "src/charts/x-axis.tsx",
      "content": "\"use client\";\n\nimport { memo, useEffect, useMemo, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { useChart, useChartStable } from \"./chart-context\";\nimport { shortDateFmt } from \"./chart-formatters\";\nimport { DEFAULT_Y_DOMAIN_TWEEN_MS } from \"./chart-phase\";\nimport { LINE_LOADING_PULSE_EASE } from \"./line-loading-timing\";\n\nconst X_AXIS_POSITION_TWEEN_MS = DEFAULT_Y_DOMAIN_TWEEN_MS;\n\nexport interface XAxisProps {\n  /** Number of ticks to show (including first and last). Default: 5. */\n  numTicks?: number;\n  /** Width of the date ticker box for fade calculation. Default: 50 */\n  tickerHalfWidth?: number;\n  /**\n   * `\"data\"` — tick labels snap to data rows so crosshair and tooltip stay aligned (default).\n   * `\"domain\"` — evenly spaced ticks across the time domain (may not align with hover).\n   */\n  tickMode?: \"domain\" | \"data\";\n}\n\ninterface AxisTick {\n  date: Date;\n  x: number;\n  label: string;\n}\n\ninterface XAxisLabelProps {\n  label: string;\n  x: number;\n  crosshairX: number | null;\n  hoveredLabel: string | null;\n  isHovering: boolean;\n  tickerHalfWidth: number;\n  animatePosition: boolean;\n}\n\nfunction XAxisLabel({\n  label,\n  x,\n  crosshairX,\n  hoveredLabel,\n  isHovering,\n  tickerHalfWidth,\n  animatePosition,\n}: XAxisLabelProps) {\n  const fadeBuffer = 20;\n  const fadeRadius = tickerHalfWidth + fadeBuffer;\n\n  let opacity = 1;\n  if (isHovering && crosshairX !== null) {\n    const distance = Math.abs(x - crosshairX);\n    if (distance < tickerHalfWidth) {\n      opacity = 0;\n    } else if (hoveredLabel && label === hoveredLabel) {\n      opacity = 0;\n    } else if (distance < fadeRadius) {\n      opacity = (distance - tickerHalfWidth) / fadeBuffer;\n    }\n  }\n\n  return (\n    <div\n      className=\"absolute\"\n      style={{\n        left: x,\n        bottom: 12,\n        width: 0,\n        display: \"flex\",\n        justifyContent: \"center\",\n        transition: animatePosition\n          ? `left ${X_AXIS_POSITION_TWEEN_MS}ms cubic-bezier(${LINE_LOADING_PULSE_EASE.join(\", \")})`\n          : undefined,\n      }}\n    >\n      <span\n        className={cn(\"whitespace-nowrap text-chart-label text-xs\")}\n        style={{\n          opacity,\n          transition: \"opacity 0.4s ease-in-out\",\n        }}\n      >\n        {label}\n      </span>\n    </div>\n  );\n}\n\nconst MAX_GAP_LAYOUTS = 400;\n\nfunction binomial(n: number, k: number): number {\n  if (k < 0 || k > n) {\n    return 0;\n  }\n  let result = 1;\n  for (let i = 0; i < k; i++) {\n    result = (result * (n - i)) / (i + 1);\n  }\n  return result;\n}\n\n/** All ways to split `span` into `parts` positive integer gaps. */\nfunction composePositiveSum(sum: number, parts: number): number[][] {\n  if (parts === 1) {\n    return sum >= 1 ? [[sum]] : [];\n  }\n\n  const layouts: number[][] = [];\n  for (let gap = 1; gap <= sum - (parts - 1); gap++) {\n    for (const tail of composePositiveSum(sum - gap, parts - 1)) {\n      layouts.push([gap, ...tail]);\n    }\n  }\n  return layouts;\n}\n\nfunction gapsToIndices(gaps: number[]): number[] {\n  const indices = [0];\n  let position = 0;\n  for (const gap of gaps) {\n    position += gap;\n    indices.push(position);\n  }\n  return indices;\n}\n\nfunction indicesForTickCount(length: number, tickCount: number): number[] {\n  const span = length - 1;\n  if (span <= 0) {\n    return [0];\n  }\n\n  const rawIndices = Array.from({ length: tickCount }, (_, index) =>\n    Math.round((index / (tickCount - 1)) * span)\n  );\n\n  const indices = [...new Set(rawIndices)].sort((a, b) => a - b);\n  if (indices[0] !== 0) {\n    indices.unshift(0);\n  }\n  if (indices.at(-1) !== span) {\n    indices.push(span);\n  }\n\n  return [...new Set(indices)].sort((a, b) => a - b);\n}\n\nfunction allIndexLayouts(length: number, tickCount: number): number[][] {\n  const span = length - 1;\n  if (span <= 0) {\n    return [[0]];\n  }\n\n  const gapCount = tickCount - 1;\n  if (gapCount <= 0) {\n    return [[0]];\n  }\n\n  const layoutCount = binomial(span - 1, gapCount - 1);\n  if (layoutCount > MAX_GAP_LAYOUTS) {\n    return [indicesForTickCount(length, tickCount)];\n  }\n\n  return composePositiveSum(span, gapCount).map(gapsToIndices);\n}\n\nfunction dedupeIndicesByLabel(\n  indices: number[],\n  data: Record<string, unknown>[],\n  dateLabels: string[],\n  xAccessor: (d: Record<string, unknown>) => Date\n): number[] {\n  const seenLabels = new Set<string>();\n  const deduped: number[] = [];\n\n  for (const index of indices) {\n    const point = data[index];\n    if (!point) {\n      continue;\n    }\n    const label = dateLabels[index] ?? shortDateFmt.format(xAccessor(point));\n    if (seenLabels.has(label)) {\n      continue;\n    }\n    seenLabels.add(label);\n    deduped.push(index);\n  }\n\n  return deduped;\n}\n\ninterface TickLayoutScore {\n  score: number;\n  symmetryPenalty: number;\n  countDistance: number;\n  /** 0 = smallest gap at end, 1 = at start, 2 = in the middle */\n  edgePreference: number;\n}\n\nfunction indexGaps(indices: number[]): number[] {\n  const gaps: number[] = [];\n  for (let i = 1; i < indices.length; i++) {\n    const current = indices[i];\n    const previous = indices[i - 1];\n    if (current == null || previous == null) {\n      continue;\n    }\n    gaps.push(current - previous);\n  }\n  return gaps;\n}\n\nfunction smallestGapEdgePreference(indices: number[]): number {\n  const gaps = indexGaps(indices);\n  const smallestGap = Math.min(...gaps);\n  const smallestGapIndex = gaps.indexOf(smallestGap);\n  if (smallestGapIndex === gaps.length - 1) {\n    return 0;\n  }\n  if (smallestGapIndex === 0) {\n    return 1;\n  }\n  return 2;\n}\n\nfunction scoreTickLayout(\n  indices: number[],\n  resolveXPx: (index: number) => number,\n  targetCount: number\n): TickLayoutScore {\n  if (indices.length < 2) {\n    return {\n      score: Number.POSITIVE_INFINITY,\n      symmetryPenalty: Number.POSITIVE_INFINITY,\n      countDistance: Number.POSITIVE_INFINITY,\n      edgePreference: Number.POSITIVE_INFINITY,\n    };\n  }\n\n  const pixelGaps: number[] = [];\n  for (let i = 1; i < indices.length; i++) {\n    const current = indices[i];\n    const previous = indices[i - 1];\n    if (current == null || previous == null) {\n      continue;\n    }\n    pixelGaps.push(resolveXPx(current) - resolveXPx(previous));\n  }\n\n  const minGap = Math.min(...pixelGaps);\n  const maxGap = Math.max(...pixelGaps);\n  const meanGap =\n    pixelGaps.reduce((sum, gap) => sum + gap, 0) / pixelGaps.length;\n  const spreadRatio =\n    meanGap > 0 ? (maxGap - minGap) / meanGap : maxGap - minGap;\n  const countDistance = Math.abs(indices.length - targetCount);\n\n  const gaps = indexGaps(indices);\n  const smallestGap = Math.min(...gaps);\n  const smallestGapIndex = gaps.indexOf(smallestGap);\n  const interiorPenalty =\n    smallestGapIndex > 0 && smallestGapIndex < gaps.length - 1 ? 0.08 : 0;\n\n  const symmetryPenalty =\n    gaps.reduce((penalty, gap, index) => {\n      return penalty + Math.abs(gap - (gaps.at(-1 - index) ?? gap));\n    }, 0) / gaps.length;\n\n  return {\n    score:\n      spreadRatio +\n      0.1 * countDistance +\n      interiorPenalty +\n      symmetryPenalty * 0.02,\n    symmetryPenalty,\n    countDistance,\n    edgePreference: smallestGapEdgePreference(indices),\n  };\n}\n\nfunction isBetterTickLayout(\n  next: TickLayoutScore,\n  best: TickLayoutScore,\n  nextCountDistance: number,\n  bestCountDistance: number\n): boolean {\n  if (next.score < best.score - 1e-6) {\n    return true;\n  }\n  if (Math.abs(next.score - best.score) > 1e-6) {\n    return false;\n  }\n  if (nextCountDistance < bestCountDistance) {\n    return true;\n  }\n  if (nextCountDistance > bestCountDistance) {\n    return false;\n  }\n  if (next.symmetryPenalty < best.symmetryPenalty - 1e-6) {\n    return true;\n  }\n  if (next.symmetryPenalty > best.symmetryPenalty + 1e-6) {\n    return false;\n  }\n  return next.edgePreference < best.edgePreference;\n}\n\n/**\n * Picks tick indices with the most even on-screen spacing. Tries\n * `targetCount ± 1` and evaluates every gap layout when feasible.\n */\nexport function selectEvenlySpacedIndices(\n  length: number,\n  targetCount: number,\n  options?: {\n    data?: Record<string, unknown>[];\n    dateLabels?: string[];\n    xAccessor?: (d: Record<string, unknown>) => Date;\n    resolveXPx?: (index: number) => number;\n  }\n): number[] {\n  if (length <= 0) {\n    return [];\n  }\n  if (length === 1) {\n    return [0];\n  }\n  if (length <= targetCount) {\n    return Array.from({ length }, (_, index) => index);\n  }\n\n  const resolveXPx = options?.resolveXPx ?? ((index: number) => index);\n\n  const minCount = Math.max(2, targetCount - 1);\n  const maxCount = Math.min(length, targetCount + 1);\n\n  let bestIndices = indicesForTickCount(length, targetCount);\n  let bestScore = scoreTickLayout(bestIndices, resolveXPx, targetCount);\n  let bestCountDistance = bestScore.countDistance;\n\n  for (let tickCount = minCount; tickCount <= maxCount; tickCount++) {\n    for (const rawIndices of allIndexLayouts(length, tickCount)) {\n      const indices =\n        options?.data && options.dateLabels && options.xAccessor\n          ? dedupeIndicesByLabel(\n              rawIndices,\n              options.data,\n              options.dateLabels,\n              options.xAccessor\n            )\n          : rawIndices;\n\n      if (indices.length < 2) {\n        continue;\n      }\n\n      const layoutScore = scoreTickLayout(indices, resolveXPx, targetCount);\n      const countDistance = Math.abs(indices.length - targetCount);\n\n      if (\n        isBetterTickLayout(\n          layoutScore,\n          bestScore,\n          countDistance,\n          bestCountDistance\n        )\n      ) {\n        bestIndices = indices;\n        bestScore = layoutScore;\n        bestCountDistance = countDistance;\n      }\n    }\n  }\n\n  return bestIndices;\n}\n\nfunction buildDataAlignedTicks({\n  data,\n  dateLabels,\n  marginLeft,\n  targetTickCount,\n  xAccessor,\n  xScale,\n}: {\n  data: Record<string, unknown>[];\n  dateLabels: string[];\n  marginLeft: number;\n  targetTickCount: number;\n  xAccessor: (d: Record<string, unknown>) => Date;\n  xScale: (date: Date) => number | undefined;\n}): AxisTick[] {\n  const seenLabels = new Set<string>();\n  const ticks: AxisTick[] = [];\n\n  const resolveXPx = (index: number) => {\n    const point = data[index];\n    if (!point) {\n      return index;\n    }\n    return xScale(xAccessor(point)) ?? 0;\n  };\n\n  for (const index of selectEvenlySpacedIndices(data.length, targetTickCount, {\n    data,\n    dateLabels,\n    resolveXPx,\n    xAccessor,\n  })) {\n    const point = data[index];\n    if (!point) {\n      continue;\n    }\n    const date = xAccessor(point);\n    const label = dateLabels[index] ?? shortDateFmt.format(date);\n    if (seenLabels.has(label)) {\n      continue;\n    }\n    seenLabels.add(label);\n    ticks.push({\n      date,\n      label,\n      x: (xScale(date) ?? 0) + marginLeft,\n    });\n  }\n\n  return ticks;\n}\n\nfunction buildDomainTicks({\n  marginLeft,\n  numTicks,\n  xScale,\n}: {\n  marginLeft: number;\n  numTicks: number;\n  xScale: {\n    domain: () => Date[];\n    (date: Date): number | undefined;\n  };\n}): AxisTick[] {\n  const domain = xScale.domain();\n  const startDate = domain[0];\n  const endDate = domain[1];\n\n  if (!(startDate && endDate)) {\n    return [];\n  }\n\n  const startTime = startDate.getTime();\n  const endTime = endDate.getTime();\n  const timeRange = endTime - startTime;\n  const tickCount = Math.max(2, numTicks);\n  const seenLabels = new Set<string>();\n  const ticks: AxisTick[] = [];\n\n  for (let i = 0; i < tickCount; i++) {\n    const t = i / (tickCount - 1);\n    const date = new Date(startTime + t * timeRange);\n    const label = shortDateFmt.format(date);\n    if (seenLabels.has(label)) {\n      continue;\n    }\n    seenLabels.add(label);\n    ticks.push({\n      date,\n      label,\n      x: (xScale(date) ?? 0) + marginLeft,\n    });\n  }\n\n  return ticks;\n}\n\nfunction domainExtendsPastData(\n  data: Record<string, unknown>[],\n  xAccessor: (d: Record<string, unknown>) => Date,\n  xScale: { domain: () => Date[] }\n): boolean {\n  if (data.length === 0) {\n    return false;\n  }\n  const domainEnd = xScale.domain()[1];\n  const lastPoint = data.at(-1);\n  if (!(domainEnd && lastPoint)) {\n    return false;\n  }\n  return domainEnd.getTime() > xAccessor(lastPoint).getTime();\n}\n\n/** Domain ticks for the projection tail when brush keeps data-aligned labels. */\nfunction appendProjectionTailTicks(\n  ticks: AxisTick[],\n  data: Record<string, unknown>[],\n  xAccessor: (d: Record<string, unknown>) => Date,\n  xScale: {\n    domain: () => Date[];\n    (date: Date): number | undefined;\n  },\n  marginLeft: number,\n  maxExtraTicks: number\n): AxisTick[] {\n  if (data.length === 0 || maxExtraTicks <= 0) {\n    return ticks;\n  }\n\n  const lastPoint = data.at(-1);\n  const domainEnd = xScale.domain()[1];\n  if (!(lastPoint && domainEnd)) {\n    return ticks;\n  }\n\n  const lastDate = xAccessor(lastPoint);\n  const startTime = lastDate.getTime();\n  const endTime = domainEnd.getTime();\n  if (endTime <= startTime) {\n    return ticks;\n  }\n\n  const seenLabels = new Set(ticks.map((tick) => tick.label));\n  const extras: AxisTick[] = [];\n  const extraCount = Math.min(maxExtraTicks, 3);\n\n  for (let i = 1; i <= extraCount; i++) {\n    const date = new Date(\n      startTime + (i / (extraCount + 1)) * (endTime - startTime)\n    );\n    const label = shortDateFmt.format(date);\n    if (seenLabels.has(label)) {\n      continue;\n    }\n    seenLabels.add(label);\n    extras.push({\n      date,\n      label,\n      x: (xScale(date) ?? 0) + marginLeft,\n    });\n  }\n\n  const endLabel = shortDateFmt.format(domainEnd);\n  if (!seenLabels.has(endLabel)) {\n    extras.push({\n      date: domainEnd,\n      label: endLabel,\n      x: (xScale(domainEnd) ?? 0) + marginLeft,\n    });\n  }\n\n  if (extras.length === 0) {\n    return ticks;\n  }\n\n  return [...ticks, ...extras].sort((a, b) => a.x - b.x);\n}\n\nexport function XAxis(props: XAxisProps) {\n  const { containerRef } = useChartStable();\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  const container = containerRef.current;\n  if (!(mounted && container)) {\n    return null;\n  }\n\n  return <XAxisInner {...props} container={container} />;\n}\n\nconst XAxisInner = memo(function XAxisInner({\n  numTicks = 5,\n  tickerHalfWidth = 50,\n  tickMode = \"data\",\n  container,\n}: XAxisProps & { container: HTMLDivElement }) {\n  const { xScale, margin, tooltipData, data, xAccessor, dateLabels, xDomain } =\n    useChart();\n\n  const labelsToShow = useMemo(() => {\n    const projectionExtendsScale =\n      tickMode === \"data\" && domainExtendsPastData(data, xAccessor, xScale);\n\n    if (tickMode === \"domain\") {\n      return buildDomainTicks({\n        marginLeft: margin.left,\n        numTicks,\n        xScale,\n      });\n    }\n\n    // No brush: evenly spaced ticks across the full domain (data + projection).\n    if (projectionExtendsScale && xDomain == null) {\n      return buildDomainTicks({\n        marginLeft: margin.left,\n        numTicks,\n        xScale,\n      });\n    }\n\n    const dataTicks = buildDataAlignedTicks({\n      data,\n      dateLabels,\n      marginLeft: margin.left,\n      targetTickCount: numTicks,\n      xAccessor,\n      xScale,\n    });\n\n    // Brush: keep data-aligned ticks, add labels only in the projection tail.\n    if (projectionExtendsScale && xDomain != null) {\n      return appendProjectionTailTicks(\n        dataTicks,\n        data,\n        xAccessor,\n        xScale,\n        margin.left,\n        Math.max(1, numTicks - dataTicks.length + 1)\n      );\n    }\n\n    return dataTicks;\n  }, [\n    tickMode,\n    xDomain,\n    data,\n    dateLabels,\n    xAccessor,\n    xScale,\n    margin.left,\n    numTicks,\n  ]);\n\n  const isHovering = tooltipData !== null;\n  const crosshairX = tooltipData ? tooltipData.x + margin.left : null;\n  const hoveredLabel =\n    isHovering && tooltipData\n      ? (dateLabels[tooltipData.index] ??\n        shortDateFmt.format(xAccessor(tooltipData.point)))\n      : null;\n\n  return createPortal(\n    <div className=\"pointer-events-none absolute inset-0\">\n      {labelsToShow.map((item) => (\n        <XAxisLabel\n          animatePosition={xDomain == null}\n          crosshairX={crosshairX}\n          hoveredLabel={hoveredLabel}\n          isHovering={isHovering}\n          key={`${item.date.getTime()}-${item.x}`}\n          label={item.label}\n          tickerHalfWidth={tickerHalfWidth}\n          x={item.x}\n        />\n      ))}\n    </div>,\n    container\n  );\n});\n\nXAxis.displayName = \"XAxis\";\n\nexport default XAxis;\n",
      "type": "registry:component",
      "target": "components/charts/x-axis.tsx"
    }
  ]
}