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%
9.2 KB · 198 lines tsx
Raw Blame History
1import Link from 'next/link';2import { Fragment, type ReactNode } from 'react';3import { Badge, ClaimBadge, MatchBadge, StatusBadge, isExactMatch } from '@/components/ui/badge';4import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';5import { EVIDENCE_LEVEL_LABEL, EVIDENCE_DESCRIPTION_CHARS, groupEvidence, therapyKey, type EvidenceItem, type EvidenceGroupBy } from '@/lib/queries/evidence';6import type { ProvRow } from '@/lib/queries/provenance';7import { toInfo } from '@/lib/queries/provenance';8import { humanize } from '@/lib/format';910const CIVIC = { sourceSlug: 'civic', sourceName: 'CIViC' };1112function Direction({ e }: { e: EvidenceItem }) {13  const dir = e.evidence_direction ?? '—';14  const sig = e.significance ?? '';15  const tone = dir === 'DOES_NOT_SUPPORT' ? 'warn' : sig.includes('RESIST') ? 'danger' : sig.includes('SENSITIV') ? 'ok' : 'neutral';16  return (17    <>18      <Badge tone={tone}>{humanize(dir)}</Badge>19      {sig ? (20        <>21          {' '}22          <Badge tone="outline">{humanize(sig.replace('SENSITIVITYRESPONSE', 'SENSITIVITY_RESPONSE'))}</Badge>23        </>24      ) : null}25    </>26  );27}2829/**30 * CIViC evidence in ONE dense table. Rows are grouped (by molecular profile → therapy, or by cancer31 * on variant pages) with a `<tbody>` per group and a row-group header, instead of one table per32 * group: a table header is rendered once, which halves the markup for pages with many small groups33 * (every element ships twice — HTML + RSC payload). Levels, directions and significance are shown34 * in their native form (§3, never collapsed to works/doesn't). One provenance popover for the whole35 * table (all rows come from one CIViC release); rows carry a compact badge.36 *37 * `summary` replaces the default "N evidence items" sentence — pass the page window when the caller38 * paginates ("Showing 1–50 of 405 evidence items").39 */40export function EvidenceTable({ items, prov, showCancer = false, groupBy = 'variant', summary }: { items: EvidenceItem[]; prov: Map<number, ProvRow>; showCancer?: boolean; groupBy?: EvidenceGroupBy; summary?: ReactNode }) {41  const groups = groupEvidence(items, groupBy);42  const first = items[0] ? toInfo(prov.get(items[0].provenance_id)) : null;43  const cancerCol = showCancer && groupBy !== 'cancer';44  const profileCol = groupBy === 'cancer';45  const cols = 7 + (cancerCol ? 1 : 0) + (profileCol ? 1 : 0);46  return (47    <div>48      <TableProvenance p={first ?? CIVIC} claim={<ClaimBadge kind="curated" />}>49        {summary ?? `${items.length} evidence item${items.length === 1 ? '' : 's'}`} · levels, directions and significance as curated at the source; each row links to its CIViC record.50      </TableProvenance>51      <div className="ci-table-wrap">52        <table className="ci-table ci-evidence">53          <thead>54            <tr>55              {profileCol ? <th scope="col">Molecular profile</th> : null}56              <th scope="col">Therapy</th>57              {cancerCol ? <th scope="col">Cancer</th> : null}58              <th scope="col">Type</th>59              <th scope="col" title="CIViC evidence level: A validated · B clinical · C case study · D preclinical · E inferential">60                Level61              </th>62              <th scope="col" title="Evidence direction and clinical significance as curated at the source">63                Direction · significance64              </th>65              <th scope="col" className="num">66                Rating (1–5)67              </th>68              <th scope="col">Status</th>69              <th scope="col">Evidence</th>70              <th scope="col">Source</th>71            </tr>72          </thead>73          {groups.map((g) => (74            <tbody key={g.key}>75              {groupBy !== 'none' ? (76                <tr className="ci-group">77                  <th scope="rowgroup" colSpan={cols}>78                    {g.href ? (79                      <Link href={g.href}>80                        {g.label}81                      </Link>82                    ) : (83                      <span>{g.label}</span>84                    )}85                    {g.genes.length > 186                      ? g.genes.map((s) => (87                          <Link key={s} href={`/gene/${s}`} className="ci-mono ml-2 text-[12px] text-ink-3 hover:text-accent">88                            {s}89                          </Link>90                        ))91                      : null}92                    <span className="ci-num ml-2 text-[12px] text-ink-3">{g.items.length}</span>93                    {g.unmapped ? (94                      <Badge tone="danger" className="ml-2">95                        unmapped disease96                      </Badge>97                    ) : null}98                  </th>99                </tr>100              ) : null}101              {g.items.map((e, i) => {102                const prevKey = i > 0 ? therapyKey(g.items[i - 1]!) : null;103                const sameTherapy = prevKey === therapyKey(e) && groupBy === 'variant';104                const desc = e.description ?? '';105                const cut = desc.length >= EVIDENCE_DESCRIPTION_CHARS;106                return (107                  <tr key={e.id}>108                    {profileCol ? <td className="w-t text-[12.5px]">{e.molecular_profile_name ?? e.name ?? '—'}</td> : null}109                    <td className="w-t">110                      {sameTherapy ? (111                        <span className="text-ink-4" aria-label="same therapy as the row above">112                          〃113                        </span>114                      ) : e.therapy_slugs?.length ? (115                        e.therapy_slugs.map((s, j) => (116                          <Fragment key={s}>117                            {j > 0 ? ' + ' : ''}118                            <Link href={`/drug/${s}`}>119                              {e.therapy_slug_names?.[j] ?? s}120                            </Link>121                          </Fragment>122                        ))123                      ) : (124                        <span className="text-ink-3">{therapyLabel(e)}</span>125                      )}126                      {e.therapy_interaction_type && !sameTherapy ? <span className="block text-[11px] text-ink-3">{humanize(e.therapy_interaction_type)}</span> : null}127                    </td>128                    {cancerCol ? (129                      <td className="w-t">130                        {e.cancer_slug ? (131                          <Link href={`/cancer/${e.cancer_slug}`}>132                            {e.cancer_name}133                          </Link>134                        ) : (135                          <span className="text-ink-3">{e.disease_name ?? '—'}</span>136                        )}137                        {/* Exact mappings are the norm; only non-exact ones (alias, broader, probabilistic, unresolved) carry the caveat badge. */}138                        {!isExactMatch(e.cancer_match_type) ? <MatchBadge matchType={e.cancer_match_type} className="ml-1" /> : null}139                      </td>140                    ) : null}141                    <td>142                      <Badge>{humanize(e.evidence_type)}</Badge>143                    </td>144                    <td>145                      <abbr {...(e.evidence_level && EVIDENCE_LEVEL_LABEL[e.evidence_level] ? { title: EVIDENCE_LEVEL_LABEL[e.evidence_level] } : {})}>146                        {e.evidence_level ?? '—'}147                      </abbr>148                    </td>149                    <td>150                      <Direction e={e} />151                    </td>152                    <td className="num">{e.evidence_rating ?? '—'}</td>153                    <td>154                      <StatusBadge status={e.status} />155                    </td>156                    <td className="ev">157                      <details>158                        <summary>EID{e.civic_id}</summary>159                        <p>160                          {desc ? `${desc}${cut ? '…' : ''}` : 'No description at source.'}161                          {cut ? <span className="text-ink-3"> (full text at CIViC)</span> : null}162                        </p>163                        <p>164                          {e.pmid ? (165                            <>166                              PMID{' '}167                              <a href={`https://pubmed.ncbi.nlm.nih.gov/${e.pmid}/`} target="_blank">168                                {e.pmid}169                              </a>170                              {e.source_citation ? <span className="text-ink-3"> · {e.source_citation}</span> : null}171                              {' · '}172                            </>173                          ) : null}174                          <a href={`https://civicdb.org/evidence/${e.civic_id}/summary`} target="_blank">175                            Open in CIViC176                          </a>177                        </p>178                      </details>179                    </td>180                    <td>181                      <SourceBadge compact title={null} p={toInfo(prov.get(e.provenance_id)) ?? CIVIC} />182                    </td>183                  </tr>184                );185              })}186            </tbody>187          ))}188        </table>189      </div>190    </div>191  );192}193194function therapyLabel(e: EvidenceItem): string {195  if (e.therapy_names.length) return e.therapy_names.join(' + ');196  return e.evidence_type === 'PREDICTIVE' ? 'Unspecified therapy' : `(${(e.evidence_type ?? 'evidence').toLowerCase()})`;197}198