PolyTools Journal 0.1.1 / src/lib/format.js
| 1 | /* PolyTools — number/date formatting (locale-aware, no DOM). */ |
| 2 | (function (root) { |
| 3 | 'use strict'; |
| 4 | |
| 5 | let locale = 'en-US'; |
| 6 | const MINUS = '−'; |
| 7 | const NA = '-'; // placeholder for a missing value (plain hyphen, no long dashes in UI text) |
| 8 | |
| 9 | const LOCALES = { en: 'en-US', ru: 'ru-RU', es: 'es-ES', zh: 'zh-CN', fr: 'fr-FR' }; |
| 10 | const PP = { en: ' pp', ru: ' п.п.', es: ' p. p.', zh: ' 个百分点', fr: ' pts' }; |
| 11 | function setLocale(lang) { locale = LOCALES[lang] || 'en-US'; } |
| 12 | |
| 13 | const nf = (min, max) => new Intl.NumberFormat(locale, { minimumFractionDigits: min, maximumFractionDigits: max }); |
| 14 | |
| 15 | /** Sign follows what is displayed: a value that rounds to zero never shows as "−0%" or "+$0.00". */ |
| 16 | function signed(str, x, sign) { |
| 17 | if (!/[1-9]/.test(str)) return str; |
| 18 | // U+2060 WORD JOINER keeps the sign glued to the number when a line wraps ("−" / "$1,845" on two lines) |
| 19 | if (x < 0) return MINUS + '\u2060' + str; |
| 20 | if (sign && x > 0) return '+\u2060' + str; |
| 21 | return str; |
| 22 | } |
| 23 | |
| 24 | /** $1,234.56 ; compact: $12.9K / $4.2M ; axis: $500 / $5K (no cents) ; sign: +$ / −$ */ |
| 25 | function money(x, o) { |
| 26 | if (x == null || !Number.isFinite(x)) return NA; |
| 27 | const opts = o || {}; |
| 28 | const a = Math.abs(x); |
| 29 | let body; |
| 30 | if (opts.axis) { |
| 31 | body = a >= 1000 |
| 32 | ? new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }).format(a) |
| 33 | : nf(0, a < 10 && a % 1 ? 2 : 0).format(a); |
| 34 | } else if (opts.compact && a >= 10000) { |
| 35 | body = new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: a >= 1e6 ? 2 : 1 }).format(a); |
| 36 | } else { |
| 37 | const d = opts.digits != null ? opts.digits : (a > 0 && a < 1 ? 3 : 2); |
| 38 | body = nf(Math.min(d, 2), d).format(a); |
| 39 | } |
| 40 | return signed('$' + body, x, opts.sign); |
| 41 | } |
| 42 | |
| 43 | /** Price in cents: 0.964 → 96.4¢ */ |
| 44 | function cents(p, digits) { |
| 45 | if (p == null || !Number.isFinite(p)) return NA; |
| 46 | return nf(0, digits != null ? digits : 2).format(p * 100) + '¢'; |
| 47 | } |
| 48 | |
| 49 | /** 0.0373 → 3.73% */ |
| 50 | function pct(x, digits, sign) { |
| 51 | if (x == null || !Number.isFinite(x)) return NA; |
| 52 | const a = Math.abs(x * 100); |
| 53 | const d = digits != null ? digits : (a >= 100 ? 0 : a >= 10 ? 1 : 2); |
| 54 | return signed(nf(0, d).format(a) + '%', x, sign); |
| 55 | } |
| 56 | |
| 57 | /** percentage points: 0.052 → +5.2 pp */ |
| 58 | function pp(x, lang) { |
| 59 | if (x == null || !Number.isFinite(x)) return NA; |
| 60 | return signed(nf(0, 1).format(Math.abs(x * 100)), x, true) + (PP[lang] || PP.en); |
| 61 | } |
| 62 | |
| 63 | function number(x, digits) { |
| 64 | if (x == null || !Number.isFinite(x)) return NA; |
| 65 | return signed(nf(0, digits != null ? digits : 2).format(Math.abs(x)), x, false); |
| 66 | } |
| 67 | |
| 68 | function int(x) { return x == null || !Number.isFinite(x) ? NA : new Intl.NumberFormat(locale).format(Math.round(x)); } |
| 69 | |
| 70 | function date(tsSec) { |
| 71 | if (!(tsSec > 0)) return NA; |
| 72 | return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(tsSec * 1000)); |
| 73 | } |
| 74 | |
| 75 | function dateIso(iso) { |
| 76 | const t = Date.parse(iso); |
| 77 | if (!Number.isFinite(t)) return NA; |
| 78 | return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(t)); |
| 79 | } |
| 80 | |
| 81 | function month(key) { |
| 82 | const [y, m] = key.split('-').map(Number); |
| 83 | return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(y, m - 1, 1))); |
| 84 | } |
| 85 | |
| 86 | /** 0.4 d → 9.6 h ; 3.2 d → 3.2 d (labels supplied by i18n) */ |
| 87 | function duration(days, t) { |
| 88 | if (days == null || !Number.isFinite(days)) return NA; |
| 89 | if (days < 1) return nf(0, 1).format(days * 24) + ' ' + t('unit.h'); |
| 90 | return nf(0, days < 10 ? 1 : 0).format(days) + ' ' + t('unit.d'); |
| 91 | } |
| 92 | |
| 93 | const fmt = { NA, setLocale, get locale() { return locale; }, money, cents, pct, pp, number, int, date, dateIso, month, duration, MINUS }; |
| 94 | root.PWL = root.PWL || {}; |
| 95 | root.PWL.fmt = fmt; |
| 96 | if (typeof module !== 'undefined' && module.exports) module.exports = fmt; |
| 97 | })(typeof globalThis !== 'undefined' ? globalThis : this); |