spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import Link from 'next/link';2import { Section, KV, Note } from '@/components/ui/section';3import { Badge, ClaimBadge } from '@/components/ui/badge';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { SourceBadge } from '@/components/ui/source-badge';7import { gapComponentForCancer, gapScopeKey } from '@/lib/queries/research-gap';8import { fmtInt, fmtPct, fmtValue, humanize } from '@/lib/format';910function ratioText(v: number | null): string {11 if (v == null) return 'undefined (no activity)';12 const abs = Math.abs(v);13 const factor = 2 ** abs;14 const how = factor >= 10 ? `${fmtInt(factor)}×` : `${fmtValue(factor, 'ratio')}×`;15 if (abs < 0.15) return 'about proportional to its share of deaths';16 return v > 0 ? `death share ${how} its activity share` : `activity share ${how} its death share`;17}1819/**20 * Small server component for a cancer page: that cancer's research-gap components in the latest21 * burden scope of a geography (default USA, both sexes). Shows the inputs behind each ratio and links22 * to the full index and the two ratio rankings. Renders an EmptyState when the cancer is not a23 * top-level entity of the scope (components exist only for top-level cancers with a mortality24 * observation — CLAUDE.md §246-247).25 */26export async function CancerGapCard({ cancerId, geography = 'USA' }: { cancerId: string; geography?: string }) {27 const res = await gapComponentForCancer(cancerId, geography);28 return (29 <Section30 id="research-gap"31 kicker="Unmet need · computed"32 title="Research gap"33 level={3}34 actions={35 <Link href="/research-gap" className="ci-link">36 Full index →37 </Link>38 }39 >40 {!res ? (41 <EmptyState compact title="No research-gap components for this entity" knows={[{ label: 'Research Gap Index (top-level cancers)', href: '/research-gap' }, { label: 'Methodology', href: '/methodology#research-gap' }]}>42 Components are computed for top-level cancers with a mortality observation in a burden scope (United States only for now). Entities below the top level inherit no value: shares would double-count their parent.43 </EmptyState>44 ) : (45 (() => {46 const { scope, row, sums } = res;47 const key = gapScopeKey(scope);48 const src = <SourceBadge p={{ sourceSlug: scope.source_slug, sourceName: scope.source_name, dataset: `mortality_count · ${scope.geography} · ${scope.year} · ${scope.sex}`, layer: 'normalized' }} compact />;49 return (50 <>51 <p className="mb-2 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">52 <ClaimBadge kind="computed" />53 <span>54 {scope.geography} · {scope.year} · {scope.sex === 'all' ? 'both sexes' : humanize(scope.sex)} · all ages · {fmtInt(sums.eligible)} eligible top-level cancers55 </span>56 {row.eligible ? (57 <Badge tone="ok">eligible</Badge>58 ) : (59 <Badge tone="outline" title={row.ineligible_reason ?? undefined}>60 ineligible61 </Badge>62 )}63 </p>64 <KV65 items={[66 {67 k: `Deaths ${scope.year}`,68 v: (69 <>70 <span className="ci-num">{fmtInt(row.deaths)}</span> {src}71 {row.death_share != null ? <span className="text-ink-3"> · {fmtPct(row.death_share, 1)} of {fmtInt(sums.deaths)} in the eligible set</span> : null}72 </>73 ),74 },75 {76 k: 'Active trials',77 v: (78 <>79 <span className="ci-num">{fmtInt(row.active_trials)}</span> <Link href="/source/clinicaltrials" className="ci-src">clinicaltrials</Link>80 {row.trial_share != null ? <span className="text-ink-3"> · {fmtPct(row.trial_share, 1)} of {fmtInt(sums.activeTrials)}</span> : null}81 <span className="text-ink-3"> · {fmtValue(row.trials_per_1000_deaths, 'per_1000_deaths')} per 1,000 deaths</span>82 </>83 ),84 },85 {86 k: 'Publications 5 y',87 v: (88 <>89 <span className="ci-num">{fmtInt(row.publications_5y)}</span> <Link href="/source/pubmed" className="ci-src">pubmed</Link>90 {row.publication_share != null ? <span className="text-ink-3"> · {fmtPct(row.publication_share, 1)} of {fmtInt(sums.publications5y)}</span> : null}91 <span className="text-ink-3"> · {fmtValue(row.publications_per_1000_deaths, 'per_1000_deaths')} per 1,000 deaths</span>92 </>93 ),94 },95 {96 k: 'Trial gap ratio',97 v: row.eligible ? (98 <>99 <span className={`ci-num font-medium ${(row.trial_gap_ratio ?? 0) >= 1 ? 'text-danger' : ''}`}>{fmtValue(row.trial_gap_ratio, 'log2_ratio')}</span>100 <span className="text-ink-3"> log₂ · {ratioText(row.trial_gap_ratio)}</span>101 {row.trial_gap_rank != null ? (102 <>103 {' '}104 ·{' '}105 <Link href={`/rankings/trial_gap_ratio?scope=${encodeURIComponent(key)}`} className="ci-link">106 rank #{row.trial_gap_rank} of {fmtInt(sums.eligible)}107 </Link>108 </>109 ) : null}110 </>111 ) : (112 <span className="text-ink-3">not computed — {row.ineligible_reason?.replace(/_/g, ' ')}</span>113 ),114 },115 {116 k: 'Research gap ratio',117 v: row.eligible ? (118 <>119 <span className={`ci-num font-medium ${(row.research_gap_ratio ?? 0) >= 1 ? 'text-danger' : ''}`}>{fmtValue(row.research_gap_ratio, 'log2_ratio')}</span>120 <span className="text-ink-3"> log₂ · {ratioText(row.research_gap_ratio)}</span>121 {row.research_gap_rank != null ? (122 <>123 {' '}124 ·{' '}125 <Link href={`/rankings/research_gap_ratio?scope=${encodeURIComponent(key)}`} className="ci-link">126 rank #{row.research_gap_rank} of {fmtInt(sums.eligible)}127 </Link>128 </>129 ) : null}130 </>131 ) : (132 <span className="text-ink-3">not computed — {row.ineligible_reason?.replace(/_/g, ' ')}</span>133 ),134 },135 { k: 'Formula', v: <span className="ci-mono text-[12px]">log2(death share ÷ activity share) · {row.formula_version}</span> },136 ]}137 />138 <Note>A gap ratio compares registered activity with deaths inside one scope. It is a signal, not a judgement of research quality; trial counts include NCIt descendants, literature counts are query-based per entity.</Note>139 <Freshness dataUpdatedAt={row.computed_at} extra={`scope ${key}`} />140 </>141 );142 })()143 )}144 </Section>145 );146}147