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,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tile_base.js
|
|
3
|
+
*
|
|
4
|
+
* Base class for all dashboard widget tiles.
|
|
5
|
+
* Provides common lifecycle management, tile chrome (header, config button),
|
|
6
|
+
* and abstract methods for subclass implementation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { openRawTracesWindow } from '../charts/plot_popout_window.js';
|
|
10
|
+
import { formatVariableLabel } from './config_schema.js';
|
|
11
|
+
// The host is INJECTED, not fetched from a global.
|
|
12
|
+
//
|
|
13
|
+
// This imported `appHost()` from ui/js/ecoagent/app_host.js — a tile reaching into
|
|
14
|
+
// the application to find out how to save a file. The framework is injection-only;
|
|
15
|
+
// the app may have a root host provider, but the library does not get to know about
|
|
16
|
+
// it. Its only use was to back "Expand" -> Export CSV/PNG, and a tile without a host
|
|
17
|
+
// falls back to a Blob download, which is exactly what the host contract promises.
|
|
18
|
+
|
|
19
|
+
export class TileBase {
|
|
20
|
+
/** @type {string} Widget type identifier - override in subclass */
|
|
21
|
+
static TYPE = 'base';
|
|
22
|
+
|
|
23
|
+
/** @type {string} Display title - override in subclass */
|
|
24
|
+
static TITLE = 'Widget';
|
|
25
|
+
|
|
26
|
+
/** @type {{w: number, h: number}} Default grid size - override in subclass */
|
|
27
|
+
static DEFAULT_SIZE = { w: 4, h: 3 };
|
|
28
|
+
|
|
29
|
+
/** @type {{minW: number, minH: number, maxW: number, maxH: number}} Size constraints */
|
|
30
|
+
static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 8 };
|
|
31
|
+
|
|
32
|
+
/** @type {boolean} Whether widget can be expanded to a separate window - override in subclass */
|
|
33
|
+
static EXPANDABLE = false;
|
|
34
|
+
|
|
35
|
+
/** @type {boolean} Whether widget supports scenario comparison overlay - override in subclass */
|
|
36
|
+
static SUPPORTS_COMPARISON = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {Object} options
|
|
40
|
+
* @param {string} options.id - Unique tile identifier
|
|
41
|
+
* @param {Object} options.grid - Parent TileGrid instance
|
|
42
|
+
* @param {Object} [options.eventBus] - Event bus for cross-component communication
|
|
43
|
+
* @param {Object} [options.config] - Widget-specific configuration
|
|
44
|
+
* @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
|
|
45
|
+
*/
|
|
46
|
+
constructor({ id, grid, eventBus = null, config = {}, readonly = false,
|
|
47
|
+
headless = false, host = null, stateGuard = null }) {
|
|
48
|
+
this.host = host;
|
|
49
|
+
// `window.__ECOSIM_JS_NEW__?.stateGuard` — an application's global, reached for
|
|
50
|
+
// by a tile. The StateGuard is a FRAMEWORK service (@flexdesk/core); the INSTANCE is
|
|
51
|
+
// the application's, and it is handed down through the grid. No guard => the
|
|
52
|
+
// dataset writes happen directly, which is what the optional chain already did.
|
|
53
|
+
this.stateGuard = stateGuard;
|
|
54
|
+
this.id = id;
|
|
55
|
+
this.grid = grid;
|
|
56
|
+
this.eventBus = eventBus;
|
|
57
|
+
this.config = { ...this.getDefaultConfig(), ...config };
|
|
58
|
+
this.readonly = readonly;
|
|
59
|
+
this.headless = headless;
|
|
60
|
+
this.element = null;
|
|
61
|
+
this.contentElement = null;
|
|
62
|
+
this.chartInstance = null;
|
|
63
|
+
this.data = null;
|
|
64
|
+
/** @type {Object|null} Unfiltered analytics data for cross-namespace variable resolution */
|
|
65
|
+
this.fullData = null;
|
|
66
|
+
this._disposed = false;
|
|
67
|
+
/** @type {Map<string, {name: string, analytics: Object, color: string}>|null} */
|
|
68
|
+
this.comparisonData = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set comparison data for overlay rendering.
|
|
73
|
+
* Only meaningful for widgets where SUPPORTS_COMPARISON = true.
|
|
74
|
+
* @param {Map<string, Object>|null} comparisonData
|
|
75
|
+
*/
|
|
76
|
+
setComparisonData(comparisonData) {
|
|
77
|
+
this.comparisonData = comparisonData;
|
|
78
|
+
if (this.data && this.contentElement) {
|
|
79
|
+
this.render(this.data);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format a raw analytics variable key into a user-friendly display label.
|
|
85
|
+
* Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
|
|
86
|
+
* @param {string} name - Raw variable key
|
|
87
|
+
* @returns {string} Friendly display label
|
|
88
|
+
*/
|
|
89
|
+
formatLabel(name) {
|
|
90
|
+
return formatVariableLabel(name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Look up the unit string for a variable from analytics metadata.
|
|
95
|
+
* Checks stocks, flows, and indicators for unit metadata.
|
|
96
|
+
* @param {string} varName - Variable name
|
|
97
|
+
* @returns {string|null} Unit string or null
|
|
98
|
+
*/
|
|
99
|
+
getVariableUnit(varName) {
|
|
100
|
+
if (!varName || !this.data) return null;
|
|
101
|
+
const sources = [this.data.stocks, this.data.flows, this.data.indicators];
|
|
102
|
+
for (const source of sources) {
|
|
103
|
+
const varData = source?.[varName];
|
|
104
|
+
if (varData?.unit) return varData.unit;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Look up a variable across stocks, flows, and indicators.
|
|
111
|
+
* If the variable is not found, returns the first available variable
|
|
112
|
+
* and updates the config key so the widget self-heals.
|
|
113
|
+
* @param {Object} data - Analytics data
|
|
114
|
+
* @param {string} configKey - Config property name (e.g. 'variable', 'xVariable')
|
|
115
|
+
* @returns {{varData: any, varName: string}|null} Resolved data or null if no data at all
|
|
116
|
+
*/
|
|
117
|
+
resolveVariable(data, configKey = 'variable') {
|
|
118
|
+
const varName = this.config[configKey];
|
|
119
|
+
if (varName) {
|
|
120
|
+
// Try exact name first
|
|
121
|
+
const varData = data.stocks?.[varName]
|
|
122
|
+
|| data.indicators?.[varName]
|
|
123
|
+
|| data.flows?.[varName];
|
|
124
|
+
if (varData) return { varData, varName };
|
|
125
|
+
|
|
126
|
+
// Try without namespace prefix (e.g. "Population.population" → "population")
|
|
127
|
+
const dotIdx = varName.indexOf('.');
|
|
128
|
+
if (dotIdx >= 0) {
|
|
129
|
+
const bare = varName.slice(dotIdx + 1);
|
|
130
|
+
const bareData = data.stocks?.[bare]
|
|
131
|
+
|| data.indicators?.[bare]
|
|
132
|
+
|| data.flows?.[bare];
|
|
133
|
+
if (bareData) return { varData: bareData, varName: bare };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Reverse lookup: bare name → namespace-prefixed key
|
|
137
|
+
// e.g. "population" matches "Population.population" in MC data
|
|
138
|
+
if (dotIdx < 0) {
|
|
139
|
+
const suffix = `.${varName}`;
|
|
140
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
141
|
+
if (!source) continue;
|
|
142
|
+
const match = Object.keys(source).find(k => k.endsWith(suffix));
|
|
143
|
+
if (match) return { varData: source[match], varName: match };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Try unfiltered data (variable may be in a different namespace)
|
|
148
|
+
if (this.fullData && this.fullData !== data) {
|
|
149
|
+
const fullVarData = this.fullData.stocks?.[varName]
|
|
150
|
+
|| this.fullData.indicators?.[varName]
|
|
151
|
+
|| this.fullData.flows?.[varName];
|
|
152
|
+
if (fullVarData) return { varData: fullVarData, varName };
|
|
153
|
+
|
|
154
|
+
// Also try bare name against unfiltered data
|
|
155
|
+
if (dotIdx >= 0) {
|
|
156
|
+
const bare = varName.slice(dotIdx + 1);
|
|
157
|
+
const fullBareData = this.fullData.stocks?.[bare]
|
|
158
|
+
|| this.fullData.indicators?.[bare]
|
|
159
|
+
|| this.fullData.flows?.[bare];
|
|
160
|
+
if (fullBareData) return { varData: fullBareData, varName: bare };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Reverse lookup against unfiltered data
|
|
164
|
+
if (dotIdx < 0) {
|
|
165
|
+
const suffix = `.${varName}`;
|
|
166
|
+
for (const source of [this.fullData.stocks, this.fullData.indicators, this.fullData.flows]) {
|
|
167
|
+
if (!source) continue;
|
|
168
|
+
const match = Object.keys(source).find(k => k.endsWith(suffix));
|
|
169
|
+
if (match) return { varData: source[match], varName: match };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Configured variable not found — auto-select the first available
|
|
176
|
+
const sources = [data.stocks, data.indicators, data.flows];
|
|
177
|
+
for (const source of sources) {
|
|
178
|
+
if (!source) continue;
|
|
179
|
+
const keys = Object.keys(source);
|
|
180
|
+
if (keys.length > 0) {
|
|
181
|
+
this.config[configKey] = keys[0];
|
|
182
|
+
return { varData: source[keys[0]], varName: keys[0] };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Find a variable by name in data, trying exact match then suffix match.
|
|
191
|
+
* Does NOT auto-fallback to first available variable.
|
|
192
|
+
* @param {Object} data - Analytics data with stocks/indicators/flows
|
|
193
|
+
* @param {string} varName - Variable name (bare or namespace-prefixed)
|
|
194
|
+
* @returns {{varData: any, varName: string}|null}
|
|
195
|
+
*/
|
|
196
|
+
static findVariable(data, varName) {
|
|
197
|
+
if (!data || !varName) return null;
|
|
198
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
199
|
+
if (!source) continue;
|
|
200
|
+
if (source[varName]) return { varData: source[varName], varName };
|
|
201
|
+
}
|
|
202
|
+
// Reverse lookup: bare "population" → "Population.population"
|
|
203
|
+
const dotIdx = varName.indexOf('.');
|
|
204
|
+
if (dotIdx < 0) {
|
|
205
|
+
const suffix = `.${varName}`;
|
|
206
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
207
|
+
if (!source) continue;
|
|
208
|
+
const match = Object.keys(source).find(k => k.endsWith(suffix));
|
|
209
|
+
if (match) return { varData: source[match], varName: match };
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
// Strip prefix: "Population.population" → "population"
|
|
213
|
+
const bare = varName.slice(dotIdx + 1);
|
|
214
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
215
|
+
if (!source) continue;
|
|
216
|
+
if (source[bare]) return { varData: source[bare], varName: bare };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get default configuration for this widget type.
|
|
224
|
+
* Override in subclass.
|
|
225
|
+
* @returns {Object} Default config object
|
|
226
|
+
*/
|
|
227
|
+
getDefaultConfig() {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get configuration schema for the config modal.
|
|
233
|
+
* Override in subclass.
|
|
234
|
+
* @returns {Object} Schema definition
|
|
235
|
+
*/
|
|
236
|
+
getConfigSchema() {
|
|
237
|
+
return { fields: [] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render widget content with data.
|
|
242
|
+
* Override in subclass.
|
|
243
|
+
* @param {Object} data - Analytics data
|
|
244
|
+
*/
|
|
245
|
+
render(data) {
|
|
246
|
+
throw new Error('TileBase.render() must be implemented by subclass');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Mount the tile into the container.
|
|
251
|
+
* @param {HTMLElement} container - Parent container
|
|
252
|
+
*/
|
|
253
|
+
mount(container) {
|
|
254
|
+
if (this._disposed) return;
|
|
255
|
+
|
|
256
|
+
this.element = document.createElement('div');
|
|
257
|
+
this.element.className = `tile${this.readonly ? ' tile--readonly' : ''}`;
|
|
258
|
+
|
|
259
|
+
// Use StateGuard bypass if available to set dataset properties
|
|
260
|
+
const stateGuard = this.stateGuard;
|
|
261
|
+
const setDataset = () => {
|
|
262
|
+
this.element.dataset.tileId = this.id;
|
|
263
|
+
this.element.dataset.tileType = this.constructor.TYPE;
|
|
264
|
+
};
|
|
265
|
+
if (stateGuard?.executeWithBypass) {
|
|
266
|
+
stateGuard.executeWithBypass('tile-mount', setDataset);
|
|
267
|
+
} else {
|
|
268
|
+
setDataset();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (this.headless) {
|
|
272
|
+
// Headless mode: content only, no header/controls (for embedding in notebook cells)
|
|
273
|
+
this.contentElement = document.createElement('div');
|
|
274
|
+
this.contentElement.className = 'tile-content';
|
|
275
|
+
this.element.appendChild(this.contentElement);
|
|
276
|
+
} else {
|
|
277
|
+
// Build tile chrome (header + content area)
|
|
278
|
+
this._buildChrome();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
container.appendChild(this.element);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Update the widget with new data and/or config.
|
|
286
|
+
* @param {Object} [data] - New analytics data
|
|
287
|
+
* @param {Object} [config] - New config values to merge
|
|
288
|
+
*/
|
|
289
|
+
update(data, config) {
|
|
290
|
+
if (this._disposed) return;
|
|
291
|
+
|
|
292
|
+
if (config) {
|
|
293
|
+
const prevDocs = (this.config?.docs || '').trim();
|
|
294
|
+
this.config = { ...this.config, ...config };
|
|
295
|
+
const nextDocs = (this.config?.docs || '').trim();
|
|
296
|
+
// Keep the header's info-button in sync with the docs state
|
|
297
|
+
if (this.element && !this.headless && prevDocs !== nextDocs) {
|
|
298
|
+
this._syncInfoButton();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (data !== undefined) {
|
|
303
|
+
this.data = data;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (this.data && this.contentElement) {
|
|
307
|
+
this.render(this.data);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
|
|
312
|
+
_syncInfoButton() {
|
|
313
|
+
const header = this.element?.querySelector('.tile-header');
|
|
314
|
+
const controls = header?.querySelector('.tile-controls');
|
|
315
|
+
if (!controls) return;
|
|
316
|
+
const docs = (this.config?.docs || '').trim();
|
|
317
|
+
let btn = controls.querySelector('.tile-info-btn');
|
|
318
|
+
if (docs) {
|
|
319
|
+
if (!btn) {
|
|
320
|
+
btn = document.createElement('button');
|
|
321
|
+
btn.className = 'tile-info-btn twm-has-tooltip';
|
|
322
|
+
btn.type = 'button';
|
|
323
|
+
btn.tabIndex = -1;
|
|
324
|
+
btn.style.cssText = 'background:transparent;border:none;padding:2px;color:rgba(255,255,255,0.55);cursor:help;display:inline-flex;align-items:center;justify-content:center;';
|
|
325
|
+
btn.setAttribute('data-tooltip-placement', 'bottom');
|
|
326
|
+
btn.setAttribute('data-tooltip-max-width', '420px');
|
|
327
|
+
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">info</span>';
|
|
328
|
+
btn.addEventListener('click', (e) => e.stopPropagation());
|
|
329
|
+
const anchor = controls.querySelector('.tile-expand-btn')
|
|
330
|
+
?? controls.querySelector('.tile-remove-btn');
|
|
331
|
+
if (anchor) controls.insertBefore(btn, anchor);
|
|
332
|
+
else controls.appendChild(btn);
|
|
333
|
+
}
|
|
334
|
+
btn.setAttribute('data-tooltip', docs);
|
|
335
|
+
} else if (btn) {
|
|
336
|
+
btn.remove();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Clean up resources and remove from DOM.
|
|
342
|
+
*/
|
|
343
|
+
dispose() {
|
|
344
|
+
if (this._disposed) return;
|
|
345
|
+
this._disposed = true;
|
|
346
|
+
|
|
347
|
+
// Close expand window if open
|
|
348
|
+
if (this._expandWindow?.isVisible) {
|
|
349
|
+
this._expandWindow.close();
|
|
350
|
+
}
|
|
351
|
+
this._expandWindow = null;
|
|
352
|
+
|
|
353
|
+
// Destroy chart instance if exists
|
|
354
|
+
if (this.chartInstance) {
|
|
355
|
+
this.chartInstance.destroy();
|
|
356
|
+
this.chartInstance = null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Remove from DOM
|
|
360
|
+
if (this.element && this.element.parentNode) {
|
|
361
|
+
this.element.parentNode.removeChild(this.element);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
this.element = null;
|
|
365
|
+
this.contentElement = null;
|
|
366
|
+
this.data = null;
|
|
367
|
+
this.fullData = null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Build the tile chrome (header, content area, resize handles).
|
|
372
|
+
* @private
|
|
373
|
+
*/
|
|
374
|
+
_buildChrome() {
|
|
375
|
+
// Header with drag handle and controls
|
|
376
|
+
const header = document.createElement('div');
|
|
377
|
+
header.className = 'tile-header';
|
|
378
|
+
|
|
379
|
+
// Build controls HTML - expand button only if widget is expandable
|
|
380
|
+
const expandBtnHtml = this.constructor.EXPANDABLE
|
|
381
|
+
? `<button class="tile-expand-btn twm-has-tooltip" data-tooltip="Open in window">
|
|
382
|
+
<span class="material-symbols-outlined">open_in_new</span>
|
|
383
|
+
</button>`
|
|
384
|
+
: '';
|
|
385
|
+
|
|
386
|
+
// Info button — only shown if config.docs has text. Uses the
|
|
387
|
+
// standard has-tooltip system so the docs appear as a hover tooltip
|
|
388
|
+
// (auto-positioned) instead of a click-to-open popover.
|
|
389
|
+
const docs = (this.config?.docs || '').trim();
|
|
390
|
+
const infoBtnHtml = docs
|
|
391
|
+
? `<button class="tile-info-btn twm-has-tooltip" type="button" tabindex="-1"
|
|
392
|
+
data-tooltip="${this.#escapeAttr(docs)}"
|
|
393
|
+
data-tooltip-placement="bottom"
|
|
394
|
+
data-tooltip-max-width="420px"
|
|
395
|
+
style="background:transparent;border:none;padding:2px;color:rgba(255,255,255,0.55);cursor:help;display:inline-flex;align-items:center;justify-content:center;">
|
|
396
|
+
<span class="material-symbols-outlined" style="font-size:18px;">info</span>
|
|
397
|
+
</button>`
|
|
398
|
+
: '';
|
|
399
|
+
|
|
400
|
+
if (this.readonly) {
|
|
401
|
+
// Read-only mode: no drag handle, no menu, no remove button
|
|
402
|
+
header.innerHTML = `
|
|
403
|
+
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
404
|
+
<div class="tile-controls">
|
|
405
|
+
${infoBtnHtml}
|
|
406
|
+
${expandBtnHtml}
|
|
407
|
+
</div>
|
|
408
|
+
`;
|
|
409
|
+
} else {
|
|
410
|
+
header.innerHTML = `
|
|
411
|
+
<span class="tile-drag-handle twm-has-tooltip" data-tooltip="Drag to reposition">
|
|
412
|
+
<span class="material-symbols-outlined">drag_indicator</span>
|
|
413
|
+
</span>
|
|
414
|
+
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
415
|
+
<div class="tile-controls">
|
|
416
|
+
<button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
|
|
417
|
+
<span class="material-symbols-outlined">settings</span>
|
|
418
|
+
</button>
|
|
419
|
+
<div class="tile-menu-wrapper">
|
|
420
|
+
<button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
|
|
421
|
+
<span class="material-symbols-outlined">more_vert</span>
|
|
422
|
+
</button>
|
|
423
|
+
<div class="tile-menu-dropdown" hidden>
|
|
424
|
+
<button class="tile-menu-item" data-action="add-to-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? 'display:none' : ''}">
|
|
425
|
+
<span class="material-symbols-outlined">post_add</span>
|
|
426
|
+
<span>Add to Documentation</span>
|
|
427
|
+
</button>
|
|
428
|
+
<button class="tile-menu-item" data-action="show-in-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? '' : 'display:none'}">
|
|
429
|
+
<span class="material-symbols-outlined">description</span>
|
|
430
|
+
<span>Show in Documentation</span>
|
|
431
|
+
</button>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
${infoBtnHtml}
|
|
435
|
+
${expandBtnHtml}
|
|
436
|
+
<button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
|
|
437
|
+
<span class="material-symbols-outlined">close</span>
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Expand button handler
|
|
444
|
+
const expandBtn = header.querySelector('.tile-expand-btn');
|
|
445
|
+
expandBtn?.addEventListener('click', (e) => {
|
|
446
|
+
e.stopPropagation();
|
|
447
|
+
this._onExpandClick();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Info button is purely decorative — the docs are shown by the global
|
|
451
|
+
// tooltip service via data-tooltip. Stop click-propagation so clicks
|
|
452
|
+
// don't open the config panel, and make the button unfocusable.
|
|
453
|
+
const infoBtn = header.querySelector('.tile-info-btn');
|
|
454
|
+
infoBtn?.addEventListener('click', (e) => e.stopPropagation());
|
|
455
|
+
|
|
456
|
+
if (!this.readonly) {
|
|
457
|
+
// Config opens ONLY via the explicit cogwheel button — not a
|
|
458
|
+
// title or content click, which fired the config panel too
|
|
459
|
+
// eagerly (e.g. when you just wanted to read or pan the chart).
|
|
460
|
+
const configBtn = header.querySelector('.tile-config-btn');
|
|
461
|
+
configBtn?.addEventListener('click', (e) => {
|
|
462
|
+
e.stopPropagation();
|
|
463
|
+
this._onConfigClick();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// "..." menu button + dropdown
|
|
467
|
+
const menuBtn = header.querySelector('.tile-menu-btn');
|
|
468
|
+
const menuDropdown = header.querySelector('.tile-menu-dropdown');
|
|
469
|
+
if (menuBtn && menuDropdown) {
|
|
470
|
+
menuBtn.addEventListener('click', (e) => {
|
|
471
|
+
e.stopPropagation();
|
|
472
|
+
const isOpen = !menuDropdown.hidden;
|
|
473
|
+
menuDropdown.hidden = !menuDropdown.hidden;
|
|
474
|
+
if (!isOpen) {
|
|
475
|
+
// Close on outside click
|
|
476
|
+
const closeMenu = (ev) => {
|
|
477
|
+
if (!menuDropdown.contains(ev.target) && ev.target !== menuBtn) {
|
|
478
|
+
menuDropdown.hidden = true;
|
|
479
|
+
document.removeEventListener('pointerdown', closeMenu, true);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
// Delay to avoid immediate close from the same click
|
|
483
|
+
requestAnimationFrame(() => {
|
|
484
|
+
document.addEventListener('pointerdown', closeMenu, true);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
menuDropdown.querySelector('[data-action="add-to-documentation"]')?.addEventListener('click', (e) => {
|
|
489
|
+
e.stopPropagation();
|
|
490
|
+
menuDropdown.hidden = true;
|
|
491
|
+
this.eventBus?.emit?.('tile:add-to-documentation', {
|
|
492
|
+
tileType: this.constructor.TYPE,
|
|
493
|
+
config: { ...this.config },
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
menuDropdown.querySelector('[data-action="show-in-documentation"]')?.addEventListener('click', (e) => {
|
|
497
|
+
e.stopPropagation();
|
|
498
|
+
menuDropdown.hidden = true;
|
|
499
|
+
if (this.config?.sourceCellId) {
|
|
500
|
+
this.eventBus?.emit?.('tile:show-in-documentation', {
|
|
501
|
+
sourceCellId: this.config.sourceCellId,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const removeBtn = header.querySelector('.tile-remove-btn');
|
|
508
|
+
removeBtn?.addEventListener('click', (e) => {
|
|
509
|
+
e.stopPropagation();
|
|
510
|
+
this._onRemoveClick();
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Content area. Clicking the content no longer opens config — that
|
|
515
|
+
// moved to the explicit cogwheel button in the header so chart
|
|
516
|
+
// interaction (pan/zoom/hover) doesn't trip the config panel.
|
|
517
|
+
this.contentElement = document.createElement('div');
|
|
518
|
+
this.contentElement.className = 'tile-content';
|
|
519
|
+
|
|
520
|
+
this.element.appendChild(header);
|
|
521
|
+
this.element.appendChild(this.contentElement);
|
|
522
|
+
|
|
523
|
+
if (!this.readonly) {
|
|
524
|
+
// Resize handle (bottom-right)
|
|
525
|
+
const resizeHandle = document.createElement('div');
|
|
526
|
+
resizeHandle.className = 'tile-resize-handle se';
|
|
527
|
+
resizeHandle.innerHTML = '<span class="material-symbols-outlined">drag_handle</span>';
|
|
528
|
+
this.element.appendChild(resizeHandle);
|
|
529
|
+
|
|
530
|
+
// Context menu for "Add to Documentation" etc.
|
|
531
|
+
this.element.addEventListener('contextmenu', (e) => {
|
|
532
|
+
e.preventDefault();
|
|
533
|
+
e.stopPropagation();
|
|
534
|
+
this.eventBus?.emit?.('tile:context-menu', {
|
|
535
|
+
tileId: this.id,
|
|
536
|
+
tileType: this.constructor.TYPE,
|
|
537
|
+
tileTitle: this.constructor.TITLE,
|
|
538
|
+
config: { ...this.config },
|
|
539
|
+
clientX: e.clientX,
|
|
540
|
+
clientY: e.clientY,
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Handle config button click — highlight tile and emit config request.
|
|
548
|
+
* @private
|
|
549
|
+
*/
|
|
550
|
+
_onConfigClick() {
|
|
551
|
+
// Deselect all sibling tiles, then select this one
|
|
552
|
+
this.element?.closest('.tile-grid')
|
|
553
|
+
?.querySelectorAll('.tile.tile--selected')
|
|
554
|
+
.forEach(el => el.classList.remove('tile--selected'));
|
|
555
|
+
this.element?.classList.add('tile--selected');
|
|
556
|
+
|
|
557
|
+
// Emit event for grid/dashboard to show config panel
|
|
558
|
+
this.eventBus?.emit?.('tile:config-request', {
|
|
559
|
+
tileId: this.id,
|
|
560
|
+
tileType: this.constructor.TYPE,
|
|
561
|
+
config: this.config,
|
|
562
|
+
schema: this.getConfigSchema()
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Also dispatch DOM event for alternative handling
|
|
566
|
+
this.element?.dispatchEvent(new CustomEvent('tile:config-request', {
|
|
567
|
+
bubbles: true,
|
|
568
|
+
detail: {
|
|
569
|
+
tileId: this.id,
|
|
570
|
+
tileType: this.constructor.TYPE,
|
|
571
|
+
config: this.config,
|
|
572
|
+
schema: this.getConfigSchema()
|
|
573
|
+
}
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Handle remove button click.
|
|
579
|
+
* @param {Event} [event] - Original click event
|
|
580
|
+
* @private
|
|
581
|
+
*/
|
|
582
|
+
_onRemoveClick(event) {
|
|
583
|
+
const removeBtn = this.element?.querySelector('.tile-remove-btn');
|
|
584
|
+
|
|
585
|
+
// Only dispatch DOM event - let the dashboard handle confirmation
|
|
586
|
+
this.element?.dispatchEvent(new CustomEvent('tile:remove-request', {
|
|
587
|
+
bubbles: true,
|
|
588
|
+
detail: {
|
|
589
|
+
tileId: this.id,
|
|
590
|
+
anchorElement: removeBtn
|
|
591
|
+
}
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Set the title displayed in the header.
|
|
597
|
+
* @param {string} title - New title text
|
|
598
|
+
*/
|
|
599
|
+
setTitle(title) {
|
|
600
|
+
const titleEl = this.element?.querySelector('.tile-title');
|
|
601
|
+
if (titleEl) {
|
|
602
|
+
titleEl.textContent = title;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Show loading state in content area.
|
|
608
|
+
*/
|
|
609
|
+
showLoading() {
|
|
610
|
+
if (this.contentElement) {
|
|
611
|
+
this.contentElement.innerHTML = `
|
|
612
|
+
<div class="tile-loading">
|
|
613
|
+
<span class="material-symbols-outlined spinning">progress_activity</span>
|
|
614
|
+
<span>Loading...</span>
|
|
615
|
+
</div>
|
|
616
|
+
`;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Show empty state in content area.
|
|
622
|
+
* @param {string} [message] - Custom message
|
|
623
|
+
*/
|
|
624
|
+
showEmpty(message = 'No data available') {
|
|
625
|
+
if (this.contentElement) {
|
|
626
|
+
this.contentElement.innerHTML = `
|
|
627
|
+
<div class="tile-empty">
|
|
628
|
+
<span class="material-symbols-outlined">inbox</span>
|
|
629
|
+
<span>${message}</span>
|
|
630
|
+
</div>
|
|
631
|
+
`;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Show error state in content area.
|
|
637
|
+
* @param {string} [message] - Error message
|
|
638
|
+
*/
|
|
639
|
+
showError(message = 'Failed to load data') {
|
|
640
|
+
if (this.contentElement) {
|
|
641
|
+
this.contentElement.innerHTML = `
|
|
642
|
+
<div class="twm-tile-error">
|
|
643
|
+
<span class="material-symbols-outlined">error</span>
|
|
644
|
+
<span>${message}</span>
|
|
645
|
+
</div>
|
|
646
|
+
`;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Format a number for display.
|
|
652
|
+
* @param {number} value - Number to format
|
|
653
|
+
* @param {number} [decimals=2] - Decimal places
|
|
654
|
+
* @returns {string} Formatted string
|
|
655
|
+
*/
|
|
656
|
+
formatNumber(value, decimals = 2) {
|
|
657
|
+
if (value == null || !Number.isFinite(value)) return '—';
|
|
658
|
+
|
|
659
|
+
const abs = Math.abs(value);
|
|
660
|
+
if (abs >= 1e9) {
|
|
661
|
+
return (value / 1e9).toFixed(1) + 'B';
|
|
662
|
+
} else if (abs >= 1e6) {
|
|
663
|
+
return (value / 1e6).toFixed(1) + 'M';
|
|
664
|
+
} else if (abs >= 1e3) {
|
|
665
|
+
return (value / 1e3).toFixed(1) + 'K';
|
|
666
|
+
} else if (abs < 0.01 && abs > 0) {
|
|
667
|
+
return value.toExponential(decimals);
|
|
668
|
+
}
|
|
669
|
+
return value.toFixed(decimals);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Get available variables from data for config dropdowns.
|
|
674
|
+
* @param {Object} data - Analytics data
|
|
675
|
+
* @returns {Array<{value: string, label: string}>} Variable options
|
|
676
|
+
*/
|
|
677
|
+
getAvailableVariables(data) {
|
|
678
|
+
if (!data) return [];
|
|
679
|
+
|
|
680
|
+
const variables = [];
|
|
681
|
+
|
|
682
|
+
// Add stocks
|
|
683
|
+
if (data.stocks) {
|
|
684
|
+
Object.keys(data.stocks).forEach(name => {
|
|
685
|
+
variables.push({ value: name, label: name, category: 'Stocks' });
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Add flows
|
|
690
|
+
if (data.flows) {
|
|
691
|
+
Object.keys(data.flows).forEach(name => {
|
|
692
|
+
variables.push({ value: name, label: name, category: 'Flows' });
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Add indicators
|
|
697
|
+
if (data.indicators) {
|
|
698
|
+
Object.keys(data.indicators).forEach(name => {
|
|
699
|
+
variables.push({ value: name, label: name, category: 'Indicators' });
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return variables;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// =========================================================================
|
|
707
|
+
// EXPAND WINDOW FUNCTIONALITY
|
|
708
|
+
// Reuses PlotPopoutWindow's patterns and CSS classes
|
|
709
|
+
// =========================================================================
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Handle expand button click.
|
|
713
|
+
* @private
|
|
714
|
+
*/
|
|
715
|
+
_onExpandClick() {
|
|
716
|
+
if (!this.constructor.EXPANDABLE) return;
|
|
717
|
+
|
|
718
|
+
const expandData = this.getExpandData();
|
|
719
|
+
if (!expandData) {
|
|
720
|
+
console.warn(`[TileBase] No expand data from widget ${this.id}`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
this._openExpandWindow(expandData);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Get data for the expanded window.
|
|
729
|
+
* Override in subclass for expandable widgets.
|
|
730
|
+
* @returns {Object|null} { title, traces, layout, tableHeaders, tableRows }
|
|
731
|
+
*/
|
|
732
|
+
getExpandData() {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Open the expand window with plot and data tabs.
|
|
738
|
+
* Delegates to openRawTracesWindow (PlotPopoutWindow module) which
|
|
739
|
+
* provides proper toolbar layout, download buttons, and data table.
|
|
740
|
+
* @param {Object} expandData - Data from getExpandData()
|
|
741
|
+
* @private
|
|
742
|
+
*/
|
|
743
|
+
async _openExpandWindow(expandData) {
|
|
744
|
+
if (this._expandWindow?.isVisible) {
|
|
745
|
+
this._expandWindow.bringToFront();
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
this._expandWindow = await openRawTracesWindow({
|
|
750
|
+
host: this.host,
|
|
751
|
+
id: `tile-expand-${this.id}`,
|
|
752
|
+
title: expandData.title || this.constructor.TITLE,
|
|
753
|
+
traces: expandData.traces,
|
|
754
|
+
layout: expandData.layout,
|
|
755
|
+
frames: expandData.frames,
|
|
756
|
+
tableHeaders: expandData.tableHeaders,
|
|
757
|
+
tableRows: expandData.tableRows,
|
|
758
|
+
services: { eventBus: this.eventBus },
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Escape a string for safe insertion into an HTML attribute.
|
|
764
|
+
* @private
|
|
765
|
+
*/
|
|
766
|
+
#escapeAttr(s) {
|
|
767
|
+
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
|
768
|
+
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
769
|
+
}[c]));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Show toast notification via eventBus.
|
|
774
|
+
* @private
|
|
775
|
+
*/
|
|
776
|
+
_notify(title, message, severity = 'info') {
|
|
777
|
+
this.eventBus?.emit?.('toast:show', {
|
|
778
|
+
title,
|
|
779
|
+
message,
|
|
780
|
+
type: severity,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export default TileBase;
|