SPB Git

spb/search-box Public

Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.

TypeScript 76.9% CSS 18.7% SQL 2.1% JavaScript 1.8% Shell 0.5%
3.7 KB · 110 lines tsx
Raw Blame History
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/components/ResearchGraph.tsx6 * Description: Signature "bloom" — the question expands into objectives and claims as real events arrive.7 */89"use client";1011import type { Claim } from "@search-box/shared";1213const W = 980;14const QX = 84;1516function truncate(s: string, n: number): string {17  return s.length > n ? `${s.slice(0, n - 1)}…` : s;18}1920const STATUS_COLOR: Record<string, string> = {21  supported: "var(--teal)",22  contradicted: "var(--accent)",23  uncertain: "var(--amber)",24  exploring: "var(--ink-faint)"25};2627/**28 * Deterministic layout: objectives fan out from the question node; claims29 * bloom further right, each visually attached to an objective branch.30 * Every element appears because a real backend event arrived.31 */32export function ResearchGraph({33  objectives,34  claims,35  live,36  hasContradiction37}: {38  objectives: string[];39  claims: Claim[];40  live: boolean;41  hasContradiction: boolean;42}) {43  const nObj = objectives.length;44  const rows = Math.max(nObj, 1);45  const rowGap = Math.min(46, 190 / rows);46  const height = Math.max(150, rows * rowGap + 70);47  const qy = height / 2;4849  const objY = (i: number) => qy + (i - (nObj - 1) / 2) * rowGap;50  const objX = 470;51  const claimX = 830;52  const claimY = (i: number) =>53    qy + (i - (claims.length - 1) / 2) * Math.min(52, (height - 60) / Math.max(claims.length, 1));5455  return (56    <div className="bloom-wrap" role="img" aria-label="Research map: question, objectives and claims">57      <svg viewBox={`0 0 ${W} ${height}`} preserveAspectRatio="xMidYMid meet">58        {/* objective branches */}59        {objectives.map((o, i) => {60          const y = objY(i);61          return (62            <g key={`o${i}`}>63              <path64                className="rg-branch"65                style={{ animationDelay: `${i * 90}ms` }}66                d={`M${QX + 14},${qy} C ${QX + 170},${qy} ${objX - 190},${y} ${objX - 10},${y}`}67              />68              <circle className="rg-node" style={{ animationDelay: `${i * 90 + 250}ms` }} cx={objX} cy={y} r={4} fill="var(--ink)" />69              <text className="rg-label rg-sm" x={objX + 12} y={y + 3.5}>70                {truncate(o, 44)}71              </text>72            </g>73          );74        })}7576        {/* claim blooms */}77        {claims.map((c, i) => {78          const anchor = nObj > 0 ? objY(i % nObj) : qy;79          const y = claimY(i);80          const color = STATUS_COLOR[c.status] ?? "var(--ink-faint)";81          return (82            <g key={c.id}>83              <path84                className="rg-twig"85                style={{ animationDelay: "120ms" }}86                d={`M${objX + 4},${anchor} C ${objX + 150},${anchor} ${claimX - 140},${y} ${claimX - 12},${y}`}87              />88              <circle className="rg-node" cx={claimX} cy={y} r={6} fill={color} opacity={0.18} style={{ animationDelay: "300ms" }} />89              <circle className="rg-node" cx={claimX} cy={y} r={Math.max(3, 5.5 * c.confidence)} fill={color} style={{ animationDelay: "300ms" }} />90              <text className="rg-label rg-sm" x={claimX + 12} y={y + 3.5} fill={color}>91                {truncate(c.text, 22)} · {Math.round(c.confidence * 100)}%92              </text>93            </g>94          );95        })}9697        {/* the question node */}98        <g>99          {live && <circle className="rg-qring live" cx={QX} cy={qy} r={16} />}100          <circle className="rg-qring" cx={QX} cy={qy} r={11} />101          <circle className="rg-node" cx={QX} cy={qy} r={7} fill={hasContradiction ? "var(--accent)" : "var(--ink)"} />102          <text className="rg-label strong" x={QX - 8} y={qy + 30}>103            question104          </text>105        </g>106      </svg>107    </div>108  );109}110