spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import { ClaimBadge, Badge } from '@/components/ui/badge';2import { SourceBadge, type ProvenanceInfo } from '@/components/ui/source-badge';3import { ExplorerChart, MAX_PALETTE_SLOTS } from '@/components/charts/explorer-chart';4import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';5import { fmtDate, fmtInt, fmtValue, humanize, unitLabel } from '@/lib/format';6import { splitIntoMultiples, shouldUseMultiples, type ComparableGroup, type ExplorerSeries } from '@/lib/explorer-series';7import type { ExplorerView } from '@/lib/explorer-params';89/**10 * Colour slots follow the entity, never the rank: one slot per (cancer × sex) identity in order of first11 * appearance across every group of the page. Beyond the palette size the panels are per cancer and the12 * slot follows the sex inside each panel (the panel title carries the cancer).13 */14export function assignSlots(groups: readonly ComparableGroup[]): { bySeries: Map<string, number> | null; bySex: Map<string, number> } {15 const ids: string[] = [];16 const sexes: string[] = [];17 for (const g of groups)18 for (const s of g.series) {19 const id = `${s.cancer_slug}|${s.sex}`;20 if (!ids.includes(id)) ids.push(id);21 if (!sexes.includes(s.sex)) sexes.push(s.sex);22 }23 const bySex = new Map(sexes.map((s, i) => [s, i % MAX_PALETTE_SLOTS]));24 if (ids.length > MAX_PALETTE_SLOTS) return { bySeries: null, bySex };25 return { bySeries: new Map(ids.map((id, i) => [id, i])), bySex };26}2728function slotFor(s: ExplorerSeries, slots: ReturnType<typeof assignSlots>): number {29 return slots.bySeries?.get(`${s.cancer_slug}|${s.sex}`) ?? slots.bySex.get(s.sex) ?? 0;30}3132export function groupProvenance(g: ComparableGroup, prov: { dataset?: string | null; dataset_version?: string | null; retrieved_at?: Date | string | null; source_url?: string | null; license?: string | null } | undefined): ProvenanceInfo {33 return {34 sourceSlug: g.source_slug,35 sourceName: g.source_name,36 dataset: prov?.dataset ?? null,37 datasetVersion: prov?.dataset_version ?? null,38 retrievedAt: prov?.retrieved_at ?? null,39 sourceUrl: prov?.source_url ?? null,40 license: prov?.license ?? null,41 layer: 'normalized',42 evidenceType: 'observed_data',43 };44}4546/**47 * One comparable group → one chart (or small multiples with a shared axis). The caption states every48 * dimension that defines the group: metric, unit, geography, sex, age group, years, standard population,49 * source and retrieval date — nothing on the axis is left to be inferred.50 */51export function ChartGroup({52 group,53 view,54 slots,55 provenance,56 index,57 total,58}: {59 group: ComparableGroup;60 view: ExplorerView;61 slots: ReturnType<typeof assignSlots>;62 provenance: ProvenanceInfo;63 index: number;64 total: number;65}) {66 const g = group;67 const metricLabel = EPI_METRIC_LABEL[g.metric] ?? humanize(g.metric);68 const multiples = shouldUseMultiples(g, view);69 const panels = multiples ? splitIntoMultiples(g) : [g];70 const sexes = [...new Set(g.series.map((s) => s.sex))];71 const hasEstimates = g.series.some((s) => s.dashed);72 const hasCi = g.series.some((s) => s.points.some((p) => p.lo != null && p.hi != null));73 const years = g.year_min === g.year_max ? String(g.year_min) : `${g.year_min}–${g.year_max}`;74 const toChart = (s: ExplorerSeries) => ({ name: s.name, points: s.points, dashed: s.dashed, slot: slotFor(s, slots) });75 return (76 <section aria-label={`${metricLabel}, ${g.geography_name}, source ${g.source_slug}${total > 1 ? ` (chart ${index + 1} of ${total})` : ''}`} className="min-w-0">77 <div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">78 <h3 className="text-base">79 {total > 1 ? <span className="ci-kicker mr-2">Chart {index + 1} of {total}</span> : null}80 {metricLabel} · {g.geography_name}81 </h3>82 <span className="text-[12px] text-ink-3">83 {unitLabel(g.unit)} · {sexes.length === 1 ? (sexes[0] === 'all' ? 'both sexes' : humanize(sexes[0]!).toLowerCase()) : 'by sex'} · {g.age_group === 'all' ? 'all ages' : `ages ${g.age_group}`} · {years}84 {g.standard_population ? ` · standard population: ${g.standard_population}` : ''}85 </span>86 </div>87 {multiples ? (88 <>89 <p className="mb-2 text-[12px] text-ink-3">90 Small multiples: one panel per cancer, <strong className="font-medium text-ink-2">shared y-axis 0–{fmtValue(g.y_max * 1.08, g.unit)} {unitLabel(g.unit)}</strong> across the {panels.length} panels so heights are comparable.91 </p>92 <div className="grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">93 {panels.map((p) => (94 <div key={p.key} className="min-w-0">95 <p className="mb-0.5 truncate text-[13px] font-medium text-ink" title={p.series[0]?.cancer_name}>96 {p.series[0]?.cancer_name}97 </p>98 <ExplorerChart series={p.series.map(toChart)} unit={p.unit} yMax={g.y_max} height={170} compact ariaLabel={`${metricLabel}, ${p.series[0]?.cancer_name ?? ''}, ${g.geography_name}, ${years}, source ${g.source_slug}`} />99 </div>100 ))}101 </div>102 </>103 ) : (104 <ExplorerChart series={g.series.map(toChart)} unit={g.unit} ariaLabel={`${metricLabel} in ${g.geography_name}, ${years}, ${g.series.length} series, source ${g.source_slug}`} />105 )}106 {/* div, not p: the SourceBadge popover contains a <dl>. */}107 <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-ink-3">108 <SourceBadge p={provenance} />109 <ClaimBadge kind="observed" />110 <span>111 {g.source_name ?? g.source_slug}112 {provenance.retrievedAt ? ` (retrieved ${fmtDate(provenance.retrievedAt)})` : ''} · {fmtInt(g.n_obs)} observations · {g.series.length} series113 </span>114 {hasCi ? <span>· shaded band = 95% CI as published</span> : null}115 {hasEstimates ? <Badge tone="warn">dashed = estimated by the source</Badge> : null}116 {g.unit === 'count' ? <span>· counts are per site group, not summed</span> : null}117 </div>118 </section>119 );120}121