Skip to main content

perspective_viewer/config/
number_string_format.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13mod enums;
14pub use enums::*;
15use serde::{Deserialize, Serialize};
16use strum::{Display, EnumIter};
17use ts_rs::TS;
18
19/// The `style` family of a numeric column's `number_format` — serialized
20/// FLATTENED into [`CustomNumberFormatConfig`]'s object, discriminated by
21/// the `style` key (`"decimal"` default, `"currency"` + `currency`/
22/// `currencyDisplay`/`currencySign`, `"percent"`, `"unit"` + `unit`/
23/// `unitDisplay`), mirroring `Intl.NumberFormat` options.
24#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, TS)]
25#[serde(rename_all = "camelCase", tag = "style")]
26pub enum NumberFormatStyle {
27    #[default]
28    Decimal,
29    Currency(CurrencyNumberFormatStyle),
30    Percent,
31    Unit(UnitNumberFormatStyle),
32}
33
34#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
35#[serde(rename_all = "camelCase")]
36pub enum CurrencyDisplay {
37    Code,
38    #[default]
39    Symbol,
40    NarrowSymbol,
41    Name,
42}
43
44#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
45#[serde(rename_all = "camelCase")]
46pub enum CurrencySign {
47    #[default]
48    Standard,
49    Accounting,
50}
51
52#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Clone, TS)]
53#[serde(rename_all = "camelCase")]
54pub struct CurrencyNumberFormatStyle {
55    #[serde(default)]
56    pub currency: CurrencyCode,
57
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub currency_display: Option<CurrencyDisplay>,
60
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub currency_sign: Option<CurrencySign>,
63}
64
65#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
66#[serde(rename_all = "camelCase")]
67pub enum UnitDisplay {
68    #[default]
69    Short,
70    Narrow,
71    Long,
72}
73
74#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, TS)]
75#[serde(rename_all = "camelCase")]
76pub struct UnitNumberFormatStyle {
77    #[serde(default)]
78    pub unit: Unit,
79
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub unit_display: Option<UnitDisplay>,
82}
83
84#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
85#[serde(rename_all = "camelCase")]
86pub enum RoundingPriority {
87    #[default]
88    Auto,
89    MorePrecision,
90    LessPrecision,
91}
92
93#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
94#[serde(rename_all = "camelCase")]
95pub enum RoundingMode {
96    Ceil,
97    Floor,
98    Expand,
99    Trunc,
100    HalfCeil,
101    HalfFloor,
102    #[default]
103    HalfExpand,
104    HalfTrunc,
105    HalfEven,
106}
107
108#[derive(Default, Debug, PartialEq, Clone, TS)]
109pub enum RoundingIncrement {
110    #[default]
111    Auto,
112    Custom(f64),
113}
114impl std::fmt::Display for RoundingIncrement {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            RoundingIncrement::Auto => f.write_str("Auto"),
118            RoundingIncrement::Custom(val) => f.write_fmt(format_args!("{val}")),
119        }
120    }
121}
122
123pub const ROUNDING_INCREMENTS: [f64; 15] = [
124    1., 2., 5., 10., 20., 25., 50., 100., 200., 250., 500., 1000., 2000., 2500., 5000.,
125];
126
127#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
128#[serde(rename_all = "camelCase")]
129pub enum TrailingZeroDisplay {
130    #[default]
131    Auto,
132    StripIfInteger,
133}
134
135/// The `notation` family of a numeric column's `number_format` —
136/// serialized FLATTENED into [`CustomNumberFormatConfig`]'s object,
137/// discriminated by the `notation` key (`"standard"` default,
138/// `"scientific"`, `"engineering"`, `"compact"` + `compactDisplay`),
139/// mirroring `Intl.NumberFormat` options.
140#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, TS)]
141#[serde(rename_all = "camelCase", tag = "notation")]
142pub enum Notation {
143    #[default]
144    Standard,
145    Scientific,
146    Engineering,
147    Compact(CompactDisplay),
148}
149
150#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
151#[serde(rename_all = "camelCase", tag = "compactDisplay")]
152pub enum CompactDisplay {
153    #[default]
154    Short,
155    Long,
156}
157
158#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
159#[serde(rename_all = "snake_case")]
160pub enum UseGrouping {
161    Always,
162
163    #[default]
164    Auto,
165    Min2, // default if notation is compact
166
167    #[serde(untagged)]
168    False(bool),
169}
170
171#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
172#[serde(rename_all = "camelCase")]
173pub enum SignDisplay {
174    #[default]
175    Auto,
176    Always,
177    ExceptZero,
178    Negative,
179    Never,
180}
181
182/// A numeric column's `number_format` (`columns_config` value) —
183/// `Intl.NumberFormat`-shaped options, written by the Style tab's number
184/// format editor and read by `createNumberFormatter`. The `style` and
185/// `notation` families ([`NumberFormatStyle`] / [`Notation`]) are serde-
186/// FLATTENED into this object but `#[ts(skip)]`'d (ts-rs cannot flatten
187/// `Option<enum>`) — the package's `NumberFormatConfig` re-composes the
188/// full wire type as `CustomNumberFormatConfig & Partial<NumberFormatStyle>
189/// & Partial<Notation>` (see `column-format.ts`).
190#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone, TS)]
191#[serde(rename_all = "camelCase")]
192pub struct CustomNumberFormatConfig {
193    #[serde(flatten)]
194    #[ts(skip)]
195    pub _style: Option<NumberFormatStyle>,
196
197    // see Digit Options
198    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#minimumintegerdigits
199    // these min/max props can all be specified but it results in possible conflicts
200    // may consider making them distinct options
201    #[serde(skip_serializing_if = "Option::is_none")]
202    #[ts(optional, as = "Option<_>")]
203    pub minimum_integer_digits: Option<f64>,
204
205    #[serde(skip_serializing_if = "Option::is_none")]
206    #[ts(optional, as = "Option<_>")]
207    pub minimum_fraction_digits: Option<f64>,
208
209    #[serde(skip_serializing_if = "Option::is_none")]
210    #[ts(optional, as = "Option<_>")]
211    pub maximum_fraction_digits: Option<f64>,
212
213    #[serde(skip_serializing_if = "Option::is_none")]
214    #[ts(optional, as = "Option<_>")]
215    pub minimum_significant_digits: Option<f64>,
216
217    #[serde(skip_serializing_if = "Option::is_none")]
218    #[ts(optional, as = "Option<_>")]
219    pub maximum_significant_digits: Option<f64>,
220
221    #[serde(skip_serializing_if = "Option::is_none")]
222    #[ts(optional, as = "Option<_>")]
223    pub rounding_priority: Option<RoundingPriority>,
224
225    // specific values https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingincrement
226    // Only available with automatic rounding priority
227    // Cannot be mixed with sigfig rounding. (Does this mean max/min sigfig must be unset?)
228    #[serde(skip_serializing_if = "Option::is_none")]
229    #[ts(optional, as = "Option<_>")]
230    pub rounding_increment: Option<f64>,
231
232    #[serde(skip_serializing_if = "Option::is_none")]
233    #[ts(optional, as = "Option<_>")]
234    pub rounding_mode: Option<RoundingMode>,
235
236    #[serde(skip_serializing_if = "Option::is_none")]
237    #[ts(optional, as = "Option<_>")]
238    pub trailing_zero_display: Option<TrailingZeroDisplay>,
239
240    #[serde(flatten)]
241    #[ts(skip)]
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub _notation: Option<Notation>,
244
245    /// NOTE (audit 2026-08-05): serialized values are the STRINGS
246    /// `"always"`/`"auto"`/`"min2"` or the untagged BOOLEAN `false` —
247    /// the former hand-written TS `useGrouping?: boolean` was wrong for
248    /// the string cases.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    #[ts(optional, as = "Option<_>")]
251    pub use_grouping: Option<UseGrouping>,
252
253    #[serde(skip_serializing_if = "Option::is_none")]
254    #[ts(optional, as = "Option<_>")]
255    pub sign_display: Option<SignDisplay>,
256}
257
258impl CustomNumberFormatConfig {
259    pub fn filter_default(self, is_float: bool) -> Self {
260        let (frac_min, frac_max) = if is_float { (2., 2.) } else { (0., 0.) };
261        let rounding_increment = self.rounding_increment;
262        let use_grouping = self
263            .use_grouping
264            .filter(|val| *val != UseGrouping::default());
265
266        let mut minimum_fraction_digits =
267            self.minimum_fraction_digits.filter(|val| *val != frac_min);
268
269        let mut maximum_fraction_digits =
270            self.maximum_fraction_digits.filter(|val| *val != frac_max);
271
272        let mut show_frac = is_float
273            && (minimum_fraction_digits.is_some()
274                || maximum_fraction_digits.is_some()
275                || use_grouping.is_some()
276                || matches!(
277                    self._style,
278                    Some(NumberFormatStyle::Percent | NumberFormatStyle::Unit(_))
279                ))
280            || !is_float && matches!(self._style, Some(NumberFormatStyle::Currency(_)));
281
282        // Rounding increment does not work unless `minimum_fraction_digits`
283        // and `maximum_fraction_digits` are set to 0.
284        if rounding_increment.is_some() {
285            show_frac = true;
286            minimum_fraction_digits = Some(0.);
287            maximum_fraction_digits = Some(0.);
288        }
289
290        let minimum_significant_digits = self.minimum_significant_digits.filter(|val| *val != 1.);
291        let maximum_significant_digits = self.maximum_significant_digits.filter(|val| *val != 21.);
292        let show_sig = minimum_significant_digits.is_some() || maximum_significant_digits.is_some();
293        Self {
294            _style: self
295                ._style
296                .filter(|style| !matches!(style, NumberFormatStyle::Decimal)),
297            minimum_integer_digits: self.minimum_integer_digits.filter(|val| *val != 1.),
298            minimum_fraction_digits: show_frac
299                .then_some(minimum_fraction_digits.unwrap_or(frac_min)),
300            maximum_fraction_digits: show_frac
301                .then_some(maximum_fraction_digits.unwrap_or(frac_max)),
302            minimum_significant_digits: show_sig
303                .then_some(minimum_significant_digits.unwrap_or(1.)),
304            maximum_significant_digits: show_sig
305                .then_some(minimum_significant_digits.unwrap_or(21.)),
306            rounding_priority: self
307                .rounding_priority
308                .filter(|val| *val != RoundingPriority::default()),
309            rounding_increment,
310            rounding_mode: self
311                .rounding_mode
312                .filter(|val| *val != RoundingMode::default()),
313            trailing_zero_display: self
314                .trailing_zero_display
315                .filter(|val| *val != TrailingZeroDisplay::default()),
316            _notation: self
317                ._notation
318                .filter(|notation| !matches!(notation, Notation::Standard)),
319            use_grouping,
320            sign_display: self
321                .sign_display
322                .filter(|val| *val != SignDisplay::default()),
323        }
324    }
325}