{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "profit-loss-line",
  "type": "registry:component",
  "title": "Profit/Loss Line",
  "description": "Sign-colored line segments for profit and loss on LineChart",
  "dependencies": [
    "@visx/curve@4.0.1-alpha.0",
    "@visx/shape@4.0.1-alpha.0"
  ],
  "registryDependencies": [
    "@bklit/line-chart",
    "@bklit/grid",
    "@bklit/x-axis",
    "@bklit/chart-tooltip",
    "@bklit/legend",
    "@bklit/utils"
  ],
  "files": [
    {
      "path": "src/charts/profit-loss-line.tsx",
      "content": "\"use client\";\n\nimport { curveLinear } from \"@visx/curve\";\nimport { LinePath } from \"@visx/shape\";\nimport { useCallback, useId, useMemo } from \"react\";\nimport { useChart, useChartStable } from \"./chart-context\";\nimport {\n  type FadeEdges,\n  fadeGradientStops,\n  resolveFadeSides,\n} from \"./fade-edges\";\nimport { useProfitLossLegendHover } from \"./profit-loss-legend-hover\";\nimport { splitProfitLossSegments } from \"./profit-loss-segments\";\n\n// CurveFactory type - simplified version compatible with visx\n// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type\ntype CurveFactory = any;\n\nexport const PROFIT_LOSS_POSITIVE_COLOR = \"var(--color-emerald-500)\";\nexport const PROFIT_LOSS_NEGATIVE_COLOR = \"var(--color-red-500)\";\n\nconst LEGEND_DIM_OPACITY = 0.25;\n\nexport function profitLossColor(value: number) {\n  return value >= 0 ? PROFIT_LOSS_POSITIVE_COLOR : PROFIT_LOSS_NEGATIVE_COLOR;\n}\n\nexport const PROFIT_LOSS_TOOLTIP_LABEL_FALLBACK = \"Profit/Loss\";\n\nexport function resolveProfitLossTooltipLabel(label: string) {\n  const trimmed = label.trim();\n  return trimmed || PROFIT_LOSS_TOOLTIP_LABEL_FALLBACK;\n}\n\nexport interface ProfitLossLineProps {\n  dataKey: string;\n  xDataKey?: string;\n  strokeWidth?: number;\n  positiveColor?: string;\n  negativeColor?: string;\n  /** Curve function. Default: curveLinear */\n  curve?: CurveFactory;\n  /**\n   * Fade the line stroke toward transparent at the chart edges.\n   * Default: false\n   */\n  fadeEdges?: FadeEdges;\n}\n\nfunction segmentLegendIndex(isPositive: boolean) {\n  return isPositive ? 0 : 1;\n}\n\nexport function ProfitLossLine({\n  dataKey,\n  xDataKey = \"date\",\n  strokeWidth = 2.5,\n  positiveColor = PROFIT_LOSS_POSITIVE_COLOR,\n  negativeColor = PROFIT_LOSS_NEGATIVE_COLOR,\n  curve = curveLinear,\n  fadeEdges = false,\n}: ProfitLossLineProps) {\n  const { tooltipData } = useChart();\n  const { hoveredIndex } = useProfitLossLegendHover();\n  const { renderData, xScale, yScale, xAccessor, innerWidth } =\n    useChartStable();\n  const reactId = useId();\n  const fadeSides = resolveFadeSides(fadeEdges);\n  const fadeStops = fadeSides.any ? fadeGradientStops(fadeSides) : null;\n  const positiveGradientId = `profit-loss-gradient-pos-${dataKey}-${reactId}`;\n  const negativeGradientId = `profit-loss-gradient-neg-${dataKey}-${reactId}`;\n\n  const focusedLegendIndex = useMemo(() => {\n    if (hoveredIndex !== null) {\n      return hoveredIndex;\n    }\n    if (!tooltipData) {\n      return null;\n    }\n    const value = tooltipData.point[dataKey];\n    if (typeof value !== \"number\") {\n      return null;\n    }\n    return segmentLegendIndex(value >= 0);\n  }, [dataKey, hoveredIndex, tooltipData]);\n\n  const segments = useMemo(\n    () =>\n      splitProfitLossSegments({\n        data: renderData,\n        dataKey,\n        xDataKey,\n        xAccessor,\n      }),\n    [dataKey, renderData, xAccessor, xDataKey]\n  );\n\n  const getX = useCallback(\n    (d: Record<string, unknown>) => xScale(xAccessor(d)) ?? 0,\n    [xAccessor, xScale]\n  );\n\n  const getY = useCallback(\n    (d: Record<string, unknown>) => {\n      const value = d[dataKey];\n      return typeof value === \"number\" ? (yScale(value) ?? 0) : 0;\n    },\n    [dataKey, yScale]\n  );\n\n  return (\n    <>\n      {fadeStops ? (\n        <defs>\n          <linearGradient\n            gradientUnits=\"userSpaceOnUse\"\n            id={positiveGradientId}\n            x1={0}\n            x2={innerWidth}\n            y1={0}\n            y2={0}\n          >\n            {fadeStops.map((stop) => (\n              <stop\n                key={stop.offset}\n                offset={stop.offset}\n                style={{\n                  stopColor: positiveColor,\n                  stopOpacity: stop.opacity,\n                }}\n              />\n            ))}\n          </linearGradient>\n          <linearGradient\n            gradientUnits=\"userSpaceOnUse\"\n            id={negativeGradientId}\n            x1={0}\n            x2={innerWidth}\n            y1={0}\n            y2={0}\n          >\n            {fadeStops.map((stop) => (\n              <stop\n                key={stop.offset}\n                offset={stop.offset}\n                style={{\n                  stopColor: negativeColor,\n                  stopOpacity: stop.opacity,\n                }}\n              />\n            ))}\n          </linearGradient>\n        </defs>\n      ) : null}\n      {segments.map((segment) => {\n        const isDimmed =\n          focusedLegendIndex !== null &&\n          focusedLegendIndex !== segmentLegendIndex(segment.isPositive);\n        const firstPoint = segment.data[0];\n        const lastPoint = segment.data.at(-1);\n        const segmentKey = `${dataKey}-${segment.isPositive ? \"pos\" : \"neg\"}-${String(firstPoint?.[xDataKey])}-${String(lastPoint?.[xDataKey])}`;\n        const stroke = segment.isPositive ? positiveColor : negativeColor;\n        const segmentStroke = fadeStops\n          ? `url(#${segment.isPositive ? positiveGradientId : negativeGradientId})`\n          : stroke;\n\n        return (\n          <g\n            key={segmentKey}\n            opacity={isDimmed ? LEGEND_DIM_OPACITY : 1}\n            style={{ transition: \"opacity 0.2s ease-in-out\" }}\n          >\n            <LinePath\n              curve={curve}\n              data={segment.data}\n              stroke={segmentStroke}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={strokeWidth}\n              x={getX}\n              y={getY}\n            />\n          </g>\n        );\n      })}\n    </>\n  );\n}\n\nProfitLossLine.displayName = \"ProfitLossLine\";\n",
      "type": "registry:component",
      "target": "components/charts/profit-loss-line.tsx"
    },
    {
      "path": "src/charts/profit-loss-segments.ts",
      "content": "type SegmentSign = \"positive\" | \"negative\";\n\nexport interface ProfitLossSegment {\n  data: Record<string, unknown>[];\n  isPositive: boolean;\n}\n\nfunction resolveSign(value: number, fallback: SegmentSign): SegmentSign {\n  if (value > 0) {\n    return \"positive\";\n  }\n  if (value < 0) {\n    return \"negative\";\n  }\n  return fallback;\n}\n\nfunction findInitialSign(\n  data: Record<string, unknown>[],\n  dataKey: string\n): SegmentSign {\n  for (const row of data) {\n    const value = row[dataKey];\n    if (typeof value !== \"number\") {\n      continue;\n    }\n    if (value > 0) {\n      return \"positive\";\n    }\n    if (value < 0) {\n      return \"negative\";\n    }\n  }\n  return \"positive\";\n}\n\nfunction interpolateZeroCrossing(\n  a: Record<string, unknown>,\n  b: Record<string, unknown>,\n  dataKey: string,\n  xDataKey: string,\n  xAccessor: (d: Record<string, unknown>) => Date\n): Record<string, unknown> {\n  const ya = a[dataKey] as number;\n  const yb = b[dataKey] as number;\n  const t = ya / (ya - yb);\n  const start = xAccessor(a).getTime();\n  const end = xAccessor(b).getTime();\n  const crossDate = new Date(start + t * (end - start));\n\n  return {\n    ...a,\n    [xDataKey]: crossDate,\n    [dataKey]: 0,\n  };\n}\n\n/** Split a single series into contiguous segments above/below zero. */\nexport function splitProfitLossSegments({\n  data,\n  dataKey,\n  xDataKey = \"date\",\n  xAccessor,\n}: {\n  data: Record<string, unknown>[];\n  dataKey: string;\n  xDataKey?: string;\n  xAccessor: (d: Record<string, unknown>) => Date;\n}): ProfitLossSegment[] {\n  if (data.length === 0) {\n    return [];\n  }\n\n  const segments: ProfitLossSegment[] = [];\n  let currentSign = findInitialSign(data, dataKey);\n  const firstPoint = data[0];\n  if (!firstPoint) {\n    return [];\n  }\n  let currentSegment: Record<string, unknown>[] = [firstPoint];\n\n  for (let i = 0; i < data.length - 1; i++) {\n    const a = data[i];\n    const b = data[i + 1];\n    if (!(a && b)) {\n      continue;\n    }\n    const ya = a[dataKey] as number;\n    const yb = b[dataKey] as number;\n\n    if (\n      typeof ya === \"number\" &&\n      typeof yb === \"number\" &&\n      ya !== 0 &&\n      yb !== 0 &&\n      Math.sign(ya) !== Math.sign(yb)\n    ) {\n      const cross = interpolateZeroCrossing(a, b, dataKey, xDataKey, xAccessor);\n      currentSegment.push(cross);\n      segments.push({\n        data: currentSegment,\n        isPositive: currentSign === \"positive\",\n      });\n      currentSegment = [cross, b];\n      currentSign = resolveSign(yb, currentSign);\n      continue;\n    }\n\n    currentSegment.push(b);\n    if (typeof yb === \"number\" && yb !== 0) {\n      currentSign = resolveSign(yb, currentSign);\n    }\n  }\n\n  if (currentSegment.length > 0) {\n    segments.push({\n      data: currentSegment,\n      isPositive: currentSign === \"positive\",\n    });\n  }\n\n  return segments;\n}\n",
      "type": "registry:lib",
      "target": "lib/profit-loss-segments.ts"
    },
    {
      "path": "src/charts/profit-loss-legend-hover.tsx",
      "content": "\"use client\";\n\nimport { createContext, type ReactNode, useContext } from \"react\";\n\ninterface ProfitLossLegendHoverContextValue {\n  hoveredIndex: number | null;\n}\n\nconst ProfitLossLegendHoverContext =\n  createContext<ProfitLossLegendHoverContextValue | null>(null);\n\nexport function ProfitLossLegendHoverProvider({\n  hoveredIndex,\n  children,\n}: {\n  hoveredIndex: number | null;\n  children: ReactNode;\n}) {\n  return (\n    <ProfitLossLegendHoverContext.Provider value={{ hoveredIndex }}>\n      {children}\n    </ProfitLossLegendHoverContext.Provider>\n  );\n}\n\nexport function useProfitLossLegendHover(): ProfitLossLegendHoverContextValue {\n  const context = useContext(ProfitLossLegendHoverContext);\n  return context ?? { hoveredIndex: null };\n}\n",
      "type": "registry:component",
      "target": "components/charts/profit-loss-legend-hover.tsx"
    },
    {
      "path": "src/charts/profit-loss-legend.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Legend, LegendItem, LegendLabel, LegendMarker } from \"./legend/index\";\nimport {\n  PROFIT_LOSS_NEGATIVE_COLOR,\n  PROFIT_LOSS_POSITIVE_COLOR,\n} from \"./profit-loss-line\";\n\nexport const PROFIT_LOSS_LEGEND_ITEMS = [\n  { label: \"Profit\", value: 0, color: PROFIT_LOSS_POSITIVE_COLOR },\n  { label: \"Loss\", value: 0, color: PROFIT_LOSS_NEGATIVE_COLOR },\n] as const;\n\nexport interface ProfitLossLegendProps {\n  hoveredIndex?: number | null;\n  onHoverChange?: (index: number | null) => void;\n  align?: \"start\" | \"center\" | \"end\";\n  className?: string;\n}\n\nconst LEGEND_ALIGN_CLASSES: Record<\n  NonNullable<ProfitLossLegendProps[\"align\"]>,\n  string\n> = {\n  start: \"justify-start\",\n  center: \"justify-center\",\n  end: \"justify-end\",\n};\n\nexport function ProfitLossLegend({\n  hoveredIndex = null,\n  onHoverChange,\n  align = \"start\",\n  className,\n}: ProfitLossLegendProps) {\n  return (\n    <div\n      className={cn(\n        \"flex w-full shrink-0 px-1 py-2\",\n        LEGEND_ALIGN_CLASSES[align],\n        className\n      )}\n    >\n      <Legend\n        className=\"flex-row flex-wrap gap-4\"\n        hoveredIndex={hoveredIndex}\n        items={[...PROFIT_LOSS_LEGEND_ITEMS]}\n        onHoverChange={onHoverChange}\n      >\n        <LegendItem className=\"flex items-center gap-2\">\n          <LegendMarker className=\"h-2.5 w-2.5\" />\n          <LegendLabel className=\"text-xs\" />\n        </LegendItem>\n      </Legend>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/charts/profit-loss-legend.tsx"
    }
  ]
}