@cfasim-ui/docs 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -160,6 +160,40 @@ Use `value-tick-format` to format the value-axis labels. `tooltip-value-format`
160
160
  </template>
161
161
  </ComponentDemo>
162
162
 
163
+ ### Logarithmic value axis
164
+
165
+ Set `value-scale-type="log"` to switch the value axis to base-10 log
166
+ scaling. Non-positive values collapse to the axis floor, and
167
+ `valueMin <= 0` is ignored. With `layout="stacked"`, individual segment
168
+ sizes are no longer proportional to their raw values, but the cumulative
169
+ top still represents the sum.
170
+
171
+ <ComponentDemo>
172
+ <BarChart
173
+ :data="[3, 24, 180, 1450, 9800]"
174
+ :categories="['Wk1', 'Wk2', 'Wk3', 'Wk4', 'Wk5']"
175
+ value-scale-type="log"
176
+ :height="220"
177
+ x-label="Week"
178
+ y-label="Cases"
179
+ />
180
+
181
+ <template #code>
182
+
183
+ ```vue
184
+ <BarChart
185
+ :data="[3, 24, 180, 1450, 9800]"
186
+ :categories="['Wk1', 'Wk2', 'Wk3', 'Wk4', 'Wk5']"
187
+ value-scale-type="log"
188
+ :height="220"
189
+ x-label="Week"
190
+ y-label="Cases"
191
+ />
192
+ ```
193
+
194
+ </template>
195
+ </ComponentDemo>
196
+
163
197
  ### Annotations
164
198
 
165
199
  Pin callouts to specific bars with `annotations`. `x` is the category
@@ -286,7 +320,7 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
286
320
  | `tooltipData` | `ArrayLike&lt;unknown&gt;` | No | — |
287
321
  | `tooltipTrigger` | `"hover" \| "click"` | No | — |
288
322
  | `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
289
- | `tooltipValueFormat` | `(value: number) =&gt; string` | No | — |
323
+ | `tooltipValueFormat` | `NumberFormat` | No | — |
290
324
  | `csv` | `string \| (() =&gt; string)` | No | — |
291
325
  | `filename` | `string` | No | — |
292
326
  | `downloadLink` | `boolean \| string` | No | — |
@@ -299,8 +333,9 @@ anchor. `lineColor`, `lineWidth`, and `lineDash` style the line.
299
333
  | `orientation` | `"vertical" \| "horizontal"` | No | `"vertical"` |
300
334
  | `layout` | `"grouped" \| "stacked"` | No | `"grouped"` |
301
335
  | `valueMin` | `number` | No | `0` |
336
+ | `valueScaleType` | `"linear" \| "log"` | No | `"linear"` |
302
337
  | `valueTicks` | `number \| number[]` | No | — |
303
- | `valueTickFormat` | `(value: number) =&gt; string` | No | — |
338
+ | `valueTickFormat` | `NumberFormat` | No | — |
304
339
  | `categoryFormat` | `(label: string, index: number) =&gt; string` | No | — |
305
340
  | `barPadding` | `number` | No | `0.2` |
306
341
  | `groupGap` | `number` | No | `1` |
@@ -1,10 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from "vue";
3
+ import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
3
4
  import ChartMenu from "../ChartMenu/ChartMenu.vue";
4
5
  import {
5
6
  snap,
6
7
  formatTick,
7
8
  computeTickValues,
9
+ computeLogTickValues,
10
+ scaleFraction,
11
+ clampExtentForScale,
8
12
  categoricalToCsv,
9
13
  useChartFoundation,
10
14
  makeTooltipValueFormatter,
@@ -45,13 +49,26 @@ interface BarChartProps extends ChartCommonProps {
45
49
  layout?: "grouped" | "stacked";
46
50
  /** Force the value axis to start at this value or lower (default 0). */
47
51
  valueMin?: number;
52
+ /**
53
+ * Scale type for the value axis. `"linear"` (default) maps values
54
+ * directly to pixels; `"log"` uses a base-10 log mapping. On a log
55
+ * axis, non-positive values collapse to the visible minimum, and
56
+ * `valueMin <= 0` is ignored. Stacked layout + log produces a
57
+ * cumulative axis but individual segment sizes are no longer
58
+ * proportional to their values.
59
+ */
60
+ valueScaleType?: "linear" | "log";
48
61
  /**
49
62
  * Tick placement on the value axis (numeric). Number = interval,
50
63
  * array = explicit values. When omitted, ticks are chosen automatically.
51
64
  */
52
65
  valueTicks?: number | number[];
53
- /** Formatter for value-axis tick labels. */
54
- valueTickFormat?: (value: number) => string;
66
+ /**
67
+ * Formatter for value-axis tick labels. Accepts a preset name, a
68
+ * printf-style format string, or a function. See `formatNumber` in
69
+ * `@cfasim-ui/shared`.
70
+ */
71
+ valueTickFormat?: NumberFormat;
55
72
  /** Formatter for category-axis labels. Receives the resolved category string. */
56
73
  categoryFormat?: (label: string, index: number) => string;
57
74
  /**
@@ -76,6 +93,7 @@ const props = withDefaults(defineProps<BarChartProps>(), {
76
93
  menu: true,
77
94
  tooltipClamp: "chart",
78
95
  valueGrid: true,
96
+ valueScaleType: "linear",
79
97
  });
80
98
 
81
99
  const emit = defineEmits<{
@@ -137,6 +155,10 @@ const isVertical = computed(() => props.orientation === "vertical");
137
155
  const valueExtent = computed(() => {
138
156
  let min = Infinity;
139
157
  let max = -Infinity;
158
+ let smallestPositive = Infinity;
159
+ const visitPositive = (v: number) => {
160
+ if (v > 0 && v < smallestPositive) smallestPositive = v;
161
+ };
140
162
  if (props.layout === "stacked") {
141
163
  const n = categoryCount.value;
142
164
  for (let i = 0; i < n; i++) {
@@ -146,6 +168,7 @@ const valueExtent = computed(() => {
146
168
  if (i >= s.data.length) continue;
147
169
  const v = Number(s.data[i]);
148
170
  if (!isFinite(v)) continue;
171
+ visitPositive(v);
149
172
  if (v >= 0) pos += v;
150
173
  else neg += v;
151
174
  }
@@ -157,6 +180,7 @@ const valueExtent = computed(() => {
157
180
  for (const v of s.data) {
158
181
  const n = Number(v);
159
182
  if (!isFinite(n)) continue;
183
+ visitPositive(n);
160
184
  if (n < min) min = n;
161
185
  if (n > max) max = n;
162
186
  }
@@ -165,11 +189,25 @@ const valueExtent = computed(() => {
165
189
  if (!isFinite(min)) min = 0;
166
190
  if (!isFinite(max)) max = 0;
167
191
  // Extend the value axis down to valueMin (default 0) when data sits
168
- // above it, so bars share a common baseline.
192
+ // above it, so bars share a common baseline. On log scales, only
193
+ // positive valueMin values are honored.
169
194
  const floor = props.valueMin ?? 0;
170
- if (floor < min) min = floor;
171
- const range = max - min || 1;
172
- return { min, max, range };
195
+ if (props.valueScaleType === "log") {
196
+ if (floor > 0 && floor < min) min = floor;
197
+ } else if (floor < min) {
198
+ min = floor;
199
+ }
200
+ const clamped = clampExtentForScale(
201
+ min,
202
+ max,
203
+ props.valueScaleType,
204
+ smallestPositive,
205
+ );
206
+ return {
207
+ min: clamped.min,
208
+ max: clamped.max,
209
+ range: clamped.max - clamped.min || 1,
210
+ };
173
211
  });
174
212
 
175
213
  /** Size (in pixels) of the categorical axis. */
@@ -218,12 +256,12 @@ const groupedBaselinePixel = computed(() => {
218
256
 
219
257
  /** Convert a data value to its pixel position along the value axis. */
220
258
  function valuePixel(v: number): number {
221
- const { min, range } = valueExtent.value;
222
- const scale = valueSize.value / range;
259
+ const { min, max } = valueExtent.value;
260
+ const frac = scaleFraction(v, min, max, props.valueScaleType);
223
261
  if (isVertical.value) {
224
- return padding.value.top + innerH.value - (v - min) * scale;
262
+ return padding.value.top + innerH.value - frac * innerH.value;
225
263
  }
226
- return padding.value.left + (v - min) * scale;
264
+ return padding.value.left + frac * innerW.value;
227
265
  }
228
266
 
229
267
  interface BarRect {
@@ -367,7 +405,9 @@ const formatTooltipValue = makeTooltipValueFormatter(
367
405
  const valueTickItems = computed(() => {
368
406
  const { min, max } = valueExtent.value;
369
407
  const fmt = (v: number) =>
370
- props.valueTickFormat ? props.valueTickFormat(v) : formatTick(v);
408
+ props.valueTickFormat !== undefined
409
+ ? formatNumber(v, props.valueTickFormat)
410
+ : formatTick(v);
371
411
  if (min === max) {
372
412
  return [
373
413
  {
@@ -377,12 +417,15 @@ const valueTickItems = computed(() => {
377
417
  ];
378
418
  }
379
419
  const targetTickPixels = isVertical.value ? 50 : 80;
380
- const values = computeTickValues({
381
- min,
382
- max,
383
- ticks: props.valueTicks,
384
- targetTickCount: valueSize.value / targetTickPixels,
385
- });
420
+ const values =
421
+ props.valueScaleType === "log"
422
+ ? computeLogTickValues({ min, max, ticks: props.valueTicks })
423
+ : computeTickValues({
424
+ min,
425
+ max,
426
+ ticks: props.valueTicks,
427
+ targetTickCount: valueSize.value / targetTickPixels,
428
+ });
386
429
  return values.map((v) => ({
387
430
  value: fmt(v),
388
431
  pos: snap(valuePixel(v)),
@@ -704,7 +704,7 @@ set `tooltip-trigger`.
704
704
  id: string` | No | — |
705
705
  | `name` | `string` | Yes | — |
706
706
  | `value` | `number \| string` | No | — |
707
- | `tooltipValueFormat` | `(value: number) =&gt; string` | No | — |
707
+ | `tooltipValueFormat` | `NumberFormat` | No | — |
708
708
  | `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
709
709
  | `focus` | `FocusValue` | No | — |
710
710
  | `focusZoomLevel` | `number` | No | `4` |
@@ -16,6 +16,7 @@ import { select } from "d3-selection";
16
16
  import "d3-transition";
17
17
  import { feature, mesh, merge } from "topojson-client";
18
18
  import type { Topology, GeometryCollection } from "topojson-specification";
19
+ import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
19
20
  import { fipsToHsa, hsaNames } from "./hsaMapping.js";
20
21
  import ChartMenu from "../ChartMenu/ChartMenu.vue";
21
22
  import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
@@ -129,11 +130,12 @@ const props = withDefaults(
129
130
  value?: number | string;
130
131
  }) => string;
131
132
  /**
132
- * Formatter for numeric values shown in the default tooltip. Receives
133
- * the raw value. Ignored when `tooltipFormat` is provided (the caller
134
- * controls the entire tooltip in that case).
133
+ * Formatter for numeric values shown in the default tooltip. Accepts a
134
+ * preset name, a printf-style format string, or a function. Ignored when
135
+ * `tooltipFormat` is provided (the caller controls the entire tooltip in
136
+ * that case). See `formatNumber` in `@cfasim-ui/shared`.
135
137
  */
136
- tooltipValueFormat?: (value: number) => string;
138
+ tooltipValueFormat?: NumberFormat;
137
139
  /**
138
140
  * Boundary for tooltip flip/clamp. `"none"` always places to the right of
139
141
  * the pointer with no clamping. `"chart"` (default) uses the map
@@ -889,8 +891,8 @@ const featureName = (
889
891
 
890
892
  function formatTooltipValue(value: number | string | undefined): string {
891
893
  if (value == null) return "";
892
- if (typeof value === "number" && props.tooltipValueFormat) {
893
- return props.tooltipValueFormat(value);
894
+ if (typeof value === "number" && props.tooltipValueFormat !== undefined) {
895
+ return formatNumber(value, props.tooltipValueFormat);
894
896
  }
895
897
  return String(value);
896
898
  }
@@ -56,6 +56,43 @@ A table for displaying columnar data. Accepts a plain record of arrays or a `Mod
56
56
  </template>
57
57
  </ComponentDemo>
58
58
 
59
+ ### Formatting cell values
60
+
61
+ Use `format` on a column to override the default rendering. Accepts a
62
+ {@link NumberFormat} value — a preset name (optionally with `:N` digits,
63
+ e.g. `"percent:1"`), a printf-style format string, or a function
64
+ `(value, row) => string`. Formatted values are also used in CSV exports.
65
+
66
+ <ComponentDemo>
67
+ <DataTable
68
+ :data="{ day: [0, 1, 2, 3, 4], rate: [0.012, 0.234, 0.467, 0.512, 0.601], cases: [1, 21, 56, 101, 141] }"
69
+ :column-config="{
70
+ day: { label: 'Day', width: 'small' },
71
+ rate: { label: 'Attack rate', format: 'percent:1' },
72
+ cases: { label: 'Cases', format: '%05d' },
73
+ }"
74
+ />
75
+
76
+ <template #code>
77
+
78
+ ```vue
79
+ <DataTable
80
+ :data="{
81
+ day: [0, 1, 2, 3, 4],
82
+ rate: [0.012, 0.234, 0.467, 0.512, 0.601],
83
+ cases: [1, 21, 56, 101, 141],
84
+ }"
85
+ :column-config="{
86
+ day: { label: 'Day', width: 'small' },
87
+ rate: { label: 'Attack rate', format: 'percent:1' },
88
+ cases: { label: 'Cases', format: '%05d' },
89
+ }"
90
+ />
91
+ ```
92
+
93
+ </template>
94
+ </ComponentDemo>
95
+
59
96
  ### Cell class and max rows
60
97
 
61
98
  <ComponentDemo>
@@ -169,5 +206,16 @@ interface ColumnConfig {
169
206
  width?: "small" | "medium" | "large" | number;
170
207
  align?: "left" | "center" | "right";
171
208
  cellClass?: string;
209
+ format?: NumberFormat | ((value: CellValue, row: number) => string);
172
210
  }
211
+
212
+ type CellValue = number | string | boolean;
213
+
214
+ type NumberFormat =
215
+ | NumberFormatPreset // "plain" | "localized" | "percent" | "compact" | "scientific" | "engineering" (optionally with ":N" digits, e.g. "percent:1")
216
+ | string // printf-style format, e.g. "%.2f", "%05d"
217
+ | ((value: number) => string);
173
218
  ```
219
+
220
+ See `formatNumber` in `@cfasim-ui/shared` for the underlying utility — you
221
+ can also call it directly in your own code.
@@ -1,7 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from "vue";
3
3
  import type { CSSProperties } from "vue";
4
- import type { ModelOutput } from "@cfasim-ui/shared";
4
+ import {
5
+ formatNumber,
6
+ type ModelOutput,
7
+ type NumberFormat,
8
+ } from "@cfasim-ui/shared";
5
9
  import ChartMenu from "../ChartMenu/ChartMenu.vue";
6
10
  import type { ChartMenuItem } from "../ChartMenu/ChartMenu.vue";
7
11
  import { downloadCsv } from "../ChartMenu/download.js";
@@ -10,12 +14,23 @@ export type TableRecord = Record<string, ArrayLike<number | string | boolean>>;
10
14
  export type TableData = TableRecord | ModelOutput;
11
15
  export type ColumnWidth = "small" | "medium" | "large";
12
16
  export type ColumnAlign = "left" | "center" | "right";
17
+ export type CellValue = number | string | boolean;
18
+ export type ColumnFormatter = (value: CellValue, row: number) => string;
13
19
 
14
20
  export interface ColumnConfig {
15
21
  label?: string;
16
22
  width?: ColumnWidth | number;
17
23
  align?: ColumnAlign;
18
24
  cellClass?: string;
25
+ /**
26
+ * Custom formatter for cell values in this column. Accepts a
27
+ * {@link NumberFormat} (preset name, printf-style string, or
28
+ * `(value) => string` function — see `formatNumber` in
29
+ * `@cfasim-ui/shared`) or a `(value, row) => string` function for full
30
+ * control. Number presets/sprintf only apply to numeric cells; other
31
+ * types fall back to default rendering. Used in CSV exports.
32
+ */
33
+ format?: NumberFormat | ColumnFormatter;
19
34
  }
20
35
 
21
36
  const COLUMN_WIDTHS: Record<ColumnWidth, string> = {
@@ -102,6 +117,18 @@ const rowCount = computed(() => {
102
117
  function cellValue(col: Column, row: number): string {
103
118
  const v = col.values[row];
104
119
  if (v === undefined || v === null) return "";
120
+ const format = props.columnConfig?.[col.name]?.format;
121
+ if (format !== undefined) {
122
+ // Function variant — either `(value: number) => string` (NumberFormat
123
+ // function) or `(value, row) => string` (ColumnFormatter). Both call
124
+ // sites are compatible; the narrower variant ignores `row`.
125
+ if (typeof format === "function") {
126
+ return (format as ColumnFormatter)(v, row);
127
+ }
128
+ // String preset/sprintf — only applies to numeric cells; other types
129
+ // fall through to default rendering.
130
+ if (typeof v === "number") return formatNumber(v, format);
131
+ }
105
132
  if (col.enumLabels && typeof v === "number")
106
133
  return col.enumLabels[v] ?? String(v);
107
134
  if (typeof v === "number") {
@@ -180,6 +180,39 @@ Control tick placement with `x-ticks` and `y-ticks`. Pass a **number** for a fix
180
180
  </template>
181
181
  </ComponentDemo>
182
182
 
183
+ ### Logarithmic y-axis
184
+
185
+ Set `y-scale-type="log"` to switch the y axis to base-10 log scaling. Useful
186
+ when data spans several orders of magnitude (e.g. epidemic case counts in
187
+ early exponential growth). Non-positive values collapse to the axis
188
+ floor, and `yMin <= 0` is ignored.
189
+
190
+ <ComponentDemo>
191
+ <LineChart
192
+ :data="[1, 3, 8, 22, 60, 165, 450, 1230, 3350]"
193
+ y-scale-type="log"
194
+ :height="220"
195
+ x-label="Day"
196
+ y-label="Cases"
197
+ y-grid
198
+ />
199
+
200
+ <template #code>
201
+
202
+ ```vue
203
+ <LineChart
204
+ :data="[1, 3, 8, 22, 60, 165, 450, 1230, 3350]"
205
+ y-scale-type="log"
206
+ :height="220"
207
+ x-label="Day"
208
+ y-label="Cases"
209
+ y-grid
210
+ />
211
+ ```
212
+
213
+ </template>
214
+ </ComponentDemo>
215
+
183
216
  ### Dashed baseline
184
217
 
185
218
  <ComponentDemo>
@@ -647,7 +680,7 @@ until the user clicks Download:
647
680
  | `tooltipData` | `ArrayLike&lt;unknown&gt;` | No | — |
648
681
  | `tooltipTrigger` | `"hover" \| "click"` | No | — |
649
682
  | `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
650
- | `tooltipValueFormat` | `(value: number) =&gt; string` | No | — |
683
+ | `tooltipValueFormat` | `NumberFormat` | No | — |
651
684
  | `csv` | `string \| (() =&gt; string)` | No | — |
652
685
  | `filename` | `string` | No | — |
653
686
  | `downloadLink` | `boolean \| string` | No | — |
@@ -661,11 +694,12 @@ until the user clicks Download:
661
694
  | `areaSections` | `AreaSection[]` | No | — |
662
695
  | `lineOpacity` | `number` | No | `1` |
663
696
  | `yMin` | `number` | No | — |
697
+ | `yScaleType` | `"linear" \| "log"` | No | `"linear"` |
664
698
  | `xMin` | `number` | No | — |
665
699
  | `xTicks` | `number \| number[]` | No | — |
666
700
  | `yTicks` | `number \| number[]` | No | — |
667
- | `xTickFormat` | `(value: number, index: number) =&gt; string` | No | — |
668
- | `yTickFormat` | `(value: number) =&gt; string` | No | — |
701
+ | `xTickFormat` | `NumberFormat \| ((value: number, index: number) =&gt; string)` | No | — |
702
+ | `yTickFormat` | `NumberFormat` | No | — |
669
703
  | `xLabels` | `string[]` | No | — |
670
704
  | `xGrid` | `boolean` | No | — |
671
705
  | `yGrid` | `boolean` | No | — |
@@ -1,10 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from "vue";
3
+ import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
3
4
  import ChartMenu from "../ChartMenu/ChartMenu.vue";
4
5
  import {
5
6
  snap,
6
7
  formatTick,
7
8
  computeTickValues,
9
+ computeLogTickValues,
10
+ scaleFraction,
11
+ clampExtentForScale,
8
12
  seriesToCsv,
9
13
  useChartFoundation,
10
14
  makeTooltipValueFormatter,
@@ -100,6 +104,13 @@ interface LineChartProps extends ChartCommonProps {
100
104
  areaSections?: AreaSection[];
101
105
  lineOpacity?: number;
102
106
  yMin?: number;
107
+ /**
108
+ * Scale type for the y axis. `"linear"` (default) maps values directly
109
+ * to pixels; `"log"` uses a base-10 log mapping. On a log axis,
110
+ * non-positive values collapse to the visible minimum, and `yMin <= 0`
111
+ * is ignored.
112
+ */
113
+ yScaleType?: "linear" | "log";
103
114
  /**
104
115
  * Offset applied to index-based x values (e.g. `xMin: 10` starts the
105
116
  * x axis at 10 instead of 0). Ignored when any series or area has
@@ -119,10 +130,18 @@ interface LineChartProps extends ChartCommonProps {
119
130
  * omitted, ticks are chosen automatically.
120
131
  */
121
132
  yTicks?: number | number[];
122
- /** Formatter for x-axis tick labels. Receives the raw numeric value. */
123
- xTickFormat?: (value: number, index: number) => string;
124
- /** Formatter for y-axis tick labels. Receives the raw numeric value. */
125
- yTickFormat?: (value: number) => string;
133
+ /**
134
+ * Formatter for x-axis tick labels. Accepts a preset name, a printf-style
135
+ * format string, or a function. The two-arg function form `(value, index)`
136
+ * is also supported for index-based labels. See `formatNumber` in
137
+ * `@cfasim-ui/shared`.
138
+ */
139
+ xTickFormat?: NumberFormat | ((value: number, index: number) => string);
140
+ /**
141
+ * Formatter for y-axis tick labels. Accepts a preset name, a printf-style
142
+ * format string, or a function. See `formatNumber` in `@cfasim-ui/shared`.
143
+ */
144
+ yTickFormat?: NumberFormat;
126
145
  /**
127
146
  * @deprecated Use `xTickFormat` (e.g. `(_, i) => labels[i]`) together
128
147
  * with `xTicks` for explicit control. Still honored for tooltip x-labels
@@ -137,6 +156,7 @@ const props = withDefaults(defineProps<LineChartProps>(), {
137
156
  lineOpacity: 1,
138
157
  menu: true,
139
158
  tooltipClamp: "chart",
159
+ yScaleType: "linear",
140
160
  });
141
161
 
142
162
  const emit = defineEmits<{
@@ -254,36 +274,42 @@ function xPixel(v: number): number {
254
274
  const extent = computed(() => {
255
275
  let min = Infinity;
256
276
  let max = -Infinity;
257
- for (const s of allSeries.value) {
258
- for (const v of s.data) {
259
- if (!isFinite(v)) continue;
260
- if (v < min) min = v;
261
- if (v > max) max = v;
262
- }
263
- }
277
+ let smallestPositive = Infinity;
278
+ const visit = (v: number) => {
279
+ if (!isFinite(v)) return;
280
+ if (v < min) min = v;
281
+ if (v > max) max = v;
282
+ if (v > 0 && v < smallestPositive) smallestPositive = v;
283
+ };
284
+ for (const s of allSeries.value) for (const v of s.data) visit(v);
264
285
  for (const a of allAreas.value) {
265
- for (const v of a.upper) {
266
- if (!isFinite(v)) continue;
267
- if (v < min) min = v;
268
- if (v > max) max = v;
269
- }
270
- for (const v of a.lower) {
271
- if (!isFinite(v)) continue;
272
- if (v < min) min = v;
273
- if (v > max) max = v;
274
- }
286
+ for (const v of a.upper) visit(v);
287
+ for (const v of a.lower) visit(v);
275
288
  }
276
289
  if (!isFinite(min)) return { min: 0, max: 0, range: 1 };
277
290
  if (props.yMin != null && props.yMin < min) min = props.yMin;
278
- return { min, max, range: max - min || 1 };
291
+ const clamped = clampExtentForScale(
292
+ min,
293
+ max,
294
+ props.yScaleType,
295
+ smallestPositive,
296
+ );
297
+ return {
298
+ min: clamped.min,
299
+ max: clamped.max,
300
+ range: clamped.max - clamped.min || 1,
301
+ };
279
302
  });
280
303
 
304
+ function yPixel(v: number): number {
305
+ const { min, max } = extent.value;
306
+ const py = padding.value.top + innerH.value;
307
+ return py - scaleFraction(v, min, max, props.yScaleType) * innerH.value;
308
+ }
309
+
281
310
  function toPath(s: ResolvedSeries): string {
282
311
  const data = s.data;
283
312
  if (data.length === 0) return "";
284
- const { min, range } = extent.value;
285
- const yScale = innerH.value / range;
286
- const py = padding.value.top + innerH.value;
287
313
  let d = "";
288
314
  let inSegment = false;
289
315
  for (let i = 0; i < data.length; i++) {
@@ -293,7 +319,7 @@ function toPath(s: ResolvedSeries): string {
293
319
  continue;
294
320
  }
295
321
  const x = xPixel(xv);
296
- const y = py - (data[i] - min) * yScale;
322
+ const y = yPixel(data[i]);
297
323
  d += inSegment ? `L${x},${y}` : `M${x},${y}`;
298
324
  inSegment = true;
299
325
  }
@@ -302,14 +328,11 @@ function toPath(s: ResolvedSeries): string {
302
328
 
303
329
  function toPoints(s: ResolvedSeries): { x: number; y: number }[] {
304
330
  const data = s.data;
305
- const { min, range } = extent.value;
306
- const yScale = innerH.value / range;
307
- const py = padding.value.top + innerH.value;
308
331
  const pts: { x: number; y: number }[] = [];
309
332
  for (let i = 0; i < data.length; i++) {
310
333
  const xv = seriesXAt(s, i);
311
334
  if (!isFinite(data[i]) || !isFinite(xv)) continue;
312
- pts.push({ x: xPixel(xv), y: py - (data[i] - min) * yScale });
335
+ pts.push({ x: xPixel(xv), y: yPixel(data[i]) });
313
336
  }
314
337
  return pts;
315
338
  }
@@ -317,10 +340,6 @@ function toPoints(s: ResolvedSeries): { x: number; y: number }[] {
317
340
  function toAreaPath(a: Area): string {
318
341
  const len = Math.min(a.upper.length, a.lower.length);
319
342
  if (len === 0) return "";
320
- const { min, range } = extent.value;
321
- const yScale = innerH.value / range;
322
- const py = padding.value.top + innerH.value;
323
- const y = (v: number) => py - (v - min) * yScale;
324
343
  // Collect contiguous segments where both upper/lower and x are finite
325
344
  const segments: number[][] = [];
326
345
  let seg: number[] = [];
@@ -339,11 +358,11 @@ function toAreaPath(a: Area): string {
339
358
  if (seg.length) segments.push(seg);
340
359
  let d = "";
341
360
  for (const s of segments) {
342
- d += `M${xPixel(areaXAt(a, s[0]))},${y(a.upper[s[0]])}`;
361
+ d += `M${xPixel(areaXAt(a, s[0]))},${yPixel(a.upper[s[0]])}`;
343
362
  for (let j = 1; j < s.length; j++)
344
- d += `L${xPixel(areaXAt(a, s[j]))},${y(a.upper[s[j]])}`;
363
+ d += `L${xPixel(areaXAt(a, s[j]))},${yPixel(a.upper[s[j]])}`;
345
364
  for (let j = s.length - 1; j >= 0; j--)
346
- d += `L${xPixel(areaXAt(a, s[j]))},${y(a.lower[s[j]])}`;
365
+ d += `L${xPixel(areaXAt(a, s[j]))},${yPixel(a.lower[s[j]])}`;
347
366
  d += "Z";
348
367
  }
349
368
  return d;
@@ -376,18 +395,15 @@ function toSectionPath(section: AreaSection, closed = true): string {
376
395
 
377
396
  const s = allSeries.value[section.seriesIndex];
378
397
  if (!s) return "";
379
- const { min, range } = extent.value;
380
- const yScale = innerH.value / range;
381
- const y = (v: number) => py - (v - min) * yScale;
382
398
 
383
399
  const start = Math.max(0, section.startIndex);
384
400
  const end = Math.min(s.data.length - 1, section.endIndex);
385
401
  if (start > end) return "";
386
402
 
387
- let d = `M${xPixel(seriesXAt(s, start))},${y(s.data[start])}`;
403
+ let d = `M${xPixel(seriesXAt(s, start))},${yPixel(s.data[start])}`;
388
404
  for (let i = start + 1; i <= end; i++) {
389
405
  if (!isFinite(s.data[i])) continue;
390
- d += `L${xPixel(seriesXAt(s, i))},${y(s.data[i])}`;
406
+ d += `L${xPixel(seriesXAt(s, i))},${yPixel(s.data[i])}`;
391
407
  }
392
408
  if (closed) {
393
409
  d += `L${xPixel(seriesXAt(s, end))},${py}`;
@@ -531,26 +547,25 @@ const sectionLabelBaseY = computed(
531
547
 
532
548
  const yTickItems = computed(() => {
533
549
  const { min, max } = extent.value;
534
- const toY = (v: number) =>
535
- snap(
536
- padding.value.top +
537
- innerH.value -
538
- ((v - min) / extent.value.range) * innerH.value,
539
- );
540
550
  const fmt = (v: number) =>
541
- props.yTickFormat ? props.yTickFormat(v) : formatTick(v);
551
+ props.yTickFormat !== undefined
552
+ ? formatNumber(v, props.yTickFormat)
553
+ : formatTick(v);
542
554
 
543
555
  if (min === max) {
544
556
  return [{ value: fmt(min), y: snap(padding.value.top + innerH.value / 2) }];
545
557
  }
546
558
 
547
- const values = computeTickValues({
548
- min,
549
- max,
550
- ticks: props.yTicks,
551
- targetTickCount: innerH.value / 50,
552
- });
553
- return values.map((v) => ({ value: fmt(v), y: toY(v) }));
559
+ const values =
560
+ props.yScaleType === "log"
561
+ ? computeLogTickValues({ min, max, ticks: props.yTicks })
562
+ : computeTickValues({
563
+ min,
564
+ max,
565
+ ticks: props.yTicks,
566
+ targetTickCount: innerH.value / 50,
567
+ });
568
+ return values.map((v) => ({ value: fmt(v), y: snap(yPixel(v)) }));
554
569
  });
555
570
 
556
571
  const xTickItems = computed(() => {
@@ -562,7 +577,12 @@ const xTickItems = computed(() => {
562
577
  // Tick values are in data-space; display labels add `xDisplayOffset`.
563
578
  const fmt = (v: number, i: number) => {
564
579
  const display = v + offset;
565
- if (props.xTickFormat) return props.xTickFormat(display, i);
580
+ const xf = props.xTickFormat;
581
+ if (xf !== undefined) {
582
+ return typeof xf === "function"
583
+ ? xf(display, i)
584
+ : formatNumber(display, xf);
585
+ }
566
586
  if (
567
587
  !hasExplicitX.value &&
568
588
  props.xLabels &&
@@ -653,9 +673,6 @@ function nearestIndex(s: ResolvedSeries, targetX: number): number | null {
653
673
  const hoverDots = computed(() => {
654
674
  const targetX = hoverDataX.value;
655
675
  if (targetX === null) return [];
656
- const { min, range } = extent.value;
657
- const yScale = innerH.value / range;
658
- const py = padding.value.top + innerH.value;
659
676
  const dots: { x: number; y: number; color: string }[] = [];
660
677
  for (const s of allSeries.value) {
661
678
  const nIdx = nearestIndex(s, targetX);
@@ -664,7 +681,7 @@ const hoverDots = computed(() => {
664
681
  if (!isFinite(yv)) continue;
665
682
  dots.push({
666
683
  x: xPixel(seriesXAt(s, nIdx)),
667
- y: py - (yv - min) * yScale,
684
+ y: yPixel(yv),
668
685
  color: s.color ?? "currentColor",
669
686
  });
670
687
  }
@@ -678,8 +695,10 @@ const hoverSlotProps = computed(() => {
678
695
  const offset = xDisplayOffset.value;
679
696
  const displayX = targetX + offset;
680
697
  let xLabel: string | undefined;
681
- if (props.xTickFormat) {
682
- xLabel = props.xTickFormat(displayX, idx);
698
+ const xf = props.xTickFormat;
699
+ if (xf !== undefined) {
700
+ xLabel =
701
+ typeof xf === "function" ? xf(displayX, idx) : formatNumber(displayX, xf);
683
702
  } else if (!hasExplicitX.value) {
684
703
  xLabel = props.xLabels?.[idx];
685
704
  } else {
@@ -706,10 +725,7 @@ function projectAnnotation(
706
725
  ): { x: number; y: number } | null {
707
726
  if (!isFinite(x) || !isFinite(y)) return null;
708
727
  const internalX = x - xDisplayOffset.value;
709
- const { min, range } = extent.value;
710
- const py =
711
- padding.value.top + innerH.value - (y - min) * (innerH.value / range);
712
- return { x: xPixel(internalX), y: py };
728
+ return { x: xPixel(internalX), y: yPixel(y) };
713
729
  }
714
730
 
715
731
  function indexFromPointer(clientX: number): number | null {
@@ -4,6 +4,7 @@
4
4
  * intersection (e.g. `defineProps<ChartCommonProps & MyExtraProps>()`).
5
5
  */
6
6
 
7
+ import type { NumberFormat } from "@cfasim-ui/shared";
7
8
  import type { ChartAnnotation } from "./annotations.js";
8
9
  import type { ChartPadding } from "./useChartPadding.js";
9
10
 
@@ -30,11 +31,12 @@ export interface ChartCommonProps {
30
31
  /** Boundary for tooltip flip/clamp. Default: `"chart"`. */
31
32
  tooltipClamp?: "none" | "chart" | "window";
32
33
  /**
33
- * Formatter for numeric values shown in the default tooltip. Receives
34
- * the raw value. When omitted, the chart falls back to its value-axis
35
- * tick formatter, then `formatTick`.
34
+ * Formatter for numeric values shown in the default tooltip. Accepts a
35
+ * preset name, a printf-style format string, or a function. When
36
+ * omitted, the chart falls back to its value-axis tick formatter, then
37
+ * `formatTick`. See `formatNumber` in `@cfasim-ui/shared`.
36
38
  */
37
- tooltipValueFormat?: (value: number) => string;
39
+ tooltipValueFormat?: NumberFormat;
38
40
  /**
39
41
  * Custom CSV content (string or function) for the Download CSV menu
40
42
  * item. When omitted, CSV is generated from the chart's series.
@@ -6,6 +6,13 @@ export {
6
6
  type ChartData,
7
7
  } from "./axes.js";
8
8
  export { computeTickValues, type TickValueOptions } from "./computeTicks.js";
9
+ export {
10
+ scaleFraction,
11
+ clampExtentForScale,
12
+ computeLogTickValues,
13
+ LOG_FLOOR,
14
+ type ScaleType,
15
+ } from "./scale.js";
9
16
  export { useChartSize, type ChartSizeOptions } from "./useChartSize.js";
10
17
  export {
11
18
  useChartPadding,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Linear and log scale helpers shared by chart components. The chart
3
+ * computes a `[min, max]` data extent then maps values to pixels via
4
+ * `scaleFraction`; log mode clamps `min` to a positive floor so we
5
+ * never take `log10(0)` or `log10(-x)`.
6
+ */
7
+
8
+ export type ScaleType = "linear" | "log";
9
+
10
+ /** Default floor used when a log-scale extent contains no positive data. */
11
+ export const LOG_FLOOR = 1;
12
+
13
+ /**
14
+ * Project a value onto a [0, 1] fraction of the axis range. The caller
15
+ * multiplies by the pixel range and adds the axis origin. On log
16
+ * scales, non-positive values collapse to the visible minimum so they
17
+ * sit on the axis floor instead of producing -Infinity.
18
+ */
19
+ export function scaleFraction(
20
+ v: number,
21
+ min: number,
22
+ max: number,
23
+ type: ScaleType,
24
+ ): number {
25
+ if (type === "log") {
26
+ const lmin = Math.log10(min);
27
+ const lmax = Math.log10(max);
28
+ const range = lmax - lmin || 1;
29
+ const safe = v > 0 ? v : min;
30
+ return (Math.log10(safe) - lmin) / range;
31
+ }
32
+ const range = max - min || 1;
33
+ return (v - min) / range;
34
+ }
35
+
36
+ /**
37
+ * Clamp the lower bound of a data extent so it's safe for log scale.
38
+ * For linear scales the inputs are returned unchanged. For log scales,
39
+ * `min` is raised to the smallest positive value in the data (or
40
+ * `LOG_FLOOR` when no positive values exist), and `max` is also
41
+ * floored to that value so a degenerate axis still renders.
42
+ */
43
+ export function clampExtentForScale(
44
+ min: number,
45
+ max: number,
46
+ type: ScaleType,
47
+ smallestPositive: number,
48
+ ): { min: number; max: number } {
49
+ if (type !== "log") return { min, max };
50
+ const floor =
51
+ smallestPositive > 0 && isFinite(smallestPositive)
52
+ ? smallestPositive
53
+ : LOG_FLOOR;
54
+ const lo = min > 0 ? min : floor;
55
+ const hi = max > 0 ? Math.max(max, lo) : lo;
56
+ return { min: lo, max: hi };
57
+ }
58
+
59
+ /**
60
+ * Generate tick values for a log axis. By default returns powers of 10
61
+ * inside `[min, max]`. Pass `ticks` as an explicit array to override;
62
+ * numeric `ticks` (linear interval) is ignored on a log axis since it
63
+ * would produce a swarm of densely-packed labels — use an array for
64
+ * non-default tick placement.
65
+ */
66
+ export function computeLogTickValues(opts: {
67
+ min: number;
68
+ max: number;
69
+ ticks?: number | number[];
70
+ }): number[] {
71
+ const { min, max, ticks } = opts;
72
+ if (!(min > 0) || !(max > 0) || min === max) return [];
73
+
74
+ if (Array.isArray(ticks)) {
75
+ return ticks.filter((v) => v > 0 && v >= min && v <= max);
76
+ }
77
+
78
+ const lo = Math.floor(Math.log10(min));
79
+ const hi = Math.ceil(Math.log10(max));
80
+ const out: number[] = [];
81
+ for (let e = lo; e <= hi; e++) {
82
+ const v = Math.pow(10, e);
83
+ if (v >= min && v <= max) out.push(v);
84
+ }
85
+ return out;
86
+ }
@@ -1,4 +1,5 @@
1
1
  import { computed } from "vue";
2
+ import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
2
3
  import { formatTick } from "./axes.js";
3
4
  import { useChartSize } from "./useChartSize.js";
4
5
  import { useChartPadding, type ChartPadding } from "./useChartPadding.js";
@@ -111,14 +112,14 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
111
112
  * `formatTick`. Both chart components use the same precedence order.
112
113
  */
113
114
  export function makeTooltipValueFormatter(
114
- tooltipFormat: () => ((v: number) => string) | undefined,
115
- axisFormat: () => ((v: number) => string) | undefined,
115
+ tooltipFormat: () => NumberFormat | undefined,
116
+ axisFormat: () => NumberFormat | undefined,
116
117
  ): (v: number) => string {
117
118
  return (v: number) => {
118
119
  const tf = tooltipFormat();
119
- if (tf) return tf(v);
120
+ if (tf !== undefined) return formatNumber(v, tf);
120
121
  const af = axisFormat();
121
- if (af) return af(v);
122
+ if (af !== undefined) return formatNumber(v, af);
122
123
  return formatTick(v);
123
124
  };
124
125
  }
@@ -232,12 +232,24 @@ Range mode works with `percent` and `live` as well:
232
232
  </template>
233
233
  </ComponentDemo>
234
234
 
235
- ### Custom slider display
235
+ ### Custom display format
236
236
 
237
- Pass `slider-display` (a `(value: number) => string` function) to format the
238
- thumb labels and the min/max labels however you like. The internal model is
239
- still a number only the displayed text changes. This applies to single
240
- sliders and ranges; the regular text input is unaffected.
237
+ Pass `format` to control how the value is displayed in the text input and
238
+ in slider thumb/min/max labels. Accepts a
239
+ [`NumberFormat`](../charts/data-table.md#columnconfig)a preset name
240
+ (optionally with a `:N` digits suffix, e.g. `"percent:1"`), a printf-style
241
+ format string (`"%.2f"`), or a `(value: number) => string` function. The
242
+ internal model stays a number — only the displayed text changes.
243
+
244
+ When unset, the default formatting follows the `percent` and `decimals`
245
+ props. When set, `format` overrides both. Formats that add suffixes or
246
+ scale the value (e.g. `"percent:1"` → `"12.3%"`) may not round-trip
247
+ through the text input — pair them with `percent: true` for value scaling
248
+ and use `format` for display shaping.
249
+
250
+ The older `slider-display` prop (a `(value: number) => string` function
251
+ that only affected slider thumb/min/max labels) is **deprecated** but
252
+ still honored when `format` is unset. Prefer `format` for new code.
241
253
 
242
254
  <ComponentDemo>
243
255
  <div style="width: 300px">
@@ -247,7 +259,7 @@ sliders and ranges; the regular text input is unaffected.
247
259
  :min="dateStart"
248
260
  :max="dateEnd"
249
261
  :step="dayMs"
250
- :slider-display="formatDate"
262
+ :format="formatDate"
251
263
  />
252
264
  </div>
253
265
 
@@ -270,7 +282,7 @@ const formatDate = (ms) =>
270
282
  :min="dateStart"
271
283
  :max="dateEnd"
272
284
  :step="dayMs"
273
- :slider-display="formatDate"
285
+ :format="formatDate"
274
286
  />
275
287
  ```
276
288
 
@@ -466,5 +478,8 @@ the input visually.
466
478
  | `numberType` | `"integer" \| "float"` | No | — |
467
479
  | `required` | `boolean` | No | — |
468
480
  | `decimals` | `number` | No | — |
481
+ | `percent` | `1"`) may not round-trip through the text input — use
482
+ // `percent: true` for value scaling and `format` for display shaping.
483
+ format?: NumberFormat` | Yes | — |
469
484
  | `sliderDisplay` | `(value: number) =&gt; string` | No | — |
470
485
 
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch, computed, onMounted, getCurrentInstance } from "vue";
3
3
  import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from "reka-ui";
4
+ import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
4
5
  import Hint from "../Hint/Hint.vue";
5
6
 
6
7
  export type NumberRange = [number, number];
@@ -29,9 +30,17 @@ const props = defineProps<{
29
30
  numberType?: "integer" | "float";
30
31
  required?: boolean;
31
32
  decimals?: number;
32
- // Custom formatter for slider thumb labels and min/max labels. Overrides
33
- // the default percent/decimal formatting when provided. Only consulted in
34
- // slider/range mode the text input keeps its own number-shaped formatting.
33
+ // Custom formatter for the displayed value. Accepts a NumberFormat
34
+ // (preset name, printf string, or function) see `formatNumber` in
35
+ // `@cfasim-ui/shared`. When set, overrides the default percent/decimal
36
+ // formatting for both the text input value and slider thumb/min/max
37
+ // labels. The model stays a raw number; only the display changes. Note
38
+ // that formats which add suffixes or scale the value (e.g.
39
+ // `"percent:1"`) may not round-trip through the text input — use
40
+ // `percent: true` for value scaling and `format` for display shaping.
41
+ format?: NumberFormat;
42
+ /** @deprecated Use `format` instead. Still honored for slider labels
43
+ * when `format` is unset, but will be removed in a future release. */
35
44
  sliderDisplay?: (value: number) => string;
36
45
  }>();
37
46
 
@@ -113,7 +122,11 @@ function roundToDecimals(v: number, d: number): number {
113
122
 
114
123
  function formatSliderValue(v: number | undefined) {
115
124
  if (v == null) return "";
116
- if (props.sliderDisplay) return props.sliderDisplay(v);
125
+ // sliderDisplay (deprecated) is a function — i.e. already a valid
126
+ // NumberFormat — so route it through formatNumber. `format` wins when
127
+ // both are set.
128
+ const fmt = props.format ?? props.sliderDisplay;
129
+ if (fmt !== undefined) return formatNumber(v, fmt);
117
130
  const d = displayDecimals.value;
118
131
  if (props.percent) return (v * 100).toFixed(d) + "%";
119
132
  return v.toLocaleString("en-US", {
@@ -149,6 +162,7 @@ function formatWithCommas(v: number | undefined): string {
149
162
 
150
163
  function formatForDisplay(v: number | undefined): string {
151
164
  if (v == null) return "";
165
+ if (props.format !== undefined) return formatNumber(v, props.format);
152
166
  const d = displayDecimals.value;
153
167
  if (d > 0) {
154
168
  return v.toLocaleString("en-US", {
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.7",
2
+ "version": "0.4.8",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,146 @@
1
+ import { sprintf } from "sprintf-js";
2
+
3
+ /**
4
+ * Named number-format presets, modelled on Streamlit's number column formats.
5
+ * - `plain` — `String(value)` (no grouping)
6
+ * - `localized` — `Intl.NumberFormat()` with the user's locale
7
+ * - `percent` — formats as a percent (value `0.5` → `"50%"`)
8
+ * - `compact` — short form (`1.2K`, `3.4M`)
9
+ * - `scientific` — scientific notation (`1.23E4`)
10
+ * - `engineering` — engineering notation (powers of 1000)
11
+ *
12
+ * Presets preserve the raw value's precision by default (no rounding).
13
+ * Append `:N` (a digit) to fix the number of fractional digits, e.g.
14
+ * `"percent:1"` → `"12.3%"`, `"localized:2"` → `"1,234.50"`.
15
+ */
16
+ export type NumberFormatPreset =
17
+ | "plain"
18
+ | "localized"
19
+ | "percent"
20
+ | "compact"
21
+ | "scientific"
22
+ | "engineering";
23
+
24
+ /**
25
+ * A number format specifier:
26
+ * - A {@link NumberFormatPreset} name, optionally with `:N` digits suffix
27
+ * (e.g. `"percent:1"`, `"compact:2"`)
28
+ * - A printf-style format string (must contain a `%` placeholder, parsed
29
+ * by sprintf-js — e.g. `"%.2f"`, `"%05d"`)
30
+ * - A function `(value) => string` for full control
31
+ *
32
+ * Strings that contain no `%` and don't match a preset throw at format
33
+ * time, so typos surface immediately instead of silently rendering wrong.
34
+ */
35
+ export type NumberFormat =
36
+ | NumberFormatPreset
37
+ | string
38
+ | ((value: number) => string);
39
+
40
+ const PRESET_NAMES = new Set<NumberFormatPreset>([
41
+ "plain",
42
+ "localized",
43
+ "percent",
44
+ "compact",
45
+ "scientific",
46
+ "engineering",
47
+ ]);
48
+
49
+ function isPreset(s: string): s is NumberFormatPreset {
50
+ return PRESET_NAMES.has(s as NumberFormatPreset);
51
+ }
52
+
53
+ /**
54
+ * Parse `"preset"` or `"preset:N"` into a name and optional digit count.
55
+ * Returns null if the string isn't a recognized preset.
56
+ */
57
+ function parsePreset(
58
+ s: string,
59
+ ): { preset: NumberFormatPreset; digits: number | undefined } | null {
60
+ const colon = s.indexOf(":");
61
+ if (colon === -1) {
62
+ return isPreset(s) ? { preset: s, digits: undefined } : null;
63
+ }
64
+ const name = s.slice(0, colon);
65
+ const rest = s.slice(colon + 1);
66
+ if (!isPreset(name)) return null;
67
+ // Digits must be a non-negative integer (matches Intl's 0..100 range).
68
+ if (!/^\d+$/.test(rest)) return null;
69
+ const digits = Number(rest);
70
+ if (digits > 100) return null;
71
+ return { preset: name, digits };
72
+ }
73
+
74
+ // Covers JS double-precision (≤17 significant digits). When the caller
75
+ // hasn't asked for a specific digit count, we use this to preserve the
76
+ // raw value rather than letting Intl round to its default precision.
77
+ const RAW_PRECISION_DIGITS = 20;
78
+
79
+ function formatPreset(
80
+ value: number,
81
+ preset: NumberFormatPreset,
82
+ digits: number | undefined,
83
+ ): string {
84
+ const fractionOpts: Intl.NumberFormatOptions =
85
+ digits !== undefined
86
+ ? { minimumFractionDigits: digits, maximumFractionDigits: digits }
87
+ : { maximumFractionDigits: RAW_PRECISION_DIGITS };
88
+ switch (preset) {
89
+ case "plain":
90
+ return digits !== undefined ? value.toFixed(digits) : String(value);
91
+ case "localized":
92
+ return new Intl.NumberFormat(undefined, fractionOpts).format(value);
93
+ case "percent":
94
+ return new Intl.NumberFormat(undefined, {
95
+ style: "percent",
96
+ ...fractionOpts,
97
+ }).format(value);
98
+ case "compact":
99
+ return new Intl.NumberFormat(undefined, {
100
+ notation: "compact",
101
+ ...fractionOpts,
102
+ }).format(value);
103
+ case "scientific":
104
+ return new Intl.NumberFormat(undefined, {
105
+ notation: "scientific",
106
+ ...fractionOpts,
107
+ }).format(value);
108
+ case "engineering":
109
+ return new Intl.NumberFormat(undefined, {
110
+ notation: "engineering",
111
+ ...fractionOpts,
112
+ }).format(value);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Format a number using a preset name, a printf-style format string, or a
118
+ * custom function. Non-finite values (NaN, ±Infinity) are returned as
119
+ * `String(value)`; if `format` is omitted, falls back to `String(value)`.
120
+ *
121
+ * Throws if `format` is a string that's neither a recognized preset (with
122
+ * an optional `:N` digit suffix) nor a printf string containing `%`.
123
+ */
124
+ export function formatNumber(value: number, format?: NumberFormat): string {
125
+ if (!Number.isFinite(value)) return String(value);
126
+ if (format === undefined) return String(value);
127
+ if (typeof format === "function") return format(value);
128
+ // printf strings always contain a `%` placeholder; everything else must
129
+ // be a recognized preset (with an optional `:N` digits suffix).
130
+ if (format.includes("%")) return sprintf(format, value);
131
+ const parsed = parsePreset(format);
132
+ if (!parsed) {
133
+ const names = [...PRESET_NAMES].join(", ");
134
+ throw new Error(
135
+ `formatNumber: invalid format ${JSON.stringify(format)}. ` +
136
+ `Expected one of ${names} (optionally with ":N" digits), ` +
137
+ `a printf format string containing "%", or a function.`,
138
+ );
139
+ }
140
+ return formatPreset(value, parsed.preset, parsed.digits);
141
+ }
142
+
143
+ /** True if `f` is a recognized {@link NumberFormat} value. */
144
+ export function isNumberFormat(f: unknown): f is NumberFormat {
145
+ return typeof f === "string" || typeof f === "function";
146
+ }
package/shared/index.ts CHANGED
@@ -14,6 +14,8 @@ export type {
14
14
  ModelOutputsWire,
15
15
  } from "./ModelOutput.js";
16
16
  export { modelOutputToCSV } from "./csv.js";
17
+ export { formatNumber, isNumberFormat } from "./formatNumber.js";
18
+ export type { NumberFormat, NumberFormatPreset } from "./formatNumber.js";
17
19
  export {
18
20
  useUrlParams,
19
21
  serialize,