{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wellhead-tree",
  "title": "Wellhead tree",
  "description": "The pressure-control stack on a completed well: a master, a swab and two wing valves whose rising stems show their state, an adjustable choke, and an accent that follows only the bore the open valves actually leave through.",
  "registryDependencies": [
    "https://robocn.dev/r/robot-style.json",
    "https://robocn.dev/r/robot-kinematics.json",
    "https://robocn.dev/r/linkage-geometry.json",
    "https://robocn.dev/r/use-robot-motion.json"
  ],
  "files": [
    {
      "path": "src/components/ui/wellhead-tree.tsx",
      "content": "\"use client\"\n\n/**\n * wellhead-tree — the valve stack that stands on a completed well.\n *\n * `solenoid-valve` is one valve; this is the assembly. Which bore carries\n * product is not decoration: the `service` pose says which valves are open, and\n * the accent is drawn only along the path those open valves actually leave\n * through. Shut the master and nothing above it is live.\n *\n * Every valve shows its state the way a real one does — the stem stands up out\n * of the bonnet when it is open and sits down when it is shut — and the\n * adjustable choke's bean opening sets both the indicator and the width of the\n * flow line leaving it.\n *\n * `pressure` is a reading you supply. The component never infers it from the\n * choke, the service pose, or anything else, and computes no flow, pressure,\n * temperature or rate of any kind.\n */\n\nimport * as React from \"react\"\n\nimport { clamp, lerp, type Vec2 } from \"@/lib/robocn/kinematics\"\nimport { arrowStep, useRobotDrag, useRobotScalar } from \"@/hooks/use-robot-motion\"\nimport { rigidPoint } from \"@/lib/robocn/linkage\"\nimport {\n  boxCorners,\n  elevationDraft,\n  fitTransform,\n  px,\n  resolveRobotPalette,\n  resolveRobotSize,\n  robotCamera,\n  robotSurface,\n  type RobotPaletteProps,\n  type RobotSize,\n  type RobotVariant,\n  type RobotView,\n} from \"@/lib/robocn/style\"\nimport { cn } from \"@/lib/utils\"\n\nexport type WellheadBehavior = \"throttle\" | \"shut-in\" | \"static\"\n/** What the tree is lined up to do. */\nexport type WellheadService = \"production\" | \"shut-in\" | \"kill\"\n\nconst VIEW_WIDTH = 210\nconst VIEW_HEIGHT = 250\nconst NATIVE_VIEW: RobotView = \"front\"\n\nconst CROSS = 168\nconst CHOKE_X = 68\nconst KILL_X = -68\n\nconst ENVELOPE = boxCorners({ x: -96, y: -12, z: -36 }, { x: 96, y: 234, z: 36 })\n\nconst viewNames: Record<RobotView, string> = {\n  plan: \"plan view\",\n  front: \"front elevation\",\n  profile: \"side elevation\",\n  iso: \"isometric view\",\n}\n\ntype ValveName = \"master\" | \"swab\" | \"wing-left\" | \"wing-right\"\n\n/** Which valves each service pose has open. */\nconst services: Record<WellheadService, Record<ValveName, boolean>> = {\n  production: { master: true, swab: false, \"wing-left\": false, \"wing-right\": true },\n  \"shut-in\": { master: false, swab: false, \"wing-left\": false, \"wing-right\": false },\n  kill: { master: true, swab: false, \"wing-left\": true, \"wing-right\": false },\n}\n\nexport interface WellheadTreeProps\n  extends Omit<React.ComponentProps<\"svg\">, \"color\">,\n    RobotPaletteProps {\n  /** Controlled choke opening, 0 shut to 1 wide open. Supplying it stops the loop. */\n  choke?: number\n  onChokeChange?: (choke: number) => void\n  behavior?: WellheadBehavior\n  service?: WellheadService\n  /**\n   * A gauge reading you supply, 0 to 1 of the dial. Never inferred: the tree\n   * has no pressure model.\n   */\n  pressure?: number\n  showFlow?: boolean\n  showGround?: boolean\n  view?: RobotView\n  speed?: number\n  phase?: number\n  paused?: boolean\n  animate?: boolean\n  interactive?: boolean\n  label?: string\n  size?: RobotSize | number\n  variant?: RobotVariant\n}\n\nfunction WellheadTree({\n  choke,\n  onChokeChange,\n  behavior = \"throttle\",\n  service = \"production\",\n  pressure = 0.58,\n  showFlow = true,\n  showGround = true,\n  view = NATIVE_VIEW,\n  speed = 0.22,\n  phase = 0,\n  paused = false,\n  animate = true,\n  interactive = false,\n  label,\n  size = \"md\",\n  variant = \"solid\",\n  color,\n  accent,\n  metal,\n  dark,\n  glow,\n  grid,\n  palette: paletteOverride,\n  className,\n  style,\n  role,\n  tabIndex,\n  onKeyDown,\n  onBlur,\n  \"aria-label\": ariaLabel,\n  ...props\n}: WellheadTreeProps) {\n  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })\n  const width = resolveRobotSize(size)\n  const svgRef = React.useRef<SVGSVGElement>(null)\n  const [held, setHeld] = React.useState<number | null>(null)\n  const controlled = choke !== undefined\n  const lineup = services[service] ?? services.production\n\n  const hold = controlled ? (Number.isFinite(choke) ? clamp(choke as number, 0, 1) : 0) : held\n  const goal = React.useCallback((clock: number) => wellheadChoke(behavior, clock), [behavior])\n  const motion = useRobotScalar(goal, {\n    rate: 0.8,\n    hold,\n    speed,\n    paused,\n    phase,\n    animate: animate && !controlled && behavior !== \"static\",\n  })\n  const open = clamp(motion.value, 0, 1)\n  const dial = Number.isFinite(pressure) ? clamp(pressure, 0, 1) : 0\n\n  const camera = robotCamera(view)\n  const frame = fitTransform(ENVELOPE, camera, VIEW_WIDTH, VIEW_HEIGHT)\n  const { point: to, path: line, solid, box, bar, disc } = elevationDraft(camera, \"front\")\n\n  const apply = React.useCallback(\n    (next: number) => {\n      const bounded = Math.round(clamp(next, 0, 1) * 100) / 100\n      setHeld(bounded)\n      onChokeChange?.(bounded)\n    },\n    [onChokeChange],\n  )\n  const dragging = useRobotDrag(svgRef, {\n    enabled: interactive,\n    onDrag: React.useCallback((unit: Vec2) => apply(1 - unit.y), [apply]),\n    onDragEnd: React.useCallback(() => setHeld(null), []),\n  })\n\n  const shell = robotSurface(\"shell\", variant, palette)\n  const machined = robotSurface(\"metal\", variant, palette)\n  const cast = robotSurface(\"dark\", variant, palette)\n\n  /** A gate valve: a body, a rising stem, and the wheel on top of it. */\n  const valve = (\n    name: ValveName,\n    centre: Vec2,\n    half: number,\n    turned: number,\n    isOpen: boolean,\n  ) => {\n    const stem = rigidPoint(centre, turned, half + 8 + (isOpen ? 20 : 1))\n    const bonnet = rigidPoint(centre, turned, half + 2)\n    return (\n      <g key={name} data-valve={name} data-open={isOpen ? \"true\" : \"false\"}>\n        <path d={box(centre.x - half, centre.y - half, centre.x + half, centre.y + half, half)} {...shell} />\n        <path d={bar(centre, bonnet, half * 0.55, half * 0.55)} {...cast} />\n        <path d={bar(bonnet, stem, 2, 2)} {...machined} />\n        <path d={disc(stem, 8, 1.6)} {...machined} />\n        <path d={disc(stem, 2.6, 2.2)} {...cast} />\n      </g>\n    )\n  }\n\n  const percent = Math.round(open * 100)\n  const live = lineup.master && open > 0.02\n  const outlet = lineup[\"wing-right\"] ? CHOKE_X : lineup[\"wing-left\"] ? KILL_X : 0\n  const needle = rigidPoint({ x: 0, y: 222 }, 210 - dial * 240, 8)\n\n  return (\n    <svg\n      ref={svgRef}\n      role={role ?? (interactive ? \"slider\" : \"img\")}\n      aria-label={\n        ariaLabel ??\n        `Wellhead tree lined up for ${service}, choke ${percent} percent open, ${viewNames[view] ?? viewNames.front}`\n      }\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? 1 : undefined}\n      aria-valuenow={interactive ? px(open) : undefined}\n      aria-valuetext={interactive ? `choke ${percent} percent open` : undefined}\n      tabIndex={tabIndex ?? (interactive ? 0 : undefined)}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (!interactive || event.defaultPrevented) return\n        const delta = arrowStep(event.key, 0.05, 0.2)\n        if (delta !== 0) apply(open + delta)\n        else if (event.key === \"Home\") apply(0)\n        else if (event.key === \"End\") apply(1)\n        else return\n        event.preventDefault()\n      }}\n      onBlur={(event) => {\n        onBlur?.(event)\n        if (!dragging) setHeld(null)\n      }}\n      viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}\n      width={width}\n      height={px((width * VIEW_HEIGHT) / VIEW_WIDTH)}\n      className={cn(\n        \"max-w-full select-none\",\n        interactive &&\n          \"cursor-grab touch-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[currentColor]\",\n        dragging && \"cursor-grabbing\",\n        className,\n      )}\n      style={{ color: palette.foreground, ...style }}\n      {...props}\n    >\n      {variant === \"blueprint\" && (\n        <path\n          d={`M ${VIEW_WIDTH / 2} 8 V ${VIEW_HEIGHT - 18}`}\n          fill=\"none\"\n          stroke={palette.grid}\n          strokeWidth={0.5}\n          strokeDasharray=\"3 4\"\n          opacity={0.4}\n        />\n      )}\n\n      <g data-view={view} transform={frame || undefined}>\n        {showGround && (\n          <>\n            <path data-ground d={solid([{ x: -92, y: 0 }, { x: 92, y: 0 }], 34)} fill={palette.dark} opacity={0.12} />\n            <path d={line([{ x: -92, y: 0 }, { x: 92, y: 0 }])} fill=\"none\" stroke={palette.dark} strokeWidth={1} opacity={0.5} />\n          </>\n        )}\n\n        {/* Casing head and the spools stacked on it. */}\n        <path d={box(-12, -12, 12, 6, 12)} fill={palette.dark} opacity={0.55} />\n        <path data-casing d={box(-34, 6, 34, 26, 34)} {...cast} />\n        <path d={box(-29, 26, 29, 52, 29)} {...shell} />\n        <path d={box(-32, 50, 32, 56, 32)} {...machined} />\n        <path data-tubing d={box(-26, 56, 26, 84, 26)} {...shell} />\n        <path d={box(-29, 82, 29, 88, 29)} {...machined} />\n\n        {/* The production bore, from the hanger up through the tree. */}\n        <path d={box(-9, 88, 9, 214, 9)} {...cast} />\n        {live && (\n          <path\n            data-flow\n            d={line([{ x: 0, y: 92 }, { x: 0, y: CROSS }])}\n            fill=\"none\"\n            stroke={palette.accent}\n            strokeWidth={5}\n            opacity={0.85}\n          />\n        )}\n\n        {/* Flow cross, with a wing each side. */}\n        <path data-cross d={box(-24, CROSS - 16, 24, CROSS + 16, 24)} {...shell} />\n        <path d={box(-56, CROSS - 10, 56, CROSS + 10, 10)} {...shell} />\n\n        {valve(\"master\", { x: 0, y: 120 }, 17, 0, lineup.master)}\n        {valve(\"swab\", { x: 0, y: 200 }, 14, 0, lineup.swab)}\n        {valve(\"wing-left\", { x: -44, y: CROSS }, 13, 180, lineup[\"wing-left\"])}\n        {valve(\"wing-right\", { x: 44, y: CROSS }, 13, 0, lineup[\"wing-right\"])}\n\n        {/* Adjustable choke: the bean, its stem, and the opening indicator. */}\n        <g data-choke data-opening={px(open)}>\n          <path d={box(CHOKE_X - 14, CROSS - 15, CHOKE_X + 14, CROSS + 15, 14)} {...cast} />\n          <path d={bar({ x: CHOKE_X, y: CROSS + 14 }, { x: CHOKE_X, y: CROSS + 22 + open * 14 }, 2.4, 2.4)} {...machined} />\n          <path d={disc({ x: CHOKE_X, y: CROSS + 26 + open * 14 }, 9, 2)} {...machined} />\n          <path\n            d={line([\n              { x: CHOKE_X - 8, y: CROSS - 9 },\n              { x: CHOKE_X - 8, y: lerp(CROSS - 9, CROSS + 9, open) },\n            ], 15)}\n            fill=\"none\"\n            stroke={palette.accent}\n            strokeWidth={3}\n            strokeLinecap=\"round\"\n          />\n        </g>\n\n        {/* Kill wing: a blind on the side product does not normally leave by. */}\n        <path d={box(KILL_X - 12, CROSS - 12, KILL_X + 12, CROSS + 12, 12)} {...cast} />\n\n        {showFlow && live && outlet !== 0 && (\n          <path\n            data-outlet\n            d={line([\n              { x: 0, y: CROSS },\n              { x: outlet, y: CROSS },\n              { x: outlet * 1.35, y: CROSS },\n              { x: outlet * 1.35, y: 16 },\n            ])}\n            fill=\"none\"\n            stroke={palette.accent}\n            strokeWidth={2 + open * 4}\n            strokeLinecap=\"round\"\n            opacity={0.9}\n          />\n        )}\n\n        {/* Tree cap and the gauge on top of it. */}\n        <path d={box(-16, 214, 16, 222, 16)} {...machined} />\n        <path data-gauge data-reading={px(dial)} d={disc({ x: 0, y: 228 }, 11, 4)} {...machined} />\n        <path d={line([{ x: 0, y: 228 }, needle], 5)} fill=\"none\" stroke={palette.dark} strokeWidth={1.6} strokeLinecap=\"round\" />\n\n        {variant === \"blueprint\" && (\n          <text\n            x={px(to({ x: 44, y: 208 }).x)}\n            y={px(to({ x: 44, y: 208 }).y)}\n            textAnchor=\"start\"\n            fontFamily=\"ui-monospace, monospace\"\n            fontSize={6}\n            fill={palette.foreground}\n          >\n            {`${percent}%`}\n          </text>\n        )}\n      </g>\n\n      {label && (\n        <text\n          x={VIEW_WIDTH / 2}\n          y={VIEW_HEIGHT - 7}\n          textAnchor=\"middle\"\n          fontFamily=\"ui-monospace, monospace\"\n          fontSize={6}\n          fill={palette.foreground}\n        >\n          {label}\n        </text>\n      )}\n    </svg>\n  )\n}\n\n/**\n * Choke opening at `clock`. `throttle` works the bean across its range;\n * `shut-in` closes it and leaves it closed, which is the other thing a choke\n * spends its life doing.\n */\nexport function wellheadChoke(behavior: WellheadBehavior, clock: number) {\n  if (behavior === \"static\" || !Number.isFinite(clock)) return 0.55\n  const t = ((clock % 1) + 1) % 1\n  if (behavior === \"shut-in\") return t < 0.35 ? 0.72 - (t / 0.35) * 0.72 : 0\n  return 0.12 + (1 - Math.abs(2 * t - 1)) * 0.8\n}\n\nexport { WellheadTree }\n",
      "type": "registry:ui",
      "target": "@ui/wellhead-tree.tsx"
    }
  ],
  "categories": [
    "robotics",
    "energy"
  ],
  "type": "registry:ui"
}