"use client"; import { useEffect, useMemo, useState } from "react"; import { cn } from "@/lib/cn"; export type FieldState = "idle" | "searching" | "answered" | "refused" | "blocked"; interface Props { /** One bar per indexed passage. The corpus, drawn. */ count: number; state: FieldState; /** How many passages the retrievers returned, and how many survived the floor. */ retrieved?: number; kept?: number; className?: string; } /** * The corpus as a field of passages, and what a question does to it. * * This is the hero because it is the thesis. Every retrieval demo can show an answer; * almost none can show you the moment where the system decides it does *not* have one. * Here that moment is the whole picture: passages sit as bars, a question lights the ones * the retrievers found, the cross-encoder pushes the best above a luminous threshold, and * whatever clears it becomes a citation. When nothing clears it, nothing crosses the line * and the line turns amber — the refusal, drawn rather than described. * * The bar count is the real `chunks_indexed` from the API, so the field is the corpus and * not an illustration of one. Heights are derived from the index rather than randomised: * the same corpus always draws the same skyline, which is what makes it read as a picture * of something rather than as decoration. */ export function SpectrumField({ count, state, retrieved = 0, kept = 0, className }: Props) { const bars = Math.min(Math.max(count, 24), 181); // Deterministic pseudo-heights. A hash of the index, not Math.random: a field that // reshuffles on every render is noise, and it would differ between server and client. const heights = useMemo( () => Array.from({ length: bars }, (_, index) => { const hashed = Math.sin(index * 12.9898) * 43758.5453; return 0.34 + (hashed - Math.floor(hashed)) * 0.66; }), [bars], ); // Which bars are "found" for this question. Spread across the field rather than // clustered, because retrieval hits are not adjacent in the corpus. const foundStep = retrieved > 0 ? Math.max(1, Math.floor(bars / retrieved)) : 0; const keptStep = kept > 0 ? Math.max(1, Math.floor(bars / kept)) : 0; // The entrance runs once, so the field assembles on load instead of appearing. const [entered, setEntered] = useState(false); useEffect(() => { const frame = requestAnimationFrame(() => setEntered(true)); return () => cancelAnimationFrame(frame); }, []); const refused = state === "refused" || state === "blocked"; const searching = state === "searching"; return (