{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "embedding-space",
  "title": "Embedding Space",
  "description": "A cloud of document chunks. Choosing a question draws lines to the closest chunks and ranks them with scores.",
  "dependencies": [
    "three",
    "@react-three/fiber"
  ],
  "devDependencies": [
    "@types/three"
  ],
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/gl-stage.json",
    "https://spotcheckui.com/r/use-gl-palette.json",
    "https://spotcheckui.com/r/use-orbit-drag.json",
    "https://spotcheckui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/embedding-space.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { invalidate as invalidateAll, useFrame, useThree } from \"@react-three/fiber\"\nimport {\n  BoxGeometry,\n  BufferAttribute,\n  BufferGeometry,\n  EdgesGeometry,\n  type Group,\n  LineBasicMaterial,\n  type Mesh,\n  MeshBasicMaterial,\n  PointsMaterial,\n  Vector3,\n} from \"three\"\n\nimport { GlStage } from \"@/registry/spotcheck/components/spotcheck/gl-stage\"\nimport { useGlPalette } from \"@/registry/spotcheck/hooks/use-gl-palette\"\nimport { useOrbitDrag } from \"@/registry/spotcheck/hooks/use-orbit-drag\"\nimport { useReducedMotion } from \"@/registry/spotcheck/hooks/use-reduced-motion\"\nimport { cn } from \"@/lib/utils\"\n\nconst START_ANGLE = 0.5\nconst TILT = 0.12\nconst SPIN_PER_SECOND = 0.15\nconst MARKER_EASE = 0.08\nconst LINE_GROWTH = 0.035\nconst POINT_SIZE = 0.15\nconst HIT_SIZE = 0.2\nconst MARKER_SIZE = 0.24\nconst FRAME_SIZE = 9\nconst PALETTE_TOKENS = [\"--ink\", \"--mute\", \"--accent-ink\"] as const\nconst CAMERA = { fov: 42, position: [0, 1.5, 16], near: 0.1, far: 100 } as const\nconst LOOK_AT: [number, number, number] = [0, 0, 0]\n\ntype Palette = Record<(typeof PALETTE_TOKENS)[number], string>\n\nexport type EmbeddingVector = [x: number, y: number, z: number]\nexport type EmbeddingPoint = { id: string; position: EmbeddingVector; label: string }\nexport type EmbeddingNeighbour = { id: string; score: number }\nexport type EmbeddingQuery = {\n  id: string\n  label: string\n  position: EmbeddingVector\n  /** The closest chunks, best first. Each `id` is a point's id. */\n  neighbours: EmbeddingNeighbour[]\n}\n\n/** Joins a query's neighbours to their points. Neighbours with no matching point are dropped. */\nexport function rankNeighbours(points: EmbeddingPoint[], query: EmbeddingQuery | undefined) {\n  if (!query) return []\n  const byId = new Map(points.map((point) => [point.id, point]))\n  return query.neighbours.flatMap((neighbour) => {\n    const point = byId.get(neighbour.id)\n    return point ? [{ ...neighbour, point }] : []\n  })\n}\n\nfunction positionAttribute(count: number) {\n  return new BufferAttribute(new Float32Array(count * 3), 3)\n}\n\ntype CloudProps = {\n  points: EmbeddingPoint[]\n  target: EmbeddingVector\n  hits: EmbeddingPoint[]\n  slots: number\n  still: boolean\n  palette: Palette | null\n  rotation: React.RefObject<number>\n  dragging: React.RefObject<boolean>\n}\n\nfunction Cloud({ points, target, hits, slots, still, palette, rotation, dragging }: CloudProps) {\n  const group = React.useRef<Group>(null)\n  const marker = React.useRef<Mesh>(null)\n  const invalidate = useThree((state) => state.invalidate)\n\n  const parts = React.useMemo(() => {\n    const cloud = new BufferGeometry()\n    const positions = new Float32Array(points.length * 3)\n    points.forEach((point, index) => positions.set(point.position, index * 3))\n    cloud.setAttribute(\"position\", new BufferAttribute(positions, 3))\n\n    const hitCloud = new BufferGeometry()\n    hitCloud.setAttribute(\"position\", positionAttribute(slots))\n    const lines = new BufferGeometry()\n    lines.setAttribute(\"position\", positionAttribute(slots * 2))\n    const frameBox = new BoxGeometry(FRAME_SIZE, FRAME_SIZE, FRAME_SIZE)\n    return {\n      cloud,\n      hitCloud,\n      lines,\n      box: new BoxGeometry(MARKER_SIZE, MARKER_SIZE, MARKER_SIZE),\n      frameEdges: new EdgesGeometry(frameBox),\n      pointMaterial: new PointsMaterial({ size: POINT_SIZE, sizeAttenuation: true }),\n      hitMaterial: new PointsMaterial({ size: HIT_SIZE, sizeAttenuation: true }),\n      lineMaterial: new LineBasicMaterial({ transparent: true, opacity: 0.9 }),\n      markerMaterial: new MeshBasicMaterial(),\n      frameMaterial: new LineBasicMaterial({ transparent: true, opacity: 0.35 }),\n    }\n  }, [points, slots])\n\n  const motion = React.useRef({ marker: new Vector3(), goal: new Vector3(), grow: 0, spin: 0 })\n\n  React.useEffect(() => {\n    return () => Object.values(parts).forEach((part) => part.dispose())\n  }, [parts])\n\n  React.useEffect(() => {\n    if (!palette) return\n    parts.pointMaterial.color.set(palette[\"--mute\"])\n    parts.frameMaterial.color.set(palette[\"--mute\"])\n    parts.hitMaterial.color.set(palette[\"--accent-ink\"])\n    parts.lineMaterial.color.set(palette[\"--accent-ink\"])\n    parts.markerMaterial.color.set(palette[\"--ink\"])\n    invalidate()\n  }, [invalidate, palette, parts])\n\n  React.useEffect(() => {\n    const positions = parts.hitCloud.getAttribute(\"position\") as BufferAttribute\n    positions.array.fill(0)\n    hits.forEach((hit, index) => positions.setXYZ(index, ...hit.position))\n    positions.needsUpdate = true\n    parts.hitCloud.setDrawRange(0, hits.length)\n    parts.lines.setDrawRange(0, hits.length * 2)\n    motion.current.goal.set(...target)\n    motion.current.grow = 0\n    invalidate()\n  }, [hits, invalidate, parts, target])\n\n  useFrame(({ clock }, delta) => {\n    if (!group.current || !marker.current) return\n    const state = motion.current\n    if (!still && !dragging.current) state.spin += delta * SPIN_PER_SECOND\n    group.current.rotation.set(TILT, rotation.current + state.spin, 0)\n\n    if (still) {\n      state.marker.copy(state.goal)\n      state.grow = 1\n    } else {\n      state.marker.lerp(state.goal, MARKER_EASE)\n      state.grow = Math.min(1, state.grow + LINE_GROWTH)\n      marker.current.rotation.set(clock.elapsedTime * 0.7, clock.elapsedTime * 0.9, 0)\n    }\n    marker.current.position.copy(state.marker)\n\n    const lines = parts.lines.getAttribute(\"position\") as BufferAttribute\n    const { x, y, z } = state.marker\n    hits.forEach((hit, index) => {\n      const [hitX, hitY, hitZ] = hit.position\n      lines.setXYZ(index * 2, x, y, z)\n      lines.setXYZ(index * 2 + 1, x + (hitX - x) * state.grow, y + (hitY - y) * state.grow, z + (hitZ - z) * state.grow)\n    })\n    lines.needsUpdate = true\n  })\n\n  return (\n    <group ref={group}>\n      <points geometry={parts.cloud} material={parts.pointMaterial} />\n      <points geometry={parts.hitCloud} material={parts.hitMaterial} frustumCulled={false} />\n      <lineSegments geometry={parts.lines} material={parts.lineMaterial} frustumCulled={false} />\n      <lineSegments geometry={parts.frameEdges} material={parts.frameMaterial} />\n      <mesh ref={marker} geometry={parts.box} material={parts.markerMaterial} />\n    </group>\n  )\n}\n\ntype EmbeddingSpaceProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> & {\n  /** Every chunk, with a 3D position you've worked out. The component doesn't compute embeddings. */\n  points: EmbeddingPoint[]\n  /** The questions to choose from, each with its own position, neighbours and scores. */\n  queries: EmbeddingQuery[]\n  /** The chosen query's id, when you control it. */\n  value?: string\n  /** The starting query's id, when the component manages itself. */\n  defaultValue?: string\n  /** Runs when a different query is chosen. */\n  onValueChange?: (value: string) => void\n  /** Describes the cloud for screen readers. */\n  label?: string\n  /** Formats each score. Pass it from a client component. */\n  formatScore?: (score: number) => string\n}\n\nexport function EmbeddingSpace({\n  points,\n  queries,\n  value,\n  defaultValue,\n  onValueChange,\n  label = \"Point cloud of document chunks\",\n  formatScore = (score) => score.toFixed(3),\n  className,\n  ...props\n}: EmbeddingSpaceProps) {\n  const host = React.useRef<HTMLDivElement>(null)\n  const reduced = useReducedMotion()\n  const palette = useGlPalette(host, PALETTE_TOKENS)\n  const { rotation, dragging, bind } = useOrbitDrag({ angle: START_ANGLE, onChange: () => invalidateAll() })\n  const headingId = React.useId()\n\n  const [internal, setInternal] = React.useState(defaultValue ?? queries[0]?.id)\n  const current = value ?? internal\n  const query = queries.find((candidate) => candidate.id === current)\n  const ranked = React.useMemo(() => rankNeighbours(points, query), [points, query])\n  const hits = React.useMemo(() => ranked.map((neighbour) => neighbour.point), [ranked])\n  const slots = Math.max(1, ...queries.map((candidate) => candidate.neighbours.length))\n\n  const choose = (id: string) => {\n    setInternal(id)\n    if (id !== current) onValueChange?.(id)\n  }\n\n  return (\n    <div\n      className={cn(\n        \"border-ink grid min-w-0 border-(length:--rule) font-(family-name:--f-body) md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]\",\n        className,\n      )}\n      {...props}\n    >\n      <GlStage\n        ref={host}\n        data-testid=\"embedding-space-cloud\"\n        label={label}\n        camera={CAMERA}\n        lookAt={LOOK_AT}\n        className=\"border-ink h-75 cursor-grab border-b-(length:--rule) select-none active:cursor-grabbing md:h-auto md:min-h-95 md:border-r-(length:--rule) md:border-b-0\"\n        {...bind}\n      >\n        <Cloud\n          points={points}\n          target={query?.position ?? LOOK_AT}\n          hits={hits}\n          slots={slots}\n          still={reduced}\n          palette={palette}\n          rotation={rotation}\n          dragging={dragging}\n        />\n      </GlStage>\n\n      <div className=\"flex min-w-0 flex-col gap-3 p-3.5\">\n        <div className=\"flex flex-col gap-1.5\">\n          <div className=\"spotcheck-label\">Query</div>\n          <div className=\"border-ink flex flex-col border-(length:--rule)\">\n            {queries.map((candidate) => (\n              <button\n                key={candidate.id}\n                type=\"button\"\n                aria-pressed={candidate.id === current}\n                className=\"spotcheck-focus aria-pressed:bg-ink aria-pressed:text-paper cursor-pointer border-[color-mix(in_srgb,var(--ink)_25%,transparent)] px-2.5 py-2 text-left text-sm -outline-offset-2 not-first:border-t\"\n                onClick={() => choose(candidate.id)}\n              >\n                {candidate.label}\n              </button>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"flex min-w-0 flex-col gap-1.5\">\n          <div id={headingId} className=\"spotcheck-label\">\n            Top {ranked.length} neighbours\n          </div>\n          <ol aria-labelledby={headingId} className=\"font-(family-name:--f-mono) text-xs\">\n            {ranked.map((neighbour, index) => (\n              <li\n                key={neighbour.id}\n                className=\"grid grid-cols-[1.6em_minmax(0,1fr)_auto] items-end gap-1.5 border-b border-[color-mix(in_srgb,var(--ink)_15%,transparent)] pt-1.5 pb-0.5\"\n              >\n                <span aria-hidden className=\"text-mute tabular-nums\">\n                  {String(index + 1).padStart(2, \"0\")}\n                </span>\n                <span className=\"truncate\">{neighbour.point.label}</span>\n                <span className=\"text-accent-ink tabular-nums\">{formatScore(neighbour.score)}</span>\n              </li>\n            ))}\n          </ol>\n        </div>\n\n        <p role=\"status\" className=\"sr-only\">\n          {query ? `Showing ${ranked.length} closest chunks for: ${query.label}` : \"\"}\n        </p>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "webgl"
  ],
  "type": "registry:component"
}