This commit is contained in:
wkh
2026-08-29 17:47:12 +02:00
commit c7885067e7
53 changed files with 13458 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import { isError, type CellValue } from '../engine';
import type { CellNumFmt } from './types';
/**
* Locale used to render numeric values. `'plain'` is not a real locale -- it
* renders the bare JS number (`.` decimal, no grouping).
*
* A *display* setting only. `rawString()` (CSV / XLSX export) stays
* locale-neutral so round-trips are lossless.
*/
export type NumberLocale =
| 'plain'
| 'de-DE'
| 'de-CH'
| 'en-US'
| 'en-GB'
| 'fr-FR'
| 'it-IT';
export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [
{ id: 'plain', label: 'Ohne Trennzeichen · 1234.5' },
{ id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' },
{ id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' },
{ id: 'en-US', label: 'English (US) · 1,234.5' },
{ id: 'en-GB', label: 'English (UK) · 1,234.5' },
{ id: 'fr-FR', label: 'Français (FR) · 1 234,5' },
{ id: 'it-IT', label: 'Italiano (IT) · 1.234,5' },
];
const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id));
export function isNumberLocale(x: unknown): x is NumberLocale {
return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale);
}
export const DEFAULT_LOCALE: NumberLocale = 'de-DE';
/** Cell number-format options offered in the UI. */
export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
{ id: 'auto', label: 'Automatisch' },
{ id: 'plain', label: 'Ohne Formatierung' },
{ id: 'int', label: 'Ganzzahl · 1.235' },
{ id: 'dec1', label: '1 Nachkommastelle · 1.234,6' },
{ id: 'dec2', label: '2 Nachkommastellen · 1.234,56' },
{ id: 'pct0', label: 'Prozent · 12 %' },
{ id: 'pct2', label: 'Prozent · 12,34 %' },
];
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
export function isCellNumFmt(x: unknown): x is CellNumFmt {
return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt);
}
const round10 = (n: number) => Math.round(n * 1e10) / 1e10;
function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat {
// for the pseudo-locale 'plain' use en-US digits but turn grouping off
return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, {
useGrouping: locale !== 'plain',
...opts,
});
}
/** Render a computed value for display (grid cell / status bar / chart). */
export function formatValue(
v: CellValue,
locale: NumberLocale = DEFAULT_LOCALE,
numFmt: CellNumFmt = 'auto',
): string {
if (v === null) return '';
if (isError(v)) return v.code;
if (typeof v !== 'number') return v;
if (!Number.isFinite(v)) return '#NUM!';
const n = round10(v);
switch (numFmt) {
case 'plain':
return String(n);
case 'int':
return nf(locale, { maximumFractionDigits: 0 }).format(n);
case 'dec1':
return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
case 'dec2':
return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
case 'pct0':
return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n);
case 'pct2':
return nf(locale, {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n);
default: // 'auto'
return locale === 'plain'
? String(n)
: nf(locale, { maximumFractionDigits: 10 }).format(n);
}
}
/** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */
export function rawString(v: CellValue): string {
if (v === null) return '';
if (isError(v)) return v.code;
if (typeof v === 'number') return String(round10(v));
return v;
}
export function isNumeric(v: CellValue): boolean {
return typeof v === 'number' && Number.isFinite(v);
}