perfect-postcode/frontend/src/lib/format.ts

171 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (0100) 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;
}