flexdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,761 @@
1
+ /**
2
+ * Data Series Plot Window
3
+ *
4
+ * Opens a data series in a ManagedWindow with a split layout:
5
+ * - Left: Plotly chart
6
+ * - Right: Configuration panel (column selection, chart type, styling)
7
+ *
8
+ * Config is persisted in workspace metadata via savePlotConfig callback.
9
+ */
10
+
11
+ import { ManagedWindow } from '../ui/components/managed_window.js';
12
+ import {
13
+ ensurePlotly,
14
+ purge,
15
+ DARK_THEME_LAYOUT,
16
+ DEFAULT_CONFIG,
17
+ getSeriesColor,
18
+ } from './plotly_wrapper.js';
19
+ import {
20
+ buildTrace,
21
+ buildYAxisLayout,
22
+ buildXAxisLayout,
23
+ buildLegendLayout,
24
+ } from './chart_types.js';
25
+ // The two header helpers are INJECTED, not imported.
26
+ //
27
+ // They used to come from ui/js/ui/utils/header_utils.js, which decides whether a
28
+ // column is a STOCK or a FLOW by looking for `asset`, `liability`, `equity`,
29
+ // `reserves`, `deposit`. That is stock-flow-consistent accounting, not charting,
30
+ // and that one import was the last thing keeping this window out of @flexdesk/charts.
31
+ //
32
+ // The defaults below are what a chart library can honestly know about a column
33
+ // name: whether it looks like a time axis, and nothing else.
34
+
35
+ /** A column called `t`/`time`/`step`/`period` is the x-axis. Everything else is data. */
36
+ const defaultClassifyHeader = (header) => {
37
+ const lower = String(header ?? '').trim().toLowerCase();
38
+ return (lower === 't' || lower === 'time' || lower === 'step' || lower === 'period')
39
+ ? 'time' : 'indicator';
40
+ };
41
+
42
+ /** No convention to strip, absent one being supplied. */
43
+ const defaultLabelHeader = (header) => header;
44
+ import { createRafResizeObserver } from '../ui/utils/raf_resize_observer.js';
45
+
46
+ const INTERPOLATION_MODES = ['linear', 'step', 'cubic'];
47
+ const CHART_TYPES = ['line', 'scatter', 'bar', 'area', 'area-stacked'];
48
+ const LEGEND_POSITIONS = ['top', 'bottom', 'right', 'hidden'];
49
+ const MAX_AUTO_COLUMNS = 3;
50
+
51
+ class DataSeriesPlotWindow {
52
+ /**
53
+ * @param {Object} options
54
+ * @param {string} options.seriesId
55
+ * @param {string} options.seriesName
56
+ * @param {string} options.datasetName
57
+ * @param {string[]} options.headers - Column headers from the series
58
+ * @param {Object} options.dataHub - DataHub service for fetching data
59
+ * @param {Object} options.services - { eventBus, logger }
60
+ * @param {Object} [options.host] - A Host (ui/js/host/host.js). Its `dialogs`
61
+ * capability backs Export PNG/CSV; absent => Blob download fallback.
62
+ * @param {Function} options.getPlotConfig - (seriesId) => config | null
63
+ * @param {Function} options.savePlotConfig - (seriesId, config) => void
64
+ */
65
+ constructor({ seriesId, seriesName, datasetName, headers, dataHub, services, host,
66
+ getPlotConfig, savePlotConfig,
67
+ classifyHeader = defaultClassifyHeader,
68
+ labelHeader = defaultLabelHeader }) {
69
+ this.classifyHeader = classifyHeader;
70
+ this.labelHeader = labelHeader;
71
+ this.seriesId = seriesId;
72
+ this.seriesName = seriesName;
73
+ this.datasetName = datasetName;
74
+ this.headers = headers;
75
+ this.dataHub = dataHub;
76
+ this.services = services;
77
+ this._host = host || null;
78
+ this.getPlotConfig = getPlotConfig;
79
+ this.savePlotConfig = savePlotConfig;
80
+
81
+ this.window = null;
82
+ this.contentEl = null;
83
+ this.plotDiv = null;
84
+ this._resizeObserver = null;
85
+ this._chartCreated = false;
86
+ this._config = null;
87
+ this._fullData = null; // { headers, rows } with all data
88
+ this._numericColumns = null; // Set of numeric column headers
89
+ this._loading = false;
90
+ }
91
+
92
+ async show() {
93
+ if (this.window?.isVisible) {
94
+ this.window.bringToFront();
95
+ return;
96
+ }
97
+
98
+ await ensurePlotly();
99
+ await this.#fetchData();
100
+
101
+ // Determine which columns are numeric from actual data
102
+ this._numericColumns = new Set(this.headers.filter(h => this.#isNumericColumn(h)));
103
+
104
+ // Load or create config (filter out any non-numeric columns from saved config)
105
+ const saved = this.getPlotConfig?.(this.seriesId);
106
+ if (saved) {
107
+ this._config = { ...saved };
108
+ this._config.yColumns = (saved.yColumns || []).filter(c => this._numericColumns.has(c.header));
109
+ } else {
110
+ this._config = DataSeriesPlotWindow.createDefaultConfig(this.headers, this._numericColumns);
111
+ }
112
+
113
+ this.#createWindow();
114
+ this.#setupResizeObserver();
115
+ await this.#renderPlot();
116
+ }
117
+
118
+ close() {
119
+ if (this.window?.isVisible) {
120
+ this.window.close();
121
+ }
122
+ }
123
+
124
+ // ── Window creation ──────────────────────────────────────────────────
125
+
126
+ #createWindow() {
127
+ this.contentEl = this.#buildContent();
128
+
129
+ const title = this.datasetName
130
+ ? `${this.datasetName} · ${this.seriesName}`
131
+ : this.seriesName || 'Series Plot';
132
+
133
+ this.window = new ManagedWindow({
134
+ id: `data-series-plot-${this.seriesId}`,
135
+ title,
136
+ icon: 'monitoring',
137
+ content: this.contentEl,
138
+ minWidth: 600,
139
+ minHeight: 400,
140
+ defaultWidth: 900,
141
+ defaultHeight: 550,
142
+ canMinimize: true,
143
+ canMaximize: true,
144
+ canResize: true,
145
+ canDrag: true,
146
+ onClose: () => this.#dispose(),
147
+ });
148
+
149
+ this.window.show();
150
+ }
151
+
152
+ #buildContent() {
153
+ const container = document.createElement('div');
154
+ container.className = 'twm-data-series-plot-container';
155
+
156
+ // Actions bar (download buttons)
157
+ const actionsBar = document.createElement('div');
158
+ actionsBar.className = 'twm-data-series-plot-actions';
159
+
160
+ const pngBtn = document.createElement('button');
161
+ pngBtn.className = 'twm-btn-icon twm-has-tooltip';
162
+ pngBtn.type = 'button';
163
+ pngBtn.setAttribute('data-tooltip', 'Download plot as PNG');
164
+ pngBtn.innerHTML = '<span class="material-symbols-outlined">image</span>';
165
+ pngBtn.addEventListener('click', () => this.#downloadPNG());
166
+
167
+ const csvBtn = document.createElement('button');
168
+ csvBtn.className = 'twm-btn-icon twm-has-tooltip';
169
+ csvBtn.type = 'button';
170
+ csvBtn.setAttribute('data-tooltip', 'Download data as CSV');
171
+ csvBtn.innerHTML = '<span class="material-symbols-outlined">download</span>';
172
+ csvBtn.addEventListener('click', () => this.#downloadCSV());
173
+
174
+ actionsBar.appendChild(pngBtn);
175
+ actionsBar.appendChild(csvBtn);
176
+
177
+ // Chart area
178
+ const chartWrap = document.createElement('div');
179
+ chartWrap.className = 'twm-data-series-plot-body';
180
+
181
+ this.plotDiv = document.createElement('div');
182
+ this.plotDiv.className = 'twm-data-series-plot-chart';
183
+ chartWrap.appendChild(this.plotDiv);
184
+
185
+ // Config panel
186
+ const configPanel = this.#buildConfigPanel();
187
+ chartWrap.appendChild(configPanel);
188
+
189
+ container.appendChild(actionsBar);
190
+ container.appendChild(chartWrap);
191
+
192
+ return container;
193
+ }
194
+
195
+ // ── Config panel ─────────────────────────────────────────────────────
196
+
197
+ #buildConfigPanel() {
198
+ const panel = document.createElement('div');
199
+ panel.className = 'twm-data-series-plot-config';
200
+
201
+ // X Column
202
+ panel.appendChild(this.#buildSection('X Axis', this.#buildXColumnSelect()));
203
+
204
+ // Y Columns
205
+ this._yColumnsList = document.createElement('div');
206
+ this._yColumnsList.className = 'twm-data-series-plot-config__columns';
207
+ this.#populateYColumns();
208
+ panel.appendChild(this.#buildSection('Y Axis', this._yColumnsList));
209
+
210
+ // Chart type
211
+ panel.appendChild(this.#buildSection('Chart Type', this.#buildSelect(
212
+ CHART_TYPES,
213
+ this._config.chartType,
214
+ (val) => { this._config.chartType = val; this.#onConfigChanged(); }
215
+ )));
216
+
217
+ // Interpolation
218
+ panel.appendChild(this.#buildSection('Interpolation', this.#buildSelect(
219
+ INTERPOLATION_MODES,
220
+ this._config.interpolation,
221
+ (val) => { this._config.interpolation = val; this.#onConfigChanged(); }
222
+ )));
223
+
224
+ // Legend position
225
+ panel.appendChild(this.#buildSection('Legend', this.#buildSelect(
226
+ LEGEND_POSITIONS,
227
+ this._config.legendPosition,
228
+ (val) => { this._config.legendPosition = val; this.#onConfigChanged(); }
229
+ )));
230
+
231
+ // Show data points
232
+ panel.appendChild(this.#buildSection('', this.#buildCheckbox(
233
+ 'Show data points',
234
+ this._config.showDataPoints,
235
+ (checked) => { this._config.showDataPoints = checked; this.#onConfigChanged(); }
236
+ )));
237
+
238
+ return panel;
239
+ }
240
+
241
+ #buildSection(label, content) {
242
+ const section = document.createElement('div');
243
+ section.className = 'twm-data-series-plot-config__section';
244
+
245
+ if (label) {
246
+ const labelEl = document.createElement('div');
247
+ labelEl.className = 'twm-data-series-plot-config__label';
248
+ labelEl.textContent = label;
249
+ section.appendChild(labelEl);
250
+ }
251
+
252
+ if (content instanceof HTMLElement) {
253
+ section.appendChild(content);
254
+ }
255
+
256
+ return section;
257
+ }
258
+
259
+ #buildXColumnSelect() {
260
+ const select = document.createElement('select');
261
+ for (const header of this.headers) {
262
+ if (!this._numericColumns.has(header)) continue;
263
+ const opt = document.createElement('option');
264
+ opt.value = header;
265
+ opt.textContent = this.labelHeader(header);
266
+ if (header === this._config.xColumn) opt.selected = true;
267
+ select.appendChild(opt);
268
+ }
269
+ select.addEventListener('change', () => {
270
+ this._config.xColumn = select.value;
271
+ this.#onConfigChanged();
272
+ });
273
+ return select;
274
+ }
275
+
276
+ #populateYColumns() {
277
+ const list = this._yColumnsList;
278
+ list.innerHTML = '';
279
+
280
+ const selectedSet = new Set(this._config.yColumns.map(c => c.header));
281
+
282
+ for (const header of this.headers) {
283
+ if (header === this._config.xColumn) continue;
284
+ if (!this._numericColumns.has(header)) continue;
285
+
286
+ const item = document.createElement('label');
287
+ item.className = 'twm-data-series-plot-config__column-item';
288
+
289
+ const cb = document.createElement('input');
290
+ cb.type = 'checkbox';
291
+ cb.checked = selectedSet.has(header);
292
+ cb.addEventListener('change', () => this.#toggleYColumn(header, cb.checked));
293
+
294
+ const colorSwatch = document.createElement('input');
295
+ colorSwatch.type = 'color';
296
+ colorSwatch.className = 'twm-data-series-plot-config__color-swatch';
297
+ const existing = this._config.yColumns.find(c => c.header === header);
298
+ colorSwatch.value = existing?.color || this.#nextColor(this.headers.indexOf(header));
299
+ colorSwatch.addEventListener('input', () => this.#updateColumnColor(header, colorSwatch.value));
300
+
301
+ const label = document.createElement('span');
302
+ label.textContent = this.labelHeader(header);
303
+ label.title = header;
304
+
305
+ item.appendChild(cb);
306
+ item.appendChild(colorSwatch);
307
+ item.appendChild(label);
308
+ list.appendChild(item);
309
+ }
310
+ }
311
+
312
+ #toggleYColumn(header, checked) {
313
+ if (checked) {
314
+ if (!this._config.yColumns.find(c => c.header === header)) {
315
+ const colorSwatch = this._yColumnsList.querySelector(
316
+ `label:has(span[title="${header}"]) input[type="color"]`
317
+ );
318
+ this._config.yColumns.push({
319
+ header,
320
+ color: colorSwatch?.value || this.#nextColor(this.headers.indexOf(header)),
321
+ });
322
+ }
323
+ } else {
324
+ this._config.yColumns = this._config.yColumns.filter(c => c.header !== header);
325
+ }
326
+ this.#onConfigChanged();
327
+ }
328
+
329
+ #updateColumnColor(header, color) {
330
+ const col = this._config.yColumns.find(c => c.header === header);
331
+ if (col) {
332
+ col.color = color;
333
+ this.#onConfigChanged();
334
+ }
335
+ }
336
+
337
+ #nextColor(index) {
338
+ return getSeriesColor(index);
339
+ }
340
+
341
+ #buildSelect(options, value, onChange) {
342
+ const select = document.createElement('select');
343
+ for (const opt of options) {
344
+ const el = document.createElement('option');
345
+ el.value = opt;
346
+ el.textContent = opt.charAt(0).toUpperCase() + opt.slice(1);
347
+ if (opt === value) el.selected = true;
348
+ select.appendChild(el);
349
+ }
350
+ select.addEventListener('change', () => onChange(select.value));
351
+ return select;
352
+ }
353
+
354
+ #buildCheckbox(label, checked, onChange) {
355
+ const wrapper = document.createElement('label');
356
+ wrapper.className = 'twm-data-series-plot-config__column-item';
357
+
358
+ const cb = document.createElement('input');
359
+ cb.type = 'checkbox';
360
+ cb.checked = checked;
361
+ cb.addEventListener('change', () => onChange(cb.checked));
362
+
363
+ const span = document.createElement('span');
364
+ span.textContent = label;
365
+
366
+ wrapper.appendChild(cb);
367
+ wrapper.appendChild(span);
368
+ return wrapper;
369
+ }
370
+
371
+ // ── Data fetching ────────────────────────────────────────────────────
372
+
373
+ async #fetchData() {
374
+ if (this._loading) return;
375
+ this._loading = true;
376
+
377
+ try {
378
+ const response = await this.dataHub.fetchSeriesById(this.seriesId, { limit: null, offset: 0 });
379
+ if (response?.ok) {
380
+ this._fullData = {
381
+ headers: response.headers || [],
382
+ rows: response.rows || [],
383
+ };
384
+ } else {
385
+ this.#notify('Plot', response?.error || 'Failed to load series data', 'error');
386
+ }
387
+ } catch (err) {
388
+ this.services.logger?.warn?.('[DataSeriesPlotWindow] Fetch failed', err);
389
+ this.#notify('Plot', 'Failed to load data', 'error');
390
+ } finally {
391
+ this._loading = false;
392
+ }
393
+ }
394
+
395
+ #isNumericColumn(header) {
396
+ if (!this._fullData) return false;
397
+ const data = this.#extractColumnData(header);
398
+ // Sample up to 20 non-empty values
399
+ let numericCount = 0;
400
+ let sampleCount = 0;
401
+ for (const v of data) {
402
+ if (v == null || v === '') continue;
403
+ sampleCount++;
404
+ if (Number.isFinite(Number(v))) numericCount++;
405
+ if (sampleCount >= 20) break;
406
+ }
407
+ return sampleCount > 0 && numericCount / sampleCount >= 0.8;
408
+ }
409
+
410
+ // ── Chart rendering ──────────────────────────────────────────────────
411
+
412
+ #extractColumnData(columnHeader) {
413
+ if (!this._fullData) return [];
414
+
415
+ const { headers, rows } = this._fullData;
416
+ const idx = headers.indexOf(columnHeader);
417
+ if (idx < 0) return [];
418
+
419
+ const TIME_NAMES = new Set(['time', 'tDisp', 'year', 'date', 'timestamp']);
420
+ const firstIsTime = TIME_NAMES.has(headers[0]);
421
+ const colOffset = firstIsTime ? 1 : 0;
422
+
423
+ return rows.map(row => {
424
+ if (Array.isArray(row)) return row[idx];
425
+ if (row && typeof row === 'object') {
426
+ // Handle {tDisp, cols} format
427
+ if (TIME_NAMES.has(columnHeader) && row.tDisp !== undefined) return row.tDisp;
428
+ if (Array.isArray(row.cols)) return row.cols[idx - colOffset] ?? '';
429
+ return row[columnHeader] ?? '';
430
+ }
431
+ return '';
432
+ }).map(v => {
433
+ const n = Number(v);
434
+ return Number.isFinite(n) ? n : v;
435
+ });
436
+ }
437
+
438
+ async #renderPlot() {
439
+ if (!this.plotDiv || !window.Plotly || !this._fullData) return;
440
+
441
+ const cfg = this._config;
442
+ const xData = this.#extractColumnData(cfg.xColumn);
443
+
444
+ if (xData.length === 0 || cfg.yColumns.length === 0) {
445
+ // Show empty state
446
+ if (this._chartCreated) {
447
+ try { purge(this.plotDiv); } catch (e) { /* ignore */ }
448
+ this._chartCreated = false;
449
+ }
450
+ this.plotDiv.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#888;font-size:13px;">Select columns to plot</div>';
451
+ return;
452
+ }
453
+
454
+ // Clear empty state if present
455
+ if (!this._chartCreated) {
456
+ this.plotDiv.innerHTML = '';
457
+ }
458
+
459
+ const traces = [];
460
+
461
+ for (let i = 0; i < cfg.yColumns.length; i++) {
462
+ const col = cfg.yColumns[i];
463
+ const yData = this.#extractColumnData(col.header);
464
+
465
+ traces.push(buildTrace({
466
+ chartType: cfg.chartType,
467
+ x: xData,
468
+ y: yData,
469
+ name: this.labelHeader(col.header),
470
+ color: col.color,
471
+ lineWidth: 2,
472
+ lineStyle: 'solid',
473
+ interpolation: cfg.interpolation,
474
+ yAxisId: 'y',
475
+ showMarkers: cfg.showDataPoints,
476
+ seriesIndex: i,
477
+ }));
478
+ }
479
+
480
+ const legendAtTop = cfg.legendPosition === 'top';
481
+ const legendAtBottom = cfg.legendPosition === 'bottom';
482
+
483
+ const layout = {
484
+ ...DARK_THEME_LAYOUT,
485
+ autosize: true,
486
+ margin: {
487
+ l: 60, r: 40,
488
+ b: legendAtBottom ? 80 : 50,
489
+ t: legendAtTop ? 60 : 30,
490
+ },
491
+ };
492
+
493
+ // Remove 3D scene from layout
494
+ delete layout.scene;
495
+
496
+ // X-axis
497
+ layout.xaxis = {
498
+ ...buildXAxisLayout({ title: this.labelHeader(cfg.xColumn) }),
499
+ };
500
+
501
+ // Unified hover
502
+ layout.hovermode = 'x unified';
503
+ layout.xaxis.showspikes = true;
504
+ layout.xaxis.spikemode = 'across';
505
+ layout.xaxis.spikethickness = 1;
506
+ layout.xaxis.spikecolor = '#666666';
507
+ layout.xaxis.spikedash = 'dot';
508
+
509
+ // Y-axis
510
+ layout.yaxis = buildYAxisLayout({
511
+ axisId: 'yaxis',
512
+ title: '',
513
+ position: 'left',
514
+ scaleType: 'linear',
515
+ showGrid: true,
516
+ });
517
+
518
+ // Legend
519
+ const legendConfig = buildLegendLayout(cfg.legendPosition);
520
+ if (legendConfig.legend) {
521
+ layout.legend = legendConfig.legend;
522
+ }
523
+ layout.showlegend = legendConfig.showlegend !== false;
524
+
525
+ const plotConfig = {
526
+ ...DEFAULT_CONFIG,
527
+ responsive: true,
528
+ displayModeBar: true,
529
+ modeBarButtonsToRemove: ['lasso2d', 'select2d', 'toImage'],
530
+ };
531
+
532
+ try {
533
+ await window.Plotly.newPlot(this.plotDiv, traces, layout, plotConfig);
534
+ this._chartCreated = true;
535
+
536
+ requestAnimationFrame(() => {
537
+ try {
538
+ window.Plotly.Plots.resize(this.plotDiv);
539
+ } catch (e) { /* ignore */ }
540
+ });
541
+ } catch (e) {
542
+ console.error('[DataSeriesPlotWindow] Chart render error:', e);
543
+ }
544
+ }
545
+
546
+ // ── Config change handler ────────────────────────────────────────────
547
+
548
+ #onConfigChanged() {
549
+ this.#renderPlot();
550
+ this.savePlotConfig?.(this.seriesId, { ...this._config, yColumns: [...this._config.yColumns] });
551
+ }
552
+
553
+ // ── Resize observer ──────────────────────────────────────────────────
554
+
555
+ #setupResizeObserver() {
556
+ if (!this.plotDiv) return;
557
+
558
+ this._resizeObserver = createRafResizeObserver(() => {
559
+ if (this.plotDiv && window.Plotly && this._chartCreated) {
560
+ try {
561
+ window.Plotly.Plots.resize(this.plotDiv);
562
+ } catch (e) { /* ignore */ }
563
+ }
564
+ });
565
+
566
+ this._resizeObserver.observe(this.plotDiv);
567
+ }
568
+
569
+ // ── Downloads ────────────────────────────────────────────────────────
570
+
571
+ async #downloadPNG() {
572
+ if (!this.plotDiv || !window.Plotly || !this._chartCreated) {
573
+ this.#notify('Download', 'Plot not ready', 'warn');
574
+ return;
575
+ }
576
+
577
+ const filename = `${(this.seriesName || 'series').replace(/[^a-z0-9]/gi, '_')}.png`;
578
+
579
+ try {
580
+ const pngDataUrl = await window.Plotly.toImage(this.plotDiv, {
581
+ format: 'png', width: 1920, height: 1080, scale: 2,
582
+ });
583
+
584
+ // null <=> no save dialog behind the capability (host.js) -> fall back.
585
+ const dialogs = this._host?.dialogs;
586
+ const result = dialogs
587
+ ? await dialogs.saveFile({ data: pngDataUrl.split(',')[1], filename, kind: 'png' })
588
+ : null;
589
+ if (result) {
590
+ if (result.ok) {
591
+ this.#notify('Download', `Plot saved to ${result.path}`, 'success');
592
+ } else if (!result.cancelled) {
593
+ this.#notify('Download', result.error || 'Failed to save image', 'error');
594
+ }
595
+ } else {
596
+ const link = document.createElement('a');
597
+ link.href = pngDataUrl;
598
+ link.download = filename;
599
+ link.click();
600
+ }
601
+ } catch (err) {
602
+ this.services.logger?.warn?.('[DataSeriesPlotWindow] PNG download failed', err);
603
+ this.#notify('Download', 'Failed to generate PNG', 'error');
604
+ }
605
+ }
606
+
607
+ async #downloadCSV() {
608
+ if (!this._fullData || !this._fullData.rows.length) {
609
+ this.#notify('Download', 'No data available', 'warn');
610
+ return;
611
+ }
612
+
613
+ const cfg = this._config;
614
+ const columns = [cfg.xColumn, ...cfg.yColumns.map(c => c.header)];
615
+ const headers = columns.map(h => this.labelHeader(h));
616
+
617
+ // Pre-extract all columns once
618
+ const columnData = columns.map(col => this.#extractColumnData(col));
619
+ const rowCount = this._fullData.rows.length;
620
+
621
+ const lines = [headers.join(',')];
622
+ for (let r = 0; r < rowCount; r++) {
623
+ const vals = columnData.map(data => this.#csvEscape(data[r]));
624
+ lines.push(vals.join(','));
625
+ }
626
+ const csv = lines.join('\n');
627
+
628
+ const filename = `${(this.seriesName || 'series').replace(/[^a-z0-9]/gi, '_')}_data.csv`;
629
+
630
+ // null <=> no save dialog behind the capability (host.js) -> fall back.
631
+ const dialogs = this._host?.dialogs;
632
+ let result = null;
633
+ if (dialogs) {
634
+ try {
635
+ result = await dialogs.saveFile({ data: csv, filename, kind: 'csv' });
636
+ } catch (err) {
637
+ this.#notify('Download', 'Failed to save file', 'error');
638
+ return;
639
+ }
640
+ }
641
+ if (result) {
642
+ if (result.ok) {
643
+ this.#notify('Download', `Saved to ${result.path}`, 'success');
644
+ } else if (!result.cancelled) {
645
+ this.#notify('Download', result.error || 'Failed to save file', 'error');
646
+ }
647
+ } else {
648
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
649
+ const url = URL.createObjectURL(blob);
650
+ const link = document.createElement('a');
651
+ link.href = url;
652
+ link.download = filename;
653
+ link.click();
654
+ URL.revokeObjectURL(url);
655
+ }
656
+ }
657
+
658
+ #csvEscape(value) {
659
+ if (value == null) return '';
660
+ const str = String(value);
661
+ if (/[",\n]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
662
+ return str;
663
+ }
664
+
665
+ // ── Notifications ────────────────────────────────────────────────────
666
+
667
+ #notify(title, message, severity = 'info') {
668
+ this.services.eventBus?.emit?.('toast:show', { title, message, type: severity });
669
+ }
670
+
671
+ // ── Disposal ─────────────────────────────────────────────────────────
672
+
673
+ #dispose() {
674
+ if (this._resizeObserver) {
675
+ this._resizeObserver.disconnect();
676
+ this._resizeObserver = null;
677
+ }
678
+
679
+ if (this.plotDiv && window.Plotly) {
680
+ try { purge(this.plotDiv); } catch (e) { /* ignore */ }
681
+ }
682
+
683
+ _openPlotWindows.delete(this.seriesId);
684
+
685
+ this.window = null;
686
+ this.contentEl = null;
687
+ this.plotDiv = null;
688
+ this._yColumnsList = null;
689
+ this._chartCreated = false;
690
+ this._fullData = null;
691
+ this._numericColumns = null;
692
+ this._config = null;
693
+ }
694
+
695
+ // ── Default config factory ───────────────────────────────────────────
696
+
697
+ static createDefaultConfig(headers, numericColumns) {
698
+ const isNumeric = numericColumns instanceof Set ? (h) => numericColumns.has(h) : () => true;
699
+
700
+ // Find best X column (time-like, must be numeric)
701
+ let xColumn = '';
702
+ for (const h of headers) {
703
+ if (isNumeric(h) && this.classifyHeader(h) === 'time') {
704
+ xColumn = h;
705
+ break;
706
+ }
707
+ }
708
+ // Fallback: first numeric column
709
+ if (!xColumn) {
710
+ for (const h of headers) {
711
+ if (isNumeric(h)) { xColumn = h; break; }
712
+ }
713
+ }
714
+
715
+ // Pick first N non-X numeric columns for Y
716
+ const yColumns = [];
717
+ for (const h of headers) {
718
+ if (h === xColumn) continue;
719
+ if (!isNumeric(h)) continue;
720
+ if (yColumns.length >= MAX_AUTO_COLUMNS) break;
721
+ yColumns.push({ header: h, color: getSeriesColor(yColumns.length) });
722
+ }
723
+
724
+ return {
725
+ xColumn,
726
+ yColumns,
727
+ chartType: 'line',
728
+ showDataPoints: false,
729
+ interpolation: 'linear',
730
+ legendPosition: 'top',
731
+ };
732
+ }
733
+ }
734
+
735
+ // ── Singleton window management ──────────────────────────────────────────
736
+
737
+ const _openPlotWindows = new Map();
738
+
739
+ export async function openDataSeriesPlotWindow(options) {
740
+ const { seriesId } = options;
741
+ let win = _openPlotWindows.get(seriesId);
742
+ if (win?.window?.isVisible) {
743
+ win.window.bringToFront();
744
+ return win;
745
+ }
746
+
747
+ win = new DataSeriesPlotWindow(options);
748
+ _openPlotWindows.set(seriesId, win);
749
+ await win.show();
750
+ return win;
751
+ }
752
+
753
+ export function closeDataSeriesPlotWindow(seriesId) {
754
+ const win = _openPlotWindows.get(seriesId);
755
+ if (win) {
756
+ win.close();
757
+ _openPlotWindows.delete(seriesId);
758
+ }
759
+ }
760
+
761
+ export default DataSeriesPlotWindow;