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
@@ -0,0 +1,785 @@
1
+ /**
2
+ * tile_base.js
3
+ *
4
+ * Base class for all dashboard widget tiles.
5
+ * Provides common lifecycle management, tile chrome (header, config button),
6
+ * and abstract methods for subclass implementation.
7
+ */
8
+
9
+ import { openRawTracesWindow } from '../charts/plot_popout_window.js';
10
+ import { formatVariableLabel } from './config_schema.js';
11
+ // The host is INJECTED, not fetched from a global.
12
+ //
13
+ // This imported `appHost()` from ui/js/ecoagent/app_host.js — a tile reaching into
14
+ // the application to find out how to save a file. The framework is injection-only;
15
+ // the app may have a root host provider, but the library does not get to know about
16
+ // it. Its only use was to back "Expand" -> Export CSV/PNG, and a tile without a host
17
+ // falls back to a Blob download, which is exactly what the host contract promises.
18
+
19
+ export class TileBase {
20
+ /** @type {string} Widget type identifier - override in subclass */
21
+ static TYPE = 'base';
22
+
23
+ /** @type {string} Display title - override in subclass */
24
+ static TITLE = 'Widget';
25
+
26
+ /** @type {{w: number, h: number}} Default grid size - override in subclass */
27
+ static DEFAULT_SIZE = { w: 4, h: 3 };
28
+
29
+ /** @type {{minW: number, minH: number, maxW: number, maxH: number}} Size constraints */
30
+ static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 8 };
31
+
32
+ /** @type {boolean} Whether widget can be expanded to a separate window - override in subclass */
33
+ static EXPANDABLE = false;
34
+
35
+ /** @type {boolean} Whether widget supports scenario comparison overlay - override in subclass */
36
+ static SUPPORTS_COMPARISON = false;
37
+
38
+ /**
39
+ * @param {Object} options
40
+ * @param {string} options.id - Unique tile identifier
41
+ * @param {Object} options.grid - Parent TileGrid instance
42
+ * @param {Object} [options.eventBus] - Event bus for cross-component communication
43
+ * @param {Object} [options.config] - Widget-specific configuration
44
+ * @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
45
+ */
46
+ constructor({ id, grid, eventBus = null, config = {}, readonly = false,
47
+ headless = false, host = null, stateGuard = null }) {
48
+ this.host = host;
49
+ // `window.__ECOSIM_JS_NEW__?.stateGuard` — an application's global, reached for
50
+ // by a tile. The StateGuard is a FRAMEWORK service (@flexdesk/core); the INSTANCE is
51
+ // the application's, and it is handed down through the grid. No guard => the
52
+ // dataset writes happen directly, which is what the optional chain already did.
53
+ this.stateGuard = stateGuard;
54
+ this.id = id;
55
+ this.grid = grid;
56
+ this.eventBus = eventBus;
57
+ this.config = { ...this.getDefaultConfig(), ...config };
58
+ this.readonly = readonly;
59
+ this.headless = headless;
60
+ this.element = null;
61
+ this.contentElement = null;
62
+ this.chartInstance = null;
63
+ this.data = null;
64
+ /** @type {Object|null} Unfiltered analytics data for cross-namespace variable resolution */
65
+ this.fullData = null;
66
+ this._disposed = false;
67
+ /** @type {Map<string, {name: string, analytics: Object, color: string}>|null} */
68
+ this.comparisonData = null;
69
+ }
70
+
71
+ /**
72
+ * Set comparison data for overlay rendering.
73
+ * Only meaningful for widgets where SUPPORTS_COMPARISON = true.
74
+ * @param {Map<string, Object>|null} comparisonData
75
+ */
76
+ setComparisonData(comparisonData) {
77
+ this.comparisonData = comparisonData;
78
+ if (this.data && this.contentElement) {
79
+ this.render(this.data);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Format a raw analytics variable key into a user-friendly display label.
85
+ * Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
86
+ * @param {string} name - Raw variable key
87
+ * @returns {string} Friendly display label
88
+ */
89
+ formatLabel(name) {
90
+ return formatVariableLabel(name);
91
+ }
92
+
93
+ /**
94
+ * Look up the unit string for a variable from analytics metadata.
95
+ * Checks stocks, flows, and indicators for unit metadata.
96
+ * @param {string} varName - Variable name
97
+ * @returns {string|null} Unit string or null
98
+ */
99
+ getVariableUnit(varName) {
100
+ if (!varName || !this.data) return null;
101
+ const sources = [this.data.stocks, this.data.flows, this.data.indicators];
102
+ for (const source of sources) {
103
+ const varData = source?.[varName];
104
+ if (varData?.unit) return varData.unit;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Look up a variable across stocks, flows, and indicators.
111
+ * If the variable is not found, returns the first available variable
112
+ * and updates the config key so the widget self-heals.
113
+ * @param {Object} data - Analytics data
114
+ * @param {string} configKey - Config property name (e.g. 'variable', 'xVariable')
115
+ * @returns {{varData: any, varName: string}|null} Resolved data or null if no data at all
116
+ */
117
+ resolveVariable(data, configKey = 'variable') {
118
+ const varName = this.config[configKey];
119
+ if (varName) {
120
+ // Try exact name first
121
+ const varData = data.stocks?.[varName]
122
+ || data.indicators?.[varName]
123
+ || data.flows?.[varName];
124
+ if (varData) return { varData, varName };
125
+
126
+ // Try without namespace prefix (e.g. "Population.population" → "population")
127
+ const dotIdx = varName.indexOf('.');
128
+ if (dotIdx >= 0) {
129
+ const bare = varName.slice(dotIdx + 1);
130
+ const bareData = data.stocks?.[bare]
131
+ || data.indicators?.[bare]
132
+ || data.flows?.[bare];
133
+ if (bareData) return { varData: bareData, varName: bare };
134
+ }
135
+
136
+ // Reverse lookup: bare name → namespace-prefixed key
137
+ // e.g. "population" matches "Population.population" in MC data
138
+ if (dotIdx < 0) {
139
+ const suffix = `.${varName}`;
140
+ for (const source of [data.stocks, data.indicators, data.flows]) {
141
+ if (!source) continue;
142
+ const match = Object.keys(source).find(k => k.endsWith(suffix));
143
+ if (match) return { varData: source[match], varName: match };
144
+ }
145
+ }
146
+
147
+ // Try unfiltered data (variable may be in a different namespace)
148
+ if (this.fullData && this.fullData !== data) {
149
+ const fullVarData = this.fullData.stocks?.[varName]
150
+ || this.fullData.indicators?.[varName]
151
+ || this.fullData.flows?.[varName];
152
+ if (fullVarData) return { varData: fullVarData, varName };
153
+
154
+ // Also try bare name against unfiltered data
155
+ if (dotIdx >= 0) {
156
+ const bare = varName.slice(dotIdx + 1);
157
+ const fullBareData = this.fullData.stocks?.[bare]
158
+ || this.fullData.indicators?.[bare]
159
+ || this.fullData.flows?.[bare];
160
+ if (fullBareData) return { varData: fullBareData, varName: bare };
161
+ }
162
+
163
+ // Reverse lookup against unfiltered data
164
+ if (dotIdx < 0) {
165
+ const suffix = `.${varName}`;
166
+ for (const source of [this.fullData.stocks, this.fullData.indicators, this.fullData.flows]) {
167
+ if (!source) continue;
168
+ const match = Object.keys(source).find(k => k.endsWith(suffix));
169
+ if (match) return { varData: source[match], varName: match };
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ // Configured variable not found — auto-select the first available
176
+ const sources = [data.stocks, data.indicators, data.flows];
177
+ for (const source of sources) {
178
+ if (!source) continue;
179
+ const keys = Object.keys(source);
180
+ if (keys.length > 0) {
181
+ this.config[configKey] = keys[0];
182
+ return { varData: source[keys[0]], varName: keys[0] };
183
+ }
184
+ }
185
+
186
+ return null;
187
+ }
188
+
189
+ /**
190
+ * Find a variable by name in data, trying exact match then suffix match.
191
+ * Does NOT auto-fallback to first available variable.
192
+ * @param {Object} data - Analytics data with stocks/indicators/flows
193
+ * @param {string} varName - Variable name (bare or namespace-prefixed)
194
+ * @returns {{varData: any, varName: string}|null}
195
+ */
196
+ static findVariable(data, varName) {
197
+ if (!data || !varName) return null;
198
+ for (const source of [data.stocks, data.indicators, data.flows]) {
199
+ if (!source) continue;
200
+ if (source[varName]) return { varData: source[varName], varName };
201
+ }
202
+ // Reverse lookup: bare "population" → "Population.population"
203
+ const dotIdx = varName.indexOf('.');
204
+ if (dotIdx < 0) {
205
+ const suffix = `.${varName}`;
206
+ for (const source of [data.stocks, data.indicators, data.flows]) {
207
+ if (!source) continue;
208
+ const match = Object.keys(source).find(k => k.endsWith(suffix));
209
+ if (match) return { varData: source[match], varName: match };
210
+ }
211
+ } else {
212
+ // Strip prefix: "Population.population" → "population"
213
+ const bare = varName.slice(dotIdx + 1);
214
+ for (const source of [data.stocks, data.indicators, data.flows]) {
215
+ if (!source) continue;
216
+ if (source[bare]) return { varData: source[bare], varName: bare };
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * Get default configuration for this widget type.
224
+ * Override in subclass.
225
+ * @returns {Object} Default config object
226
+ */
227
+ getDefaultConfig() {
228
+ return {};
229
+ }
230
+
231
+ /**
232
+ * Get configuration schema for the config modal.
233
+ * Override in subclass.
234
+ * @returns {Object} Schema definition
235
+ */
236
+ getConfigSchema() {
237
+ return { fields: [] };
238
+ }
239
+
240
+ /**
241
+ * Render widget content with data.
242
+ * Override in subclass.
243
+ * @param {Object} data - Analytics data
244
+ */
245
+ render(data) {
246
+ throw new Error('TileBase.render() must be implemented by subclass');
247
+ }
248
+
249
+ /**
250
+ * Mount the tile into the container.
251
+ * @param {HTMLElement} container - Parent container
252
+ */
253
+ mount(container) {
254
+ if (this._disposed) return;
255
+
256
+ this.element = document.createElement('div');
257
+ this.element.className = `tile${this.readonly ? ' tile--readonly' : ''}`;
258
+
259
+ // Use StateGuard bypass if available to set dataset properties
260
+ const stateGuard = this.stateGuard;
261
+ const setDataset = () => {
262
+ this.element.dataset.tileId = this.id;
263
+ this.element.dataset.tileType = this.constructor.TYPE;
264
+ };
265
+ if (stateGuard?.executeWithBypass) {
266
+ stateGuard.executeWithBypass('tile-mount', setDataset);
267
+ } else {
268
+ setDataset();
269
+ }
270
+
271
+ if (this.headless) {
272
+ // Headless mode: content only, no header/controls (for embedding in notebook cells)
273
+ this.contentElement = document.createElement('div');
274
+ this.contentElement.className = 'tile-content';
275
+ this.element.appendChild(this.contentElement);
276
+ } else {
277
+ // Build tile chrome (header + content area)
278
+ this._buildChrome();
279
+ }
280
+
281
+ container.appendChild(this.element);
282
+ }
283
+
284
+ /**
285
+ * Update the widget with new data and/or config.
286
+ * @param {Object} [data] - New analytics data
287
+ * @param {Object} [config] - New config values to merge
288
+ */
289
+ update(data, config) {
290
+ if (this._disposed) return;
291
+
292
+ if (config) {
293
+ const prevDocs = (this.config?.docs || '').trim();
294
+ this.config = { ...this.config, ...config };
295
+ const nextDocs = (this.config?.docs || '').trim();
296
+ // Keep the header's info-button in sync with the docs state
297
+ if (this.element && !this.headless && prevDocs !== nextDocs) {
298
+ this._syncInfoButton();
299
+ }
300
+ }
301
+
302
+ if (data !== undefined) {
303
+ this.data = data;
304
+ }
305
+
306
+ if (this.data && this.contentElement) {
307
+ this.render(this.data);
308
+ }
309
+ }
310
+
311
+ /** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
312
+ _syncInfoButton() {
313
+ const header = this.element?.querySelector('.tile-header');
314
+ const controls = header?.querySelector('.tile-controls');
315
+ if (!controls) return;
316
+ const docs = (this.config?.docs || '').trim();
317
+ let btn = controls.querySelector('.tile-info-btn');
318
+ if (docs) {
319
+ if (!btn) {
320
+ btn = document.createElement('button');
321
+ btn.className = 'tile-info-btn twm-has-tooltip';
322
+ btn.type = 'button';
323
+ btn.tabIndex = -1;
324
+ 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;';
325
+ btn.setAttribute('data-tooltip-placement', 'bottom');
326
+ btn.setAttribute('data-tooltip-max-width', '420px');
327
+ btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">info</span>';
328
+ btn.addEventListener('click', (e) => e.stopPropagation());
329
+ const anchor = controls.querySelector('.tile-expand-btn')
330
+ ?? controls.querySelector('.tile-remove-btn');
331
+ if (anchor) controls.insertBefore(btn, anchor);
332
+ else controls.appendChild(btn);
333
+ }
334
+ btn.setAttribute('data-tooltip', docs);
335
+ } else if (btn) {
336
+ btn.remove();
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Clean up resources and remove from DOM.
342
+ */
343
+ dispose() {
344
+ if (this._disposed) return;
345
+ this._disposed = true;
346
+
347
+ // Close expand window if open
348
+ if (this._expandWindow?.isVisible) {
349
+ this._expandWindow.close();
350
+ }
351
+ this._expandWindow = null;
352
+
353
+ // Destroy chart instance if exists
354
+ if (this.chartInstance) {
355
+ this.chartInstance.destroy();
356
+ this.chartInstance = null;
357
+ }
358
+
359
+ // Remove from DOM
360
+ if (this.element && this.element.parentNode) {
361
+ this.element.parentNode.removeChild(this.element);
362
+ }
363
+
364
+ this.element = null;
365
+ this.contentElement = null;
366
+ this.data = null;
367
+ this.fullData = null;
368
+ }
369
+
370
+ /**
371
+ * Build the tile chrome (header, content area, resize handles).
372
+ * @private
373
+ */
374
+ _buildChrome() {
375
+ // Header with drag handle and controls
376
+ const header = document.createElement('div');
377
+ header.className = 'tile-header';
378
+
379
+ // Build controls HTML - expand button only if widget is expandable
380
+ const expandBtnHtml = this.constructor.EXPANDABLE
381
+ ? `<button class="tile-expand-btn twm-has-tooltip" data-tooltip="Open in window">
382
+ <span class="material-symbols-outlined">open_in_new</span>
383
+ </button>`
384
+ : '';
385
+
386
+ // Info button — only shown if config.docs has text. Uses the
387
+ // standard has-tooltip system so the docs appear as a hover tooltip
388
+ // (auto-positioned) instead of a click-to-open popover.
389
+ const docs = (this.config?.docs || '').trim();
390
+ const infoBtnHtml = docs
391
+ ? `<button class="tile-info-btn twm-has-tooltip" type="button" tabindex="-1"
392
+ data-tooltip="${this.#escapeAttr(docs)}"
393
+ data-tooltip-placement="bottom"
394
+ data-tooltip-max-width="420px"
395
+ 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;">
396
+ <span class="material-symbols-outlined" style="font-size:18px;">info</span>
397
+ </button>`
398
+ : '';
399
+
400
+ if (this.readonly) {
401
+ // Read-only mode: no drag handle, no menu, no remove button
402
+ header.innerHTML = `
403
+ <span class="tile-title">${this.constructor.TITLE}</span>
404
+ <div class="tile-controls">
405
+ ${infoBtnHtml}
406
+ ${expandBtnHtml}
407
+ </div>
408
+ `;
409
+ } else {
410
+ header.innerHTML = `
411
+ <span class="tile-drag-handle twm-has-tooltip" data-tooltip="Drag to reposition">
412
+ <span class="material-symbols-outlined">drag_indicator</span>
413
+ </span>
414
+ <span class="tile-title">${this.constructor.TITLE}</span>
415
+ <div class="tile-controls">
416
+ <button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
417
+ <span class="material-symbols-outlined">settings</span>
418
+ </button>
419
+ <div class="tile-menu-wrapper">
420
+ <button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
421
+ <span class="material-symbols-outlined">more_vert</span>
422
+ </button>
423
+ <div class="tile-menu-dropdown" hidden>
424
+ <button class="tile-menu-item" data-action="add-to-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? 'display:none' : ''}">
425
+ <span class="material-symbols-outlined">post_add</span>
426
+ <span>Add to Documentation</span>
427
+ </button>
428
+ <button class="tile-menu-item" data-action="show-in-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? '' : 'display:none'}">
429
+ <span class="material-symbols-outlined">description</span>
430
+ <span>Show in Documentation</span>
431
+ </button>
432
+ </div>
433
+ </div>
434
+ ${infoBtnHtml}
435
+ ${expandBtnHtml}
436
+ <button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
437
+ <span class="material-symbols-outlined">close</span>
438
+ </button>
439
+ </div>
440
+ `;
441
+ }
442
+
443
+ // Expand button handler
444
+ const expandBtn = header.querySelector('.tile-expand-btn');
445
+ expandBtn?.addEventListener('click', (e) => {
446
+ e.stopPropagation();
447
+ this._onExpandClick();
448
+ });
449
+
450
+ // Info button is purely decorative — the docs are shown by the global
451
+ // tooltip service via data-tooltip. Stop click-propagation so clicks
452
+ // don't open the config panel, and make the button unfocusable.
453
+ const infoBtn = header.querySelector('.tile-info-btn');
454
+ infoBtn?.addEventListener('click', (e) => e.stopPropagation());
455
+
456
+ if (!this.readonly) {
457
+ // Config opens ONLY via the explicit cogwheel button — not a
458
+ // title or content click, which fired the config panel too
459
+ // eagerly (e.g. when you just wanted to read or pan the chart).
460
+ const configBtn = header.querySelector('.tile-config-btn');
461
+ configBtn?.addEventListener('click', (e) => {
462
+ e.stopPropagation();
463
+ this._onConfigClick();
464
+ });
465
+
466
+ // "..." menu button + dropdown
467
+ const menuBtn = header.querySelector('.tile-menu-btn');
468
+ const menuDropdown = header.querySelector('.tile-menu-dropdown');
469
+ if (menuBtn && menuDropdown) {
470
+ menuBtn.addEventListener('click', (e) => {
471
+ e.stopPropagation();
472
+ const isOpen = !menuDropdown.hidden;
473
+ menuDropdown.hidden = !menuDropdown.hidden;
474
+ if (!isOpen) {
475
+ // Close on outside click
476
+ const closeMenu = (ev) => {
477
+ if (!menuDropdown.contains(ev.target) && ev.target !== menuBtn) {
478
+ menuDropdown.hidden = true;
479
+ document.removeEventListener('pointerdown', closeMenu, true);
480
+ }
481
+ };
482
+ // Delay to avoid immediate close from the same click
483
+ requestAnimationFrame(() => {
484
+ document.addEventListener('pointerdown', closeMenu, true);
485
+ });
486
+ }
487
+ });
488
+ menuDropdown.querySelector('[data-action="add-to-documentation"]')?.addEventListener('click', (e) => {
489
+ e.stopPropagation();
490
+ menuDropdown.hidden = true;
491
+ this.eventBus?.emit?.('tile:add-to-documentation', {
492
+ tileType: this.constructor.TYPE,
493
+ config: { ...this.config },
494
+ });
495
+ });
496
+ menuDropdown.querySelector('[data-action="show-in-documentation"]')?.addEventListener('click', (e) => {
497
+ e.stopPropagation();
498
+ menuDropdown.hidden = true;
499
+ if (this.config?.sourceCellId) {
500
+ this.eventBus?.emit?.('tile:show-in-documentation', {
501
+ sourceCellId: this.config.sourceCellId,
502
+ });
503
+ }
504
+ });
505
+ }
506
+
507
+ const removeBtn = header.querySelector('.tile-remove-btn');
508
+ removeBtn?.addEventListener('click', (e) => {
509
+ e.stopPropagation();
510
+ this._onRemoveClick();
511
+ });
512
+ }
513
+
514
+ // Content area. Clicking the content no longer opens config — that
515
+ // moved to the explicit cogwheel button in the header so chart
516
+ // interaction (pan/zoom/hover) doesn't trip the config panel.
517
+ this.contentElement = document.createElement('div');
518
+ this.contentElement.className = 'tile-content';
519
+
520
+ this.element.appendChild(header);
521
+ this.element.appendChild(this.contentElement);
522
+
523
+ if (!this.readonly) {
524
+ // Resize handle (bottom-right)
525
+ const resizeHandle = document.createElement('div');
526
+ resizeHandle.className = 'tile-resize-handle se';
527
+ resizeHandle.innerHTML = '<span class="material-symbols-outlined">drag_handle</span>';
528
+ this.element.appendChild(resizeHandle);
529
+
530
+ // Context menu for "Add to Documentation" etc.
531
+ this.element.addEventListener('contextmenu', (e) => {
532
+ e.preventDefault();
533
+ e.stopPropagation();
534
+ this.eventBus?.emit?.('tile:context-menu', {
535
+ tileId: this.id,
536
+ tileType: this.constructor.TYPE,
537
+ tileTitle: this.constructor.TITLE,
538
+ config: { ...this.config },
539
+ clientX: e.clientX,
540
+ clientY: e.clientY,
541
+ });
542
+ });
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Handle config button click — highlight tile and emit config request.
548
+ * @private
549
+ */
550
+ _onConfigClick() {
551
+ // Deselect all sibling tiles, then select this one
552
+ this.element?.closest('.tile-grid')
553
+ ?.querySelectorAll('.tile.tile--selected')
554
+ .forEach(el => el.classList.remove('tile--selected'));
555
+ this.element?.classList.add('tile--selected');
556
+
557
+ // Emit event for grid/dashboard to show config panel
558
+ this.eventBus?.emit?.('tile:config-request', {
559
+ tileId: this.id,
560
+ tileType: this.constructor.TYPE,
561
+ config: this.config,
562
+ schema: this.getConfigSchema()
563
+ });
564
+
565
+ // Also dispatch DOM event for alternative handling
566
+ this.element?.dispatchEvent(new CustomEvent('tile:config-request', {
567
+ bubbles: true,
568
+ detail: {
569
+ tileId: this.id,
570
+ tileType: this.constructor.TYPE,
571
+ config: this.config,
572
+ schema: this.getConfigSchema()
573
+ }
574
+ }));
575
+ }
576
+
577
+ /**
578
+ * Handle remove button click.
579
+ * @param {Event} [event] - Original click event
580
+ * @private
581
+ */
582
+ _onRemoveClick(event) {
583
+ const removeBtn = this.element?.querySelector('.tile-remove-btn');
584
+
585
+ // Only dispatch DOM event - let the dashboard handle confirmation
586
+ this.element?.dispatchEvent(new CustomEvent('tile:remove-request', {
587
+ bubbles: true,
588
+ detail: {
589
+ tileId: this.id,
590
+ anchorElement: removeBtn
591
+ }
592
+ }));
593
+ }
594
+
595
+ /**
596
+ * Set the title displayed in the header.
597
+ * @param {string} title - New title text
598
+ */
599
+ setTitle(title) {
600
+ const titleEl = this.element?.querySelector('.tile-title');
601
+ if (titleEl) {
602
+ titleEl.textContent = title;
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Show loading state in content area.
608
+ */
609
+ showLoading() {
610
+ if (this.contentElement) {
611
+ this.contentElement.innerHTML = `
612
+ <div class="tile-loading">
613
+ <span class="material-symbols-outlined spinning">progress_activity</span>
614
+ <span>Loading...</span>
615
+ </div>
616
+ `;
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Show empty state in content area.
622
+ * @param {string} [message] - Custom message
623
+ */
624
+ showEmpty(message = 'No data available') {
625
+ if (this.contentElement) {
626
+ this.contentElement.innerHTML = `
627
+ <div class="tile-empty">
628
+ <span class="material-symbols-outlined">inbox</span>
629
+ <span>${message}</span>
630
+ </div>
631
+ `;
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Show error state in content area.
637
+ * @param {string} [message] - Error message
638
+ */
639
+ showError(message = 'Failed to load data') {
640
+ if (this.contentElement) {
641
+ this.contentElement.innerHTML = `
642
+ <div class="twm-tile-error">
643
+ <span class="material-symbols-outlined">error</span>
644
+ <span>${message}</span>
645
+ </div>
646
+ `;
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Format a number for display.
652
+ * @param {number} value - Number to format
653
+ * @param {number} [decimals=2] - Decimal places
654
+ * @returns {string} Formatted string
655
+ */
656
+ formatNumber(value, decimals = 2) {
657
+ if (value == null || !Number.isFinite(value)) return '—';
658
+
659
+ const abs = Math.abs(value);
660
+ if (abs >= 1e9) {
661
+ return (value / 1e9).toFixed(1) + 'B';
662
+ } else if (abs >= 1e6) {
663
+ return (value / 1e6).toFixed(1) + 'M';
664
+ } else if (abs >= 1e3) {
665
+ return (value / 1e3).toFixed(1) + 'K';
666
+ } else if (abs < 0.01 && abs > 0) {
667
+ return value.toExponential(decimals);
668
+ }
669
+ return value.toFixed(decimals);
670
+ }
671
+
672
+ /**
673
+ * Get available variables from data for config dropdowns.
674
+ * @param {Object} data - Analytics data
675
+ * @returns {Array<{value: string, label: string}>} Variable options
676
+ */
677
+ getAvailableVariables(data) {
678
+ if (!data) return [];
679
+
680
+ const variables = [];
681
+
682
+ // Add stocks
683
+ if (data.stocks) {
684
+ Object.keys(data.stocks).forEach(name => {
685
+ variables.push({ value: name, label: name, category: 'Stocks' });
686
+ });
687
+ }
688
+
689
+ // Add flows
690
+ if (data.flows) {
691
+ Object.keys(data.flows).forEach(name => {
692
+ variables.push({ value: name, label: name, category: 'Flows' });
693
+ });
694
+ }
695
+
696
+ // Add indicators
697
+ if (data.indicators) {
698
+ Object.keys(data.indicators).forEach(name => {
699
+ variables.push({ value: name, label: name, category: 'Indicators' });
700
+ });
701
+ }
702
+
703
+ return variables;
704
+ }
705
+
706
+ // =========================================================================
707
+ // EXPAND WINDOW FUNCTIONALITY
708
+ // Reuses PlotPopoutWindow's patterns and CSS classes
709
+ // =========================================================================
710
+
711
+ /**
712
+ * Handle expand button click.
713
+ * @private
714
+ */
715
+ _onExpandClick() {
716
+ if (!this.constructor.EXPANDABLE) return;
717
+
718
+ const expandData = this.getExpandData();
719
+ if (!expandData) {
720
+ console.warn(`[TileBase] No expand data from widget ${this.id}`);
721
+ return;
722
+ }
723
+
724
+ this._openExpandWindow(expandData);
725
+ }
726
+
727
+ /**
728
+ * Get data for the expanded window.
729
+ * Override in subclass for expandable widgets.
730
+ * @returns {Object|null} { title, traces, layout, tableHeaders, tableRows }
731
+ */
732
+ getExpandData() {
733
+ return null;
734
+ }
735
+
736
+ /**
737
+ * Open the expand window with plot and data tabs.
738
+ * Delegates to openRawTracesWindow (PlotPopoutWindow module) which
739
+ * provides proper toolbar layout, download buttons, and data table.
740
+ * @param {Object} expandData - Data from getExpandData()
741
+ * @private
742
+ */
743
+ async _openExpandWindow(expandData) {
744
+ if (this._expandWindow?.isVisible) {
745
+ this._expandWindow.bringToFront();
746
+ return;
747
+ }
748
+
749
+ this._expandWindow = await openRawTracesWindow({
750
+ host: this.host,
751
+ id: `tile-expand-${this.id}`,
752
+ title: expandData.title || this.constructor.TITLE,
753
+ traces: expandData.traces,
754
+ layout: expandData.layout,
755
+ frames: expandData.frames,
756
+ tableHeaders: expandData.tableHeaders,
757
+ tableRows: expandData.tableRows,
758
+ services: { eventBus: this.eventBus },
759
+ });
760
+ }
761
+
762
+ /**
763
+ * Escape a string for safe insertion into an HTML attribute.
764
+ * @private
765
+ */
766
+ #escapeAttr(s) {
767
+ return String(s ?? '').replace(/[&<>"']/g, c => ({
768
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
769
+ }[c]));
770
+ }
771
+
772
+ /**
773
+ * Show toast notification via eventBus.
774
+ * @private
775
+ */
776
+ _notify(title, message, severity = 'info') {
777
+ this.eventBus?.emit?.('toast:show', {
778
+ title,
779
+ message,
780
+ type: severity,
781
+ });
782
+ }
783
+ }
784
+
785
+ export default TileBase;