flexdesk 0.1.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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
package/dist/tiles.js ADDED
@@ -0,0 +1,2068 @@
1
+ import {
2
+ ActionDropdown
3
+ } from "./chunk-TLZUUFOE.js";
4
+ import {
5
+ openRawTracesWindow
6
+ } from "./chunk-DRYCDMEG.js";
7
+ import "./chunk-CT4YXXLP.js";
8
+ import "./chunk-UCJ2WD4D.js";
9
+ import "./chunk-FL5KFNQH.js";
10
+ import {
11
+ TileRegistry,
12
+ createWidget,
13
+ getAllWidgetTypes,
14
+ getWidget,
15
+ getWidgetCatalog,
16
+ getWidgetMetadata,
17
+ hasWidget,
18
+ init_tile_registry,
19
+ registerWidget,
20
+ setTileLogger,
21
+ tile_registry_exports
22
+ } from "./chunk-FOOS3T5L.js";
23
+ import {
24
+ __toCommonJS
25
+ } from "./chunk-JYWURG5T.js";
26
+
27
+ // src/tiles/config_schema.js
28
+ var FieldTypes = {
29
+ TEXT: "text",
30
+ NUMBER: "number",
31
+ CHECKBOX: "checkbox",
32
+ SELECT: "select",
33
+ MULTISELECT: "multiselect",
34
+ COLOR: "color",
35
+ RANGE: "range"
36
+ };
37
+ function validateField(value, field) {
38
+ if (field.required && (value == null || value === "")) {
39
+ return { valid: false, error: `${field.label} is required` };
40
+ }
41
+ switch (field.type) {
42
+ case FieldTypes.NUMBER:
43
+ case FieldTypes.RANGE:
44
+ if (value != null && value !== "") {
45
+ const num = parseFloat(value);
46
+ if (isNaN(num)) {
47
+ return { valid: false, error: `${field.label} must be a number` };
48
+ }
49
+ if (field.min != null && num < field.min) {
50
+ return { valid: false, error: `${field.label} must be at least ${field.min}` };
51
+ }
52
+ if (field.max != null && num > field.max) {
53
+ return { valid: false, error: `${field.label} must be at most ${field.max}` };
54
+ }
55
+ }
56
+ break;
57
+ case FieldTypes.SELECT:
58
+ if (field.required && !value) {
59
+ return { valid: false, error: `${field.label} must be selected` };
60
+ }
61
+ break;
62
+ case FieldTypes.MULTISELECT:
63
+ if (field.required && (!Array.isArray(value) || value.length === 0)) {
64
+ return { valid: false, error: `${field.label} must have at least one selection` };
65
+ }
66
+ break;
67
+ }
68
+ return { valid: true };
69
+ }
70
+ function validateConfig(config, schema) {
71
+ const errors = {};
72
+ let valid = true;
73
+ if (!schema?.fields) {
74
+ return { valid: true, errors: {} };
75
+ }
76
+ schema.fields.forEach((field) => {
77
+ const result = validateField(config[field.key], field);
78
+ if (!result.valid) {
79
+ errors[field.key] = result.error;
80
+ valid = false;
81
+ }
82
+ });
83
+ return { valid, errors };
84
+ }
85
+ function mergeWithDefaults(config, schema) {
86
+ const result = { ...config };
87
+ if (schema?.fields) {
88
+ schema.fields.forEach((field) => {
89
+ if (result[field.key] === void 0 && field.default !== void 0) {
90
+ result[field.key] = field.default;
91
+ }
92
+ });
93
+ }
94
+ return result;
95
+ }
96
+ function getFieldOptions(field, data) {
97
+ if (Array.isArray(field.options)) {
98
+ return field.options;
99
+ }
100
+ if (field.options === "variables") {
101
+ return getVariableOptions(data);
102
+ }
103
+ if (field.options === "namespaces") {
104
+ return getNamespaceOptions(data);
105
+ }
106
+ return [];
107
+ }
108
+ function formatVariableLabel(name) {
109
+ if (!name) return "";
110
+ let label = name;
111
+ const dotIdx = label.indexOf(".");
112
+ if (dotIdx > 0) {
113
+ label = label.slice(dotIdx + 1);
114
+ }
115
+ label = label.replace(/^__\w+__::/, "");
116
+ label = label.replace(/\[State\]$/i, "");
117
+ label = label.replace(/\[Rate\]$/i, "");
118
+ label = label.replace(/\[\]$/, "");
119
+ label = label.replace(/::/g, " / ");
120
+ label = label.replace(/:/g, "_");
121
+ return label.replace(/\s{2,}/g, " ").trim();
122
+ }
123
+ function getVariableOptions(data) {
124
+ const options = [];
125
+ if (!data) return options;
126
+ if (data.stocks) {
127
+ Object.keys(data.stocks).forEach((name) => {
128
+ options.push({
129
+ value: name,
130
+ label: formatVariableLabel(name),
131
+ category: "Stocks"
132
+ });
133
+ });
134
+ }
135
+ if (data.flows) {
136
+ Object.keys(data.flows).forEach((name) => {
137
+ options.push({
138
+ value: name,
139
+ label: formatVariableLabel(name),
140
+ category: "Flows"
141
+ });
142
+ });
143
+ }
144
+ if (data.indicators) {
145
+ Object.keys(data.indicators).forEach((name) => {
146
+ options.push({
147
+ value: name,
148
+ label: formatVariableLabel(name),
149
+ category: "Indicators"
150
+ });
151
+ });
152
+ }
153
+ return options;
154
+ }
155
+ function getNamespaceOptions(data) {
156
+ const namespaces = /* @__PURE__ */ new Set();
157
+ const sources = [data?.stocks, data?.flows, data?.indicators];
158
+ for (const source of sources) {
159
+ if (!source) continue;
160
+ for (const key of Object.keys(source)) {
161
+ const dotIdx = key.indexOf(".");
162
+ if (dotIdx > 0) {
163
+ namespaces.add(key.slice(0, dotIdx));
164
+ }
165
+ }
166
+ }
167
+ if (namespaces.size === 0) namespaces.add("Main");
168
+ return Array.from(namespaces).sort().map((ns) => ({ value: ns, label: ns }));
169
+ }
170
+
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 {
176
+ 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;
189
+ }
190
+ /**
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
196
+ */
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
+ };
211
+ }
212
+ return { tiles: [], version: LAYOUT_VERSION };
213
+ }
214
+ /**
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
218
+ */
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}`);
231
+ }
232
+ /**
233
+ * Delete layout for a specific context.
234
+ * @param {string} contextId - Whatever the app keys layouts by.
235
+ */
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}`);
242
+ }
243
+ }
244
+ /**
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).
255
+ */
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;
266
+ }
267
+ /**
268
+ * Check if a context has a saved layout.
269
+ * @param {string} contextId - Whatever the app keys layouts by.
270
+ * @returns {boolean}
271
+ */
272
+ hasLayout(contextId) {
273
+ const all = this._loadAll();
274
+ return !!all[contextId];
275
+ }
276
+ /**
277
+ * Get all context IDs with saved layouts.
278
+ * @returns {Array<string>}
279
+ */
280
+ getAllContextIds() {
281
+ const all = this._loadAll();
282
+ return Object.keys(all);
283
+ }
284
+ /**
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
296
+ */
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
+ }
311
+ }
312
+ /**
313
+ * Clear all saved layouts.
314
+ */
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
+ }
323
+ }
324
+ /**
325
+ * Load all layouts from storage.
326
+ * @returns {Object}
327
+ * @private
328
+ */
329
+ _loadAll() {
330
+ if (this._cache) {
331
+ return this._cache;
332
+ }
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);
341
+ }
342
+ this._cache = {};
343
+ return this._cache;
344
+ }
345
+ /**
346
+ * Save all layouts to storage.
347
+ * @param {Object} data
348
+ * @private
349
+ */
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);
358
+ }
359
+ }
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]];
372
+ }
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);
379
+ }
380
+ }
381
+ /**
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
385
+ */
386
+ getTemplateId(contextId) {
387
+ const all = this._loadAll();
388
+ return all[contextId]?.templateId || null;
389
+ }
390
+ /**
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}
394
+ */
395
+ isTemplateModified(contextId) {
396
+ const all = this._loadAll();
397
+ return all[contextId]?.templateModified ?? false;
398
+ }
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;
412
+ }
413
+ return {
414
+ activeId: null,
415
+ configs: {}
416
+ };
417
+ }
418
+ /**
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
427
+ */
428
+ saveConfiguration(contextId, name, layout, options) {
429
+ const all = this._loadConfigs();
430
+ if (!all[contextId]) {
431
+ all[contextId] = { activeId: null, configs: {} };
432
+ }
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;
445
+ }
446
+ this._saveConfigs(all);
447
+ this._logger?.info?.(`[LayoutPersistence] Saved configuration "${uniqueName}" for context ${contextId}`);
448
+ return configId;
449
+ }
450
+ /**
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})`;
468
+ }
469
+ return candidate;
470
+ }
471
+ /**
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
476
+ */
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;
484
+ }
485
+ return null;
486
+ }
487
+ /**
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
492
+ */
493
+ deleteConfiguration(contextId, configId) {
494
+ const all = this._loadConfigs();
495
+ if (!all[contextId]?.configs?.[configId]) {
496
+ return false;
497
+ }
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;
502
+ }
503
+ this._saveConfigs(all);
504
+ this._logger?.info?.(`[LayoutPersistence] Deleted configuration ${configId} for context ${contextId}`);
505
+ return true;
506
+ }
507
+ /**
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
513
+ */
514
+ updateConfiguration(contextId, configId, layout) {
515
+ const all = this._loadConfigs();
516
+ if (!all[contextId]?.configs?.[configId]) {
517
+ return false;
518
+ }
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
+ }
526
+ /**
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
532
+ */
533
+ updateConfigurationTemplateId(contextId, configId, templateId) {
534
+ const all = this._loadConfigs();
535
+ if (!all[contextId]?.configs?.[configId]) {
536
+ return false;
537
+ }
538
+ all[contextId].configs[configId].templateId = templateId;
539
+ this._saveConfigs(all);
540
+ return true;
541
+ }
542
+ /**
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
549
+ */
550
+ renameConfiguration(contextId, configId, newName) {
551
+ const all = this._loadConfigs();
552
+ if (!all[contextId]?.configs?.[configId]) {
553
+ return false;
554
+ }
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;
565
+ }
566
+ /**
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
570
+ */
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
+ }
584
+ return null;
585
+ }
586
+ /**
587
+ * Set the active configuration.
588
+ * @param {string} contextId - Whatever the app keys layouts by.
589
+ * @param {string} configId - Configuration ID
590
+ */
591
+ setActiveConfiguration(contextId, configId) {
592
+ const all = this._loadConfigs();
593
+ if (all[contextId]?.configs?.[configId]) {
594
+ all[contextId].activeId = configId;
595
+ this._saveConfigs(all);
596
+ }
597
+ }
598
+ /**
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 }
602
+ */
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));
615
+ }
616
+ /**
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}
621
+ */
622
+ getTemplateSetId(contextId) {
623
+ const all = this._loadConfigs();
624
+ return all[contextId]?.templateSetId || null;
625
+ }
626
+ /**
627
+ * Store the template set fingerprint for a context.
628
+ * @param {string} contextId
629
+ * @param {string} templateSetId
630
+ */
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);
638
+ }
639
+ /**
640
+ * Delete ALL configurations for a context.
641
+ * @param {string} contextId
642
+ */
643
+ clearConfigurations(contextId) {
644
+ const all = this._loadConfigs();
645
+ delete all[contextId];
646
+ this._saveConfigs(all);
647
+ }
648
+ /**
649
+ * Load all configurations from storage.
650
+ * @returns {Object}
651
+ * @private
652
+ */
653
+ _loadConfigs() {
654
+ try {
655
+ const raw = this._storage?.getItem(this._configsKey);
656
+ if (raw) {
657
+ return JSON.parse(raw);
658
+ }
659
+ } catch (err) {
660
+ console.error("[LayoutPersistence] Failed to load configurations:", err);
661
+ }
662
+ return {};
663
+ }
664
+ /**
665
+ * Save all configurations to storage.
666
+ * @param {Object} data
667
+ * @private
668
+ */
669
+ _saveConfigs(data) {
670
+ try {
671
+ this._storage?.setItem(this._configsKey, JSON.stringify(data));
672
+ } catch (err) {
673
+ console.error("[LayoutPersistence] Failed to save configurations:", err);
674
+ }
675
+ }
676
+ };
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);
686
+ }
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
+ */
712
+ constructor({
713
+ id,
714
+ grid,
715
+ eventBus = null,
716
+ config = {},
717
+ readonly = false,
718
+ headless = false,
719
+ host = null,
720
+ stateGuard = null
721
+ }) {
722
+ this.host = host;
723
+ this.stateGuard = stateGuard;
724
+ this.id = id;
725
+ this.grid = grid;
726
+ this.eventBus = eventBus;
727
+ this.config = { ...this.getDefaultConfig(), ...config };
728
+ this.readonly = readonly;
729
+ this.headless = headless;
730
+ this.element = null;
731
+ this.contentElement = null;
732
+ this.chartInstance = null;
733
+ this.data = null;
734
+ this.fullData = null;
735
+ this._disposed = false;
736
+ this.comparisonData = null;
737
+ }
738
+ /**
739
+ * Set comparison data for overlay rendering.
740
+ * Only meaningful for widgets where SUPPORTS_COMPARISON = true.
741
+ * @param {Map<string, Object>|null} comparisonData
742
+ */
743
+ setComparisonData(comparisonData) {
744
+ this.comparisonData = comparisonData;
745
+ if (this.data && this.contentElement) {
746
+ this.render(this.data);
747
+ }
748
+ }
749
+ /**
750
+ * Format a raw analytics variable key into a user-friendly display label.
751
+ * Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
752
+ * @param {string} name - Raw variable key
753
+ * @returns {string} Friendly display label
754
+ */
755
+ formatLabel(name) {
756
+ return formatVariableLabel(name);
757
+ }
758
+ /**
759
+ * Look up the unit string for a variable from analytics metadata.
760
+ * Checks stocks, flows, and indicators for unit metadata.
761
+ * @param {string} varName - Variable name
762
+ * @returns {string|null} Unit string or null
763
+ */
764
+ getVariableUnit(varName) {
765
+ if (!varName || !this.data) return null;
766
+ const sources = [this.data.stocks, this.data.flows, this.data.indicators];
767
+ for (const source of sources) {
768
+ const varData = source?.[varName];
769
+ if (varData?.unit) return varData.unit;
770
+ }
771
+ return null;
772
+ }
773
+ /**
774
+ * Look up a variable across stocks, flows, and indicators.
775
+ * If the variable is not found, returns the first available variable
776
+ * and updates the config key so the widget self-heals.
777
+ * @param {Object} data - Analytics data
778
+ * @param {string} configKey - Config property name (e.g. 'variable', 'xVariable')
779
+ * @returns {{varData: any, varName: string}|null} Resolved data or null if no data at all
780
+ */
781
+ resolveVariable(data, configKey = "variable") {
782
+ const varName = this.config[configKey];
783
+ if (varName) {
784
+ const varData = data.stocks?.[varName] || data.indicators?.[varName] || data.flows?.[varName];
785
+ if (varData) return { varData, varName };
786
+ const dotIdx = varName.indexOf(".");
787
+ if (dotIdx >= 0) {
788
+ const bare = varName.slice(dotIdx + 1);
789
+ const bareData = data.stocks?.[bare] || data.indicators?.[bare] || data.flows?.[bare];
790
+ if (bareData) return { varData: bareData, varName: bare };
791
+ }
792
+ if (dotIdx < 0) {
793
+ const suffix = `.${varName}`;
794
+ for (const source of [data.stocks, data.indicators, data.flows]) {
795
+ if (!source) continue;
796
+ const match = Object.keys(source).find((k) => k.endsWith(suffix));
797
+ if (match) return { varData: source[match], varName: match };
798
+ }
799
+ }
800
+ if (this.fullData && this.fullData !== data) {
801
+ const fullVarData = this.fullData.stocks?.[varName] || this.fullData.indicators?.[varName] || this.fullData.flows?.[varName];
802
+ if (fullVarData) return { varData: fullVarData, varName };
803
+ if (dotIdx >= 0) {
804
+ const bare = varName.slice(dotIdx + 1);
805
+ const fullBareData = this.fullData.stocks?.[bare] || this.fullData.indicators?.[bare] || this.fullData.flows?.[bare];
806
+ if (fullBareData) return { varData: fullBareData, varName: bare };
807
+ }
808
+ if (dotIdx < 0) {
809
+ const suffix = `.${varName}`;
810
+ for (const source of [this.fullData.stocks, this.fullData.indicators, this.fullData.flows]) {
811
+ if (!source) continue;
812
+ const match = Object.keys(source).find((k) => k.endsWith(suffix));
813
+ if (match) return { varData: source[match], varName: match };
814
+ }
815
+ }
816
+ }
817
+ }
818
+ const sources = [data.stocks, data.indicators, data.flows];
819
+ for (const source of sources) {
820
+ if (!source) continue;
821
+ const keys = Object.keys(source);
822
+ if (keys.length > 0) {
823
+ this.config[configKey] = keys[0];
824
+ return { varData: source[keys[0]], varName: keys[0] };
825
+ }
826
+ }
827
+ return null;
828
+ }
829
+ /**
830
+ * Find a variable by name in data, trying exact match then suffix match.
831
+ * Does NOT auto-fallback to first available variable.
832
+ * @param {Object} data - Analytics data with stocks/indicators/flows
833
+ * @param {string} varName - Variable name (bare or namespace-prefixed)
834
+ * @returns {{varData: any, varName: string}|null}
835
+ */
836
+ static findVariable(data, varName) {
837
+ if (!data || !varName) return null;
838
+ for (const source of [data.stocks, data.indicators, data.flows]) {
839
+ if (!source) continue;
840
+ if (source[varName]) return { varData: source[varName], varName };
841
+ }
842
+ const dotIdx = varName.indexOf(".");
843
+ if (dotIdx < 0) {
844
+ const suffix = `.${varName}`;
845
+ for (const source of [data.stocks, data.indicators, data.flows]) {
846
+ if (!source) continue;
847
+ const match = Object.keys(source).find((k) => k.endsWith(suffix));
848
+ if (match) return { varData: source[match], varName: match };
849
+ }
850
+ } else {
851
+ const bare = varName.slice(dotIdx + 1);
852
+ for (const source of [data.stocks, data.indicators, data.flows]) {
853
+ if (!source) continue;
854
+ if (source[bare]) return { varData: source[bare], varName: bare };
855
+ }
856
+ }
857
+ return null;
858
+ }
859
+ /**
860
+ * Get default configuration for this widget type.
861
+ * Override in subclass.
862
+ * @returns {Object} Default config object
863
+ */
864
+ getDefaultConfig() {
865
+ return {};
866
+ }
867
+ /**
868
+ * Get configuration schema for the config modal.
869
+ * Override in subclass.
870
+ * @returns {Object} Schema definition
871
+ */
872
+ getConfigSchema() {
873
+ return { fields: [] };
874
+ }
875
+ /**
876
+ * Render widget content with data.
877
+ * Override in subclass.
878
+ * @param {Object} data - Analytics data
879
+ */
880
+ render(data) {
881
+ throw new Error("TileBase.render() must be implemented by subclass");
882
+ }
883
+ /**
884
+ * Mount the tile into the container.
885
+ * @param {HTMLElement} container - Parent container
886
+ */
887
+ mount(container) {
888
+ if (this._disposed) return;
889
+ this.element = document.createElement("div");
890
+ this.element.className = `tile${this.readonly ? " tile--readonly" : ""}`;
891
+ const stateGuard = this.stateGuard;
892
+ const setDataset = () => {
893
+ this.element.dataset.tileId = this.id;
894
+ this.element.dataset.tileType = this.constructor.TYPE;
895
+ };
896
+ if (stateGuard?.executeWithBypass) {
897
+ stateGuard.executeWithBypass("tile-mount", setDataset);
898
+ } else {
899
+ setDataset();
900
+ }
901
+ if (this.headless) {
902
+ this.contentElement = document.createElement("div");
903
+ this.contentElement.className = "tile-content";
904
+ this.element.appendChild(this.contentElement);
905
+ } else {
906
+ this._buildChrome();
907
+ }
908
+ container.appendChild(this.element);
909
+ }
910
+ /**
911
+ * Update the widget with new data and/or config.
912
+ * @param {Object} [data] - New analytics data
913
+ * @param {Object} [config] - New config values to merge
914
+ */
915
+ update(data, config) {
916
+ if (this._disposed) return;
917
+ if (config) {
918
+ const prevDocs = (this.config?.docs || "").trim();
919
+ this.config = { ...this.config, ...config };
920
+ const nextDocs = (this.config?.docs || "").trim();
921
+ if (this.element && !this.headless && prevDocs !== nextDocs) {
922
+ this._syncInfoButton();
923
+ }
924
+ }
925
+ if (data !== void 0) {
926
+ this.data = data;
927
+ }
928
+ if (this.data && this.contentElement) {
929
+ this.render(this.data);
930
+ }
931
+ }
932
+ /** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
933
+ _syncInfoButton() {
934
+ const header = this.element?.querySelector(".tile-header");
935
+ const controls = header?.querySelector(".tile-controls");
936
+ if (!controls) return;
937
+ const docs = (this.config?.docs || "").trim();
938
+ let btn = controls.querySelector(".tile-info-btn");
939
+ if (docs) {
940
+ if (!btn) {
941
+ btn = document.createElement("button");
942
+ btn.className = "tile-info-btn twm-has-tooltip";
943
+ btn.type = "button";
944
+ btn.tabIndex = -1;
945
+ 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;";
946
+ btn.setAttribute("data-tooltip-placement", "bottom");
947
+ btn.setAttribute("data-tooltip-max-width", "420px");
948
+ btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">info</span>';
949
+ btn.addEventListener("click", (e) => e.stopPropagation());
950
+ const anchor = controls.querySelector(".tile-expand-btn") ?? controls.querySelector(".tile-remove-btn");
951
+ if (anchor) controls.insertBefore(btn, anchor);
952
+ else controls.appendChild(btn);
953
+ }
954
+ btn.setAttribute("data-tooltip", docs);
955
+ } else if (btn) {
956
+ btn.remove();
957
+ }
958
+ }
959
+ /**
960
+ * Clean up resources and remove from DOM.
961
+ */
962
+ dispose() {
963
+ if (this._disposed) return;
964
+ this._disposed = true;
965
+ if (this._expandWindow?.isVisible) {
966
+ this._expandWindow.close();
967
+ }
968
+ this._expandWindow = null;
969
+ if (this.chartInstance) {
970
+ this.chartInstance.destroy();
971
+ this.chartInstance = null;
972
+ }
973
+ if (this.element && this.element.parentNode) {
974
+ this.element.parentNode.removeChild(this.element);
975
+ }
976
+ this.element = null;
977
+ this.contentElement = null;
978
+ this.data = null;
979
+ this.fullData = null;
980
+ }
981
+ /**
982
+ * Build the tile chrome (header, content area, resize handles).
983
+ * @private
984
+ */
985
+ _buildChrome() {
986
+ const header = document.createElement("div");
987
+ header.className = "tile-header";
988
+ const expandBtnHtml = this.constructor.EXPANDABLE ? `<button class="tile-expand-btn twm-has-tooltip" data-tooltip="Open in window">
989
+ <span class="material-symbols-outlined">open_in_new</span>
990
+ </button>` : "";
991
+ const docs = (this.config?.docs || "").trim();
992
+ const infoBtnHtml = docs ? `<button class="tile-info-btn twm-has-tooltip" type="button" tabindex="-1"
993
+ data-tooltip="${this.#escapeAttr(docs)}"
994
+ data-tooltip-placement="bottom"
995
+ data-tooltip-max-width="420px"
996
+ 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;">
997
+ <span class="material-symbols-outlined" style="font-size:18px;">info</span>
998
+ </button>` : "";
999
+ if (this.readonly) {
1000
+ header.innerHTML = `
1001
+ <span class="tile-title">${this.constructor.TITLE}</span>
1002
+ <div class="tile-controls">
1003
+ ${infoBtnHtml}
1004
+ ${expandBtnHtml}
1005
+ </div>
1006
+ `;
1007
+ } else {
1008
+ header.innerHTML = `
1009
+ <span class="tile-drag-handle twm-has-tooltip" data-tooltip="Drag to reposition">
1010
+ <span class="material-symbols-outlined">drag_indicator</span>
1011
+ </span>
1012
+ <span class="tile-title">${this.constructor.TITLE}</span>
1013
+ <div class="tile-controls">
1014
+ <button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
1015
+ <span class="material-symbols-outlined">settings</span>
1016
+ </button>
1017
+ <div class="tile-menu-wrapper">
1018
+ <button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
1019
+ <span class="material-symbols-outlined">more_vert</span>
1020
+ </button>
1021
+ <div class="tile-menu-dropdown" hidden>
1022
+ <button class="tile-menu-item" data-action="add-to-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? "display:none" : ""}">
1023
+ <span class="material-symbols-outlined">post_add</span>
1024
+ <span>Add to Documentation</span>
1025
+ </button>
1026
+ <button class="tile-menu-item" data-action="show-in-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? "" : "display:none"}">
1027
+ <span class="material-symbols-outlined">description</span>
1028
+ <span>Show in Documentation</span>
1029
+ </button>
1030
+ </div>
1031
+ </div>
1032
+ ${infoBtnHtml}
1033
+ ${expandBtnHtml}
1034
+ <button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
1035
+ <span class="material-symbols-outlined">close</span>
1036
+ </button>
1037
+ </div>
1038
+ `;
1039
+ }
1040
+ const expandBtn = header.querySelector(".tile-expand-btn");
1041
+ expandBtn?.addEventListener("click", (e) => {
1042
+ e.stopPropagation();
1043
+ this._onExpandClick();
1044
+ });
1045
+ const infoBtn = header.querySelector(".tile-info-btn");
1046
+ infoBtn?.addEventListener("click", (e) => e.stopPropagation());
1047
+ if (!this.readonly) {
1048
+ const configBtn = header.querySelector(".tile-config-btn");
1049
+ configBtn?.addEventListener("click", (e) => {
1050
+ e.stopPropagation();
1051
+ this._onConfigClick();
1052
+ });
1053
+ const menuBtn = header.querySelector(".tile-menu-btn");
1054
+ const menuDropdown = header.querySelector(".tile-menu-dropdown");
1055
+ if (menuBtn && menuDropdown) {
1056
+ menuBtn.addEventListener("click", (e) => {
1057
+ e.stopPropagation();
1058
+ const isOpen = !menuDropdown.hidden;
1059
+ menuDropdown.hidden = !menuDropdown.hidden;
1060
+ if (!isOpen) {
1061
+ const closeMenu = (ev) => {
1062
+ if (!menuDropdown.contains(ev.target) && ev.target !== menuBtn) {
1063
+ menuDropdown.hidden = true;
1064
+ document.removeEventListener("pointerdown", closeMenu, true);
1065
+ }
1066
+ };
1067
+ requestAnimationFrame(() => {
1068
+ document.addEventListener("pointerdown", closeMenu, true);
1069
+ });
1070
+ }
1071
+ });
1072
+ menuDropdown.querySelector('[data-action="add-to-documentation"]')?.addEventListener("click", (e) => {
1073
+ e.stopPropagation();
1074
+ menuDropdown.hidden = true;
1075
+ this.eventBus?.emit?.("tile:add-to-documentation", {
1076
+ tileType: this.constructor.TYPE,
1077
+ config: { ...this.config }
1078
+ });
1079
+ });
1080
+ menuDropdown.querySelector('[data-action="show-in-documentation"]')?.addEventListener("click", (e) => {
1081
+ e.stopPropagation();
1082
+ menuDropdown.hidden = true;
1083
+ if (this.config?.sourceCellId) {
1084
+ this.eventBus?.emit?.("tile:show-in-documentation", {
1085
+ sourceCellId: this.config.sourceCellId
1086
+ });
1087
+ }
1088
+ });
1089
+ }
1090
+ const removeBtn = header.querySelector(".tile-remove-btn");
1091
+ removeBtn?.addEventListener("click", (e) => {
1092
+ e.stopPropagation();
1093
+ this._onRemoveClick();
1094
+ });
1095
+ }
1096
+ this.contentElement = document.createElement("div");
1097
+ this.contentElement.className = "tile-content";
1098
+ this.element.appendChild(header);
1099
+ this.element.appendChild(this.contentElement);
1100
+ if (!this.readonly) {
1101
+ const resizeHandle = document.createElement("div");
1102
+ resizeHandle.className = "tile-resize-handle se";
1103
+ resizeHandle.innerHTML = '<span class="material-symbols-outlined">drag_handle</span>';
1104
+ this.element.appendChild(resizeHandle);
1105
+ this.element.addEventListener("contextmenu", (e) => {
1106
+ e.preventDefault();
1107
+ e.stopPropagation();
1108
+ this.eventBus?.emit?.("tile:context-menu", {
1109
+ tileId: this.id,
1110
+ tileType: this.constructor.TYPE,
1111
+ tileTitle: this.constructor.TITLE,
1112
+ config: { ...this.config },
1113
+ clientX: e.clientX,
1114
+ clientY: e.clientY
1115
+ });
1116
+ });
1117
+ }
1118
+ }
1119
+ /**
1120
+ * Handle config button click — highlight tile and emit config request.
1121
+ * @private
1122
+ */
1123
+ _onConfigClick() {
1124
+ this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el) => el.classList.remove("tile--selected"));
1125
+ this.element?.classList.add("tile--selected");
1126
+ this.eventBus?.emit?.("tile:config-request", {
1127
+ tileId: this.id,
1128
+ tileType: this.constructor.TYPE,
1129
+ config: this.config,
1130
+ schema: this.getConfigSchema()
1131
+ });
1132
+ this.element?.dispatchEvent(new CustomEvent("tile:config-request", {
1133
+ bubbles: true,
1134
+ detail: {
1135
+ tileId: this.id,
1136
+ tileType: this.constructor.TYPE,
1137
+ config: this.config,
1138
+ schema: this.getConfigSchema()
1139
+ }
1140
+ }));
1141
+ }
1142
+ /**
1143
+ * Handle remove button click.
1144
+ * @param {Event} [event] - Original click event
1145
+ * @private
1146
+ */
1147
+ _onRemoveClick(event) {
1148
+ const removeBtn = this.element?.querySelector(".tile-remove-btn");
1149
+ this.element?.dispatchEvent(new CustomEvent("tile:remove-request", {
1150
+ bubbles: true,
1151
+ detail: {
1152
+ tileId: this.id,
1153
+ anchorElement: removeBtn
1154
+ }
1155
+ }));
1156
+ }
1157
+ /**
1158
+ * Set the title displayed in the header.
1159
+ * @param {string} title - New title text
1160
+ */
1161
+ setTitle(title) {
1162
+ const titleEl = this.element?.querySelector(".tile-title");
1163
+ if (titleEl) {
1164
+ titleEl.textContent = title;
1165
+ }
1166
+ }
1167
+ /**
1168
+ * Show loading state in content area.
1169
+ */
1170
+ showLoading() {
1171
+ if (this.contentElement) {
1172
+ this.contentElement.innerHTML = `
1173
+ <div class="tile-loading">
1174
+ <span class="material-symbols-outlined spinning">progress_activity</span>
1175
+ <span>Loading...</span>
1176
+ </div>
1177
+ `;
1178
+ }
1179
+ }
1180
+ /**
1181
+ * Show empty state in content area.
1182
+ * @param {string} [message] - Custom message
1183
+ */
1184
+ showEmpty(message = "No data available") {
1185
+ if (this.contentElement) {
1186
+ this.contentElement.innerHTML = `
1187
+ <div class="tile-empty">
1188
+ <span class="material-symbols-outlined">inbox</span>
1189
+ <span>${message}</span>
1190
+ </div>
1191
+ `;
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Show error state in content area.
1196
+ * @param {string} [message] - Error message
1197
+ */
1198
+ showError(message = "Failed to load data") {
1199
+ if (this.contentElement) {
1200
+ this.contentElement.innerHTML = `
1201
+ <div class="twm-tile-error">
1202
+ <span class="material-symbols-outlined">error</span>
1203
+ <span>${message}</span>
1204
+ </div>
1205
+ `;
1206
+ }
1207
+ }
1208
+ /**
1209
+ * Format a number for display.
1210
+ * @param {number} value - Number to format
1211
+ * @param {number} [decimals=2] - Decimal places
1212
+ * @returns {string} Formatted string
1213
+ */
1214
+ formatNumber(value, decimals = 2) {
1215
+ if (value == null || !Number.isFinite(value)) return "\u2014";
1216
+ const abs = Math.abs(value);
1217
+ if (abs >= 1e9) {
1218
+ return (value / 1e9).toFixed(1) + "B";
1219
+ } else if (abs >= 1e6) {
1220
+ return (value / 1e6).toFixed(1) + "M";
1221
+ } else if (abs >= 1e3) {
1222
+ return (value / 1e3).toFixed(1) + "K";
1223
+ } else if (abs < 0.01 && abs > 0) {
1224
+ return value.toExponential(decimals);
1225
+ }
1226
+ return value.toFixed(decimals);
1227
+ }
1228
+ /**
1229
+ * Get available variables from data for config dropdowns.
1230
+ * @param {Object} data - Analytics data
1231
+ * @returns {Array<{value: string, label: string}>} Variable options
1232
+ */
1233
+ getAvailableVariables(data) {
1234
+ if (!data) return [];
1235
+ const variables = [];
1236
+ if (data.stocks) {
1237
+ Object.keys(data.stocks).forEach((name) => {
1238
+ variables.push({ value: name, label: name, category: "Stocks" });
1239
+ });
1240
+ }
1241
+ if (data.flows) {
1242
+ Object.keys(data.flows).forEach((name) => {
1243
+ variables.push({ value: name, label: name, category: "Flows" });
1244
+ });
1245
+ }
1246
+ if (data.indicators) {
1247
+ Object.keys(data.indicators).forEach((name) => {
1248
+ variables.push({ value: name, label: name, category: "Indicators" });
1249
+ });
1250
+ }
1251
+ return variables;
1252
+ }
1253
+ // =========================================================================
1254
+ // EXPAND WINDOW FUNCTIONALITY
1255
+ // Reuses PlotPopoutWindow's patterns and CSS classes
1256
+ // =========================================================================
1257
+ /**
1258
+ * Handle expand button click.
1259
+ * @private
1260
+ */
1261
+ _onExpandClick() {
1262
+ if (!this.constructor.EXPANDABLE) return;
1263
+ const expandData = this.getExpandData();
1264
+ if (!expandData) {
1265
+ console.warn(`[TileBase] No expand data from widget ${this.id}`);
1266
+ return;
1267
+ }
1268
+ this._openExpandWindow(expandData);
1269
+ }
1270
+ /**
1271
+ * Get data for the expanded window.
1272
+ * Override in subclass for expandable widgets.
1273
+ * @returns {Object|null} { title, traces, layout, tableHeaders, tableRows }
1274
+ */
1275
+ getExpandData() {
1276
+ return null;
1277
+ }
1278
+ /**
1279
+ * Open the expand window with plot and data tabs.
1280
+ * Delegates to openRawTracesWindow (PlotPopoutWindow module) which
1281
+ * provides proper toolbar layout, download buttons, and data table.
1282
+ * @param {Object} expandData - Data from getExpandData()
1283
+ * @private
1284
+ */
1285
+ async _openExpandWindow(expandData) {
1286
+ if (this._expandWindow?.isVisible) {
1287
+ this._expandWindow.bringToFront();
1288
+ return;
1289
+ }
1290
+ this._expandWindow = await openRawTracesWindow({
1291
+ host: this.host,
1292
+ id: `tile-expand-${this.id}`,
1293
+ title: expandData.title || this.constructor.TITLE,
1294
+ traces: expandData.traces,
1295
+ layout: expandData.layout,
1296
+ frames: expandData.frames,
1297
+ tableHeaders: expandData.tableHeaders,
1298
+ tableRows: expandData.tableRows,
1299
+ services: { eventBus: this.eventBus }
1300
+ });
1301
+ }
1302
+ /**
1303
+ * Escape a string for safe insertion into an HTML attribute.
1304
+ * @private
1305
+ */
1306
+ #escapeAttr(s) {
1307
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ({
1308
+ "&": "&amp;",
1309
+ "<": "&lt;",
1310
+ ">": "&gt;",
1311
+ '"': "&quot;",
1312
+ "'": "&#39;"
1313
+ })[c]);
1314
+ }
1315
+ /**
1316
+ * Show toast notification via eventBus.
1317
+ * @private
1318
+ */
1319
+ _notify(title, message, severity = "info") {
1320
+ this.eventBus?.emit?.("toast:show", {
1321
+ title,
1322
+ message,
1323
+ type: severity
1324
+ });
1325
+ }
1326
+ };
1327
+
1328
+ // src/tiles/tile_grid.js
1329
+ init_tile_registry();
1330
+ var TileGrid = class {
1331
+ /**
1332
+ * @param {Object} options
1333
+ * @param {HTMLElement} options.container - Container element for the grid
1334
+ * @param {number} [options.columns=12] - Number of grid columns
1335
+ * @param {number} [options.rowHeight=80] - Height of each grid row in pixels
1336
+ * @param {number} [options.gap=16] - Gap between tiles in pixels
1337
+ * @param {Object} [options.eventBus] - Event bus for cross-component communication
1338
+ * @param {boolean} [options.showToolbar=true] - Whether to show built-in toolbar
1339
+ * @param {Function} [options.onLayoutChange] - Callback when layout changes
1340
+ * @param {Function} [options.onAddWidget] - Callback for add widget action
1341
+ * @param {Function} [options.onResetLayout] - Callback for reset layout action
1342
+ * @param {Function} [options.onExport] - Callback for export action
1343
+ */
1344
+ constructor({
1345
+ container,
1346
+ columns = 12,
1347
+ rowHeight = 80,
1348
+ gap = 16,
1349
+ eventBus = null,
1350
+ showToolbar = true,
1351
+ readonly = false,
1352
+ onLayoutChange,
1353
+ onBeforeLayoutChange,
1354
+ onAddWidget,
1355
+ onResetLayout,
1356
+ onExport,
1357
+ // Handed down to every tile. TileBase used to import appHost() from
1358
+ // ui/js/ecoagent/ to back Expand -> Export CSV/PNG; a grid that has
1359
+ // no host simply gets the Blob-download fallback the host contract
1360
+ // promises. It is not an error to have none.
1361
+ host = null,
1362
+ stateGuard = null
1363
+ }) {
1364
+ this.host = host;
1365
+ this.stateGuard = stateGuard;
1366
+ this.container = container;
1367
+ this.columns = columns;
1368
+ this.rowHeight = rowHeight;
1369
+ this.gap = gap;
1370
+ this.eventBus = eventBus;
1371
+ this.showToolbar = showToolbar;
1372
+ this.readonly = readonly;
1373
+ this._onLayoutChangeCallback = onLayoutChange;
1374
+ this._onBeforeLayoutChangeCallback = onBeforeLayoutChange;
1375
+ this._onAddWidgetCallback = onAddWidget;
1376
+ this._onResetLayoutCallback = onResetLayout;
1377
+ this._onExportCallback = onExport;
1378
+ this.tiles = /* @__PURE__ */ new Map();
1379
+ this.layout = [];
1380
+ this.documentationCellIds = null;
1381
+ this.gridElement = null;
1382
+ this.data = null;
1383
+ this.fullData = null;
1384
+ this._dragState = null;
1385
+ this._resizeState = null;
1386
+ this._ghostElement = null;
1387
+ this._boundOnPointerMove = this._onPointerMove.bind(this);
1388
+ this._boundOnPointerUp = this._onPointerUp.bind(this);
1389
+ this._init();
1390
+ }
1391
+ /**
1392
+ * Initialize the grid container.
1393
+ * @private
1394
+ */
1395
+ _init() {
1396
+ this.gridElement = document.createElement("div");
1397
+ this.gridElement.className = "tile-grid";
1398
+ this.gridElement.style.setProperty("--grid-columns", this.columns);
1399
+ this.gridElement.style.setProperty("--grid-row-height", `${this.rowHeight}px`);
1400
+ this.gridElement.style.setProperty("--grid-gap", `${this.gap}px`);
1401
+ this.container.innerHTML = "";
1402
+ this.container.appendChild(this.gridElement);
1403
+ if (this.showToolbar) {
1404
+ this._buildToolbar();
1405
+ }
1406
+ this.gridElement.addEventListener("tile:config-request", this._onTileConfigRequest.bind(this));
1407
+ this.gridElement.addEventListener("tile:remove-request", this._onTileRemoveRequest.bind(this));
1408
+ document.addEventListener("pointermove", this._boundOnPointerMove);
1409
+ document.addEventListener("pointerup", this._boundOnPointerUp);
1410
+ }
1411
+ /**
1412
+ * Build the dashboard bottom toolbar with add widget dropdown.
1413
+ * @private
1414
+ */
1415
+ _buildToolbar() {
1416
+ const toolbar = document.createElement("div");
1417
+ toolbar.className = "tile-grid-toolbar tile-grid-toolbar--bottom";
1418
+ this._toolbarElement = toolbar;
1419
+ toolbar.innerHTML = `
1420
+ <div class="toolbar-group toolbar-group--main">
1421
+ <div class="add-widget-container">
1422
+ <button class="toolbar-btn add-widget-btn twm-has-tooltip" data-tooltip="Add widget">
1423
+ <span class="material-symbols-outlined">add</span>
1424
+ <span>Add Widget</span>
1425
+ <span class="material-symbols-outlined dropdown-arrow">expand_less</span>
1426
+ </button>
1427
+ </div>
1428
+ <button class="toolbar-btn reset-layout-btn twm-has-tooltip" data-tooltip="Reset to default layout">
1429
+ <span class="material-symbols-outlined">restart_alt</span>
1430
+ <span class="btn-text">Reset</span>
1431
+ </button>
1432
+ <button class="toolbar-btn export-btn twm-has-tooltip" data-tooltip="Export report">
1433
+ <span class="material-symbols-outlined">download</span>
1434
+ <span class="btn-text">Export</span>
1435
+ </button>
1436
+ </div>
1437
+ `;
1438
+ const addBtn = toolbar.querySelector(".add-widget-btn");
1439
+ const catalog = getWidgetCatalog();
1440
+ this._addWidgetDropdown = new ActionDropdown({
1441
+ trigger: addBtn,
1442
+ options: catalog.map((w) => ({ type: w.type, label: w.title, icon: w.icon, description: w.description })),
1443
+ onSelect: (opt) => this._addWidgetFromDropdown(opt.type),
1444
+ className: "add-widget-dropdown-menu"
1445
+ });
1446
+ toolbar.querySelector(".reset-layout-btn")?.addEventListener("click", () => this._resetLayout());
1447
+ toolbar.querySelector(".export-btn")?.addEventListener("click", () => this._exportReport());
1448
+ this.container.appendChild(toolbar);
1449
+ }
1450
+ /**
1451
+ * Add a widget from the dropdown selection.
1452
+ * @param {string} type - Widget type
1453
+ * @private
1454
+ */
1455
+ _addWidgetFromDropdown(type) {
1456
+ import("./tile_registry-6WZPTUZV.js").then(({ getWidget: getWidget2 }) => {
1457
+ const WidgetClass = getWidget2(type);
1458
+ if (WidgetClass) {
1459
+ const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
1460
+ const pos = this.findNextPosition(size.w, size.h);
1461
+ this.addTile(type, { ...pos, ...size });
1462
+ }
1463
+ });
1464
+ }
1465
+ /**
1466
+ * Add a tile to the grid.
1467
+ * @param {string} type - Widget type
1468
+ * @param {Object} position - Grid position {x, y, w, h}
1469
+ * @param {Object} [config] - Widget configuration
1470
+ * @param {string} [id] - Optional tile ID (generated if not provided)
1471
+ * @returns {import('./tile_base.js').TileBase | null} The created tile or null
1472
+ */
1473
+ addTile(type, position, config = {}, id = null) {
1474
+ const tileId = id || `tile-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
1475
+ const tile = createWidget(type, {
1476
+ id: tileId,
1477
+ grid: this,
1478
+ eventBus: this.eventBus,
1479
+ config,
1480
+ readonly: this.readonly,
1481
+ host: this.host,
1482
+ stateGuard: this.stateGuard
1483
+ });
1484
+ if (!tile) return null;
1485
+ const layoutEntry = {
1486
+ id: tileId,
1487
+ type,
1488
+ x: position.x,
1489
+ y: position.y,
1490
+ w: position.w,
1491
+ h: position.h,
1492
+ config
1493
+ };
1494
+ this.layout.push(layoutEntry);
1495
+ this.tiles.set(tileId, tile);
1496
+ tile.mount(this.gridElement);
1497
+ this._applyTilePosition(tile, layoutEntry);
1498
+ this._setupTileDragHandlers(tile);
1499
+ if (this.data) {
1500
+ tile.fullData = this.fullData;
1501
+ tile.update(this.data);
1502
+ }
1503
+ this._emitLayoutChanged();
1504
+ return tile;
1505
+ }
1506
+ /**
1507
+ * Remove a tile from the grid.
1508
+ * @param {string} tileId - Tile ID to remove
1509
+ */
1510
+ removeTile(tileId) {
1511
+ const tile = this.tiles.get(tileId);
1512
+ if (!tile) return;
1513
+ tile.dispose();
1514
+ this.tiles.delete(tileId);
1515
+ this.layout = this.layout.filter((l) => l.id !== tileId);
1516
+ this._emitLayoutChanged();
1517
+ }
1518
+ /**
1519
+ * Update all tiles with new data.
1520
+ * @param {Object} data - Analytics data
1521
+ */
1522
+ setData(data) {
1523
+ this.data = data;
1524
+ this.tiles.forEach((tile) => {
1525
+ tile.fullData = this.fullData;
1526
+ tile.update(data);
1527
+ });
1528
+ }
1529
+ /**
1530
+ * Get current layout.
1531
+ * @returns {Array} Layout array
1532
+ */
1533
+ getLayout() {
1534
+ return this.layout.map((l) => ({
1535
+ id: l.id,
1536
+ type: l.type,
1537
+ x: l.x,
1538
+ y: l.y,
1539
+ w: l.w,
1540
+ h: l.h,
1541
+ config: this.tiles.get(l.id)?.config || l.config
1542
+ }));
1543
+ }
1544
+ /**
1545
+ * Set layout and rebuild tiles.
1546
+ * @param {Array|Object} layout - Layout array or object with tiles property
1547
+ */
1548
+ setLayout(layout) {
1549
+ this.tiles.forEach((tile) => tile.dispose());
1550
+ this.tiles.clear();
1551
+ this.layout = [];
1552
+ this.gridElement.innerHTML = "";
1553
+ const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
1554
+ tilesArray.forEach((item) => {
1555
+ this.addTile(item.type, {
1556
+ x: item.x,
1557
+ y: item.y,
1558
+ w: item.w,
1559
+ h: item.h
1560
+ }, item.config, item.id);
1561
+ });
1562
+ }
1563
+ /**
1564
+ * Apply CSS grid position to a tile element.
1565
+ * @param {import('./tile_base.js').TileBase} tile
1566
+ * @param {{x: number, y: number, w: number, h: number}} position
1567
+ * @private
1568
+ */
1569
+ _applyTilePosition(tile, position) {
1570
+ if (!tile.element) return;
1571
+ tile.element.style.gridColumn = `${position.x + 1} / span ${position.w}`;
1572
+ tile.element.style.gridRow = `${position.y + 1} / span ${position.h}`;
1573
+ }
1574
+ /**
1575
+ * Setup drag and resize handlers for a tile.
1576
+ * @param {import('./tile_base.js').TileBase} tile
1577
+ * @private
1578
+ */
1579
+ _setupTileDragHandlers(tile) {
1580
+ if (!tile.element || this.readonly) return;
1581
+ const dragHandle = tile.element.querySelector(".tile-drag-handle");
1582
+ const resizeHandle = tile.element.querySelector(".tile-resize-handle");
1583
+ if (dragHandle) {
1584
+ dragHandle.addEventListener("pointerdown", (e) => this._onDragStart(tile, e));
1585
+ }
1586
+ if (resizeHandle) {
1587
+ resizeHandle.addEventListener("pointerdown", (e) => this._onResizeStart(tile, e));
1588
+ }
1589
+ }
1590
+ /**
1591
+ * Handle drag start.
1592
+ * @param {import('./tile_base.js').TileBase} tile
1593
+ * @param {PointerEvent} e
1594
+ * @private
1595
+ */
1596
+ _onDragStart(tile, e) {
1597
+ e.preventDefault();
1598
+ e.stopPropagation();
1599
+ const rect = tile.element.getBoundingClientRect();
1600
+ const gridRect = this.gridElement.getBoundingClientRect();
1601
+ const layoutItem = this.layout.find((l) => l.id === tile.id);
1602
+ if (!layoutItem) return;
1603
+ this._emitBeforeLayoutChange();
1604
+ this._dragState = {
1605
+ tile,
1606
+ tileId: tile.id,
1607
+ startX: e.clientX,
1608
+ startY: e.clientY,
1609
+ startGridX: layoutItem.x,
1610
+ startGridY: layoutItem.y,
1611
+ tileRect: rect,
1612
+ gridRect,
1613
+ w: layoutItem.w,
1614
+ h: layoutItem.h
1615
+ };
1616
+ tile.element.classList.add("dragging");
1617
+ this._createGhost(rect);
1618
+ }
1619
+ /**
1620
+ * Handle resize start.
1621
+ * @param {import('./tile_base.js').TileBase} tile
1622
+ * @param {PointerEvent} e
1623
+ * @private
1624
+ */
1625
+ _onResizeStart(tile, e) {
1626
+ e.preventDefault();
1627
+ e.stopPropagation();
1628
+ const layoutItem = this.layout.find((l) => l.id === tile.id);
1629
+ if (!layoutItem) return;
1630
+ const gridRect = this.gridElement.getBoundingClientRect();
1631
+ const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
1632
+ this._emitBeforeLayoutChange();
1633
+ this._resizeState = {
1634
+ tile,
1635
+ tileId: tile.id,
1636
+ startX: e.clientX,
1637
+ startY: e.clientY,
1638
+ startW: layoutItem.w,
1639
+ startH: layoutItem.h,
1640
+ gridRect,
1641
+ cellWidth,
1642
+ x: layoutItem.x,
1643
+ y: layoutItem.y
1644
+ };
1645
+ tile.element.classList.add("resizing");
1646
+ }
1647
+ /**
1648
+ * Handle pointer move (drag/resize).
1649
+ * @param {PointerEvent} e
1650
+ * @private
1651
+ */
1652
+ _onPointerMove(e) {
1653
+ if (this._dragState) {
1654
+ this._onDragMove(e);
1655
+ } else if (this._resizeState) {
1656
+ this._onResizeMove(e);
1657
+ }
1658
+ }
1659
+ /**
1660
+ * Handle drag move.
1661
+ * @param {PointerEvent} e
1662
+ * @private
1663
+ */
1664
+ _onDragMove(e) {
1665
+ const state = this._dragState;
1666
+ if (!state) return;
1667
+ const dx = e.clientX - state.startX;
1668
+ const dy = e.clientY - state.startY;
1669
+ const gridRect = state.gridRect;
1670
+ const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
1671
+ const cellHeight = this.rowHeight;
1672
+ const deltaGridX = Math.round(dx / (cellWidth + this.gap));
1673
+ const deltaGridY = Math.round(dy / (cellHeight + this.gap));
1674
+ let newX = state.startGridX + deltaGridX;
1675
+ let newY = state.startGridY + deltaGridY;
1676
+ newX = Math.max(0, Math.min(newX, this.columns - state.w));
1677
+ newY = Math.max(0, newY);
1678
+ if (this._ghostElement) {
1679
+ const ghostLeft = newX * (cellWidth + this.gap);
1680
+ const ghostTop = newY * (cellHeight + this.gap);
1681
+ this._ghostElement.style.transform = `translate(${ghostLeft}px, ${ghostTop}px)`;
1682
+ this._ghostElement.style.width = `${state.w * cellWidth + (state.w - 1) * this.gap}px`;
1683
+ this._ghostElement.style.height = `${state.h * cellHeight + (state.h - 1) * this.gap}px`;
1684
+ }
1685
+ state.pendingX = newX;
1686
+ state.pendingY = newY;
1687
+ }
1688
+ /**
1689
+ * Handle resize move.
1690
+ * @param {PointerEvent} e
1691
+ * @private
1692
+ */
1693
+ _onResizeMove(e) {
1694
+ const state = this._resizeState;
1695
+ if (!state) return;
1696
+ const dx = e.clientX - state.startX;
1697
+ const dy = e.clientY - state.startY;
1698
+ const deltaW = Math.round(dx / (state.cellWidth + this.gap));
1699
+ const deltaH = Math.round(dy / (this.rowHeight + this.gap));
1700
+ let newW = state.startW + deltaW;
1701
+ let newH = state.startH + deltaH;
1702
+ const tileClass = state.tile.constructor;
1703
+ const constraints = tileClass.SIZE_CONSTRAINTS || { minW: 2, minH: 2, maxW: 12, maxH: 8 };
1704
+ newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
1705
+ newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
1706
+ newW = Math.min(newW, this.columns - state.x);
1707
+ state.tile.element.style.gridColumn = `${state.x + 1} / span ${newW}`;
1708
+ state.tile.element.style.gridRow = `${state.y + 1} / span ${newH}`;
1709
+ state.pendingW = newW;
1710
+ state.pendingH = newH;
1711
+ }
1712
+ /**
1713
+ * Handle pointer up (end drag/resize).
1714
+ * @param {PointerEvent} e
1715
+ * @private
1716
+ */
1717
+ _onPointerUp(e) {
1718
+ if (this._dragState) {
1719
+ this._onDragEnd();
1720
+ } else if (this._resizeState) {
1721
+ this._onResizeEnd();
1722
+ }
1723
+ }
1724
+ /**
1725
+ * Handle drag end.
1726
+ * @private
1727
+ */
1728
+ _onDragEnd() {
1729
+ const state = this._dragState;
1730
+ if (!state) return;
1731
+ state.tile.element.classList.remove("dragging");
1732
+ this._removeGhost();
1733
+ if (state.pendingX !== void 0 && state.pendingY !== void 0) {
1734
+ const layoutItem = this.layout.find((l) => l.id === state.tileId);
1735
+ if (layoutItem) {
1736
+ layoutItem.x = state.pendingX;
1737
+ layoutItem.y = state.pendingY;
1738
+ this._applyTilePosition(state.tile, layoutItem);
1739
+ this._resolveOverlaps(state.tileId);
1740
+ this._emitLayoutChanged();
1741
+ }
1742
+ }
1743
+ this._dragState = null;
1744
+ }
1745
+ /**
1746
+ * Handle resize end.
1747
+ * @private
1748
+ */
1749
+ _onResizeEnd() {
1750
+ const state = this._resizeState;
1751
+ if (!state) return;
1752
+ state.tile.element.classList.remove("resizing");
1753
+ if (state.pendingW !== void 0 && state.pendingH !== void 0) {
1754
+ const layoutItem = this.layout.find((l) => l.id === state.tileId);
1755
+ if (layoutItem) {
1756
+ layoutItem.w = state.pendingW;
1757
+ layoutItem.h = state.pendingH;
1758
+ this._resolveOverlaps(state.tileId);
1759
+ this._emitLayoutChanged();
1760
+ if (this.data) {
1761
+ state.tile.fullData = this.fullData;
1762
+ state.tile.update(this.data);
1763
+ }
1764
+ }
1765
+ }
1766
+ this._resizeState = null;
1767
+ }
1768
+ /**
1769
+ * Check if two layout items overlap.
1770
+ * @param {Object} a - First layout item {x, y, w, h}
1771
+ * @param {Object} b - Second layout item {x, y, w, h}
1772
+ * @returns {boolean}
1773
+ * @private
1774
+ */
1775
+ _tilesOverlap(a, b) {
1776
+ return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
1777
+ }
1778
+ /**
1779
+ * Resolve overlaps by pushing tiles down.
1780
+ * @param {string} sourceTileId - The tile that was resized/moved
1781
+ * @private
1782
+ */
1783
+ _resolveOverlaps(sourceTileId) {
1784
+ const sourceItem = this.layout.find((l) => l.id === sourceTileId);
1785
+ if (!sourceItem) return;
1786
+ const otherTiles = this.layout.filter((l) => l.id !== sourceTileId).sort((a, b) => a.y - b.y || a.x - b.x);
1787
+ let hasChanges = true;
1788
+ let iterations = 0;
1789
+ const maxIterations = 100;
1790
+ while (hasChanges && iterations < maxIterations) {
1791
+ hasChanges = false;
1792
+ iterations++;
1793
+ for (const item of otherTiles) {
1794
+ if (this._tilesOverlap(sourceItem, item)) {
1795
+ const newY = sourceItem.y + sourceItem.h;
1796
+ if (item.y !== newY) {
1797
+ item.y = newY;
1798
+ hasChanges = true;
1799
+ const tile = this.tiles.get(item.id);
1800
+ if (tile) {
1801
+ this._applyTilePosition(tile, item);
1802
+ }
1803
+ }
1804
+ }
1805
+ }
1806
+ for (let i = 0; i < otherTiles.length; i++) {
1807
+ for (let j = i + 1; j < otherTiles.length; j++) {
1808
+ if (this._tilesOverlap(otherTiles[i], otherTiles[j])) {
1809
+ const pusher = otherTiles[i];
1810
+ const pushed = otherTiles[j];
1811
+ const newY = pusher.y + pusher.h;
1812
+ if (pushed.y < newY && this._tilesOverlap(pusher, pushed)) {
1813
+ pushed.y = newY;
1814
+ hasChanges = true;
1815
+ const tile = this.tiles.get(pushed.id);
1816
+ if (tile) {
1817
+ this._applyTilePosition(tile, pushed);
1818
+ }
1819
+ }
1820
+ }
1821
+ }
1822
+ }
1823
+ }
1824
+ this._compactLayout();
1825
+ }
1826
+ /**
1827
+ * Compact the layout by removing vertical gaps.
1828
+ * @private
1829
+ */
1830
+ _compactLayout() {
1831
+ const sortedTiles = [...this.layout].sort((a, b) => a.y - b.y || a.x - b.x);
1832
+ for (const item of sortedTiles) {
1833
+ let newY = 0;
1834
+ while (newY < item.y) {
1835
+ const testPosition = { ...item, y: newY };
1836
+ let hasOverlap = false;
1837
+ for (const other of this.layout) {
1838
+ if (other.id === item.id) continue;
1839
+ if (this._tilesOverlap(testPosition, other)) {
1840
+ hasOverlap = true;
1841
+ newY = other.y + other.h;
1842
+ break;
1843
+ }
1844
+ }
1845
+ if (!hasOverlap) {
1846
+ break;
1847
+ }
1848
+ }
1849
+ if (newY < item.y) {
1850
+ item.y = newY;
1851
+ const tile = this.tiles.get(item.id);
1852
+ if (tile) {
1853
+ this._applyTilePosition(tile, item);
1854
+ }
1855
+ }
1856
+ }
1857
+ }
1858
+ /**
1859
+ * Create ghost element for drag preview.
1860
+ * @param {DOMRect} rect - Original tile rect
1861
+ * @private
1862
+ */
1863
+ _createGhost(rect) {
1864
+ this._ghostElement = document.createElement("div");
1865
+ this._ghostElement.className = "tile-ghost";
1866
+ this.gridElement.appendChild(this._ghostElement);
1867
+ }
1868
+ /**
1869
+ * Remove ghost element.
1870
+ * @private
1871
+ */
1872
+ _removeGhost() {
1873
+ if (this._ghostElement) {
1874
+ this._ghostElement.remove();
1875
+ this._ghostElement = null;
1876
+ }
1877
+ }
1878
+ /**
1879
+ * Find next available position for a new tile.
1880
+ * @param {number} w - Width in cells
1881
+ * @param {number} h - Height in cells
1882
+ * @returns {{x: number, y: number}}
1883
+ */
1884
+ findNextPosition(w, h) {
1885
+ const maxY = Math.max(...this.layout.map((l) => l.y + l.h), 0);
1886
+ const occupied = /* @__PURE__ */ new Set();
1887
+ this.layout.forEach((l) => {
1888
+ for (let x = l.x; x < l.x + l.w; x++) {
1889
+ for (let y = l.y; y < l.y + l.h; y++) {
1890
+ occupied.add(`${x},${y}`);
1891
+ }
1892
+ }
1893
+ });
1894
+ for (let y = 0; y <= maxY + 1; y++) {
1895
+ for (let x = 0; x <= this.columns - w; x++) {
1896
+ let fits = true;
1897
+ for (let dx = 0; dx < w && fits; dx++) {
1898
+ for (let dy = 0; dy < h && fits; dy++) {
1899
+ if (occupied.has(`${x + dx},${y + dy}`)) {
1900
+ fits = false;
1901
+ }
1902
+ }
1903
+ }
1904
+ if (fits) {
1905
+ return { x, y };
1906
+ }
1907
+ }
1908
+ }
1909
+ return { x: 0, y: maxY + 1 };
1910
+ }
1911
+ /**
1912
+ * Handle tile config request.
1913
+ * @param {CustomEvent} e
1914
+ * @private
1915
+ */
1916
+ _onTileConfigRequest(e) {
1917
+ const { tileId, tileType, config, schema } = e.detail;
1918
+ this.eventBus?.emit?.("grid:config-modal-show", {
1919
+ tileId,
1920
+ tileType,
1921
+ config,
1922
+ schema,
1923
+ variables: this.data ? this._getVariableList() : [],
1924
+ onSave: (newConfig) => {
1925
+ const tile = this.tiles.get(tileId);
1926
+ if (tile) {
1927
+ tile.fullData = this.fullData;
1928
+ tile.update(this.data, newConfig);
1929
+ const layoutItem = this.layout.find((l) => l.id === tileId);
1930
+ if (layoutItem) {
1931
+ layoutItem.config = newConfig;
1932
+ }
1933
+ this._emitLayoutChanged();
1934
+ }
1935
+ }
1936
+ });
1937
+ }
1938
+ /**
1939
+ * Handle tile remove request.
1940
+ * Note: Let the event bubble up to the dashboard which handles confirmation.
1941
+ * @param {CustomEvent} e
1942
+ * @private
1943
+ */
1944
+ _onTileRemoveRequest(e) {
1945
+ }
1946
+ /**
1947
+ * Get list of available variables from data.
1948
+ * @returns {Array<{value: string, label: string}>}
1949
+ * @private
1950
+ */
1951
+ _getVariableList() {
1952
+ if (!this.data) return [];
1953
+ const variables = [];
1954
+ if (this.data.stocks) {
1955
+ Object.keys(this.data.stocks).forEach((name) => {
1956
+ variables.push({ value: name, label: name });
1957
+ });
1958
+ }
1959
+ if (this.data.indicators) {
1960
+ Object.keys(this.data.indicators).forEach((name) => {
1961
+ variables.push({ value: name, label: name });
1962
+ });
1963
+ }
1964
+ return variables;
1965
+ }
1966
+ /**
1967
+ * Show add widget menu.
1968
+ * @private
1969
+ */
1970
+ _showAddWidgetMenu() {
1971
+ this.eventBus?.emit?.("grid:add-widget-menu-show", {
1972
+ onSelect: (type) => {
1973
+ const { getWidget: getWidget2 } = (init_tile_registry(), __toCommonJS(tile_registry_exports));
1974
+ const WidgetClass = getWidget2(type);
1975
+ if (WidgetClass) {
1976
+ const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
1977
+ const pos = this.findNextPosition(size.w, size.h);
1978
+ this.addTile(type, { ...pos, ...size });
1979
+ }
1980
+ }
1981
+ });
1982
+ }
1983
+ /**
1984
+ * Reset to default layout.
1985
+ * @private
1986
+ */
1987
+ _resetLayout() {
1988
+ this.eventBus?.emit?.("grid:reset-layout-confirm", {
1989
+ onConfirm: () => {
1990
+ this.eventBus?.emit?.("grid:layout-reset-requested");
1991
+ }
1992
+ });
1993
+ }
1994
+ /**
1995
+ * Export report.
1996
+ * @private
1997
+ */
1998
+ _exportReport() {
1999
+ this.eventBus?.emit?.("grid:export-requested", {
2000
+ gridElement: this.gridElement,
2001
+ layout: this.getLayout()
2002
+ });
2003
+ }
2004
+ /**
2005
+ * Notify that a layout mutation is about to begin (drag/resize start).
2006
+ * @private
2007
+ */
2008
+ _emitBeforeLayoutChange() {
2009
+ if (typeof this._onBeforeLayoutChangeCallback === "function") {
2010
+ this._onBeforeLayoutChangeCallback(this.getLayout());
2011
+ }
2012
+ }
2013
+ /**
2014
+ * Emit layout changed event and call the onLayoutChange callback.
2015
+ * @private
2016
+ */
2017
+ _emitLayoutChanged() {
2018
+ const layout = this.getLayout();
2019
+ this.eventBus?.emit?.("grid:layout-changed", { layout });
2020
+ if (typeof this._onLayoutChangeCallback === "function") {
2021
+ this._onLayoutChangeCallback(layout);
2022
+ }
2023
+ }
2024
+ /**
2025
+ * Clean up resources.
2026
+ */
2027
+ dispose() {
2028
+ document.removeEventListener("pointermove", this._boundOnPointerMove);
2029
+ document.removeEventListener("pointerup", this._boundOnPointerUp);
2030
+ this._addWidgetDropdown?.destroy();
2031
+ this._addWidgetDropdown = null;
2032
+ this.tiles.forEach((tile) => tile.dispose());
2033
+ this.tiles.clear();
2034
+ this.layout = [];
2035
+ if (this.gridElement) {
2036
+ this.gridElement.remove();
2037
+ this.gridElement = null;
2038
+ }
2039
+ }
2040
+ };
2041
+
2042
+ // tiles.js
2043
+ init_tile_registry();
2044
+ export {
2045
+ FieldTypes,
2046
+ LayoutPersistence,
2047
+ TileBase,
2048
+ TileGrid,
2049
+ TileRegistry,
2050
+ configureLayoutPersistence,
2051
+ createWidget,
2052
+ formatVariableLabel,
2053
+ getAllWidgetTypes,
2054
+ getFieldOptions,
2055
+ getLayoutPersistence,
2056
+ getNamespaceOptions,
2057
+ getVariableOptions,
2058
+ getWidget,
2059
+ getWidgetCatalog,
2060
+ getWidgetMetadata,
2061
+ hasWidget,
2062
+ mergeWithDefaults,
2063
+ registerWidget,
2064
+ setTileLogger,
2065
+ validateConfig,
2066
+ validateField
2067
+ };
2068
+ //# sourceMappingURL=tiles.js.map