flexdesk 0.12.0 → 0.13.0
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/README.md +55 -1
- package/css/flexdesk.css +106 -9
- package/css/overrides.css +29 -9
- package/dist/charts.js +4 -73
- package/dist/charts.js.map +3 -3
- package/dist/{chunk-5QPGYHBN.js → chunk-CWPMLX67.js} +2 -2
- package/dist/{chunk-2LEJQD4H.js → chunk-IYMNDPWH.js} +109 -2
- package/dist/chunk-IYMNDPWH.js.map +7 -0
- package/dist/{chunk-6EZPPN4E.js → chunk-NNFTETK3.js} +8 -4
- package/dist/chunk-NNFTETK3.js.map +7 -0
- package/dist/flexdesk.css +106 -9
- package/dist/flow.js +1 -1
- package/dist/tiles.js +1752 -1031
- package/dist/tiles.js.map +4 -4
- package/dist/widgets.js +2 -2
- package/dist/wm.js +23 -5
- package/dist/wm.js.map +2 -2
- package/package.json +1 -1
- package/src/charts/chart_types.js +40 -1
- package/src/tiles/chart_tiles.js +827 -0
- package/src/tiles/tile_grid.js +4 -3
- package/src/tiling/content_registry.js +5 -0
- package/src/tiling/tile_renderer.js +45 -3
- package/src/ui/components/modal.js +4 -2
- package/dist/chunk-2LEJQD4H.js.map +0 -7
- package/dist/chunk-6EZPPN4E.js.map +0 -7
- /package/dist/{chunk-5QPGYHBN.js.map → chunk-CWPMLX67.js.map} +0 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chart_tiles.js — a CHART tile and a KPI tile for a `TileGrid`.
|
|
3
|
+
*
|
|
4
|
+
* Lifted from Tables' dashboard widgets (`tables.chart`, `tables.kpi`), with the
|
|
5
|
+
* Tables half left behind: no binding, no Dataset, no column types, no currency
|
|
6
|
+
* refusals. A tile here is handed ALREADY-COMPUTED series through `setData()`;
|
|
7
|
+
* turning records into series is the embedder's job, because only the embedder
|
|
8
|
+
* knows what a record is.
|
|
9
|
+
*
|
|
10
|
+
* import { registerChartTiles } from '@flexdesk/tiles';
|
|
11
|
+
* registerChartTiles(); // idempotent; nothing registers at import
|
|
12
|
+
* const tile = grid.addTile('flexdesk.chart', {x: 0, y: 0, w: 6, h: 4},
|
|
13
|
+
* { chart_type: 'line', title: 'Burndown', unit: 'pts' });
|
|
14
|
+
* tile.setData({
|
|
15
|
+
* series: [
|
|
16
|
+
* { x: days, y: remaining, name: 'Remaining', interpolation: 'step' },
|
|
17
|
+
* { x: days, y: ideal, name: 'Ideal', lineStyle: 'dashed' },
|
|
18
|
+
* ],
|
|
19
|
+
* markers: [{ x: today, label: 'Today' }],
|
|
20
|
+
* note: 'Points from 41 stories',
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* ══ THE DATA A CHART TILE TAKES ═════════════════════════════════════════
|
|
24
|
+
*
|
|
25
|
+
* { series: [{x, y, name, color?, lineStyle?, interpolation?, line?, customdata?}],
|
|
26
|
+
* markers?: [{x, label?, color?, lineStyle?}], vertical lines (buildOverlayShapes)
|
|
27
|
+
* referenceLines?: [{value, label?, …}], horizontal lines, passed through
|
|
28
|
+
* xKind?: 'date' | 'number' | 'category', inferred from x when absent
|
|
29
|
+
* note?: string } a line under the figure
|
|
30
|
+
*
|
|
31
|
+
* Shorthands: an ARRAY is `{series: array}`; a single `{x, y}`, `{labels, values}`
|
|
32
|
+
* or `{values}` object is one series. Pie, donut and funnel read `labels`/`values`
|
|
33
|
+
* (or `x`/`y`) of the first series; a histogram reads `values` (or `y`/`x`) of
|
|
34
|
+
* each. `line` on a series is merged over the built `trace.line`, so
|
|
35
|
+
* `line: {shape: 'hv', dash: 'dash'}` is as good as `interpolation`/`lineStyle`.
|
|
36
|
+
*
|
|
37
|
+
* ══ WHY THE DRAW HAS ITS OWN TRY/CATCH, AND A SEQUENCE ══════════════════
|
|
38
|
+
*
|
|
39
|
+
* The draw is `await ensurePlotly()` deep, so `render()` returns long before
|
|
40
|
+
* anything can throw. `_draw` therefore reports its own failures (Plotly
|
|
41
|
+
* absent included — a clear error state, never an unhandled rejection), and
|
|
42
|
+
* `_drawSeq` discards a draw that lands after a newer one.
|
|
43
|
+
*
|
|
44
|
+
* EVERY STATE CHANGE INVALIDATES THE FIGURE. `showEmpty`, `showLoading` and
|
|
45
|
+
* `showError` replace the content with `innerHTML`, which detaches the Plotly div
|
|
46
|
+
* without telling Plotly. So each of them here first purges the figure, drops the
|
|
47
|
+
* reference, marks it not drawn, and bumps `_drawSeq` — and a draw still in
|
|
48
|
+
* flight when `setData(empty)` arrives finds itself stale and purges its own
|
|
49
|
+
* figure instead of marking it drawn.
|
|
50
|
+
*
|
|
51
|
+
* `scrollZoom` is off per call: a tile sits on a scrolling board, and the wheel
|
|
52
|
+
* over a chart must scroll past it rather than zoom it.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { TileBase } from './tile_base.js';
|
|
56
|
+
import { registerWidget, getWidget } from './tile_registry.js';
|
|
57
|
+
import {
|
|
58
|
+
buildTrace, buildXAxisLayout, buildYAxisLayout, buildLegendLayout,
|
|
59
|
+
buildHistogramTrace, buildOverlayShapes,
|
|
60
|
+
} from '../charts/chart_types.js';
|
|
61
|
+
import {
|
|
62
|
+
createChart, resize as resizePlot, purge, ensurePlotly, getSeriesColor,
|
|
63
|
+
} from '../charts/plotly_wrapper.js';
|
|
64
|
+
import { strideDownsampleShared } from '../charts/downsample.js';
|
|
65
|
+
|
|
66
|
+
/** Part-of-whole types: one series, no axes. */
|
|
67
|
+
const CATEGORICAL_ONLY = new Set(['pie', 'donut', 'funnel']);
|
|
68
|
+
|
|
69
|
+
/** Every type the chart tile draws. */
|
|
70
|
+
export const CHART_TILE_TYPES = Object.freeze([
|
|
71
|
+
{ value: 'line', label: 'Line' },
|
|
72
|
+
{ value: 'bar', label: 'Bar' },
|
|
73
|
+
{ value: 'bar-stacked', label: 'Bar (stacked)' },
|
|
74
|
+
{ value: 'bar-horizontal', label: 'Bar (horizontal)' },
|
|
75
|
+
{ value: 'area', label: 'Area' },
|
|
76
|
+
{ value: 'area-stacked', label: 'Area (stacked %)' },
|
|
77
|
+
{ value: 'scatter', label: 'Scatter' },
|
|
78
|
+
{ value: 'pie', label: 'Pie' },
|
|
79
|
+
{ value: 'donut', label: 'Donut' },
|
|
80
|
+
{ value: 'funnel', label: 'Funnel' },
|
|
81
|
+
{ value: 'histogram', label: 'Histogram' },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const AXIS_CHARTS = ['bar', 'bar-stacked', 'bar-horizontal', 'line', 'area', 'area-stacked', 'scatter'];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* THE DISPLAY SETTINGS EACH CHART TYPE READS, and the only place that says so:
|
|
88
|
+
* the config schema shows a setting for exactly these types and the draw reads
|
|
89
|
+
* it for no others, so a hidden setting a config still carries cannot go on
|
|
90
|
+
* changing the figure unseen.
|
|
91
|
+
*/
|
|
92
|
+
const READS = Object.freeze({
|
|
93
|
+
legend: new Set([...AXIS_CHARTS, 'pie', 'donut', 'histogram']),
|
|
94
|
+
axis_titles: new Set([...AXIS_CHARTS, 'histogram']),
|
|
95
|
+
y_scale: new Set(['bar', 'bar-horizontal', 'line', 'area', 'scatter']),
|
|
96
|
+
y_range: new Set(['bar', 'bar-stacked', 'bar-horizontal', 'line', 'area', 'scatter']),
|
|
97
|
+
show_markers: new Set(['line', 'area', 'area-stacked']),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** Room for the tile's own header, and none for a Plotly title never set. */
|
|
101
|
+
const TILE_MARGIN = { l: 48, r: 16, t: 8, b: 36 };
|
|
102
|
+
|
|
103
|
+
/** The point count above which a continuous series is decimated — a rendering
|
|
104
|
+
* budget, not a data limit. */
|
|
105
|
+
const MAX_POINTS = 2000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Normalise what `setData` was handed into `{series, markers, referenceLines,
|
|
109
|
+
* xKind, note}`, or `null` when there is nothing to draw. Exported for tests
|
|
110
|
+
* and for an embedder that wants to know what the tile will make of its data.
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeChartData(data) {
|
|
113
|
+
if (data == null) return null;
|
|
114
|
+
let d = data;
|
|
115
|
+
if (Array.isArray(d)) d = { series: d };
|
|
116
|
+
else if (typeof d !== 'object') return null;
|
|
117
|
+
else if (!Array.isArray(d.series)) {
|
|
118
|
+
const single = ['x', 'y', 'labels', 'values'].some((k) => Array.isArray(d[k]));
|
|
119
|
+
d = { ...d, series: single ? [d] : [] };
|
|
120
|
+
}
|
|
121
|
+
const series = d.series.filter((s) => s && pointCount(s) > 0);
|
|
122
|
+
if (!series.length) return null;
|
|
123
|
+
return {
|
|
124
|
+
series,
|
|
125
|
+
markers: Array.isArray(d.markers) ? d.markers : [],
|
|
126
|
+
referenceLines: Array.isArray(d.referenceLines) ? d.referenceLines : [],
|
|
127
|
+
xKind: d.xKind || null,
|
|
128
|
+
note: typeof d.note === 'string' ? d.note : '',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pointCount(s) {
|
|
133
|
+
for (const k of ['y', 'values', 'x', 'labels']) {
|
|
134
|
+
if (Array.isArray(s[k])) return s[k].length;
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class ChartTile extends TileBase {
|
|
140
|
+
static TYPE = 'flexdesk.chart';
|
|
141
|
+
static TITLE = 'Chart';
|
|
142
|
+
static ICON = 'insert_chart';
|
|
143
|
+
static DESCRIPTION = 'Line, bar, area, pie, donut, funnel or histogram over series you supply.';
|
|
144
|
+
static DEFAULT_SIZE = { w: 6, h: 4 };
|
|
145
|
+
static SIZE_CONSTRAINTS = { minW: 3, minH: 3, maxW: 12, maxH: 10 };
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {Object} options - TileBase's, plus:
|
|
149
|
+
* @param {Function} [options.onPointClick] - `({customdata, point, tile})`
|
|
150
|
+
* on a click on a mark. Also assignable later as `tile.onPointClick`.
|
|
151
|
+
*/
|
|
152
|
+
constructor(options = {}) {
|
|
153
|
+
super(options);
|
|
154
|
+
this.onPointClick = typeof options.onPointClick === 'function' ? options.onPointClick : null;
|
|
155
|
+
this._plotEl = null;
|
|
156
|
+
this._drawn = false;
|
|
157
|
+
this._drawSeq = 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Pure and static-only — it runs inside `TileBase`'s constructor. */
|
|
161
|
+
getDefaultConfig() {
|
|
162
|
+
return {
|
|
163
|
+
title: '',
|
|
164
|
+
chart_type: 'line',
|
|
165
|
+
unit: '',
|
|
166
|
+
legend: 'bottom',
|
|
167
|
+
x_title: '',
|
|
168
|
+
y_title: '',
|
|
169
|
+
y_scale: 'linear',
|
|
170
|
+
y_min: null,
|
|
171
|
+
y_max: null,
|
|
172
|
+
show_markers: false,
|
|
173
|
+
max_points: MAX_POINTS,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
getConfigSchema() {
|
|
178
|
+
return {
|
|
179
|
+
fields: [
|
|
180
|
+
{ name: 'title', type: 'text', label: 'Tile title' },
|
|
181
|
+
{ name: 'chart_type', type: 'select', label: 'Chart type',
|
|
182
|
+
options: CHART_TILE_TYPES.map((o) => ({ ...o })) },
|
|
183
|
+
{ name: 'unit', type: 'text', label: 'Unit',
|
|
184
|
+
help: 'Shown with the value axis title, e.g. "pts" or "h".' },
|
|
185
|
+
{ name: 'legend', type: 'select', label: 'Legend',
|
|
186
|
+
visibleWhen: { field: 'chart_type', in: [...READS.legend] },
|
|
187
|
+
options: [
|
|
188
|
+
{ value: 'bottom', label: 'Below' },
|
|
189
|
+
{ value: 'top', label: 'Above' },
|
|
190
|
+
{ value: 'right', label: 'Right' },
|
|
191
|
+
{ value: 'left', label: 'Left' },
|
|
192
|
+
{ value: 'hidden', label: 'None' },
|
|
193
|
+
] },
|
|
194
|
+
{ name: 'x_title', type: 'text', label: 'X axis title',
|
|
195
|
+
visibleWhen: { field: 'chart_type', in: [...READS.axis_titles] } },
|
|
196
|
+
{ name: 'y_title', type: 'text', label: 'Y axis title',
|
|
197
|
+
visibleWhen: { field: 'chart_type', in: [...READS.axis_titles] } },
|
|
198
|
+
{ name: 'y_scale', type: 'select', label: 'Y scale',
|
|
199
|
+
visibleWhen: { field: 'chart_type', in: [...READS.y_scale] },
|
|
200
|
+
options: [{ value: 'linear', label: 'Linear' }, { value: 'log', label: 'Logarithmic' }] },
|
|
201
|
+
{ name: 'y_min', type: 'number', label: 'Y minimum',
|
|
202
|
+
visibleWhen: { field: 'chart_type', in: [...READS.y_range] } },
|
|
203
|
+
{ name: 'y_max', type: 'number', label: 'Y maximum',
|
|
204
|
+
visibleWhen: { field: 'chart_type', in: [...READS.y_range] } },
|
|
205
|
+
{ name: 'show_markers', type: 'checkbox', label: 'Show point markers',
|
|
206
|
+
visibleWhen: { field: 'chart_type', in: [...READS.show_markers] } },
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
mount(container) {
|
|
212
|
+
super.mount(container);
|
|
213
|
+
applyTitle(this);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `null` here is "the grid has no data", not "clear this tile": TileGrid's
|
|
218
|
+
* config save calls `update(grid.data, newConfig)`, and a grid whose tiles
|
|
219
|
+
* are fed one by one through `setData` has `grid.data === null` — passed on,
|
|
220
|
+
* it would drop the tile's series and skip the redraw in the new chart type.
|
|
221
|
+
* `setData(null)` is how a tile is cleared.
|
|
222
|
+
*/
|
|
223
|
+
update(data, config) {
|
|
224
|
+
super.update(data === null ? undefined : data, config);
|
|
225
|
+
if (config) applyTitle(this);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Hand this tile its series. Unlike `update()`, an empty or null value is
|
|
230
|
+
* drawn — as the empty state — rather than ignored.
|
|
231
|
+
*/
|
|
232
|
+
setData(data) {
|
|
233
|
+
if (this._disposed) return;
|
|
234
|
+
this.data = data ?? null;
|
|
235
|
+
if (this.contentElement) this.render(this.data);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Kick off the draw and return. Deliberately not `async`: nothing awaits
|
|
240
|
+
* `render`, so a returned promise would be a rejection nobody catches.
|
|
241
|
+
*/
|
|
242
|
+
render(data) {
|
|
243
|
+
this._draw(data);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One relayout when a resize settles; nothing while a draw is in flight. */
|
|
247
|
+
onResize() {
|
|
248
|
+
if (!this._plotEl || !this._drawn) return;
|
|
249
|
+
resizePlot(this._plotEl).catch(() => {});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
menuItems() {
|
|
253
|
+
return [{ action: 'download-png', label: 'Download PNG', icon: 'image' }];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_onMenuAction(action) {
|
|
257
|
+
if (action === 'download-png') this._downloadPng();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── the three states: each one invalidates the figure ───────────────
|
|
261
|
+
|
|
262
|
+
showLoading() {
|
|
263
|
+
this._invalidate();
|
|
264
|
+
super.showLoading();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
showEmpty(message) {
|
|
268
|
+
this._invalidate();
|
|
269
|
+
super.showEmpty(message);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
showError(message) {
|
|
273
|
+
this._invalidate();
|
|
274
|
+
super.showError(message);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Release the figure BEFORE the element goes — `TileBase.dispose` nulls it. */
|
|
278
|
+
dispose() {
|
|
279
|
+
this._invalidate();
|
|
280
|
+
super.dispose();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Any draw in flight is now stale, and the figure on screen is released. */
|
|
284
|
+
_invalidate() {
|
|
285
|
+
this._drawSeq += 1;
|
|
286
|
+
this._releasePlot();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Hand the current figure back to Plotly and forget it. Idempotent, and
|
|
290
|
+
* not awaited: a rejected purge (Plotly absent, already purged) must not
|
|
291
|
+
* surface as an unhandled rejection. */
|
|
292
|
+
_releasePlot() {
|
|
293
|
+
const el = this._plotEl;
|
|
294
|
+
this._plotEl = null;
|
|
295
|
+
this._drawn = false;
|
|
296
|
+
if (el) purge(el).catch(() => {});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── the draw ────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
async _draw(data) {
|
|
302
|
+
this._drawSeq += 1;
|
|
303
|
+
const seq = this._drawSeq;
|
|
304
|
+
const content = this.contentElement;
|
|
305
|
+
if (!content) return;
|
|
306
|
+
|
|
307
|
+
// Every path below replaces the content, so the figure there now is
|
|
308
|
+
// about to be orphaned whichever way this goes.
|
|
309
|
+
this._releasePlot();
|
|
310
|
+
|
|
311
|
+
const norm = normalizeChartData(data);
|
|
312
|
+
if (!norm) { this.showEmpty('No data to show.'); return; }
|
|
313
|
+
|
|
314
|
+
let plan;
|
|
315
|
+
try {
|
|
316
|
+
plan = this._plan(norm);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
this._failed(err);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!plan) return; // `_plan` has already said why
|
|
322
|
+
|
|
323
|
+
const host = document.createElement('div');
|
|
324
|
+
host.className = 'fd-chart-tile';
|
|
325
|
+
const parts = [host];
|
|
326
|
+
if (norm.note) {
|
|
327
|
+
const note = document.createElement('div');
|
|
328
|
+
note.className = 'fd-chart-tile__note';
|
|
329
|
+
note.setAttribute('role', 'note');
|
|
330
|
+
note.textContent = norm.note;
|
|
331
|
+
note.title = norm.note;
|
|
332
|
+
parts.push(note);
|
|
333
|
+
}
|
|
334
|
+
content.replaceChildren(...parts);
|
|
335
|
+
this._plotEl = host;
|
|
336
|
+
this._drawn = false;
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await createChart(host, plan.traces, plan.layout, {
|
|
340
|
+
scrollZoom: false,
|
|
341
|
+
displayModeBar: false,
|
|
342
|
+
responsive: true,
|
|
343
|
+
});
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (this._disposed || seq !== this._drawSeq) return;
|
|
346
|
+
this._failed(err);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (this._disposed || seq !== this._drawSeq) {
|
|
350
|
+
// A newer draw or a state change has already replaced the content;
|
|
351
|
+
// this figure is detached, and purging it releases its listeners.
|
|
352
|
+
purge(host).catch(() => {});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this._drawn = true;
|
|
356
|
+
this._wireClick(host);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Plotly's click, reduced to the point's own `customdata`. A pie's point
|
|
360
|
+
* carries its slice index as `pointNumber` (older builds: `pointIndex`),
|
|
361
|
+
* and its `customdata` is read off the trace by that index. */
|
|
362
|
+
_wireClick(host) {
|
|
363
|
+
if (typeof host.on !== 'function') return;
|
|
364
|
+
host.on('plotly_click', (ev) => {
|
|
365
|
+
const point = ev?.points?.[0];
|
|
366
|
+
if (!point || typeof this.onPointClick !== 'function') return;
|
|
367
|
+
const trace = point.data || {};
|
|
368
|
+
let customdata = point.customdata;
|
|
369
|
+
if (trace.type === 'pie' || customdata === undefined) {
|
|
370
|
+
const i = point.pointNumber ?? point.pointIndex;
|
|
371
|
+
if (Array.isArray(trace.customdata) && i != null) customdata = trace.customdata[i];
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
this.onPointClick({ customdata, point, tile: this });
|
|
375
|
+
} catch (err) {
|
|
376
|
+
console.error('[ChartTile] onPointClick threw:', err);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Turn normalised data into `{traces, layout}`, or put the tile in the
|
|
383
|
+
* empty state and answer `null`.
|
|
384
|
+
*/
|
|
385
|
+
_plan(norm) {
|
|
386
|
+
const type = this.config.chart_type || 'line';
|
|
387
|
+
const { series } = norm;
|
|
388
|
+
|
|
389
|
+
if (CATEGORICAL_ONLY.has(type)) {
|
|
390
|
+
// ONE series. A pie of three is three pies drawn on top of one another.
|
|
391
|
+
const s = series[0];
|
|
392
|
+
const labels = s.labels ?? s.x ?? [];
|
|
393
|
+
const values = s.values ?? s.y ?? [];
|
|
394
|
+
const trace = buildTrace({
|
|
395
|
+
chartType: type,
|
|
396
|
+
x: labels.map((v) => (v === null || v === undefined ? '—' : String(v))),
|
|
397
|
+
y: values,
|
|
398
|
+
name: s.name || '',
|
|
399
|
+
colors: s.colors || null,
|
|
400
|
+
});
|
|
401
|
+
if (Array.isArray(s.customdata)) trace.customdata = s.customdata;
|
|
402
|
+
return {
|
|
403
|
+
traces: [trace],
|
|
404
|
+
layout: {
|
|
405
|
+
margin: type === 'funnel' ? { ...TILE_MARGIN, l: 96 } : TILE_MARGIN,
|
|
406
|
+
...this._legend(),
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (type === 'histogram') {
|
|
412
|
+
const traces = series.map((s, i) => {
|
|
413
|
+
const raw = s.values ?? s.y ?? s.x ?? [];
|
|
414
|
+
const trace = {
|
|
415
|
+
...buildHistogramTrace({
|
|
416
|
+
x: raw.map(toNumber).filter((v) => v !== null),
|
|
417
|
+
color: s.color || getSeriesColor(i),
|
|
418
|
+
name: s.name || `Series ${i + 1}`,
|
|
419
|
+
...(s.nbins ? { nbins: s.nbins } : {}),
|
|
420
|
+
}),
|
|
421
|
+
opacity: series.length > 1 ? 0.7 : 1,
|
|
422
|
+
};
|
|
423
|
+
return trace;
|
|
424
|
+
});
|
|
425
|
+
return {
|
|
426
|
+
traces,
|
|
427
|
+
layout: {
|
|
428
|
+
margin: TILE_MARGIN, ...this._legend(), barmode: 'overlay',
|
|
429
|
+
xaxis: buildXAxisLayout({ title: this.config.x_title || '' }),
|
|
430
|
+
yaxis: buildYAxisLayout({ axisId: 'yaxis', title: this.config.y_title || 'Count' }),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const kind = norm.xKind || inferXKind(series);
|
|
436
|
+
const base = type.startsWith('bar') ? 'bar' : type;
|
|
437
|
+
const target = Number(this.config.max_points) || MAX_POINTS;
|
|
438
|
+
const showMarkers = READS.show_markers.has(type) && this.config.show_markers === true;
|
|
439
|
+
|
|
440
|
+
const traces = series.map((s, i) => {
|
|
441
|
+
let x = Array.isArray(s.x) ? s.x : (s.y || []).map((_, j) => j);
|
|
442
|
+
let y = Array.isArray(s.y) ? s.y : (s.values || []);
|
|
443
|
+
let customdata = Array.isArray(s.customdata) ? s.customdata : null;
|
|
444
|
+
if (kind !== 'category') {
|
|
445
|
+
const cut = strideDownsampleShared(x, customdata ? { y, customdata } : { y }, target);
|
|
446
|
+
x = cut.time;
|
|
447
|
+
y = cut.series.y;
|
|
448
|
+
if (customdata) customdata = cut.series.customdata;
|
|
449
|
+
}
|
|
450
|
+
const trace = buildTrace({
|
|
451
|
+
chartType: base,
|
|
452
|
+
x,
|
|
453
|
+
y,
|
|
454
|
+
name: s.name || `Series ${i + 1}`,
|
|
455
|
+
color: s.color || null,
|
|
456
|
+
lineStyle: s.lineStyle || 'solid',
|
|
457
|
+
interpolation: s.interpolation || 'linear',
|
|
458
|
+
...(s.lineWidth ? { lineWidth: s.lineWidth } : {}),
|
|
459
|
+
seriesIndex: i,
|
|
460
|
+
showMarkers,
|
|
461
|
+
});
|
|
462
|
+
if (s.line && typeof s.line === 'object') trace.line = { ...(trace.line || {}), ...s.line };
|
|
463
|
+
if (customdata) trace.customdata = customdata;
|
|
464
|
+
return trace;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const xaxis = buildXAxisLayout({ title: this.config.x_title || '', scaleType: 'linear' });
|
|
468
|
+
if (kind !== 'number') xaxis.type = kind === 'date' ? 'date' : 'category';
|
|
469
|
+
|
|
470
|
+
const log = READS.y_scale.has(type) && this.config.y_scale === 'log';
|
|
471
|
+
const yaxis = buildYAxisLayout({
|
|
472
|
+
axisId: 'yaxis',
|
|
473
|
+
title: this._valueTitle(series),
|
|
474
|
+
scaleType: log ? 'log' : 'linear',
|
|
475
|
+
min: READS.y_range.has(type) ? axisBound(this.config.y_min, log) : null,
|
|
476
|
+
max: READS.y_range.has(type) ? axisBound(this.config.y_max, log) : null,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const layout = { margin: TILE_MARGIN, xaxis, yaxis, ...this._legend() };
|
|
480
|
+
if (type === 'bar') layout.barmode = 'group';
|
|
481
|
+
if (type === 'bar-stacked') layout.barmode = 'stack';
|
|
482
|
+
if (type === 'bar-horizontal') {
|
|
483
|
+
// Swap the DATA and the AXES together; swapping one draws NaN.
|
|
484
|
+
layout.barmode = 'group';
|
|
485
|
+
for (const t of traces) { [t.x, t.y] = [t.y, t.x]; t.orientation = 'h'; }
|
|
486
|
+
[layout.xaxis, layout.yaxis] = [yaxis, xaxis];
|
|
487
|
+
layout.margin = { ...TILE_MARGIN, l: 96 };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const overlay = buildOverlayShapes({
|
|
491
|
+
verticalLines: type === 'bar-horizontal' ? [] : norm.markers,
|
|
492
|
+
referenceLines: norm.referenceLines,
|
|
493
|
+
});
|
|
494
|
+
if (overlay.shapes.length) layout.shapes = overlay.shapes;
|
|
495
|
+
if (overlay.annotations.length) layout.annotations = overlay.annotations;
|
|
496
|
+
return { traces, layout };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The value axis title: the configured one, else the single series' name,
|
|
500
|
+
* with the unit beside it. */
|
|
501
|
+
_valueTitle(series) {
|
|
502
|
+
const unit = (this.config.unit || '').trim();
|
|
503
|
+
const base = this.config.y_title || (series.length === 1 ? (series[0].name || '') : '');
|
|
504
|
+
if (!unit) return base;
|
|
505
|
+
return base ? `${base} (${unit})` : unit;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* `showlegend` and `legend`, lifted out by name: `buildLegendLayout`'s
|
|
510
|
+
* top-level `font` would replace the theme's whole font object. Below the
|
|
511
|
+
* plot it is anchored to the CONTAINER, so the axis title sits above it
|
|
512
|
+
* however tall the tick labels are.
|
|
513
|
+
*/
|
|
514
|
+
_legend() {
|
|
515
|
+
if (!READS.legend.has(this.config.chart_type || 'line')) return { showlegend: false };
|
|
516
|
+
const position = this.config.legend || 'bottom';
|
|
517
|
+
const spec = buildLegendLayout(position);
|
|
518
|
+
const out = { showlegend: spec.showlegend === true };
|
|
519
|
+
if (spec.legend) out.legend = spec.legend;
|
|
520
|
+
if (position === 'bottom' && out.legend) {
|
|
521
|
+
out.legend = { ...out.legend, yref: 'container', y: 0, yanchor: 'bottom' };
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async _downloadPng() {
|
|
527
|
+
if (!this._plotEl || !this._drawn) return;
|
|
528
|
+
try {
|
|
529
|
+
const P = await ensurePlotly();
|
|
530
|
+
const uri = await P.toImage(this._plotEl, { format: 'png', scale: 2 });
|
|
531
|
+
const a = document.createElement('a');
|
|
532
|
+
a.href = uri;
|
|
533
|
+
a.download = `${slug(this.config.title || this.constructor.TITLE)}.png`;
|
|
534
|
+
a.click();
|
|
535
|
+
} catch (err) {
|
|
536
|
+
this._notify('Download failed', `The image could not be made: ${err?.message || err}`, 'error');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
_failed(err) {
|
|
541
|
+
if (this._disposed) return;
|
|
542
|
+
this.showError(`This chart could not be drawn: ${err?.message || err}`);
|
|
543
|
+
this.eventBus?.emit?.('tile:render-failed', {
|
|
544
|
+
tileId: this.id,
|
|
545
|
+
message: err?.message || String(err),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* ONE NUMBER, AND WHAT IT IS DOING. Plain DOM, no Plotly: a KPI strip is the
|
|
552
|
+
* common board, and it should not pay 4 MB for three divs — nor wait for them.
|
|
553
|
+
* It therefore renders synchronously and renders without Plotly loaded at all.
|
|
554
|
+
*
|
|
555
|
+
* tile.setData({ value: 42, reference: 37, label: 'Open bugs', note: 'vs last week' })
|
|
556
|
+
*
|
|
557
|
+
* A bare number is `{value}`. The delta's ARROW states the fact (up or down);
|
|
558
|
+
* `invert_delta` flips only the colour's judgement, so a falling cost is a
|
|
559
|
+
* green ▼. The sign is in the text too, because colour alone does not survive
|
|
560
|
+
* a monochrome display.
|
|
561
|
+
*/
|
|
562
|
+
export class KpiTile extends TileBase {
|
|
563
|
+
static TYPE = 'flexdesk.kpi';
|
|
564
|
+
static TITLE = 'KPI';
|
|
565
|
+
static ICON = 'speed';
|
|
566
|
+
static DESCRIPTION = 'One number with a label and an optional comparison.';
|
|
567
|
+
static DEFAULT_SIZE = { w: 3, h: 2 };
|
|
568
|
+
static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 6 };
|
|
569
|
+
|
|
570
|
+
getDefaultConfig() {
|
|
571
|
+
return {
|
|
572
|
+
title: '',
|
|
573
|
+
label: '',
|
|
574
|
+
unit: '',
|
|
575
|
+
format: 'number',
|
|
576
|
+
decimals: null,
|
|
577
|
+
prefix: '',
|
|
578
|
+
suffix: '',
|
|
579
|
+
currency: null,
|
|
580
|
+
delta_relative: false,
|
|
581
|
+
invert_delta: false,
|
|
582
|
+
foot: '',
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
getConfigSchema() {
|
|
587
|
+
return {
|
|
588
|
+
fields: [
|
|
589
|
+
{ name: 'title', type: 'text', label: 'Tile title' },
|
|
590
|
+
{ name: 'label', type: 'text', label: 'Label' },
|
|
591
|
+
{ name: 'unit', type: 'text', label: 'Unit', help: 'Shown after the number, e.g. "pts".' },
|
|
592
|
+
{ name: 'format', type: 'select', label: 'Format',
|
|
593
|
+
options: [
|
|
594
|
+
{ value: 'number', label: 'Number' },
|
|
595
|
+
{ value: 'percent', label: 'Percentage (of a ratio)' },
|
|
596
|
+
{ value: 'currency', label: 'Currency' },
|
|
597
|
+
{ value: 'duration', label: 'Duration (seconds)' },
|
|
598
|
+
{ value: 'text', label: 'Text' },
|
|
599
|
+
] },
|
|
600
|
+
{ name: 'decimals', type: 'number', label: 'Decimals', min: 0, max: 6 },
|
|
601
|
+
{ name: 'currency', type: 'text', label: 'Currency code',
|
|
602
|
+
help: 'ISO 4217, e.g. EUR. Left empty, the number is shown without a symbol.' },
|
|
603
|
+
{ name: 'prefix', type: 'text', label: 'Prefix' },
|
|
604
|
+
{ name: 'suffix', type: 'text', label: 'Suffix' },
|
|
605
|
+
{ name: 'delta_relative', type: 'checkbox', label: 'Show the change as a percentage' },
|
|
606
|
+
{ name: 'invert_delta', type: 'checkbox', label: 'A fall is good',
|
|
607
|
+
help: 'For a cost, a queue length or a latency: a smaller number is the better one.' },
|
|
608
|
+
{ name: 'foot', type: 'text', label: 'Caption' },
|
|
609
|
+
],
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
mount(container) {
|
|
614
|
+
super.mount(container);
|
|
615
|
+
applyTitle(this);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** `null` is "the grid has no data" — see `ChartTile.update`. And a value of
|
|
619
|
+
* `0` is a value: `TileBase.update` re-renders truthy data only. */
|
|
620
|
+
update(data, config) {
|
|
621
|
+
if (this._disposed) return;
|
|
622
|
+
super.update(data === null ? undefined : data, config);
|
|
623
|
+
if (config) applyTitle(this);
|
|
624
|
+
const d = this.data;
|
|
625
|
+
if (d !== null && d !== undefined && !d && this.contentElement) this.render(d);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** `{value, reference?, label?, note?}` or a bare number; null is the empty state. */
|
|
629
|
+
setData(data) {
|
|
630
|
+
if (this._disposed) return;
|
|
631
|
+
this.data = data ?? null;
|
|
632
|
+
if (this.contentElement) this.render(this.data);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
render(data) {
|
|
636
|
+
const content = this.contentElement;
|
|
637
|
+
if (!content) return;
|
|
638
|
+
const d = typeof data === 'number' ? { value: data } : data;
|
|
639
|
+
if (!d || typeof d !== 'object' || d.value === null || d.value === undefined || d.value === '') {
|
|
640
|
+
this.showEmpty('No value to show.');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
content.replaceChildren();
|
|
645
|
+
const box = document.createElement('div');
|
|
646
|
+
box.className = 'fd-kpi';
|
|
647
|
+
|
|
648
|
+
const labelText = d.label || this.config.label || '';
|
|
649
|
+
if (labelText) {
|
|
650
|
+
const label = document.createElement('div');
|
|
651
|
+
label.className = 'fd-kpi__label';
|
|
652
|
+
label.textContent = labelText;
|
|
653
|
+
label.title = labelText;
|
|
654
|
+
box.append(label);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const big = document.createElement('div');
|
|
658
|
+
big.className = 'fd-kpi__value';
|
|
659
|
+
big.textContent = this._format(d.value, true);
|
|
660
|
+
big.title = String(d.value);
|
|
661
|
+
box.append(big);
|
|
662
|
+
|
|
663
|
+
const delta = this._buildDelta(d.value, d.reference);
|
|
664
|
+
if (delta) box.append(delta);
|
|
665
|
+
|
|
666
|
+
const foot = [this.config.foot, d.note].filter(Boolean).join(' · ');
|
|
667
|
+
if (foot) {
|
|
668
|
+
const el = document.createElement('div');
|
|
669
|
+
el.className = 'fd-kpi__foot';
|
|
670
|
+
el.textContent = foot;
|
|
671
|
+
el.title = foot;
|
|
672
|
+
box.append(el);
|
|
673
|
+
}
|
|
674
|
+
content.append(box);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** The comparison line, or `null` when there is nothing to compare against.
|
|
678
|
+
* `0` is a real reference — the test is finiteness, never truthiness. */
|
|
679
|
+
_buildDelta(rawValue, rawReference) {
|
|
680
|
+
const value = Number(rawValue);
|
|
681
|
+
if (rawReference === null || rawReference === undefined || rawReference === '') return null;
|
|
682
|
+
const reference = Number(rawReference);
|
|
683
|
+
if (!Number.isFinite(value) || !Number.isFinite(reference)) return null;
|
|
684
|
+
|
|
685
|
+
const change = value - reference;
|
|
686
|
+
const el = document.createElement('div');
|
|
687
|
+
el.className = 'fd-kpi__delta';
|
|
688
|
+
// No change gets neither colour: a flat week is not a good one.
|
|
689
|
+
if (change > 0) el.classList.add(this.config.invert_delta ? 'fd-kpi__delta--down' : 'fd-kpi__delta--up');
|
|
690
|
+
else if (change < 0) el.classList.add(this.config.invert_delta ? 'fd-kpi__delta--up' : 'fd-kpi__delta--down');
|
|
691
|
+
|
|
692
|
+
const arrow = change > 0 ? '▲' : change < 0 ? '▼' : '–';
|
|
693
|
+
const magnitude = this.config.delta_relative
|
|
694
|
+
? (reference === 0 ? null : formatValue(Math.abs(change / reference), {
|
|
695
|
+
format: 'percent', decimals: this.config.decimals ?? 1 }))
|
|
696
|
+
: this._format(Math.abs(change), false);
|
|
697
|
+
const sign = change > 0 ? '+' : change < 0 ? '-' : '';
|
|
698
|
+
el.textContent = magnitude === null ? `${arrow} —` : `${arrow} ${sign}${magnitude}`;
|
|
699
|
+
el.title = `Compared with ${this._format(reference, false)}`;
|
|
700
|
+
return el;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
_format(value, withAffixes) {
|
|
704
|
+
const format = this.config.format || 'number';
|
|
705
|
+
const body = formatValue(value, {
|
|
706
|
+
format: format === 'text' && !withAffixes ? 'number' : format,
|
|
707
|
+
decimals: this.config.decimals,
|
|
708
|
+
currency: this.config.currency,
|
|
709
|
+
prefix: withAffixes ? this.config.prefix : '',
|
|
710
|
+
suffix: withAffixes ? this.config.suffix : '',
|
|
711
|
+
});
|
|
712
|
+
const unit = (this.config.unit || '').trim();
|
|
713
|
+
return unit && body !== '—' ? `${body} ${unit}` : body;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Register both tiles. Idempotent: a second call registers nothing new and
|
|
719
|
+
* replaces nothing — and nothing registers at import time, so an app that
|
|
720
|
+
* imports `@flexdesk/tiles` without wanting these never sees them in its
|
|
721
|
+
* Add-widget catalogue.
|
|
722
|
+
*/
|
|
723
|
+
export function registerChartTiles() {
|
|
724
|
+
for (const Tile of [ChartTile, KpiTile]) {
|
|
725
|
+
if (getWidget(Tile.TYPE) !== Tile) registerWidget(Tile);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ── helpers ─────────────────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
/** The header shows `config.title`, else the type's own title. */
|
|
732
|
+
function applyTitle(tile) {
|
|
733
|
+
tile.setTitle(tile.config.title || tile.constructor.TITLE);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** `'date' | 'number' | 'category'` from the x values themselves. */
|
|
737
|
+
function inferXKind(series) {
|
|
738
|
+
let sawAny = false;
|
|
739
|
+
let allNumbers = true;
|
|
740
|
+
let allDates = true;
|
|
741
|
+
for (const s of series) {
|
|
742
|
+
for (const v of (Array.isArray(s.x) ? s.x : [])) {
|
|
743
|
+
if (v === null || v === undefined) continue;
|
|
744
|
+
sawAny = true;
|
|
745
|
+
if (typeof v !== 'number') allNumbers = false;
|
|
746
|
+
if (!(v instanceof Date) && !(typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v))) allDates = false;
|
|
747
|
+
if (!allNumbers && !allDates) return 'category';
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (!sawAny || allNumbers) return 'number';
|
|
751
|
+
return allDates ? 'date' : 'category';
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** A value as a number, or `null` — never `NaN`. */
|
|
755
|
+
function toNumber(value) {
|
|
756
|
+
if (value === null || value === undefined || value === '') return null;
|
|
757
|
+
const n = Number(value);
|
|
758
|
+
return Number.isFinite(n) ? n : null;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** A Y bound as the axis reads it: on a log axis Plotly reads `range` in log₁₀
|
|
762
|
+
* units, and a non-positive bound has no place there at all. */
|
|
763
|
+
function axisBound(value, log) {
|
|
764
|
+
const n = toNumber(value);
|
|
765
|
+
if (n === null || !log) return n;
|
|
766
|
+
return n > 0 ? Math.log10(n) : null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function fractionDigits(decimals) {
|
|
770
|
+
const d = Math.max(0, Math.min(20, Number(decimals) || 0));
|
|
771
|
+
return { minimumFractionDigits: d, maximumFractionDigits: d };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Seconds as `1d 4h`, `4h 12m`, `12m 30s`, `30s` — two units, never three. */
|
|
775
|
+
function formatDuration(seconds) {
|
|
776
|
+
if (!Number.isFinite(seconds)) return '—';
|
|
777
|
+
const sign = seconds < 0 ? '-' : '';
|
|
778
|
+
let rest = Math.abs(Math.round(seconds));
|
|
779
|
+
const days = Math.floor(rest / 86400); rest -= days * 86400;
|
|
780
|
+
const hours = Math.floor(rest / 3600); rest -= hours * 3600;
|
|
781
|
+
const minutes = Math.floor(rest / 60);
|
|
782
|
+
const secs = rest - minutes * 60;
|
|
783
|
+
if (days) return `${sign}${days}d ${hours}h`;
|
|
784
|
+
if (hours) return `${sign}${hours}h ${minutes}m`;
|
|
785
|
+
if (minutes) return `${sign}${minutes}m ${secs}s`;
|
|
786
|
+
return `${sign}${secs}s`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The KPI's formatter. `currency` has NO default: a symbol nobody chose is a
|
|
791
|
+
* claim about money that may be false. `percent` takes a RATIO (0.42 → 42%).
|
|
792
|
+
*/
|
|
793
|
+
function formatValue(value, { format = 'number', decimals = null, prefix = '', suffix = '',
|
|
794
|
+
currency = null } = {}) {
|
|
795
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
796
|
+
const n = Number(value);
|
|
797
|
+
const body = (() => {
|
|
798
|
+
switch (format) {
|
|
799
|
+
case 'number':
|
|
800
|
+
if (!Number.isFinite(n)) return String(value);
|
|
801
|
+
return decimals === null || decimals === undefined
|
|
802
|
+
? n.toLocaleString()
|
|
803
|
+
: n.toLocaleString(undefined, fractionDigits(decimals));
|
|
804
|
+
case 'percent':
|
|
805
|
+
if (!Number.isFinite(n)) return String(value);
|
|
806
|
+
return `${(n * 100).toLocaleString(undefined, fractionDigits(decimals ?? 1))}%`;
|
|
807
|
+
case 'currency':
|
|
808
|
+
if (!Number.isFinite(n)) return String(value);
|
|
809
|
+
if (!currency) return n.toLocaleString(undefined, fractionDigits(decimals ?? 2));
|
|
810
|
+
try {
|
|
811
|
+
return n.toLocaleString(undefined, { style: 'currency', currency, ...fractionDigits(decimals ?? 2) });
|
|
812
|
+
} catch {
|
|
813
|
+
// An unknown code throws a RangeError from inside a render.
|
|
814
|
+
return n.toLocaleString(undefined, fractionDigits(decimals ?? 2));
|
|
815
|
+
}
|
|
816
|
+
case 'duration':
|
|
817
|
+
return formatDuration(n);
|
|
818
|
+
default:
|
|
819
|
+
return String(value);
|
|
820
|
+
}
|
|
821
|
+
})();
|
|
822
|
+
return `${prefix || ''}${body}${suffix || ''}`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function slug(text) {
|
|
826
|
+
return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'chart';
|
|
827
|
+
}
|