flexdesk 0.12.1 → 0.14.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/dist/tiles.js CHANGED
@@ -15,8 +15,20 @@ import {
15
15
  ActionDropdown
16
16
  } from "./chunk-O5OHMWBB.js";
17
17
  import {
18
- openRawTracesWindow
19
- } from "./chunk-2LEJQD4H.js";
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/layout_persistence.js
172
- var LAYOUT_VERSION = 3;
173
- var DEFAULT_LAYOUTS_KEY = "twm.tiles.layouts";
174
- var DEFAULT_CONFIGS_KEY = "twm.tiles.configs";
175
- var LayoutPersistence = class {
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
- storage = typeof localStorage !== "undefined" ? localStorage : null,
178
- layoutsKey = DEFAULT_LAYOUTS_KEY,
179
- configsKey = DEFAULT_CONFIGS_KEY,
180
- resolveTemplate = null,
181
- logger = null
182
- } = {}) {
183
- this._cache = null;
184
- this._storage = storage;
185
- this._logger = logger;
186
- this._layoutsKey = layoutsKey;
187
- this._configsKey = configsKey;
188
- this._resolveTemplate = typeof resolveTemplate === "function" ? resolveTemplate : null;
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
- * Get layout for a specific context.
192
- * Falls back to template resolution when no saved layout exists.
193
- * @param {string} contextId - Whatever the app keys layouts by.
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
- getLayout(contextId, context) {
198
- const all = this._loadAll();
199
- const layout = all[contextId];
200
- if (layout && layout.version === LAYOUT_VERSION) {
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
- * Save layout for a specific context.
216
- * @param {string} contextId - Whatever the app keys layouts by.
217
- * @param {Array|Object} layout - Layout array or object with tiles property
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
- saveLayout(contextId, layout) {
220
- const all = this._loadAll();
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
- * Delete layout for a specific context.
234
- * @param {string} contextId - Whatever the app keys layouts by.
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
- deleteLayout(contextId) {
237
- const all = this._loadAll();
238
- if (all[contextId]) {
239
- delete all[contextId];
240
- this._saveAll(all);
241
- this._logger?.info?.(`[LayoutPersistence] Deleted layout for context ${contextId}`);
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
- * Reset a layout by re-resolving the best-matching template.
246
- *
247
- * With no `resolveTemplate` supplied, "reset" means "empty grid" — which is the
248
- * honest answer for a library that has no templates. The previous code called
249
- * `TemplateRegistry.instantiate()` unconditionally and read `.tiles` off the
250
- * result, so a null resolver here would have been a TypeError.
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
- resetLayout(contextId, context) {
257
- const instantiated = this._resolveTemplate?.(context) ?? null;
258
- const layout = {
259
- tiles: instantiated?.tiles ?? [],
260
- version: LAYOUT_VERSION,
261
- templateId: instantiated?.templateId ?? null,
262
- templateModified: false
263
- };
264
- this.saveLayout(contextId, layout);
265
- return layout;
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
- * Check if a context has a saved layout.
269
- * @param {string} contextId - Whatever the app keys layouts by.
270
- * @returns {boolean}
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
- hasLayout(contextId) {
273
- const all = this._loadAll();
274
- return !!all[contextId];
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 all context IDs with saved layouts.
278
- * @returns {Array<string>}
360
+ * Get default configuration for this widget type.
361
+ * Override in subclass.
362
+ * @returns {Object} Default config object
279
363
  */
280
- getAllContextIds() {
281
- const all = this._loadAll();
282
- return Object.keys(all);
364
+ getDefaultConfig() {
365
+ return {};
283
366
  }
284
367
  /**
285
- * Export all layouts as JSON string.
286
- * @returns {string}
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
- importAll(json, merge = false) {
298
- try {
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
- * Clear all saved layouts.
376
+ * Render widget content with data.
377
+ * Override in subclass.
378
+ * @param {Object} data - Analytics data
314
379
  */
315
- clearAll() {
316
- try {
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
- * Load all layouts from storage.
326
- * @returns {Object}
327
- * @private
384
+ * Mount the tile into the container.
385
+ * @param {HTMLElement} container - Parent container
328
386
  */
329
- _loadAll() {
330
- if (this._cache) {
331
- return this._cache;
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
- try {
334
- const raw = this._storage?.getItem(this._layoutsKey);
335
- if (raw) {
336
- this._cache = JSON.parse(raw);
337
- return this._cache;
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._cache = {};
343
- return this._cache;
408
+ container.appendChild(this.element);
344
409
  }
345
410
  /**
346
- * Save all layouts to storage.
347
- * @param {Object} data
348
- * @private
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
- _saveAll(data) {
351
- try {
352
- this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
353
- this._cache = data;
354
- } catch (err) {
355
- console.error("[LayoutPersistence] Failed to save layouts:", err);
356
- if (err.name === "QuotaExceededError") {
357
- this._pruneOldLayouts(data);
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
- try {
374
- this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
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
- * Get the template ID associated with a saved layout.
383
- * @param {string} contextId - Whatever the app keys layouts by.
384
- * @returns {string|null} Template ID or null
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
- getTemplateId(contextId) {
387
- const all = this._loadAll();
388
- return all[contextId]?.templateId || null;
448
+ async loadData(_opts = {}) {
389
449
  }
390
450
  /**
391
- * Check if the user has modified the layout since the template was applied.
392
- * @param {string} contextId - Whatever the app keys layouts by.
393
- * @returns {boolean}
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
- isTemplateModified(contextId) {
396
- const all = this._loadAll();
397
- return all[contextId]?.templateModified ?? false;
457
+ onResize() {
398
458
  }
399
- // =========================================================================
400
- // NAMED CONFIGURATION MANAGEMENT
401
- // =========================================================================
402
- /**
403
- * Get all saved configurations for a context.
404
- * @param {string} contextId - Whatever the app keys layouts by.
405
- * @returns {Object} { activeId: string, configs: { id: { name, tiles, createdAt } } }
406
- */
407
- getConfigurations(contextId) {
408
- const all = this._loadConfigs();
409
- const contextConfigs = all[contextId];
410
- if (contextConfigs && Object.keys(contextConfigs.configs || {}).length > 0) {
411
- return contextConfigs;
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
- * Save the current layout as a named configuration.
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
- saveConfiguration(contextId, name, layout, options) {
429
- const all = this._loadConfigs();
430
- if (!all[contextId]) {
431
- all[contextId] = { activeId: null, configs: {} };
489
+ dispose() {
490
+ if (this._disposed) return;
491
+ this._disposed = true;
492
+ if (this._expandWindow?.isVisible) {
493
+ this._expandWindow.close();
432
494
  }
433
- const uniqueName = this._getUniqueName(all[contextId].configs, name);
434
- const configId = `config-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
435
- const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
436
- all[contextId].configs[configId] = {
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._saveConfigs(all);
447
- this._logger?.info?.(`[LayoutPersistence] Saved configuration "${uniqueName}" for context ${contextId}`);
448
- return configId;
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
- * Generate a unique name by appending (2), (3), etc. if name already exists.
452
- * @param {Object} configs - Existing configurations object
453
- * @param {string} name - Desired name
454
- * @returns {string} Unique name
455
- * @private
456
- */
457
- _getUniqueName(configs, name) {
458
- if (!configs) return name;
459
- const existingNames = new Set(Object.values(configs).map((c) => c.name));
460
- if (!existingNames.has(name)) {
461
- return name;
462
- }
463
- let counter = 2;
464
- let candidate = `${name} (${counter})`;
465
- while (existingNames.has(candidate)) {
466
- counter++;
467
- candidate = `${name} (${counter})`;
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
- * Load a specific configuration.
473
- * @param {string} contextId - Whatever the app keys layouts by.
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
- loadConfiguration(contextId, configId) {
478
- const all = this._loadConfigs();
479
- const config = all[contextId]?.configs?.[configId];
480
- if (config) {
481
- all[contextId].activeId = configId;
482
- this._saveConfigs(all);
483
- return config;
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
- * Delete a saved configuration.
489
- * @param {string} contextId - Whatever the app keys layouts by.
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
- deleteConfiguration(contextId, configId) {
494
- const all = this._loadConfigs();
495
- if (!all[contextId]?.configs?.[configId]) {
496
- return false;
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
- delete all[contextId].configs[configId];
499
- if (all[contextId].activeId === configId) {
500
- const remaining = Object.keys(all[contextId].configs);
501
- all[contextId].activeId = remaining.length > 0 ? remaining[0] : null;
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
- * Update an existing configuration's layout (without creating a new ID).
509
- * @param {string} contextId - Whatever the app keys layouts by.
510
- * @param {string} configId - Configuration ID
511
- * @param {Array|Object} layout - New layout to save
512
- * @returns {boolean} True if updated, false if not found
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
- updateConfiguration(contextId, configId, layout) {
515
- const all = this._loadConfigs();
516
- if (!all[contextId]?.configs?.[configId]) {
517
- return false;
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
- * Update the templateId on an existing configuration.
528
- * @param {string} contextId - Whatever the app keys layouts by.
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
- updateConfigurationTemplateId(contextId, configId, templateId) {
534
- const all = this._loadConfigs();
535
- if (!all[contextId]?.configs?.[configId]) {
536
- return false;
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
- * Rename a configuration.
544
- * Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
545
- * @param {string} contextId - Whatever the app keys layouts by.
546
- * @param {string} configId - Configuration ID
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
- renameConfiguration(contextId, configId, newName) {
551
- const all = this._loadConfigs();
552
- if (!all[contextId]?.configs?.[configId]) {
553
- return false;
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
- const otherConfigs = {};
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 the active configuration for a context.
568
- * @param {string} contextId - Whatever the app keys layouts by.
569
- * @returns {Object|null} { id, name, tiles } or 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
- getActiveConfiguration(contextId) {
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
- * Set the active configuration.
588
- * @param {string} contextId - Whatever the app keys layouts by.
589
- * @param {string} configId - Configuration ID
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
- setActiveConfiguration(contextId, configId) {
592
- const all = this._loadConfigs();
593
- if (all[contextId]?.configs?.[configId]) {
594
- all[contextId].activeId = configId;
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
- * List all configurations for a context.
600
- * @param {string} contextId - Whatever the app keys layouts by.
601
- * @returns {Array} Array of { id, name, createdAt, isActive }
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
- listConfigurations(contextId) {
604
- const all = this._loadConfigs();
605
- const contextConfigs = all[contextId];
606
- if (!contextConfigs?.configs) {
607
- return [];
608
- }
609
- return Object.entries(contextConfigs.configs).map(([id, config]) => ({
610
- id,
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
+ "&": "&amp;",
892
+ "<": "&lt;",
893
+ ">": "&gt;",
894
+ '"': "&quot;",
895
+ "'": "&#39;"
896
+ })[c]);
615
897
  }
616
898
  /**
617
- * Get the template set fingerprint for a context.
618
- * Used to detect when templates have changed and configs need regeneration.
619
- * @param {string} contextId
620
- * @returns {string|null}
899
+ * Show toast notification via eventBus.
900
+ * @private
621
901
  */
622
- getTemplateSetId(contextId) {
623
- const all = this._loadConfigs();
624
- return all[contextId]?.templateSetId || null;
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
- * Store the template set fingerprint for a context.
628
- * @param {string} contextId
629
- * @param {string} templateSetId
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
- setTemplateSetId(contextId, templateSetId) {
632
- const all = this._loadConfigs();
633
- if (!all[contextId]) {
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
- * Delete ALL configurations for a context.
641
- * @param {string} contextId
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
- clearConfigurations(contextId) {
644
- const all = this._loadConfigs();
645
- delete all[contextId];
646
- this._saveConfigs(all);
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
- * Load all configurations from storage.
650
- * @returns {Object}
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
- _loadConfigs() {
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
- const raw = this._storage?.getItem(this._configsKey);
656
- if (raw) {
657
- return JSON.parse(raw);
658
- }
1156
+ plan = this._plan(norm);
659
1157
  } catch (err) {
660
- console.error("[LayoutPersistence] Failed to load configurations:", err);
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
- * Save all configurations to storage.
666
- * @param {Object} data
667
- * @private
668
- */
669
- _saveConfigs(data) {
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
- this._storage?.setItem(this._configsKey, JSON.stringify(data));
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
- console.error("[LayoutPersistence] Failed to save configurations:", err);
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 instance = null;
678
- var _options = {};
679
- function configureLayoutPersistence(options = {}) {
680
- _options = options;
681
- instance = null;
682
- }
683
- function getLayoutPersistence() {
684
- if (!instance) {
685
- instance = new LayoutPersistence(_options);
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
- return instance;
688
- }
689
-
690
- // src/tiles/tile_base.js
691
- var TileBase = class {
692
- /** @type {string} Widget type identifier - override in subclass */
693
- static TYPE = "base";
694
- /** @type {string} Display title - override in subclass */
695
- static TITLE = "Widget";
696
- /** @type {{w: number, h: number}} Default grid size - override in subclass */
697
- static DEFAULT_SIZE = { w: 4, h: 3 };
698
- /** @type {{minW: number, minH: number, maxW: number, maxH: number}} Size constraints */
699
- static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 8 };
700
- /** @type {boolean} Whether widget can be expanded to a separate window - override in subclass */
701
- static EXPANDABLE = false;
702
- /** @type {boolean} Whether widget supports scenario comparison overlay - override in subclass */
703
- static SUPPORTS_COMPARISON = false;
704
- /**
705
- * @param {Object} options
706
- * @param {string} options.id - Unique tile identifier
707
- * @param {Object} options.grid - Parent TileGrid instance
708
- * @param {Object} [options.eventBus] - Event bus for cross-component communication
709
- * @param {Object} [options.config] - Widget-specific configuration
710
- * @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
711
- * @param {{resolve: Function}} [options.dataSource] - Where this tile asks for its
712
- * own rows. Handed down by `TileGrid`; absent for a grid whose data is
713
- * broadcast with `setData()`, and then nothing ever calls `loadData()`.
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
- id,
717
- grid,
718
- eventBus = null,
719
- config = {},
720
- readonly = false,
721
- headless = false,
722
- host = null,
723
- stateGuard = null,
724
- dataSource = null
725
- }) {
726
- this.host = host;
727
- this.dataSource = dataSource;
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
- * Set comparison data for overlay rendering.
747
- * Only meaningful for widgets where SUPPORTS_COMPARISON = true.
748
- * @param {Map<string, Object>|null} comparisonData
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
- setComparisonData(comparisonData) {
751
- this.comparisonData = comparisonData;
752
- if (this.data && this.contentElement) {
753
- this.render(this.data);
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
- * Format a raw analytics variable key into a user-friendly display label.
758
- * Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
759
- * @param {string} name - Raw variable key
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
- formatLabel(name) {
763
- return formatVariableLabel(name);
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
- * Look up the unit string for a variable from analytics metadata.
767
- * Checks stocks, flows, and indicators for unit metadata.
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
- getVariableUnit(varName) {
772
- if (!varName || !this.data) return null;
773
- const sources = [this.data.stocks, this.data.flows, this.data.indicators];
774
- for (const source of sources) {
775
- const varData = source?.[varName];
776
- if (varData?.unit) return varData.unit;
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
- * Look up a variable across stocks, flows, and indicators.
782
- * If the variable is not found, returns the first available variable
783
- * and updates the config key so the widget self-heals.
784
- * @param {Object} data - Analytics data
785
- * @param {string} configKey - Config property name (e.g. 'variable', 'xVariable')
786
- * @returns {{varData: any, varName: string}|null} Resolved data or null if no data at all
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
- resolveVariable(data, configKey = "variable") {
789
- const varName = this.config[configKey];
790
- if (varName) {
791
- const varData = data.stocks?.[varName] || data.indicators?.[varName] || data.flows?.[varName];
792
- if (varData) return { varData, varName };
793
- const dotIdx = varName.indexOf(".");
794
- if (dotIdx >= 0) {
795
- const bare = varName.slice(dotIdx + 1);
796
- const bareData = data.stocks?.[bare] || data.indicators?.[bare] || data.flows?.[bare];
797
- if (bareData) return { varData: bareData, varName: bare };
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
- * Find a variable by name in data, trying exact match then suffix match.
838
- * Does NOT auto-fallback to first available variable.
839
- * @param {Object} data - Analytics data with stocks/indicators/flows
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
- static findVariable(data, varName) {
844
- if (!data || !varName) return null;
845
- for (const source of [data.stocks, data.indicators, data.flows]) {
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 default configuration for this widget type.
868
- * Override in subclass.
869
- * @returns {Object} Default config object
1721
+ * Get all context IDs with saved layouts.
1722
+ * @returns {Array<string>}
870
1723
  */
871
- getDefaultConfig() {
872
- return {};
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
- * Get configuration schema for the config modal.
876
- * Override in subclass.
877
- * @returns {Object} Schema definition
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
- getConfigSchema() {
880
- return { fields: [] };
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
- * Render widget content with data.
884
- * Override in subclass.
885
- * @param {Object} data - Analytics data
1757
+ * Clear all saved layouts.
886
1758
  */
887
- render(data) {
888
- throw new Error("TileBase.render() must be implemented by subclass");
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
- * Mount the tile into the container.
892
- * @param {HTMLElement} container - Parent container
1769
+ * Load all layouts from storage.
1770
+ * @returns {Object}
1771
+ * @private
893
1772
  */
894
- mount(container) {
895
- if (this._disposed) return;
896
- this.element = document.createElement("div");
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
- if (this.headless) {
909
- this.contentElement = document.createElement("div");
910
- this.contentElement.className = "tile-content";
911
- this.element.appendChild(this.contentElement);
912
- } else {
913
- this._buildChrome();
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
- container.appendChild(this.element);
1786
+ this._cache = {};
1787
+ return this._cache;
916
1788
  }
917
1789
  /**
918
- * Update the widget with new data and/or config.
919
- * @param {Object} [data] - New analytics data
920
- * @param {Object} [config] - New config values to merge
1790
+ * Save all layouts to storage.
1791
+ * @param {Object} data
1792
+ * @private
921
1793
  */
922
- update(data, config) {
923
- if (this._disposed) return;
924
- if (config) {
925
- const prevDocs = (this.config?.docs || "").trim();
926
- this.config = { ...this.config, ...config };
927
- const nextDocs = (this.config?.docs || "").trim();
928
- if (this.element && !this.headless && prevDocs !== nextDocs) {
929
- this._syncInfoButton();
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
- * Load this tile's own data from `this.dataSource` and render it.
941
- *
942
- * A NO-OP HERE ON PURPOSE. The base class knows nothing about what a binding
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
- async loadData(_opts = {}) {
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
- * Called once after a drag-resize settles, for widgets whose content does not
959
- * reflow on its own — a Plotly figure sizes itself at draw time and stays that
960
- * size until something calls `Plotly.Plots.resize`. Deliberately NOT wired to a
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
- onResize() {
965
- }
966
- /** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
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
- * Clean up resources and remove from DOM.
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
- dispose() {
997
- if (this._disposed) return;
998
- this._disposed = true;
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
- * The entries in the tile's ⋯ menu, as `{action, label, icon, hidden}`.
1017
- *
1018
- * This existed as a LITERAL inside `_buildChrome`, and the literal was
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
- menuItems() {
1036
- const linked = !!(this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId));
1037
- return [
1038
- {
1039
- action: "add-to-documentation",
1040
- label: "Add to Documentation",
1041
- icon: "post_add",
1042
- hidden: linked
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
- * Act on a ⋯ menu selection. One dispatch point for every entry, so an
1054
- * override of `menuItems()` cannot add a row that has nothing behind it, and
1055
- * an unrecognised action is silently ignored rather than throwing into a
1056
- * click handler.
1057
- * @param {string} action
1058
- * @protected
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
- _onMenuAction(action) {
1061
- if (action === "add-to-documentation") {
1062
- this.eventBus?.emit?.("tile:add-to-documentation", {
1063
- tileType: this.constructor.TYPE,
1064
- config: { ...this.config }
1065
- });
1066
- } else if (action === "show-in-documentation") {
1067
- if (this.config?.sourceCellId) {
1068
- this.eventBus?.emit?.("tile:show-in-documentation", {
1069
- sourceCellId: this.config.sourceCellId
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
- * Build the tile chrome (header, content area, resize handles).
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
- _buildChrome() {
1079
- const header = document.createElement("div");
1080
- header.className = "tile-header";
1081
- const expandBtnHtml = this.constructor.EXPANDABLE ? `<button class="tile-expand-btn twm-has-tooltip" data-tooltip="Open in window">
1082
- <span class="material-symbols-outlined">open_in_new</span>
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
- this.contentElement = document.createElement("div");
1179
- this.contentElement.className = "tile-content";
1180
- this.element.appendChild(header);
1181
- this.element.appendChild(this.contentElement);
1182
- if (!this.readonly) {
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
- * Handle config button click — highlight tile and emit config request.
1203
- * @private
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
- _onConfigClick() {
1206
- this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el) => el.classList.remove("tile--selected"));
1207
- this.element?.classList.add("tile--selected");
1208
- this.eventBus?.emit?.("tile:config-request", {
1209
- tileId: this.id,
1210
- tileType: this.constructor.TYPE,
1211
- config: this.config,
1212
- schema: this.getConfigSchema()
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
- * Handle remove button click.
1226
- * @param {Event} [event] - Original click event
1227
- * @private
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
- _onRemoveClick(event) {
1230
- const removeBtn = this.element?.querySelector(".tile-remove-btn");
1231
- this.element?.dispatchEvent(new CustomEvent("tile:remove-request", {
1232
- bubbles: true,
1233
- detail: {
1234
- tileId: this.id,
1235
- anchorElement: removeBtn
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
- * Set the title displayed in the header.
1241
- * @param {string} title - New title text
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
- setTitle(title) {
1244
- const titleEl = this.element?.querySelector(".tile-title");
1245
- if (titleEl) {
1246
- titleEl.textContent = title;
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
- * Show loading state in content area.
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
- showLoading() {
1253
- if (this.contentElement) {
1254
- this.contentElement.innerHTML = `
1255
- <div class="tile-loading">
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
- * Show empty state in content area.
1264
- *
1265
- * The message is ESCAPED. Both this and `showError` interpolated straight
1266
- * into `innerHTML`, and the string reaching `showError` is the one place a
1267
- * caller has least control over — a server's error text, echoed back with a
1268
- * column name or a filter value in it. Callers pass plain text; this decides
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
- showEmpty(message = "No data available") {
1274
- if (this.contentElement) {
1275
- this.contentElement.innerHTML = `
1276
- <div class="tile-empty">
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
- * Show error state in content area. The message is escaped — see `showEmpty`.
1285
- * @param {string} [message] - Error message
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
- showError(message = "Failed to load data") {
1288
- if (this.contentElement) {
1289
- this.contentElement.innerHTML = `
1290
- <div class="twm-tile-error">
1291
- <span class="material-symbols-outlined">error</span>
1292
- <span>${this.#escapeAttr(message)}</span>
1293
- </div>
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
- * Format a number for display.
1299
- * @param {number} value - Number to format
1300
- * @param {number} [decimals=2] - Decimal places
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
- formatNumber(value, decimals = 2) {
1304
- if (value == null || !Number.isFinite(value)) return "\u2014";
1305
- const abs = Math.abs(value);
1306
- if (abs >= 1e9) {
1307
- return (value / 1e9).toFixed(1) + "B";
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
- * Get available variables from data for config dropdowns.
1319
- * @param {Object} data - Analytics data
1320
- * @returns {Array<{value: string, label: string}>} Variable options
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
- getAvailableVariables(data) {
1323
- if (!data) return [];
1324
- const variables = [];
1325
- if (data.stocks) {
1326
- Object.keys(data.stocks).forEach((name) => {
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 variables;
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
- * Handle expand button click.
1348
- * @private
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
- _onExpandClick() {
1351
- if (!this.constructor.EXPANDABLE) return;
1352
- const expandData = this.getExpandData();
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
- * Get data for the expanded window.
1361
- * Override in subclass for expandable widgets.
1362
- * @returns {Object|null} { title, traces, layout, tableHeaders, tableRows }
2071
+ * Store the template set fingerprint for a context.
2072
+ * @param {string} contextId
2073
+ * @param {string} templateSetId
1363
2074
  */
1364
- getExpandData() {
1365
- return null;
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
- * Open the expand window with plot and data tabs.
1369
- * Delegates to openRawTracesWindow (PlotPopoutWindow module) which
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
- async _openExpandWindow(expandData) {
1375
- if (this._expandWindow?.isVisible) {
1376
- this._expandWindow.bringToFront();
1377
- return;
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
- * Escape a string for safe insertion into HTML — an attribute value or a text
1393
- * node, since the five characters that matter are the same five in both.
2093
+ * Load all configurations from storage.
2094
+ * @returns {Object}
1394
2095
  * @private
1395
2096
  */
1396
- #escapeAttr(s) {
1397
- return String(s ?? "").replace(/[&<>"']/g, (c) => ({
1398
- "&": "&amp;",
1399
- "<": "&lt;",
1400
- ">": "&gt;",
1401
- '"': "&quot;",
1402
- "'": "&#39;"
1403
- })[c]);
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
- * Show toast notification via eventBus.
2109
+ * Save all configurations to storage.
2110
+ * @param {Object} data
1407
2111
  * @private
1408
2112
  */
1409
- _notify(title, message, severity = "info") {
1410
- this.eventBus?.emit?.("toast:show", {
1411
- title,
1412
- message,
1413
- type: severity
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 if (this.dataSource) {
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,