spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/**2 * Comparability rules of the Data explorer (docs/methodology/data-explorer.md). Pure, unit-tested.3 *4 * Observations are only overlaid on one chart when they share metric, unit, geography, source,5 * standard population and age group. Anything else (a rate standardized to the US 2000 standard next to6 * one standardized to the World standard, CDC WONDER next to USCS, ages 65+ next to all ages) becomes a7 * separate chart with a caption naming the dimension that differs — never a silent overlay.8 */910export interface ComparableObs {11 cancer_slug: string;12 cancer_name: string;13 geography_slug: string;14 geography_name: string;15 year: number;16 year_end?: number | null;17 sex: string;18 age_group: string;19 metric: string;20 value: number;21 unit: string;22 lower_ci?: number | null;23 upper_ci?: number | null;24 standard_population: string | null;25 estimate_type: string;26 site_definition?: string | null;27 source_slug: string;28 source_name?: string | null;29 provenance_id?: number | null;30}3132export interface ExplorerSeries {33 key: string;34 name: string;35 cancer_slug: string;36 cancer_name: string;37 sex: string;38 estimate_type: string;39 site_definition: string | null;40 dashed: boolean;41 points: Array<{ x: number; y: number; lo?: number | null; hi?: number | null }>;42}4344export interface ComparableGroup {45 key: string;46 metric: string;47 unit: string;48 geography_slug: string;49 geography_name: string;50 source_slug: string;51 source_name: string | null;52 standard_population: string | null;53 age_group: string;54 series: ExplorerSeries[];55 n_obs: number;56 year_min: number;57 year_max: number;58 provenance_ids: number[];59 y_max: number; // max of value / upper CI across the group (for a shared axis)60}6162/** Dimensions that decide whether two observations may share one chart (order = caption order). */63export const COMPARABILITY_DIMENSIONS = ['metric', 'unit', 'geography_slug', 'source_slug', 'standard_population', 'age_group'] as const;64export type ComparabilityDimension = (typeof COMPARABILITY_DIMENSIONS)[number];6566export function groupKey(o: Pick<ComparableObs, ComparabilityDimension>): string {67 return COMPARABILITY_DIMENSIONS.map((d) => o[d] ?? '').join('|');68}6970function seriesKey(o: ComparableObs): string {71 return `${o.cancer_slug}|${o.sex}|${o.estimate_type}|${o.site_definition ?? ''}`;72}7374function sexSuffix(sex: string): string {75 return sex === 'all' ? 'both sexes' : sex.replace(/_/g, ' ');76}7778/**79 * Group observations into comparable chart groups; inside a group one series per (cancer × sex ×80 * estimate type × site definition). Series named "<cancer> · <sex>" (sex omitted when every series in81 * the group shares it); dashed when the source labels the value estimated/projected.82 * Groups are ordered by observation count (largest first), series by cancer name then sex.83 */84export function groupComparable(obs: readonly ComparableObs[]): ComparableGroup[] {85 const groups = new Map<string, { meta: ComparableObs; series: Map<string, ExplorerSeries & { _rows: ComparableObs[] }>; prov: Set<number>; n: number; ymin: number; ymax: number; vmax: number }>();86 for (const o of obs) {87 if (!Number.isFinite(o.value) || !Number.isFinite(o.year)) continue;88 const gk = groupKey(o);89 let g = groups.get(gk);90 if (!g) {91 g = { meta: o, series: new Map(), prov: new Set(), n: 0, ymin: o.year, ymax: o.year, vmax: 0 };92 groups.set(gk, g);93 }94 const sk = seriesKey(o);95 let s = g.series.get(sk);96 if (!s) {97 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: [] };98 g.series.set(sk, s);99 }100 s.points.push({ x: o.year, y: o.value, lo: o.lower_ci ?? null, hi: o.upper_ci ?? null });101 s._rows.push(o);102 g.n += 1;103 g.ymin = Math.min(g.ymin, o.year);104 g.ymax = Math.max(g.ymax, o.year_end ?? o.year);105 g.vmax = Math.max(g.vmax, o.value, o.upper_ci ?? 0);106 if (o.provenance_id != null) g.prov.add(o.provenance_id);107 }108109 const out: ComparableGroup[] = [];110 for (const [key, g] of groups) {111 const list = [...g.series.values()];112 const sexes = new Set(list.map((s) => s.sex));113 const siteDefsByCancer = new Map<string, Set<string>>();114 for (const s of list) {115 if (!siteDefsByCancer.has(s.cancer_slug)) siteDefsByCancer.set(s.cancer_slug, new Set());116 siteDefsByCancer.get(s.cancer_slug)!.add(s.site_definition ?? '');117 }118 for (const s of list) {119 const bits = [s.cancer_name];120 if (sexes.size > 1) bits.push(sexSuffix(s.sex));121 if ((siteDefsByCancer.get(s.cancer_slug)?.size ?? 0) > 1 && s.site_definition) bits.push(s.site_definition);122 if (s.estimate_type !== 'observed') bits.push(s.estimate_type);123 s.name = bits.join(' · ');124 s.points.sort((a, b) => a.x - b.x);125 }126 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));127 out.push({128 key,129 metric: g.meta.metric,130 unit: g.meta.unit,131 geography_slug: g.meta.geography_slug,132 geography_name: g.meta.geography_name,133 source_slug: g.meta.source_slug,134 source_name: g.meta.source_name ?? null,135 standard_population: g.meta.standard_population,136 age_group: g.meta.age_group,137 series: list.map(({ _rows, ...s }) => s),138 n_obs: g.n,139 year_min: g.ymin,140 year_max: g.ymax,141 provenance_ids: [...g.prov].sort((a, b) => a - b),142 y_max: g.vmax,143 });144 }145 out.sort((a, b) => b.n_obs - a.n_obs || a.source_slug.localeCompare(b.source_slug) || (a.standard_population ?? '').localeCompare(b.standard_population ?? ''));146 return out;147}148149function sexOrder(sex: string): number {150 return sex === 'all' ? 0 : sex === 'male' ? 1 : sex === 'female' ? 2 : 3;151}152153/** Human labels for the comparability dimensions in captions. */154export function dimensionLabel(d: ComparabilityDimension): string {155 switch (d) {156 case 'metric':157 return 'metric';158 case 'unit':159 return 'unit';160 case 'geography_slug':161 return 'geography';162 case 'source_slug':163 return 'source';164 case 'standard_population':165 return 'standard population';166 case 'age_group':167 return 'age group';168 }169}170171function dimValue(g: ComparableGroup, d: ComparabilityDimension): string {172 switch (d) {173 case 'geography_slug':174 return g.geography_name;175 case 'source_slug':176 return g.source_slug;177 case 'standard_population':178 return g.standard_population ?? 'no standard population (crude or count)';179 case 'age_group':180 return g.age_group === 'all' ? 'all ages' : g.age_group;181 default:182 return String(g[d]);183 }184}185186/** Dimensions on which at least two groups differ — the reason they are drawn as separate charts. */187export function differingDimensions(groups: readonly ComparableGroup[]): ComparabilityDimension[] {188 if (groups.length < 2) return [];189 return COMPARABILITY_DIMENSIONS.filter((d) => new Set(groups.map((g) => dimValue(g, d))).size > 1);190}191192/**193 * Caption explaining why a set of groups is not overlaid, e.g.194 * "Shown as 2 separate charts: the observations differ by standard population (2000 U.S. standard population195 * (19 age groups) vs 2000 U.S. Std. Population) and source (cdc-uscs vs cdc-wonder). Values standardized to196 * different populations or published by different sources are never overlaid."197 */198export function explainSplit(groups: readonly ComparableGroup[]): string | null {199 const dims = differingDimensions(groups);200 if (dims.length === 0) return null;201 const parts = dims.map((d) => `${dimensionLabel(d)} (${[...new Set(groups.map((g) => dimValue(g, d)))].join(' vs ')})`);202 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.`;203}204205/** The values of the dimensions that identify one group, for its own caption ("what exactly is on this chart"). */206export function groupDescriptor(g: ComparableGroup, dims: readonly ComparabilityDimension[] = COMPARABILITY_DIMENSIONS): Array<{ label: string; value: string }> {207 return dims.map((d) => ({ label: dimensionLabel(d), value: dimValue(g, d) }));208}209210export const MAX_OVERLAID_SERIES = 4;211212/**213 * Split a group into small multiples, one per cancer, keeping the group's y_max so panels share one214 * axis. Used when the user asks for `view=multiples` or when a group would overlay more than215 * MAX_OVERLAID_SERIES series.216 */217export function splitIntoMultiples(g: ComparableGroup): ComparableGroup[] {218 const byCancer = new Map<string, ExplorerSeries[]>();219 for (const s of g.series) {220 if (!byCancer.has(s.cancer_slug)) byCancer.set(s.cancer_slug, []);221 byCancer.get(s.cancer_slug)!.push(s);222 }223 return [...byCancer.entries()].map(([slug, series]) => ({224 ...g,225 key: `${g.key}|${slug}`,226 series,227 n_obs: series.reduce((n, s) => n + s.points.length, 0),228 // y_max intentionally inherited: shared axis across panels229 }));230}231232export function shouldUseMultiples(g: ComparableGroup, view: 'lines' | 'multiples'): boolean {233 return view === 'multiples' ? new Set(g.series.map((s) => s.cancer_slug)).size > 1 : g.series.length > MAX_OVERLAID_SERIES;234}235236/** Latest observed point of each series (for "latest value" captions). */237export function latestPoint(s: ExplorerSeries): { x: number; y: number } | null {238 if (s.points.length === 0) return null;239 const p = s.points[s.points.length - 1]!;240 return { x: p.x, y: p.y };241}242