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%
6.1 KB · 121 lines tsx
Raw Blame History
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