/** * Scale transforms for choropleth and bubble maps (inspired by d3-in-angular COVID demo). * Maps a raw metric value to a display position t ∈ [0, 1] for color and bubble sizing. */ import type { CensusValueMode } from './censusMapValueMode' import { giniLetterSuffix } from './giniLetterGrade' export type CensusScaleId = 'linear' | 'sqrt' | 'log' | 'exp' /** * Choropleth fill easing close to D3’s ``easeCubicInOut`` (see d3-in-angular county demos). * Use on SVG ``style`` (not the ``fill`` attribute) so the browser can interpolate colors. */ export const CENSUS_CHORO_FILL_TRANSITION = 'fill 1.2s cubic-bezier(0.65, 0, 0.35, 1), stroke 0.45s cubic-bezier(0.65, 0, 0.35, 1)' /** Robust choropleth range (reduces “flat” maps when a few outliers dominate min/max). */ export function quantileExtent( values: (number | null | undefined)[], qLow = 0.04, qHigh = 0.96, ): { min: number; max: number } { const nums = values .filter((x): x is number => typeof x === 'number' && Number.isFinite(x)) .sort((a, b) => a - b) const n = nums.length if (n < 2) return { min: 0, max: 1 } const lo = nums[Math.max(0, Math.min(n - 1, Math.floor(qLow * (n - 1))))]! const hi = nums[Math.max(0, Math.min(n - 1, Math.ceil(qHigh * (n - 1))))]! if (!(lo < hi)) { const mid = nums[Math.floor(n / 2)]! return { min: mid * 0.9, max: mid * 1.1 } } return { min: lo, max: hi } } /** Min/max of observed values (same as ``quantileExtent(..., 0, 1)``). Used for bubble radii so sizes span the full metric range. */ export function minMaxExtent(values: (number | null | undefined)[]): { min: number; max: number } { return quantileExtent(values, 0, 1) } export const CENSUS_SCALES: { id: CensusScaleId; label: string }[] = [ { id: 'linear', label: 'Linear' }, { id: 'sqrt', label: 'Square root' }, { id: 'log', label: 'Logarithmic' }, { id: 'exp', label: 'Exponential (t²)' }, ] export function metricToDisplayT( v: number | null | undefined, min: number, max: number, scale: CensusScaleId, ): number | null { if (v == null || !Number.isFinite(v) || max <= min) return null const clamped = Math.max(min, Math.min(max, v)) const u = (clamped - min) / (max - min) switch (scale) { case 'linear': return u case 'sqrt': return Math.sqrt(Math.max(0, u)) case 'log': { const lo = Math.log10(Math.max(min, 1)) const hi = Math.log10(Math.max(max, 1)) const span = hi - lo || 1e-9 return (Math.log10(Math.max(clamped, 1)) - lo) / span } case 'exp': return u * u default: return u } } /** Multi-stop ramp (readable light end → sky → blue → navy). Low end is darker * than typical sequential blues so the floor of the scale still reads against * the pale-blue map panel — otherwise the lowest-bucket places (e.g. * Tuscaloosa city in a Tuscaloosa-County drill-down) fade into the * background and look unfilled. */ const CHORO_RGB_STOPS: { t: number; rgb: [number, number, number] }[] = [ { t: 0, rgb: [170, 184, 210] }, { t: 0.12, rgb: [176, 195, 224] }, { t: 0.28, rgb: [165, 198, 236] }, { t: 0.44, rgb: [125, 211, 252] }, { t: 0.58, rgb: [56, 189, 248] }, { t: 0.72, rgb: [59, 130, 246] }, { t: 0.86, rgb: [29, 78, 216] }, { t: 1, rgb: [23, 37, 84] }, ] function lerpChannel(a: number, b: number, u: number): number { return Math.round(a + (b - a) * u) } export function colorFromT(t: number | null): string { if (t == null || !Number.isFinite(t)) return '#e2e8f0' const x = Math.min(1, Math.max(0, t)) let i = 0 while (i < CHORO_RGB_STOPS.length - 2 && x > CHORO_RGB_STOPS[i + 1]!.t) i += 1 const lo = CHORO_RGB_STOPS[i]! const hi = CHORO_RGB_STOPS[i + 1]! const span = hi.t - lo.t || 1e-9 const u = (x - lo.t) / span const r = lerpChannel(lo.rgb[0], hi.rgb[0], u) const g = lerpChannel(lo.rgb[1], hi.rgb[1], u) const b = lerpChannel(lo.rgb[2], hi.rgb[2], u) return `rgb(${r},${g},${b})` } /** * Bubble fill ramp — **Option 1 · Deep Ocean** (boardroom / clinical feel). * Steel Blue (low) → Teal (mid) → Deep Emerald (high). Distinct from choropleth fills. */ const BUBBLE_BRAND_RGB_STOPS: { t: number; rgb: [number, number, number] }[] = [ { t: 0, rgb: [44, 110, 138] }, // #2C6E8A Steel Blue { t: 0.52, rgb: [42, 157, 143] }, // #2A9D8F Teal { t: 1, rgb: [27, 107, 90] }, // #1B6B5A Deep Emerald ] /** Same interpolation as ``colorFromT`` but using the bubble brand ramp; optional alpha for SVG / canvas. */ export function bubbleFillFromT(t: number | null, alpha = 0.88): string { if (t == null || !Number.isFinite(t)) return `rgba(226, 232, 240, ${Math.min(1, alpha + 0.07)})` const x = Math.min(1, Math.max(0, t)) let i = 0 while (i < BUBBLE_BRAND_RGB_STOPS.length - 2 && x > BUBBLE_BRAND_RGB_STOPS[i + 1]!.t) i += 1 const lo = BUBBLE_BRAND_RGB_STOPS[i]! const hi = BUBBLE_BRAND_RGB_STOPS[i + 1]! const span = hi.t - lo.t || 1e-9 const u = (x - lo.t) / span const r = lerpChannel(lo.rgb[0], hi.rgb[0], u) const g = lerpChannel(lo.rgb[1], hi.rgb[1], u) const b = lerpChannel(lo.rgb[2], hi.rgb[2], u) return `rgba(${r},${g},${b},${alpha})` } export function bubbleRadiusPx( v: number | null | undefined, min: number, max: number, scale: CensusScaleId, rMin = 3, rMax = 18, ): number { const t = metricToDisplayT(v, min, max, scale) if (t == null) return rMin return rMin + t * (rMax - rMin) } /** * Compact tick labels for census map bar axes (matches former Recharts XAxis tickFormatter). * When ``valueSpan`` is small (Gini, age, rent %), use decimals instead of rounding to 0 / ``k``. */ export function formatCensusMapAxisTick(x: number, valueSpan?: number): string { const n = Number(x) if (!Number.isFinite(n)) return '' if (valueSpan != null && Number.isFinite(valueSpan) && valueSpan > 0) { if (valueSpan <= 2.5) return n.toFixed(2) if (valueSpan <= 120) return n.toFixed(1) } if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (Math.abs(n) >= 1000) return `${Math.round(n / 1000)}K` return String(Math.round(n)) } /** Tables / tooltips: ``26 min``, ``1 hr 5 min``. */ export function formatMinutesDisplay(v: number): string { if (!Number.isFinite(v)) return '—' const sign = v < 0 ? '-' : '' const whole = Math.round(Math.abs(v)) if (whole < 60) return `${sign}${whole} min` const h = Math.floor(whole / 60) const m = whole % 60 if (m === 0) return `${sign}${h} hr` return `${sign}${h} hr ${m} min` } /** Bar ends: ``26m``, ``1h5m``. */ export function formatMinutesCompactDisplay(v: number): string { if (!Number.isFinite(v)) return '—' const sign = v < 0 ? '-' : '' const whole = Math.round(Math.abs(v)) if (whole < 60) return `${sign}${whole}m` const h = Math.floor(whole / 60) const m = whole % 60 return m === 0 ? `${sign}${h}h` : `${sign}${h}h${m}m` } /** X-axis ticks when the metric is a duration in minutes (never ``250k``-style magnitudes). */ export function formatCensusMapAxisTickMinutes(x: number, valueSpan?: number): string { const n = Number(x) if (!Number.isFinite(n)) return '' const span = valueSpan != null && Number.isFinite(valueSpan) && valueSpan > 0 ? valueSpan : 30 if (span <= 4) return `${stripFracZeros(Math.abs(n).toFixed(1))}m` const r = Math.round(n) if (Math.abs(r) >= 60 && span >= 40) { const h = r / 60 if (Math.abs(h - Math.round(h)) < 0.11) return `${Math.round(h)}h` return `${stripFracZeros(h.toFixed(1))}h` } return `${r}m` } export function formatCensusMapAxisTickForMetric( slug: string, metrics: CensusMetricFormatRow[], x: number, valueSpan?: number, valueMode: CensusValueMode = 'raw', ): string { const m = metrics.find((r) => r.slug === slug) if (m?.format === 'minutes' || slug === 'travel_time_to_work_minutes') { return formatCensusMapAxisTickMinutes(x, valueSpan) } if (valueMode === 'yoy' || valueMode === 'vs_natl') { const n = Number(x) if (!Number.isFinite(n)) return '' const span = valueSpan != null && Number.isFinite(valueSpan) && valueSpan > 0 ? valueSpan : 20 const a = Math.abs(n) const decimals = span <= 3 ? 1 : a >= 10 ? 0 : 1 return `${n.toFixed(decimals)}%` } return formatCensusMapAxisTick(x, valueSpan) } /** Minimal metric row for compact bar-end labels. */ export type CensusMetricFormatRow = { slug: string; format: string } function stripFracZeros(s: string): string { return s.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '') } /** Optional display tuning for dense tables (e.g. Data Explorer scorecard). */ export type FormatMetricValueDisplayOptions = { /** * When true, use fixed fractional width for currency/count K/M/B, percents, and years * so columns align (e.g. ``$64.12K`` vs ``$62.03K``, ``3.20%``). */ uniformTableDecimals?: boolean } /** ``$`` / count style compact: 1.2k, 3.4M, 12 (no unit suffix for counts). */ function formatCompactMagnitude(abs: number, withDollar: boolean, uniformTable = false): string { const p = withDollar ? '$' : '' if (!(abs > 0) || !Number.isFinite(abs)) return `${p}0` if (uniformTable) { if (abs >= 1e9) return `${p}${(abs / 1e9).toFixed(2)}B` if (abs >= 1e6) return `${p}${(abs / 1e6).toFixed(2)}M` if (abs >= 1000) return `${p}${(abs / 1000).toFixed(2)}K` return withDollar ? `${p}${abs.toFixed(2)}` : `${p}${abs.toFixed(0)}` } if (abs >= 1e9) return `${p}${stripFracZeros((abs / 1e9).toFixed(2))}B` if (abs >= 1e6) return `${p}${stripFracZeros((abs / 1e6).toFixed(2))}M` if (abs >= 100_000) return `${p}${Math.round(abs / 1000)}K` if (abs >= 1000) return `${p}${stripFracZeros((abs / 1000).toFixed(2))}K` if (abs >= 100) return `${p}${Math.round(abs)}` if (abs >= 10) return `${p}${stripFracZeros(abs.toFixed(1))}` if (abs >= 1) return `${p}${stripFracZeros(abs.toFixed(1))}` return `${p}${stripFracZeros(abs.toFixed(2))}` } /** * Short labels for race bar ends: ``$72k``, ``1.2M``, ``4.2%``, ``0.48`` (Gini), ``37.2y``. * For ``yoy`` / ``vs_natl`` uses percent with sensible precision. */ export function formatMetricValueCompact( slug: string, v: number, metrics: CensusMetricFormatRow[], valueMode: 'raw' | 'yoy' | 'vs_natl', ): string { if (!Number.isFinite(v)) return '—' if (valueMode === 'yoy' || valueMode === 'vs_natl') { const a = Math.abs(v) const decimals = a >= 10 ? 0 : 1 return `${v.toFixed(decimals)}%` } const m = metrics.find((x) => x.slug === slug) const f = m?.format ?? '' const sign = v < 0 ? '-' : '' const av = Math.abs(v) if (f === 'minutes' || slug === 'travel_time_to_work_minutes') { return formatMinutesCompactDisplay(v) } if (f === 'currency') return `${sign}${formatCompactMagnitude(av, true)}` if (f === 'count') return `${sign}${formatCompactMagnitude(av, false)}` if (f === 'percent') return `${stripFracZeros(v.toFixed(1))}%` if (f === 'ratio') { const base = stripFracZeros(v.toFixed(3)) return slug === 'gini_income_inequality' && valueMode === 'raw' ? `${base}${giniLetterSuffix(v)}` : base } if (f === 'years') return `${stripFracZeros(v.toFixed(1))}y` return `${sign}${formatCompactMagnitude(av, false)}` } /** * Human-readable values for tooltips, tables, and bar-chart main figures: ``$39.3M``, ``39287K``, * percents for YoY / vs-national modes, and full words for minutes. * * Pass ``{ uniformTableDecimals: true }`` from scorecard-style tables so K/M/B and % line up. */ export function formatMetricValueDisplay( slug: string, v: number | null | undefined, metrics: CensusMetricFormatRow[], valueMode: CensusValueMode = 'raw', options?: FormatMetricValueDisplayOptions, ): string { if (v == null || !Number.isFinite(v)) return '—' const u = options?.uniformTableDecimals ?? false if (valueMode === 'yoy' || valueMode === 'vs_natl') { const a = Math.abs(v) return `${v.toFixed(u ? 2 : a >= 10 ? 0 : 1)}%` } const m = metrics.find((x) => x.slug === slug) const f = m?.format ?? '' if (f === 'minutes' || slug === 'travel_time_to_work_minutes') { return formatMinutesDisplay(v) } if (f === 'currency') { const sign = v < 0 ? '-' : '' return `${sign}${formatCompactMagnitude(Math.abs(v), true, u)}` } if (f === 'count') { const sign = v < 0 ? '-' : '' return `${sign}${formatCompactMagnitude(Math.abs(v), false, u)}` } if (f === 'percent') return `${v.toFixed(u ? 2 : 1)}%` if (f === 'ratio') return v.toFixed(3) if (f === 'years') return `${v.toFixed(u ? 2 : 1)} yrs` const sign = v < 0 ? '-' : '' return `${sign}${formatCompactMagnitude(Math.abs(v), false, u)}` }