File size: 5,570 Bytes
3fc8e60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | "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 (
<div
className={cn("relative select-none", className)}
aria-hidden
// Decorative: the same information is in the answer, the citations and the score
// ladder, all of which are real text. A screen reader gains nothing from a skyline.
>
<div className="flex h-[104px] items-end gap-[2px] sm:gap-[3px]">
{heights.map((height, index) => {
const isFound = foundStep > 0 && index % foundStep === 0;
const isKept = keptStep > 0 && index % keptStep === 0 && !refused;
// At rest the field carries the spectrum itself, so the scale that colour
// means is legible before a question is asked rather than only after one.
const idleHue =
index / bars < 0.34
? "var(--color-indigo)"
: index / bars < 0.67
? "var(--color-violet)"
: "var(--color-ochre)";
const active = isKept
? "var(--color-indigo)"
: isFound
? refused
? "var(--color-ochre)"
: "var(--color-violet)"
: idleHue;
return (
<span
key={index}
className={cn(
"flex-1 rounded-[1px] transition-all duration-700 ease-out",
searching ? "pulse" : "shimmer",
)}
style={{
background: active,
height: entered ? `${(isKept ? 1 : isFound ? 0.8 : 0.52) * height * 100}%` : "4%",
opacity: entered ? (isKept ? 1 : isFound ? 0.9 : 0.42) : 0,
// Staggered so the field assembles left to right, ~0.6s end to end, and
// the idle shimmer inherits the same offset so it travels rather than
// flickering in place.
transitionDelay: `${(index % bars) * (600 / bars)}ms`,
animationDelay: `${(index % bars) * (2600 / bars)}ms`,
}}
/>
);
})}
</div>
{/* The threshold. Everything above it is answerable; everything below is a near
miss. It is the only element in the interface allowed to glow. */}
<div
className={cn("threshold mt-3 w-full", searching && "pulse")}
data-state={refused ? "refused" : undefined}
/>
<div className="mt-2 flex items-baseline justify-between">
<span className="marginal">
{refused
? "nothing cleared the line — Lexora refuses"
: state === "answered"
? `${kept} passage${kept === 1 ? "" : "s"} cleared the line and became citations`
: searching
? "searching…"
: `${count} passages, waiting for a question`}
</span>
<span className="marginal hidden sm:inline">
{state === "idle" ? "" : `${retrieved} found`}
</span>
</div>
</div>
);
}
|