perfect-postcode/frontend/src/lib/format.ts
2026-07-03 18:47:28 +01:00

265 lines
9.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.

import i18n from 'i18next';
interface ValueFormat {
prefix?: string;
suffix?: string;
/** Show full integer (no k/M abbreviation) */
raw?: boolean;
}
function usesChineseNumberUnits(): boolean {
return i18n.language?.toLowerCase().startsWith('zh') ?? false;
}
function formatChineseCompactNumber(value: number): string | null {
const abs = Math.abs(value);
if (abs >= 100_000_000) return `${trimFixed(value / 100_000_000)}亿`;
if (abs >= 10_000) return `${trimFixed(value / 10_000)}`;
return null;
}
function trimFixed(value: number): string {
return value.toFixed(1).replace(/\.0$/, '');
}
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 (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
return `${p}${value.toFixed(1)}${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, rawOrFmt?: boolean | ValueFormat): string {
const fmt = typeof rawOrFmt === 'object' ? rawOrFmt : { raw: rawOrFmt };
const p = fmt?.prefix ?? '';
const s = fmt?.suffix ?? '';
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
if (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
if (Number.isInteger(value)) return `${p}${value}${s}`;
return `${p}${value.toFixed(2)}${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}${s}`;
return `${p}${value.toFixed(2)}${s}`;
}
/** Parse a user-typed value like "250k", "1.2M", "£300000", "50 sqm" back to a number. */
export function parseInputValue(
text: string,
opts?: { prefix?: string; suffix?: string; step?: number }
): number | null {
let s = text.trim();
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
s = s.trim().replace(/[,]/g, '');
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM万亿億]?)$/);
if (!m) return null;
let val = parseFloat(m[1]);
if (isNaN(val)) return null;
const unit = m[2];
if (unit === 'k') val *= 1_000;
else if (unit === 'K') val *= 1_000;
else if (unit === 'm' || unit === 'M') val *= 1_000_000;
else if (unit === '万') val *= 10_000;
else if (unit === '亿' || unit === '億') val *= 100_000_000;
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
return val;
}
export function formatDuration(d: string): string {
if (d === 'F' || d === 'L') {
// These are server enum values: translate via ts()
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
if (d === 'F') return ts('Freehold');
return ts('Leasehold');
}
return d;
}
export function formatTransactionDate(fractionalYear: number): string {
const year = Math.floor(fractionalYear);
const monthIndex = Math.min(Math.round((fractionalYear - year) * 12), 11);
const language = i18n.language || undefined;
return new Intl.DateTimeFormat(language, { month: 'short', year: 'numeric' }).format(
new Date(Date.UTC(year, monthIndex, 1))
);
}
export function formatYearMonth(year: number, month: number): string {
const monthIndex = Math.min(Math.max(month - 1, 0), 11);
const language = i18n.language || undefined;
return new Intl.DateTimeFormat(language, { month: 'short', year: 'numeric' }).format(
new Date(Date.UTC(year, monthIndex, 1))
);
}
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();
}
/**
* Compute percentages that always sum to exactly 100, using the largest-remainder
* (Hamilton) method. Floors each raw percentage, then distributes the residual to
* the segments with the largest fractional parts. Eliminates rounding drift where
* three 33.3% segments would otherwise display as "33%, 33%, 33% = 99%".
*
* Assumes `total` equals (or closely equals) the sum of `values`.
*/
export function roundedPercentages(values: number[], total: number, decimals = 0): number[] {
if (total <= 0 || values.length === 0) return values.map(() => 0);
const scale = 10 ** decimals;
const targetSum = 100 * scale;
const raw = values.map((v) => (v / total) * 100 * scale);
const floors = raw.map((r) => Math.floor(r));
const result = floors.slice();
let diff = targetSum - floors.reduce((a, b) => a + b, 0);
const order = raw.map((r, i) => ({ i, frac: r - floors[i] })).sort((a, b) => b.frac - a.frac);
for (let k = 0; k < order.length && diff > 0; k++) {
result[order[k].i] += 1;
diff -= 1;
}
return result.map((v) => v / scale);
}
export function formatRelativeTime(isoDate: string): string {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const i18n = require('../i18n').default as {
t: (key: string, opts?: Record<string, unknown>) => 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 i18n.t('format.justNow');
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return i18n.t('format.minutesAgo', { count: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return i18n.t('format.hoursAgo', { count: diffHr });
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 30) return i18n.t('format.daysAgo', { count: diffDay });
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;
}