interface ValueFormat { prefix?: string; suffix?: string; /** Show full integer (no k/M abbreviation) */ raw?: boolean; } export function formatValue(value: number, fmt?: ValueFormat): string { const p = fmt?.prefix ?? ''; const s = fmt?.suffix ?? ''; if (fmt?.raw) return `${p}${Math.round(value)}${s}`; if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`; if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`; if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`; return `${p}${value.toFixed(1)}${s}`; } export function formatFilterValue(value: number, raw?: boolean): string { if (raw) return Math.round(value).toString(); if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; if (Number.isInteger(value)) return value.toString(); return value.toFixed(2); } export function formatDuration(d: string): string { if (d === 'F') return 'Freehold'; if (d === 'L') return 'Leasehold'; return d; } const MONTH_NAMES = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; export function formatTransactionDate(fractionalYear: number): string { const year = Math.floor(fractionalYear); const monthIndex = Math.min(Math.round((fractionalYear - year) * 12), 11); return `${MONTH_NAMES[monthIndex]} ${year}`; } export function formatAge(value: number, approximate = true): string { if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`; return Math.round(value).toString(); } // Format number with optional decimals, used in PropertyCard export function formatNumber(value: number | undefined, decimals = 0): string { if (value === undefined) return ''; return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString(); } export function formatRelativeTime(isoDate: string): string { const now = Date.now(); const then = new Date(isoDate).getTime(); const diffMs = now - then; const diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) return 'just now'; const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return `${diffMin}m ago`; const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return `${diffHr}h ago`; const diffDay = Math.floor(diffHr / 24); if (diffDay < 30) return `${diffDay}d ago`; return new Date(isoDate).toLocaleDateString(); } // Percentile-based scale: maps between percentile space (0–100) and absolute values // using the histogram's CDF. Each percentile step = 1% of data. export interface PercentileScale { toValue: (percentile: number) => number; toPercentile: (value: number) => number; } export function buildPercentileScale(hist: { min: number; max: number; p1: number; p99: number; counts: number[]; }): PercentileScale { const n = hist.counts.length; const total = hist.counts.reduce((a, b) => a + b, 0); if (n === 0 || total === 0) { const range = hist.max - hist.min || 1; return { toValue: (p) => hist.min + (p / 100) * range, toPercentile: (v) => ((v - hist.min) / range) * 100, }; } // Bin boundaries: [min, p1, ..middle edges.., p99, max] const boundaries: number[] = []; if (n === 1) { boundaries.push(hist.min, hist.max); } else { boundaries.push(hist.min, hist.p1); if (n > 2) { const middleWidth = (hist.p99 - hist.p1) / (n - 2); for (let i = 1; i < n - 1; i++) { boundaries.push(hist.p1 + i * middleWidth); } } boundaries.push(hist.max); } // Cumulative fraction: cumFrac[0]=0, cumFrac[n]=1 const cumFrac: number[] = [0]; for (let i = 0; i < n; i++) { cumFrac.push(cumFrac[i] + hist.counts[i] / total); } cumFrac[n] = 1; // ensure exact 1.0 return { toValue(percentile: number): number { const target = Math.max(0, Math.min(1, percentile / 100)); if (target <= 0) return boundaries[0]; if (target >= 1) return boundaries[n]; let i = 0; for (; i < n - 1; i++) { if (cumFrac[i + 1] > target) break; } const binFrac = cumFrac[i + 1] - cumFrac[i]; const t = binFrac > 0 ? (target - cumFrac[i]) / binFrac : 0; return boundaries[i] + t * (boundaries[i + 1] - boundaries[i]); }, toPercentile(value: number): number { if (value <= boundaries[0]) return 0; if (value >= boundaries[n]) return 100; let i = 0; for (; i < n - 1; i++) { if (boundaries[i + 1] > value) break; } const binWidth = boundaries[i + 1] - boundaries[i]; const t = binWidth > 0 ? (value - boundaries[i]) / binWidth : 0; return (cumFrac[i] + t * (cumFrac[i + 1] - cumFrac[i])) * 100; }, }; } // Calculate weighted mean from histogram with outlier bins. // Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max]. export function calculateHistogramMean(histogram: { min: number; max: number; p1: number; p99: number; counts: number[]; }): number | undefined { const n = histogram.counts.length; if (n === 0) return undefined; const totalCount = histogram.counts.reduce((a, b) => a + b, 0); if (totalCount === 0) return undefined; const { min, max, p1, p99 } = histogram; const middleBins = Math.max(n - 2, 0); const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0; let weightedSum = 0; for (let i = 0; i < n; i++) { let center: number; if (i === 0) center = (min + p1) / 2; else if (i === n - 1) center = (p99 + max) / 2; else center = p1 + (i - 0.5) * middleWidth; weightedSum += center * histogram.counts[i]; } return weightedSum / totalCount; }