open-navigator / web_app /src /utils /censusMapTransforms.ts
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
12.7 kB
/**
* 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)}`
}