{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "markers",
  "type": "registry:component",
  "title": "Chart Markers",
  "description": "Marker components for highlighting data points on charts",
  "registryDependencies": [
    "@bklit/chart-context",
    "@bklit/chart-tooltip",
    "@bklit/utils"
  ],
  "files": [
    {
      "path": "src/charts/markers/chart-markers.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useMemo } from \"react\";\nimport { chartCssVars, useChart, useChartHover } from \"../chart-context\";\nimport { type ChartMarker, MarkerGroup } from \"./marker-group\";\n\nexport interface ChartMarkersProps {\n  /** Array of markers to display */\n  items: ChartMarker[];\n  /** Size of each marker circle. Default: 28 */\n  size?: number;\n  /** Whether to show vertical guide lines. Default: true */\n  showLines?: boolean;\n  /** Whether to animate markers on entrance. Default: true */\n  animate?: boolean;\n}\n\n// Tooltip content for markers\nexport interface MarkerTooltipContentProps {\n  markers: ChartMarker[];\n}\n\nconst MAX_TOOLTIP_MARKERS = 2;\n\nexport function MarkerTooltipContent({ markers }: MarkerTooltipContentProps) {\n  if (markers.length === 0) {\n    return null;\n  }\n\n  const visibleMarkers = markers.slice(0, MAX_TOOLTIP_MARKERS);\n  const hiddenCount = markers.length - MAX_TOOLTIP_MARKERS;\n\n  return (\n    <div className=\"mt-2 space-y-2 border-chart-tooltip-muted border-t pt-2\">\n      {visibleMarkers.map((marker) => {\n        const isClickable = !!(marker.onClick || marker.href);\n        return (\n          <div className=\"flex items-start gap-2\" key={marker.title}>\n            <div\n              className=\"flex h-5 w-5 shrink-0 items-center justify-center rounded-full\"\n              style={{\n                backgroundColor: marker.color || chartCssVars.markerBackground,\n                border: `1px solid ${chartCssVars.markerBorder}`,\n              }}\n            >\n              <span\n                className=\"text-xs\"\n                style={{ color: chartCssVars.markerForeground }}\n              >\n                {marker.icon}\n              </span>\n            </div>\n            <div className=\"min-w-0 flex-1\">\n              {marker.content ? (\n                marker.content\n              ) : (\n                <>\n                  <div className=\"flex items-center gap-1.5 truncate font-medium text-chart-tooltip-foreground text-sm\">\n                    {marker.title}\n                    {isClickable && (\n                      <span className=\"text-[10px] text-chart-tooltip-muted\">\n                        ↗\n                      </span>\n                    )}\n                  </div>\n                  {marker.description && (\n                    <div className=\"truncate text-chart-tooltip-muted text-xs\">\n                      {marker.description}\n                    </div>\n                  )}\n                </>\n              )}\n            </div>\n          </div>\n        );\n      })}\n      {hiddenCount > 0 && (\n        <div className=\"pl-7 text-chart-tooltip-muted text-xs\">\n          +{hiddenCount} more...\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function ChartMarkers({\n  items,\n  size = 28,\n  showLines = true,\n  animate = true,\n}: ChartMarkersProps) {\n  const {\n    xScale,\n    innerHeight,\n    margin,\n    containerRef,\n    tooltipData,\n    setTooltipData,\n    animationDuration,\n  } = useChart();\n\n  // Hide the crosshair when hovering markers (matching original behavior)\n  const handleMarkerHover = useCallback(\n    (markers: ChartMarker[] | null) => {\n      if (markers) {\n        // Hide crosshair when hovering a marker\n        setTooltipData(null);\n      }\n    },\n    [setTooltipData]\n  );\n\n  // Group markers by date\n  const markersByDate = useMemo(() => {\n    const grouped = new Map<string, ChartMarker[]>();\n    for (const marker of items) {\n      const dateKey = marker.date.toDateString();\n      const existing = grouped.get(dateKey) || [];\n      grouped.set(dateKey, [...existing, marker]);\n    }\n    return grouped;\n  }, [items]);\n\n  // Get markers for currently hovered date\n  const _activeMarkers = useMemo(() => {\n    if (!tooltipData) {\n      return [];\n    }\n    const point = tooltipData.point;\n    const date =\n      point.date instanceof Date\n        ? point.date\n        : new Date(point.date as string | number);\n    const dateKey = date.toDateString();\n    return markersByDate.get(dateKey) || [];\n  }, [tooltipData, markersByDate]);\n\n  // Y position for markers (above chart area)\n  const markerY = -8;\n\n  return (\n    <>\n      {/* SVG markers rendered in chart space */}\n      {Array.from(markersByDate.entries()).map(\n        ([dateKey, dateMarkers], groupIndex) => {\n          const markerDate = dateMarkers[0]?.date;\n          if (!markerDate) {\n            return null;\n          }\n\n          const markerX = xScale(markerDate) ?? 0;\n          const isActive = tooltipData\n            ? (() => {\n                const point = tooltipData.point;\n                const date =\n                  point.date instanceof Date\n                    ? point.date\n                    : new Date(point.date as string | number);\n                return date.toDateString() === dateKey;\n              })()\n            : undefined;\n\n          const markerDelay = animate\n            ? animationDuration / 1000 + groupIndex * 0.1\n            : 0;\n\n          return (\n            <MarkerGroup\n              animate={animate}\n              animationDelay={markerDelay}\n              containerRef={containerRef}\n              isActive={isActive}\n              key={dateKey}\n              lineHeight={innerHeight}\n              marginLeft={margin.left}\n              marginTop={margin.top}\n              markers={dateMarkers}\n              onHover={handleMarkerHover}\n              showLine={showLines}\n              size={size}\n              x={markerX}\n              y={markerY}\n            />\n          );\n        }\n      )}\n    </>\n  );\n}\n\n// Hook to get active markers for tooltip\nexport function useActiveMarkers(items: ChartMarker[]) {\n  const { tooltipData } = useChartHover();\n\n  return useMemo(() => {\n    if (!tooltipData) {\n      return [];\n    }\n    const point = tooltipData.point;\n    const date =\n      point.date instanceof Date\n        ? point.date\n        : new Date(point.date as string | number);\n    const dateKey = date.toDateString();\n    return items.filter((m) => m.date.toDateString() === dateKey);\n  }, [tooltipData, items]);\n}\n\nChartMarkers.displayName = \"ChartMarkers\";\n// Marker for SVG component detection (renders after mouse overlay for interaction)\n(ChartMarkers as { __isChartMarkers?: boolean }).__isChartMarkers = true;\nMarkerTooltipContent.displayName = \"MarkerTooltipContent\";\n\nexport default ChartMarkers;\n",
      "type": "registry:component",
      "target": "components/charts/markers/chart-markers.tsx"
    },
    {
      "path": "src/charts/markers/marker-group.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport type * as React from \"react\";\nimport { useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport { chartCssVars } from \"../chart-context\";\n\n// Fan configuration\nconst FAN_RADIUS = 50;\nconst FAN_ANGLE = 160;\n\nexport interface ChartMarker {\n  /** Date for this marker (will be matched to nearest data point) */\n  date: Date;\n  /** Icon to display in the marker circle */\n  icon: React.ReactNode;\n  /** Title shown in tooltip */\n  title: string;\n  /** Optional description */\n  description?: string;\n  /** Optional custom content for tooltip (overrides title/description) */\n  content?: React.ReactNode;\n  /** Optional color override for the marker circle */\n  color?: string;\n  /** Click handler */\n  onClick?: () => void;\n  /** URL to navigate to when clicked */\n  href?: string;\n  /** Open href in new tab. Default: false */\n  target?: \"_blank\" | \"_self\";\n}\n\nexport interface MarkerGroupProps {\n  /** X position in pixels */\n  x: number;\n  /** Y position (top of chart area) */\n  y: number;\n  /** Markers at this position */\n  markers: ChartMarker[];\n  /** Whether this marker group is currently hovered (via chart hover) */\n  isActive?: boolean;\n  /** Size of each marker circle */\n  size?: number;\n  /** Callback when marker group is hovered */\n  onHover?: (markers: ChartMarker[] | null) => void;\n  /** Reference to chart container for portal positioning */\n  containerRef?: React.RefObject<HTMLDivElement | null>;\n  /** Margin left offset from chart container */\n  marginLeft?: number;\n  /** Margin top offset from chart container */\n  marginTop?: number;\n  /** Delay before entrance animation starts */\n  animationDelay?: number;\n  /** Whether the marker should animate in */\n  animate?: boolean;\n  /** Height of the vertical guide line below the marker */\n  lineHeight?: number;\n  /** Whether to show the vertical guide line. Default: true */\n  showLine?: boolean;\n  /**\n   * Force the marker fan to open even when the user isn't hovering this\n   * group directly. Used by parent layers (e.g. a clustered marker layer)\n   * to fan out a cluster when the chart's main crosshair lands on one of\n   * the cluster's buckets.\n   */\n  forceOpen?: boolean;\n  /**\n   * Make the icon `foreignObject` fill the entire circle (no 4px inset)\n   * so favicon-style icons sit edge-to-edge with the marker border.\n   */\n  iconFill?: boolean;\n  /**\n   * Override the marker circle's stroke color. Falls back to\n   * `chartCssVars.markerBorder` when not set.\n   */\n  borderColor?: string;\n  /**\n   * Marker circle stroke width in px. Default 1.5.\n   */\n  borderWidth?: number;\n  /**\n   * Cap the number of markers rendered in the fan-out arc. The count\n   * badge still reflects the full cluster size. Without this, large\n   * clusters (e.g. 20+ spikes) collapse the fan angular spacing to\n   * essentially zero and the markers stack on top of each other.\n   */\n  maxFanned?: number;\n  /**\n   * Fade this marker group when another cluster has focus. Used by\n   * parent layers to spotlight the active cluster.\n   */\n  isMuted?: boolean;\n}\n\n// Entrance + fanned + muted variants. `fanned` shrinks and dims the\n// collapsed marker while its siblings are flying out in the portal, so\n// users only see the fan and not a duplicate icon sitting underneath.\n// `muted` fades non-active clusters when one cluster is being focused.\nconst markerEntranceVariants = {\n  hidden: {\n    scale: 0.85,\n    opacity: 0,\n    filter: \"blur(2px)\",\n  },\n  visible: {\n    scale: 1,\n    opacity: 1,\n    filter: \"blur(0px)\",\n  },\n  fanned: {\n    scale: 0.6,\n    opacity: 0,\n    filter: \"blur(2px)\",\n  },\n  muted: {\n    scale: 1,\n    opacity: 0.4,\n    filter: \"blur(0px)\",\n  },\n};\n\nexport function MarkerGroup({\n  x,\n  y,\n  markers,\n  isActive = false,\n  size = 28,\n  onHover,\n  containerRef,\n  marginLeft = 0,\n  marginTop = 0,\n  animationDelay = 0,\n  animate = true,\n  lineHeight = 0,\n  showLine = true,\n  forceOpen = false,\n  iconFill = false,\n  borderColor,\n  borderWidth = 1.5,\n  maxFanned,\n  isMuted = false,\n}: MarkerGroupProps) {\n  const [isHovered, setIsHovered] = useState(false);\n  const shouldFan = (isHovered || forceOpen) && markers.length > 1;\n  const hasMultiple = markers.length > 1;\n  const fannedMarkers =\n    maxFanned === undefined ? markers : markers.slice(0, maxFanned);\n  let currentVariant: \"fanned\" | \"muted\" | \"visible\" = \"visible\";\n  if (shouldFan) {\n    currentVariant = \"fanned\";\n  } else if (isMuted) {\n    currentVariant = \"muted\";\n  }\n\n  const getCirclePosition = (index: number, total: number) => {\n    const startAngle = -90 - FAN_ANGLE / 2;\n    const angleStep = total > 1 ? FAN_ANGLE / (total - 1) : 0;\n    const angle = startAngle + index * angleStep;\n    const radians = (angle * Math.PI) / 180;\n\n    return {\n      x: Math.cos(radians) * FAN_RADIUS,\n      y: Math.sin(radians) * FAN_RADIUS,\n    };\n  };\n\n  const handleMouseEnter = (e: React.MouseEvent) => {\n    e.stopPropagation(); // Prevent chart from handling this event\n    setIsHovered(true);\n    onHover?.(markers);\n  };\n\n  const handleMouseLeave = (e: React.MouseEvent) => {\n    e.stopPropagation(); // Prevent chart from handling this event\n    setIsHovered(false);\n    onHover?.(null);\n  };\n\n  const handleMouseMove = (e: React.MouseEvent) => {\n    e.stopPropagation(); // Prevent chart crosshair from moving while hovering markers\n  };\n\n  const portalX = x + marginLeft;\n  const portalY = y + marginTop;\n\n  return (\n    <>\n      {/* Position group - no interaction */}\n      <g transform={`translate(${x}, ${y})`}>\n        {/* Vertical guide line - non-interactive, rendered first (behind marker) */}\n        {showLine && lineHeight > 0 && (\n          <motion.line\n            animate={{\n              strokeOpacity: (() => {\n                if (isHovered) {\n                  return 1;\n                }\n                if (isActive) {\n                  return 0;\n                }\n                return 0.6;\n              })(),\n            }}\n            initial={{ strokeOpacity: 0.6 }}\n            stroke={chartCssVars.markerBorder}\n            strokeDasharray=\"4,4\"\n            strokeLinecap=\"round\"\n            strokeWidth={1}\n            style={{ pointerEvents: \"none\" }}\n            transition={{ duration: 0.2, ease: \"easeOut\" }}\n            x1={0}\n            x2={0}\n            y1={size / 2 + 4}\n            y2={lineHeight + Math.abs(y)}\n          />\n        )}\n\n        {/* Interactive marker group */}\n        {/* biome-ignore lint/a11y/noStaticElementInteractions: Chart marker interaction */}\n        <g\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n          onMouseMove={handleMouseMove}\n          style={{ cursor: \"pointer\" }}\n        >\n          <motion.g\n            animate={currentVariant}\n            initial={animate ? \"hidden\" : currentVariant}\n            transition={{\n              type: \"spring\",\n              stiffness: 300,\n              damping: 25,\n              delay: animationDelay,\n            }}\n            variants={markerEntranceVariants}\n          >\n            {/* Hit area - covers marker circle with padding for count badge above */}\n            <rect\n              fill=\"transparent\"\n              height={size * 1.5}\n              width={size * 1.5}\n              x={-size * 0.75}\n              y={-size}\n            />\n\n            {/* Main marker */}\n            <MarkerCircle\n              borderColor={borderColor}\n              borderWidth={borderWidth}\n              color={markers[0]?.color}\n              icon={markers[0]?.icon}\n              iconFill={iconFill}\n              size={size}\n            />\n\n            {/* Count badge */}\n            <AnimatePresence>\n              {hasMultiple && !shouldFan && (\n                <motion.g\n                  animate={{ scale: 1, opacity: 1 }}\n                  exit={{ scale: 0, opacity: 0 }}\n                  initial={{ scale: 0, opacity: 0 }}\n                  transition={{ type: \"spring\", stiffness: 400, damping: 20 }}\n                >\n                  <circle\n                    cx={size / 2 + 2}\n                    cy={-size / 2 - 2}\n                    r={9}\n                    style={{ fill: chartCssVars.badgeBackground }}\n                  />\n                  <text\n                    dominantBaseline=\"central\"\n                    fontSize={11}\n                    fontWeight={600}\n                    style={{ fill: chartCssVars.badgeForeground }}\n                    textAnchor=\"middle\"\n                    x={size / 2 + 2}\n                    y={-size / 2 - 2}\n                  >\n                    {markers.length}\n                  </text>\n                </motion.g>\n              )}\n            </AnimatePresence>\n          </motion.g>\n        </g>\n      </g>\n\n      {/* Portal for fanned circles */}\n      {containerRef?.current &&\n        createPortal(\n          // biome-ignore lint/a11y/noStaticElementInteractions: Marker hover portal\n          // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Marker hover portal\n          <div\n            className=\"absolute\"\n            onMouseEnter={handleMouseEnter}\n            onMouseLeave={handleMouseLeave}\n            onMouseMove={handleMouseMove}\n            style={{\n              // Position the div so its center is at the marker position\n              // The div covers the entire fan area to prevent mouseLeave when moving between markers\n              left: portalX - (FAN_RADIUS + size / 2),\n              top: portalY - (FAN_RADIUS + size / 2),\n              width: FAN_RADIUS * 2 + size,\n              height: FAN_RADIUS * 2 + size,\n              zIndex: 100,\n              pointerEvents: shouldFan ? \"auto\" : \"none\",\n            }}\n          >\n            {/* Center point offset - all fanned markers are positioned relative to this */}\n            <div\n              className=\"absolute\"\n              style={{\n                left: FAN_RADIUS + size / 2,\n                top: FAN_RADIUS + size / 2,\n              }}\n            >\n              <AnimatePresence mode=\"sync\">\n                {shouldFan &&\n                  fannedMarkers.map((marker, index) => {\n                    const position = getCirclePosition(\n                      index,\n                      fannedMarkers.length\n                    );\n                    return (\n                      <motion.div\n                        animate={{\n                          x: position.x,\n                          y: position.y,\n                          scale: 1,\n                          opacity: 1,\n                        }}\n                        className=\"absolute\"\n                        exit={{ x: 0, y: 0, scale: 0, opacity: 0 }}\n                        initial={{ x: 0, y: 0, scale: 0, opacity: 0 }}\n                        key={`fan-${marker.title}`}\n                        style={{\n                          width: size,\n                          height: size,\n                          left: -size / 2,\n                          top: -size / 2,\n                        }}\n                        transition={{\n                          type: \"spring\",\n                          stiffness: 400,\n                          damping: 22,\n                          delay: index * 0.04,\n                        }}\n                      >\n                        <MarkerCircleHTML\n                          borderColor={borderColor}\n                          borderWidth={borderWidth}\n                          color={marker.color}\n                          href={marker.href}\n                          icon={marker.icon}\n                          iconFill={iconFill}\n                          isClickable={!!(marker.onClick || marker.href)}\n                          onClick={marker.onClick}\n                          size={size}\n                          target={marker.target}\n                        />\n                      </motion.div>\n                    );\n                  })}\n              </AnimatePresence>\n\n              <AnimatePresence>\n                {shouldFan && (\n                  <motion.div\n                    animate={{ scale: 1, opacity: 0.5 }}\n                    className=\"absolute\"\n                    exit={{ scale: 0, opacity: 0 }}\n                    initial={{ scale: 0, opacity: 0 }}\n                    style={{\n                      width: size * 0.5,\n                      height: size * 0.5,\n                      left: -size * 0.25,\n                      top: -size * 0.25,\n                    }}\n                    transition={{ type: \"spring\", stiffness: 400, damping: 20 }}\n                  >\n                    <div\n                      className=\"h-full w-full rounded-full\"\n                      style={{ backgroundColor: chartCssVars.markerBorder }}\n                    />\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </div>,\n          containerRef.current\n        )}\n    </>\n  );\n}\n\ninterface MarkerCircleProps {\n  icon: React.ReactNode;\n  size: number;\n  color?: string;\n  onClick?: () => void;\n  href?: string;\n  target?: \"_blank\" | \"_self\";\n  isClickable?: boolean;\n  /** Edge-to-edge icon (no 4px inset). */\n  iconFill?: boolean;\n  /** Override circle stroke color. */\n  borderColor?: string;\n  /** Circle stroke width. */\n  borderWidth?: number;\n}\n\nfunction MarkerCircle({\n  icon,\n  size,\n  color,\n  iconFill = false,\n  borderColor,\n  borderWidth = 1.5,\n}: MarkerCircleProps) {\n  const inset = iconFill ? 0 : 4;\n  return (\n    <g>\n      <circle cx={0} cy={2} fill=\"black\" opacity={0.15} r={size / 2} />\n      <circle\n        cx={0}\n        cy={0}\n        fill={color || chartCssVars.markerBackground}\n        r={size / 2}\n        stroke={borderColor ?? chartCssVars.markerBorder}\n        strokeWidth={borderWidth}\n      />\n      <foreignObject\n        height={size - inset * 2}\n        width={size - inset * 2}\n        x={-size / 2 + inset}\n        y={-size / 2 + inset}\n      >\n        <div\n          style={{\n            width: \"100%\",\n            height: \"100%\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            color: chartCssVars.markerForeground,\n            fontSize: size * 0.5,\n            overflow: \"hidden\",\n            borderRadius: \"50%\",\n          }}\n        >\n          {icon}\n        </div>\n      </foreignObject>\n    </g>\n  );\n}\n\nfunction MarkerCircleHTML({\n  icon,\n  size,\n  color,\n  onClick,\n  href,\n  target = \"_self\",\n  isClickable = false,\n  iconFill = false,\n  borderColor,\n  borderWidth = 1.5,\n}: MarkerCircleProps) {\n  const hasAction = isClickable || onClick || href;\n  const inset = iconFill ? 0 : 4;\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    if (onClick) {\n      onClick();\n    } else if (href) {\n      if (target === \"_blank\") {\n        window.open(href, \"_blank\", \"noopener,noreferrer\");\n      } else {\n        window.location.href = href;\n      }\n    }\n  };\n\n  // Note: color and CSS vars must remain inline styles as they're dynamic\n  return (\n    <motion.div\n      className={cn(\n        \"relative flex h-full w-full items-center justify-center rounded-full shadow-lg\",\n        hasAction && \"cursor-pointer\"\n      )}\n      onClick={hasAction ? handleClick : undefined}\n      style={{\n        backgroundColor: color || chartCssVars.markerBackground,\n        border: `${borderWidth}px solid ${borderColor ?? chartCssVars.markerBorder}`,\n        fontSize: size * 0.5,\n        color: chartCssVars.markerForeground,\n        padding: inset,\n        overflow: \"hidden\",\n      }}\n      transition={{ type: \"spring\", stiffness: 400, damping: 17 }}\n      whileHover={\n        hasAction\n          ? { scale: 1.15, boxShadow: \"0 4px 20px rgba(0,0,0,0.25)\" }\n          : undefined\n      }\n      whileTap={hasAction ? { scale: 0.95 } : undefined}\n    >\n      {icon}\n    </motion.div>\n  );\n}\n\nMarkerGroup.displayName = \"MarkerGroup\";\n\nexport default MarkerGroup;\n",
      "type": "registry:component",
      "target": "components/charts/markers/marker-group.tsx"
    },
    {
      "path": "src/charts/markers/index.ts",
      "content": "export {\n  ChartMarkers,\n  type ChartMarkersProps,\n  MarkerTooltipContent,\n  type MarkerTooltipContentProps,\n  useActiveMarkers,\n} from \"./chart-markers\";\nexport {\n  type ChartMarker,\n  MarkerGroup,\n  type MarkerGroupProps,\n} from \"./marker-group\";\n",
      "type": "registry:component",
      "target": "components/charts/markers/index.ts"
    }
  ]
}