spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { ClaimDTO, HistoryPoint, ProvenanceDTO } from "@dci/core";4import { ArrowRight, ExternalLink, History as HistoryIcon, Quote } from "lucide-react";5import { useMemo, useState, type ReactNode } from "react";6import { ConfidenceBadge, EstimateMark, ScopeBadge, SemanticsLabel, TierMark } from "@/components/ui/badge";7import { Button } from "@/components/ui/button";8import { cn } from "@/components/ui/cn";9import { Sheet } from "@/components/ui/sheet";10import { SourceBadge } from "@/components/ui/source-badge";11import { Tab, TabList, TabPanel, Tabs } from "@/components/ui/tabs";12import { fieldLabel, valueHint } from "@/lib/field-labels";13import { fmtDate, fmtValue } from "@/lib/format";14import { CLAIM_STATUS_LABEL, CONFIDENCE_LABEL, EXTRACTION_LABEL, extractionKind, isSiteScopeLabel } from "@/lib/labels";15import { routes } from "@/lib/routes";16import { fieldAliases, useEvidence } from "./evidence-context";1718/* ------------------------------------------------------------------------------------------19 Evidence drawer (§24, §80): right-side sheet on desktop, bottom sheet on mobile. Shows every20 claim + provenance observation behind one field with source, dates, confidence, authority tier,21 extraction method and the supporting sentence; the HISTORY tab lists dated value changes with22 old → new, the source and a WHY? toggle revealing the evidence behind each change.23 ------------------------------------------------------------------------------------------ */2425function hostOf(url: string | null | undefined): string {26 if (!url) return "";27 try {28 return new URL(url).host.replace(/^www\./, "");29 } catch {30 return url;31 }32}3334/** Highlight the figure inside the supporting sentence (first numeric token matching the value, else nothing). */35function Quoted({ text, value }: { text: string; value: number | string | null }) {36 const parts = useMemo(() => {37 if (value === null || value === undefined) return [text];38 const v = typeof value === "number" ? value : Number(String(value).replace(/[^\d.]/g, ""));39 if (!Number.isFinite(v)) return [text];40 const variants = new Set<string>([String(v), v.toLocaleString("en-US"), v.toFixed(1), v.toFixed(0), (v / 1000).toString(), (v / 1e6).toString(), (v / 1e9).toString()]);41 for (const cand of Array.from(variants).sort((a, b) => b.length - a.length)) {42 const i = text.indexOf(cand);43 if (i >= 0 && cand.length > 0) return [text.slice(0, i), text.slice(i, i + cand.length), text.slice(i + cand.length)];44 }45 return [text];46 }, [text, value]);47 return (48 <blockquote className="relative rounded-sm border border-hair bg-surface-2/60 px-3 py-2 text-[12.5px] leading-relaxed text-ink-2">49 <Quote className="absolute right-2 top-2 size-3 text-ink-4" aria-hidden />50 {parts.length === 3 ? (51 <>52 {parts[0]}53 <mark className="rounded-[1px] bg-accent/20 px-0.5 text-ink">{parts[1]}</mark>54 {parts[2]}55 </>56 ) : (57 parts[0]58 )}59 </blockquote>60 );61}6263function Row({ k, v }: { k: string; v: ReactNode }) {64 return (65 <div className="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 border-b border-hair py-1.5 text-[12.5px] last:border-b-0">66 <dt className="text-ink-3">{k}</dt>67 <dd className="min-w-0 break-words text-ink">{v}</dd>68 </div>69 );70}7172function ClaimCard({ c, hint }: { c: ClaimDTO; hint: ReturnType<typeof valueHint> }) {73 const method = extractionKind(c.parserName);74 const nonSite = !isSiteScopeLabel(c.scope);75 return (76 <article className={cn("rounded-sm border px-3 py-2.5", c.isWinner ? "border-accent/40" : "border-hair", c.status !== "current" && "opacity-80")} aria-label={`${c.label} claim from ${c.sourceName}`}>77 <div className="flex flex-wrap items-center gap-1.5">78 <SourceBadge kind={c.sourceKind} name={c.sourceName} url={c.url} publishedAt={c.publishedAt} retrievedAt={c.retrievedAt} documentId={c.documentId} />79 <ScopeBadge scope={c.scope} />80 <SemanticsLabel semantics={c.predicate} />81 <span className="ml-auto inline-flex items-center gap-1.5">82 {c.isWinner && <span className="inline-flex h-[18px] items-center rounded-xs border border-accent/40 bg-accent-soft px-1.5 text-[10px] font-medium uppercase tracking-[0.06em] text-accent-ink">shown</span>}83 {c.status !== "current" && <span className="inline-flex h-[18px] items-center rounded-xs border border-hair-2 px-1.5 text-[10px] uppercase tracking-[0.06em] text-ink-3">{CLAIM_STATUS_LABEL[c.status]}</span>}84 </span>85 </div>86 <div className="mt-2 flex items-baseline gap-2">87 <span className="figure text-[18px] font-medium text-ink">88 {c.isEstimate && <EstimateMark className="mr-0.5 text-[13px]" />}89 {c.value !== null ? fmtValue(c.value, hint ?? (c.unit === "MW" ? "mw" : c.unit === "USD" ? "usd" : undefined)) : (c.valueText ?? "—")}90 </span>91 {c.unit && c.value !== null && !/MW|USD/.test(c.unit) && <span className="text-[12px] text-ink-3">{c.unit}</span>}92 </div>93 {nonSite && <p className="mt-1 text-[11.5px] text-ink-3">Not assigned to this site: the sentence describes a {c.scope === "unknown" ? "figure of unknown scope" : `${c.scope}-level figure`}{c.scopeReason ? ` (${c.scopeReason})` : ""}. Stored as evidence, never summed.</p>}94 {c.evidenceText && (95 <div className="mt-2">96 <Quoted text={c.evidenceText} value={c.value} />97 </div>98 )}99 <dl className="mt-2">100 <Row k="Document" v={<a href={c.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink hover:text-accent">{hostOf(c.url)} <ExternalLink className="size-3" /></a>} />101 <Row k="Published" v={<span className="figure">{c.publishedAt ? fmtDate(c.publishedAt) : "not stated"}</span>} />102 <Row k="Retrieved" v={<span className="figure">{fmtDate(c.retrievedAt)}</span>} />103 <Row k="Confidence" v={<ConfidenceBadge level={c.confidence} short={false} size="xs" />} />104 <Row k="Authority" v={<span className="inline-flex items-center gap-1.5"><TierMark tier={c.authorityTier} /> <span className="text-[11.5px] text-ink-3">for this field</span></span>} />105 <Row k="Extraction" v={<span>{EXTRACTION_LABEL[method]}{c.parserName && <span className="figure ml-1.5 text-[11px] text-ink-3">{c.parserName}{c.parserVersion ? ` v${c.parserVersion}` : ""}</span>}</span>} />106 {c.rejectionReason && <Row k="Reason" v={<span className="text-ink-2">{c.rejectionReason}</span>} />}107 </dl>108 <div className="mt-2 flex items-center gap-2">109 <Button href={c.url} external size="xs" variant="secondary">110 View source <ExternalLink className="size-3" />111 </Button>112 {c.documentId && (113 <Button href={`/admin/documents/${encodeURIComponent(c.documentId)}`} size="xs" variant="ghost" className="text-ink-3">114 Archived document115 </Button>116 )}117 </div>118 </article>119 );120}121122function ProvenanceCard({ p, hint }: { p: ProvenanceDTO; hint: ReturnType<typeof valueHint> }) {123 const method = extractionKind(p.method);124 return (125 <article className={cn("rounded-sm border px-3 py-2.5", p.isWinner ? "border-accent/40" : "border-hair")} aria-label={`${fieldLabel(p.field)} observed on ${p.sourceName}`}>126 <div className="flex flex-wrap items-center gap-1.5">127 <SourceBadge kind={p.sourceKind} name={p.sourceName} url={p.url} retrievedAt={p.retrievedAt} documentId={p.documentId} />128 {p.scope && <ScopeBadge scope={p.scope} />}129 {p.isWinner && <span className="ml-auto inline-flex h-[18px] items-center rounded-xs border border-accent/40 bg-accent-soft px-1.5 text-[10px] font-medium uppercase tracking-[0.06em] text-accent-ink">shown</span>}130 </div>131 <div className="mt-2 figure text-[16px] font-medium text-ink [overflow-wrap:anywhere]">132 {p.isEstimate && <EstimateMark className="mr-0.5 text-[12px]" />}133 {fmtValue(p.value, hint)}134 </div>135 <dl className="mt-2">136 <Row k="Source" v={<span>{p.sourceName} <span className="text-ink-3">· {hostOf(p.url)}</span></span>} />137 <Row k="First observed" v={<span className="figure">{fmtDate(p.firstObserved)}</span>} />138 <Row k="Last observed" v={<span className="figure">{fmtDate(p.lastObserved)}</span>} />139 <Row k="Retrieved" v={<span className="figure">{fmtDate(p.retrievedAt)}</span>} />140 <Row k="Confidence" v={<ConfidenceBadge level={p.confidence} short={false} size="xs" />} />141 <Row k="Extraction" v={<span>{EXTRACTION_LABEL[method]}{p.method && <span className="figure ml-1.5 text-[11px] text-ink-3">{p.method}</span>}</span>} />142 </dl>143 <div className="mt-2">144 <Button href={p.url} external size="xs" variant="secondary">145 View source <ExternalLink className="size-3" />146 </Button>147 </div>148 </article>149 );150}151152function HistoryList({ points, claims, hint }: { points: HistoryPoint[]; claims: ClaimDTO[]; hint: ReturnType<typeof valueHint> }) {153 const [why, setWhy] = useState<number | null>(null);154 if (!points.length) return <p className="rounded-sm border border-dashed border-hair-2 px-3 py-3 text-[12.5px] text-ink-3">No dated history for this field yet — the value has been observed once. Every future change (old → new, source, evidence) will be listed here.</p>;155 return (156 <ol className="relative ml-1.5 border-l border-hair">157 {points.map((h, i) => {158 const claim = h.claimId ? claims.find((c) => c.id === h.claimId) : undefined;159 const open = why === i;160 return (161 <li key={`${h.date}-${i}`} className="relative pb-3 pl-4 last:pb-0">162 <span aria-hidden className={cn("absolute -left-[4.5px] top-[6px] size-2 rounded-full ring-2 ring-surface", h.kind === "changed" ? "bg-accent" : h.kind === "claim" ? "bg-ink-3" : "bg-ink-4")} />163 <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">164 <time className="figure text-[11.5px] text-ink-3" dateTime={h.date}>165 {fmtDate(h.date)}166 </time>167 <span className="text-[11px] uppercase tracking-[0.06em] text-ink-4">{h.kind}</span>168 </div>169 <div className="figure mt-0.5 inline-flex flex-wrap items-center gap-1.5 text-[13px]">170 {h.oldValue !== undefined && h.oldValue !== null && (171 <>172 <span className="text-ink-3 line-through decoration-ink-4">{fmtValue(h.oldValue, hint)}</span>173 <ArrowRight className="size-3 text-ink-4" aria-label="changed to" />174 </>175 )}176 <span className="font-medium text-ink">{fmtValue(h.value, hint)}</span>177 </div>178 <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-ink-3">179 {h.sourceKind && <SourceBadge kind={h.sourceKind} name={h.sourceName} url={h.url} />}180 {h.sourceName && <span>{h.sourceName}</span>}181 {h.url && (182 <a href={h.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:text-accent">183 source <ExternalLink className="size-3" />184 </a>185 )}186 <button type="button" onClick={() => setWhy(open ? null : i)} aria-expanded={open} className="ml-auto inline-flex h-7 items-center rounded-xs border border-hair-2 px-1.5 text-[10.5px] font-medium uppercase tracking-[0.06em] text-ink-2 hover:border-ink hover:text-ink">187 Why?188 </button>189 </div>190 {open && (191 <div className="mt-2 animate-fade-in">192 {claim ? (193 <ClaimCard c={claim} hint={hint} />194 ) : (195 <div className="rounded-sm border border-hair px-3 py-2 text-[12.5px] text-ink-2">196 {h.kind === "changed" ? "Detected as a change between two observations of the same source page." : h.kind === "claim" ? "Recorded as a claim from the document linked above." : "Observed value on the linked source page (no sentence-level evidence recorded for this observation)."}197 {h.url && (198 <div className="mt-1.5">199 <Button href={h.url} external size="xs" variant="secondary">200 View source <ExternalLink className="size-3" />201 </Button>202 </div>203 )}204 </div>205 )}206 </div>207 )}208 </li>209 );210 })}211 </ol>212 );213}214215export function EvidenceDrawer() {216 const ctx = useEvidence();217 const t = ctx?.target ?? null;218 const [tab, setTab] = useState<string>("evidence");219 const [lastTarget, setLastTarget] = useState<typeof t>(null);220 if (t !== lastTarget) {221 setLastTarget(t);222 if (t) setTab(t.mode);223 }224 const aliases = useMemo(() => new Set(t ? t.fields.flatMap(fieldAliases) : []), [t]);225 const claims = useMemo(() => (ctx && t ? ctx.claims.filter((c) => aliases.has(c.predicate)).sort((a, b) => Number(b.isWinner) - Number(a.isWinner) || (a.status === "current" ? -1 : 1)) : []), [ctx, t, aliases]);226 const prov = useMemo(() => (ctx && t ? ctx.provenance.filter((p) => aliases.has(p.field)).sort((a, b) => Number(!!b.isWinner) - Number(!!a.isWinner) || (a.lastObserved < b.lastObserved ? 1 : -1)) : []), [ctx, t, aliases]);227 const history = useMemo(() => (ctx && t ? ctx.history.filter((h) => aliases.has(h.field) || (h.predicate ? aliases.has(h.predicate) : false)).sort((a, b) => (a.date < b.date ? 1 : -1)) : []), [ctx, t, aliases]);228 if (!ctx) return null;229 const hint = t ? valueHint(t.field) : undefined;230 const winner = claims.find((c) => c.isWinner) ?? null;231 const winnerProv = prov.find((p) => p.isWinner) ?? prov[0] ?? null;232 const display = t?.display ?? (winner ? fmtValue(winner.value ?? winner.valueText, hint) : winnerProv ? fmtValue(winnerProv.value, hint) : "—");233 const sources = new Set([...claims.map((c) => c.sourceId), ...prov.map((p) => p.sourceId)]).size;234235 return (236 <Sheet open={!!t} onClose={ctx.close} title={t ? `${t.label}` : undefined} description={t ? `${ctx.entityName} · ${sources} ${sources === 1 ? "source" : "sources"}` : undefined} width={440} heightVh={78} side="right">237 {t && (238 <div className="flex flex-col gap-3">239 <div className="flex flex-wrap items-end gap-x-3 gap-y-1 border-b border-hair pb-3">240 <div className="figure text-[26px] font-medium leading-none tracking-[-0.02em] text-ink">{display}</div>241 <div className="flex flex-wrap items-center gap-1.5 pb-0.5">242 {winner ? (243 <>244 <SemanticsLabel semantics={winner.predicate} />245 <ScopeBadge scope={winner.scope} />246 </>247 ) : winnerProv?.scope ? (248 <ScopeBadge scope={winnerProv.scope} />249 ) : null}250 {(winner ?? winnerProv) && <ConfidenceBadge level={(winner ?? winnerProv)!.confidence} size="xs" />}251 </div>252 {display === "—" && <p className="w-full text-[12px] text-ink-3">Not published by any indexed source. Nothing is imputed.</p>}253 </div>254255 <Tabs value={tab} onValueChange={setTab}>256 <TabList label="Evidence views">257 <Tab value="evidence" count={claims.length + prov.length}>258 Evidence259 </Tab>260 <Tab value="history" count={history.length}>261 <HistoryIcon className="size-3.5" aria-hidden /> History262 </Tab>263 </TabList>264 <TabPanel value="evidence">265 {claims.length === 0 && prov.length === 0 ? (266 <p className="rounded-sm border border-dashed border-hair-2 px-3 py-3 text-[12.5px] text-ink-3">No source has published this field yet. When a document does, its sentence, date and authority tier appear here.</p>267 ) : (268 <div className="flex flex-col gap-2.5">269 {claims.map((c) => (270 <ClaimCard key={c.id} c={c} hint={hint} />271 ))}272 {prov.length > 0 && claims.length > 0 && <div className="label-caps pt-1">Other observations</div>}273 {prov.map((p, i) => (274 <ProvenanceCard key={`${p.sourceId}-${p.field}-${i}`} p={p} hint={hint} />275 ))}276 </div>277 )}278 <p className="mt-3 text-[11px] text-ink-4">279 {(winner ?? winnerProv) ? CONFIDENCE_LABEL[(winner ?? winnerProv)!.confidence] : "Confidence"} is computed from the source kind, the number of independent primary sources and the extraction method.{" "}280 <a href={`${routes.methodology()}#confidence`} className="underline decoration-hair-2 hover:text-ink">281 Methodology282 </a>283 </p>284 </TabPanel>285 <TabPanel value="history">286 <HistoryList points={history} claims={ctx.claims} hint={hint} />287 </TabPanel>288 </Tabs>289 </div>290 )}291 </Sheet>292 );293}294