| "use client"; |
|
|
| import { useEffect, useMemo, useState } from "react"; |
|
|
| import { cn } from "@/lib/cn"; |
|
|
| export type FieldState = "idle" | "searching" | "answered" | "refused" | "blocked"; |
|
|
| interface Props { |
| |
| count: number; |
| state: FieldState; |
| |
| retrieved?: number; |
| kept?: number; |
| className?: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function SpectrumField({ count, state, retrieved = 0, kept = 0, className }: Props) { |
| const bars = Math.min(Math.max(count, 24), 181); |
|
|
| |
| |
| 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], |
| ); |
|
|
| |
| |
| const foundStep = retrieved > 0 ? Math.max(1, Math.floor(bars / retrieved)) : 0; |
| const keptStep = kept > 0 ? Math.max(1, Math.floor(bars / kept)) : 0; |
|
|
| |
| 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> |
| ); |
| } |
|
|