flexdesk 0.16.1 → 0.17.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 (252) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2217 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3146 -164
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3146 -164
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +657 -234
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3252 -2124
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -347
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +7 -7
  144. package/src/tiles/scope_picker.js +0 -0
  145. package/src/tiles/tile_base.js +1 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +2 -0
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +0 -0
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  241. package/dist/chunk-FBGP5KN5.js.map +0 -7
  242. package/dist/chunk-FL5KFNQH.js.map +0 -7
  243. package/dist/chunk-NLTOY57M.js.map +0 -7
  244. package/dist/chunk-NNFTETK3.js +0 -633
  245. package/dist/chunk-NNFTETK3.js.map +0 -7
  246. package/dist/chunk-O5OHMWBB.js +0 -284
  247. package/dist/chunk-O5OHMWBB.js.map +0 -7
  248. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  249. package/dist/chunk-SN4LSOAR.js.map +0 -7
  250. package/dist/chunk-W6FBJLPW.js +0 -367
  251. package/dist/chunk-W6FBJLPW.js.map +0 -7
  252. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -13,11 +13,14 @@
13
13
  * - Multi-subplot grid layouts (1x1, 1x2, 2x1, 2x2, 1x3, wide-top, wide-bottom)
14
14
  * - Series visibility toggle panel
15
15
  * - Zoom/pan toolbar with zoom in/out, autoscale, reset
16
- * - PyWebView integration for file downloads (CSV, PNG)
16
+ * - CSV and PNG export through the host's dialogs (else a browser download)
17
17
  * - NotificationCenter integration for user feedback
18
18
  * - Live streaming updates while the data source is running
19
- * - HP filter trend/cycle decomposition
19
+ * - Derived series from injected transforms (`seriesTransforms`)
20
20
  * - ETL actuals overlay support
21
+ *
22
+ * The figures are drawn by renderPlotGrid (plot_grid.js); this window adds the
23
+ * tabs, the toolbar, the series toggles and the data table around them.
21
24
  */
22
25
 
23
26
  import { ManagedWindow } from '../ui/components/managed_window.js';
@@ -28,17 +31,8 @@ import {
28
31
  purge,
29
32
  getSeriesColor,
30
33
  } 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';
34
+ import { migrateData } from './plot_config.js';
35
+ import { renderPlotGrid, resolveSeriesData } from './plot_grid.js';
42
36
 
43
37
  export class PlotPopoutWindow {
44
38
  /**
@@ -52,14 +46,29 @@ export class PlotPopoutWindow {
52
46
  * capability backs Export PNG/CSV; absent => Blob download fallback.
53
47
  * @param {Function} [options.onClose] - Called when window closes
54
48
  * @param {Object} [options.actuals] - ETL overlay data { seriesId: { years, values } }
55
- * @param {Function} [options.configRenderer] - (container) => void — renders config panel
49
+ * @param {Function} [options.configRenderer] - `(container) => { dispose() } | void` —
50
+ * renders the Config tab. What it returns is disposed (`dispose()`) when the
51
+ * window closes, so an editor drawn there (renderPlotConfigEditor) goes with it.
52
+ * @param {Object} [options.seriesTransforms] - `{ [id]: transform }` (series_transforms.js);
53
+ * absent => no derived series are drawn
54
+ * @param {string} [options.refreshEvent='data:changed'] - Bus event after which the
55
+ * window re-reads `getResults()` and redraws.
56
+ * @param {{delta?: string|null, complete?: string|null}} [options.streamEvents] - Bus
57
+ * events a live data source emits while it runs (`delta`) and when it ends
58
+ * (`complete`); the window redraws on each while live update is on.
59
+ * There are no default names: without `streamEvents` (or with a name
60
+ * left out or given as null) nothing is listened for, and the window
61
+ * updates through `refreshEvent` and explicit calls only.
62
+ * @param {Function} [options.resolveSeries] - `(series, results, subplot) => { y, x? } | null`
63
+ * (plot_grid.js); used by the chart and the data table. Default resolveSeriesData.
56
64
  */
57
65
  constructor({ id, title, plotConfig, getResults, services, host, onClose, actuals,
58
- configRenderer,
66
+ configRenderer, seriesTransforms, resolveSeries = resolveSeriesData,
59
67
  // The window used to listen for 'simulation:run:completed' and tell
60
68
  // the user to "run simulation first". A chart window does not know
61
69
  // that its data comes from a simulation; the app that opens it does.
62
70
  refreshEvent = 'data:changed',
71
+ streamEvents = {},
63
72
  emptyMessage = 'No data yet.',
64
73
  tableEmptyMessage = 'No data yet.' }) {
65
74
  this._id = id;
@@ -71,7 +80,11 @@ export class PlotPopoutWindow {
71
80
  this._onClose = onClose ?? (() => {});
72
81
  this._actuals = actuals ?? null;
73
82
  this._configRenderer = configRenderer ?? null;
83
+ this._seriesTransforms = seriesTransforms ?? null;
84
+ this._resolveSeries = resolveSeries;
74
85
  this.refreshEvent = refreshEvent;
86
+ this.streamEvents = Object.freeze({ delta: streamEvents?.delta ?? null,
87
+ complete: streamEvents?.complete ?? null });
75
88
  // TWO messages, not one. The chart says "No data — run simulation first."
76
89
  // and the data table underneath says "Run simulation to see data". Folding
77
90
  // them into a single option would have quietly rewritten one of them, which
@@ -82,12 +95,10 @@ export class PlotPopoutWindow {
82
95
 
83
96
  this._window = null;
84
97
  this._contentEl = null;
85
- this._chartDivs = [];
98
+ this._grid = null;
86
99
  this._gridEl = null;
87
100
  this._activeTab = 'plot';
88
101
  this._eventDisposers = [];
89
- this._resizeObserver = null;
90
- this._chartCreated = false;
91
102
  this._lastResults = null;
92
103
 
93
104
  // Data table
@@ -103,6 +114,7 @@ export class PlotPopoutWindow {
103
114
  this._plotContent = null;
104
115
  this._dataContent = null;
105
116
  this._configContent = null;
117
+ this._configHandle = null;
106
118
  this._toolbarActions = null;
107
119
  this._zoomBtn = null;
108
120
  this._panBtn = null;
@@ -118,6 +130,8 @@ export class PlotPopoutWindow {
118
130
  this._outsideClickHandler = null;
119
131
 
120
132
  this._renderPending = false;
133
+ /** updateConfig() ran since the last #render(); applied even without results. */
134
+ this._configChanged = false;
121
135
 
122
136
  // Live-update toggle: when true, window re-renders on streaming events
123
137
  this._liveUpdate = true;
@@ -135,7 +149,6 @@ export class PlotPopoutWindow {
135
149
  await ensurePlotly();
136
150
  this.#createWindow();
137
151
  this.#subscribeEvents();
138
- this.#setupResizeObserver();
139
152
  await this.#render();
140
153
  }
141
154
 
@@ -146,7 +159,7 @@ export class PlotPopoutWindow {
146
159
  /** Update plot config and re-render. */
147
160
  updateConfig(plotConfig) {
148
161
  this._plotConfig = migrateData(plotConfig);
149
- this.#buildSubplotGrid();
162
+ this._configChanged = true;
150
163
  this.#scheduleRender();
151
164
  }
152
165
 
@@ -279,7 +292,20 @@ export class PlotPopoutWindow {
279
292
  this._gridEl.className = 'twm-plot-window-chart';
280
293
  this._plotContent.appendChild(this._gridEl);
281
294
 
282
- this.#buildSubplotGrid();
295
+ this._grid = renderPlotGrid(this._gridEl, this._plotConfig, null, {
296
+ resolveSeries: this._resolveSeries,
297
+ overlays: this._actuals,
298
+ hiddenSeries: this._hiddenSeries,
299
+ seriesTransforms: this._seriesTransforms,
300
+ onTransformError: ({ transformId, traceName, error }) => this._services?.logger?.warn?.(
301
+ `[PlotPopoutWindow] transform "${transformId}" failed on "${traceName}"`, error),
302
+ // Popout windows have space — always show the legend.
303
+ alwaysShowLegend: true,
304
+ layout: { autosize: true, hovermode: 'x unified' },
305
+ emptyMessage: this.emptyMessage,
306
+ onError: ({ index, error }) => this._services?.logger?.warn?.(
307
+ `[PlotPopoutWindow] subplot ${index + 1} failed to draw`, error),
308
+ });
283
309
 
284
310
  // Data tab content
285
311
  this._dataContent = document.createElement('div');
@@ -300,7 +326,7 @@ export class PlotPopoutWindow {
300
326
  this._configContent.className = 'twm-plot-tab-content';
301
327
  this._configContent.setAttribute('data-tab', 'config');
302
328
  this._configContent.style.cssText = 'display:none; flex-direction:column; overflow:auto; padding:8px 12px;';
303
- this._configRenderer(this._configContent);
329
+ this._configHandle = this._configRenderer(this._configContent) ?? null;
304
330
  content.appendChild(this._configContent);
305
331
  }
306
332
 
@@ -316,40 +342,6 @@ export class PlotPopoutWindow {
316
342
  return container;
317
343
  }
318
344
 
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
345
  // ── Toolbar helpers ─────────────────────────────────────────────────
354
346
 
355
347
  #toolbarGroup() {
@@ -370,9 +362,13 @@ export class PlotPopoutWindow {
370
362
 
371
363
  // ── Plotly toolbar actions ───────────────────────────────────────────
372
364
 
365
+ get #charts() { return this._grid?.charts ?? []; }
366
+ /** The subplots that show a figure — the grid knows, however it drew them. */
367
+ get #plottedCharts() { return this._grid?.plotted ?? []; }
368
+
373
369
  #setDragMode(mode) {
374
- if (!this._chartCreated) return;
375
- for (const div of this._chartDivs) {
370
+ if (!this.#plottedCharts.length) return;
371
+ for (const div of this.#charts) {
376
372
  if (div._fullLayout) {
377
373
  window.Plotly.relayout(div, { dragmode: mode }).catch(() => {});
378
374
  }
@@ -382,7 +378,7 @@ export class PlotPopoutWindow {
382
378
  }
383
379
 
384
380
  #zoom(factor) {
385
- for (const div of this._chartDivs) {
381
+ for (const div of this.#charts) {
386
382
  if (!div._fullLayout) continue;
387
383
  const xa = div._fullLayout.xaxis;
388
384
  const ya = div._fullLayout.yaxis;
@@ -403,7 +399,7 @@ export class PlotPopoutWindow {
403
399
  }
404
400
 
405
401
  #autoscale() {
406
- for (const div of this._chartDivs) {
402
+ for (const div of this.#charts) {
407
403
  if (!div._fullLayout) continue;
408
404
  window.Plotly.relayout(div, {
409
405
  'xaxis.autorange': true,
@@ -412,20 +408,6 @@ export class PlotPopoutWindow {
412
408
  }
413
409
  }
414
410
 
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
411
  #switchTab(tabName) {
430
412
  this._activeTab = tabName;
431
413
 
@@ -443,13 +425,7 @@ export class PlotPopoutWindow {
443
425
  this._configContent.style.display = tabName === 'config' ? 'flex' : 'none';
444
426
  }
445
427
 
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
- }
428
+ if (tabName === 'plot') this._grid?.resize();
453
429
 
454
430
  if (tabName === 'data') {
455
431
  this.#renderDataTable();
@@ -649,17 +625,16 @@ export class PlotPopoutWindow {
649
625
  const { eventBus } = this._services;
650
626
  if (!eventBus) return;
651
627
 
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
628
+ const { delta, complete } = this.streamEvents;
629
+ for (const name of new Set([delta, complete])) {
630
+ if (!name) continue;
631
+ this._eventDisposers.push(
632
+ eventBus.on(name, () => {
633
+ if (this._liveUpdate) this.#scheduleRender();
634
+ })
635
+ );
636
+ }
637
+ // Final analytics arrive asynchronously after the stream's complete event
663
638
  // (backend fetch + transform). Without this, the popout window may
664
639
  // render stale data from a previous run when the dashboard tab
665
640
  // didn't receive streaming deltas (e.g. user was on another tab).
@@ -699,7 +674,21 @@ export class PlotPopoutWindow {
699
674
  if (!this._window?.isVisible) return;
700
675
 
701
676
  const results = this._getResults?.();
702
- if (!results) return;
677
+ const configChanged = this._configChanged;
678
+ this._configChanged = false;
679
+ if (!results) {
680
+ // No data yet: the figures already drawn stay, unless the layout or
681
+ // the subplots changed, in which case the grid is rebuilt for them.
682
+ if (configChanged) {
683
+ await this._grid.update({
684
+ plotConfig: this._plotConfig,
685
+ results: null,
686
+ overlays: this._actuals,
687
+ hiddenSeries: this._hiddenSeries,
688
+ });
689
+ }
690
+ return;
691
+ }
703
692
 
704
693
  this._lastResults = results;
705
694
 
@@ -707,7 +696,13 @@ export class PlotPopoutWindow {
707
696
  this.#collectSeriesInfo();
708
697
 
709
698
  // Render all subplot charts
710
- this.#renderAllCharts(results);
699
+ await this._grid.update({
700
+ plotConfig: this._plotConfig,
701
+ results,
702
+ overlays: this._actuals,
703
+ hiddenSeries: this._hiddenSeries,
704
+ });
705
+ if (!this._window) return;
711
706
 
712
707
  // Update table data
713
708
  this.#updateTableData(results);
@@ -737,7 +732,7 @@ export class PlotPopoutWindow {
737
732
  this._allSeriesLabels.push(label);
738
733
 
739
734
  // Include overlay in toggle list
740
- if (s.overlay?.etlKey) {
735
+ if (s.overlay) {
741
736
  const ovLabel = s.overlay.label || `${label} (actual)`;
742
737
  this._allSeriesInfo.push({
743
738
  label: ovLabel,
@@ -778,204 +773,6 @@ export class PlotPopoutWindow {
778
773
  }
779
774
  }
780
775
 
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
776
  // ── Data table ──────────────────────────────────────────────────────
980
777
 
981
778
  #updateTableData(results) {
@@ -987,7 +784,7 @@ export class PlotPopoutWindow {
987
784
 
988
785
  const headers = ['Time'];
989
786
  const seriesColumns = [];
990
- const { time, series } = results;
787
+ const { time } = results;
991
788
  const subplots = this._plotConfig.subplots ?? [];
992
789
 
993
790
  for (const sp of subplots) {
@@ -995,14 +792,7 @@ export class PlotPopoutWindow {
995
792
  for (const s of axis.series ?? []) {
996
793
  if (!s.variable) continue;
997
794
  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
- }
795
+ const rawY = this._resolveSeries(s, results, sp)?.y;
1006
796
  if (!rawY) continue;
1007
797
  headers.push(label);
1008
798
  seriesColumns.push(rawY);
@@ -1108,10 +898,9 @@ export class PlotPopoutWindow {
1108
898
  }
1109
899
 
1110
900
  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) {
901
+ // The first subplot that shows a figure.
902
+ const targetDiv = this.#plottedCharts[0];
903
+ if (!targetDiv || !window.Plotly) {
1115
904
  this.#notify('Download', 'Plot not ready', 'warn');
1116
905
  return;
1117
906
  }
@@ -1170,26 +959,24 @@ export class PlotPopoutWindow {
1170
959
 
1171
960
  this.#hideSeriesPanel();
1172
961
 
1173
- if (this._resizeObserver) {
1174
- this._resizeObserver.disconnect();
1175
- this._resizeObserver = null;
1176
- }
1177
-
1178
962
  for (const disposer of this._eventDisposers) {
1179
963
  try { disposer?.dispose?.(); } catch (_) {}
1180
964
  }
1181
965
  this._eventDisposers = [];
1182
966
 
1183
- for (const div of this._chartDivs) {
1184
- if (div && window.Plotly) {
1185
- try { purge(div); } catch (_) {}
1186
- }
967
+ // What configRenderer returned (an editor with dropdowns in document.body,
968
+ // say) goes with the window.
969
+ try { this._configHandle?.dispose?.(); } catch (error) {
970
+ this._services?.logger?.warn?.('[PlotPopoutWindow] the config panel failed to dispose', error);
1187
971
  }
972
+ this._configHandle = null;
973
+
974
+ this._grid?.dispose();
975
+ this._grid = null;
1188
976
 
1189
977
  this._window = null;
1190
978
  this._contentEl = null;
1191
979
  this._gridEl = null;
1192
- this._chartDivs = [];
1193
980
  this._plotTab = null;
1194
981
  this._dataTab = null;
1195
982
  this._plotContent = null;
@@ -1204,7 +991,6 @@ export class PlotPopoutWindow {
1204
991
  this._hiddenSeries = null;
1205
992
  this._allSeriesInfo = null;
1206
993
  this._allSeriesLabels = null;
1207
- this._chartCreated = false;
1208
994
  this._lastResults = null;
1209
995
  }
1210
996
  }
@@ -88,7 +88,7 @@ if (typeof HTMLCanvasElement !== 'undefined') {
88
88
  }
89
89
 
90
90
  /**
91
- * Dark theme layout defaults matching EcoSim UI
91
+ * Dark theme layout defaults matching the shell's dark UI
92
92
  */
93
93
  export const DARK_THEME_LAYOUT = {
94
94
  paper_bgcolor: 'transparent',
@@ -146,6 +146,9 @@ export const DARK_THEME_LAYOUT = {
146
146
  }
147
147
  };
148
148
 
149
+ /** The PNG export's file name (without extension) when the embedder names none. */
150
+ export const DEFAULT_CHART_EXPORT_FILENAME = 'chart';
151
+
149
152
  /**
150
153
  * Default Plotly config options
151
154
  */
@@ -156,11 +159,28 @@ export const DEFAULT_CONFIG = {
156
159
  scrollZoom: true, // Allow zoom with scroll wheel instead
157
160
  toImageButtonOptions: {
158
161
  format: 'png',
159
- filename: 'ecosim_chart',
162
+ filename: DEFAULT_CHART_EXPORT_FILENAME,
160
163
  scale: 2
161
164
  }
162
165
  };
163
166
 
167
+ /**
168
+ * Name the file a chart's PNG export saves as (Plotly adds `.png`). Applies to
169
+ * every chart created afterwards through DEFAULT_CONFIG; a chart's own
170
+ * `config.toImageButtonOptions` still wins.
171
+ *
172
+ * @param {string} [filename=DEFAULT_CHART_EXPORT_FILENAME]
173
+ */
174
+ export function setChartExportFilename(filename = DEFAULT_CHART_EXPORT_FILENAME) {
175
+ DEFAULT_CONFIG.toImageButtonOptions.filename =
176
+ (typeof filename === 'string' && filename) ? filename : DEFAULT_CHART_EXPORT_FILENAME;
177
+ }
178
+
179
+ /** @returns {string} The PNG export's file name (without extension). */
180
+ export function getChartExportFilename() {
181
+ return DEFAULT_CONFIG.toImageButtonOptions.filename;
182
+ }
183
+
164
184
  /**
165
185
  * Load Plotly.js from CDN.
166
186
  * Uses singleton pattern to avoid multiple loads.