@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.
- package/charts/BarChart/BarChart.md +37 -2
- package/charts/BarChart/BarChart.vue +60 -17
- package/charts/ChoroplethMap/ChoroplethMap.md +1 -1
- package/charts/ChoroplethMap/ChoroplethMap.vue +8 -6
- package/charts/DataTable/DataTable.md +48 -0
- package/charts/DataTable/DataTable.vue +28 -1
- package/charts/LineChart/LineChart.md +37 -3
- package/charts/LineChart/LineChart.vue +83 -67
- package/charts/_shared/chartProps.ts +6 -4
- package/charts/_shared/index.ts +7 -0
- package/charts/_shared/scale.ts +86 -0
- package/charts/_shared/useChartFoundation.ts +5 -4
- package/components/NumberInput/NumberInput.md +22 -7
- package/components/NumberInput/NumberInput.vue +18 -4
- package/index.json +1 -1
- package/package.json +1 -1
- package/shared/formatNumber.ts +146 -0
- package/shared/index.ts +2 -0
|
@@ -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<unknown>` | No | — |
|
|
287
321
|
| `tooltipTrigger` | `"hover" \| "click"` | No | — |
|
|
288
322
|
| `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
|
|
289
|
-
| `tooltipValueFormat` | `
|
|
323
|
+
| `tooltipValueFormat` | `NumberFormat` | No | — |
|
|
290
324
|
| `csv` | `string \| (() => 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` | `
|
|
338
|
+
| `valueTickFormat` | `NumberFormat` | No | — |
|
|
304
339
|
| `categoryFormat` | `(label: string, index: number) => 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
|
-
/**
|
|
54
|
-
|
|
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 (
|
|
171
|
-
|
|
172
|
-
|
|
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,
|
|
222
|
-
const
|
|
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 -
|
|
262
|
+
return padding.value.top + innerH.value - frac * innerH.value;
|
|
225
263
|
}
|
|
226
|
-
return padding.value.left +
|
|
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
|
|
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 =
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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` | `
|
|
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.
|
|
133
|
-
*
|
|
134
|
-
* controls the entire tooltip in
|
|
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?:
|
|
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
|
|
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
|
|
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<unknown>` | No | — |
|
|
648
681
|
| `tooltipTrigger` | `"hover" \| "click"` | No | — |
|
|
649
682
|
| `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
|
|
650
|
-
| `tooltipValueFormat` | `
|
|
683
|
+
| `tooltipValueFormat` | `NumberFormat` | No | — |
|
|
651
684
|
| `csv` | `string \| (() => 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) => string` | No | — |
|
|
668
|
-
| `yTickFormat` | `
|
|
701
|
+
| `xTickFormat` | `NumberFormat \| ((value: number, index: number) => 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
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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]))},${
|
|
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]))},${
|
|
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]))},${
|
|
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))},${
|
|
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))},${
|
|
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
|
|
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 =
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
* tick formatter, then
|
|
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?:
|
|
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.
|
package/charts/_shared/index.ts
CHANGED
|
@@ -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: () =>
|
|
115
|
-
axisFormat: () =>
|
|
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
|
|
120
|
+
if (tf !== undefined) return formatNumber(v, tf);
|
|
120
121
|
const af = axisFormat();
|
|
121
|
-
if (af) return
|
|
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
|
|
235
|
+
### Custom display format
|
|
236
236
|
|
|
237
|
-
Pass `
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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) => 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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -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,
|