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%
9.6 KB · 242 lines typescript
Raw Blame History
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