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,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;
|