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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- 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
|
+
}
|