spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader, Section, KV, Note } from '@/components/ui/section';4import { Badge, ClaimBadge, claimKindFromCategory } from '@/components/ui/badge';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { SourceBadge } from '@/components/ui/source-badge';8import { RadialGraph } from '@/components/graph/radial-graph';9import { PathChainView } from '@/components/graph/path-chain';10import { defaultFocus, edgesFreshness, loadNeighborhood, loadPaths, resolveFocus, suggestedFoci, DEFAULT_GROUP_LIMIT, EXPANDED_GROUP_LIMIT, TRIAL_GROUP_LIMIT, FREQ_MIN, CASES_MIN, type FocusSuggestion } from '@/lib/queries/graph';11import { loadProvenance, toInfo } from '@/lib/queries/provenance';12import { type GraphEdge, type GraphNode, NODE_TYPE_LABEL, NODE_TYPE_ORDER, focusHref, nodeKey, parseFocus, relationshipLabel } from '@/lib/graph-model';13import { fmtInt } from '@/lib/format';14import { str, withParams, type SP } from '@/lib/search-params';1516export const dynamic = 'force-dynamic';1718const MAX_DRAWN = 60;1920export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {21 const sp = await searchParams;22 const f = parseFocus(str(sp, 'focus'));23 const node = f ? await resolveFocus(f) : null;24 const title = node ? `${node.label} — knowledge graph` : 'Knowledge graph';25 return {26 title,27 description: node ? `Contextual knowledge graph around ${node.label}: source-native cancer–gene–variant–drug edges with evidence level and cancer context, plus derived registry counts (trials, cohort frequencies, approvals).` : 'Cancer–gene–variant–drug–trial knowledge graph: every edge carries its cancer context, direction, evidence level and provenance.',28 robots: { index: !str(sp, 'more') },29 alternates: { canonical: node ? `/graph?focus=${node.type}:${encodeURIComponent(node.ref)}` : '/graph' },30 };31}3233function FocusForm({ focus, suggestions }: { focus: string; suggestions: FocusSuggestion[] }) {34 return (35 <div className="mt-3">36 <form method="get" action="/graph" className="flex max-w-2xl flex-wrap gap-2">37 <label htmlFor="focus" className="sr-only">38 Focus entity (type:reference)39 </label>40 <input id="focus" name="focus" defaultValue={focus} placeholder="cancer:melanoma · gene:EGFR · variant:braf-v600e · drug:osimertinib · trial:NCT04487080" className="min-w-0 flex-1 border border-rule-strong bg-white px-3 py-2 text-[14px] outline-none focus:border-accent" spellCheck={false} />41 <button type="submit" className="border border-ink bg-ink px-4 py-2 text-[14px] text-paper hover:bg-ink-2">42 Focus43 </button>44 </form>45 <p className="mt-1.5 text-[12px] text-ink-3">46 Accepted: <code className="ci-mono">cancer:<slug></code>, <code className="ci-mono">gene:<symbol></code>, <code className="ci-mono">variant:<slug></code>, <code className="ci-mono">drug:<slug></code>, <code className="ci-mono">trial:<NCT id></code>, or a bare CI id. Find slugs with{' '}47 <Link href="/search" className="ci-link">48 search49 </Link>50 .51 </p>52 {suggestions.length ? (53 <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-ink-3">54 <span className="ci-kicker">Most connected</span>55 {suggestions.map((s) => (56 <Link key={`${s.type}:${s.ref}`} href={`/graph?focus=${s.type}:${encodeURIComponent(s.ref)}`} className="ci-link" title={`${fmtInt(s.edges)} knowledge edges`}>57 {s.type === 'gene' ? <span className="ci-mono">{s.label}</span> : s.label} <span className="ci-num text-ink-4">{fmtInt(s.edges)}</span>58 </Link>59 ))}60 </p>61 ) : null}62 </div>63 );64}6566function DirectionCell({ e }: { e: GraphEdge }) {67 if (!e.direction) return <span className="text-ink-4">—</span>;68 const tone = e.direction === 'resistance' ? 'warn' : e.direction === 'sensitivity' ? 'ok' : e.direction === 'mixed' ? 'warn' : 'neutral';69 return <Badge tone={tone}>{e.direction}</Badge>;70}7172function ContextCell({ e }: { e: GraphEdge }) {73 if (e.cancerContext.length === 0) return <span className="text-ink-4">{e.derived ? 'not mapped' : '—'}</span>;74 const shown = e.cancerContext.slice(0, 3);75 return (76 <span className="inline-flex flex-wrap gap-x-1.5 gap-y-0.5">77 {shown.map((c, i) => (78 <span key={c.id}>79 {c.slug ? (80 <Link href={`/graph?focus=cancer:${c.slug}`} className="ci-link" title={`Explore ${c.name} in the graph`}>81 {c.name}82 </Link>83 ) : (84 <span className="ci-mono">{c.id}</span>85 )}86 {i < shown.length - 1 ? ',' : ''}87 </span>88 ))}89 {e.cancerContext.length > 3 ? <span className="text-ink-3">+{e.cancerContext.length - 3}</span> : null}90 </span>91 );92}9394export default async function GraphPage({ searchParams }: { searchParams: Promise<SP> }) {95 const sp = await searchParams;96 const focusParam = str(sp, 'focus').slice(0, 200);97 const more = str(sp, 'more').slice(0, 40).toUpperCase() || null;98 const suggestions = await suggestedFoci();99 const requested = parseFocus(focusParam);100 const focusRef = requested ?? (await defaultFocus());101 const focus = focusRef ? await resolveFocus(focusRef) : null;102103 if (!focus) {104 return (105 <div>106 <PageHeader kicker="Knowledge graph" title="Knowledge graph" lede="Cancer–gene–variant–drug–trial relationships as the sources state them: every edge carries its cancer context, direction, evidence level and provenance. CancerIndex never infers an edge." />107 <FocusForm focus={focusParam} suggestions={suggestions} />108 <div className="mt-6">109 <EmptyState title={requested ? `No ${requested.type} matches “${requested.ref}”` : 'Data not yet available'} knows={[...suggestions.map((s) => ({ label: `${s.label} (${s.type})`, href: `/graph?focus=${s.type}:${encodeURIComponent(s.ref)}` })), { label: 'Search entities', href: '/search' }]}>110 {requested ? 'The reference must be the entity slug (cancer, variant, drug), the HGNC symbol (gene) or the NCT id (trial). Use search to find it, or pick a suggested focus.' : 'No knowledge edges are loaded on this environment yet.'}111 </EmptyState>112 </div>113 </div>114 );115 }116117 const nb = await loadNeighborhood(focus, { more });118 const [paths, freshAt] = await Promise.all([focus.type === 'cancer' ? loadPaths(focus, nb.cancerIds) : Promise.resolve([]), edgesFreshness(focus)]);119 const allEdges = nb.groups.flatMap((g) => g.edges);120 const prov = await loadProvenance(allEdges.map((e) => e.provenanceIds[0]).filter((x): x is number => typeof x === 'number'));121 const nodeByKey = new Map<string, GraphNode>(nb.nodes.map((n) => [nodeKey(n), n]));122 const totalEdges = nb.groups.reduce((a, g) => a + g.total, 0);123 const current = { focus: `${focus.type}:${focus.ref}`, more: more ?? '' };124 const hrefMore = (rel: string | null) => `/graph${withParams(current, { more: rel ?? '' })}#edges`;125 const typeOrder = NODE_TYPE_ORDER.filter((t) => nb.degreeByType[t] > 0);126127 return (128 <article>129 <PageHeader kicker={`Knowledge graph · ${NODE_TYPE_LABEL[focus.type].replace(/s$/, '')}`} title={focus.type === 'gene' ? <span className="ci-mono font-sans">{focus.label}</span> : focus.label} lede={focus.sublabel ? focus.sublabel.replace(/_/g, ' ') : undefined}>130 <div className="mt-3 grid gap-4 sm:grid-cols-2">131 <KV132 items={[133 { k: 'Identifier', v: <span className="ci-mono">{focus.id}</span> },134 {135 k: 'Entity page',136 v: (137 <Link href={focus.href} className="ci-link">138 {focus.href} ↗139 </Link>140 ),141 },142 { k: 'Edges', v: <span className="ci-num">{fmtInt(totalEdges)}</span> },143 ]}144 />145 <KV146 items={[147 {148 k: 'Neighbours',149 v: typeOrder.length ? (150 <span className="flex flex-wrap gap-x-3 gap-y-0.5">151 {typeOrder.map((t) => (152 <span key={t}>153 <span className="ci-num">{fmtInt(nb.degreeByType[t])}</span> {NODE_TYPE_LABEL[t].toLowerCase()}154 </span>155 ))}156 </span>157 ) : (158 'none'159 ),160 },161 { k: 'Drawn', v: `${fmtInt(Math.min(nb.nodes.length, MAX_DRAWN))} of ${fmtInt(nb.nodes.length)} neighbours (cap ${MAX_DRAWN}); table lists every fetched edge` },162 ]}163 />164 </div>165 <FocusForm focus={`${focus.type}:${focus.ref}`} suggestions={suggestions} />166 </PageHeader>167168 {nb.nodes.length === 0 ? (169 <EmptyState knows={[{ label: `Open ${focus.label}`, href: focus.href }, ...suggestions.slice(0, 4).map((s) => ({ label: s.label, href: `/graph?focus=${s.type}:${encodeURIComponent(s.ref)}` }))]}>170 No knowledge edge or registry link touches this {focus.type} on this environment. Edges appear once a source (CIViC, ChEMBL, openFDA, ClinicalTrials.gov, GDC) states one — CancerIndex does not infer them.171 </EmptyState>172 ) : (173 <div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">174 <Section id="graph" kicker="Neighbourhood" title="Radial view" description="Focus at the centre; neighbours grouped by entity type. Click a node to re-centre the graph on it; ↗ opens the entity page. Hover a spoke for relationship, evidence level, cancer context and source." level={3}>175 <RadialGraph focus={nb.focus} nodes={nb.nodes} edges={allEdges} maxNodes={MAX_DRAWN} />176 </Section>177178 <Section id="paths" kicker="Paths" title={focus.type === 'cancer' ? 'Strongest chains' : 'Paths'} description={focus.type === 'cancer' ? 'Gene → variant → drug chains anchored in this cancer (or a descendant), ranked by source-native evidence level then support, with the approval and trial registry hops when they exist.' : 'Chains are built for cancer foci only.'} level={3}>179 {focus.type !== 'cancer' ? (180 <p className="text-[13px] text-ink-3">181 Pick a cancer in the graph or from the neighbour table to see gene → variant → drug → approval → trial chains.182 {nb.nodes.some((n) => n.type === 'cancer') ? (183 <>184 {' '}185 Cancers here:{' '}186 {nb.nodes187 .filter((n) => n.type === 'cancer')188 .slice(0, 5)189 .map((n, i) => (190 <span key={n.id}>191 {i > 0 ? ', ' : ''}192 <Link href={focusHref(n.type, n.ref) ?? n.href} className="ci-link">193 {n.label}194 </Link>195 </span>196 ))}197 .198 </>199 ) : null}200 </p>201 ) : paths.length === 0 ? (202 <EmptyState compact>No PREDICTS_RESPONSE_TO edge with direction “sensitivity” names this cancer (or a descendant) in its context, so no chain can be assembled. The neighbour table still lists what the sources state.</EmptyState>203 ) : (204 <>205 <ol className="m-0 list-none p-0">206 {paths.map((c) => (207 <PathChainView key={`${c.variant.id}:${c.drug.id}`} chain={c} />208 ))}209 </ol>210 <p className="mt-2 text-[12px] text-ink-3">211 Each hop keeps its own claim category: cohort alteration frequency (observed, cases ≥ {CASES_MIN}), CIViC predictive edge (curated, level as stated by CIViC), regulatory approval (authority, jurisdiction, date as published), trial count (registry). A missing hop is shown as missing — never filled in. Not treatment guidance.212 </p>213 </>214 )}215 </Section>216 </div>217 )}218219 {nb.groups.length ? (220 <Section id="edges" kicker="Edges" title="Every edge, grouped by relationship" description={`Source-native edges first (aggregated per neighbour, direction, evidence level and source), then derived registry links. Up to ${DEFAULT_GROUP_LIMIT} per relationship (${TRIAL_GROUP_LIMIT} trials); “show all” raises one group to ${EXPANDED_GROUP_LIMIT}.`}>221 <div className="ci-table-wrap">222 <table className="ci-table ci-evidence">223 <thead>224 <tr>225 <th>Relationship</th>226 <th>Neighbour</th>227 <th>Direction</th>228 <th>Evidence level</th>229 <th>Cancer context</th>230 <th className="num">Support</th>231 <th>Claim</th>232 <th>Source</th>233 <th>Expand</th>234 </tr>235 </thead>236 {nb.groups.map((g) => {237 const expanded = more === g.relationshipType;238 return (239 <tbody key={g.relationshipType}>240 <tr className="ci-group">241 <th colSpan={9} scope="colgroup">242 <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">243 <span>244 {focus.label} <span className="text-ink-2">{relationshipLabel(g.relationshipType)}</span> …245 </span>246 <span className="ci-mono text-[11px] text-ink-3">{g.relationshipType}</span>247 {g.derived ? <Badge tone="outline">derived</Badge> : null}248 <span className="ci-num text-[12px] text-ink-3">249 {fmtInt(g.edges.length)} of {fmtInt(g.total)}250 </span>251 {g.total > g.edges.length ? (252 <Link href={hrefMore(g.relationshipType)} className="ci-link text-[12.5px]">253 show all (up to {EXPANDED_GROUP_LIMIT})254 </Link>255 ) : expanded ? (256 <Link href={hrefMore(null)} className="ci-link text-[12.5px]">257 show fewer258 </Link>259 ) : null}260 </span>261 </th>262 </tr>263 {g.edges.map((e) => {264 const nnode = nodeByKey.get(e.neighborKey);265 if (!nnode) return null;266 const p = toInfo(prov.get(e.provenanceIds[0] ?? -1));267 const expand = focusHref(nnode.type, nnode.ref);268 return (269 <tr key={e.key}>270 <td className="whitespace-nowrap text-[12.5px] text-ink-2">271 {e.via ? (272 <span title="The focus is the cancer context of this edge (the neighbour and the third entity are the edge's ends)">in context · </span>273 ) : (274 <span aria-label={e.outgoing ? 'focus to neighbour' : 'neighbour to focus'}>{e.outgoing ? '→' : '←'} </span>275 )}276 {relationshipLabel(e.relationshipType)}277 </td>278 <td className="w-t">279 <Link href={nnode.href} className="ci-link">280 {nnode.type === 'gene' ? <span className="ci-mono">{nnode.label}</span> : nnode.label}281 </Link>282 {nnode.sublabel && nnode.type !== 'gene' ? <span className="ml-1.5 text-[12px] text-ink-3">{nnode.sublabel}</span> : null}283 {e.via ? (284 <span className="text-[12.5px] text-ink-2">285 {' '}286 → {relationshipLabel(e.relationshipType)}{' '}287 <Link href={e.via.href} className="ci-link">288 {e.via.label}289 </Link>290 </span>291 ) : null}292 {e.detail ? <div className="mt-0.5 text-[12px] text-ink-3">{e.detail}</div> : null}293 </td>294 <td>295 <DirectionCell e={e} />296 </td>297 <td>{e.evidenceLevel ? <Badge tone="accent" mono title="Source-native scale (CIViC A–E, ChEMBL max phase, FDA application type, approval status) — never re-scaled">{e.evidenceLevel}</Badge> : <span className="text-ink-4">—</span>}</td>298 <td className="text-[12.5px]">299 <ContextCell e={e} />300 </td>301 <td className="num">{fmtInt(e.supportCount)}</td>302 <td>303 <ClaimBadge kind={claimKindFromCategory(e.evidenceCategory)} />304 </td>305 <td>306 {e.sourceSlugs.map((s) => (307 <SourceBadge key={s} compact p={p ?? { sourceSlug: s }} title={p ? undefined : e.derived ? 'Derived by CancerIndex from registry rows of this source' : null} />308 ))}309 </td>310 <td className="whitespace-nowrap">311 {expand ? (312 <Link href={expand} className="ci-link text-[12.5px]">313 graph →314 </Link>315 ) : (316 <Link href={nnode.href} className="ci-link text-[12.5px]">317 open ↗318 </Link>319 )}320 </td>321 </tr>322 );323 })}324 </tbody>325 );326 })}327 </table>328 </div>329 <div className="mt-3 space-y-2">330 <Note>331 An <strong>edge</strong> is a relationship stated by a source (CIViC evidence item, ChEMBL indication or mechanism, openFDA approval) and kept with its native evidence level, direction and cancer context — CancerIndex never infers, merges or re-scales it. Rows marked <em>derived</em> are counts and measurements read from registries (ClinicalTrials.gov conditions and interventions, GDC/cBioPortal cohort frequencies ≥ {Math.round(FREQ_MIN * 100)} % with ≥ {CASES_MIN} cases affected, regulatory approval records): they say how often two entities co-occur in a registry, not that a source asserted a biological or clinical link. Trials are rolled up over the cancer and its descendants; approvals list authority, jurisdiction and date as published.332 </Note>333 <Freshness dataUpdatedAt={freshAt} extra={`${fmtInt(totalEdges)} edges in the database for this focus`} />334 </div>335 </Section>336 ) : null}337 </article>338 );339}340