265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
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 (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;
|
||
}
|