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.
@@ -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
+ }