flexdesk 0.12.1 → 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 +33 -1
- package/css/flexdesk.css +77 -0
- package/dist/charts.js +4 -73
- package/dist/charts.js.map +3 -3
- package/dist/{chunk-2LEJQD4H.js → chunk-IYMNDPWH.js} +109 -2
- package/dist/chunk-IYMNDPWH.js.map +7 -0
- package/dist/flexdesk.css +77 -0
- package/dist/tiles.js +1752 -1031
- package/dist/tiles.js.map +4 -4
- 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/dist/chunk-2LEJQD4H.js.map +0 -7
package/dist/tiles.js
CHANGED
|
@@ -15,8 +15,20 @@ import {
|
|
|
15
15
|
ActionDropdown
|
|
16
16
|
} from "./chunk-O5OHMWBB.js";
|
|
17
17
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
buildHistogramTrace,
|
|
19
|
+
buildLegendLayout,
|
|
20
|
+
buildOverlayShapes,
|
|
21
|
+
buildTrace,
|
|
22
|
+
buildXAxisLayout,
|
|
23
|
+
buildYAxisLayout,
|
|
24
|
+
createChart,
|
|
25
|
+
ensurePlotly,
|
|
26
|
+
getSeriesColor,
|
|
27
|
+
openRawTracesWindow,
|
|
28
|
+
purge,
|
|
29
|
+
resize,
|
|
30
|
+
strideDownsampleShared
|
|
31
|
+
} from "./chunk-IYMNDPWH.js";
|
|
20
32
|
import "./chunk-M2L4XW2U.js";
|
|
21
33
|
import "./chunk-6JSOVNID.js";
|
|
22
34
|
import "./chunk-FL5KFNQH.js";
|
|
@@ -168,1252 +180,1956 @@ function getNamespaceOptions(data) {
|
|
|
168
180
|
return Array.from(namespaces).sort().map((ns) => ({ value: ns, label: ns }));
|
|
169
181
|
}
|
|
170
182
|
|
|
171
|
-
// src/tiles/
|
|
172
|
-
var
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
// src/tiles/tile_base.js
|
|
184
|
+
var TileBase = class {
|
|
185
|
+
/** @type {string} Widget type identifier - override in subclass */
|
|
186
|
+
static TYPE = "base";
|
|
187
|
+
/** @type {string} Display title - override in subclass */
|
|
188
|
+
static TITLE = "Widget";
|
|
189
|
+
/** @type {{w: number, h: number}} Default grid size - override in subclass */
|
|
190
|
+
static DEFAULT_SIZE = { w: 4, h: 3 };
|
|
191
|
+
/** @type {{minW: number, minH: number, maxW: number, maxH: number}} Size constraints */
|
|
192
|
+
static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 8 };
|
|
193
|
+
/** @type {boolean} Whether widget can be expanded to a separate window - override in subclass */
|
|
194
|
+
static EXPANDABLE = false;
|
|
195
|
+
/** @type {boolean} Whether widget supports scenario comparison overlay - override in subclass */
|
|
196
|
+
static SUPPORTS_COMPARISON = false;
|
|
197
|
+
/**
|
|
198
|
+
* @param {Object} options
|
|
199
|
+
* @param {string} options.id - Unique tile identifier
|
|
200
|
+
* @param {Object} options.grid - Parent TileGrid instance
|
|
201
|
+
* @param {Object} [options.eventBus] - Event bus for cross-component communication
|
|
202
|
+
* @param {Object} [options.config] - Widget-specific configuration
|
|
203
|
+
* @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
|
|
204
|
+
* @param {{resolve: Function}} [options.dataSource] - Where this tile asks for its
|
|
205
|
+
* own rows. Handed down by `TileGrid`; absent for a grid whose data is
|
|
206
|
+
* broadcast with `setData()`, and then nothing ever calls `loadData()`.
|
|
207
|
+
*/
|
|
176
208
|
constructor({
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
this.
|
|
188
|
-
this.
|
|
209
|
+
id,
|
|
210
|
+
grid,
|
|
211
|
+
eventBus = null,
|
|
212
|
+
config = {},
|
|
213
|
+
readonly = false,
|
|
214
|
+
headless = false,
|
|
215
|
+
host = null,
|
|
216
|
+
stateGuard = null,
|
|
217
|
+
dataSource = null
|
|
218
|
+
}) {
|
|
219
|
+
this.host = host;
|
|
220
|
+
this.dataSource = dataSource;
|
|
221
|
+
this.stateGuard = stateGuard;
|
|
222
|
+
this.id = id;
|
|
223
|
+
this.grid = grid;
|
|
224
|
+
this.eventBus = eventBus;
|
|
225
|
+
this.config = { ...this.getDefaultConfig(), ...config };
|
|
226
|
+
this.readonly = readonly;
|
|
227
|
+
this.headless = headless;
|
|
228
|
+
this.element = null;
|
|
229
|
+
this.contentElement = null;
|
|
230
|
+
this.chartInstance = null;
|
|
231
|
+
this.data = null;
|
|
232
|
+
this.fullData = null;
|
|
233
|
+
this._disposed = false;
|
|
234
|
+
this._lastLoadedAt = null;
|
|
235
|
+
this._loadSeq = 0;
|
|
236
|
+
this.comparisonData = null;
|
|
189
237
|
}
|
|
190
238
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* @param {string}
|
|
194
|
-
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
195
|
-
* @returns {Object} Layout object with tiles array
|
|
239
|
+
* Set comparison data for overlay rendering.
|
|
240
|
+
* Only meaningful for widgets where SUPPORTS_COMPARISON = true.
|
|
241
|
+
* @param {Map<string, Object>|null} comparisonData
|
|
196
242
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return layout;
|
|
202
|
-
}
|
|
203
|
-
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
204
|
-
if (instantiated) {
|
|
205
|
-
return {
|
|
206
|
-
tiles: instantiated.tiles,
|
|
207
|
-
version: LAYOUT_VERSION,
|
|
208
|
-
templateId: instantiated.templateId,
|
|
209
|
-
templateModified: false
|
|
210
|
-
};
|
|
243
|
+
setComparisonData(comparisonData) {
|
|
244
|
+
this.comparisonData = comparisonData;
|
|
245
|
+
if (this.data && this.contentElement) {
|
|
246
|
+
this.render(this.data);
|
|
211
247
|
}
|
|
212
|
-
return { tiles: [], version: LAYOUT_VERSION };
|
|
213
248
|
}
|
|
214
249
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* @param {
|
|
250
|
+
* Format a raw analytics variable key into a user-friendly display label.
|
|
251
|
+
* Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
|
|
252
|
+
* @param {string} name - Raw variable key
|
|
253
|
+
* @returns {string} Friendly display label
|
|
218
254
|
*/
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
222
|
-
all[contextId] = {
|
|
223
|
-
tiles: tilesArray,
|
|
224
|
-
version: LAYOUT_VERSION,
|
|
225
|
-
updatedAt: Date.now(),
|
|
226
|
-
templateId: layout?.templateId || all[contextId]?.templateId || null,
|
|
227
|
-
templateModified: layout?.templateModified ?? true
|
|
228
|
-
};
|
|
229
|
-
this._saveAll(all);
|
|
230
|
-
this._logger?.info?.(`[LayoutPersistence] Saved layout for context ${contextId}`);
|
|
255
|
+
formatLabel(name) {
|
|
256
|
+
return formatVariableLabel(name);
|
|
231
257
|
}
|
|
232
258
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
259
|
+
* Look up the unit string for a variable from analytics metadata.
|
|
260
|
+
* Checks stocks, flows, and indicators for unit metadata.
|
|
261
|
+
* @param {string} varName - Variable name
|
|
262
|
+
* @returns {string|null} Unit string or null
|
|
235
263
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
264
|
+
getVariableUnit(varName) {
|
|
265
|
+
if (!varName || !this.data) return null;
|
|
266
|
+
const sources = [this.data.stocks, this.data.flows, this.data.indicators];
|
|
267
|
+
for (const source of sources) {
|
|
268
|
+
const varData = source?.[varName];
|
|
269
|
+
if (varData?.unit) return varData.unit;
|
|
242
270
|
}
|
|
271
|
+
return null;
|
|
243
272
|
}
|
|
244
273
|
/**
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
253
|
-
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
254
|
-
* @returns {Object} The resolved layout (empty if nothing resolves).
|
|
274
|
+
* Look up a variable across stocks, flows, and indicators.
|
|
275
|
+
* If the variable is not found, returns the first available variable
|
|
276
|
+
* and updates the config key so the widget self-heals.
|
|
277
|
+
* @param {Object} data - Analytics data
|
|
278
|
+
* @param {string} configKey - Config property name (e.g. 'variable', 'xVariable')
|
|
279
|
+
* @returns {{varData: any, varName: string}|null} Resolved data or null if no data at all
|
|
255
280
|
*/
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
281
|
+
resolveVariable(data, configKey = "variable") {
|
|
282
|
+
const varName = this.config[configKey];
|
|
283
|
+
if (varName) {
|
|
284
|
+
const varData = data.stocks?.[varName] || data.indicators?.[varName] || data.flows?.[varName];
|
|
285
|
+
if (varData) return { varData, varName };
|
|
286
|
+
const dotIdx = varName.indexOf(".");
|
|
287
|
+
if (dotIdx >= 0) {
|
|
288
|
+
const bare = varName.slice(dotIdx + 1);
|
|
289
|
+
const bareData = data.stocks?.[bare] || data.indicators?.[bare] || data.flows?.[bare];
|
|
290
|
+
if (bareData) return { varData: bareData, varName: bare };
|
|
291
|
+
}
|
|
292
|
+
if (dotIdx < 0) {
|
|
293
|
+
const suffix = `.${varName}`;
|
|
294
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
295
|
+
if (!source) continue;
|
|
296
|
+
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
297
|
+
if (match) return { varData: source[match], varName: match };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (this.fullData && this.fullData !== data) {
|
|
301
|
+
const fullVarData = this.fullData.stocks?.[varName] || this.fullData.indicators?.[varName] || this.fullData.flows?.[varName];
|
|
302
|
+
if (fullVarData) return { varData: fullVarData, varName };
|
|
303
|
+
if (dotIdx >= 0) {
|
|
304
|
+
const bare = varName.slice(dotIdx + 1);
|
|
305
|
+
const fullBareData = this.fullData.stocks?.[bare] || this.fullData.indicators?.[bare] || this.fullData.flows?.[bare];
|
|
306
|
+
if (fullBareData) return { varData: fullBareData, varName: bare };
|
|
307
|
+
}
|
|
308
|
+
if (dotIdx < 0) {
|
|
309
|
+
const suffix = `.${varName}`;
|
|
310
|
+
for (const source of [this.fullData.stocks, this.fullData.indicators, this.fullData.flows]) {
|
|
311
|
+
if (!source) continue;
|
|
312
|
+
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
313
|
+
if (match) return { varData: source[match], varName: match };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const sources = [data.stocks, data.indicators, data.flows];
|
|
319
|
+
for (const source of sources) {
|
|
320
|
+
if (!source) continue;
|
|
321
|
+
const keys = Object.keys(source);
|
|
322
|
+
if (keys.length > 0) {
|
|
323
|
+
this.config[configKey] = keys[0];
|
|
324
|
+
return { varData: source[keys[0]], varName: keys[0] };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
266
328
|
}
|
|
267
329
|
/**
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* @
|
|
330
|
+
* Find a variable by name in data, trying exact match then suffix match.
|
|
331
|
+
* Does NOT auto-fallback to first available variable.
|
|
332
|
+
* @param {Object} data - Analytics data with stocks/indicators/flows
|
|
333
|
+
* @param {string} varName - Variable name (bare or namespace-prefixed)
|
|
334
|
+
* @returns {{varData: any, varName: string}|null}
|
|
271
335
|
*/
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
336
|
+
static findVariable(data, varName) {
|
|
337
|
+
if (!data || !varName) return null;
|
|
338
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
339
|
+
if (!source) continue;
|
|
340
|
+
if (source[varName]) return { varData: source[varName], varName };
|
|
341
|
+
}
|
|
342
|
+
const dotIdx = varName.indexOf(".");
|
|
343
|
+
if (dotIdx < 0) {
|
|
344
|
+
const suffix = `.${varName}`;
|
|
345
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
346
|
+
if (!source) continue;
|
|
347
|
+
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
348
|
+
if (match) return { varData: source[match], varName: match };
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
const bare = varName.slice(dotIdx + 1);
|
|
352
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
353
|
+
if (!source) continue;
|
|
354
|
+
if (source[bare]) return { varData: source[bare], varName: bare };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
275
358
|
}
|
|
276
359
|
/**
|
|
277
|
-
* Get
|
|
278
|
-
*
|
|
360
|
+
* Get default configuration for this widget type.
|
|
361
|
+
* Override in subclass.
|
|
362
|
+
* @returns {Object} Default config object
|
|
279
363
|
*/
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
return Object.keys(all);
|
|
364
|
+
getDefaultConfig() {
|
|
365
|
+
return {};
|
|
283
366
|
}
|
|
284
367
|
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
|
|
288
|
-
exportAll() {
|
|
289
|
-
const all = this._loadAll();
|
|
290
|
-
return JSON.stringify(all, null, 2);
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Import layouts from JSON string.
|
|
294
|
-
* @param {string} json - JSON string of layouts
|
|
295
|
-
* @param {boolean} [merge=false] - Whether to merge with existing or replace
|
|
368
|
+
* Get configuration schema for the config modal.
|
|
369
|
+
* Override in subclass.
|
|
370
|
+
* @returns {Object} Schema definition
|
|
296
371
|
*/
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const imported = JSON.parse(json);
|
|
300
|
-
if (merge) {
|
|
301
|
-
const existing = this._loadAll();
|
|
302
|
-
this._saveAll({ ...existing, ...imported });
|
|
303
|
-
} else {
|
|
304
|
-
this._saveAll(imported);
|
|
305
|
-
}
|
|
306
|
-
this._cache = null;
|
|
307
|
-
this._logger?.info?.("[LayoutPersistence] Imported layouts");
|
|
308
|
-
} catch (err) {
|
|
309
|
-
console.error("[LayoutPersistence] Failed to import layouts:", err);
|
|
310
|
-
}
|
|
372
|
+
getConfigSchema() {
|
|
373
|
+
return { fields: [] };
|
|
311
374
|
}
|
|
312
375
|
/**
|
|
313
|
-
*
|
|
376
|
+
* Render widget content with data.
|
|
377
|
+
* Override in subclass.
|
|
378
|
+
* @param {Object} data - Analytics data
|
|
314
379
|
*/
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
this._storage?.removeItem(this._layoutsKey);
|
|
318
|
-
this._cache = null;
|
|
319
|
-
this._logger?.info?.("[LayoutPersistence] Cleared all layouts");
|
|
320
|
-
} catch (err) {
|
|
321
|
-
console.error("[LayoutPersistence] Failed to clear layouts:", err);
|
|
322
|
-
}
|
|
380
|
+
render(data) {
|
|
381
|
+
throw new Error("TileBase.render() must be implemented by subclass");
|
|
323
382
|
}
|
|
324
383
|
/**
|
|
325
|
-
*
|
|
326
|
-
* @
|
|
327
|
-
* @private
|
|
384
|
+
* Mount the tile into the container.
|
|
385
|
+
* @param {HTMLElement} container - Parent container
|
|
328
386
|
*/
|
|
329
|
-
|
|
330
|
-
if (this.
|
|
331
|
-
|
|
387
|
+
mount(container) {
|
|
388
|
+
if (this._disposed) return;
|
|
389
|
+
this.element = document.createElement("div");
|
|
390
|
+
this.element.className = `tile${this.readonly ? " tile--readonly" : ""}`;
|
|
391
|
+
const stateGuard = this.stateGuard;
|
|
392
|
+
const setDataset = () => {
|
|
393
|
+
this.element.dataset.tileId = this.id;
|
|
394
|
+
this.element.dataset.tileType = this.constructor.TYPE;
|
|
395
|
+
};
|
|
396
|
+
if (stateGuard?.executeWithBypass) {
|
|
397
|
+
stateGuard.executeWithBypass("tile-mount", setDataset);
|
|
398
|
+
} else {
|
|
399
|
+
setDataset();
|
|
332
400
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
} catch (err) {
|
|
340
|
-
console.error("[LayoutPersistence] Failed to load layouts:", err);
|
|
401
|
+
if (this.headless) {
|
|
402
|
+
this.contentElement = document.createElement("div");
|
|
403
|
+
this.contentElement.className = "tile-content";
|
|
404
|
+
this.element.appendChild(this.contentElement);
|
|
405
|
+
} else {
|
|
406
|
+
this._buildChrome();
|
|
341
407
|
}
|
|
342
|
-
this.
|
|
343
|
-
return this._cache;
|
|
408
|
+
container.appendChild(this.element);
|
|
344
409
|
}
|
|
345
410
|
/**
|
|
346
|
-
*
|
|
347
|
-
* @param {Object} data
|
|
348
|
-
* @
|
|
411
|
+
* Update the widget with new data and/or config.
|
|
412
|
+
* @param {Object} [data] - New analytics data
|
|
413
|
+
* @param {Object} [config] - New config values to merge
|
|
349
414
|
*/
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
this.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
357
|
-
this.
|
|
415
|
+
update(data, config) {
|
|
416
|
+
if (this._disposed) return;
|
|
417
|
+
if (config) {
|
|
418
|
+
const prevDocs = (this.config?.docs || "").trim();
|
|
419
|
+
this.config = { ...this.config, ...config };
|
|
420
|
+
const nextDocs = (this.config?.docs || "").trim();
|
|
421
|
+
if (this.element && !this.headless && prevDocs !== nextDocs) {
|
|
422
|
+
this._syncInfoButton();
|
|
358
423
|
}
|
|
359
424
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
* Remove oldest layouts to free up space.
|
|
363
|
-
* @param {Object} data
|
|
364
|
-
* @private
|
|
365
|
-
*/
|
|
366
|
-
_pruneOldLayouts(data) {
|
|
367
|
-
const entries = Object.entries(data);
|
|
368
|
-
entries.sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0));
|
|
369
|
-
const removeCount = Math.ceil(entries.length * 0.25);
|
|
370
|
-
for (let i = 0; i < removeCount; i++) {
|
|
371
|
-
delete data[entries[i][0]];
|
|
425
|
+
if (data !== void 0) {
|
|
426
|
+
this.data = data;
|
|
372
427
|
}
|
|
373
|
-
|
|
374
|
-
this.
|
|
375
|
-
this._cache = data;
|
|
376
|
-
this._logger?.warn?.("[LayoutPersistence] Pruned old layouts due to quota");
|
|
377
|
-
} catch (err) {
|
|
378
|
-
console.error("[LayoutPersistence] Still cannot save after pruning:", err);
|
|
428
|
+
if (this.data && this.contentElement) {
|
|
429
|
+
this.render(this.data);
|
|
379
430
|
}
|
|
380
431
|
}
|
|
381
432
|
/**
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
433
|
+
* Load this tile's own data from `this.dataSource` and render it.
|
|
434
|
+
*
|
|
435
|
+
* A NO-OP HERE ON PURPOSE. The base class knows nothing about what a binding
|
|
436
|
+
* is or what a dataset looks like — those belong to the consumer, and the
|
|
437
|
+
* consumer that has them (Tables' `TablesTile`) implements this in ~30 lines
|
|
438
|
+
* over `showLoading()` / `render()` / `showError()`. What upstream owns is the
|
|
439
|
+
* CALL SITES: `TileGrid` invokes this after mount, after a resize settles and
|
|
440
|
+
* after a config save, but only when a `dataSource` was injected. Making it a
|
|
441
|
+
* hook rather than an implementation is what keeps EcoAgent's broadcast path
|
|
442
|
+
* (`setData` -> `update`) bit-identical: with no data source nothing here ever
|
|
443
|
+
* runs.
|
|
444
|
+
*
|
|
445
|
+
* @param {{force?: boolean}} [_opts] - `force` bypasses the source's cache.
|
|
446
|
+
* @returns {Promise<void>}
|
|
385
447
|
*/
|
|
386
|
-
|
|
387
|
-
const all = this._loadAll();
|
|
388
|
-
return all[contextId]?.templateId || null;
|
|
448
|
+
async loadData(_opts = {}) {
|
|
389
449
|
}
|
|
390
450
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
451
|
+
* Called once after a drag-resize settles, for widgets whose content does not
|
|
452
|
+
* reflow on its own — a Plotly figure sizes itself at draw time and stays that
|
|
453
|
+
* size until something calls `Plotly.Plots.resize`. Deliberately NOT wired to a
|
|
454
|
+
* ResizeObserver: the grid already knows when a resize ended, and an observer
|
|
455
|
+
* per tile fires during the drag, which is a relayout per pointer-move.
|
|
394
456
|
*/
|
|
395
|
-
|
|
396
|
-
const all = this._loadAll();
|
|
397
|
-
return all[contextId]?.templateModified ?? false;
|
|
457
|
+
onResize() {
|
|
398
458
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
459
|
+
/** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
|
|
460
|
+
_syncInfoButton() {
|
|
461
|
+
const header = this.element?.querySelector(".tile-header");
|
|
462
|
+
const controls = header?.querySelector(".tile-controls");
|
|
463
|
+
if (!controls) return;
|
|
464
|
+
const docs = (this.config?.docs || "").trim();
|
|
465
|
+
let btn = controls.querySelector(".tile-info-btn");
|
|
466
|
+
if (docs) {
|
|
467
|
+
if (!btn) {
|
|
468
|
+
btn = document.createElement("button");
|
|
469
|
+
btn.className = "tile-info-btn twm-has-tooltip";
|
|
470
|
+
btn.type = "button";
|
|
471
|
+
btn.tabIndex = -1;
|
|
472
|
+
btn.style.cssText = "background:transparent;border:none;padding:2px;color:rgba(255,255,255,0.55);cursor:help;display:inline-flex;align-items:center;justify-content:center;";
|
|
473
|
+
btn.setAttribute("data-tooltip-placement", "bottom");
|
|
474
|
+
btn.setAttribute("data-tooltip-max-width", "420px");
|
|
475
|
+
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">info</span>';
|
|
476
|
+
btn.addEventListener("click", (e) => e.stopPropagation());
|
|
477
|
+
const anchor = controls.querySelector(".tile-expand-btn") ?? controls.querySelector(".tile-remove-btn");
|
|
478
|
+
if (anchor) controls.insertBefore(btn, anchor);
|
|
479
|
+
else controls.appendChild(btn);
|
|
480
|
+
}
|
|
481
|
+
btn.setAttribute("data-tooltip", docs);
|
|
482
|
+
} else if (btn) {
|
|
483
|
+
btn.remove();
|
|
412
484
|
}
|
|
413
|
-
return {
|
|
414
|
-
activeId: null,
|
|
415
|
-
configs: {}
|
|
416
|
-
};
|
|
417
485
|
}
|
|
418
486
|
/**
|
|
419
|
-
*
|
|
420
|
-
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
421
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
422
|
-
* @param {string} name - Configuration name
|
|
423
|
-
* @param {Array|Object} layout - Layout to save
|
|
424
|
-
* @param {Object} [options] - Additional options
|
|
425
|
-
* @param {string} [options.templateId] - Template ID that generated this layout
|
|
426
|
-
* @returns {string} The new configuration ID
|
|
487
|
+
* Clean up resources and remove from DOM.
|
|
427
488
|
*/
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
489
|
+
dispose() {
|
|
490
|
+
if (this._disposed) return;
|
|
491
|
+
this._disposed = true;
|
|
492
|
+
if (this._expandWindow?.isVisible) {
|
|
493
|
+
this._expandWindow.close();
|
|
432
494
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
name: uniqueName,
|
|
438
|
-
tiles: tilesArray,
|
|
439
|
-
version: LAYOUT_VERSION,
|
|
440
|
-
createdAt: Date.now(),
|
|
441
|
-
templateId: options?.templateId || null
|
|
442
|
-
};
|
|
443
|
-
if (!all[contextId].activeId) {
|
|
444
|
-
all[contextId].activeId = configId;
|
|
495
|
+
this._expandWindow = null;
|
|
496
|
+
if (this.chartInstance) {
|
|
497
|
+
this.chartInstance.destroy();
|
|
498
|
+
this.chartInstance = null;
|
|
445
499
|
}
|
|
446
|
-
this.
|
|
447
|
-
|
|
448
|
-
|
|
500
|
+
if (this.element && this.element.parentNode) {
|
|
501
|
+
this.element.parentNode.removeChild(this.element);
|
|
502
|
+
}
|
|
503
|
+
this.element = null;
|
|
504
|
+
this.contentElement = null;
|
|
505
|
+
this.data = null;
|
|
506
|
+
this.fullData = null;
|
|
449
507
|
}
|
|
450
508
|
/**
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
509
|
+
* The entries in the tile's ⋯ menu, as `{action, label, icon, hidden}`.
|
|
510
|
+
*
|
|
511
|
+
* This existed as a LITERAL inside `_buildChrome`, and the literal was
|
|
512
|
+
* EcoAgent's: "Add to Documentation" and "Show in Documentation". Both are
|
|
513
|
+
* meaningful only for a tile whose `config.sourceCellId` names a notebook
|
|
514
|
+
* cell, and the first one's hide condition is `sourceCellId && already-added` —
|
|
515
|
+
* so on a tile that has no `sourceCellId` at all it renders VISIBLE, and
|
|
516
|
+
* choosing it emits `tile:add-to-documentation` at an application that has no
|
|
517
|
+
* documentation. Every consumer other than EcoAgent therefore shipped a menu
|
|
518
|
+
* item that did nothing, and could not remove it without editing this file.
|
|
519
|
+
*
|
|
520
|
+
* Returning `[]` drops the whole wrapper — button, dropdown and all — because
|
|
521
|
+
* a ⋯ button that opens an empty menu reads as a broken feature rather than
|
|
522
|
+
* an absent one. The default is the two entries above, so EcoAgent's chrome
|
|
523
|
+
* is unchanged.
|
|
524
|
+
*
|
|
525
|
+
* Selection is routed through `_onMenuAction()`; override both together.
|
|
526
|
+
* @returns {Array<{action: string, label: string, icon?: string, hidden?: boolean}>}
|
|
527
|
+
*/
|
|
528
|
+
menuItems() {
|
|
529
|
+
const linked = !!(this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId));
|
|
530
|
+
return [
|
|
531
|
+
{
|
|
532
|
+
action: "add-to-documentation",
|
|
533
|
+
label: "Add to Documentation",
|
|
534
|
+
icon: "post_add",
|
|
535
|
+
hidden: linked
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
action: "show-in-documentation",
|
|
539
|
+
label: "Show in Documentation",
|
|
540
|
+
icon: "description",
|
|
541
|
+
hidden: !linked
|
|
542
|
+
}
|
|
543
|
+
];
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Act on a ⋯ menu selection. One dispatch point for every entry, so an
|
|
547
|
+
* override of `menuItems()` cannot add a row that has nothing behind it, and
|
|
548
|
+
* an unrecognised action is silently ignored rather than throwing into a
|
|
549
|
+
* click handler.
|
|
550
|
+
* @param {string} action
|
|
551
|
+
* @protected
|
|
552
|
+
*/
|
|
553
|
+
_onMenuAction(action) {
|
|
554
|
+
if (action === "add-to-documentation") {
|
|
555
|
+
this.eventBus?.emit?.("tile:add-to-documentation", {
|
|
556
|
+
tileType: this.constructor.TYPE,
|
|
557
|
+
config: { ...this.config }
|
|
558
|
+
});
|
|
559
|
+
} else if (action === "show-in-documentation") {
|
|
560
|
+
if (this.config?.sourceCellId) {
|
|
561
|
+
this.eventBus?.emit?.("tile:show-in-documentation", {
|
|
562
|
+
sourceCellId: this.config.sourceCellId
|
|
563
|
+
});
|
|
564
|
+
}
|
|
468
565
|
}
|
|
469
|
-
return candidate;
|
|
470
566
|
}
|
|
471
567
|
/**
|
|
472
|
-
*
|
|
473
|
-
* @
|
|
474
|
-
* @param {string} configId - Configuration ID
|
|
475
|
-
* @returns {Object|null} Configuration layout or null if not found
|
|
568
|
+
* Build the tile chrome (header, content area, resize handles).
|
|
569
|
+
* @private
|
|
476
570
|
*/
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
571
|
+
_buildChrome() {
|
|
572
|
+
const header = document.createElement("div");
|
|
573
|
+
header.className = "tile-header";
|
|
574
|
+
const expandBtnHtml = this.constructor.EXPANDABLE ? `<button class="tile-expand-btn twm-has-tooltip" data-tooltip="Open in window">
|
|
575
|
+
<span class="material-symbols-outlined">open_in_new</span>
|
|
576
|
+
</button>` : "";
|
|
577
|
+
const docs = (this.config?.docs || "").trim();
|
|
578
|
+
const infoBtnHtml = docs ? `<button class="tile-info-btn twm-has-tooltip" type="button" tabindex="-1"
|
|
579
|
+
data-tooltip="${this.#escapeAttr(docs)}"
|
|
580
|
+
data-tooltip-placement="bottom"
|
|
581
|
+
data-tooltip-max-width="420px"
|
|
582
|
+
style="background:transparent;border:none;padding:2px;color:rgba(255,255,255,0.55);cursor:help;display:inline-flex;align-items:center;justify-content:center;">
|
|
583
|
+
<span class="material-symbols-outlined" style="font-size:18px;">info</span>
|
|
584
|
+
</button>` : "";
|
|
585
|
+
const menuEntries = (this.readonly ? [] : this.menuItems()) ?? [];
|
|
586
|
+
const menuHtml = menuEntries.length ? `<div class="tile-menu-wrapper">
|
|
587
|
+
<button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
|
|
588
|
+
<span class="material-symbols-outlined">more_vert</span>
|
|
589
|
+
</button>
|
|
590
|
+
<div class="tile-menu-dropdown" hidden>
|
|
591
|
+
${menuEntries.map((item) => `
|
|
592
|
+
<button class="tile-menu-item" data-action="${this.#escapeAttr(item.action)}"${item.hidden ? ' style="display:none"' : ""}>
|
|
593
|
+
${item.icon ? `<span class="material-symbols-outlined">${this.#escapeAttr(item.icon)}</span>` : ""}
|
|
594
|
+
<span>${this.#escapeAttr(item.label ?? "")}</span>
|
|
595
|
+
</button>`).join("")}
|
|
596
|
+
</div>
|
|
597
|
+
</div>` : "";
|
|
598
|
+
if (this.readonly) {
|
|
599
|
+
header.innerHTML = `
|
|
600
|
+
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
601
|
+
<div class="tile-controls">
|
|
602
|
+
${infoBtnHtml}
|
|
603
|
+
${expandBtnHtml}
|
|
604
|
+
</div>
|
|
605
|
+
`;
|
|
606
|
+
} else {
|
|
607
|
+
header.innerHTML = `
|
|
608
|
+
<span class="tile-drag-handle twm-has-tooltip" data-tooltip="Drag to reposition">
|
|
609
|
+
<span class="material-symbols-outlined">drag_indicator</span>
|
|
610
|
+
</span>
|
|
611
|
+
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
612
|
+
<div class="tile-controls">
|
|
613
|
+
<button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
|
|
614
|
+
<span class="material-symbols-outlined">settings</span>
|
|
615
|
+
</button>
|
|
616
|
+
${menuHtml}
|
|
617
|
+
${infoBtnHtml}
|
|
618
|
+
${expandBtnHtml}
|
|
619
|
+
<button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
|
|
620
|
+
<span class="material-symbols-outlined">close</span>
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
`;
|
|
624
|
+
}
|
|
625
|
+
const expandBtn = header.querySelector(".tile-expand-btn");
|
|
626
|
+
expandBtn?.addEventListener("click", (e) => {
|
|
627
|
+
e.stopPropagation();
|
|
628
|
+
this._onExpandClick();
|
|
629
|
+
});
|
|
630
|
+
const infoBtn = header.querySelector(".tile-info-btn");
|
|
631
|
+
infoBtn?.addEventListener("click", (e) => e.stopPropagation());
|
|
632
|
+
if (!this.readonly) {
|
|
633
|
+
const configBtn = header.querySelector(".tile-config-btn");
|
|
634
|
+
configBtn?.addEventListener("click", (e) => {
|
|
635
|
+
e.stopPropagation();
|
|
636
|
+
this._onConfigClick();
|
|
637
|
+
});
|
|
638
|
+
const menuBtn = header.querySelector(".tile-menu-btn");
|
|
639
|
+
const menuDropdown = header.querySelector(".tile-menu-dropdown");
|
|
640
|
+
if (menuBtn && menuDropdown) {
|
|
641
|
+
menuBtn.addEventListener("click", (e) => {
|
|
642
|
+
e.stopPropagation();
|
|
643
|
+
const isOpen = !menuDropdown.hidden;
|
|
644
|
+
menuDropdown.hidden = !menuDropdown.hidden;
|
|
645
|
+
if (!isOpen) {
|
|
646
|
+
const closeMenu = (ev) => {
|
|
647
|
+
if (!menuDropdown.contains(ev.target) && ev.target !== menuBtn) {
|
|
648
|
+
menuDropdown.hidden = true;
|
|
649
|
+
document.removeEventListener("pointerdown", closeMenu, true);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
requestAnimationFrame(() => {
|
|
653
|
+
document.addEventListener("pointerdown", closeMenu, true);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
menuDropdown.querySelectorAll(".tile-menu-item").forEach((itemEl) => {
|
|
658
|
+
itemEl.addEventListener("click", (e) => {
|
|
659
|
+
e.stopPropagation();
|
|
660
|
+
menuDropdown.hidden = true;
|
|
661
|
+
this._onMenuAction(itemEl.dataset.action);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
const removeBtn = header.querySelector(".tile-remove-btn");
|
|
666
|
+
removeBtn?.addEventListener("click", (e) => {
|
|
667
|
+
e.stopPropagation();
|
|
668
|
+
this._onRemoveClick();
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
this.contentElement = document.createElement("div");
|
|
672
|
+
this.contentElement.className = "tile-content";
|
|
673
|
+
this.element.appendChild(header);
|
|
674
|
+
this.element.appendChild(this.contentElement);
|
|
675
|
+
if (!this.readonly) {
|
|
676
|
+
const resizeHandle = document.createElement("div");
|
|
677
|
+
resizeHandle.className = "tile-resize-handle se";
|
|
678
|
+
resizeHandle.innerHTML = '<span class="material-symbols-outlined">drag_handle</span>';
|
|
679
|
+
this.element.appendChild(resizeHandle);
|
|
680
|
+
this.element.addEventListener("contextmenu", (e) => {
|
|
681
|
+
e.preventDefault();
|
|
682
|
+
e.stopPropagation();
|
|
683
|
+
this.eventBus?.emit?.("tile:context-menu", {
|
|
684
|
+
tileId: this.id,
|
|
685
|
+
tileType: this.constructor.TYPE,
|
|
686
|
+
tileTitle: this.constructor.TITLE,
|
|
687
|
+
config: { ...this.config },
|
|
688
|
+
clientX: e.clientX,
|
|
689
|
+
clientY: e.clientY
|
|
690
|
+
});
|
|
691
|
+
});
|
|
484
692
|
}
|
|
485
|
-
return null;
|
|
486
693
|
}
|
|
487
694
|
/**
|
|
488
|
-
*
|
|
489
|
-
* @
|
|
490
|
-
* @param {string} configId - Configuration ID
|
|
491
|
-
* @returns {boolean} True if deleted, false if not found
|
|
695
|
+
* Handle config button click — highlight tile and emit config request.
|
|
696
|
+
* @private
|
|
492
697
|
*/
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
698
|
+
_onConfigClick() {
|
|
699
|
+
this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el) => el.classList.remove("tile--selected"));
|
|
700
|
+
this.element?.classList.add("tile--selected");
|
|
701
|
+
this.eventBus?.emit?.("tile:config-request", {
|
|
702
|
+
tileId: this.id,
|
|
703
|
+
tileType: this.constructor.TYPE,
|
|
704
|
+
config: this.config,
|
|
705
|
+
schema: this.getConfigSchema()
|
|
706
|
+
});
|
|
707
|
+
this.element?.dispatchEvent(new CustomEvent("tile:config-request", {
|
|
708
|
+
bubbles: true,
|
|
709
|
+
detail: {
|
|
710
|
+
tileId: this.id,
|
|
711
|
+
tileType: this.constructor.TYPE,
|
|
712
|
+
config: this.config,
|
|
713
|
+
schema: this.getConfigSchema()
|
|
714
|
+
}
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Handle remove button click.
|
|
719
|
+
* @param {Event} [event] - Original click event
|
|
720
|
+
* @private
|
|
721
|
+
*/
|
|
722
|
+
_onRemoveClick(event) {
|
|
723
|
+
const removeBtn = this.element?.querySelector(".tile-remove-btn");
|
|
724
|
+
this.element?.dispatchEvent(new CustomEvent("tile:remove-request", {
|
|
725
|
+
bubbles: true,
|
|
726
|
+
detail: {
|
|
727
|
+
tileId: this.id,
|
|
728
|
+
anchorElement: removeBtn
|
|
729
|
+
}
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Set the title displayed in the header.
|
|
734
|
+
* @param {string} title - New title text
|
|
735
|
+
*/
|
|
736
|
+
setTitle(title) {
|
|
737
|
+
const titleEl = this.element?.querySelector(".tile-title");
|
|
738
|
+
if (titleEl) {
|
|
739
|
+
titleEl.textContent = title;
|
|
497
740
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Show loading state in content area.
|
|
744
|
+
*/
|
|
745
|
+
showLoading() {
|
|
746
|
+
if (this.contentElement) {
|
|
747
|
+
this.contentElement.innerHTML = `
|
|
748
|
+
<div class="tile-loading">
|
|
749
|
+
<span class="material-symbols-outlined spinning">progress_activity</span>
|
|
750
|
+
<span>Loading...</span>
|
|
751
|
+
</div>
|
|
752
|
+
`;
|
|
502
753
|
}
|
|
503
|
-
this._saveConfigs(all);
|
|
504
|
-
this._logger?.info?.(`[LayoutPersistence] Deleted configuration ${configId} for context ${contextId}`);
|
|
505
|
-
return true;
|
|
506
754
|
}
|
|
507
755
|
/**
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
756
|
+
* Show empty state in content area.
|
|
757
|
+
*
|
|
758
|
+
* The message is ESCAPED. Both this and `showError` interpolated straight
|
|
759
|
+
* into `innerHTML`, and the string reaching `showError` is the one place a
|
|
760
|
+
* caller has least control over — a server's error text, echoed back with a
|
|
761
|
+
* column name or a filter value in it. Callers pass plain text; this decides
|
|
762
|
+
* what that means in HTML.
|
|
763
|
+
*
|
|
764
|
+
* @param {string} [message] - Custom message
|
|
513
765
|
*/
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
766
|
+
showEmpty(message = "No data available") {
|
|
767
|
+
if (this.contentElement) {
|
|
768
|
+
this.contentElement.innerHTML = `
|
|
769
|
+
<div class="tile-empty">
|
|
770
|
+
<span class="material-symbols-outlined">inbox</span>
|
|
771
|
+
<span>${this.#escapeAttr(message)}</span>
|
|
772
|
+
</div>
|
|
773
|
+
`;
|
|
518
774
|
}
|
|
519
|
-
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
520
|
-
all[contextId].configs[configId].tiles = tilesArray;
|
|
521
|
-
all[contextId].configs[configId].version = LAYOUT_VERSION;
|
|
522
|
-
all[contextId].configs[configId].updatedAt = Date.now();
|
|
523
|
-
this._saveConfigs(all);
|
|
524
|
-
return true;
|
|
525
775
|
}
|
|
526
776
|
/**
|
|
527
|
-
*
|
|
528
|
-
* @param {string}
|
|
529
|
-
* @param {string} configId - Configuration ID
|
|
530
|
-
* @param {string} templateId - New template ID
|
|
531
|
-
* @returns {boolean} True if updated, false if not found
|
|
777
|
+
* Show error state in content area. The message is escaped — see `showEmpty`.
|
|
778
|
+
* @param {string} [message] - Error message
|
|
532
779
|
*/
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
780
|
+
showError(message = "Failed to load data") {
|
|
781
|
+
if (this.contentElement) {
|
|
782
|
+
this.contentElement.innerHTML = `
|
|
783
|
+
<div class="twm-tile-error">
|
|
784
|
+
<span class="material-symbols-outlined">error</span>
|
|
785
|
+
<span>${this.#escapeAttr(message)}</span>
|
|
786
|
+
</div>
|
|
787
|
+
`;
|
|
537
788
|
}
|
|
538
|
-
all[contextId].configs[configId].templateId = templateId;
|
|
539
|
-
this._saveConfigs(all);
|
|
540
|
-
return true;
|
|
541
789
|
}
|
|
542
790
|
/**
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
* @param {
|
|
546
|
-
* @
|
|
547
|
-
* @param {string} newName - New name
|
|
548
|
-
* @returns {string|false} The actual name used (may differ if duplicate), or false if not found
|
|
791
|
+
* Format a number for display.
|
|
792
|
+
* @param {number} value - Number to format
|
|
793
|
+
* @param {number} [decimals=2] - Decimal places
|
|
794
|
+
* @returns {string} Formatted string
|
|
549
795
|
*/
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
796
|
+
formatNumber(value, decimals = 2) {
|
|
797
|
+
if (value == null || !Number.isFinite(value)) return "\u2014";
|
|
798
|
+
const abs = Math.abs(value);
|
|
799
|
+
if (abs >= 1e9) {
|
|
800
|
+
return (value / 1e9).toFixed(1) + "B";
|
|
801
|
+
} else if (abs >= 1e6) {
|
|
802
|
+
return (value / 1e6).toFixed(1) + "M";
|
|
803
|
+
} else if (abs >= 1e3) {
|
|
804
|
+
return (value / 1e3).toFixed(1) + "K";
|
|
805
|
+
} else if (abs < 0.01 && abs > 0) {
|
|
806
|
+
return value.toExponential(decimals);
|
|
807
|
+
}
|
|
808
|
+
return value.toFixed(decimals);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Get available variables from data for config dropdowns.
|
|
812
|
+
* @param {Object} data - Analytics data
|
|
813
|
+
* @returns {Array<{value: string, label: string}>} Variable options
|
|
814
|
+
*/
|
|
815
|
+
getAvailableVariables(data) {
|
|
816
|
+
if (!data) return [];
|
|
817
|
+
const variables = [];
|
|
818
|
+
if (data.stocks) {
|
|
819
|
+
Object.keys(data.stocks).forEach((name) => {
|
|
820
|
+
variables.push({ value: name, label: name, category: "Stocks" });
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
if (data.flows) {
|
|
824
|
+
Object.keys(data.flows).forEach((name) => {
|
|
825
|
+
variables.push({ value: name, label: name, category: "Flows" });
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
if (data.indicators) {
|
|
829
|
+
Object.keys(data.indicators).forEach((name) => {
|
|
830
|
+
variables.push({ value: name, label: name, category: "Indicators" });
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
return variables;
|
|
834
|
+
}
|
|
835
|
+
// =========================================================================
|
|
836
|
+
// EXPAND WINDOW FUNCTIONALITY
|
|
837
|
+
// Reuses PlotPopoutWindow's patterns and CSS classes
|
|
838
|
+
// =========================================================================
|
|
839
|
+
/**
|
|
840
|
+
* Handle expand button click.
|
|
841
|
+
* @private
|
|
842
|
+
*/
|
|
843
|
+
_onExpandClick() {
|
|
844
|
+
if (!this.constructor.EXPANDABLE) return;
|
|
845
|
+
const expandData = this.getExpandData();
|
|
846
|
+
if (!expandData) {
|
|
847
|
+
console.warn(`[TileBase] No expand data from widget ${this.id}`);
|
|
848
|
+
return;
|
|
554
849
|
}
|
|
555
|
-
|
|
556
|
-
Object.entries(all[contextId].configs).forEach(([id, config]) => {
|
|
557
|
-
if (id !== configId) {
|
|
558
|
-
otherConfigs[id] = config;
|
|
559
|
-
}
|
|
560
|
-
});
|
|
561
|
-
const uniqueName = this._getUniqueName(otherConfigs, newName);
|
|
562
|
-
all[contextId].configs[configId].name = uniqueName;
|
|
563
|
-
this._saveConfigs(all);
|
|
564
|
-
return uniqueName;
|
|
850
|
+
this._openExpandWindow(expandData);
|
|
565
851
|
}
|
|
566
852
|
/**
|
|
567
|
-
* Get
|
|
568
|
-
*
|
|
569
|
-
* @returns {Object|null} {
|
|
853
|
+
* Get data for the expanded window.
|
|
854
|
+
* Override in subclass for expandable widgets.
|
|
855
|
+
* @returns {Object|null} { title, traces, layout, tableHeaders, tableRows }
|
|
570
856
|
*/
|
|
571
|
-
|
|
572
|
-
const all = this._loadConfigs();
|
|
573
|
-
const contextConfigs = all[contextId];
|
|
574
|
-
if (!contextConfigs?.activeId) {
|
|
575
|
-
return null;
|
|
576
|
-
}
|
|
577
|
-
const activeConfig = contextConfigs.configs[contextConfigs.activeId];
|
|
578
|
-
if (activeConfig) {
|
|
579
|
-
return {
|
|
580
|
-
id: contextConfigs.activeId,
|
|
581
|
-
...activeConfig
|
|
582
|
-
};
|
|
583
|
-
}
|
|
857
|
+
getExpandData() {
|
|
584
858
|
return null;
|
|
585
859
|
}
|
|
586
860
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
861
|
+
* Open the expand window with plot and data tabs.
|
|
862
|
+
* Delegates to openRawTracesWindow (PlotPopoutWindow module) which
|
|
863
|
+
* provides proper toolbar layout, download buttons, and data table.
|
|
864
|
+
* @param {Object} expandData - Data from getExpandData()
|
|
865
|
+
* @private
|
|
590
866
|
*/
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
this._saveConfigs(all);
|
|
867
|
+
async _openExpandWindow(expandData) {
|
|
868
|
+
if (this._expandWindow?.isVisible) {
|
|
869
|
+
this._expandWindow.bringToFront();
|
|
870
|
+
return;
|
|
596
871
|
}
|
|
872
|
+
this._expandWindow = await openRawTracesWindow({
|
|
873
|
+
host: this.host,
|
|
874
|
+
id: `tile-expand-${this.id}`,
|
|
875
|
+
title: expandData.title || this.constructor.TITLE,
|
|
876
|
+
traces: expandData.traces,
|
|
877
|
+
layout: expandData.layout,
|
|
878
|
+
frames: expandData.frames,
|
|
879
|
+
tableHeaders: expandData.tableHeaders,
|
|
880
|
+
tableRows: expandData.tableRows,
|
|
881
|
+
services: { eventBus: this.eventBus }
|
|
882
|
+
});
|
|
597
883
|
}
|
|
598
884
|
/**
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
* @
|
|
885
|
+
* Escape a string for safe insertion into HTML — an attribute value or a text
|
|
886
|
+
* node, since the five characters that matter are the same five in both.
|
|
887
|
+
* @private
|
|
602
888
|
*/
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
name: config.name,
|
|
612
|
-
createdAt: config.createdAt,
|
|
613
|
-
isActive: id === contextConfigs.activeId
|
|
614
|
-
})).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
889
|
+
#escapeAttr(s) {
|
|
890
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
891
|
+
"&": "&",
|
|
892
|
+
"<": "<",
|
|
893
|
+
">": ">",
|
|
894
|
+
'"': """,
|
|
895
|
+
"'": "'"
|
|
896
|
+
})[c]);
|
|
615
897
|
}
|
|
616
898
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* @param {string} contextId
|
|
620
|
-
* @returns {string|null}
|
|
899
|
+
* Show toast notification via eventBus.
|
|
900
|
+
* @private
|
|
621
901
|
*/
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
902
|
+
_notify(title, message, severity = "info") {
|
|
903
|
+
this.eventBus?.emit?.("toast:show", {
|
|
904
|
+
title,
|
|
905
|
+
message,
|
|
906
|
+
type: severity
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
// src/tiles/chart_tiles.js
|
|
912
|
+
init_tile_registry();
|
|
913
|
+
var CATEGORICAL_ONLY = /* @__PURE__ */ new Set(["pie", "donut", "funnel"]);
|
|
914
|
+
var CHART_TILE_TYPES = Object.freeze([
|
|
915
|
+
{ value: "line", label: "Line" },
|
|
916
|
+
{ value: "bar", label: "Bar" },
|
|
917
|
+
{ value: "bar-stacked", label: "Bar (stacked)" },
|
|
918
|
+
{ value: "bar-horizontal", label: "Bar (horizontal)" },
|
|
919
|
+
{ value: "area", label: "Area" },
|
|
920
|
+
{ value: "area-stacked", label: "Area (stacked %)" },
|
|
921
|
+
{ value: "scatter", label: "Scatter" },
|
|
922
|
+
{ value: "pie", label: "Pie" },
|
|
923
|
+
{ value: "donut", label: "Donut" },
|
|
924
|
+
{ value: "funnel", label: "Funnel" },
|
|
925
|
+
{ value: "histogram", label: "Histogram" }
|
|
926
|
+
]);
|
|
927
|
+
var AXIS_CHARTS = ["bar", "bar-stacked", "bar-horizontal", "line", "area", "area-stacked", "scatter"];
|
|
928
|
+
var READS = Object.freeze({
|
|
929
|
+
legend: /* @__PURE__ */ new Set([...AXIS_CHARTS, "pie", "donut", "histogram"]),
|
|
930
|
+
axis_titles: /* @__PURE__ */ new Set([...AXIS_CHARTS, "histogram"]),
|
|
931
|
+
y_scale: /* @__PURE__ */ new Set(["bar", "bar-horizontal", "line", "area", "scatter"]),
|
|
932
|
+
y_range: /* @__PURE__ */ new Set(["bar", "bar-stacked", "bar-horizontal", "line", "area", "scatter"]),
|
|
933
|
+
show_markers: /* @__PURE__ */ new Set(["line", "area", "area-stacked"])
|
|
934
|
+
});
|
|
935
|
+
var TILE_MARGIN = { l: 48, r: 16, t: 8, b: 36 };
|
|
936
|
+
var MAX_POINTS = 2e3;
|
|
937
|
+
function normalizeChartData(data) {
|
|
938
|
+
if (data == null) return null;
|
|
939
|
+
let d = data;
|
|
940
|
+
if (Array.isArray(d)) d = { series: d };
|
|
941
|
+
else if (typeof d !== "object") return null;
|
|
942
|
+
else if (!Array.isArray(d.series)) {
|
|
943
|
+
const single = ["x", "y", "labels", "values"].some((k) => Array.isArray(d[k]));
|
|
944
|
+
d = { ...d, series: single ? [d] : [] };
|
|
945
|
+
}
|
|
946
|
+
const series = d.series.filter((s) => s && pointCount(s) > 0);
|
|
947
|
+
if (!series.length) return null;
|
|
948
|
+
return {
|
|
949
|
+
series,
|
|
950
|
+
markers: Array.isArray(d.markers) ? d.markers : [],
|
|
951
|
+
referenceLines: Array.isArray(d.referenceLines) ? d.referenceLines : [],
|
|
952
|
+
xKind: d.xKind || null,
|
|
953
|
+
note: typeof d.note === "string" ? d.note : ""
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function pointCount(s) {
|
|
957
|
+
for (const k of ["y", "values", "x", "labels"]) {
|
|
958
|
+
if (Array.isArray(s[k])) return s[k].length;
|
|
959
|
+
}
|
|
960
|
+
return 0;
|
|
961
|
+
}
|
|
962
|
+
var ChartTile = class extends TileBase {
|
|
963
|
+
static TYPE = "flexdesk.chart";
|
|
964
|
+
static TITLE = "Chart";
|
|
965
|
+
static ICON = "insert_chart";
|
|
966
|
+
static DESCRIPTION = "Line, bar, area, pie, donut, funnel or histogram over series you supply.";
|
|
967
|
+
static DEFAULT_SIZE = { w: 6, h: 4 };
|
|
968
|
+
static SIZE_CONSTRAINTS = { minW: 3, minH: 3, maxW: 12, maxH: 10 };
|
|
969
|
+
/**
|
|
970
|
+
* @param {Object} options - TileBase's, plus:
|
|
971
|
+
* @param {Function} [options.onPointClick] - `({customdata, point, tile})`
|
|
972
|
+
* on a click on a mark. Also assignable later as `tile.onPointClick`.
|
|
973
|
+
*/
|
|
974
|
+
constructor(options = {}) {
|
|
975
|
+
super(options);
|
|
976
|
+
this.onPointClick = typeof options.onPointClick === "function" ? options.onPointClick : null;
|
|
977
|
+
this._plotEl = null;
|
|
978
|
+
this._drawn = false;
|
|
979
|
+
this._drawSeq = 0;
|
|
980
|
+
}
|
|
981
|
+
/** Pure and static-only — it runs inside `TileBase`'s constructor. */
|
|
982
|
+
getDefaultConfig() {
|
|
983
|
+
return {
|
|
984
|
+
title: "",
|
|
985
|
+
chart_type: "line",
|
|
986
|
+
unit: "",
|
|
987
|
+
legend: "bottom",
|
|
988
|
+
x_title: "",
|
|
989
|
+
y_title: "",
|
|
990
|
+
y_scale: "linear",
|
|
991
|
+
y_min: null,
|
|
992
|
+
y_max: null,
|
|
993
|
+
show_markers: false,
|
|
994
|
+
max_points: MAX_POINTS
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
getConfigSchema() {
|
|
998
|
+
return {
|
|
999
|
+
fields: [
|
|
1000
|
+
{ name: "title", type: "text", label: "Tile title" },
|
|
1001
|
+
{
|
|
1002
|
+
name: "chart_type",
|
|
1003
|
+
type: "select",
|
|
1004
|
+
label: "Chart type",
|
|
1005
|
+
options: CHART_TILE_TYPES.map((o) => ({ ...o }))
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
name: "unit",
|
|
1009
|
+
type: "text",
|
|
1010
|
+
label: "Unit",
|
|
1011
|
+
help: 'Shown with the value axis title, e.g. "pts" or "h".'
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
name: "legend",
|
|
1015
|
+
type: "select",
|
|
1016
|
+
label: "Legend",
|
|
1017
|
+
visibleWhen: { field: "chart_type", in: [...READS.legend] },
|
|
1018
|
+
options: [
|
|
1019
|
+
{ value: "bottom", label: "Below" },
|
|
1020
|
+
{ value: "top", label: "Above" },
|
|
1021
|
+
{ value: "right", label: "Right" },
|
|
1022
|
+
{ value: "left", label: "Left" },
|
|
1023
|
+
{ value: "hidden", label: "None" }
|
|
1024
|
+
]
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
name: "x_title",
|
|
1028
|
+
type: "text",
|
|
1029
|
+
label: "X axis title",
|
|
1030
|
+
visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
name: "y_title",
|
|
1034
|
+
type: "text",
|
|
1035
|
+
label: "Y axis title",
|
|
1036
|
+
visibleWhen: { field: "chart_type", in: [...READS.axis_titles] }
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
name: "y_scale",
|
|
1040
|
+
type: "select",
|
|
1041
|
+
label: "Y scale",
|
|
1042
|
+
visibleWhen: { field: "chart_type", in: [...READS.y_scale] },
|
|
1043
|
+
options: [{ value: "linear", label: "Linear" }, { value: "log", label: "Logarithmic" }]
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
name: "y_min",
|
|
1047
|
+
type: "number",
|
|
1048
|
+
label: "Y minimum",
|
|
1049
|
+
visibleWhen: { field: "chart_type", in: [...READS.y_range] }
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
name: "y_max",
|
|
1053
|
+
type: "number",
|
|
1054
|
+
label: "Y maximum",
|
|
1055
|
+
visibleWhen: { field: "chart_type", in: [...READS.y_range] }
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
name: "show_markers",
|
|
1059
|
+
type: "checkbox",
|
|
1060
|
+
label: "Show point markers",
|
|
1061
|
+
visibleWhen: { field: "chart_type", in: [...READS.show_markers] }
|
|
1062
|
+
}
|
|
1063
|
+
]
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
mount(container) {
|
|
1067
|
+
super.mount(container);
|
|
1068
|
+
applyTitle(this);
|
|
625
1069
|
}
|
|
626
1070
|
/**
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
1071
|
+
* `null` here is "the grid has no data", not "clear this tile": TileGrid's
|
|
1072
|
+
* config save calls `update(grid.data, newConfig)`, and a grid whose tiles
|
|
1073
|
+
* are fed one by one through `setData` has `grid.data === null` — passed on,
|
|
1074
|
+
* it would drop the tile's series and skip the redraw in the new chart type.
|
|
1075
|
+
* `setData(null)` is how a tile is cleared.
|
|
630
1076
|
*/
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (
|
|
634
|
-
all[contextId] = { activeId: null, configs: {} };
|
|
635
|
-
}
|
|
636
|
-
all[contextId].templateSetId = templateSetId;
|
|
637
|
-
this._saveConfigs(all);
|
|
1077
|
+
update(data, config) {
|
|
1078
|
+
super.update(data === null ? void 0 : data, config);
|
|
1079
|
+
if (config) applyTitle(this);
|
|
638
1080
|
}
|
|
639
1081
|
/**
|
|
640
|
-
*
|
|
641
|
-
*
|
|
1082
|
+
* Hand this tile its series. Unlike `update()`, an empty or null value is
|
|
1083
|
+
* drawn — as the empty state — rather than ignored.
|
|
642
1084
|
*/
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
this.
|
|
1085
|
+
setData(data) {
|
|
1086
|
+
if (this._disposed) return;
|
|
1087
|
+
this.data = data ?? null;
|
|
1088
|
+
if (this.contentElement) this.render(this.data);
|
|
647
1089
|
}
|
|
648
1090
|
/**
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
* @private
|
|
1091
|
+
* Kick off the draw and return. Deliberately not `async`: nothing awaits
|
|
1092
|
+
* `render`, so a returned promise would be a rejection nobody catches.
|
|
652
1093
|
*/
|
|
653
|
-
|
|
1094
|
+
render(data) {
|
|
1095
|
+
this._draw(data);
|
|
1096
|
+
}
|
|
1097
|
+
/** One relayout when a resize settles; nothing while a draw is in flight. */
|
|
1098
|
+
onResize() {
|
|
1099
|
+
if (!this._plotEl || !this._drawn) return;
|
|
1100
|
+
resize(this._plotEl).catch(() => {
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
menuItems() {
|
|
1104
|
+
return [{ action: "download-png", label: "Download PNG", icon: "image" }];
|
|
1105
|
+
}
|
|
1106
|
+
_onMenuAction(action) {
|
|
1107
|
+
if (action === "download-png") this._downloadPng();
|
|
1108
|
+
}
|
|
1109
|
+
// ── the three states: each one invalidates the figure ───────────────
|
|
1110
|
+
showLoading() {
|
|
1111
|
+
this._invalidate();
|
|
1112
|
+
super.showLoading();
|
|
1113
|
+
}
|
|
1114
|
+
showEmpty(message) {
|
|
1115
|
+
this._invalidate();
|
|
1116
|
+
super.showEmpty(message);
|
|
1117
|
+
}
|
|
1118
|
+
showError(message) {
|
|
1119
|
+
this._invalidate();
|
|
1120
|
+
super.showError(message);
|
|
1121
|
+
}
|
|
1122
|
+
/** Release the figure BEFORE the element goes — `TileBase.dispose` nulls it. */
|
|
1123
|
+
dispose() {
|
|
1124
|
+
this._invalidate();
|
|
1125
|
+
super.dispose();
|
|
1126
|
+
}
|
|
1127
|
+
/** Any draw in flight is now stale, and the figure on screen is released. */
|
|
1128
|
+
_invalidate() {
|
|
1129
|
+
this._drawSeq += 1;
|
|
1130
|
+
this._releasePlot();
|
|
1131
|
+
}
|
|
1132
|
+
/** Hand the current figure back to Plotly and forget it. Idempotent, and
|
|
1133
|
+
* not awaited: a rejected purge (Plotly absent, already purged) must not
|
|
1134
|
+
* surface as an unhandled rejection. */
|
|
1135
|
+
_releasePlot() {
|
|
1136
|
+
const el = this._plotEl;
|
|
1137
|
+
this._plotEl = null;
|
|
1138
|
+
this._drawn = false;
|
|
1139
|
+
if (el) purge(el).catch(() => {
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
// ── the draw ────────────────────────────────────────────────────────
|
|
1143
|
+
async _draw(data) {
|
|
1144
|
+
this._drawSeq += 1;
|
|
1145
|
+
const seq = this._drawSeq;
|
|
1146
|
+
const content = this.contentElement;
|
|
1147
|
+
if (!content) return;
|
|
1148
|
+
this._releasePlot();
|
|
1149
|
+
const norm = normalizeChartData(data);
|
|
1150
|
+
if (!norm) {
|
|
1151
|
+
this.showEmpty("No data to show.");
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
let plan;
|
|
654
1155
|
try {
|
|
655
|
-
|
|
656
|
-
if (raw) {
|
|
657
|
-
return JSON.parse(raw);
|
|
658
|
-
}
|
|
1156
|
+
plan = this._plan(norm);
|
|
659
1157
|
} catch (err) {
|
|
660
|
-
|
|
1158
|
+
this._failed(err);
|
|
1159
|
+
return;
|
|
661
1160
|
}
|
|
662
|
-
return
|
|
1161
|
+
if (!plan) return;
|
|
1162
|
+
const host = document.createElement("div");
|
|
1163
|
+
host.className = "fd-chart-tile";
|
|
1164
|
+
const parts = [host];
|
|
1165
|
+
if (norm.note) {
|
|
1166
|
+
const note = document.createElement("div");
|
|
1167
|
+
note.className = "fd-chart-tile__note";
|
|
1168
|
+
note.setAttribute("role", "note");
|
|
1169
|
+
note.textContent = norm.note;
|
|
1170
|
+
note.title = norm.note;
|
|
1171
|
+
parts.push(note);
|
|
1172
|
+
}
|
|
1173
|
+
content.replaceChildren(...parts);
|
|
1174
|
+
this._plotEl = host;
|
|
1175
|
+
this._drawn = false;
|
|
1176
|
+
try {
|
|
1177
|
+
await createChart(host, plan.traces, plan.layout, {
|
|
1178
|
+
scrollZoom: false,
|
|
1179
|
+
displayModeBar: false,
|
|
1180
|
+
responsive: true
|
|
1181
|
+
});
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
if (this._disposed || seq !== this._drawSeq) return;
|
|
1184
|
+
this._failed(err);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (this._disposed || seq !== this._drawSeq) {
|
|
1188
|
+
purge(host).catch(() => {
|
|
1189
|
+
});
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
this._drawn = true;
|
|
1193
|
+
this._wireClick(host);
|
|
1194
|
+
}
|
|
1195
|
+
/** Plotly's click, reduced to the point's own `customdata`. A pie's point
|
|
1196
|
+
* carries its slice index as `pointNumber` (older builds: `pointIndex`),
|
|
1197
|
+
* and its `customdata` is read off the trace by that index. */
|
|
1198
|
+
_wireClick(host) {
|
|
1199
|
+
if (typeof host.on !== "function") return;
|
|
1200
|
+
host.on("plotly_click", (ev) => {
|
|
1201
|
+
const point = ev?.points?.[0];
|
|
1202
|
+
if (!point || typeof this.onPointClick !== "function") return;
|
|
1203
|
+
const trace = point.data || {};
|
|
1204
|
+
let customdata = point.customdata;
|
|
1205
|
+
if (trace.type === "pie" || customdata === void 0) {
|
|
1206
|
+
const i = point.pointNumber ?? point.pointIndex;
|
|
1207
|
+
if (Array.isArray(trace.customdata) && i != null) customdata = trace.customdata[i];
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
this.onPointClick({ customdata, point, tile: this });
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
console.error("[ChartTile] onPointClick threw:", err);
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
663
1215
|
}
|
|
664
1216
|
/**
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1217
|
+
* Turn normalised data into `{traces, layout}`, or put the tile in the
|
|
1218
|
+
* empty state and answer `null`.
|
|
1219
|
+
*/
|
|
1220
|
+
_plan(norm) {
|
|
1221
|
+
const type = this.config.chart_type || "line";
|
|
1222
|
+
const { series } = norm;
|
|
1223
|
+
if (CATEGORICAL_ONLY.has(type)) {
|
|
1224
|
+
const s = series[0];
|
|
1225
|
+
const labels = s.labels ?? s.x ?? [];
|
|
1226
|
+
const values = s.values ?? s.y ?? [];
|
|
1227
|
+
const trace = buildTrace({
|
|
1228
|
+
chartType: type,
|
|
1229
|
+
x: labels.map((v) => v === null || v === void 0 ? "\u2014" : String(v)),
|
|
1230
|
+
y: values,
|
|
1231
|
+
name: s.name || "",
|
|
1232
|
+
colors: s.colors || null
|
|
1233
|
+
});
|
|
1234
|
+
if (Array.isArray(s.customdata)) trace.customdata = s.customdata;
|
|
1235
|
+
return {
|
|
1236
|
+
traces: [trace],
|
|
1237
|
+
layout: {
|
|
1238
|
+
margin: type === "funnel" ? { ...TILE_MARGIN, l: 96 } : TILE_MARGIN,
|
|
1239
|
+
...this._legend()
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
if (type === "histogram") {
|
|
1244
|
+
const traces2 = series.map((s, i) => {
|
|
1245
|
+
const raw = s.values ?? s.y ?? s.x ?? [];
|
|
1246
|
+
const trace = {
|
|
1247
|
+
...buildHistogramTrace({
|
|
1248
|
+
x: raw.map(toNumber).filter((v) => v !== null),
|
|
1249
|
+
color: s.color || getSeriesColor(i),
|
|
1250
|
+
name: s.name || `Series ${i + 1}`,
|
|
1251
|
+
...s.nbins ? { nbins: s.nbins } : {}
|
|
1252
|
+
}),
|
|
1253
|
+
opacity: series.length > 1 ? 0.7 : 1
|
|
1254
|
+
};
|
|
1255
|
+
return trace;
|
|
1256
|
+
});
|
|
1257
|
+
return {
|
|
1258
|
+
traces: traces2,
|
|
1259
|
+
layout: {
|
|
1260
|
+
margin: TILE_MARGIN,
|
|
1261
|
+
...this._legend(),
|
|
1262
|
+
barmode: "overlay",
|
|
1263
|
+
xaxis: buildXAxisLayout({ title: this.config.x_title || "" }),
|
|
1264
|
+
yaxis: buildYAxisLayout({ axisId: "yaxis", title: this.config.y_title || "Count" })
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
const kind = norm.xKind || inferXKind(series);
|
|
1269
|
+
const base = type.startsWith("bar") ? "bar" : type;
|
|
1270
|
+
const target = Number(this.config.max_points) || MAX_POINTS;
|
|
1271
|
+
const showMarkers = READS.show_markers.has(type) && this.config.show_markers === true;
|
|
1272
|
+
const traces = series.map((s, i) => {
|
|
1273
|
+
let x = Array.isArray(s.x) ? s.x : (s.y || []).map((_, j) => j);
|
|
1274
|
+
let y = Array.isArray(s.y) ? s.y : s.values || [];
|
|
1275
|
+
let customdata = Array.isArray(s.customdata) ? s.customdata : null;
|
|
1276
|
+
if (kind !== "category") {
|
|
1277
|
+
const cut = strideDownsampleShared(x, customdata ? { y, customdata } : { y }, target);
|
|
1278
|
+
x = cut.time;
|
|
1279
|
+
y = cut.series.y;
|
|
1280
|
+
if (customdata) customdata = cut.series.customdata;
|
|
1281
|
+
}
|
|
1282
|
+
const trace = buildTrace({
|
|
1283
|
+
chartType: base,
|
|
1284
|
+
x,
|
|
1285
|
+
y,
|
|
1286
|
+
name: s.name || `Series ${i + 1}`,
|
|
1287
|
+
color: s.color || null,
|
|
1288
|
+
lineStyle: s.lineStyle || "solid",
|
|
1289
|
+
interpolation: s.interpolation || "linear",
|
|
1290
|
+
...s.lineWidth ? { lineWidth: s.lineWidth } : {},
|
|
1291
|
+
seriesIndex: i,
|
|
1292
|
+
showMarkers
|
|
1293
|
+
});
|
|
1294
|
+
if (s.line && typeof s.line === "object") trace.line = { ...trace.line || {}, ...s.line };
|
|
1295
|
+
if (customdata) trace.customdata = customdata;
|
|
1296
|
+
return trace;
|
|
1297
|
+
});
|
|
1298
|
+
const xaxis = buildXAxisLayout({ title: this.config.x_title || "", scaleType: "linear" });
|
|
1299
|
+
if (kind !== "number") xaxis.type = kind === "date" ? "date" : "category";
|
|
1300
|
+
const log = READS.y_scale.has(type) && this.config.y_scale === "log";
|
|
1301
|
+
const yaxis = buildYAxisLayout({
|
|
1302
|
+
axisId: "yaxis",
|
|
1303
|
+
title: this._valueTitle(series),
|
|
1304
|
+
scaleType: log ? "log" : "linear",
|
|
1305
|
+
min: READS.y_range.has(type) ? axisBound(this.config.y_min, log) : null,
|
|
1306
|
+
max: READS.y_range.has(type) ? axisBound(this.config.y_max, log) : null
|
|
1307
|
+
});
|
|
1308
|
+
const layout = { margin: TILE_MARGIN, xaxis, yaxis, ...this._legend() };
|
|
1309
|
+
if (type === "bar") layout.barmode = "group";
|
|
1310
|
+
if (type === "bar-stacked") layout.barmode = "stack";
|
|
1311
|
+
if (type === "bar-horizontal") {
|
|
1312
|
+
layout.barmode = "group";
|
|
1313
|
+
for (const t of traces) {
|
|
1314
|
+
[t.x, t.y] = [t.y, t.x];
|
|
1315
|
+
t.orientation = "h";
|
|
1316
|
+
}
|
|
1317
|
+
[layout.xaxis, layout.yaxis] = [yaxis, xaxis];
|
|
1318
|
+
layout.margin = { ...TILE_MARGIN, l: 96 };
|
|
1319
|
+
}
|
|
1320
|
+
const overlay = buildOverlayShapes({
|
|
1321
|
+
verticalLines: type === "bar-horizontal" ? [] : norm.markers,
|
|
1322
|
+
referenceLines: norm.referenceLines
|
|
1323
|
+
});
|
|
1324
|
+
if (overlay.shapes.length) layout.shapes = overlay.shapes;
|
|
1325
|
+
if (overlay.annotations.length) layout.annotations = overlay.annotations;
|
|
1326
|
+
return { traces, layout };
|
|
1327
|
+
}
|
|
1328
|
+
/** The value axis title: the configured one, else the single series' name,
|
|
1329
|
+
* with the unit beside it. */
|
|
1330
|
+
_valueTitle(series) {
|
|
1331
|
+
const unit = (this.config.unit || "").trim();
|
|
1332
|
+
const base = this.config.y_title || (series.length === 1 ? series[0].name || "" : "");
|
|
1333
|
+
if (!unit) return base;
|
|
1334
|
+
return base ? `${base} (${unit})` : unit;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* `showlegend` and `legend`, lifted out by name: `buildLegendLayout`'s
|
|
1338
|
+
* top-level `font` would replace the theme's whole font object. Below the
|
|
1339
|
+
* plot it is anchored to the CONTAINER, so the axis title sits above it
|
|
1340
|
+
* however tall the tick labels are.
|
|
1341
|
+
*/
|
|
1342
|
+
_legend() {
|
|
1343
|
+
if (!READS.legend.has(this.config.chart_type || "line")) return { showlegend: false };
|
|
1344
|
+
const position = this.config.legend || "bottom";
|
|
1345
|
+
const spec = buildLegendLayout(position);
|
|
1346
|
+
const out = { showlegend: spec.showlegend === true };
|
|
1347
|
+
if (spec.legend) out.legend = spec.legend;
|
|
1348
|
+
if (position === "bottom" && out.legend) {
|
|
1349
|
+
out.legend = { ...out.legend, yref: "container", y: 0, yanchor: "bottom" };
|
|
1350
|
+
}
|
|
1351
|
+
return out;
|
|
1352
|
+
}
|
|
1353
|
+
async _downloadPng() {
|
|
1354
|
+
if (!this._plotEl || !this._drawn) return;
|
|
670
1355
|
try {
|
|
671
|
-
|
|
1356
|
+
const P = await ensurePlotly();
|
|
1357
|
+
const uri = await P.toImage(this._plotEl, { format: "png", scale: 2 });
|
|
1358
|
+
const a = document.createElement("a");
|
|
1359
|
+
a.href = uri;
|
|
1360
|
+
a.download = `${slug(this.config.title || this.constructor.TITLE)}.png`;
|
|
1361
|
+
a.click();
|
|
672
1362
|
} catch (err) {
|
|
673
|
-
|
|
1363
|
+
this._notify("Download failed", `The image could not be made: ${err?.message || err}`, "error");
|
|
674
1364
|
}
|
|
675
1365
|
}
|
|
1366
|
+
_failed(err) {
|
|
1367
|
+
if (this._disposed) return;
|
|
1368
|
+
this.showError(`This chart could not be drawn: ${err?.message || err}`);
|
|
1369
|
+
this.eventBus?.emit?.("tile:render-failed", {
|
|
1370
|
+
tileId: this.id,
|
|
1371
|
+
message: err?.message || String(err)
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
676
1374
|
};
|
|
677
|
-
var
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1375
|
+
var KpiTile = class extends TileBase {
|
|
1376
|
+
static TYPE = "flexdesk.kpi";
|
|
1377
|
+
static TITLE = "KPI";
|
|
1378
|
+
static ICON = "speed";
|
|
1379
|
+
static DESCRIPTION = "One number with a label and an optional comparison.";
|
|
1380
|
+
static DEFAULT_SIZE = { w: 3, h: 2 };
|
|
1381
|
+
static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 6 };
|
|
1382
|
+
getDefaultConfig() {
|
|
1383
|
+
return {
|
|
1384
|
+
title: "",
|
|
1385
|
+
label: "",
|
|
1386
|
+
unit: "",
|
|
1387
|
+
format: "number",
|
|
1388
|
+
decimals: null,
|
|
1389
|
+
prefix: "",
|
|
1390
|
+
suffix: "",
|
|
1391
|
+
currency: null,
|
|
1392
|
+
delta_relative: false,
|
|
1393
|
+
invert_delta: false,
|
|
1394
|
+
foot: ""
|
|
1395
|
+
};
|
|
686
1396
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1397
|
+
getConfigSchema() {
|
|
1398
|
+
return {
|
|
1399
|
+
fields: [
|
|
1400
|
+
{ name: "title", type: "text", label: "Tile title" },
|
|
1401
|
+
{ name: "label", type: "text", label: "Label" },
|
|
1402
|
+
{ name: "unit", type: "text", label: "Unit", help: 'Shown after the number, e.g. "pts".' },
|
|
1403
|
+
{
|
|
1404
|
+
name: "format",
|
|
1405
|
+
type: "select",
|
|
1406
|
+
label: "Format",
|
|
1407
|
+
options: [
|
|
1408
|
+
{ value: "number", label: "Number" },
|
|
1409
|
+
{ value: "percent", label: "Percentage (of a ratio)" },
|
|
1410
|
+
{ value: "currency", label: "Currency" },
|
|
1411
|
+
{ value: "duration", label: "Duration (seconds)" },
|
|
1412
|
+
{ value: "text", label: "Text" }
|
|
1413
|
+
]
|
|
1414
|
+
},
|
|
1415
|
+
{ name: "decimals", type: "number", label: "Decimals", min: 0, max: 6 },
|
|
1416
|
+
{
|
|
1417
|
+
name: "currency",
|
|
1418
|
+
type: "text",
|
|
1419
|
+
label: "Currency code",
|
|
1420
|
+
help: "ISO 4217, e.g. EUR. Left empty, the number is shown without a symbol."
|
|
1421
|
+
},
|
|
1422
|
+
{ name: "prefix", type: "text", label: "Prefix" },
|
|
1423
|
+
{ name: "suffix", type: "text", label: "Suffix" },
|
|
1424
|
+
{ name: "delta_relative", type: "checkbox", label: "Show the change as a percentage" },
|
|
1425
|
+
{
|
|
1426
|
+
name: "invert_delta",
|
|
1427
|
+
type: "checkbox",
|
|
1428
|
+
label: "A fall is good",
|
|
1429
|
+
help: "For a cost, a queue length or a latency: a smaller number is the better one."
|
|
1430
|
+
},
|
|
1431
|
+
{ name: "foot", type: "text", label: "Caption" }
|
|
1432
|
+
]
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
mount(container) {
|
|
1436
|
+
super.mount(container);
|
|
1437
|
+
applyTitle(this);
|
|
1438
|
+
}
|
|
1439
|
+
/** `null` is "the grid has no data" — see `ChartTile.update`. And a value of
|
|
1440
|
+
* `0` is a value: `TileBase.update` re-renders truthy data only. */
|
|
1441
|
+
update(data, config) {
|
|
1442
|
+
if (this._disposed) return;
|
|
1443
|
+
super.update(data === null ? void 0 : data, config);
|
|
1444
|
+
if (config) applyTitle(this);
|
|
1445
|
+
const d = this.data;
|
|
1446
|
+
if (d !== null && d !== void 0 && !d && this.contentElement) this.render(d);
|
|
1447
|
+
}
|
|
1448
|
+
/** `{value, reference?, label?, note?}` or a bare number; null is the empty state. */
|
|
1449
|
+
setData(data) {
|
|
1450
|
+
if (this._disposed) return;
|
|
1451
|
+
this.data = data ?? null;
|
|
1452
|
+
if (this.contentElement) this.render(this.data);
|
|
1453
|
+
}
|
|
1454
|
+
render(data) {
|
|
1455
|
+
const content = this.contentElement;
|
|
1456
|
+
if (!content) return;
|
|
1457
|
+
const d = typeof data === "number" ? { value: data } : data;
|
|
1458
|
+
if (!d || typeof d !== "object" || d.value === null || d.value === void 0 || d.value === "") {
|
|
1459
|
+
this.showEmpty("No value to show.");
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
content.replaceChildren();
|
|
1463
|
+
const box = document.createElement("div");
|
|
1464
|
+
box.className = "fd-kpi";
|
|
1465
|
+
const labelText = d.label || this.config.label || "";
|
|
1466
|
+
if (labelText) {
|
|
1467
|
+
const label = document.createElement("div");
|
|
1468
|
+
label.className = "fd-kpi__label";
|
|
1469
|
+
label.textContent = labelText;
|
|
1470
|
+
label.title = labelText;
|
|
1471
|
+
box.append(label);
|
|
1472
|
+
}
|
|
1473
|
+
const big = document.createElement("div");
|
|
1474
|
+
big.className = "fd-kpi__value";
|
|
1475
|
+
big.textContent = this._format(d.value, true);
|
|
1476
|
+
big.title = String(d.value);
|
|
1477
|
+
box.append(big);
|
|
1478
|
+
const delta = this._buildDelta(d.value, d.reference);
|
|
1479
|
+
if (delta) box.append(delta);
|
|
1480
|
+
const foot = [this.config.foot, d.note].filter(Boolean).join(" \xB7 ");
|
|
1481
|
+
if (foot) {
|
|
1482
|
+
const el = document.createElement("div");
|
|
1483
|
+
el.className = "fd-kpi__foot";
|
|
1484
|
+
el.textContent = foot;
|
|
1485
|
+
el.title = foot;
|
|
1486
|
+
box.append(el);
|
|
1487
|
+
}
|
|
1488
|
+
content.append(box);
|
|
1489
|
+
}
|
|
1490
|
+
/** The comparison line, or `null` when there is nothing to compare against.
|
|
1491
|
+
* `0` is a real reference — the test is finiteness, never truthiness. */
|
|
1492
|
+
_buildDelta(rawValue, rawReference) {
|
|
1493
|
+
const value = Number(rawValue);
|
|
1494
|
+
if (rawReference === null || rawReference === void 0 || rawReference === "") return null;
|
|
1495
|
+
const reference = Number(rawReference);
|
|
1496
|
+
if (!Number.isFinite(value) || !Number.isFinite(reference)) return null;
|
|
1497
|
+
const change = value - reference;
|
|
1498
|
+
const el = document.createElement("div");
|
|
1499
|
+
el.className = "fd-kpi__delta";
|
|
1500
|
+
if (change > 0) el.classList.add(this.config.invert_delta ? "fd-kpi__delta--down" : "fd-kpi__delta--up");
|
|
1501
|
+
else if (change < 0) el.classList.add(this.config.invert_delta ? "fd-kpi__delta--up" : "fd-kpi__delta--down");
|
|
1502
|
+
const arrow = change > 0 ? "\u25B2" : change < 0 ? "\u25BC" : "\u2013";
|
|
1503
|
+
const magnitude = this.config.delta_relative ? reference === 0 ? null : formatValue(Math.abs(change / reference), {
|
|
1504
|
+
format: "percent",
|
|
1505
|
+
decimals: this.config.decimals ?? 1
|
|
1506
|
+
}) : this._format(Math.abs(change), false);
|
|
1507
|
+
const sign = change > 0 ? "+" : change < 0 ? "-" : "";
|
|
1508
|
+
el.textContent = magnitude === null ? `${arrow} \u2014` : `${arrow} ${sign}${magnitude}`;
|
|
1509
|
+
el.title = `Compared with ${this._format(reference, false)}`;
|
|
1510
|
+
return el;
|
|
1511
|
+
}
|
|
1512
|
+
_format(value, withAffixes) {
|
|
1513
|
+
const format = this.config.format || "number";
|
|
1514
|
+
const body = formatValue(value, {
|
|
1515
|
+
format: format === "text" && !withAffixes ? "number" : format,
|
|
1516
|
+
decimals: this.config.decimals,
|
|
1517
|
+
currency: this.config.currency,
|
|
1518
|
+
prefix: withAffixes ? this.config.prefix : "",
|
|
1519
|
+
suffix: withAffixes ? this.config.suffix : ""
|
|
1520
|
+
});
|
|
1521
|
+
const unit = (this.config.unit || "").trim();
|
|
1522
|
+
return unit && body !== "\u2014" ? `${body} ${unit}` : body;
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
function registerChartTiles() {
|
|
1526
|
+
for (const Tile of [ChartTile, KpiTile]) {
|
|
1527
|
+
if (getWidget(Tile.TYPE) !== Tile) registerWidget(Tile);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
function applyTitle(tile) {
|
|
1531
|
+
tile.setTitle(tile.config.title || tile.constructor.TITLE);
|
|
1532
|
+
}
|
|
1533
|
+
function inferXKind(series) {
|
|
1534
|
+
let sawAny = false;
|
|
1535
|
+
let allNumbers = true;
|
|
1536
|
+
let allDates = true;
|
|
1537
|
+
for (const s of series) {
|
|
1538
|
+
for (const v of Array.isArray(s.x) ? s.x : []) {
|
|
1539
|
+
if (v === null || v === void 0) continue;
|
|
1540
|
+
sawAny = true;
|
|
1541
|
+
if (typeof v !== "number") allNumbers = false;
|
|
1542
|
+
if (!(v instanceof Date) && !(typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v))) allDates = false;
|
|
1543
|
+
if (!allNumbers && !allDates) return "category";
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (!sawAny || allNumbers) return "number";
|
|
1547
|
+
return allDates ? "date" : "category";
|
|
1548
|
+
}
|
|
1549
|
+
function toNumber(value) {
|
|
1550
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
1551
|
+
const n = Number(value);
|
|
1552
|
+
return Number.isFinite(n) ? n : null;
|
|
1553
|
+
}
|
|
1554
|
+
function axisBound(value, log) {
|
|
1555
|
+
const n = toNumber(value);
|
|
1556
|
+
if (n === null || !log) return n;
|
|
1557
|
+
return n > 0 ? Math.log10(n) : null;
|
|
1558
|
+
}
|
|
1559
|
+
function fractionDigits(decimals) {
|
|
1560
|
+
const d = Math.max(0, Math.min(20, Number(decimals) || 0));
|
|
1561
|
+
return { minimumFractionDigits: d, maximumFractionDigits: d };
|
|
1562
|
+
}
|
|
1563
|
+
function formatDuration(seconds) {
|
|
1564
|
+
if (!Number.isFinite(seconds)) return "\u2014";
|
|
1565
|
+
const sign = seconds < 0 ? "-" : "";
|
|
1566
|
+
let rest = Math.abs(Math.round(seconds));
|
|
1567
|
+
const days = Math.floor(rest / 86400);
|
|
1568
|
+
rest -= days * 86400;
|
|
1569
|
+
const hours = Math.floor(rest / 3600);
|
|
1570
|
+
rest -= hours * 3600;
|
|
1571
|
+
const minutes = Math.floor(rest / 60);
|
|
1572
|
+
const secs = rest - minutes * 60;
|
|
1573
|
+
if (days) return `${sign}${days}d ${hours}h`;
|
|
1574
|
+
if (hours) return `${sign}${hours}h ${minutes}m`;
|
|
1575
|
+
if (minutes) return `${sign}${minutes}m ${secs}s`;
|
|
1576
|
+
return `${sign}${secs}s`;
|
|
1577
|
+
}
|
|
1578
|
+
function formatValue(value, {
|
|
1579
|
+
format = "number",
|
|
1580
|
+
decimals = null,
|
|
1581
|
+
prefix = "",
|
|
1582
|
+
suffix = "",
|
|
1583
|
+
currency = null
|
|
1584
|
+
} = {}) {
|
|
1585
|
+
if (value === null || value === void 0 || value === "") return "\u2014";
|
|
1586
|
+
const n = Number(value);
|
|
1587
|
+
const body = (() => {
|
|
1588
|
+
switch (format) {
|
|
1589
|
+
case "number":
|
|
1590
|
+
if (!Number.isFinite(n)) return String(value);
|
|
1591
|
+
return decimals === null || decimals === void 0 ? n.toLocaleString() : n.toLocaleString(void 0, fractionDigits(decimals));
|
|
1592
|
+
case "percent":
|
|
1593
|
+
if (!Number.isFinite(n)) return String(value);
|
|
1594
|
+
return `${(n * 100).toLocaleString(void 0, fractionDigits(decimals ?? 1))}%`;
|
|
1595
|
+
case "currency":
|
|
1596
|
+
if (!Number.isFinite(n)) return String(value);
|
|
1597
|
+
if (!currency) return n.toLocaleString(void 0, fractionDigits(decimals ?? 2));
|
|
1598
|
+
try {
|
|
1599
|
+
return n.toLocaleString(void 0, { style: "currency", currency, ...fractionDigits(decimals ?? 2) });
|
|
1600
|
+
} catch {
|
|
1601
|
+
return n.toLocaleString(void 0, fractionDigits(decimals ?? 2));
|
|
1602
|
+
}
|
|
1603
|
+
case "duration":
|
|
1604
|
+
return formatDuration(n);
|
|
1605
|
+
default:
|
|
1606
|
+
return String(value);
|
|
1607
|
+
}
|
|
1608
|
+
})();
|
|
1609
|
+
return `${prefix || ""}${body}${suffix || ""}`;
|
|
1610
|
+
}
|
|
1611
|
+
function slug(text) {
|
|
1612
|
+
return String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "chart";
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// src/tiles/layout_persistence.js
|
|
1616
|
+
var LAYOUT_VERSION = 3;
|
|
1617
|
+
var DEFAULT_LAYOUTS_KEY = "twm.tiles.layouts";
|
|
1618
|
+
var DEFAULT_CONFIGS_KEY = "twm.tiles.configs";
|
|
1619
|
+
var LayoutPersistence = class {
|
|
715
1620
|
constructor({
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
this.
|
|
727
|
-
this.
|
|
728
|
-
this.stateGuard = stateGuard;
|
|
729
|
-
this.id = id;
|
|
730
|
-
this.grid = grid;
|
|
731
|
-
this.eventBus = eventBus;
|
|
732
|
-
this.config = { ...this.getDefaultConfig(), ...config };
|
|
733
|
-
this.readonly = readonly;
|
|
734
|
-
this.headless = headless;
|
|
735
|
-
this.element = null;
|
|
736
|
-
this.contentElement = null;
|
|
737
|
-
this.chartInstance = null;
|
|
738
|
-
this.data = null;
|
|
739
|
-
this.fullData = null;
|
|
740
|
-
this._disposed = false;
|
|
741
|
-
this._lastLoadedAt = null;
|
|
742
|
-
this._loadSeq = 0;
|
|
743
|
-
this.comparisonData = null;
|
|
1621
|
+
storage = typeof localStorage !== "undefined" ? localStorage : null,
|
|
1622
|
+
layoutsKey = DEFAULT_LAYOUTS_KEY,
|
|
1623
|
+
configsKey = DEFAULT_CONFIGS_KEY,
|
|
1624
|
+
resolveTemplate = null,
|
|
1625
|
+
logger = null
|
|
1626
|
+
} = {}) {
|
|
1627
|
+
this._cache = null;
|
|
1628
|
+
this._storage = storage;
|
|
1629
|
+
this._logger = logger;
|
|
1630
|
+
this._layoutsKey = layoutsKey;
|
|
1631
|
+
this._configsKey = configsKey;
|
|
1632
|
+
this._resolveTemplate = typeof resolveTemplate === "function" ? resolveTemplate : null;
|
|
744
1633
|
}
|
|
745
1634
|
/**
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
* @param {
|
|
1635
|
+
* Get layout for a specific context.
|
|
1636
|
+
* Falls back to template resolution when no saved layout exists.
|
|
1637
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1638
|
+
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
1639
|
+
* @returns {Object} Layout object with tiles array
|
|
749
1640
|
*/
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1641
|
+
getLayout(contextId, context) {
|
|
1642
|
+
const all = this._loadAll();
|
|
1643
|
+
const layout = all[contextId];
|
|
1644
|
+
if (layout && layout.version === LAYOUT_VERSION) {
|
|
1645
|
+
return layout;
|
|
1646
|
+
}
|
|
1647
|
+
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
1648
|
+
if (instantiated) {
|
|
1649
|
+
return {
|
|
1650
|
+
tiles: instantiated.tiles,
|
|
1651
|
+
version: LAYOUT_VERSION,
|
|
1652
|
+
templateId: instantiated.templateId,
|
|
1653
|
+
templateModified: false
|
|
1654
|
+
};
|
|
754
1655
|
}
|
|
1656
|
+
return { tiles: [], version: LAYOUT_VERSION };
|
|
755
1657
|
}
|
|
756
1658
|
/**
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
* @param {
|
|
760
|
-
* @returns {string} Friendly display label
|
|
1659
|
+
* Save layout for a specific context.
|
|
1660
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1661
|
+
* @param {Array|Object} layout - Layout array or object with tiles property
|
|
761
1662
|
*/
|
|
762
|
-
|
|
763
|
-
|
|
1663
|
+
saveLayout(contextId, layout) {
|
|
1664
|
+
const all = this._loadAll();
|
|
1665
|
+
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
1666
|
+
all[contextId] = {
|
|
1667
|
+
tiles: tilesArray,
|
|
1668
|
+
version: LAYOUT_VERSION,
|
|
1669
|
+
updatedAt: Date.now(),
|
|
1670
|
+
templateId: layout?.templateId || all[contextId]?.templateId || null,
|
|
1671
|
+
templateModified: layout?.templateModified ?? true
|
|
1672
|
+
};
|
|
1673
|
+
this._saveAll(all);
|
|
1674
|
+
this._logger?.info?.(`[LayoutPersistence] Saved layout for context ${contextId}`);
|
|
764
1675
|
}
|
|
765
1676
|
/**
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
* @param {string} varName - Variable name
|
|
769
|
-
* @returns {string|null} Unit string or null
|
|
1677
|
+
* Delete layout for a specific context.
|
|
1678
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
770
1679
|
*/
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1680
|
+
deleteLayout(contextId) {
|
|
1681
|
+
const all = this._loadAll();
|
|
1682
|
+
if (all[contextId]) {
|
|
1683
|
+
delete all[contextId];
|
|
1684
|
+
this._saveAll(all);
|
|
1685
|
+
this._logger?.info?.(`[LayoutPersistence] Deleted layout for context ${contextId}`);
|
|
777
1686
|
}
|
|
778
|
-
return null;
|
|
779
1687
|
}
|
|
780
1688
|
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
1689
|
+
* Reset a layout by re-resolving the best-matching template.
|
|
1690
|
+
*
|
|
1691
|
+
* With no `resolveTemplate` supplied, "reset" means "empty grid" — which is the
|
|
1692
|
+
* honest answer for a library that has no templates. The previous code called
|
|
1693
|
+
* `TemplateRegistry.instantiate()` unconditionally and read `.tiles` off the
|
|
1694
|
+
* result, so a null resolver here would have been a TypeError.
|
|
1695
|
+
*
|
|
1696
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1697
|
+
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
1698
|
+
* @returns {Object} The resolved layout (empty if nothing resolves).
|
|
787
1699
|
*/
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
}
|
|
799
|
-
if (dotIdx < 0) {
|
|
800
|
-
const suffix = `.${varName}`;
|
|
801
|
-
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
802
|
-
if (!source) continue;
|
|
803
|
-
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
804
|
-
if (match) return { varData: source[match], varName: match };
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
if (this.fullData && this.fullData !== data) {
|
|
808
|
-
const fullVarData = this.fullData.stocks?.[varName] || this.fullData.indicators?.[varName] || this.fullData.flows?.[varName];
|
|
809
|
-
if (fullVarData) return { varData: fullVarData, varName };
|
|
810
|
-
if (dotIdx >= 0) {
|
|
811
|
-
const bare = varName.slice(dotIdx + 1);
|
|
812
|
-
const fullBareData = this.fullData.stocks?.[bare] || this.fullData.indicators?.[bare] || this.fullData.flows?.[bare];
|
|
813
|
-
if (fullBareData) return { varData: fullBareData, varName: bare };
|
|
814
|
-
}
|
|
815
|
-
if (dotIdx < 0) {
|
|
816
|
-
const suffix = `.${varName}`;
|
|
817
|
-
for (const source of [this.fullData.stocks, this.fullData.indicators, this.fullData.flows]) {
|
|
818
|
-
if (!source) continue;
|
|
819
|
-
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
820
|
-
if (match) return { varData: source[match], varName: match };
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
const sources = [data.stocks, data.indicators, data.flows];
|
|
826
|
-
for (const source of sources) {
|
|
827
|
-
if (!source) continue;
|
|
828
|
-
const keys = Object.keys(source);
|
|
829
|
-
if (keys.length > 0) {
|
|
830
|
-
this.config[configKey] = keys[0];
|
|
831
|
-
return { varData: source[keys[0]], varName: keys[0] };
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
return null;
|
|
1700
|
+
resetLayout(contextId, context) {
|
|
1701
|
+
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
1702
|
+
const layout = {
|
|
1703
|
+
tiles: instantiated?.tiles ?? [],
|
|
1704
|
+
version: LAYOUT_VERSION,
|
|
1705
|
+
templateId: instantiated?.templateId ?? null,
|
|
1706
|
+
templateModified: false
|
|
1707
|
+
};
|
|
1708
|
+
this.saveLayout(contextId, layout);
|
|
1709
|
+
return layout;
|
|
835
1710
|
}
|
|
836
1711
|
/**
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
* @
|
|
840
|
-
* @param {string} varName - Variable name (bare or namespace-prefixed)
|
|
841
|
-
* @returns {{varData: any, varName: string}|null}
|
|
1712
|
+
* Check if a context has a saved layout.
|
|
1713
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1714
|
+
* @returns {boolean}
|
|
842
1715
|
*/
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
if (!source) continue;
|
|
847
|
-
if (source[varName]) return { varData: source[varName], varName };
|
|
848
|
-
}
|
|
849
|
-
const dotIdx = varName.indexOf(".");
|
|
850
|
-
if (dotIdx < 0) {
|
|
851
|
-
const suffix = `.${varName}`;
|
|
852
|
-
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
853
|
-
if (!source) continue;
|
|
854
|
-
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
855
|
-
if (match) return { varData: source[match], varName: match };
|
|
856
|
-
}
|
|
857
|
-
} else {
|
|
858
|
-
const bare = varName.slice(dotIdx + 1);
|
|
859
|
-
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
860
|
-
if (!source) continue;
|
|
861
|
-
if (source[bare]) return { varData: source[bare], varName: bare };
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
return null;
|
|
1716
|
+
hasLayout(contextId) {
|
|
1717
|
+
const all = this._loadAll();
|
|
1718
|
+
return !!all[contextId];
|
|
865
1719
|
}
|
|
866
1720
|
/**
|
|
867
|
-
* Get
|
|
868
|
-
*
|
|
869
|
-
* @returns {Object} Default config object
|
|
1721
|
+
* Get all context IDs with saved layouts.
|
|
1722
|
+
* @returns {Array<string>}
|
|
870
1723
|
*/
|
|
871
|
-
|
|
872
|
-
|
|
1724
|
+
getAllContextIds() {
|
|
1725
|
+
const all = this._loadAll();
|
|
1726
|
+
return Object.keys(all);
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Export all layouts as JSON string.
|
|
1730
|
+
* @returns {string}
|
|
1731
|
+
*/
|
|
1732
|
+
exportAll() {
|
|
1733
|
+
const all = this._loadAll();
|
|
1734
|
+
return JSON.stringify(all, null, 2);
|
|
873
1735
|
}
|
|
874
1736
|
/**
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
* @
|
|
1737
|
+
* Import layouts from JSON string.
|
|
1738
|
+
* @param {string} json - JSON string of layouts
|
|
1739
|
+
* @param {boolean} [merge=false] - Whether to merge with existing or replace
|
|
878
1740
|
*/
|
|
879
|
-
|
|
880
|
-
|
|
1741
|
+
importAll(json, merge = false) {
|
|
1742
|
+
try {
|
|
1743
|
+
const imported = JSON.parse(json);
|
|
1744
|
+
if (merge) {
|
|
1745
|
+
const existing = this._loadAll();
|
|
1746
|
+
this._saveAll({ ...existing, ...imported });
|
|
1747
|
+
} else {
|
|
1748
|
+
this._saveAll(imported);
|
|
1749
|
+
}
|
|
1750
|
+
this._cache = null;
|
|
1751
|
+
this._logger?.info?.("[LayoutPersistence] Imported layouts");
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
console.error("[LayoutPersistence] Failed to import layouts:", err);
|
|
1754
|
+
}
|
|
881
1755
|
}
|
|
882
1756
|
/**
|
|
883
|
-
*
|
|
884
|
-
* Override in subclass.
|
|
885
|
-
* @param {Object} data - Analytics data
|
|
1757
|
+
* Clear all saved layouts.
|
|
886
1758
|
*/
|
|
887
|
-
|
|
888
|
-
|
|
1759
|
+
clearAll() {
|
|
1760
|
+
try {
|
|
1761
|
+
this._storage?.removeItem(this._layoutsKey);
|
|
1762
|
+
this._cache = null;
|
|
1763
|
+
this._logger?.info?.("[LayoutPersistence] Cleared all layouts");
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
console.error("[LayoutPersistence] Failed to clear layouts:", err);
|
|
1766
|
+
}
|
|
889
1767
|
}
|
|
890
1768
|
/**
|
|
891
|
-
*
|
|
892
|
-
* @
|
|
1769
|
+
* Load all layouts from storage.
|
|
1770
|
+
* @returns {Object}
|
|
1771
|
+
* @private
|
|
893
1772
|
*/
|
|
894
|
-
|
|
895
|
-
if (this.
|
|
896
|
-
|
|
897
|
-
this.element.className = `tile${this.readonly ? " tile--readonly" : ""}`;
|
|
898
|
-
const stateGuard = this.stateGuard;
|
|
899
|
-
const setDataset = () => {
|
|
900
|
-
this.element.dataset.tileId = this.id;
|
|
901
|
-
this.element.dataset.tileType = this.constructor.TYPE;
|
|
902
|
-
};
|
|
903
|
-
if (stateGuard?.executeWithBypass) {
|
|
904
|
-
stateGuard.executeWithBypass("tile-mount", setDataset);
|
|
905
|
-
} else {
|
|
906
|
-
setDataset();
|
|
1773
|
+
_loadAll() {
|
|
1774
|
+
if (this._cache) {
|
|
1775
|
+
return this._cache;
|
|
907
1776
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1777
|
+
try {
|
|
1778
|
+
const raw = this._storage?.getItem(this._layoutsKey);
|
|
1779
|
+
if (raw) {
|
|
1780
|
+
this._cache = JSON.parse(raw);
|
|
1781
|
+
return this._cache;
|
|
1782
|
+
}
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
console.error("[LayoutPersistence] Failed to load layouts:", err);
|
|
914
1785
|
}
|
|
915
|
-
|
|
1786
|
+
this._cache = {};
|
|
1787
|
+
return this._cache;
|
|
916
1788
|
}
|
|
917
1789
|
/**
|
|
918
|
-
*
|
|
919
|
-
* @param {Object}
|
|
920
|
-
* @
|
|
1790
|
+
* Save all layouts to storage.
|
|
1791
|
+
* @param {Object} data
|
|
1792
|
+
* @private
|
|
921
1793
|
*/
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
if (
|
|
929
|
-
this.
|
|
1794
|
+
_saveAll(data) {
|
|
1795
|
+
try {
|
|
1796
|
+
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
1797
|
+
this._cache = data;
|
|
1798
|
+
} catch (err) {
|
|
1799
|
+
console.error("[LayoutPersistence] Failed to save layouts:", err);
|
|
1800
|
+
if (err.name === "QuotaExceededError") {
|
|
1801
|
+
this._pruneOldLayouts(data);
|
|
930
1802
|
}
|
|
931
1803
|
}
|
|
932
|
-
if (data !== void 0) {
|
|
933
|
-
this.data = data;
|
|
934
|
-
}
|
|
935
|
-
if (this.data && this.contentElement) {
|
|
936
|
-
this.render(this.data);
|
|
937
|
-
}
|
|
938
1804
|
}
|
|
939
1805
|
/**
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* is or what a dataset looks like — those belong to the consumer, and the
|
|
944
|
-
* consumer that has them (Tables' `TablesTile`) implements this in ~30 lines
|
|
945
|
-
* over `showLoading()` / `render()` / `showError()`. What upstream owns is the
|
|
946
|
-
* CALL SITES: `TileGrid` invokes this after mount, after a resize settles and
|
|
947
|
-
* after a config save, but only when a `dataSource` was injected. Making it a
|
|
948
|
-
* hook rather than an implementation is what keeps EcoAgent's broadcast path
|
|
949
|
-
* (`setData` -> `update`) bit-identical: with no data source nothing here ever
|
|
950
|
-
* runs.
|
|
951
|
-
*
|
|
952
|
-
* @param {{force?: boolean}} [_opts] - `force` bypasses the source's cache.
|
|
953
|
-
* @returns {Promise<void>}
|
|
1806
|
+
* Remove oldest layouts to free up space.
|
|
1807
|
+
* @param {Object} data
|
|
1808
|
+
* @private
|
|
954
1809
|
*/
|
|
955
|
-
|
|
1810
|
+
_pruneOldLayouts(data) {
|
|
1811
|
+
const entries = Object.entries(data);
|
|
1812
|
+
entries.sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0));
|
|
1813
|
+
const removeCount = Math.ceil(entries.length * 0.25);
|
|
1814
|
+
for (let i = 0; i < removeCount; i++) {
|
|
1815
|
+
delete data[entries[i][0]];
|
|
1816
|
+
}
|
|
1817
|
+
try {
|
|
1818
|
+
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
1819
|
+
this._cache = data;
|
|
1820
|
+
this._logger?.warn?.("[LayoutPersistence] Pruned old layouts due to quota");
|
|
1821
|
+
} catch (err) {
|
|
1822
|
+
console.error("[LayoutPersistence] Still cannot save after pruning:", err);
|
|
1823
|
+
}
|
|
956
1824
|
}
|
|
957
1825
|
/**
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
* ResizeObserver: the grid already knows when a resize ended, and an observer
|
|
962
|
-
* per tile fires during the drag, which is a relayout per pointer-move.
|
|
1826
|
+
* Get the template ID associated with a saved layout.
|
|
1827
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1828
|
+
* @returns {string|null} Template ID or null
|
|
963
1829
|
*/
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
_syncInfoButton() {
|
|
968
|
-
const header = this.element?.querySelector(".tile-header");
|
|
969
|
-
const controls = header?.querySelector(".tile-controls");
|
|
970
|
-
if (!controls) return;
|
|
971
|
-
const docs = (this.config?.docs || "").trim();
|
|
972
|
-
let btn = controls.querySelector(".tile-info-btn");
|
|
973
|
-
if (docs) {
|
|
974
|
-
if (!btn) {
|
|
975
|
-
btn = document.createElement("button");
|
|
976
|
-
btn.className = "tile-info-btn twm-has-tooltip";
|
|
977
|
-
btn.type = "button";
|
|
978
|
-
btn.tabIndex = -1;
|
|
979
|
-
btn.style.cssText = "background:transparent;border:none;padding:2px;color:rgba(255,255,255,0.55);cursor:help;display:inline-flex;align-items:center;justify-content:center;";
|
|
980
|
-
btn.setAttribute("data-tooltip-placement", "bottom");
|
|
981
|
-
btn.setAttribute("data-tooltip-max-width", "420px");
|
|
982
|
-
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">info</span>';
|
|
983
|
-
btn.addEventListener("click", (e) => e.stopPropagation());
|
|
984
|
-
const anchor = controls.querySelector(".tile-expand-btn") ?? controls.querySelector(".tile-remove-btn");
|
|
985
|
-
if (anchor) controls.insertBefore(btn, anchor);
|
|
986
|
-
else controls.appendChild(btn);
|
|
987
|
-
}
|
|
988
|
-
btn.setAttribute("data-tooltip", docs);
|
|
989
|
-
} else if (btn) {
|
|
990
|
-
btn.remove();
|
|
991
|
-
}
|
|
1830
|
+
getTemplateId(contextId) {
|
|
1831
|
+
const all = this._loadAll();
|
|
1832
|
+
return all[contextId]?.templateId || null;
|
|
992
1833
|
}
|
|
993
1834
|
/**
|
|
994
|
-
*
|
|
1835
|
+
* Check if the user has modified the layout since the template was applied.
|
|
1836
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1837
|
+
* @returns {boolean}
|
|
995
1838
|
*/
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
if (this._expandWindow?.isVisible) {
|
|
1000
|
-
this._expandWindow.close();
|
|
1001
|
-
}
|
|
1002
|
-
this._expandWindow = null;
|
|
1003
|
-
if (this.chartInstance) {
|
|
1004
|
-
this.chartInstance.destroy();
|
|
1005
|
-
this.chartInstance = null;
|
|
1006
|
-
}
|
|
1007
|
-
if (this.element && this.element.parentNode) {
|
|
1008
|
-
this.element.parentNode.removeChild(this.element);
|
|
1009
|
-
}
|
|
1010
|
-
this.element = null;
|
|
1011
|
-
this.contentElement = null;
|
|
1012
|
-
this.data = null;
|
|
1013
|
-
this.fullData = null;
|
|
1839
|
+
isTemplateModified(contextId) {
|
|
1840
|
+
const all = this._loadAll();
|
|
1841
|
+
return all[contextId]?.templateModified ?? false;
|
|
1014
1842
|
}
|
|
1843
|
+
// =========================================================================
|
|
1844
|
+
// NAMED CONFIGURATION MANAGEMENT
|
|
1845
|
+
// =========================================================================
|
|
1015
1846
|
/**
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
* EcoAgent's: "Add to Documentation" and "Show in Documentation". Both are
|
|
1020
|
-
* meaningful only for a tile whose `config.sourceCellId` names a notebook
|
|
1021
|
-
* cell, and the first one's hide condition is `sourceCellId && already-added` —
|
|
1022
|
-
* so on a tile that has no `sourceCellId` at all it renders VISIBLE, and
|
|
1023
|
-
* choosing it emits `tile:add-to-documentation` at an application that has no
|
|
1024
|
-
* documentation. Every consumer other than EcoAgent therefore shipped a menu
|
|
1025
|
-
* item that did nothing, and could not remove it without editing this file.
|
|
1026
|
-
*
|
|
1027
|
-
* Returning `[]` drops the whole wrapper — button, dropdown and all — because
|
|
1028
|
-
* a ⋯ button that opens an empty menu reads as a broken feature rather than
|
|
1029
|
-
* an absent one. The default is the two entries above, so EcoAgent's chrome
|
|
1030
|
-
* is unchanged.
|
|
1031
|
-
*
|
|
1032
|
-
* Selection is routed through `_onMenuAction()`; override both together.
|
|
1033
|
-
* @returns {Array<{action: string, label: string, icon?: string, hidden?: boolean}>}
|
|
1847
|
+
* Get all saved configurations for a context.
|
|
1848
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1849
|
+
* @returns {Object} { activeId: string, configs: { id: { name, tiles, createdAt } } }
|
|
1034
1850
|
*/
|
|
1035
|
-
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
action: "show-in-documentation",
|
|
1046
|
-
label: "Show in Documentation",
|
|
1047
|
-
icon: "description",
|
|
1048
|
-
hidden: !linked
|
|
1049
|
-
}
|
|
1050
|
-
];
|
|
1851
|
+
getConfigurations(contextId) {
|
|
1852
|
+
const all = this._loadConfigs();
|
|
1853
|
+
const contextConfigs = all[contextId];
|
|
1854
|
+
if (contextConfigs && Object.keys(contextConfigs.configs || {}).length > 0) {
|
|
1855
|
+
return contextConfigs;
|
|
1856
|
+
}
|
|
1857
|
+
return {
|
|
1858
|
+
activeId: null,
|
|
1859
|
+
configs: {}
|
|
1860
|
+
};
|
|
1051
1861
|
}
|
|
1052
1862
|
/**
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
* @param {
|
|
1058
|
-
* @
|
|
1863
|
+
* Save the current layout as a named configuration.
|
|
1864
|
+
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
1865
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1866
|
+
* @param {string} name - Configuration name
|
|
1867
|
+
* @param {Array|Object} layout - Layout to save
|
|
1868
|
+
* @param {Object} [options] - Additional options
|
|
1869
|
+
* @param {string} [options.templateId] - Template ID that generated this layout
|
|
1870
|
+
* @returns {string} The new configuration ID
|
|
1059
1871
|
*/
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1872
|
+
saveConfiguration(contextId, name, layout, options) {
|
|
1873
|
+
const all = this._loadConfigs();
|
|
1874
|
+
if (!all[contextId]) {
|
|
1875
|
+
all[contextId] = { activeId: null, configs: {} };
|
|
1876
|
+
}
|
|
1877
|
+
const uniqueName = this._getUniqueName(all[contextId].configs, name);
|
|
1878
|
+
const configId = `config-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1879
|
+
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
1880
|
+
all[contextId].configs[configId] = {
|
|
1881
|
+
name: uniqueName,
|
|
1882
|
+
tiles: tilesArray,
|
|
1883
|
+
version: LAYOUT_VERSION,
|
|
1884
|
+
createdAt: Date.now(),
|
|
1885
|
+
templateId: options?.templateId || null
|
|
1886
|
+
};
|
|
1887
|
+
if (!all[contextId].activeId) {
|
|
1888
|
+
all[contextId].activeId = configId;
|
|
1072
1889
|
}
|
|
1890
|
+
this._saveConfigs(all);
|
|
1891
|
+
this._logger?.info?.(`[LayoutPersistence] Saved configuration "${uniqueName}" for context ${contextId}`);
|
|
1892
|
+
return configId;
|
|
1073
1893
|
}
|
|
1074
1894
|
/**
|
|
1075
|
-
*
|
|
1895
|
+
* Generate a unique name by appending (2), (3), etc. if name already exists.
|
|
1896
|
+
* @param {Object} configs - Existing configurations object
|
|
1897
|
+
* @param {string} name - Desired name
|
|
1898
|
+
* @returns {string} Unique name
|
|
1076
1899
|
* @private
|
|
1077
1900
|
*/
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
</button>` : "";
|
|
1084
|
-
const docs = (this.config?.docs || "").trim();
|
|
1085
|
-
const infoBtnHtml = docs ? `<button class="tile-info-btn twm-has-tooltip" type="button" tabindex="-1"
|
|
1086
|
-
data-tooltip="${this.#escapeAttr(docs)}"
|
|
1087
|
-
data-tooltip-placement="bottom"
|
|
1088
|
-
data-tooltip-max-width="420px"
|
|
1089
|
-
style="background:transparent;border:none;padding:2px;color:rgba(255,255,255,0.55);cursor:help;display:inline-flex;align-items:center;justify-content:center;">
|
|
1090
|
-
<span class="material-symbols-outlined" style="font-size:18px;">info</span>
|
|
1091
|
-
</button>` : "";
|
|
1092
|
-
const menuEntries = (this.readonly ? [] : this.menuItems()) ?? [];
|
|
1093
|
-
const menuHtml = menuEntries.length ? `<div class="tile-menu-wrapper">
|
|
1094
|
-
<button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
|
|
1095
|
-
<span class="material-symbols-outlined">more_vert</span>
|
|
1096
|
-
</button>
|
|
1097
|
-
<div class="tile-menu-dropdown" hidden>
|
|
1098
|
-
${menuEntries.map((item) => `
|
|
1099
|
-
<button class="tile-menu-item" data-action="${this.#escapeAttr(item.action)}"${item.hidden ? ' style="display:none"' : ""}>
|
|
1100
|
-
${item.icon ? `<span class="material-symbols-outlined">${this.#escapeAttr(item.icon)}</span>` : ""}
|
|
1101
|
-
<span>${this.#escapeAttr(item.label ?? "")}</span>
|
|
1102
|
-
</button>`).join("")}
|
|
1103
|
-
</div>
|
|
1104
|
-
</div>` : "";
|
|
1105
|
-
if (this.readonly) {
|
|
1106
|
-
header.innerHTML = `
|
|
1107
|
-
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
1108
|
-
<div class="tile-controls">
|
|
1109
|
-
${infoBtnHtml}
|
|
1110
|
-
${expandBtnHtml}
|
|
1111
|
-
</div>
|
|
1112
|
-
`;
|
|
1113
|
-
} else {
|
|
1114
|
-
header.innerHTML = `
|
|
1115
|
-
<span class="tile-drag-handle twm-has-tooltip" data-tooltip="Drag to reposition">
|
|
1116
|
-
<span class="material-symbols-outlined">drag_indicator</span>
|
|
1117
|
-
</span>
|
|
1118
|
-
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
1119
|
-
<div class="tile-controls">
|
|
1120
|
-
<button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
|
|
1121
|
-
<span class="material-symbols-outlined">settings</span>
|
|
1122
|
-
</button>
|
|
1123
|
-
${menuHtml}
|
|
1124
|
-
${infoBtnHtml}
|
|
1125
|
-
${expandBtnHtml}
|
|
1126
|
-
<button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
|
|
1127
|
-
<span class="material-symbols-outlined">close</span>
|
|
1128
|
-
</button>
|
|
1129
|
-
</div>
|
|
1130
|
-
`;
|
|
1131
|
-
}
|
|
1132
|
-
const expandBtn = header.querySelector(".tile-expand-btn");
|
|
1133
|
-
expandBtn?.addEventListener("click", (e) => {
|
|
1134
|
-
e.stopPropagation();
|
|
1135
|
-
this._onExpandClick();
|
|
1136
|
-
});
|
|
1137
|
-
const infoBtn = header.querySelector(".tile-info-btn");
|
|
1138
|
-
infoBtn?.addEventListener("click", (e) => e.stopPropagation());
|
|
1139
|
-
if (!this.readonly) {
|
|
1140
|
-
const configBtn = header.querySelector(".tile-config-btn");
|
|
1141
|
-
configBtn?.addEventListener("click", (e) => {
|
|
1142
|
-
e.stopPropagation();
|
|
1143
|
-
this._onConfigClick();
|
|
1144
|
-
});
|
|
1145
|
-
const menuBtn = header.querySelector(".tile-menu-btn");
|
|
1146
|
-
const menuDropdown = header.querySelector(".tile-menu-dropdown");
|
|
1147
|
-
if (menuBtn && menuDropdown) {
|
|
1148
|
-
menuBtn.addEventListener("click", (e) => {
|
|
1149
|
-
e.stopPropagation();
|
|
1150
|
-
const isOpen = !menuDropdown.hidden;
|
|
1151
|
-
menuDropdown.hidden = !menuDropdown.hidden;
|
|
1152
|
-
if (!isOpen) {
|
|
1153
|
-
const closeMenu = (ev) => {
|
|
1154
|
-
if (!menuDropdown.contains(ev.target) && ev.target !== menuBtn) {
|
|
1155
|
-
menuDropdown.hidden = true;
|
|
1156
|
-
document.removeEventListener("pointerdown", closeMenu, true);
|
|
1157
|
-
}
|
|
1158
|
-
};
|
|
1159
|
-
requestAnimationFrame(() => {
|
|
1160
|
-
document.addEventListener("pointerdown", closeMenu, true);
|
|
1161
|
-
});
|
|
1162
|
-
}
|
|
1163
|
-
});
|
|
1164
|
-
menuDropdown.querySelectorAll(".tile-menu-item").forEach((itemEl) => {
|
|
1165
|
-
itemEl.addEventListener("click", (e) => {
|
|
1166
|
-
e.stopPropagation();
|
|
1167
|
-
menuDropdown.hidden = true;
|
|
1168
|
-
this._onMenuAction(itemEl.dataset.action);
|
|
1169
|
-
});
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
const removeBtn = header.querySelector(".tile-remove-btn");
|
|
1173
|
-
removeBtn?.addEventListener("click", (e) => {
|
|
1174
|
-
e.stopPropagation();
|
|
1175
|
-
this._onRemoveClick();
|
|
1176
|
-
});
|
|
1901
|
+
_getUniqueName(configs, name) {
|
|
1902
|
+
if (!configs) return name;
|
|
1903
|
+
const existingNames = new Set(Object.values(configs).map((c) => c.name));
|
|
1904
|
+
if (!existingNames.has(name)) {
|
|
1905
|
+
return name;
|
|
1177
1906
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const resizeHandle = document.createElement("div");
|
|
1184
|
-
resizeHandle.className = "tile-resize-handle se";
|
|
1185
|
-
resizeHandle.innerHTML = '<span class="material-symbols-outlined">drag_handle</span>';
|
|
1186
|
-
this.element.appendChild(resizeHandle);
|
|
1187
|
-
this.element.addEventListener("contextmenu", (e) => {
|
|
1188
|
-
e.preventDefault();
|
|
1189
|
-
e.stopPropagation();
|
|
1190
|
-
this.eventBus?.emit?.("tile:context-menu", {
|
|
1191
|
-
tileId: this.id,
|
|
1192
|
-
tileType: this.constructor.TYPE,
|
|
1193
|
-
tileTitle: this.constructor.TITLE,
|
|
1194
|
-
config: { ...this.config },
|
|
1195
|
-
clientX: e.clientX,
|
|
1196
|
-
clientY: e.clientY
|
|
1197
|
-
});
|
|
1198
|
-
});
|
|
1907
|
+
let counter = 2;
|
|
1908
|
+
let candidate = `${name} (${counter})`;
|
|
1909
|
+
while (existingNames.has(candidate)) {
|
|
1910
|
+
counter++;
|
|
1911
|
+
candidate = `${name} (${counter})`;
|
|
1199
1912
|
}
|
|
1913
|
+
return candidate;
|
|
1200
1914
|
}
|
|
1201
1915
|
/**
|
|
1202
|
-
*
|
|
1203
|
-
* @
|
|
1916
|
+
* Load a specific configuration.
|
|
1917
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1918
|
+
* @param {string} configId - Configuration ID
|
|
1919
|
+
* @returns {Object|null} Configuration layout or null if not found
|
|
1204
1920
|
*/
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
this.element?.dispatchEvent(new CustomEvent("tile:config-request", {
|
|
1215
|
-
bubbles: true,
|
|
1216
|
-
detail: {
|
|
1217
|
-
tileId: this.id,
|
|
1218
|
-
tileType: this.constructor.TYPE,
|
|
1219
|
-
config: this.config,
|
|
1220
|
-
schema: this.getConfigSchema()
|
|
1221
|
-
}
|
|
1222
|
-
}));
|
|
1921
|
+
loadConfiguration(contextId, configId) {
|
|
1922
|
+
const all = this._loadConfigs();
|
|
1923
|
+
const config = all[contextId]?.configs?.[configId];
|
|
1924
|
+
if (config) {
|
|
1925
|
+
all[contextId].activeId = configId;
|
|
1926
|
+
this._saveConfigs(all);
|
|
1927
|
+
return config;
|
|
1928
|
+
}
|
|
1929
|
+
return null;
|
|
1223
1930
|
}
|
|
1224
1931
|
/**
|
|
1225
|
-
*
|
|
1226
|
-
* @param {
|
|
1227
|
-
* @
|
|
1932
|
+
* Delete a saved configuration.
|
|
1933
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1934
|
+
* @param {string} configId - Configuration ID
|
|
1935
|
+
* @returns {boolean} True if deleted, false if not found
|
|
1228
1936
|
*/
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1937
|
+
deleteConfiguration(contextId, configId) {
|
|
1938
|
+
const all = this._loadConfigs();
|
|
1939
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
1940
|
+
return false;
|
|
1941
|
+
}
|
|
1942
|
+
delete all[contextId].configs[configId];
|
|
1943
|
+
if (all[contextId].activeId === configId) {
|
|
1944
|
+
const remaining = Object.keys(all[contextId].configs);
|
|
1945
|
+
all[contextId].activeId = remaining.length > 0 ? remaining[0] : null;
|
|
1946
|
+
}
|
|
1947
|
+
this._saveConfigs(all);
|
|
1948
|
+
this._logger?.info?.(`[LayoutPersistence] Deleted configuration ${configId} for context ${contextId}`);
|
|
1949
|
+
return true;
|
|
1238
1950
|
}
|
|
1239
1951
|
/**
|
|
1240
|
-
*
|
|
1241
|
-
* @param {string}
|
|
1952
|
+
* Update an existing configuration's layout (without creating a new ID).
|
|
1953
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1954
|
+
* @param {string} configId - Configuration ID
|
|
1955
|
+
* @param {Array|Object} layout - New layout to save
|
|
1956
|
+
* @returns {boolean} True if updated, false if not found
|
|
1242
1957
|
*/
|
|
1243
|
-
|
|
1244
|
-
const
|
|
1245
|
-
if (
|
|
1246
|
-
|
|
1958
|
+
updateConfiguration(contextId, configId, layout) {
|
|
1959
|
+
const all = this._loadConfigs();
|
|
1960
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
1961
|
+
return false;
|
|
1247
1962
|
}
|
|
1963
|
+
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
1964
|
+
all[contextId].configs[configId].tiles = tilesArray;
|
|
1965
|
+
all[contextId].configs[configId].version = LAYOUT_VERSION;
|
|
1966
|
+
all[contextId].configs[configId].updatedAt = Date.now();
|
|
1967
|
+
this._saveConfigs(all);
|
|
1968
|
+
return true;
|
|
1248
1969
|
}
|
|
1249
1970
|
/**
|
|
1250
|
-
*
|
|
1971
|
+
* Update the templateId on an existing configuration.
|
|
1972
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1973
|
+
* @param {string} configId - Configuration ID
|
|
1974
|
+
* @param {string} templateId - New template ID
|
|
1975
|
+
* @returns {boolean} True if updated, false if not found
|
|
1251
1976
|
*/
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
<span class="material-symbols-outlined spinning">progress_activity</span>
|
|
1257
|
-
<span>Loading...</span>
|
|
1258
|
-
</div>
|
|
1259
|
-
`;
|
|
1977
|
+
updateConfigurationTemplateId(contextId, configId, templateId) {
|
|
1978
|
+
const all = this._loadConfigs();
|
|
1979
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
1980
|
+
return false;
|
|
1260
1981
|
}
|
|
1982
|
+
all[contextId].configs[configId].templateId = templateId;
|
|
1983
|
+
this._saveConfigs(all);
|
|
1984
|
+
return true;
|
|
1261
1985
|
}
|
|
1262
1986
|
/**
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
* what that means in HTML.
|
|
1270
|
-
*
|
|
1271
|
-
* @param {string} [message] - Custom message
|
|
1987
|
+
* Rename a configuration.
|
|
1988
|
+
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
1989
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
1990
|
+
* @param {string} configId - Configuration ID
|
|
1991
|
+
* @param {string} newName - New name
|
|
1992
|
+
* @returns {string|false} The actual name used (may differ if duplicate), or false if not found
|
|
1272
1993
|
*/
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
<span class="material-symbols-outlined">inbox</span>
|
|
1278
|
-
<span>${this.#escapeAttr(message)}</span>
|
|
1279
|
-
</div>
|
|
1280
|
-
`;
|
|
1994
|
+
renameConfiguration(contextId, configId, newName) {
|
|
1995
|
+
const all = this._loadConfigs();
|
|
1996
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
1997
|
+
return false;
|
|
1281
1998
|
}
|
|
1999
|
+
const otherConfigs = {};
|
|
2000
|
+
Object.entries(all[contextId].configs).forEach(([id, config]) => {
|
|
2001
|
+
if (id !== configId) {
|
|
2002
|
+
otherConfigs[id] = config;
|
|
2003
|
+
}
|
|
2004
|
+
});
|
|
2005
|
+
const uniqueName = this._getUniqueName(otherConfigs, newName);
|
|
2006
|
+
all[contextId].configs[configId].name = uniqueName;
|
|
2007
|
+
this._saveConfigs(all);
|
|
2008
|
+
return uniqueName;
|
|
1282
2009
|
}
|
|
1283
2010
|
/**
|
|
1284
|
-
*
|
|
1285
|
-
* @param {string}
|
|
2011
|
+
* Get the active configuration for a context.
|
|
2012
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
2013
|
+
* @returns {Object|null} { id, name, tiles } or null
|
|
1286
2014
|
*/
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
2015
|
+
getActiveConfiguration(contextId) {
|
|
2016
|
+
const all = this._loadConfigs();
|
|
2017
|
+
const contextConfigs = all[contextId];
|
|
2018
|
+
if (!contextConfigs?.activeId) {
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
const activeConfig = contextConfigs.configs[contextConfigs.activeId];
|
|
2022
|
+
if (activeConfig) {
|
|
2023
|
+
return {
|
|
2024
|
+
id: contextConfigs.activeId,
|
|
2025
|
+
...activeConfig
|
|
2026
|
+
};
|
|
1295
2027
|
}
|
|
2028
|
+
return null;
|
|
1296
2029
|
}
|
|
1297
2030
|
/**
|
|
1298
|
-
*
|
|
1299
|
-
* @param {
|
|
1300
|
-
* @param {
|
|
1301
|
-
* @returns {string} Formatted string
|
|
2031
|
+
* Set the active configuration.
|
|
2032
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
2033
|
+
* @param {string} configId - Configuration ID
|
|
1302
2034
|
*/
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
} else if (abs >= 1e6) {
|
|
1309
|
-
return (value / 1e6).toFixed(1) + "M";
|
|
1310
|
-
} else if (abs >= 1e3) {
|
|
1311
|
-
return (value / 1e3).toFixed(1) + "K";
|
|
1312
|
-
} else if (abs < 0.01 && abs > 0) {
|
|
1313
|
-
return value.toExponential(decimals);
|
|
2035
|
+
setActiveConfiguration(contextId, configId) {
|
|
2036
|
+
const all = this._loadConfigs();
|
|
2037
|
+
if (all[contextId]?.configs?.[configId]) {
|
|
2038
|
+
all[contextId].activeId = configId;
|
|
2039
|
+
this._saveConfigs(all);
|
|
1314
2040
|
}
|
|
1315
|
-
return value.toFixed(decimals);
|
|
1316
2041
|
}
|
|
1317
2042
|
/**
|
|
1318
|
-
*
|
|
1319
|
-
* @param {
|
|
1320
|
-
* @returns {Array
|
|
2043
|
+
* List all configurations for a context.
|
|
2044
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
2045
|
+
* @returns {Array} Array of { id, name, createdAt, isActive }
|
|
1321
2046
|
*/
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
const
|
|
1325
|
-
if (
|
|
1326
|
-
|
|
1327
|
-
variables.push({ value: name, label: name, category: "Stocks" });
|
|
1328
|
-
});
|
|
1329
|
-
}
|
|
1330
|
-
if (data.flows) {
|
|
1331
|
-
Object.keys(data.flows).forEach((name) => {
|
|
1332
|
-
variables.push({ value: name, label: name, category: "Flows" });
|
|
1333
|
-
});
|
|
1334
|
-
}
|
|
1335
|
-
if (data.indicators) {
|
|
1336
|
-
Object.keys(data.indicators).forEach((name) => {
|
|
1337
|
-
variables.push({ value: name, label: name, category: "Indicators" });
|
|
1338
|
-
});
|
|
2047
|
+
listConfigurations(contextId) {
|
|
2048
|
+
const all = this._loadConfigs();
|
|
2049
|
+
const contextConfigs = all[contextId];
|
|
2050
|
+
if (!contextConfigs?.configs) {
|
|
2051
|
+
return [];
|
|
1339
2052
|
}
|
|
1340
|
-
return
|
|
2053
|
+
return Object.entries(contextConfigs.configs).map(([id, config]) => ({
|
|
2054
|
+
id,
|
|
2055
|
+
name: config.name,
|
|
2056
|
+
createdAt: config.createdAt,
|
|
2057
|
+
isActive: id === contextConfigs.activeId
|
|
2058
|
+
})).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
1341
2059
|
}
|
|
1342
|
-
// =========================================================================
|
|
1343
|
-
// EXPAND WINDOW FUNCTIONALITY
|
|
1344
|
-
// Reuses PlotPopoutWindow's patterns and CSS classes
|
|
1345
|
-
// =========================================================================
|
|
1346
2060
|
/**
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
2061
|
+
* Get the template set fingerprint for a context.
|
|
2062
|
+
* Used to detect when templates have changed and configs need regeneration.
|
|
2063
|
+
* @param {string} contextId
|
|
2064
|
+
* @returns {string|null}
|
|
1349
2065
|
*/
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
if (!expandData) {
|
|
1354
|
-
console.warn(`[TileBase] No expand data from widget ${this.id}`);
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
this._openExpandWindow(expandData);
|
|
2066
|
+
getTemplateSetId(contextId) {
|
|
2067
|
+
const all = this._loadConfigs();
|
|
2068
|
+
return all[contextId]?.templateSetId || null;
|
|
1358
2069
|
}
|
|
1359
2070
|
/**
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
* @
|
|
2071
|
+
* Store the template set fingerprint for a context.
|
|
2072
|
+
* @param {string} contextId
|
|
2073
|
+
* @param {string} templateSetId
|
|
1363
2074
|
*/
|
|
1364
|
-
|
|
1365
|
-
|
|
2075
|
+
setTemplateSetId(contextId, templateSetId) {
|
|
2076
|
+
const all = this._loadConfigs();
|
|
2077
|
+
if (!all[contextId]) {
|
|
2078
|
+
all[contextId] = { activeId: null, configs: {} };
|
|
2079
|
+
}
|
|
2080
|
+
all[contextId].templateSetId = templateSetId;
|
|
2081
|
+
this._saveConfigs(all);
|
|
1366
2082
|
}
|
|
1367
2083
|
/**
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
* provides proper toolbar layout, download buttons, and data table.
|
|
1371
|
-
* @param {Object} expandData - Data from getExpandData()
|
|
1372
|
-
* @private
|
|
2084
|
+
* Delete ALL configurations for a context.
|
|
2085
|
+
* @param {string} contextId
|
|
1373
2086
|
*/
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
}
|
|
1379
|
-
this._expandWindow = await openRawTracesWindow({
|
|
1380
|
-
host: this.host,
|
|
1381
|
-
id: `tile-expand-${this.id}`,
|
|
1382
|
-
title: expandData.title || this.constructor.TITLE,
|
|
1383
|
-
traces: expandData.traces,
|
|
1384
|
-
layout: expandData.layout,
|
|
1385
|
-
frames: expandData.frames,
|
|
1386
|
-
tableHeaders: expandData.tableHeaders,
|
|
1387
|
-
tableRows: expandData.tableRows,
|
|
1388
|
-
services: { eventBus: this.eventBus }
|
|
1389
|
-
});
|
|
2087
|
+
clearConfigurations(contextId) {
|
|
2088
|
+
const all = this._loadConfigs();
|
|
2089
|
+
delete all[contextId];
|
|
2090
|
+
this._saveConfigs(all);
|
|
1390
2091
|
}
|
|
1391
2092
|
/**
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
2093
|
+
* Load all configurations from storage.
|
|
2094
|
+
* @returns {Object}
|
|
1394
2095
|
* @private
|
|
1395
2096
|
*/
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
2097
|
+
_loadConfigs() {
|
|
2098
|
+
try {
|
|
2099
|
+
const raw = this._storage?.getItem(this._configsKey);
|
|
2100
|
+
if (raw) {
|
|
2101
|
+
return JSON.parse(raw);
|
|
2102
|
+
}
|
|
2103
|
+
} catch (err) {
|
|
2104
|
+
console.error("[LayoutPersistence] Failed to load configurations:", err);
|
|
2105
|
+
}
|
|
2106
|
+
return {};
|
|
1404
2107
|
}
|
|
1405
2108
|
/**
|
|
1406
|
-
*
|
|
2109
|
+
* Save all configurations to storage.
|
|
2110
|
+
* @param {Object} data
|
|
1407
2111
|
* @private
|
|
1408
2112
|
*/
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
}
|
|
2113
|
+
_saveConfigs(data) {
|
|
2114
|
+
try {
|
|
2115
|
+
this._storage?.setItem(this._configsKey, JSON.stringify(data));
|
|
2116
|
+
} catch (err) {
|
|
2117
|
+
console.error("[LayoutPersistence] Failed to save configurations:", err);
|
|
2118
|
+
}
|
|
1415
2119
|
}
|
|
1416
2120
|
};
|
|
2121
|
+
var instance = null;
|
|
2122
|
+
var _options = {};
|
|
2123
|
+
function configureLayoutPersistence(options = {}) {
|
|
2124
|
+
_options = options;
|
|
2125
|
+
instance = null;
|
|
2126
|
+
}
|
|
2127
|
+
function getLayoutPersistence() {
|
|
2128
|
+
if (!instance) {
|
|
2129
|
+
instance = new LayoutPersistence(_options);
|
|
2130
|
+
}
|
|
2131
|
+
return instance;
|
|
2132
|
+
}
|
|
1417
2133
|
|
|
1418
2134
|
// src/tiles/tile_grid.js
|
|
1419
2135
|
init_tile_registry();
|
|
@@ -1884,7 +2600,7 @@ var TileGrid = class {
|
|
|
1884
2600
|
if (this.data) {
|
|
1885
2601
|
state.tile.fullData = this.fullData;
|
|
1886
2602
|
state.tile.update(this.data);
|
|
1887
|
-
} else
|
|
2603
|
+
} else {
|
|
1888
2604
|
state.tile.onResize?.();
|
|
1889
2605
|
}
|
|
1890
2606
|
}
|
|
@@ -2169,7 +2885,10 @@ var TileGrid = class {
|
|
|
2169
2885
|
// tiles.js
|
|
2170
2886
|
init_tile_registry();
|
|
2171
2887
|
export {
|
|
2888
|
+
CHART_TILE_TYPES,
|
|
2889
|
+
ChartTile,
|
|
2172
2890
|
FieldTypes,
|
|
2891
|
+
KpiTile,
|
|
2173
2892
|
LayoutPersistence,
|
|
2174
2893
|
TileBase,
|
|
2175
2894
|
TileGrid,
|
|
@@ -2187,6 +2906,8 @@ export {
|
|
|
2187
2906
|
getWidgetMetadata,
|
|
2188
2907
|
hasWidget,
|
|
2189
2908
|
mergeWithDefaults,
|
|
2909
|
+
normalizeChartData,
|
|
2910
|
+
registerChartTiles,
|
|
2190
2911
|
registerWidget,
|
|
2191
2912
|
setTileLogger,
|
|
2192
2913
|
validateConfig,
|