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,1560 @@
1
+ /**
2
+ * Plot Popout Window
3
+ *
4
+ * Opens a plot in a separate draggable/resizable ManagedWindow with two tabs:
5
+ * - Plot tab: Interactive Plotly chart grid with modebar controls
6
+ * - Data tab: Table showing all series using the unified DataTable component
7
+ *
8
+ * Uses the PlotCell data model (multi-layout subplots) as its canonical format.
9
+ * All plot window contexts (notebook, dashboard, documentation) use this single
10
+ * implementation.
11
+ *
12
+ * Features:
13
+ * - Multi-subplot grid layouts (1x1, 1x2, 2x1, 2x2, 1x3, wide-top, wide-bottom)
14
+ * - Series visibility toggle panel
15
+ * - Zoom/pan toolbar with zoom in/out, autoscale, reset
16
+ * - PyWebView integration for file downloads (CSV, PNG)
17
+ * - NotificationCenter integration for user feedback
18
+ * - Live streaming updates while the data source is running
19
+ * - HP filter trend/cycle decomposition
20
+ * - ETL actuals overlay support
21
+ */
22
+
23
+ import { ManagedWindow } from '../ui/components/managed_window.js';
24
+ import { DataTable } from '../ui/components/data_table.js';
25
+ import { createRafResizeObserver } from '../ui/utils/raf_resize_observer.js';
26
+ import {
27
+ ensurePlotly,
28
+ purge,
29
+ getSeriesColor,
30
+ } from './plotly_wrapper.js';
31
+ import {
32
+ buildTrace,
33
+ buildYAxisLayout,
34
+ buildLegendLayout,
35
+ buildOverlayShapes,
36
+ } from './chart_types.js';
37
+ import {
38
+ PLOT_LAYOUTS,
39
+ migrateData,
40
+ applyNanHandling,
41
+ } from './plot_config.js';
42
+
43
+ export class PlotPopoutWindow {
44
+ /**
45
+ * @param {Object} options
46
+ * @param {string} options.id - Unique window identifier
47
+ * @param {string} options.title - Window title
48
+ * @param {Object} options.plotConfig - PlotCell format: { layout, subplots }
49
+ * @param {Function} options.getResults - Returns { time: number[], series: { [key]: number[] } }
50
+ * @param {Object} options.services - { eventBus, logger }
51
+ * @param {Object} [options.host] - A Host (ui/js/host/host.js). Its `dialogs`
52
+ * capability backs Export PNG/CSV; absent => Blob download fallback.
53
+ * @param {Function} [options.onClose] - Called when window closes
54
+ * @param {Object} [options.actuals] - ETL overlay data { seriesId: { years, values } }
55
+ * @param {Function} [options.configRenderer] - (container) => void — renders config panel
56
+ */
57
+ constructor({ id, title, plotConfig, getResults, services, host, onClose, actuals,
58
+ configRenderer,
59
+ // The window used to listen for 'simulation:run:completed' and tell
60
+ // the user to "run simulation first". A chart window does not know
61
+ // that its data comes from a simulation; the app that opens it does.
62
+ refreshEvent = 'data:changed',
63
+ emptyMessage = 'No data yet.',
64
+ tableEmptyMessage = 'No data yet.' }) {
65
+ this._id = id;
66
+ this._title = title || 'Plot';
67
+ this._plotConfig = migrateData(plotConfig);
68
+ this._getResults = getResults;
69
+ this._services = services;
70
+ this._host = host || null;
71
+ this._onClose = onClose ?? (() => {});
72
+ this._actuals = actuals ?? null;
73
+ this._configRenderer = configRenderer ?? null;
74
+ this.refreshEvent = refreshEvent;
75
+ // TWO messages, not one. The chart says "No data — run simulation first."
76
+ // and the data table underneath says "Run simulation to see data". Folding
77
+ // them into a single option would have quietly rewritten one of them, which
78
+ // is the kind of change a pixel gate catches only if a captured view happens
79
+ // to be showing an empty plot.
80
+ this.emptyMessage = emptyMessage;
81
+ this.tableEmptyMessage = tableEmptyMessage;
82
+
83
+ this._window = null;
84
+ this._contentEl = null;
85
+ this._chartDivs = [];
86
+ this._gridEl = null;
87
+ this._activeTab = 'plot';
88
+ this._eventDisposers = [];
89
+ this._resizeObserver = null;
90
+ this._chartCreated = false;
91
+ this._lastResults = null;
92
+
93
+ // Data table
94
+ this._dataTable = null;
95
+ this._dataTableContainer = null;
96
+ this._tableHeaders = [];
97
+ this._tableRows = [];
98
+
99
+ // DOM refs
100
+ this._plotTab = null;
101
+ this._dataTab = null;
102
+ this._configTab = null;
103
+ this._plotContent = null;
104
+ this._dataContent = null;
105
+ this._configContent = null;
106
+ this._toolbarActions = null;
107
+ this._zoomBtn = null;
108
+ this._panBtn = null;
109
+
110
+ // Series visibility toggle
111
+ this._hiddenSeries = new Set();
112
+ this._allSeriesInfo = [];
113
+ this._allSeriesLabels = [];
114
+ this._seriesToggleBtn = null;
115
+ this._seriesTogglePanel = null;
116
+ this._seriesTogglePanelVisible = false;
117
+ this._lastTogglePanelSignature = null;
118
+ this._outsideClickHandler = null;
119
+
120
+ this._renderPending = false;
121
+
122
+ // Live-update toggle: when true, window re-renders on streaming events
123
+ this._liveUpdate = true;
124
+ this._liveBtn = null;
125
+ }
126
+
127
+ get isVisible() { return this._window?.isVisible ?? false; }
128
+
129
+ async show() {
130
+ if (this._window?.isVisible) {
131
+ this._window.bringToFront();
132
+ return;
133
+ }
134
+
135
+ await ensurePlotly();
136
+ this.#createWindow();
137
+ this.#subscribeEvents();
138
+ this.#setupResizeObserver();
139
+ await this.#render();
140
+ }
141
+
142
+ bringToFront() { this._window?.bringToFront?.(); }
143
+
144
+ close() { this._window?.close?.(); }
145
+
146
+ /** Update plot config and re-render. */
147
+ updateConfig(plotConfig) {
148
+ this._plotConfig = migrateData(plotConfig);
149
+ this.#buildSubplotGrid();
150
+ this.#scheduleRender();
151
+ }
152
+
153
+ /** Update actuals overlay data and re-render. */
154
+ setActuals(actuals) {
155
+ this._actuals = actuals;
156
+ this.#scheduleRender();
157
+ }
158
+
159
+ // ── Window creation ─────────────────────────────────────────────────
160
+
161
+ #createWindow() {
162
+ this._contentEl = this.#buildContent();
163
+
164
+ this._window = new ManagedWindow({
165
+ id: `plot-window-${this._id}`,
166
+ title: this._title,
167
+ icon: 'monitoring',
168
+ content: this._contentEl,
169
+ minWidth: 500,
170
+ minHeight: 400,
171
+ defaultWidth: 800,
172
+ defaultHeight: 600,
173
+ canMinimize: true,
174
+ canMaximize: true,
175
+ canResize: true,
176
+ canDrag: true,
177
+ onClose: () => { this.#dispose(); this._onClose(); },
178
+ });
179
+
180
+ this._window.show();
181
+ }
182
+
183
+ #buildContent() {
184
+ const container = document.createElement('div');
185
+ container.className = 'twm-plot-window-container';
186
+
187
+ // Tab header
188
+ const tabHeader = document.createElement('div');
189
+ tabHeader.className = 'twm-code-tabs-header';
190
+
191
+ const tabs = document.createElement('div');
192
+ tabs.className = 'tabs twm-code-tabs';
193
+
194
+ this._plotTab = document.createElement('button');
195
+ this._plotTab.className = 'twm-code-tab active';
196
+ this._plotTab.type = 'button';
197
+ this._plotTab.textContent = 'Plot';
198
+ this._plotTab.addEventListener('click', () => this.#switchTab('plot'));
199
+
200
+ this._dataTab = document.createElement('button');
201
+ this._dataTab.className = 'twm-code-tab';
202
+ this._dataTab.type = 'button';
203
+ this._dataTab.textContent = 'Data';
204
+ this._dataTab.addEventListener('click', () => this.#switchTab('data'));
205
+
206
+ tabs.appendChild(this._plotTab);
207
+ tabs.appendChild(this._dataTab);
208
+
209
+ if (this._configRenderer) {
210
+ this._configTab = document.createElement('button');
211
+ this._configTab.className = 'twm-code-tab';
212
+ this._configTab.type = 'button';
213
+ this._configTab.textContent = 'Config';
214
+ this._configTab.addEventListener('click', () => this.#switchTab('config'));
215
+ tabs.appendChild(this._configTab);
216
+ }
217
+
218
+ // Toolbar (visible when Plot tab is active)
219
+ this._toolbarActions = document.createElement('div');
220
+ this._toolbarActions.className = 'twm-plot-window-toolbar';
221
+
222
+ // Drag mode group
223
+ const dragGroup = this.#toolbarGroup();
224
+ this._zoomBtn = this.#toolbarBtn('search', 'Zoom', () => this.#setDragMode('zoom'));
225
+ this._zoomBtn.classList.add('twm-is-active');
226
+ this._panBtn = this.#toolbarBtn('pan_tool', 'Pan', () => this.#setDragMode('pan'));
227
+ dragGroup.appendChild(this._zoomBtn);
228
+ dragGroup.appendChild(this._panBtn);
229
+ this._toolbarActions.appendChild(dragGroup);
230
+
231
+ // Zoom control group
232
+ const zoomGroup = this.#toolbarGroup();
233
+ zoomGroup.appendChild(this.#toolbarBtn('zoom_in', 'Zoom in', () => this.#zoom(0.5)));
234
+ zoomGroup.appendChild(this.#toolbarBtn('zoom_out', 'Zoom out', () => this.#zoom(2)));
235
+ zoomGroup.appendChild(this.#toolbarBtn('zoom_out_map', 'Autoscale', () => this.#autoscale()));
236
+ zoomGroup.appendChild(this.#toolbarBtn('home', 'Reset axes', () => this.#autoscale()));
237
+ this._toolbarActions.appendChild(zoomGroup);
238
+
239
+ // Spacer
240
+ const spacer = document.createElement('div');
241
+ spacer.style.flex = '1';
242
+ this._toolbarActions.appendChild(spacer);
243
+
244
+ // Action buttons group
245
+ const actionsGroup = this.#toolbarGroup();
246
+
247
+ // Live-update toggle
248
+ this._liveBtn = this.#toolbarBtn('stream', 'Live update — click to lock', () => this.#toggleLiveUpdate());
249
+ this._liveBtn.classList.add('twm-is-active');
250
+ actionsGroup.appendChild(this._liveBtn);
251
+
252
+ // Series visibility toggle
253
+ const toggleSeriesBtn = this.#toolbarBtn('visibility', 'Toggle series visibility', () => this.#toggleSeriesPanel());
254
+ this._seriesToggleBtn = toggleSeriesBtn;
255
+ actionsGroup.appendChild(toggleSeriesBtn);
256
+
257
+ // Download PNG
258
+ actionsGroup.appendChild(this.#toolbarBtn('image', 'Download plot as PNG', () => this.#downloadPNG()));
259
+
260
+ // Download CSV
261
+ actionsGroup.appendChild(this.#toolbarBtn('download', 'Download data as CSV', () => this.#downloadCSV()));
262
+
263
+ this._toolbarActions.appendChild(actionsGroup);
264
+
265
+ tabHeader.appendChild(tabs);
266
+ tabHeader.appendChild(this._toolbarActions);
267
+
268
+ // Tab content area
269
+ const content = document.createElement('div');
270
+ content.className = 'twm-plot-window-content';
271
+
272
+ // Plot tab content
273
+ this._plotContent = document.createElement('div');
274
+ this._plotContent.className = 'twm-plot-tab-content active';
275
+ this._plotContent.setAttribute('data-tab', 'plot');
276
+ this._plotContent.style.cssText = 'display:flex; flex-direction:column;';
277
+
278
+ this._gridEl = document.createElement('div');
279
+ this._gridEl.className = 'twm-plot-window-chart';
280
+ this._plotContent.appendChild(this._gridEl);
281
+
282
+ this.#buildSubplotGrid();
283
+
284
+ // Data tab content
285
+ this._dataContent = document.createElement('div');
286
+ this._dataContent.className = 'twm-plot-tab-content';
287
+ this._dataContent.setAttribute('data-tab', 'data');
288
+ this._dataContent.style.cssText = 'display:none; flex-direction:column;';
289
+
290
+ this._dataTableContainer = document.createElement('div');
291
+ this._dataTableContainer.style.cssText = 'flex:1; min-height:0; display:flex; flex-direction:column;';
292
+ this._dataContent.appendChild(this._dataTableContainer);
293
+
294
+ content.appendChild(this._plotContent);
295
+ content.appendChild(this._dataContent);
296
+
297
+ // Config tab content (optional)
298
+ if (this._configRenderer) {
299
+ this._configContent = document.createElement('div');
300
+ this._configContent.className = 'twm-plot-tab-content';
301
+ this._configContent.setAttribute('data-tab', 'config');
302
+ this._configContent.style.cssText = 'display:none; flex-direction:column; overflow:auto; padding:8px 12px;';
303
+ this._configRenderer(this._configContent);
304
+ content.appendChild(this._configContent);
305
+ }
306
+
307
+ // Series visibility dropdown panel (initially hidden)
308
+ this._seriesTogglePanel = document.createElement('div');
309
+ this._seriesTogglePanel.className = 'twm-plot-series-toggle-panel';
310
+ this._seriesTogglePanel.style.display = 'none';
311
+
312
+ container.appendChild(tabHeader);
313
+ container.appendChild(content);
314
+ container.appendChild(this._seriesTogglePanel);
315
+
316
+ return container;
317
+ }
318
+
319
+ // ── Subplot grid ────────────────────────────────────────────────────
320
+
321
+ #buildSubplotGrid() {
322
+ if (!this._gridEl) return;
323
+
324
+ // Purge existing charts
325
+ for (const div of this._chartDivs) {
326
+ if (div && window.Plotly) {
327
+ try { purge(div); } catch (_) {}
328
+ }
329
+ }
330
+ this._gridEl.innerHTML = '';
331
+ this._chartDivs = [];
332
+
333
+ const tmpl = PLOT_LAYOUTS[this._plotConfig.layout] || PLOT_LAYOUTS['1x1'];
334
+ this._gridEl.style.display = 'grid';
335
+ this._gridEl.style.gridTemplateColumns = tmpl.grid.columns;
336
+ this._gridEl.style.gridTemplateRows = tmpl.grid.rows;
337
+ this._gridEl.style.gap = '4px';
338
+ this._gridEl.style.height = '100%';
339
+ this._gridEl.style.width = '100%';
340
+
341
+ for (let i = 0; i < tmpl.slots; i++) {
342
+ const div = document.createElement('div');
343
+ div.className = 'plot-tile-chart';
344
+ div.style.gridArea = tmpl.areas[i].gridArea;
345
+ div.style.minHeight = '0';
346
+ this._gridEl.appendChild(div);
347
+ this._chartDivs.push(div);
348
+ }
349
+
350
+ this._chartCreated = false;
351
+ }
352
+
353
+ // ── Toolbar helpers ─────────────────────────────────────────────────
354
+
355
+ #toolbarGroup() {
356
+ const g = document.createElement('div');
357
+ g.className = 'twm-plot-window-toolbar__group';
358
+ return g;
359
+ }
360
+
361
+ #toolbarBtn(icon, tooltip, onClick) {
362
+ const btn = document.createElement('button');
363
+ btn.className = 'twm-btn-icon twm-has-tooltip';
364
+ btn.type = 'button';
365
+ btn.setAttribute('data-tooltip', tooltip);
366
+ btn.innerHTML = `<span class="material-symbols-outlined">${icon}</span>`;
367
+ if (onClick) btn.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
368
+ return btn;
369
+ }
370
+
371
+ // ── Plotly toolbar actions ───────────────────────────────────────────
372
+
373
+ #setDragMode(mode) {
374
+ if (!this._chartCreated) return;
375
+ for (const div of this._chartDivs) {
376
+ if (div._fullLayout) {
377
+ window.Plotly.relayout(div, { dragmode: mode }).catch(() => {});
378
+ }
379
+ }
380
+ this._zoomBtn?.classList.toggle('twm-is-active', mode === 'zoom');
381
+ this._panBtn?.classList.toggle('twm-is-active', mode === 'pan');
382
+ }
383
+
384
+ #zoom(factor) {
385
+ for (const div of this._chartDivs) {
386
+ if (!div._fullLayout) continue;
387
+ const xa = div._fullLayout.xaxis;
388
+ const ya = div._fullLayout.yaxis;
389
+ if (!xa || !ya) continue;
390
+
391
+ const xr = xa.range;
392
+ const yr = ya.range;
393
+ const xMid = (xr[0] + xr[1]) / 2;
394
+ const yMid = (yr[0] + yr[1]) / 2;
395
+ const xHalf = (xr[1] - xr[0]) / 2 * factor;
396
+ const yHalf = (yr[1] - yr[0]) / 2 * factor;
397
+
398
+ window.Plotly.relayout(div, {
399
+ 'xaxis.range': [xMid - xHalf, xMid + xHalf],
400
+ 'yaxis.range': [yMid - yHalf, yMid + yHalf],
401
+ }).catch(() => {});
402
+ }
403
+ }
404
+
405
+ #autoscale() {
406
+ for (const div of this._chartDivs) {
407
+ if (!div._fullLayout) continue;
408
+ window.Plotly.relayout(div, {
409
+ 'xaxis.autorange': true,
410
+ 'yaxis.autorange': true,
411
+ }).catch(() => {});
412
+ }
413
+ }
414
+
415
+ #setupResizeObserver() {
416
+ if (!this._plotContent) return;
417
+ this._resizeObserver = createRafResizeObserver(() => {
418
+ if (this._activeTab === 'plot' && this._chartCreated && window.Plotly) {
419
+ for (const div of this._chartDivs) {
420
+ if (div._fullLayout) {
421
+ Plotly.Plots.resize(div).catch(() => {});
422
+ }
423
+ }
424
+ }
425
+ });
426
+ this._resizeObserver.observe(this._plotContent);
427
+ }
428
+
429
+ #switchTab(tabName) {
430
+ this._activeTab = tabName;
431
+
432
+ this._plotTab.classList.toggle('active', tabName === 'plot');
433
+ this._dataTab.classList.toggle('active', tabName === 'data');
434
+ this._configTab?.classList.toggle('active', tabName === 'config');
435
+
436
+ if (this._toolbarActions) {
437
+ this._toolbarActions.style.display = tabName === 'plot' ? '' : 'none';
438
+ }
439
+
440
+ this._plotContent.style.display = tabName === 'plot' ? 'flex' : 'none';
441
+ this._dataContent.style.display = tabName === 'data' ? 'flex' : 'none';
442
+ if (this._configContent) {
443
+ this._configContent.style.display = tabName === 'config' ? 'flex' : 'none';
444
+ }
445
+
446
+ if (tabName === 'plot' && this._chartCreated && window.Plotly) {
447
+ for (const div of this._chartDivs) {
448
+ if (div._fullLayout) {
449
+ Plotly.Plots.resize(div).catch(() => {});
450
+ }
451
+ }
452
+ }
453
+
454
+ if (tabName === 'data') {
455
+ this.#renderDataTable();
456
+ }
457
+ }
458
+
459
+ // ── Series visibility toggle ────────────────────────────────────────
460
+
461
+ #toggleSeriesPanel() {
462
+ if (this._seriesTogglePanelVisible) {
463
+ this.#hideSeriesPanel();
464
+ } else {
465
+ this.#showSeriesPanel();
466
+ }
467
+ }
468
+
469
+ #showSeriesPanel() {
470
+ if (!this._seriesTogglePanel || !this._seriesToggleBtn) return;
471
+
472
+ const btnRect = this._seriesToggleBtn.getBoundingClientRect();
473
+ const containerRect = this._contentEl.getBoundingClientRect();
474
+
475
+ this._seriesTogglePanel.style.top = `${btnRect.bottom - containerRect.top + 4}px`;
476
+ this._seriesTogglePanel.style.right = `${containerRect.right - btnRect.right}px`;
477
+ this._seriesTogglePanel.style.display = '';
478
+ this._seriesTogglePanelVisible = true;
479
+
480
+ this._seriesToggleBtn.classList.add('twm-is-active');
481
+ this.#rebuildSeriesToggleList();
482
+
483
+ this._outsideClickHandler = (e) => {
484
+ if (!this._seriesTogglePanel.contains(e.target) &&
485
+ !this._seriesToggleBtn.contains(e.target)) {
486
+ this.#hideSeriesPanel();
487
+ }
488
+ };
489
+ requestAnimationFrame(() => {
490
+ document.addEventListener('pointerdown', this._outsideClickHandler, true);
491
+ });
492
+ }
493
+
494
+ #hideSeriesPanel() {
495
+ if (this._seriesTogglePanel) {
496
+ this._seriesTogglePanel.style.display = 'none';
497
+ }
498
+ this._seriesTogglePanelVisible = false;
499
+ this._lastTogglePanelSignature = null;
500
+ this._seriesToggleBtn?.classList.remove('twm-is-active');
501
+
502
+ if (this._outsideClickHandler) {
503
+ document.removeEventListener('pointerdown', this._outsideClickHandler, true);
504
+ this._outsideClickHandler = null;
505
+ }
506
+ }
507
+
508
+ #rebuildSeriesToggleList() {
509
+ const panel = this._seriesTogglePanel;
510
+ if (!panel) return;
511
+ panel.innerHTML = '';
512
+
513
+ // Header with "All" / "None" actions
514
+ const header = document.createElement('div');
515
+ header.className = 'twm-plot-series-toggle-panel__header';
516
+
517
+ const title = document.createElement('span');
518
+ title.className = 'twm-plot-series-toggle-panel__title';
519
+ title.textContent = 'Series';
520
+ header.appendChild(title);
521
+
522
+ const headerActions = document.createElement('div');
523
+ headerActions.className = 'twm-plot-series-toggle-panel__header-actions';
524
+
525
+ const showAllBtn = document.createElement('button');
526
+ showAllBtn.className = 'twm-plot-series-toggle-panel__action';
527
+ showAllBtn.type = 'button';
528
+ showAllBtn.textContent = 'All';
529
+ showAllBtn.addEventListener('click', () => {
530
+ this._hiddenSeries.clear();
531
+ this.#rebuildSeriesToggleList();
532
+ this.#scheduleRender();
533
+ });
534
+
535
+ const hideAllBtn = document.createElement('button');
536
+ hideAllBtn.className = 'twm-plot-series-toggle-panel__action';
537
+ hideAllBtn.type = 'button';
538
+ hideAllBtn.textContent = 'None';
539
+ hideAllBtn.addEventListener('click', () => {
540
+ for (const label of this._allSeriesLabels) {
541
+ this._hiddenSeries.add(label);
542
+ }
543
+ this.#rebuildSeriesToggleList();
544
+ this.#scheduleRender();
545
+ });
546
+
547
+ headerActions.appendChild(showAllBtn);
548
+ headerActions.appendChild(hideAllBtn);
549
+ header.appendChild(headerActions);
550
+ panel.appendChild(header);
551
+
552
+ // Split series into model vs overlay
553
+ const modelSeries = this._allSeriesInfo.filter(s => !s.isOverlay);
554
+ const overlaySeries = this._allSeriesInfo.filter(s => s.isOverlay);
555
+
556
+ const buildItem = ({ label, color, lineStyle }) => {
557
+ const item = document.createElement('label');
558
+ item.className = 'twm-plot-series-toggle-panel__item';
559
+
560
+ const cb = document.createElement('input');
561
+ cb.type = 'checkbox';
562
+ cb.checked = !this._hiddenSeries.has(label);
563
+ cb.addEventListener('change', () => {
564
+ if (cb.checked) this._hiddenSeries.delete(label);
565
+ else this._hiddenSeries.add(label);
566
+ this.#scheduleRender();
567
+ });
568
+
569
+ const dashMap = { solid: '', dashed: '4,2', dotted: '1,2' };
570
+ const indicator = document.createElement('div');
571
+ indicator.className = 'twm-plot-series-toggle-panel__line-indicator';
572
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
573
+ svg.setAttribute('viewBox', '0 0 28 14');
574
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
575
+ line.setAttribute('x1', '3');
576
+ line.setAttribute('y1', '7');
577
+ line.setAttribute('x2', '25');
578
+ line.setAttribute('y2', '7');
579
+ line.setAttribute('stroke', color || '#888');
580
+ line.setAttribute('stroke-width', '2');
581
+ line.setAttribute('stroke-linecap', 'round');
582
+ const dash = dashMap[lineStyle] || '';
583
+ if (dash) line.setAttribute('stroke-dasharray', dash);
584
+ svg.appendChild(line);
585
+ indicator.appendChild(svg);
586
+
587
+ const text = document.createElement('span');
588
+ text.className = 'twm-plot-series-toggle-panel__label';
589
+ text.textContent = label;
590
+ text.title = label;
591
+
592
+ item.appendChild(cb);
593
+ item.appendChild(indicator);
594
+ item.appendChild(text);
595
+ return item;
596
+ };
597
+
598
+ // Model series section
599
+ const list = document.createElement('div');
600
+ list.className = 'twm-plot-series-toggle-panel__list';
601
+
602
+ const subplotGroups = new Map();
603
+ for (const info of modelSeries) {
604
+ if (!subplotGroups.has(info.subplotIndex)) {
605
+ subplotGroups.set(info.subplotIndex, []);
606
+ }
607
+ subplotGroups.get(info.subplotIndex).push(info);
608
+ }
609
+
610
+ const multipleSubplots = subplotGroups.size > 1;
611
+
612
+ for (const [spIdx, seriesList] of subplotGroups) {
613
+ if (multipleSubplots) {
614
+ const spName = seriesList[0]?.subplotName || `Subplot ${spIdx + 1}`;
615
+ const spHeader = document.createElement('div');
616
+ spHeader.className = 'twm-plot-series-toggle-panel__axis-label';
617
+ spHeader.textContent = spName;
618
+ list.appendChild(spHeader);
619
+ }
620
+
621
+ for (const info of seriesList) {
622
+ list.appendChild(buildItem(info));
623
+ }
624
+ }
625
+
626
+ panel.appendChild(list);
627
+
628
+ // Overlays section (only if any exist)
629
+ if (overlaySeries.length > 0) {
630
+ const ovList = document.createElement('div');
631
+ ovList.className = 'twm-plot-series-toggle-panel__list';
632
+
633
+ const ovHeader = document.createElement('div');
634
+ ovHeader.className = 'twm-plot-series-toggle-panel__axis-label';
635
+ ovHeader.textContent = 'Overlays';
636
+ ovList.appendChild(ovHeader);
637
+
638
+ for (const info of overlaySeries) {
639
+ ovList.appendChild(buildItem(info));
640
+ }
641
+
642
+ panel.appendChild(ovList);
643
+ }
644
+ }
645
+
646
+ // ── Event subscriptions ─────────────────────────────────────────────
647
+
648
+ #subscribeEvents() {
649
+ const { eventBus } = this._services;
650
+ if (!eventBus) return;
651
+
652
+ this._eventDisposers.push(
653
+ eventBus.on('streaming:delta', () => {
654
+ if (this._liveUpdate) this.#scheduleRender();
655
+ })
656
+ );
657
+ this._eventDisposers.push(
658
+ eventBus.on('streaming:complete', () => {
659
+ if (this._liveUpdate) this.#scheduleRender();
660
+ })
661
+ );
662
+ // Final analytics arrive asynchronously after streaming:complete
663
+ // (backend fetch + transform). Without this, the popout window may
664
+ // render stale data from a previous run when the dashboard tab
665
+ // didn't receive streaming deltas (e.g. user was on another tab).
666
+ this._eventDisposers.push(
667
+ eventBus.on(this.refreshEvent, () => {
668
+ if (this._liveUpdate) this.#scheduleRender();
669
+ })
670
+ );
671
+ }
672
+
673
+ #toggleLiveUpdate() {
674
+ this._liveUpdate = !this._liveUpdate;
675
+ if (this._liveBtn) {
676
+ this._liveBtn.classList.toggle('twm-is-active', this._liveUpdate);
677
+ const icon = this._liveBtn.querySelector('.material-symbols-outlined');
678
+ if (icon) icon.textContent = this._liveUpdate ? 'stream' : 'lock';
679
+ this._liveBtn.title = this._liveUpdate
680
+ ? 'Live update — click to lock'
681
+ : 'Locked — click for live update';
682
+ }
683
+ // If switching back to live, immediately render latest data
684
+ if (this._liveUpdate) this.#scheduleRender();
685
+ }
686
+
687
+ #scheduleRender() {
688
+ if (this._renderPending) return;
689
+ this._renderPending = true;
690
+ requestAnimationFrame(() => {
691
+ this._renderPending = false;
692
+ this.#render();
693
+ });
694
+ }
695
+
696
+ // ── Rendering ───────────────────────────────────────────────────────
697
+
698
+ async #render() {
699
+ if (!this._window?.isVisible) return;
700
+
701
+ const results = this._getResults?.();
702
+ if (!results) return;
703
+
704
+ this._lastResults = results;
705
+
706
+ // Build series info for toggle panel
707
+ this.#collectSeriesInfo();
708
+
709
+ // Render all subplot charts
710
+ this.#renderAllCharts(results);
711
+
712
+ // Update table data
713
+ this.#updateTableData(results);
714
+
715
+ if (this._activeTab === 'data') {
716
+ this.#renderDataTable();
717
+ }
718
+ }
719
+
720
+ #collectSeriesInfo() {
721
+ this._allSeriesInfo = [];
722
+ this._allSeriesLabels = [];
723
+
724
+ const subplots = this._plotConfig.subplots ?? [];
725
+ for (let spIdx = 0; spIdx < subplots.length; spIdx++) {
726
+ const sp = subplots[spIdx];
727
+ for (const axis of sp.yAxes ?? []) {
728
+ for (const s of axis.series ?? []) {
729
+ const label = s.label || s.variable || 'Series';
730
+ this._allSeriesInfo.push({
731
+ label,
732
+ color: s.color || getSeriesColor(this._allSeriesInfo.length),
733
+ lineStyle: s.lineStyle || 'solid',
734
+ subplotIndex: spIdx,
735
+ subplotName: sp.displayName || `Subplot ${spIdx + 1}`,
736
+ });
737
+ this._allSeriesLabels.push(label);
738
+
739
+ // Include overlay in toggle list
740
+ if (s.overlay?.etlKey) {
741
+ const ovLabel = s.overlay.label || `${label} (actual)`;
742
+ this._allSeriesInfo.push({
743
+ label: ovLabel,
744
+ color: s.overlay.color || s.color || getSeriesColor(this._allSeriesInfo.length),
745
+ lineStyle: s.overlay.lineStyle || 'dashed',
746
+ subplotIndex: spIdx,
747
+ subplotName: sp.displayName || `Subplot ${spIdx + 1}`,
748
+ isOverlay: true,
749
+ });
750
+ this._allSeriesLabels.push(ovLabel);
751
+ }
752
+ }
753
+ }
754
+ }
755
+
756
+ // Prune hidden labels that no longer exist
757
+ for (const label of this._hiddenSeries) {
758
+ if (!this._allSeriesLabels.includes(label)) {
759
+ this._hiddenSeries.delete(label);
760
+ }
761
+ }
762
+
763
+ // Update toggle panel if visible
764
+ if (this._seriesTogglePanelVisible) {
765
+ const newSig = this._allSeriesInfo.map(s => `${s.subplotIndex}|${s.label}|${s.lineStyle}`).join('\x00');
766
+ if (newSig !== this._lastTogglePanelSignature) {
767
+ this._lastTogglePanelSignature = newSig;
768
+ this.#rebuildSeriesToggleList();
769
+ }
770
+ }
771
+
772
+ // Update toggle button icon
773
+ if (this._seriesToggleBtn) {
774
+ const icon = this._seriesToggleBtn.querySelector('.material-symbols-outlined');
775
+ if (icon) {
776
+ icon.textContent = this._hiddenSeries.size > 0 ? 'visibility_off' : 'visibility';
777
+ }
778
+ }
779
+ }
780
+
781
+ #renderAllCharts(results) {
782
+ if (!window.Plotly) return;
783
+
784
+ const subplots = this._plotConfig.subplots ?? [];
785
+ for (let i = 0; i < this._chartDivs.length; i++) {
786
+ const chartDiv = this._chartDivs[i];
787
+ const sp = subplots[i];
788
+ if (!chartDiv) continue;
789
+ if (!sp) {
790
+ chartDiv.innerHTML = '<div class="twm-plot-no-data" style="color:rgba(255,255,255,0.3);text-align:center;padding:16px;">No subplot configured.</div>';
791
+ continue;
792
+ }
793
+ this.#renderSubplotChart(chartDiv, sp, results);
794
+ }
795
+ }
796
+
797
+ async #renderSubplotChart(chartDiv, sp, results) {
798
+ const { time, series } = results;
799
+ const traces = [];
800
+ const layoutAxes = {};
801
+ const yRefByAxisId = {};
802
+ let axisCount = 0;
803
+
804
+ for (const axis of sp.yAxes ?? []) {
805
+ axisCount++;
806
+ const yAxisKey = axisCount === 1 ? 'yaxis' : `yaxis${axisCount}`;
807
+ const yRef = axisCount === 1 ? 'y' : `y${axisCount}`;
808
+ if (axis.id) yRefByAxisId[axis.id] = yRef;
809
+
810
+ layoutAxes[yAxisKey] = buildYAxisLayout({
811
+ axisId: yAxisKey,
812
+ title: axis.label || '',
813
+ position: axis.position || 'left',
814
+ scaleType: axis.scale || 'linear',
815
+ min: axis.min,
816
+ max: axis.max,
817
+ showGrid: (sp.yAxes ?? []).length === 1,
818
+ axisPosition: axisCount > 1 ? (axis.position === 'right' ? 1 : 0) : null,
819
+ });
820
+
821
+ for (const s of axis.series ?? []) {
822
+ if (!s.variable) continue;
823
+
824
+ const label = s.label || s.variable;
825
+
826
+ // Skip hidden series
827
+ if (this._hiddenSeries.has(label)) continue;
828
+
829
+ // Resolve variable: try result key (paper multi-scenario), exact, then bare (without namespace prefix)
830
+ let rawY = series?.[s._resultKey ?? s.variable];
831
+ if (!rawY && s._resultKey) {
832
+ rawY = series?.[s.variable];
833
+ }
834
+ if (!rawY) {
835
+ const dotIdx = s.variable.indexOf('.');
836
+ if (dotIdx >= 0) rawY = series?.[s.variable.slice(dotIdx + 1)];
837
+ }
838
+ if (!rawY) continue;
839
+
840
+ const y = Array.isArray(rawY) ? applyNanHandling(rawY, sp.nanHandling ?? 'gap', time) : rawY;
841
+ const seriesTime = s._resultTime ?? time;
842
+ traces.push(buildTrace({
843
+ chartType: sp.chartType || 'line',
844
+ x: seriesTime,
845
+ y,
846
+ name: label,
847
+ color: s.color,
848
+ lineWidth: s.lineWidth ?? 2,
849
+ lineStyle: s.lineStyle ?? 'solid',
850
+ interpolation: sp.interpolation ?? 'linear',
851
+ yAxisId: yRef,
852
+ showMarkers: sp.showDataPoints ?? false,
853
+ stackGroup: s.stackGroup ?? null,
854
+ }));
855
+
856
+ // Per-series overlay trace (ETL actuals)
857
+ if (s.overlay?.etlKey && this._actuals?.[s.id]) {
858
+ const ovLabel = s.overlay.label || `${label} (actual)`;
859
+ if (!this._hiddenSeries.has(ovLabel)) {
860
+ const act = this._actuals[s.id];
861
+ if (act?.years?.length) {
862
+ const ov = s.overlay;
863
+ const dashMap = { dashed: 'dash', dotted: 'dot', solid: 'solid' };
864
+ traces.push({
865
+ type: 'scatter',
866
+ mode: ov.mode || 'markers+lines',
867
+ x: act.years,
868
+ y: act.values,
869
+ name: ov.label || `${label} (actual)`,
870
+ yaxis: yRef === 'y' ? undefined : yRef,
871
+ line: {
872
+ color: ov.color || s.color,
873
+ width: ov.lineWidth ?? 2,
874
+ dash: dashMap[ov.lineStyle] || 'dash',
875
+ },
876
+ marker: {
877
+ color: ov.color || s.color,
878
+ size: 4,
879
+ symbol: 'circle',
880
+ },
881
+ showlegend: true,
882
+ });
883
+ }
884
+ }
885
+ }
886
+ }
887
+ }
888
+
889
+ // HP filter traces
890
+ if ((sp.showHpTrend || sp.showHpCycle) && window.pywebview?.api?.hp_filter) {
891
+ const hpLambda = sp.hpLambda ?? 1600;
892
+ for (const trace of [...traces]) {
893
+ if (trace.type && trace.type !== 'scatter' && trace.type !== 'scattergl') continue;
894
+ try {
895
+ const hpResult = await window.pywebview.api.hp_filter({
896
+ data: trace.y,
897
+ lambda: hpLambda,
898
+ });
899
+ if (!hpResult?.ok) continue;
900
+ const { trend, cycle } = hpResult;
901
+ const origColor = trace.line?.color || trace.marker?.color || getSeriesColor(0);
902
+ if (sp.showHpTrend && trend) {
903
+ traces.push({
904
+ x: trace.x, y: trend, type: 'scatter', mode: 'lines',
905
+ name: `${trace.name} (HP Trend)`,
906
+ yaxis: trace.yaxis || 'y',
907
+ line: { color: origColor, width: 2, dash: 'dash' },
908
+ showlegend: true,
909
+ });
910
+ }
911
+ if (sp.showHpCycle && cycle) {
912
+ traces.push({
913
+ x: trace.x, y: cycle, type: 'scatter', mode: 'lines',
914
+ name: `${trace.name} (HP Cycle)`,
915
+ yaxis: trace.yaxis || 'y',
916
+ line: { color: origColor, width: 1, dash: 'dot' },
917
+ showlegend: true,
918
+ });
919
+ }
920
+ } catch (_) { /* ignore HP filter failures */ }
921
+ }
922
+ }
923
+
924
+ if (traces.length === 0 && this._hiddenSeries.size === 0) {
925
+ chartDiv.innerHTML = `<div class="twm-plot-no-data" style="color:rgba(255,255,255,0.3);text-align:center;padding:16px;font-size:11px;">${this.emptyMessage}</div>`;
926
+ return;
927
+ }
928
+
929
+ // Popout windows have space — always show legend
930
+ const legendPos = sp.legendPosition === 'hidden' ? 'top' : (sp.legendPosition ?? 'top');
931
+ const legend = buildLegendLayout(legendPos);
932
+
933
+ // Clip the x-axis to the data's time range so overlays don't run past t0–t1
934
+ const xRange = time?.length >= 2 ? [time[0], time[time.length - 1]] : undefined;
935
+
936
+ const { shapes, annotations } = buildOverlayShapes({
937
+ referenceLines: sp.referenceLines,
938
+ shadedBands: sp.shadedBands,
939
+ yRefByAxisId,
940
+ });
941
+
942
+ const layout = {
943
+ ...layoutAxes,
944
+ ...(shapes.length ? { shapes } : {}),
945
+ ...(annotations.length ? { annotations } : {}),
946
+ xaxis: {
947
+ gridcolor: 'rgba(255,255,255,0.05)',
948
+ linecolor: 'rgba(255,255,255,0.08)',
949
+ tickcolor: 'rgba(255,255,255,0.3)',
950
+ zerolinecolor: 'rgba(255,255,255,0.06)',
951
+ automargin: true,
952
+ ...(xRange ? { range: xRange } : {}),
953
+ },
954
+ ...legend,
955
+ paper_bgcolor: 'transparent',
956
+ plot_bgcolor: 'transparent',
957
+ font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', color: '#cccccc', size: 11 },
958
+ margin: { t: 10, r: 20, b: 40, l: 50 },
959
+ hoverlabel: {
960
+ bgcolor: '#1e2228',
961
+ bordercolor: '#444444',
962
+ font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', color: '#cccccc' },
963
+ },
964
+ autosize: true,
965
+ hovermode: 'x unified',
966
+ };
967
+
968
+ // Guard: skip if div is detached or zero-size
969
+ if (!chartDiv.isConnected || chartDiv.offsetWidth === 0) return;
970
+
971
+ try {
972
+ await Plotly.react(chartDiv, traces, layout, { responsive: true, displayModeBar: false });
973
+ this._chartCreated = true;
974
+ } catch (_) {
975
+ // Plotly can throw if div is removed mid-render
976
+ }
977
+ }
978
+
979
+ // ── Data table ──────────────────────────────────────────────────────
980
+
981
+ #updateTableData(results) {
982
+ if (!results) {
983
+ this._tableHeaders = [];
984
+ this._tableRows = [];
985
+ return;
986
+ }
987
+
988
+ const headers = ['Time'];
989
+ const seriesColumns = [];
990
+ const { time, series } = results;
991
+ const subplots = this._plotConfig.subplots ?? [];
992
+
993
+ for (const sp of subplots) {
994
+ for (const axis of sp.yAxes ?? []) {
995
+ for (const s of axis.series ?? []) {
996
+ if (!s.variable) continue;
997
+ const label = s.label || s.variable;
998
+ let rawY = series?.[s._resultKey ?? s.variable];
999
+ if (!rawY && s._resultKey) {
1000
+ rawY = series?.[s.variable];
1001
+ }
1002
+ if (!rawY) {
1003
+ const dotIdx = s.variable.indexOf('.');
1004
+ if (dotIdx >= 0) rawY = series?.[s.variable.slice(dotIdx + 1)];
1005
+ }
1006
+ if (!rawY) continue;
1007
+ headers.push(label);
1008
+ seriesColumns.push(rawY);
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ const rows = [];
1014
+ for (let i = 0; i < time.length; i++) {
1015
+ const row = [time[i]];
1016
+ for (const col of seriesColumns) {
1017
+ row.push(Array.isArray(col) ? col[i] : null);
1018
+ }
1019
+ rows.push(row);
1020
+ }
1021
+
1022
+ this._tableHeaders = headers;
1023
+ this._tableRows = rows;
1024
+ }
1025
+
1026
+ #renderDataTable() {
1027
+ if (!this._dataTableContainer) return;
1028
+
1029
+ if (!this._dataTable) {
1030
+ this._dataTable = new DataTable(this._dataTableContainer, {
1031
+ headers: this._tableHeaders,
1032
+ rows: this._tableRows,
1033
+ pageSize: 100,
1034
+ pagination: true,
1035
+ selectable: true,
1036
+ copyable: true,
1037
+ sortable: true,
1038
+ filterable: true,
1039
+ readonly: false,
1040
+ emptyMessage: this.tableEmptyMessage,
1041
+ services: this._services,
1042
+ host: this._host,
1043
+ });
1044
+ this._dataTable.render();
1045
+ } else {
1046
+ this._dataTable.setData({
1047
+ headers: this._tableHeaders,
1048
+ rows: this._tableRows,
1049
+ });
1050
+ }
1051
+ }
1052
+
1053
+ // ── Downloads ────────────────────────────────────────────────────────
1054
+
1055
+ async #downloadCSV() {
1056
+ if (this._tableRows.length === 0) {
1057
+ this.#notify('Download', 'No data available to download.', 'warn');
1058
+ return;
1059
+ }
1060
+
1061
+ const filename = `${this._title.replace(/[^a-z0-9]/gi, '_')}_data.csv`;
1062
+
1063
+ const lines = [this._tableHeaders.join(',')];
1064
+ for (const row of this._tableRows) {
1065
+ lines.push(row.map(val => this.#csvEscape(val)).join(','));
1066
+ }
1067
+ const csv = lines.join('\n');
1068
+
1069
+ // null <=> the host has no save dialog behind its `dialogs` capability
1070
+ // (host.js). Fall back; do not report it as a failed save.
1071
+ const dialogs = this._host?.dialogs;
1072
+ let result = null;
1073
+ if (dialogs) {
1074
+ try {
1075
+ result = await dialogs.saveFile({ data: csv, filename, kind: 'csv' });
1076
+ } catch (err) {
1077
+ console.error('[PlotPopoutWindow] CSV download failed:', err);
1078
+ this.#notify('Download', 'Failed to save file', 'error');
1079
+ return;
1080
+ }
1081
+ }
1082
+ if (result) {
1083
+ if (result.ok) {
1084
+ this.#notify('Download', `Saved ${this._tableRows.length} rows to ${result.path}`, 'success');
1085
+ } else if (!result.cancelled) {
1086
+ this.#notify('Download', result.error || 'Failed to save file', 'error');
1087
+ }
1088
+ return;
1089
+ }
1090
+
1091
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
1092
+ const url = URL.createObjectURL(blob);
1093
+ const link = document.createElement('a');
1094
+ link.href = url;
1095
+ link.download = filename;
1096
+ link.click();
1097
+ URL.revokeObjectURL(url);
1098
+ this.#notify('Download', 'Download started', 'info');
1099
+ }
1100
+
1101
+ #csvEscape(value) {
1102
+ if (value == null) return '';
1103
+ const str = String(value);
1104
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
1105
+ return `"${str.replace(/"/g, '""')}"`;
1106
+ }
1107
+ return str;
1108
+ }
1109
+
1110
+ async #downloadPNG() {
1111
+ // For multi-subplot, capture the grid container as a whole
1112
+ // Fall back to first chart div if only one subplot
1113
+ const targetDiv = this._chartDivs.length === 1 ? this._chartDivs[0] : this._chartDivs[0];
1114
+ if (!targetDiv || !window.Plotly || !this._chartCreated) {
1115
+ this.#notify('Download', 'Plot not ready', 'warn');
1116
+ return;
1117
+ }
1118
+
1119
+ const filename = `${this._title.replace(/[^a-z0-9]/gi, '_')}.png`;
1120
+
1121
+ try {
1122
+ const pngDataUrl = await window.Plotly.toImage(targetDiv, {
1123
+ format: 'png',
1124
+ width: 1920,
1125
+ height: 1080,
1126
+ scale: 2,
1127
+ });
1128
+
1129
+ // null <=> no save dialog behind the capability (host.js) -> fall back.
1130
+ const dialogs = this._host?.dialogs;
1131
+ const result = dialogs
1132
+ ? await dialogs.saveFile({ data: pngDataUrl.split(',')[1], filename, kind: 'png' })
1133
+ : null;
1134
+ if (result) {
1135
+ if (result.ok) {
1136
+ this.#notify('Download', `Plot saved to ${result.path}`, 'success');
1137
+ } else if (!result.cancelled) {
1138
+ this.#notify('Download', result.error || 'Failed to save image', 'error');
1139
+ }
1140
+ } else {
1141
+ const link = document.createElement('a');
1142
+ link.href = pngDataUrl;
1143
+ link.download = filename;
1144
+ link.click();
1145
+ this.#notify('Download', 'PNG download started', 'info');
1146
+ }
1147
+ } catch (err) {
1148
+ this._services.logger?.warn?.('[PlotPopoutWindow] PNG download failed', err);
1149
+ this.#notify('Download', 'Failed to generate PNG', 'error');
1150
+ }
1151
+ }
1152
+
1153
+ // ── Utilities ───────────────────────────────────────────────────────
1154
+
1155
+ #notify(title, message, severity = 'info') {
1156
+ this._services.eventBus?.emit?.('toast:show', {
1157
+ title,
1158
+ message,
1159
+ type: severity,
1160
+ });
1161
+ }
1162
+
1163
+ // ── Cleanup ─────────────────────────────────────────────────────────
1164
+
1165
+ #dispose() {
1166
+ if (this._dataTable) {
1167
+ this._dataTable.dispose();
1168
+ this._dataTable = null;
1169
+ }
1170
+
1171
+ this.#hideSeriesPanel();
1172
+
1173
+ if (this._resizeObserver) {
1174
+ this._resizeObserver.disconnect();
1175
+ this._resizeObserver = null;
1176
+ }
1177
+
1178
+ for (const disposer of this._eventDisposers) {
1179
+ try { disposer?.dispose?.(); } catch (_) {}
1180
+ }
1181
+ this._eventDisposers = [];
1182
+
1183
+ for (const div of this._chartDivs) {
1184
+ if (div && window.Plotly) {
1185
+ try { purge(div); } catch (_) {}
1186
+ }
1187
+ }
1188
+
1189
+ this._window = null;
1190
+ this._contentEl = null;
1191
+ this._gridEl = null;
1192
+ this._chartDivs = [];
1193
+ this._plotTab = null;
1194
+ this._dataTab = null;
1195
+ this._plotContent = null;
1196
+ this._dataContent = null;
1197
+ this._dataTableContainer = null;
1198
+ this._toolbarActions = null;
1199
+ this._zoomBtn = null;
1200
+ this._panBtn = null;
1201
+ this._seriesToggleBtn = null;
1202
+ this._seriesTogglePanel = null;
1203
+ this._lastTogglePanelSignature = null;
1204
+ this._hiddenSeries = null;
1205
+ this._allSeriesInfo = null;
1206
+ this._allSeriesLabels = null;
1207
+ this._chartCreated = false;
1208
+ this._lastResults = null;
1209
+ }
1210
+ }
1211
+
1212
+ // ── Singleton window manager ────────────────────────────────────────────
1213
+
1214
+ const _openWindows = new Map();
1215
+
1216
+ /**
1217
+ * Open or focus a plot popout window.
1218
+ *
1219
+ * @param {Object} options
1220
+ * @param {string} options.id - Unique window identifier
1221
+ * @param {string} options.title - Window title
1222
+ * @param {Object} options.plotConfig - PlotCell format: { layout, subplots }
1223
+ * @param {Function} options.getResults - Returns { time: number[], series: { [key]: number[] } }
1224
+ * @param {Object} options.services - { eventBus, logger }
1225
+ * @param {Object} [options.actuals] - ETL overlay data
1226
+ * @returns {PlotPopoutWindow}
1227
+ */
1228
+ export async function openPlotPopoutWindow(options) {
1229
+ const { id } = options;
1230
+ let win = _openWindows.get(id);
1231
+ if (win?.isVisible) {
1232
+ win.bringToFront();
1233
+ return win;
1234
+ }
1235
+
1236
+ // Forward the WHOLE options object. This used to destructure a fixed list of
1237
+ // eight names and rebuild the object from them, which means every option added
1238
+ // to the constructor from now on would be silently dropped on its way through
1239
+ // the factory — including `refreshEvent`, whose loss would show up as a plot
1240
+ // window that simply stops updating, with no error anywhere.
1241
+ win = new PlotPopoutWindow({
1242
+ ...options,
1243
+ onClose: () => _openWindows.delete(id),
1244
+ });
1245
+ _openWindows.set(id, win);
1246
+
1247
+ await win.show();
1248
+ return win;
1249
+ }
1250
+
1251
+ // ── Raw-traces window (for dashboard tile expand) ──────────────────────
1252
+
1253
+ /**
1254
+ * Open a ManagedWindow with pre-built Plotly traces.
1255
+ *
1256
+ * Reuses the same toolbar, tab layout, and download logic as PlotPopoutWindow
1257
+ * but accepts ready-made `{ traces, layout }` instead of plotConfig + getResults.
1258
+ * Used by dashboard tile widgets whose getExpandData() returns raw Plotly specs.
1259
+ *
1260
+ * @param {Object} options
1261
+ * @param {string} options.id - Unique window identifier
1262
+ * @param {string} options.title - Window title
1263
+ * @param {Array} options.traces - Plotly trace objects
1264
+ * @param {Object} options.layout - Plotly layout object
1265
+ * @param {Array} [options.tableHeaders] - Column headers for Data tab
1266
+ * @param {Array} [options.tableRows] - Row data for Data tab
1267
+ * @param {Object} [options.services] - { eventBus, logger }
1268
+ * @param {Object} [options.host] - A Host (ui/js/host/host.js); backs Export PNG/CSV.
1269
+ * @returns {{ close: Function, bringToFront: Function }}
1270
+ */
1271
+ export async function openRawTracesWindow({ id, title, traces, layout, frames, tableHeaders, tableRows, services, host }) {
1272
+ const existing = _openWindows.get(id);
1273
+ if (existing?.isVisible) {
1274
+ existing.bringToFront();
1275
+ return existing;
1276
+ }
1277
+
1278
+ await ensurePlotly();
1279
+
1280
+ // State
1281
+ let chartDiv = null;
1282
+ let chartCreated = false;
1283
+ let resizeObserver = null;
1284
+ let dataTable = null;
1285
+ let managedWindow = null;
1286
+
1287
+ // ── Build content DOM ───────────────────────────────────────────
1288
+
1289
+ const container = document.createElement('div');
1290
+ container.className = 'twm-plot-window-container';
1291
+
1292
+ // Tab header
1293
+ const tabHeader = document.createElement('div');
1294
+ tabHeader.className = 'twm-code-tabs-header';
1295
+
1296
+ const tabs = document.createElement('div');
1297
+ tabs.className = 'tabs twm-code-tabs';
1298
+
1299
+ const plotTab = document.createElement('button');
1300
+ plotTab.className = 'twm-code-tab active';
1301
+ plotTab.type = 'button';
1302
+ plotTab.textContent = 'Plot';
1303
+
1304
+ const dataTab = document.createElement('button');
1305
+ dataTab.className = 'twm-code-tab';
1306
+ dataTab.type = 'button';
1307
+ dataTab.textContent = 'Data';
1308
+
1309
+ tabs.appendChild(plotTab);
1310
+ tabs.appendChild(dataTab);
1311
+
1312
+ // Toolbar (same layout as PlotPopoutWindow)
1313
+ const toolbar = document.createElement('div');
1314
+ toolbar.className = 'twm-plot-window-toolbar';
1315
+
1316
+ const spacer = document.createElement('div');
1317
+ spacer.style.flex = '1';
1318
+ toolbar.appendChild(spacer);
1319
+
1320
+ const actionsGroup = document.createElement('div');
1321
+ actionsGroup.className = 'twm-plot-window-toolbar__group';
1322
+
1323
+ const mkBtn = (icon, tooltip, onClick) => {
1324
+ const btn = document.createElement('button');
1325
+ btn.className = 'twm-btn-icon twm-has-tooltip';
1326
+ btn.type = 'button';
1327
+ btn.setAttribute('data-tooltip', tooltip);
1328
+ btn.innerHTML = `<span class="material-symbols-outlined">${icon}</span>`;
1329
+ btn.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
1330
+ return btn;
1331
+ };
1332
+
1333
+ actionsGroup.appendChild(mkBtn('image', 'Download plot as PNG', downloadPNG));
1334
+ actionsGroup.appendChild(mkBtn('download', 'Download data as CSV', downloadCSV));
1335
+ toolbar.appendChild(actionsGroup);
1336
+
1337
+ tabHeader.appendChild(tabs);
1338
+ tabHeader.appendChild(toolbar);
1339
+
1340
+ // Tab content
1341
+ const content = document.createElement('div');
1342
+ content.className = 'twm-plot-window-content';
1343
+
1344
+ const plotContent = document.createElement('div');
1345
+ plotContent.className = 'twm-plot-tab-content active';
1346
+ plotContent.setAttribute('data-tab', 'plot');
1347
+ plotContent.style.cssText = 'display:flex; flex-direction:column;';
1348
+
1349
+ chartDiv = document.createElement('div');
1350
+ chartDiv.className = 'twm-plot-window-chart';
1351
+ plotContent.appendChild(chartDiv);
1352
+
1353
+ const dataContent = document.createElement('div');
1354
+ dataContent.className = 'twm-plot-tab-content';
1355
+ dataContent.setAttribute('data-tab', 'data');
1356
+ dataContent.style.cssText = 'display:none; flex-direction:column;';
1357
+
1358
+ const dataTableContainer = document.createElement('div');
1359
+ dataTableContainer.style.cssText = 'flex:1; min-height:0; display:flex; flex-direction:column;';
1360
+ dataContent.appendChild(dataTableContainer);
1361
+
1362
+ content.appendChild(plotContent);
1363
+ content.appendChild(dataContent);
1364
+
1365
+ container.appendChild(tabHeader);
1366
+ container.appendChild(content);
1367
+
1368
+ // Tab switching
1369
+ plotTab.addEventListener('click', () => {
1370
+ plotTab.classList.add('active');
1371
+ dataTab.classList.remove('active');
1372
+ toolbar.style.display = '';
1373
+ plotContent.style.display = 'flex';
1374
+ dataContent.style.display = 'none';
1375
+ if (chartCreated && window.Plotly && chartDiv) {
1376
+ try { window.Plotly.Plots.resize(chartDiv); } catch (_) {}
1377
+ }
1378
+ });
1379
+
1380
+ dataTab.addEventListener('click', () => {
1381
+ dataTab.classList.add('active');
1382
+ plotTab.classList.remove('active');
1383
+ toolbar.style.display = 'none';
1384
+ plotContent.style.display = 'none';
1385
+ dataContent.style.display = 'flex';
1386
+ if (!dataTable && tableHeaders?.length) {
1387
+ dataTable = new DataTable(dataTableContainer, {
1388
+ headers: tableHeaders,
1389
+ rows: tableRows || [],
1390
+ pagination: true,
1391
+ pageSize: 100,
1392
+ selectable: true,
1393
+ copyable: true,
1394
+ sortable: true,
1395
+ filterable: true,
1396
+ readonly: true,
1397
+ emptyMessage: 'No data available',
1398
+ services,
1399
+ host,
1400
+ });
1401
+ dataTable.render();
1402
+ }
1403
+ });
1404
+
1405
+ // ── Create window ───────────────────────────────────────────────
1406
+
1407
+ function dispose() {
1408
+ if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
1409
+ if (dataTable) { dataTable.dispose(); dataTable = null; }
1410
+ if (chartDiv && window.Plotly) { try { purge(chartDiv); } catch (_) {} }
1411
+ chartDiv = null;
1412
+ chartCreated = false;
1413
+ managedWindow = null;
1414
+ _openWindows.delete(id);
1415
+ }
1416
+
1417
+ managedWindow = new ManagedWindow({
1418
+ id: `raw-plot-${id}`,
1419
+ title: title || 'Chart',
1420
+ icon: 'monitoring',
1421
+ content: container,
1422
+ minWidth: 500,
1423
+ minHeight: 400,
1424
+ defaultWidth: 800,
1425
+ defaultHeight: 600,
1426
+ canMinimize: true,
1427
+ canMaximize: true,
1428
+ canResize: true,
1429
+ canDrag: true,
1430
+ onClose: dispose,
1431
+ });
1432
+
1433
+ const handle = {
1434
+ get isVisible() { return managedWindow?.isVisible ?? false; },
1435
+ bringToFront() { managedWindow?.bringToFront?.(); },
1436
+ close() { managedWindow?.close?.(); },
1437
+ };
1438
+
1439
+ _openWindows.set(id, handle);
1440
+ managedWindow.show();
1441
+
1442
+ // Render plot
1443
+ const defaultMargin = { l: 60, r: 30, t: 40, b: 60 };
1444
+ const plotLayout = {
1445
+ paper_bgcolor: 'transparent',
1446
+ plot_bgcolor: 'transparent',
1447
+ font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', color: '#cccccc', size: 11 },
1448
+ hoverlabel: {
1449
+ bgcolor: '#1e2228',
1450
+ bordercolor: '#444444',
1451
+ font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', color: '#cccccc' },
1452
+ },
1453
+ autosize: true,
1454
+ hovermode: 'x unified',
1455
+ ...layout,
1456
+ margin: { ...defaultMargin, ...(layout?.margin || {}) },
1457
+ };
1458
+
1459
+ await window.Plotly.newPlot(chartDiv, traces, plotLayout, {
1460
+ responsive: true,
1461
+ displayModeBar: true,
1462
+ displaylogo: false,
1463
+ modeBarButtonsToRemove: ['lasso2d', 'select2d', 'toImage'],
1464
+ });
1465
+ if (Array.isArray(frames) && frames.length) {
1466
+ try { await window.Plotly.addFrames(chartDiv, frames); }
1467
+ catch (err) { console.warn('[openRawTracesWindow] addFrames failed:', err); }
1468
+ }
1469
+ chartCreated = true;
1470
+
1471
+ // Resize observer
1472
+ resizeObserver = createRafResizeObserver(() => {
1473
+ if (chartDiv && chartCreated && window.Plotly) {
1474
+ try { window.Plotly.Plots.resize(chartDiv); } catch (_) {}
1475
+ }
1476
+ });
1477
+ resizeObserver.observe(plotContent);
1478
+
1479
+ // ── Download helpers ────────────────────────────────────────────
1480
+
1481
+ function notify(ntitle, message, severity = 'info') {
1482
+ services?.eventBus?.emit?.('toast:show', { title: ntitle, message, type: severity });
1483
+ }
1484
+
1485
+ async function downloadPNG() {
1486
+ if (!chartDiv || !chartCreated || !window.Plotly) {
1487
+ notify('Download', 'Plot not ready', 'warn');
1488
+ return;
1489
+ }
1490
+ const filename = `${(title || 'chart').replace(/[^a-z0-9]/gi, '_')}.png`;
1491
+ try {
1492
+ const dataUrl = await window.Plotly.toImage(chartDiv, {
1493
+ format: 'png', width: 1920, height: 1080, scale: 2,
1494
+ });
1495
+ // null <=> no save dialog behind the capability (host.js) -> fall back.
1496
+ const dialogs = host?.dialogs;
1497
+ const result = dialogs
1498
+ ? await dialogs.saveFile({ data: dataUrl.split(',')[1], filename, kind: 'png' })
1499
+ : null;
1500
+ if (result) {
1501
+ if (result.ok) notify('Download', `Plot saved to ${result.path}`, 'success');
1502
+ else if (!result.cancelled) notify('Download', result.error || 'Failed to save image', 'error');
1503
+ } else {
1504
+ const link = document.createElement('a');
1505
+ link.href = dataUrl;
1506
+ link.download = filename;
1507
+ link.click();
1508
+ notify('Download', 'PNG download started', 'info');
1509
+ }
1510
+ } catch (err) {
1511
+ console.error('[openRawTracesWindow] PNG download failed:', err);
1512
+ notify('Download', 'Failed to generate PNG', 'error');
1513
+ }
1514
+ }
1515
+
1516
+ async function downloadCSV() {
1517
+ if (!tableRows?.length) {
1518
+ notify('Download', 'No data available to download', 'warn');
1519
+ return;
1520
+ }
1521
+ const filename = `${(title || 'data').replace(/[^a-z0-9]/gi, '_')}_data.csv`;
1522
+ const csvEscape = (val) => {
1523
+ if (val == null) return '';
1524
+ const str = String(val);
1525
+ return (str.includes(',') || str.includes('"') || str.includes('\n'))
1526
+ ? `"${str.replace(/"/g, '""')}"` : str;
1527
+ };
1528
+ const lines = [(tableHeaders || []).join(',')];
1529
+ for (const row of tableRows) lines.push(row.map(csvEscape).join(','));
1530
+ const csv = lines.join('\n');
1531
+
1532
+ // null <=> no save dialog behind the capability (host.js) -> fall back.
1533
+ const dialogs = host?.dialogs;
1534
+ let result = null;
1535
+ if (dialogs) {
1536
+ try {
1537
+ result = await dialogs.saveFile({ data: csv, filename, kind: 'csv' });
1538
+ } catch (err) {
1539
+ console.error('[openRawTracesWindow] CSV download failed:', err);
1540
+ notify('Download', 'Failed to save file', 'error');
1541
+ return;
1542
+ }
1543
+ }
1544
+ if (result) {
1545
+ if (result.ok) notify('Download', `Saved ${tableRows.length} rows to ${result.path}`, 'success');
1546
+ else if (!result.cancelled) notify('Download', result.error || 'Failed to save file', 'error');
1547
+ } else {
1548
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
1549
+ const url = URL.createObjectURL(blob);
1550
+ const link = document.createElement('a');
1551
+ link.href = url;
1552
+ link.download = filename;
1553
+ link.click();
1554
+ URL.revokeObjectURL(url);
1555
+ notify('Download', 'Download started', 'info');
1556
+ }
1557
+ }
1558
+
1559
+ return handle;
1560
+ }