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 } from '@/components/ui/section';4import { Badge, ConfidenceBadge } from '@/components/ui/badge';5import { CompletenessDots } from '@/components/ui/completeness';6import { EmptyState } from '@/components/ui/empty-state';7import { Pagination } from '@/components/ui/pagination';8import { explorerCount, explorerRows, listAnatomicalSites, ENTITY_TYPES, SORTS, type ExplorerFilters, type ExplorerRow } from '@/lib/queries/cancers';9import { fmtInt, humanize } from '@/lib/format';10import { str, int, oneOf, bool, withParams, type SP } from '@/lib/search-params';1112export const metadata: Metadata = { title: 'Cancers', description: 'Explore every indexed cancer entity with filters for type, anatomical site, hematologic and pediatric relevance.' };13// Filtered list: rendered per request (searchParams); 600 s is the CDN/ISR hint shared by all list pages.14export const revalidate = 600;1516const PAGE_SIZE = 50;1718function dataConfidence(r: ExplorerRow): string {19 const domains = [r.epidemiology_obs_count, r.survival_obs_count, r.gene_count, r.evidence_count, r.drug_count, r.active_trial_count, r.publication_count_5y].filter((n) => (n ?? 0) > 0).length;20 if (domains >= 5) return 'HIGH';21 if (domains >= 3) return 'MEDIUM';22 if (domains >= 1) return 'LOW';23 return 'INSUFFICIENT_DATA';24}2526export default async function CancersPage({ searchParams }: { searchParams: Promise<SP> }) {27 const sp = await searchParams;28 const f: ExplorerFilters = {29 q: str(sp, 'q'),30 level: oneOf(sp, 'level', ['top', 'all'] as const, 'all'),31 entityType: ENTITY_TYPES.includes(str(sp, 'entity_type') as (typeof ENTITY_TYPES)[number]) ? str(sp, 'entity_type') : '',32 malignant: bool(sp, 'malignant'),33 hematologic: bool(sp, 'hematologic'),34 pediatric: bool(sp, 'pediatric'),35 site: str(sp, 'site'),36 sort: oneOf(sp, 'sort', SORTS, 'name'),37 page: int(sp, 'page', 1, 1, 10_000),38 pageSize: PAGE_SIZE,39 };40 const [total, rows, sites] = await Promise.all([explorerCount(f), explorerRows(f), listAnatomicalSites()]);41 const current = { q: f.q, level: f.level, entity_type: f.entityType, malignant: f.malignant == null ? '' : f.malignant ? '1' : '0', hematologic: f.hematologic == null ? '' : f.hematologic ? '1' : '0', pediatric: f.pediatric == null ? '' : f.pediatric ? '1' : '0', site: f.site, sort: f.sort };42 const href = (o: Record<string, string | number | null | undefined>) => `/cancers${withParams({ ...current, level: f.level === 'all' ? '' : f.level }, o)}`;4344 return (45 <div>46 <PageHeader kicker="Explorer" title="Cancers" lede="Every indexed disease entity from the canonical taxonomy. Counters aggregate over each entity's descendants; completeness dots show which data domains hold at least one record." />4748 <form method="get" action="/cancers" className="grid gap-2 border-y border-rule py-3 text-[13.5px] sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_1fr_auto]">49 <label className="flex flex-col gap-1">50 <span className="ci-kicker">Name, alias or code</span>51 <input name="q" defaultValue={f.q} placeholder="e.g. glioblastoma, NSCLC, C4914" className="border border-rule-strong bg-white px-2 py-1.5 outline-none focus:border-accent" />52 </label>53 <label className="flex flex-col gap-1">54 <span className="ci-kicker">Level</span>55 <select name="level" defaultValue={f.level} className="border border-rule-strong bg-white px-2 py-1.5">56 <option value="all">All entities</option>57 <option value="top">Top-level (ranking set)</option>58 </select>59 </label>60 <label className="flex flex-col gap-1">61 <span className="ci-kicker">Entity type</span>62 <select name="entity_type" defaultValue={f.entityType} className="border border-rule-strong bg-white px-2 py-1.5">63 <option value="">Any</option>64 {ENTITY_TYPES.map((t) => (65 <option key={t} value={t}>66 {humanize(t)}67 </option>68 ))}69 </select>70 </label>71 <label className="flex flex-col gap-1">72 <span className="ci-kicker">Anatomical site</span>73 <select name="site" defaultValue={f.site} className="border border-rule-strong bg-white px-2 py-1.5">74 <option value="">Any</option>75 {sites.map((s) => (76 <option key={s.slug} value={s.slug}>77 {s.name} ({s.n})78 </option>79 ))}80 </select>81 </label>82 <label className="flex flex-col gap-1">83 <span className="ci-kicker">Sort</span>84 <select name="sort" defaultValue={f.sort} className="border border-rule-strong bg-white px-2 py-1.5">85 <option value="name">Name</option>86 <option value="trials">Active trials</option>87 <option value="publications">Publications (5y)</option>88 <option value="evidence">Evidence items</option>89 <option value="descendants">Descendants</option>90 </select>91 </label>92 <div className="flex flex-col justify-end gap-1">93 <div className="flex flex-wrap gap-x-3 gap-y-1 text-[12.5px]">94 <label className="inline-flex items-center gap-1">95 <input type="checkbox" name="malignant" value="1" defaultChecked={f.malignant === true} /> Malignant96 </label>97 <label className="inline-flex items-center gap-1">98 <input type="checkbox" name="hematologic" value="1" defaultChecked={f.hematologic === true} /> Hematologic99 </label>100 <label className="inline-flex items-center gap-1">101 <input type="checkbox" name="pediatric" value="1" defaultChecked={f.pediatric === true} /> Pediatric102 </label>103 </div>104 <button type="submit" className="border border-ink bg-ink px-3 py-1.5 text-paper hover:bg-ink-2">105 Apply106 </button>107 </div>108 </form>109110 <p className="mt-3 text-[13px] text-ink-2">111 <span className="ci-num font-medium text-ink">{fmtInt(total)}</span> {total === 1 ? 'entity' : 'entities'}112 {f.q ? (113 <>114 {' '}115 matching <q>{f.q}</q>116 </>117 ) : null}118 {f.level === 'top' && total === 0 ? <span className="text-ink-3"> — the top-level flag is assigned when the NCIt anchor concepts are ingested; until then browse all entities.</span> : null}119 </p>120121 {rows.length === 0 ? (122 <div className="mt-3">123 <EmptyState title="No entity matches these filters" knows={[{ label: 'Browse all entities', href: '/cancers' }, { label: 'Taxonomy tree', href: '/taxonomy' }]} />124 </div>125 ) : (126 <>127 <div className="ci-table-wrap mt-3">128 <table className="ci-table">129 <thead>130 <tr>131 <th className="sticky-col">Cancer</th>132 <th>Type</th>133 <th>Parent(s)</th>134 <th className="num">Active trials (count)</th>135 <th className="num">Publications 5y (count)</th>136 <th className="num">Evidence items (count)</th>137 <th className="num">Children</th>138 <th>Completeness</th>139 <th>Data confidence</th>140 </tr>141 </thead>142 <tbody>143 {rows.map((r) => (144 <tr key={r.id}>145 <td className="sticky-col min-w-[220px]">146 <Link href={`/cancer/${r.slug}`} className="ci-link font-medium">147 {r.canonical_name}148 </Link>149 <span className="ci-mono ml-2 text-[10.5px] text-ink-4">{r.id}</span>150 <span className="mt-0.5 flex flex-wrap gap-1">151 {r.hematologic ? <Badge>Hematologic</Badge> : null}152 {r.pediatric_relevant ? <Badge>Pediatric</Badge> : null}153 {r.rare_cancer === true ? <Badge tone="accent">Rare</Badge> : null}154 {!r.malignant ? <Badge tone="outline">Non-malignant / precursor</Badge> : null}155 </span>156 </td>157 <td>158 <Badge>{humanize(r.entity_type)}</Badge>159 </td>160 <td className="max-w-[260px] text-[12.5px] text-ink-2">161 {r.parents?.length162 ? r.parents.map((p, i) => (163 <span key={p.slug}>164 {i > 0 ? ', ' : ''}165 <Link className="ci-link" href={`/cancer/${p.slug}`}>166 {p.name}167 </Link>168 </span>169 ))170 : <span className="text-ink-4">root</span>}171 </td>172 <td className="num">{r.active_trial_count == null ? <span className="text-ink-4">—</span> : fmtInt(r.active_trial_count)}</td>173 <td className="num">{r.publication_count_5y == null ? <span className="text-ink-4">—</span> : fmtInt(r.publication_count_5y)}</td>174 <td className="num">{r.evidence_count == null ? <span className="text-ink-4">—</span> : fmtInt(r.evidence_count)}</td>175 <td className="num">{fmtInt(r.child_count)}</td>176 <td>177 <CompletenessDots filled={{ taxonomy: true, epidemiology: r.epidemiology_obs_count ?? 0, survival: r.survival_obs_count ?? 0, genomics: r.gene_count ?? 0, evidence: r.evidence_count ?? 0, drugs: r.drug_count ?? 0, trials: r.active_trial_count ?? 0, literature: r.publication_count_5y ?? 0 }} />178 </td>179 <td>180 <ConfidenceBadge level={dataConfidence(r)} />181 </td>182 </tr>183 ))}184 </tbody>185 </table>186 </div>187 <Pagination page={f.page} pageSize={PAGE_SIZE} total={total} hrefFor={(p) => href({ page: p === 1 ? '' : p })} />188 <p className="mt-2 text-[11.5px] text-ink-3">"—" means no counter has been computed for this entity yet (counters appear after the first connector of that domain runs). Data confidence summarizes how many domains hold data; it is not a clinical judgement.</p>189 </>190 )}191 </div>192 );193}194