SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
19.2 KB · 340 lines tsx
Raw Blame History
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:&lt;slug&gt;</code>, <code className="ci-mono">gene:&lt;symbol&gt;</code>, <code className="ci-mono">variant:&lt;slug&gt;</code>, <code className="ci-mono">drug:&lt;slug&gt;</code>, <code className="ci-mono">trial:&lt;NCT id&gt;</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