171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
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;
|
||
}
|