{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "range-explorer",
  "title": "Range explorer",
  "description": "Walk a solved preflop tree seat by seat: the table redrawn at every decision, the acting seat's range on the hand matrix, every action's share of the range, and one hand's frequency and EV per action. Bring your own node loader.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://www.pokerstudy.ai/r/poker-core.json",
    "https://www.pokerstudy.ai/r/hand-matrix.json",
    "https://www.pokerstudy.ai/r/poker-table.json"
  ],
  "files": [
    {
      "path": "src/components/pokerstudy/range-explorer.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useState, type ReactNode } from \"react\";\nimport { HandMatrix, type MatrixCell } from \"@/components/pokerstudy/hand-matrix\";\nimport { EndCard, ExplorerHeader, SeatStrip, StatGroup, Widget, type ExplorerAction } from \"@/components/pokerstudy/range-explorer-panels\";\nimport { TableTheme, type TrainerSkin } from \"@/components/trainer/skin\";\nimport { PokerTable, type TableSeatView } from \"@/components/trainer/table\";\nimport { ALL_HANDS, type ActionPaletteId } from \"@/lib/pokerstudy/poker\";\nimport {\n  advanceHistory,\n  backHistory,\n  colorWalkActions,\n  endOfWalk,\n  formatBbExact,\n  isClosed,\n  parseHistory,\n  rangeMix,\n  rewindHistory,\n  rootHistory,\n  type TableSpec,\n  type WalkAction,\n  type WalkNode,\n} from \"@/lib/pokerstudy/preflop-walk\";\n\n/**\n * Walk a solved preflop tree one seat at a time. The table redraws at every\n * decision (seats, bets, the pot, the dealer button, the seat to act lit),\n * the acting seat's range fills the 13×13 matrix with each action's share\n * in its legend, and the seat strip offers the actions that walk the line on.\n *\n * The explorer owns the walk; `loadNode` owns the data. It is asked for the\n * decision at each history and answers null once the betting round has\n * closed on that line. `PreflopRangeExplorer` wires it to the Poker Study AI\n * API; pokerdata.io points it at its own range packs.\n */\nexport function RangeExplorer({\n  table,\n  loadNode,\n  initialHistory,\n  initialHand = null,\n  onHistoryChange,\n  renderRange,\n  actionsFor,\n  avatars = false,\n  tableClassName = \"min-h-[380px]\",\n  renderError,\n  footer,\n  header,\n  contentClassName = \"\",\n  stats: statsProp,\n  theme,\n  palette,\n  fill = false,\n}: {\n  /** Seats, stacks and antes of the table the tree was solved for. */\n  table: TableSpec;\n  /** The decision at a history, or null when the line has closed there. Throw to show an error. */\n  loadNode: (history: string) => Promise<WalkNode | null>;\n  /** Where to start; the first seat's open by default. */\n  initialHistory?: string;\n  /** Hand class to pin first, e.g. \"A5s\". */\n  initialHand?: string | null;\n  /** Called with each history the walk reaches, for a shareable URL. */\n  onHistoryChange?: (history: string) => void;\n  /** Draw the range another way (a PLO hand list); the matrix by default. */\n  renderRange?: (ctx: { node: WalkNode; history: string; actions: ExplorerAction[] }) => ReactNode;\n  /** Share of the range per action when the data isn't a 13×13 grid. */\n  actionsFor?: (node: WalkNode, colored: ExplorerAction[]) => ExplorerAction[];\n  /** Draw seat portraits on the table. Off unless your app serves `/avatars`. */\n  avatars?: boolean;\n  /** Height of the table box; it grows to match the range beside it. */\n  tableClassName?: string;\n  /** Draw a `loadNode` error your own way (a sign-in prompt); plain text by default. */\n  renderError?: (message: string) => ReactNode;\n  /** Content under the panels, such as a link to the full pack; a function sees the node it sits under. */\n  footer?: ReactNode | ((ctx: { node: WalkNode | null; history: string; actions: ExplorerAction[] }) => ReactNode);\n  /** A bar across the top with Back and Reset; `trailing` takes the host's close button, `title` is optional. */\n  header?: { title?: ReactNode; trailing?: ReactNode };\n  /** Classes for the box round the panels (padding, max width) when the header is on. */\n  contentClassName?: string;\n  /** The strip of figures above the table: the pack's facts (game, stack, players, rake, ante). The live pot, to-call and stacks by default. */\n  stats?: { label: string; value: string }[];\n  /** Pin the table to one theme (\"pokerdata\" for its monochrome felt). Omitted: the page's theme. */\n  theme?: TrainerSkin;\n  /** Action colors: \"classic\" (fold blue, call green, raises red), \"midnight\", \"frost\" or \"mint\". */\n  palette?: ActionPaletteId;\n  /**\n   * Fill the host's height (a drawer, a full-page viewer): the explorer takes\n   * the whole box, the stat strip is compact, and on a wide screen the table\n   * and the matrix size to the height left under the seat strip, so the walk\n   * fits without scrolling. A narrow screen stacks the panels and scrolls them\n   * as usual. The host's box needs a definite height.\n   */\n  fill?: boolean;\n}) {\n  const root = rootHistory(table);\n  const [history, setHistory] = useState(initialHistory ?? root);\n  // The last answer from `loadNode`, kept with the history it answers so a\n  // stale one never shows for a newer step.\n  const [result, setResult] = useState<{ history: string; node: WalkNode | null; error?: string } | null>(null);\n  // Raise-to per line from each node the walk has loaded, so the table sizes\n  // tokens like `Open` exactly as the solver did.\n  const [sizes, setSizes] = useState<Record<string, number>>({});\n  const [pinnedHand, setPinnedHand] = useState<string | null>(initialHand);\n  const closed = isClosed(history);\n\n  useEffect(() => {\n    if (closed) return;\n    let cancelled = false;\n    loadNode(history)\n      .then((n) => {\n        if (cancelled) return;\n        setResult({ history, node: n });\n        if (n) {\n          const found: Record<string, number> = {};\n          for (const a of n.actions) {\n            if (a.sizeBb != null && a.kind !== \"fold\" && a.kind !== \"call\") found[`${history}_${a.action}`] = a.sizeBb;\n          }\n          if (Object.keys(found).length) setSizes((prev) => ({ ...prev, ...found }));\n        }\n      })\n      .catch((err: unknown) => {\n        if (!cancelled) setResult({ history, node: null, error: err instanceof Error ? err.message : \"No range here.\" });\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [history, closed, loadNode]);\n\n  const settled = !closed && result?.history === history ? result : null;\n  const node = settled?.node ?? null;\n  const error = settled?.error ?? null;\n  const loading = !closed && !settled;\n\n  const walk = useMemo(() => parseHistory(history, table, sizes), [history, table, sizes]);\n  // A line that closed without a node (null) still names the next seat in\n  // its history; nobody acts once the round is over.\n  const ended = closed || (!!settled && !error && node === null);\n  const actor = ended ? null : walk.actor;\n  const end = useMemo(() => (ended ? endOfWalk(walk, table.order) : null), [ended, walk, table.order]);\n\n  const colored = useMemo(\n    () => (node ? colorWalkActions(node.actions, table.stack, table.sizeOf, palette) : []),\n    [node, table.stack, table.sizeOf, palette],\n  );\n  const foldIndex = colored.findIndex((c) => c.action.kind === \"fold\");\n  const actions: ExplorerAction[] = useMemo(() => {\n    if (!node) return [];\n    if (actionsFor) return actionsFor(node, colored);\n    const mix = rangeMix(colored);\n    return colored.map((c, i) => ({ ...c, freq: mix[i]?.freq, detail: mix[i]?.detail }));\n  }, [node, colored, actionsFor]);\n\n  const cells = useMemo<(MatrixCell | null)[]>(\n    () =>\n      ALL_HANDS.map((h) => {\n        const freqs = colored.map(({ action }) => action.weights[h] ?? 0);\n        return freqs.reduce((s, f) => s + f, 0) > 0.001 ? { label: h, freqs } : null;\n      }),\n    [colored],\n  );\n\n  const go = (next: string) => {\n    setHistory(next);\n    onHistoryChange?.(next);\n  };\n  const take = (action: WalkAction) => {\n    if (!node || actor == null) return;\n    go(advanceHistory(history, action.action, table));\n  };\n  const rewind = (pos: string) => {\n    const next = rewindHistory(history, pos);\n    if (next !== null) go(next);\n  };\n  const atRoot = history === root;\n  const restart = () => go(root);\n  const back = () => {\n    if (!atRoot) go(backHistory(history));\n  };\n\n  // Seats round the oval: the button at the bottom, the rest clockwise.\n  const seats = useMemo<TableSeatView[]>(() => {\n    const order = table.order;\n    const btn = Math.max(0, order.indexOf(\"BTN\"));\n    return order.map((_, i) => {\n      const seat = walk.seats[order[(btn + i) % order.length]];\n      return {\n        pos: seat.pos,\n        remaining: seat.remaining,\n        committed: seat.committed,\n        folded: seat.folded,\n        isHero: false,\n        isDealer: seat.isDealer,\n        // The seat strip names each action; the table only shows who is still in.\n        action: null,\n        acting: seat.pos === actor,\n        cardMode: seat.folded ? \"none\" : \"back\",\n      };\n    });\n  }, [walk, table.order, actor]);\n\n  const range = end ? (\n    <EndCard end={end} onRestart={restart} />\n  ) : error ? (\n    renderError ? (\n      renderError(error)\n    ) : (\n      <p role=\"alert\" className=\"rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300\">\n        {error}\n      </p>\n    )\n  ) : loading && !node ? (\n    <div aria-busy className=\"aspect-square w-full max-w-[560px] animate-pulse rounded-md bg-white/[0.04]\" />\n  ) : node ? (\n    renderRange ? (\n      renderRange({ node, history, actions })\n    ) : (\n      <HandMatrix\n        cells={cells}\n        colors={colored.map((c) => c.color)}\n        legend={actions.map((a) => ({ label: `${a.label} ${a.freq === undefined ? \"\" : `${(a.freq * 100).toFixed(0)}%`}`.trim(), color: a.color }))}\n        selected={pinnedHand}\n        onSelect={(h) => setPinnedHand((cur) => (cur === h ? null : h))}\n        foldIndex={foldIndex < 0 ? undefined : foldIndex}\n        aggressiveFirst\n      />\n    )\n  ) : null;\n\n  const foot = typeof footer === \"function\" ? footer({ node, history, actions }) : footer;\n  const live = table.order.filter((p) => !walk.seats[p].folded);\n  const effective = live.length ? Math.min(...live.map((p) => walk.seats[p].remaining)) : 0;\n  const stats = statsProp ?? [\n    { label: \"Pot\", value: `${formatBbExact(walk.pot)}bb` },\n    { label: \"To call\", value: `${formatBbExact(actor ? Math.max(0, walk.toCall - walk.seats[actor].committed) : 0)}bb` },\n    { label: \"Players in\", value: String(live.length) },\n    { label: \"Effective stack\", value: `${formatBbExact(effective)}bb` },\n  ];\n  const progress = Math.min(1, (history.split(\"_\").length - (ended ? 0 : 1)) / 2 / table.order.length);\n\n  const panels = (\n    <div\n      className={`flex flex-col gap-4 ${fill ? \"min-h-0 flex-1 overflow-y-auto\" : \"\"} ${header ? contentClassName : \"\"}`}\n    >\n      <SeatStrip seats={table.order.map((p) => walk.seats[p])} actor={actor} actions={actions} loading={loading} onTake={take} onRewind={rewind} />\n      <StatGroup stats={stats} compact={fill} />\n\n      {/* The table and the range side by side. The table lies flat once its\n          card is 40rem wide (its own padding takes the rest). In fill mode\n          the row takes the height left on a wide screen, and the matrix is\n          sized to it (a size container, so `cqh` is the range card's own\n          height) rather than to the card's width. */}\n      <div className={`grid grid-cols-2 gap-4 lg:grid-cols-4 ${fill ? \"lg:min-h-[280px] lg:flex-1\" : \"\"}`}>\n        <Widget className={`col-span-full lg:col-span-2 ${fill ? \"lg:min-h-0\" : \"\"}`}>\n          <div\n            data-skin={theme}\n            className={`@container -m-4 flex-1 overflow-hidden bg-background ${fill ? \"lg:min-h-0\" : \"\"} ${tableClassName}`}\n          >\n            {theme ? (\n              <TableTheme skin={theme}>\n                <PokerTable seats={seats} pot={walk.pot} heroCards={null} avatars={avatars} />\n              </TableTheme>\n            ) : (\n              <PokerTable seats={seats} pot={walk.pot} heroCards={null} avatars={avatars} />\n            )}\n          </div>\n        </Widget>\n\n        <Widget className={`col-span-full lg:col-span-2 ${fill ? \"lg:min-h-0\" : \"\"}`}>\n          {fill ? (\n            <div className=\"flex min-h-0 flex-1 flex-col items-center lg:overflow-y-auto lg:[container-type:size]\">\n              {/* The matrix is square, so capping its width to the height left\n                  (less its legend) keeps the whole range in view; its usual\n                  560px cap goes, since the height is the cap here. A custom\n                  range panel can read the same `cqh` to size its own matrix.\n                  Auto margins centre it without clipping a panel that is\n                  taller than the card. */}\n              <div className=\"w-full max-w-[560px] lg:my-auto lg:w-[min(100%,calc(100cqh_-_2.75rem))] lg:max-w-none lg:[--ps-matrix-max:100%]\">\n                {range}\n              </div>\n            </div>\n          ) : (\n            range\n          )}\n        </Widget>\n\n        {foot ? <div className=\"col-span-full\">{foot}</div> : null}\n      </div>\n    </div>\n  );\n\n  if (!header) return panels;\n  return (\n    <div className={`flex flex-col ${fill ? \"h-full min-h-0\" : \"\"}`}>\n      <ExplorerHeader\n        title={header.title}\n        canBack={!atRoot}\n        onBack={back}\n        onReset={restart}\n        progress={progress}\n        trailing={header.trailing}\n        className=\"sticky top-0\"\n      />\n      {panels}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerstudy/range-explorer.tsx"
    },
    {
      "path": "src/components/pokerstudy/range-explorer-panels.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { ArrowLeft, ChevronRight, RotateCcw } from \"lucide-react\";\nimport { comboCount } from \"@/lib/pokerstudy/poker\";\nimport { formatBbExact, type WalkAction, type WalkEnd, type WalkSeat } from \"@/lib/pokerstudy/preflop-walk\";\n\n/**\n * The panels round the Range Explorer's table and matrix: the header with\n * the line and its controls, the seat strip, the widget frames, and how a\n * closed line ended. Each is a plain view of the walk; the explorer owns the\n * state. `ActionMenu` and `HandDetail` stay for hosts that want them.\n */\n\n/** One action at the node with its colour, label and share of the range. */\nexport interface ExplorerAction {\n  action: WalkAction;\n  label: string;\n  color: string;\n  /** Share of the range taking it, 0–1; undefined when the data has no figure. */\n  freq?: number;\n  /** \"240.9 combos\" / \"3,261 hands\". */\n  detail?: string;\n}\n\n// ─── Seat strip ────────────────────────────────────────────────────────────\n\n/**\n * Every seat in acting order. The acting seat lists its actions; a seat that\n * has acted shows what it did and rewinds the line to that decision when\n * clicked.\n */\nexport function SeatStrip({\n  seats,\n  actor,\n  actions,\n  loading,\n  onTake,\n  onRewind,\n}: {\n  seats: WalkSeat[];\n  actor: string | null;\n  /** The acting seat's actions, in display order. */\n  actions: ExplorerAction[];\n  loading: boolean;\n  onTake: (action: WalkAction) => void;\n  onRewind: (pos: string) => void;\n}) {\n  return (\n    <div role=\"list\" aria-label=\"Seats\" className=\"flex shrink-0 gap-2 overflow-x-auto pb-1\">\n      {seats.map((seat) => {\n        const isActor = seat.pos === actor;\n        const rewinds = !isActor && !!seat.action;\n        const frame = `flex min-h-24 w-[132px] shrink-0 flex-col gap-1 rounded-[var(--rb-r-sm,6px)] border p-2 text-left transition-colors ${\n          isActor ? \"border-foreground/40 bg-background\" : \"border-border bg-background/60\"\n        } ${rewinds ? \"cursor-pointer hover:border-foreground/40\" : \"\"}`;\n        const header = (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"flex items-center gap-1.5\">\n              <span className={`text-sm font-semibold ${seat.folded ? \"text-muted-foreground/60\" : \"text-foreground\"}`}>\n                {seat.pos}\n              </span>\n              {seat.isDealer ? (\n                <span className=\"flex size-3.5 items-center justify-center rounded-full bg-foreground text-[8px] font-bold text-background\">\n                  D\n                </span>\n              ) : null}\n            </span>\n            <span className={`text-[11px] font-medium tabular-nums ${seat.folded ? \"text-muted-foreground/60\" : \"text-muted-foreground\"}`}>\n              {formatBbExact(seat.remaining)}\n            </span>\n          </div>\n        );\n        const body = isActor ? (\n          <div className=\"flex flex-col\">\n            {actions.length === 0 ? (\n              <span className=\"py-1 text-xs text-muted-foreground\">{loading ? \"…\" : \"—\"}</span>\n            ) : (\n              actions.map((a) => (\n                <button\n                  key={a.action.action}\n                  type=\"button\"\n                  onClick={() => onTake(a.action)}\n                  className=\"flex cursor-pointer items-center justify-between gap-1 rounded px-1 py-1 text-left text-xs text-foreground transition-colors hover:bg-muted\"\n                >\n                  <span className=\"truncate\">{a.label}</span>\n                  <ChevronRight aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" />\n                </button>\n              ))\n            )}\n          </div>\n        ) : (\n          <div className=\"flex flex-1 items-center py-1\">\n            {seat.action ? (\n              <span\n                className={`inline-block max-w-full truncate rounded px-2 py-0.5 text-xs font-semibold ${\n                  seat.folded ? \"border border-border text-muted-foreground/70\" : \"bg-muted text-foreground\"\n                }`}\n              >\n                {seat.action}\n              </span>\n            ) : (\n              <span className=\"text-xs text-muted-foreground/60\">—</span>\n            )}\n          </div>\n        );\n        return rewinds ? (\n          <button key={seat.pos} type=\"button\" role=\"listitem\" onClick={() => onRewind(seat.pos)} className={frame}>\n            {header}\n            {body}\n          </button>\n        ) : (\n          <div key={seat.pos} role=\"listitem\" aria-current={isActor || undefined} className={frame}>\n            {header}\n            {body}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\n// ─── Widgets ───────────────────────────────────────────────────────────────\n\n/** The widget frame of the dashboard blocks: a bordered card with a title row. */\nexport function Widget({\n  title,\n  aside,\n  className = \"\",\n  children,\n}: {\n  /** Omitted: no title row; the content starts at the top of the card. */\n  title?: ReactNode;\n  /** Right end of the title row: a caption or small controls. */\n  aside?: ReactNode;\n  className?: string;\n  children: ReactNode;\n}) {\n  return (\n    <section className={`relative flex min-w-0 flex-col gap-3 overflow-hidden rounded-[var(--rb-r-lg,10px)] border border-border bg-card p-4 text-card-foreground ${className}`}>\n      {title === undefined && !aside ? null : (\n        <div className=\"flex min-h-7 items-center justify-between gap-3\">\n          <h2 className=\"min-w-0 truncate text-[14px]/[16px] font-[450] text-foreground\">{title}</h2>\n          {aside}\n        </div>\n      )}\n      {children}\n    </section>\n  );\n}\n\n/**\n * The stat strip across the top: one cell per figure, ruled like the\n * dashboard tiles. On a phone the figures sit two to a row, an odd last one\n * across the row. `compact` halves the strip's height for a viewer that has\n * to fit a screen: smaller figures, three to a row once there is room.\n */\nexport function StatGroup({ stats, compact = false }: { stats: { label: string; value: string }[]; compact?: boolean }) {\n  return (\n    <div className=\"@container rounded-[var(--rb-r-lg,10px)] border border-border bg-card\">\n      <div\n        className={`grid grid-cols-2 overflow-hidden *:-mt-px *:-ml-px *:border-t *:border-l *:border-border/60 *:last:odd:col-span-2 @md:grid-flow-col @md:auto-cols-fr @md:grid-cols-none @md:*:last:odd:col-span-1 ${\n          compact ? \"@sm:grid-cols-3\" : \"\"\n        }`}\n      >\n        {stats.map((s) => (\n          <div key={s.label} className={`flex min-w-0 flex-col ${compact ? \"gap-1 px-3 py-2\" : \"gap-2 px-4 py-3\"}`}>\n            <p className={`truncate font-[450] text-foreground/80 ${compact ? \"text-[12px]/[16px]\" : \"text-[14px]/[16px]\"}`}>{s.label}</p>\n            <p className={`truncate font-[450] text-foreground tabular-nums ${compact ? \"text-[18px]/[24px] @md:text-[20px]/[24px]\" : \"text-[28px]/[32px]\"}`}>\n              {s.value}\n            </p>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n// ─── Header ────────────────────────────────────────────────────────────────\n\nconst ghostButton =\n  \"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--rb-r-sm,6px)] bg-transparent text-foreground transition-[color,background-color,scale] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)] select-none hover:not-disabled:bg-foreground/5 active:scale-[0.98] active:bg-foreground/8 disabled:cursor-not-allowed disabled:text-foreground/40 motion-reduce:active:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\";\n\n/**\n * The bar across the top of the explorer, in the article-header idiom: a\n * back button at the left, the title, Reset and the host's own controls (a\n * close button) at the right, and a hairline along the bottom that fills as\n * the walk goes round the table.\n */\nexport function ExplorerHeader({\n  title,\n  canBack,\n  onBack,\n  onReset,\n  progress = 0,\n  trailing,\n  className = \"\",\n}: {\n  /** Omitted: the bar holds only the controls. */\n  title?: ReactNode;\n  canBack: boolean;\n  onBack: () => void;\n  onReset: () => void;\n  /** 0–1: how far round the table the line has gone. */\n  progress?: number;\n  /** Right end of the bar, after Reset: the host's close button. */\n  trailing?: ReactNode;\n  className?: string;\n}) {\n  return (\n    <header className={`relative z-20 shrink-0 border-b border-border bg-background ${className}`}>\n      <div className=\"flex h-14 items-center gap-2 px-2 sm:px-3\">\n        <button type=\"button\" aria-label=\"Back one step\" onClick={onBack} disabled={!canBack} className={`${ghostButton} size-8`}>\n          <ArrowLeft aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n        <p className=\"min-w-0 flex-1 truncate text-[14px]/[16px] font-[450] text-foreground\">{title}</p>\n        <div className=\"flex shrink-0 items-center gap-0.5\">\n          <button type=\"button\" onClick={onReset} disabled={!canBack} className={`${ghostButton} h-8 px-3 text-[14px]/[16px] font-[450] text-foreground/80 hover:not-disabled:text-foreground`}>\n            <RotateCcw aria-hidden=\"true\" className=\"size-4\" />\n            <span className=\"hidden sm:inline\">Reset</span>\n          </button>\n          {trailing}\n        </div>\n      </div>\n      <div aria-hidden=\"true\" className=\"absolute inset-x-0 bottom-0 h-[2px]\">\n        <div\n          style={{ width: `${Math.round(Math.min(1, Math.max(0, progress)) * 100)}%` }}\n          className=\"block h-full bg-foreground transition-[width] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none\"\n        />\n      </div>\n    </header>\n  );\n}\n\n// ─── Action menu ───────────────────────────────────────────────────────────\n\n/**\n * Every action the solved tree offers at this node, including the ones the\n * strategy never takes, with its share of the range. A row walks the line\n * forward, like the seat strip.\n */\nexport function ActionMenu({\n  actions,\n  actor,\n  seat,\n  onTake,\n}: {\n  actions: ExplorerAction[];\n  actor: string | null;\n  /** The acting seat, for its chips in front and behind. */\n  seat?: WalkSeat;\n  onTake: (action: WalkAction) => void;\n}) {\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-baseline justify-between gap-2 text-[12px]/[16px] text-muted-foreground tabular-nums\">\n        <span>\n          {actor ? `${actor} · ` : \"\"}\n          {actions.length} in tree\n        </span>\n        {seat ? (\n          <span>\n            {formatBbExact(seat.committed)}bb in · {formatBbExact(seat.remaining)}bb behind\n          </span>\n        ) : null}\n      </div>\n      {actions.some((a) => a.freq !== undefined) ? (\n        <div className=\"flex h-2 w-full overflow-hidden rounded-full bg-secondary\">\n          {actions.map((a) =>\n            !a.freq || a.freq <= 0.0005 ? null : (\n              <div key={a.action.action} style={{ flexGrow: a.freq, backgroundColor: a.color }} />\n            ),\n          )}\n        </div>\n      ) : null}\n      <div className=\"overflow-hidden rounded-[var(--rb-r-sm,6px)] border border-border\">\n        <div className=\"grid grid-cols-[minmax(0,1fr)_92px_12px] gap-2 border-b border-border bg-muted/50 px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground\">\n          <span>Action</span>\n          <span className=\"text-right\">Range</span>\n          <span />\n        </div>\n        {actions.map((a) => {\n          const zero = a.freq !== undefined && a.freq <= 0.0005;\n          return (\n            <button\n              key={a.action.action}\n              type=\"button\"\n              onClick={() => onTake(a.action)}\n              title={`Take ${a.label}${a.action.sizeBb != null ? ` (to ${formatBbExact(a.action.sizeBb)}bb)` : \"\"}`}\n              className=\"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_92px_12px] items-center gap-2 border-b border-border/60 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-muted\"\n            >\n              <span className=\"flex min-w-0 items-center gap-2\">\n                <span\n                  aria-hidden\n                  className=\"size-2.5 shrink-0 rounded-[3px]\"\n                  style={{ backgroundColor: a.color, opacity: zero ? 0.45 : 1 }}\n                />\n                <span className={`truncate text-[13px] font-medium ${zero ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n                  {a.label}\n                </span>\n              </span>\n              <span className=\"flex flex-col items-end leading-tight\">\n                <span className={`text-xs font-semibold tabular-nums ${zero ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n                  {a.freq === undefined ? \"—\" : `${(a.freq * 100).toFixed(1)}%`}\n                </span>\n                {a.detail ? (\n                  <span className=\"whitespace-nowrap text-[10px] text-muted-foreground tabular-nums\">{a.detail}</span>\n                ) : null}\n              </span>\n              <ChevronRight\n                aria-hidden=\"true\"\n                className=\"size-3.5 text-muted-foreground/60 transition-colors group-hover:text-foreground\"\n              />\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n// ─── Hand detail ───────────────────────────────────────────────────────────\n\n/**\n * One hand class at this node: its frequency for every action and each\n * action's EV. A 0% the data omitted shows as 0%; an EV it doesn't carry is\n * \"n/a\", never a made-up zero.\n */\nexport function HandDetail({\n  hand,\n  pinned,\n  actions,\n  evUnit,\n  actor,\n  onClear,\n}: {\n  hand: string | null;\n  pinned: boolean;\n  actions: ExplorerAction[];\n  /** \"sb\": the data's EVs are small blinds (shown as bb). \"usd\": tournament dollars. */\n  evUnit: \"sb\" | \"usd\";\n  actor: string | null;\n  onClear: () => void;\n}) {\n  const usd = evUnit === \"usd\";\n  if (!hand) {\n    return (\n      <div className=\"rounded-[var(--rb-r-sm,6px)] border border-dashed border-border px-4 py-5 text-center text-[13px]/[18px] text-muted-foreground\">\n        Hover or tap a hand in the matrix to see its frequency and EV for every action. Click to pin it.\n      </div>\n    );\n  }\n\n  const rows = actions.map((a) => {\n    const w = a.action.weights[hand];\n    const e = a.action.evs?.[hand];\n    return { a, freq: w ?? 0, omitted: w === undefined, ev: e === undefined ? undefined : usd ? e : e / 2 };\n  });\n  const inRange = rows.reduce((s, r) => s + r.freq, 0);\n  // Folding costs every hand the same, so when this hand's fold EV is\n  // missing, any other hand's fold EV at the node is it.\n  const ownFoldEv = rows.find((r) => r.a.action.kind === \"fold\")?.ev;\n  const sharedFoldEv = ownFoldEv === undefined ? nodeFoldEv(actions, usd) : undefined;\n  const foldEv = ownFoldEv ?? sharedFoldEv;\n  let evW = 0;\n  let evD = 0;\n  for (const r of rows) {\n    if (r.ev !== undefined && r.freq > 0) {\n      evW += r.freq * r.ev;\n      evD += r.freq;\n    }\n  }\n  const handEv = evD > 0 ? evW / evD : undefined;\n  const fmt = (v: number) => (usd ? `${v < 0 ? \"-\" : \"\"}$${Math.abs(v).toFixed(2)}` : `${v > 0 ? \"+\" : \"\"}${v.toFixed(2)}`);\n  const unit = usd ? \"$\" : \"bb\";\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-baseline justify-between gap-2\">\n        <span className=\"text-[12px]/[16px] text-muted-foreground\">\n          {actor ?? \"\"}\n          {pinned ? \" · pinned\" : \" · hovering\"}\n        </span>\n        {pinned ? (\n          <button\n            type=\"button\"\n            onClick={onClear}\n            className=\"cursor-pointer text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground\"\n          >\n            Unpin\n          </button>\n        ) : null}\n      </div>\n      <div className=\"overflow-hidden rounded-[var(--rb-r-sm,6px)] border border-border\">\n        <div className=\"flex items-baseline justify-between border-b border-border bg-muted/50 px-3 py-2\">\n          <span className=\"text-base font-semibold text-foreground\">{hand}</span>\n          <span className=\"text-[11px] text-muted-foreground tabular-nums\">\n            {comboCount(hand)} combos ·{\" \"}\n            {inRange > 0.0005 ? (\n              <>\n                EV{\" \"}\n                <span className=\"text-foreground\">{handEv === undefined ? \"n/a\" : `${fmt(handEv)}${usd ? \"\" : \"bb\"}`}</span>\n              </>\n            ) : (\n              \"not in this range\"\n            )}\n          </span>\n        </div>\n        <div className=\"grid grid-cols-[minmax(0,1fr)_56px_60px_60px] gap-2 border-b border-border/60 px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground\">\n          <span>Action</span>\n          <span className=\"text-right\">Freq</span>\n          <span className=\"text-right\">EV {unit}</span>\n          <span className=\"text-right\">vs fold</span>\n        </div>\n        {rows.map((r) => (\n          <div\n            key={r.a.action.action}\n            className=\"grid grid-cols-[minmax(0,1fr)_56px_60px_60px] items-center gap-2 border-b border-border/60 px-3 py-1.5 last:border-b-0\"\n          >\n            <span className=\"flex min-w-0 items-center gap-2\">\n              <span aria-hidden className=\"size-2.5 shrink-0 rounded-[3px]\" style={{ backgroundColor: r.a.color }} />\n              <span className=\"truncate text-xs text-foreground\">{r.a.label}</span>\n            </span>\n            <span\n              className={`text-right text-xs tabular-nums ${r.freq > 0 ? \"font-semibold text-foreground\" : \"text-muted-foreground\"}`}\n              title={r.omitted ? \"Omitted by the data, which means 0\" : undefined}\n            >\n              {(r.freq * 100).toFixed(1)}%\n            </span>\n            <span\n              className={`text-right text-xs tabular-nums ${r.ev === undefined ? \"text-muted-foreground/60\" : \"text-foreground\"}`}\n              title={r.ev === undefined ? \"The data carries no EV for this action and hand\" : undefined}\n            >\n              {r.ev !== undefined\n                ? fmt(r.ev)\n                : r.a.action.kind === \"fold\" && sharedFoldEv !== undefined\n                  ? fmt(sharedFoldEv)\n                  : \"n/a\"}\n            </span>\n            <span className=\"text-right text-xs text-muted-foreground tabular-nums\">\n              {foldEv === undefined\n                ? \"n/a\"\n                : r.a.action.kind === \"fold\"\n                  ? fmt(0)\n                  : r.ev === undefined\n                    ? \"n/a\"\n                    : fmt(r.ev - foldEv)}\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/** The fold EV shared by every hand at a node, when the data's values agree. */\nfunction nodeFoldEv(actions: ExplorerAction[], usd: boolean): number | undefined {\n  const fold = actions.find((a) => a.action.kind === \"fold\")?.action.evs;\n  if (!fold || usd) return undefined;\n  const values = new Set(Object.values(fold).map((v) => Math.round(v * 1000)));\n  if (values.size !== 1) return undefined;\n  return Object.values(fold)[0] / 2;\n}\n\n// ─── End of line ───────────────────────────────────────────────────────────\n\n/** How the line ended: folded to one player, or a flop. */\nexport function EndCard({ end, onRestart, children }: { end: WalkEnd; onRestart: () => void; children?: React.ReactNode }) {\n  return (\n    <div className=\"flex flex-col items-center gap-3 rounded-[var(--rb-r-sm,6px)] border border-dashed border-border px-6 py-7 text-center\">\n      {end.reason === \"won\" ? (\n        <span className=\"text-sm text-muted-foreground\">\n          Hand ends preflop — {end.survivors[0] ? `${end.survivors[0]} wins uncontested` : \"everyone folded\"}.\n        </span>\n      ) : (\n        <>\n          <span className=\"text-sm font-medium text-foreground\">Preflop complete — {end.survivors.join(\" / \")} see the flop</span>\n          <span className=\"text-xs text-muted-foreground\">\n            {end.survivors.length}-way · pot {formatBbExact(end.potBb)}bb · {formatBbExact(end.effStackBb)}bb behind\n          </span>\n        </>\n      )}\n      {children}\n      <button\n        type=\"button\"\n        onClick={onRestart}\n        className=\"cursor-pointer rounded-full border border-border px-3.5 py-1.5 text-xs font-medium text-muted-foreground hover:border-foreground/40 hover:text-foreground\"\n      >\n        Start over\n      </button>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerstudy/range-explorer-panels.tsx"
    },
    {
      "path": "src/lib/pokerstudy/preflop-walk.ts",
      "content": "import {\n  ALL_HANDS,\n  actionPalette,\n  comboCount,\n  sortActions,\n  type ActionPaletteId,\n  type NodeAction,\n} from \"@/lib/pokerstudy/poker\";\n\n/**\n * The preflop walk behind the Range Explorer: a history string such as\n * `UTG_60%_HJ_Fold_CO` replayed into what every seat has done, what it has in\n * front, what is left behind and the pot, plus who acts next after an action.\n * Pure functions, no fetching: the explorer asks its data source for each\n * node and hands the answer here.\n *\n * A history alternates seat and action tokens. An odd count ends on the seat\n * to act; an even count ends on an action, which closed the line.\n */\n\n/** One action at a decision as the explorer needs it: poker-core's node action plus the solver's raise-to. */\nexport interface WalkAction extends NodeAction {\n  /** Raise-to in big blinds when the data source decoded it; the token is sized otherwise. */\n  sizeBb?: number | null;\n}\n\n/** The decision at a history: who acts and every action the tree offers. */\nexport interface WalkNode {\n  actor?: string;\n  actions: WalkAction[];\n}\n\n/** The table a walk runs on. */\nexport interface TableSpec {\n  /** Seats in preflop acting order, clockwise from the first to act, `BTN`, `SB` and `BB` among them (heads-up: just `SB`, the button, then `BB`). */\n  order: string[];\n  /** Effective stack in big blinds. */\n  stack: number;\n  /** Per-seat starting stacks when they differ (an ICM stage); every other seat has `stack`. */\n  seatStacks?: Record<string, number>;\n  /** Big-blind ante, posted by the BB and counted in the pot. */\n  bbAnte?: number;\n  /** Ante every seat posts, counted in the pot. */\n  antePerSeat?: number;\n  /** Dead ante nobody's stack pays, counted in the pot. */\n  deadAnte?: number;\n  /** Raise-to in bb for a raise token the generic rules can't size (house codes); null to fall through. */\n  sizeOf?: (token: string) => number | null;\n}\n\nexport interface WalkSeat {\n  pos: string;\n  /** What the seat did on this line, or null while it waits. */\n  action: string | null;\n  /** Chips in front, blinds included, in bb. */\n  committed: number;\n  /** Chips still behind, in bb. */\n  remaining: number;\n  folded: boolean;\n  isDealer: boolean;\n  /** Ante paid from this seat's stack. */\n  ante: number;\n  /** Raise-to of the seat's last raise, when it raised. */\n  raiseTo?: number;\n}\n\nexport interface WalkTable {\n  seats: Record<string, WalkSeat>;\n  pot: number;\n  /** The seat to act, or null when the history ends on an action (the line is closed). */\n  actor: string | null;\n  /** The bet to match right now. */\n  toCall: number;\n  /** Antes in the pot. */\n  antes: number;\n}\n\n/** Pot-limit raise tokens: `Open`, `3B`, … optionally suffixed with a size code. */\nconst PLO_RAISE_RE = /^(Open|3B|4B|5B|6B|7B|8B)(?:-(\\d+))?$/;\n\nconst PLO_RAISE_LABEL: Record<string, string> = {\n  Open: \"Pot\",\n  \"3B\": \"3-Bet\",\n  \"4B\": \"4-Bet\",\n  \"5B\": \"5-Bet\",\n  \"6B\": \"6-Bet\",\n  \"7B\": \"7-Bet\",\n  \"8B\": \"8-Bet\",\n};\n\n/** HydraSolver PLO5 raise: `Raise-4.7x` is a raise to 4.7bb. */\nconst PLO5_RAISE_RE = /^Raise-(\\d+(?:\\.\\d+)?)x$/;\n\nfunction plo5RaiseToBb(token: string): number | null {\n  const m = PLO5_RAISE_RE.exec(token);\n  return m ? parseFloat(m[1]) : null;\n}\n\n/** bb cut (never rounded up) to two decimals, trailing zeros dropped: 39.875 → 39.87. */\nexport function formatBbExact(v: number): string {\n  return String(Math.trunc(v * 100 + (v < 0 ? -1e-6 : 1e-6)) / 100);\n}\n\nfunction isFold(token: string): boolean {\n  return token === \"Fold\" || token === \"F\";\n}\n\nfunction isCall(token: string): boolean {\n  return token === \"Call\" || token === \"C\" || token === \"X\" || token === \"Limp\";\n}\n\nfunction isAllIn(token: string): boolean {\n  return token === \"AI\" || token === \"AllIn\";\n}\n\n/** The word a seat shows for a token it took. */\nfunction displayAction(token: string, sizeOf?: TableSpec[\"sizeOf\"]): string {\n  if (isFold(token)) return \"Fold\";\n  if (token === \"Call\" || token === \"C\") return \"Call\";\n  if (token === \"Limp\") return \"Limp\";\n  if (token === \"X\") return \"Check\";\n  if (isAllIn(token)) return \"All-in\";\n  const plo5 = plo5RaiseToBb(token);\n  if (plo5 !== null) return `Raise ${formatBbExact(plo5)}`;\n  const plo = token.match(PLO_RAISE_RE);\n  if (plo) return PLO_RAISE_LABEL[plo[1]] ?? token;\n  const house = sizeOf?.(token) ?? null;\n  if (house !== null) return `Raise ${formatBbExact(house)}`;\n  return token;\n}\n\n/** The first seat to act, which is where a walk starts. */\nexport function rootHistory(spec: Pick<TableSpec, \"order\">): string {\n  return spec.order[0];\n}\n\n/** A history that ends on an action rather than a seat: the line closed. */\nexport function isClosed(history: string): boolean {\n  return history.split(\"_\").length % 2 === 0;\n}\n\n/**\n * Replay a history on the table. `sizes` maps `\"<history>_<token>\"` to the\n * raise-to the solver reported for that line, which wins over decoding the\n * token (a token like `Open` carries no size of its own).\n */\nexport function parseHistory(history: string, spec: TableSpec, sizes: Record<string, number> = {}): WalkTable {\n  const { order, stack, seatStacks, bbAnte = 0, antePerSeat = 0, deadAnte = 0, sizeOf } = spec;\n  const stackOf = (p: string) => seatStacks?.[p] ?? stack;\n  const seats: Record<string, WalkSeat> = {};\n  for (const p of order) {\n    seats[p] = {\n      pos: p,\n      action: null,\n      committed: p === \"SB\" ? 0.5 : p === \"BB\" ? 1 : 0,\n      remaining: stackOf(p),\n      folded: false,\n      // Heads-up has no BTN seat: the small blind holds the button.\n      isDealer: p === \"BTN\" || (p === \"SB\" && !order.includes(\"BTN\")),\n      ante: (p === \"BB\" ? bbAnte : 0) + antePerSeat,\n    };\n  }\n  const sum = () => order.reduce((s, p) => s + seats[p].committed, 0);\n  // Seats passed over between two acting seats folded.\n  const foldBetween = (fromIdx: number, toIdx: number) => {\n    let cur = (fromIdx + 1) % order.length;\n    while (cur !== toIdx) {\n      const s = seats[order[cur]];\n      if (!s.folded && s.action === null) {\n        s.folded = true;\n        s.action = \"Fold\";\n      }\n      cur = (cur + 1) % order.length;\n    }\n  };\n\n  const tokens = history.split(\"_\");\n  let prevIdx = -1;\n  let toCall = 1;\n\n  for (let i = 0; i + 1 < tokens.length; i += 2) {\n    const pos = tokens[i];\n    const token = tokens[i + 1];\n    const posIdx = order.indexOf(pos);\n    if (posIdx === -1) continue;\n    if (prevIdx >= 0) foldBetween(prevIdx, posIdx);\n\n    const seat = seats[pos];\n    const sized = sizes[tokens.slice(0, i + 2).join(\"_\")];\n    const house = sizeOf?.(token) ?? plo5RaiseToBb(token);\n    seat.action = displayAction(token, sizeOf);\n    const raiseTo = (to: number) => {\n      const capped = Math.min(stackOf(pos), to);\n      seat.committed = capped;\n      seat.raiseTo = capped;\n      toCall = Math.max(toCall, capped);\n    };\n    if (isFold(token)) {\n      seat.folded = true;\n    } else if (isCall(token)) {\n      seat.committed = toCall;\n    } else if (isAllIn(token)) {\n      raiseTo(stackOf(pos) - seat.ante);\n    } else if (sized !== undefined) {\n      raiseTo(sized);\n      seat.action = `Raise ${formatBbExact(seat.raiseTo ?? sized)}`;\n    } else if (house !== null) {\n      raiseTo(house);\n    } else if (token.endsWith(\"%\")) {\n      // A percentage of the pot after the call.\n      raiseTo(toCall + (parseFloat(token) / 100) * sum());\n    } else if (PLO_RAISE_RE.test(token)) {\n      // Pot-limit raise: call, then raise by the pot after the call.\n      raiseTo(2 * toCall + sum() - seat.committed);\n    }\n    prevIdx = posIdx;\n  }\n\n  const closed = tokens.length % 2 === 0;\n  const actor = closed ? null : tokens[tokens.length - 1] || order[0];\n  const actorIdx = actor ? order.indexOf(actor) : -1;\n  if (prevIdx >= 0 && actorIdx !== -1) foldBetween(prevIdx, actorIdx);\n\n  for (const p of order) {\n    seats[p].remaining = Math.max(0, stackOf(p) - seats[p].committed - seats[p].ante);\n  }\n  const antes = bbAnte + antePerSeat * order.length + deadAnte;\n  return {\n    seats,\n    pot: sum() + antes,\n    actor: actor && seats[actor] ? actor : null,\n    toCall: Math.max(...order.map((p) => seats[p].committed)),\n    antes,\n  };\n}\n\n/** The seat that acts after `token` is taken at `history`, or null when nobody is left to act. */\nexport function nextActorAfter(history: string, token: string, spec: TableSpec): string | null {\n  const { order } = spec;\n  const { seats } = parseHistory(history, spec);\n  const tokens = history.split(\"_\");\n  const actor = tokens[tokens.length - 1];\n  const actorIdx = order.indexOf(actor);\n  if (actorIdx === -1) return null;\n  const live = new Set(order.filter((p) => !seats[p].folded));\n  if (isFold(token)) live.delete(actor);\n  for (let i = 1; i <= order.length; i++) {\n    const cand = order[(actorIdx + i) % order.length];\n    if (live.has(cand)) return cand;\n  }\n  return null;\n}\n\n/** `history` with `token` taken: the next seat's decision, or a closed line. */\nexport function advanceHistory(history: string, token: string, spec: TableSpec): string {\n  const next = nextActorAfter(history, token, spec);\n  return next ? `${history}_${token}_${next}` : `${history}_${token}`;\n}\n\n/** `history` with the last action undone (and the seat it handed the turn to). */\nexport function backHistory(history: string): string {\n  const tokens = history.split(\"_\");\n  if (tokens.length < 2) return history;\n  return tokens.slice(0, isClosed(history) ? -1 : -2).join(\"_\");\n}\n\n/** `history` rewound to `pos`'s last decision on the line, or null if it hasn't acted. */\nexport function rewindHistory(history: string, pos: string): string | null {\n  const tokens = history.split(\"_\");\n  let last = -1;\n  for (let i = 0; i < tokens.length; i += 2) if (tokens[i] === pos) last = i;\n  return last === -1 ? null : tokens.slice(0, last + 1).join(\"_\");\n}\n\n/** Who is still in and what the pot holds once a line has closed. */\nexport interface WalkEnd {\n  /** \"won\": folded to one player. \"flop\": two or more see the flop. */\n  reason: \"won\" | \"flop\";\n  survivors: string[];\n  potBb: number;\n  effStackBb: number;\n}\n\nexport function endOfWalk(table: WalkTable, order: string[]): WalkEnd {\n  const survivors = order.filter((p) => !table.seats[p].folded);\n  const effStackBb = survivors.length ? Math.min(...survivors.map((p) => table.seats[p].remaining)) : 0;\n  return { reason: survivors.length <= 1 ? \"won\" : \"flop\", survivors, potBb: table.pot, effStackBb };\n}\n\n/** Display label for an action: the solver's raise-to beats any token spelling. */\nexport function walkActionLabel(action: WalkAction, stack: number, sizeOf?: TableSpec[\"sizeOf\"]): string {\n  if (action.kind === \"fold\") return \"Fold\";\n  if (action.kind === \"call\") {\n    if (action.action === \"X\") return \"Check\";\n    if (action.action === \"Limp\") return \"Limp\";\n    return \"Call\";\n  }\n  if (action.kind === \"allin\" || isAllIn(action.action)) return `All-in ${formatBbExact(action.sizeBb ?? stack)}`;\n  if (action.sizeBb != null) return `Raise ${formatBbExact(action.sizeBb)}`;\n  if (action.kind === \"raise\") {\n    if (action.action === \"Open\") return \"Open\";\n    const house = sizeOf?.(action.action) ?? plo5RaiseToBb(action.action);\n    if (house !== null) return `Raise ${formatBbExact(house)}`;\n    const plo = action.action.match(PLO_RAISE_RE);\n    if (plo) return plo[2] ? `${PLO_RAISE_LABEL[plo[1]]} pot (${plo[2]})` : `${PLO_RAISE_LABEL[plo[1]]} pot`;\n    return `Raise ${action.action}`;\n  }\n  return action.action;\n}\n\n/** Fold → call → raises by size → all-in, each with its stable color (from `palette`, Classic by default) and label. */\nexport function colorWalkActions(\n  actions: WalkAction[],\n  stack: number,\n  sizeOf?: TableSpec[\"sizeOf\"],\n  palette?: ActionPaletteId | null,\n): { action: WalkAction; label: string; color: string }[] {\n  const p = actionPalette(palette);\n  const sorted = sortActions(actions).sort((a, b) =>\n    a.kind === b.kind && a.kind === \"raise\" && a.sizePct == null && b.sizePct == null\n      ? (a.sizeBb ?? 0) - (b.sizeBb ?? 0)\n      : 0,\n  );\n  let raise = 0;\n  return sorted.map((action) => {\n    let color: string;\n    if (action.kind === \"fold\") color = p.Fold;\n    else if (action.kind === \"call\") color = p.Call;\n    else if (action.kind === \"allin\") color = p.AllIn;\n    else color = p.raises[Math.min(raise++, p.raises.length - 1)];\n    return { action, label: walkActionLabel(action, stack, sizeOf), color };\n  });\n}\n\n/** Each action's share of the range on the 13×13 grid, weighting every class by its combos. */\nexport function rangeMix(colored: { action: WalkAction; label: string; color: string }[]) {\n  let rangeCombos = 0;\n  for (const hand of ALL_HANDS) {\n    if (colored.some(({ action }) => (action.weights[hand] ?? 0) > 0)) rangeCombos += comboCount(hand);\n  }\n  const denom = rangeCombos || 1;\n  return colored.map(({ action, label, color }) => {\n    let combos = 0;\n    for (const hand of ALL_HANDS) {\n      const w = action.weights[hand] ?? 0;\n      if (w > 0) combos += w * comboCount(hand);\n    }\n    return { label, color, freq: combos / denom, detail: `${combos.toFixed(1)} combos` };\n  });\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerstudy/preflop-walk.ts"
    }
  ],
  "type": "registry:block"
}