/** * Comparability rules of the Data explorer (docs/methodology/data-explorer.md). Pure, unit-tested. * * Observations are only overlaid on one chart when they share metric, unit, geography, source, * standard population and age group. Anything else (a rate standardized to the US 2000 standard next to * one standardized to the World standard, CDC WONDER next to USCS, ages 65+ next to all ages) becomes a * separate chart with a caption naming the dimension that differs — never a silent overlay. */ export interface ComparableObs { cancer_slug: string; cancer_name: string; geography_slug: string; geography_name: string; year: number; year_end?: number | null; sex: string; age_group: string; metric: string; value: number; unit: string; lower_ci?: number | null; upper_ci?: number | null; standard_population: string | null; estimate_type: string; site_definition?: string | null; source_slug: string; source_name?: string | null; provenance_id?: number | null; } export interface ExplorerSeries { key: string; name: string; cancer_slug: string; cancer_name: string; sex: string; estimate_type: string; site_definition: string | null; dashed: boolean; points: Array<{ x: number; y: number; lo?: number | null; hi?: number | null }>; } export interface ComparableGroup { key: string; metric: string; unit: string; geography_slug: string; geography_name: string; source_slug: string; source_name: string | null; standard_population: string | null; age_group: string; series: ExplorerSeries[]; n_obs: number; year_min: number; year_max: number; provenance_ids: number[]; y_max: number; // max of value / upper CI across the group (for a shared axis) } /** Dimensions that decide whether two observations may share one chart (order = caption order). */ export const COMPARABILITY_DIMENSIONS = ['metric', 'unit', 'geography_slug', 'source_slug', 'standard_population', 'age_group'] as const; export type ComparabilityDimension = (typeof COMPARABILITY_DIMENSIONS)[number]; export function groupKey(o: Pick): string { return COMPARABILITY_DIMENSIONS.map((d) => o[d] ?? '').join('|'); } function seriesKey(o: ComparableObs): string { return `${o.cancer_slug}|${o.sex}|${o.estimate_type}|${o.site_definition ?? ''}`; } function sexSuffix(sex: string): string { return sex === 'all' ? 'both sexes' : sex.replace(/_/g, ' '); } /** * Group observations into comparable chart groups; inside a group one series per (cancer × sex × * estimate type × site definition). Series named " · " (sex omitted when every series in * the group shares it); dashed when the source labels the value estimated/projected. * Groups are ordered by observation count (largest first), series by cancer name then sex. */ export function groupComparable(obs: readonly ComparableObs[]): ComparableGroup[] { const groups = new Map; prov: Set; n: number; ymin: number; ymax: number; vmax: number }>(); for (const o of obs) { if (!Number.isFinite(o.value) || !Number.isFinite(o.year)) continue; const gk = groupKey(o); let g = groups.get(gk); if (!g) { g = { meta: o, series: new Map(), prov: new Set(), n: 0, ymin: o.year, ymax: o.year, vmax: 0 }; groups.set(gk, g); } const sk = seriesKey(o); let s = g.series.get(sk); if (!s) { s = { key: sk, name: '', cancer_slug: o.cancer_slug, cancer_name: o.cancer_name, sex: o.sex, estimate_type: o.estimate_type, site_definition: o.site_definition ?? null, dashed: o.estimate_type !== 'observed', points: [], _rows: [] }; g.series.set(sk, s); } s.points.push({ x: o.year, y: o.value, lo: o.lower_ci ?? null, hi: o.upper_ci ?? null }); s._rows.push(o); g.n += 1; g.ymin = Math.min(g.ymin, o.year); g.ymax = Math.max(g.ymax, o.year_end ?? o.year); g.vmax = Math.max(g.vmax, o.value, o.upper_ci ?? 0); if (o.provenance_id != null) g.prov.add(o.provenance_id); } const out: ComparableGroup[] = []; for (const [key, g] of groups) { const list = [...g.series.values()]; const sexes = new Set(list.map((s) => s.sex)); const siteDefsByCancer = new Map>(); for (const s of list) { if (!siteDefsByCancer.has(s.cancer_slug)) siteDefsByCancer.set(s.cancer_slug, new Set()); siteDefsByCancer.get(s.cancer_slug)!.add(s.site_definition ?? ''); } for (const s of list) { const bits = [s.cancer_name]; if (sexes.size > 1) bits.push(sexSuffix(s.sex)); if ((siteDefsByCancer.get(s.cancer_slug)?.size ?? 0) > 1 && s.site_definition) bits.push(s.site_definition); if (s.estimate_type !== 'observed') bits.push(s.estimate_type); s.name = bits.join(' · '); s.points.sort((a, b) => a.x - b.x); } list.sort((a, b) => a.cancer_name.localeCompare(b.cancer_name) || sexOrder(a.sex) - sexOrder(b.sex) || Number(a.estimate_type !== 'observed') - Number(b.estimate_type !== 'observed') || a.estimate_type.localeCompare(b.estimate_type)); out.push({ key, metric: g.meta.metric, unit: g.meta.unit, geography_slug: g.meta.geography_slug, geography_name: g.meta.geography_name, source_slug: g.meta.source_slug, source_name: g.meta.source_name ?? null, standard_population: g.meta.standard_population, age_group: g.meta.age_group, series: list.map(({ _rows, ...s }) => s), n_obs: g.n, year_min: g.ymin, year_max: g.ymax, provenance_ids: [...g.prov].sort((a, b) => a - b), y_max: g.vmax, }); } out.sort((a, b) => b.n_obs - a.n_obs || a.source_slug.localeCompare(b.source_slug) || (a.standard_population ?? '').localeCompare(b.standard_population ?? '')); return out; } function sexOrder(sex: string): number { return sex === 'all' ? 0 : sex === 'male' ? 1 : sex === 'female' ? 2 : 3; } /** Human labels for the comparability dimensions in captions. */ export function dimensionLabel(d: ComparabilityDimension): string { switch (d) { case 'metric': return 'metric'; case 'unit': return 'unit'; case 'geography_slug': return 'geography'; case 'source_slug': return 'source'; case 'standard_population': return 'standard population'; case 'age_group': return 'age group'; } } function dimValue(g: ComparableGroup, d: ComparabilityDimension): string { switch (d) { case 'geography_slug': return g.geography_name; case 'source_slug': return g.source_slug; case 'standard_population': return g.standard_population ?? 'no standard population (crude or count)'; case 'age_group': return g.age_group === 'all' ? 'all ages' : g.age_group; default: return String(g[d]); } } /** Dimensions on which at least two groups differ — the reason they are drawn as separate charts. */ export function differingDimensions(groups: readonly ComparableGroup[]): ComparabilityDimension[] { if (groups.length < 2) return []; return COMPARABILITY_DIMENSIONS.filter((d) => new Set(groups.map((g) => dimValue(g, d))).size > 1); } /** * Caption explaining why a set of groups is not overlaid, e.g. * "Shown as 2 separate charts: the observations differ by standard population (2000 U.S. standard population * (19 age groups) vs 2000 U.S. Std. Population) and source (cdc-uscs vs cdc-wonder). Values standardized to * different populations or published by different sources are never overlaid." */ export function explainSplit(groups: readonly ComparableGroup[]): string | null { const dims = differingDimensions(groups); if (dims.length === 0) return null; const parts = dims.map((d) => `${dimensionLabel(d)} (${[...new Set(groups.map((g) => dimValue(g, d)))].join(' vs ')})`); return `Shown as ${groups.length} separate charts: the observations differ by ${parts.join(' and ')}. Values that differ on any of these dimensions are never overlaid on one axis.`; } /** The values of the dimensions that identify one group, for its own caption ("what exactly is on this chart"). */ export function groupDescriptor(g: ComparableGroup, dims: readonly ComparabilityDimension[] = COMPARABILITY_DIMENSIONS): Array<{ label: string; value: string }> { return dims.map((d) => ({ label: dimensionLabel(d), value: dimValue(g, d) })); } export const MAX_OVERLAID_SERIES = 4; /** * Split a group into small multiples, one per cancer, keeping the group's y_max so panels share one * axis. Used when the user asks for `view=multiples` or when a group would overlay more than * MAX_OVERLAID_SERIES series. */ export function splitIntoMultiples(g: ComparableGroup): ComparableGroup[] { const byCancer = new Map(); for (const s of g.series) { if (!byCancer.has(s.cancer_slug)) byCancer.set(s.cancer_slug, []); byCancer.get(s.cancer_slug)!.push(s); } return [...byCancer.entries()].map(([slug, series]) => ({ ...g, key: `${g.key}|${slug}`, series, n_obs: series.reduce((n, s) => n + s.points.length, 0), // y_max intentionally inherited: shared axis across panels })); } export function shouldUseMultiples(g: ComparableGroup, view: 'lines' | 'multiples'): boolean { return view === 'multiples' ? new Set(g.series.map((s) => s.cancer_slug)).size > 1 : g.series.length > MAX_OVERLAID_SERIES; } /** Latest observed point of each series (for "latest value" captions). */ export function latestPoint(s: ExplorerSeries): { x: number; y: number } | null { if (s.points.length === 0) return null; const p = s.points[s.points.length - 1]!; return { x: p.x, y: p.y }; }