flexdesk 0.4.6 → 0.4.7
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/README.md +301 -276
- package/dist/charts.js +782 -782
- package/dist/{chunk-HWCX5I4R.js → chunk-F5FYDCOA.js} +2373 -2373
- package/dist/{chunk-HWCX5I4R.js.map → chunk-F5FYDCOA.js.map} +7 -7
- package/dist/{chunk-CYTWMP2Y.js → chunk-WMGOJ3I6.js} +2628 -2622
- package/dist/chunk-WMGOJ3I6.js.map +7 -0
- package/dist/tiles.js +2195 -2195
- package/dist/widgets.js +5796 -5796
- package/dist/wm.js +8074 -8015
- package/dist/wm.js.map +7 -7
- package/package.json +81 -81
- package/src/tiling/tile_breadcrumb.js +21 -0
- package/src/tiling/tile_renderer.js +11 -0
- package/src/tiling/wm.js +3916 -3872
- package/src/ui/components/data_table.js +3294 -3281
- package/dist/chunk-CYTWMP2Y.js.map +0 -7
package/dist/tiles.js
CHANGED
|
@@ -1,2195 +1,2195 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ActionDropdown
|
|
3
|
-
} from "./chunk-O5OHMWBB.js";
|
|
4
|
-
import {
|
|
5
|
-
openRawTracesWindow
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
8
|
-
import "./chunk-LH5TSOZW.js";
|
|
9
|
-
import "./chunk-FL5KFNQH.js";
|
|
10
|
-
import {
|
|
11
|
-
TileRegistry,
|
|
12
|
-
createWidget,
|
|
13
|
-
getAllWidgetTypes,
|
|
14
|
-
getWidget,
|
|
15
|
-
getWidgetCatalog,
|
|
16
|
-
getWidgetMetadata,
|
|
17
|
-
hasWidget,
|
|
18
|
-
init_tile_registry,
|
|
19
|
-
registerWidget,
|
|
20
|
-
setTileLogger,
|
|
21
|
-
tile_registry_exports
|
|
22
|
-
} from "./chunk-FOOS3T5L.js";
|
|
23
|
-
import {
|
|
24
|
-
__toCommonJS
|
|
25
|
-
} from "./chunk-JYWURG5T.js";
|
|
26
|
-
|
|
27
|
-
// src/tiles/config_schema.js
|
|
28
|
-
var FieldTypes = {
|
|
29
|
-
TEXT: "text",
|
|
30
|
-
NUMBER: "number",
|
|
31
|
-
CHECKBOX: "checkbox",
|
|
32
|
-
SELECT: "select",
|
|
33
|
-
MULTISELECT: "multiselect",
|
|
34
|
-
COLOR: "color",
|
|
35
|
-
RANGE: "range"
|
|
36
|
-
};
|
|
37
|
-
function validateField(value, field) {
|
|
38
|
-
if (field.required && (value == null || value === "")) {
|
|
39
|
-
return { valid: false, error: `${field.label} is required` };
|
|
40
|
-
}
|
|
41
|
-
switch (field.type) {
|
|
42
|
-
case FieldTypes.NUMBER:
|
|
43
|
-
case FieldTypes.RANGE:
|
|
44
|
-
if (value != null && value !== "") {
|
|
45
|
-
const num = parseFloat(value);
|
|
46
|
-
if (isNaN(num)) {
|
|
47
|
-
return { valid: false, error: `${field.label} must be a number` };
|
|
48
|
-
}
|
|
49
|
-
if (field.min != null && num < field.min) {
|
|
50
|
-
return { valid: false, error: `${field.label} must be at least ${field.min}` };
|
|
51
|
-
}
|
|
52
|
-
if (field.max != null && num > field.max) {
|
|
53
|
-
return { valid: false, error: `${field.label} must be at most ${field.max}` };
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
break;
|
|
57
|
-
case FieldTypes.SELECT:
|
|
58
|
-
if (field.required && !value) {
|
|
59
|
-
return { valid: false, error: `${field.label} must be selected` };
|
|
60
|
-
}
|
|
61
|
-
break;
|
|
62
|
-
case FieldTypes.MULTISELECT:
|
|
63
|
-
if (field.required && (!Array.isArray(value) || value.length === 0)) {
|
|
64
|
-
return { valid: false, error: `${field.label} must have at least one selection` };
|
|
65
|
-
}
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
return { valid: true };
|
|
69
|
-
}
|
|
70
|
-
function validateConfig(config, schema) {
|
|
71
|
-
const errors = {};
|
|
72
|
-
let valid = true;
|
|
73
|
-
if (!schema?.fields) {
|
|
74
|
-
return { valid: true, errors: {} };
|
|
75
|
-
}
|
|
76
|
-
schema.fields.forEach((field) => {
|
|
77
|
-
const result = validateField(config[field.key], field);
|
|
78
|
-
if (!result.valid) {
|
|
79
|
-
errors[field.key] = result.error;
|
|
80
|
-
valid = false;
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
return { valid, errors };
|
|
84
|
-
}
|
|
85
|
-
function mergeWithDefaults(config, schema) {
|
|
86
|
-
const result = { ...config };
|
|
87
|
-
if (schema?.fields) {
|
|
88
|
-
schema.fields.forEach((field) => {
|
|
89
|
-
if (result[field.key] === void 0 && field.default !== void 0) {
|
|
90
|
-
result[field.key] = field.default;
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
return result;
|
|
95
|
-
}
|
|
96
|
-
function getFieldOptions(field, data) {
|
|
97
|
-
if (Array.isArray(field.options)) {
|
|
98
|
-
return field.options;
|
|
99
|
-
}
|
|
100
|
-
if (field.options === "variables") {
|
|
101
|
-
return getVariableOptions(data);
|
|
102
|
-
}
|
|
103
|
-
if (field.options === "namespaces") {
|
|
104
|
-
return getNamespaceOptions(data);
|
|
105
|
-
}
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
108
|
-
function formatVariableLabel(name) {
|
|
109
|
-
if (!name) return "";
|
|
110
|
-
let label = name;
|
|
111
|
-
const dotIdx = label.indexOf(".");
|
|
112
|
-
if (dotIdx > 0) {
|
|
113
|
-
label = label.slice(dotIdx + 1);
|
|
114
|
-
}
|
|
115
|
-
label = label.replace(/^__\w+__::/, "");
|
|
116
|
-
label = label.replace(/\[State\]$/i, "");
|
|
117
|
-
label = label.replace(/\[Rate\]$/i, "");
|
|
118
|
-
label = label.replace(/\[\]$/, "");
|
|
119
|
-
label = label.replace(/::/g, " / ");
|
|
120
|
-
label = label.replace(/:/g, "_");
|
|
121
|
-
return label.replace(/\s{2,}/g, " ").trim();
|
|
122
|
-
}
|
|
123
|
-
function getVariableOptions(data) {
|
|
124
|
-
const options = [];
|
|
125
|
-
if (!data) return options;
|
|
126
|
-
if (data.stocks) {
|
|
127
|
-
Object.keys(data.stocks).forEach((name) => {
|
|
128
|
-
options.push({
|
|
129
|
-
value: name,
|
|
130
|
-
label: formatVariableLabel(name),
|
|
131
|
-
category: "Stocks"
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
if (data.flows) {
|
|
136
|
-
Object.keys(data.flows).forEach((name) => {
|
|
137
|
-
options.push({
|
|
138
|
-
value: name,
|
|
139
|
-
label: formatVariableLabel(name),
|
|
140
|
-
category: "Flows"
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
if (data.indicators) {
|
|
145
|
-
Object.keys(data.indicators).forEach((name) => {
|
|
146
|
-
options.push({
|
|
147
|
-
value: name,
|
|
148
|
-
label: formatVariableLabel(name),
|
|
149
|
-
category: "Indicators"
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
return options;
|
|
154
|
-
}
|
|
155
|
-
function getNamespaceOptions(data) {
|
|
156
|
-
const namespaces = /* @__PURE__ */ new Set();
|
|
157
|
-
const sources = [data?.stocks, data?.flows, data?.indicators];
|
|
158
|
-
for (const source of sources) {
|
|
159
|
-
if (!source) continue;
|
|
160
|
-
for (const key of Object.keys(source)) {
|
|
161
|
-
const dotIdx = key.indexOf(".");
|
|
162
|
-
if (dotIdx > 0) {
|
|
163
|
-
namespaces.add(key.slice(0, dotIdx));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (namespaces.size === 0) namespaces.add("Main");
|
|
168
|
-
return Array.from(namespaces).sort().map((ns) => ({ value: ns, label: ns }));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// src/tiles/layout_persistence.js
|
|
172
|
-
var LAYOUT_VERSION = 3;
|
|
173
|
-
var DEFAULT_LAYOUTS_KEY = "twm.tiles.layouts";
|
|
174
|
-
var DEFAULT_CONFIGS_KEY = "twm.tiles.configs";
|
|
175
|
-
var LayoutPersistence = class {
|
|
176
|
-
constructor({
|
|
177
|
-
storage = typeof localStorage !== "undefined" ? localStorage : null,
|
|
178
|
-
layoutsKey = DEFAULT_LAYOUTS_KEY,
|
|
179
|
-
configsKey = DEFAULT_CONFIGS_KEY,
|
|
180
|
-
resolveTemplate = null,
|
|
181
|
-
logger = null
|
|
182
|
-
} = {}) {
|
|
183
|
-
this._cache = null;
|
|
184
|
-
this._storage = storage;
|
|
185
|
-
this._logger = logger;
|
|
186
|
-
this._layoutsKey = layoutsKey;
|
|
187
|
-
this._configsKey = configsKey;
|
|
188
|
-
this._resolveTemplate = typeof resolveTemplate === "function" ? resolveTemplate : null;
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Get layout for a specific context.
|
|
192
|
-
* Falls back to template resolution when no saved layout exists.
|
|
193
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
194
|
-
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
195
|
-
* @returns {Object} Layout object with tiles array
|
|
196
|
-
*/
|
|
197
|
-
getLayout(contextId, context) {
|
|
198
|
-
const all = this._loadAll();
|
|
199
|
-
const layout = all[contextId];
|
|
200
|
-
if (layout && layout.version === LAYOUT_VERSION) {
|
|
201
|
-
return layout;
|
|
202
|
-
}
|
|
203
|
-
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
204
|
-
if (instantiated) {
|
|
205
|
-
return {
|
|
206
|
-
tiles: instantiated.tiles,
|
|
207
|
-
version: LAYOUT_VERSION,
|
|
208
|
-
templateId: instantiated.templateId,
|
|
209
|
-
templateModified: false
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
return { tiles: [], version: LAYOUT_VERSION };
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Save layout for a specific context.
|
|
216
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
217
|
-
* @param {Array|Object} layout - Layout array or object with tiles property
|
|
218
|
-
*/
|
|
219
|
-
saveLayout(contextId, layout) {
|
|
220
|
-
const all = this._loadAll();
|
|
221
|
-
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
222
|
-
all[contextId] = {
|
|
223
|
-
tiles: tilesArray,
|
|
224
|
-
version: LAYOUT_VERSION,
|
|
225
|
-
updatedAt: Date.now(),
|
|
226
|
-
templateId: layout?.templateId || all[contextId]?.templateId || null,
|
|
227
|
-
templateModified: layout?.templateModified ?? true
|
|
228
|
-
};
|
|
229
|
-
this._saveAll(all);
|
|
230
|
-
this._logger?.info?.(`[LayoutPersistence] Saved layout for context ${contextId}`);
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Delete layout for a specific context.
|
|
234
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
235
|
-
*/
|
|
236
|
-
deleteLayout(contextId) {
|
|
237
|
-
const all = this._loadAll();
|
|
238
|
-
if (all[contextId]) {
|
|
239
|
-
delete all[contextId];
|
|
240
|
-
this._saveAll(all);
|
|
241
|
-
this._logger?.info?.(`[LayoutPersistence] Deleted layout for context ${contextId}`);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Reset a layout by re-resolving the best-matching template.
|
|
246
|
-
*
|
|
247
|
-
* With no `resolveTemplate` supplied, "reset" means "empty grid" — which is the
|
|
248
|
-
* honest answer for a library that has no templates. The previous code called
|
|
249
|
-
* `TemplateRegistry.instantiate()` unconditionally and read `.tiles` off the
|
|
250
|
-
* result, so a null resolver here would have been a TypeError.
|
|
251
|
-
*
|
|
252
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
253
|
-
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
254
|
-
* @returns {Object} The resolved layout (empty if nothing resolves).
|
|
255
|
-
*/
|
|
256
|
-
resetLayout(contextId, context) {
|
|
257
|
-
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
258
|
-
const layout = {
|
|
259
|
-
tiles: instantiated?.tiles ?? [],
|
|
260
|
-
version: LAYOUT_VERSION,
|
|
261
|
-
templateId: instantiated?.templateId ?? null,
|
|
262
|
-
templateModified: false
|
|
263
|
-
};
|
|
264
|
-
this.saveLayout(contextId, layout);
|
|
265
|
-
return layout;
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Check if a context has a saved layout.
|
|
269
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
270
|
-
* @returns {boolean}
|
|
271
|
-
*/
|
|
272
|
-
hasLayout(contextId) {
|
|
273
|
-
const all = this._loadAll();
|
|
274
|
-
return !!all[contextId];
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Get all context IDs with saved layouts.
|
|
278
|
-
* @returns {Array<string>}
|
|
279
|
-
*/
|
|
280
|
-
getAllContextIds() {
|
|
281
|
-
const all = this._loadAll();
|
|
282
|
-
return Object.keys(all);
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Export all layouts as JSON string.
|
|
286
|
-
* @returns {string}
|
|
287
|
-
*/
|
|
288
|
-
exportAll() {
|
|
289
|
-
const all = this._loadAll();
|
|
290
|
-
return JSON.stringify(all, null, 2);
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Import layouts from JSON string.
|
|
294
|
-
* @param {string} json - JSON string of layouts
|
|
295
|
-
* @param {boolean} [merge=false] - Whether to merge with existing or replace
|
|
296
|
-
*/
|
|
297
|
-
importAll(json, merge = false) {
|
|
298
|
-
try {
|
|
299
|
-
const imported = JSON.parse(json);
|
|
300
|
-
if (merge) {
|
|
301
|
-
const existing = this._loadAll();
|
|
302
|
-
this._saveAll({ ...existing, ...imported });
|
|
303
|
-
} else {
|
|
304
|
-
this._saveAll(imported);
|
|
305
|
-
}
|
|
306
|
-
this._cache = null;
|
|
307
|
-
this._logger?.info?.("[LayoutPersistence] Imported layouts");
|
|
308
|
-
} catch (err) {
|
|
309
|
-
console.error("[LayoutPersistence] Failed to import layouts:", err);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Clear all saved layouts.
|
|
314
|
-
*/
|
|
315
|
-
clearAll() {
|
|
316
|
-
try {
|
|
317
|
-
this._storage?.removeItem(this._layoutsKey);
|
|
318
|
-
this._cache = null;
|
|
319
|
-
this._logger?.info?.("[LayoutPersistence] Cleared all layouts");
|
|
320
|
-
} catch (err) {
|
|
321
|
-
console.error("[LayoutPersistence] Failed to clear layouts:", err);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Load all layouts from storage.
|
|
326
|
-
* @returns {Object}
|
|
327
|
-
* @private
|
|
328
|
-
*/
|
|
329
|
-
_loadAll() {
|
|
330
|
-
if (this._cache) {
|
|
331
|
-
return this._cache;
|
|
332
|
-
}
|
|
333
|
-
try {
|
|
334
|
-
const raw = this._storage?.getItem(this._layoutsKey);
|
|
335
|
-
if (raw) {
|
|
336
|
-
this._cache = JSON.parse(raw);
|
|
337
|
-
return this._cache;
|
|
338
|
-
}
|
|
339
|
-
} catch (err) {
|
|
340
|
-
console.error("[LayoutPersistence] Failed to load layouts:", err);
|
|
341
|
-
}
|
|
342
|
-
this._cache = {};
|
|
343
|
-
return this._cache;
|
|
344
|
-
}
|
|
345
|
-
/**
|
|
346
|
-
* Save all layouts to storage.
|
|
347
|
-
* @param {Object} data
|
|
348
|
-
* @private
|
|
349
|
-
*/
|
|
350
|
-
_saveAll(data) {
|
|
351
|
-
try {
|
|
352
|
-
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
353
|
-
this._cache = data;
|
|
354
|
-
} catch (err) {
|
|
355
|
-
console.error("[LayoutPersistence] Failed to save layouts:", err);
|
|
356
|
-
if (err.name === "QuotaExceededError") {
|
|
357
|
-
this._pruneOldLayouts(data);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
/**
|
|
362
|
-
* Remove oldest layouts to free up space.
|
|
363
|
-
* @param {Object} data
|
|
364
|
-
* @private
|
|
365
|
-
*/
|
|
366
|
-
_pruneOldLayouts(data) {
|
|
367
|
-
const entries = Object.entries(data);
|
|
368
|
-
entries.sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0));
|
|
369
|
-
const removeCount = Math.ceil(entries.length * 0.25);
|
|
370
|
-
for (let i = 0; i < removeCount; i++) {
|
|
371
|
-
delete data[entries[i][0]];
|
|
372
|
-
}
|
|
373
|
-
try {
|
|
374
|
-
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
375
|
-
this._cache = data;
|
|
376
|
-
this._logger?.warn?.("[LayoutPersistence] Pruned old layouts due to quota");
|
|
377
|
-
} catch (err) {
|
|
378
|
-
console.error("[LayoutPersistence] Still cannot save after pruning:", err);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* Get the template ID associated with a saved layout.
|
|
383
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
384
|
-
* @returns {string|null} Template ID or null
|
|
385
|
-
*/
|
|
386
|
-
getTemplateId(contextId) {
|
|
387
|
-
const all = this._loadAll();
|
|
388
|
-
return all[contextId]?.templateId || null;
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Check if the user has modified the layout since the template was applied.
|
|
392
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
393
|
-
* @returns {boolean}
|
|
394
|
-
*/
|
|
395
|
-
isTemplateModified(contextId) {
|
|
396
|
-
const all = this._loadAll();
|
|
397
|
-
return all[contextId]?.templateModified ?? false;
|
|
398
|
-
}
|
|
399
|
-
// =========================================================================
|
|
400
|
-
// NAMED CONFIGURATION MANAGEMENT
|
|
401
|
-
// =========================================================================
|
|
402
|
-
/**
|
|
403
|
-
* Get all saved configurations for a context.
|
|
404
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
405
|
-
* @returns {Object} { activeId: string, configs: { id: { name, tiles, createdAt } } }
|
|
406
|
-
*/
|
|
407
|
-
getConfigurations(contextId) {
|
|
408
|
-
const all = this._loadConfigs();
|
|
409
|
-
const contextConfigs = all[contextId];
|
|
410
|
-
if (contextConfigs && Object.keys(contextConfigs.configs || {}).length > 0) {
|
|
411
|
-
return contextConfigs;
|
|
412
|
-
}
|
|
413
|
-
return {
|
|
414
|
-
activeId: null,
|
|
415
|
-
configs: {}
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Save the current layout as a named configuration.
|
|
420
|
-
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
421
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
422
|
-
* @param {string} name - Configuration name
|
|
423
|
-
* @param {Array|Object} layout - Layout to save
|
|
424
|
-
* @param {Object} [options] - Additional options
|
|
425
|
-
* @param {string} [options.templateId] - Template ID that generated this layout
|
|
426
|
-
* @returns {string} The new configuration ID
|
|
427
|
-
*/
|
|
428
|
-
saveConfiguration(contextId, name, layout, options) {
|
|
429
|
-
const all = this._loadConfigs();
|
|
430
|
-
if (!all[contextId]) {
|
|
431
|
-
all[contextId] = { activeId: null, configs: {} };
|
|
432
|
-
}
|
|
433
|
-
const uniqueName = this._getUniqueName(all[contextId].configs, name);
|
|
434
|
-
const configId = `config-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
435
|
-
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
436
|
-
all[contextId].configs[configId] = {
|
|
437
|
-
name: uniqueName,
|
|
438
|
-
tiles: tilesArray,
|
|
439
|
-
version: LAYOUT_VERSION,
|
|
440
|
-
createdAt: Date.now(),
|
|
441
|
-
templateId: options?.templateId || null
|
|
442
|
-
};
|
|
443
|
-
if (!all[contextId].activeId) {
|
|
444
|
-
all[contextId].activeId = configId;
|
|
445
|
-
}
|
|
446
|
-
this._saveConfigs(all);
|
|
447
|
-
this._logger?.info?.(`[LayoutPersistence] Saved configuration "${uniqueName}" for context ${contextId}`);
|
|
448
|
-
return configId;
|
|
449
|
-
}
|
|
450
|
-
/**
|
|
451
|
-
* Generate a unique name by appending (2), (3), etc. if name already exists.
|
|
452
|
-
* @param {Object} configs - Existing configurations object
|
|
453
|
-
* @param {string} name - Desired name
|
|
454
|
-
* @returns {string} Unique name
|
|
455
|
-
* @private
|
|
456
|
-
*/
|
|
457
|
-
_getUniqueName(configs, name) {
|
|
458
|
-
if (!configs) return name;
|
|
459
|
-
const existingNames = new Set(Object.values(configs).map((c) => c.name));
|
|
460
|
-
if (!existingNames.has(name)) {
|
|
461
|
-
return name;
|
|
462
|
-
}
|
|
463
|
-
let counter = 2;
|
|
464
|
-
let candidate = `${name} (${counter})`;
|
|
465
|
-
while (existingNames.has(candidate)) {
|
|
466
|
-
counter++;
|
|
467
|
-
candidate = `${name} (${counter})`;
|
|
468
|
-
}
|
|
469
|
-
return candidate;
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* Load a specific configuration.
|
|
473
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
474
|
-
* @param {string} configId - Configuration ID
|
|
475
|
-
* @returns {Object|null} Configuration layout or null if not found
|
|
476
|
-
*/
|
|
477
|
-
loadConfiguration(contextId, configId) {
|
|
478
|
-
const all = this._loadConfigs();
|
|
479
|
-
const config = all[contextId]?.configs?.[configId];
|
|
480
|
-
if (config) {
|
|
481
|
-
all[contextId].activeId = configId;
|
|
482
|
-
this._saveConfigs(all);
|
|
483
|
-
return config;
|
|
484
|
-
}
|
|
485
|
-
return null;
|
|
486
|
-
}
|
|
487
|
-
/**
|
|
488
|
-
* Delete a saved configuration.
|
|
489
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
490
|
-
* @param {string} configId - Configuration ID
|
|
491
|
-
* @returns {boolean} True if deleted, false if not found
|
|
492
|
-
*/
|
|
493
|
-
deleteConfiguration(contextId, configId) {
|
|
494
|
-
const all = this._loadConfigs();
|
|
495
|
-
if (!all[contextId]?.configs?.[configId]) {
|
|
496
|
-
return false;
|
|
497
|
-
}
|
|
498
|
-
delete all[contextId].configs[configId];
|
|
499
|
-
if (all[contextId].activeId === configId) {
|
|
500
|
-
const remaining = Object.keys(all[contextId].configs);
|
|
501
|
-
all[contextId].activeId = remaining.length > 0 ? remaining[0] : null;
|
|
502
|
-
}
|
|
503
|
-
this._saveConfigs(all);
|
|
504
|
-
this._logger?.info?.(`[LayoutPersistence] Deleted configuration ${configId} for context ${contextId}`);
|
|
505
|
-
return true;
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Update an existing configuration's layout (without creating a new ID).
|
|
509
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
510
|
-
* @param {string} configId - Configuration ID
|
|
511
|
-
* @param {Array|Object} layout - New layout to save
|
|
512
|
-
* @returns {boolean} True if updated, false if not found
|
|
513
|
-
*/
|
|
514
|
-
updateConfiguration(contextId, configId, layout) {
|
|
515
|
-
const all = this._loadConfigs();
|
|
516
|
-
if (!all[contextId]?.configs?.[configId]) {
|
|
517
|
-
return false;
|
|
518
|
-
}
|
|
519
|
-
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
520
|
-
all[contextId].configs[configId].tiles = tilesArray;
|
|
521
|
-
all[contextId].configs[configId].version = LAYOUT_VERSION;
|
|
522
|
-
all[contextId].configs[configId].updatedAt = Date.now();
|
|
523
|
-
this._saveConfigs(all);
|
|
524
|
-
return true;
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* Update the templateId on an existing configuration.
|
|
528
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
529
|
-
* @param {string} configId - Configuration ID
|
|
530
|
-
* @param {string} templateId - New template ID
|
|
531
|
-
* @returns {boolean} True if updated, false if not found
|
|
532
|
-
*/
|
|
533
|
-
updateConfigurationTemplateId(contextId, configId, templateId) {
|
|
534
|
-
const all = this._loadConfigs();
|
|
535
|
-
if (!all[contextId]?.configs?.[configId]) {
|
|
536
|
-
return false;
|
|
537
|
-
}
|
|
538
|
-
all[contextId].configs[configId].templateId = templateId;
|
|
539
|
-
this._saveConfigs(all);
|
|
540
|
-
return true;
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Rename a configuration.
|
|
544
|
-
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
545
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
546
|
-
* @param {string} configId - Configuration ID
|
|
547
|
-
* @param {string} newName - New name
|
|
548
|
-
* @returns {string|false} The actual name used (may differ if duplicate), or false if not found
|
|
549
|
-
*/
|
|
550
|
-
renameConfiguration(contextId, configId, newName) {
|
|
551
|
-
const all = this._loadConfigs();
|
|
552
|
-
if (!all[contextId]?.configs?.[configId]) {
|
|
553
|
-
return false;
|
|
554
|
-
}
|
|
555
|
-
const otherConfigs = {};
|
|
556
|
-
Object.entries(all[contextId].configs).forEach(([id, config]) => {
|
|
557
|
-
if (id !== configId) {
|
|
558
|
-
otherConfigs[id] = config;
|
|
559
|
-
}
|
|
560
|
-
});
|
|
561
|
-
const uniqueName = this._getUniqueName(otherConfigs, newName);
|
|
562
|
-
all[contextId].configs[configId].name = uniqueName;
|
|
563
|
-
this._saveConfigs(all);
|
|
564
|
-
return uniqueName;
|
|
565
|
-
}
|
|
566
|
-
/**
|
|
567
|
-
* Get the active configuration for a context.
|
|
568
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
569
|
-
* @returns {Object|null} { id, name, tiles } or null
|
|
570
|
-
*/
|
|
571
|
-
getActiveConfiguration(contextId) {
|
|
572
|
-
const all = this._loadConfigs();
|
|
573
|
-
const contextConfigs = all[contextId];
|
|
574
|
-
if (!contextConfigs?.activeId) {
|
|
575
|
-
return null;
|
|
576
|
-
}
|
|
577
|
-
const activeConfig = contextConfigs.configs[contextConfigs.activeId];
|
|
578
|
-
if (activeConfig) {
|
|
579
|
-
return {
|
|
580
|
-
id: contextConfigs.activeId,
|
|
581
|
-
...activeConfig
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
return null;
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* Set the active configuration.
|
|
588
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
589
|
-
* @param {string} configId - Configuration ID
|
|
590
|
-
*/
|
|
591
|
-
setActiveConfiguration(contextId, configId) {
|
|
592
|
-
const all = this._loadConfigs();
|
|
593
|
-
if (all[contextId]?.configs?.[configId]) {
|
|
594
|
-
all[contextId].activeId = configId;
|
|
595
|
-
this._saveConfigs(all);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
/**
|
|
599
|
-
* List all configurations for a context.
|
|
600
|
-
* @param {string} contextId - Whatever the app keys layouts by.
|
|
601
|
-
* @returns {Array} Array of { id, name, createdAt, isActive }
|
|
602
|
-
*/
|
|
603
|
-
listConfigurations(contextId) {
|
|
604
|
-
const all = this._loadConfigs();
|
|
605
|
-
const contextConfigs = all[contextId];
|
|
606
|
-
if (!contextConfigs?.configs) {
|
|
607
|
-
return [];
|
|
608
|
-
}
|
|
609
|
-
return Object.entries(contextConfigs.configs).map(([id, config]) => ({
|
|
610
|
-
id,
|
|
611
|
-
name: config.name,
|
|
612
|
-
createdAt: config.createdAt,
|
|
613
|
-
isActive: id === contextConfigs.activeId
|
|
614
|
-
})).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
615
|
-
}
|
|
616
|
-
/**
|
|
617
|
-
* Get the template set fingerprint for a context.
|
|
618
|
-
* Used to detect when templates have changed and configs need regeneration.
|
|
619
|
-
* @param {string} contextId
|
|
620
|
-
* @returns {string|null}
|
|
621
|
-
*/
|
|
622
|
-
getTemplateSetId(contextId) {
|
|
623
|
-
const all = this._loadConfigs();
|
|
624
|
-
return all[contextId]?.templateSetId || null;
|
|
625
|
-
}
|
|
626
|
-
/**
|
|
627
|
-
* Store the template set fingerprint for a context.
|
|
628
|
-
* @param {string} contextId
|
|
629
|
-
* @param {string} templateSetId
|
|
630
|
-
*/
|
|
631
|
-
setTemplateSetId(contextId, templateSetId) {
|
|
632
|
-
const all = this._loadConfigs();
|
|
633
|
-
if (!all[contextId]) {
|
|
634
|
-
all[contextId] = { activeId: null, configs: {} };
|
|
635
|
-
}
|
|
636
|
-
all[contextId].templateSetId = templateSetId;
|
|
637
|
-
this._saveConfigs(all);
|
|
638
|
-
}
|
|
639
|
-
/**
|
|
640
|
-
* Delete ALL configurations for a context.
|
|
641
|
-
* @param {string} contextId
|
|
642
|
-
*/
|
|
643
|
-
clearConfigurations(contextId) {
|
|
644
|
-
const all = this._loadConfigs();
|
|
645
|
-
delete all[contextId];
|
|
646
|
-
this._saveConfigs(all);
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Load all configurations from storage.
|
|
650
|
-
* @returns {Object}
|
|
651
|
-
* @private
|
|
652
|
-
*/
|
|
653
|
-
_loadConfigs() {
|
|
654
|
-
try {
|
|
655
|
-
const raw = this._storage?.getItem(this._configsKey);
|
|
656
|
-
if (raw) {
|
|
657
|
-
return JSON.parse(raw);
|
|
658
|
-
}
|
|
659
|
-
} catch (err) {
|
|
660
|
-
console.error("[LayoutPersistence] Failed to load configurations:", err);
|
|
661
|
-
}
|
|
662
|
-
return {};
|
|
663
|
-
}
|
|
664
|
-
/**
|
|
665
|
-
* Save all configurations to storage.
|
|
666
|
-
* @param {Object} data
|
|
667
|
-
* @private
|
|
668
|
-
*/
|
|
669
|
-
_saveConfigs(data) {
|
|
670
|
-
try {
|
|
671
|
-
this._storage?.setItem(this._configsKey, JSON.stringify(data));
|
|
672
|
-
} catch (err) {
|
|
673
|
-
console.error("[LayoutPersistence] Failed to save configurations:", err);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
};
|
|
677
|
-
var instance = null;
|
|
678
|
-
var _options = {};
|
|
679
|
-
function configureLayoutPersistence(options = {}) {
|
|
680
|
-
_options = options;
|
|
681
|
-
instance = null;
|
|
682
|
-
}
|
|
683
|
-
function getLayoutPersistence() {
|
|
684
|
-
if (!instance) {
|
|
685
|
-
instance = new LayoutPersistence(_options);
|
|
686
|
-
}
|
|
687
|
-
return instance;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// src/tiles/tile_base.js
|
|
691
|
-
var TileBase = class {
|
|
692
|
-
/** @type {string} Widget type identifier - override in subclass */
|
|
693
|
-
static TYPE = "base";
|
|
694
|
-
/** @type {string} Display title - override in subclass */
|
|
695
|
-
static TITLE = "Widget";
|
|
696
|
-
/** @type {{w: number, h: number}} Default grid size - override in subclass */
|
|
697
|
-
static DEFAULT_SIZE = { w: 4, h: 3 };
|
|
698
|
-
/** @type {{minW: number, minH: number, maxW: number, maxH: number}} Size constraints */
|
|
699
|
-
static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 8 };
|
|
700
|
-
/** @type {boolean} Whether widget can be expanded to a separate window - override in subclass */
|
|
701
|
-
static EXPANDABLE = false;
|
|
702
|
-
/** @type {boolean} Whether widget supports scenario comparison overlay - override in subclass */
|
|
703
|
-
static SUPPORTS_COMPARISON = false;
|
|
704
|
-
/**
|
|
705
|
-
* @param {Object} options
|
|
706
|
-
* @param {string} options.id - Unique tile identifier
|
|
707
|
-
* @param {Object} options.grid - Parent TileGrid instance
|
|
708
|
-
* @param {Object} [options.eventBus] - Event bus for cross-component communication
|
|
709
|
-
* @param {Object} [options.config] - Widget-specific configuration
|
|
710
|
-
* @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
|
|
711
|
-
* @param {{resolve: Function}} [options.dataSource] - Where this tile asks for its
|
|
712
|
-
* own rows. Handed down by `TileGrid`; absent for a grid whose data is
|
|
713
|
-
* broadcast with `setData()`, and then nothing ever calls `loadData()`.
|
|
714
|
-
*/
|
|
715
|
-
constructor({
|
|
716
|
-
id,
|
|
717
|
-
grid,
|
|
718
|
-
eventBus = null,
|
|
719
|
-
config = {},
|
|
720
|
-
readonly = false,
|
|
721
|
-
headless = false,
|
|
722
|
-
host = null,
|
|
723
|
-
stateGuard = null,
|
|
724
|
-
dataSource = null
|
|
725
|
-
}) {
|
|
726
|
-
this.host = host;
|
|
727
|
-
this.dataSource = dataSource;
|
|
728
|
-
this.stateGuard = stateGuard;
|
|
729
|
-
this.id = id;
|
|
730
|
-
this.grid = grid;
|
|
731
|
-
this.eventBus = eventBus;
|
|
732
|
-
this.config = { ...this.getDefaultConfig(), ...config };
|
|
733
|
-
this.readonly = readonly;
|
|
734
|
-
this.headless = headless;
|
|
735
|
-
this.element = null;
|
|
736
|
-
this.contentElement = null;
|
|
737
|
-
this.chartInstance = null;
|
|
738
|
-
this.data = null;
|
|
739
|
-
this.fullData = null;
|
|
740
|
-
this._disposed = false;
|
|
741
|
-
this._lastLoadedAt = null;
|
|
742
|
-
this._loadSeq = 0;
|
|
743
|
-
this.comparisonData = null;
|
|
744
|
-
}
|
|
745
|
-
/**
|
|
746
|
-
* Set comparison data for overlay rendering.
|
|
747
|
-
* Only meaningful for widgets where SUPPORTS_COMPARISON = true.
|
|
748
|
-
* @param {Map<string, Object>|null} comparisonData
|
|
749
|
-
*/
|
|
750
|
-
setComparisonData(comparisonData) {
|
|
751
|
-
this.comparisonData = comparisonData;
|
|
752
|
-
if (this.data && this.contentElement) {
|
|
753
|
-
this.render(this.data);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
/**
|
|
757
|
-
* Format a raw analytics variable key into a user-friendly display label.
|
|
758
|
-
* Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
|
|
759
|
-
* @param {string} name - Raw variable key
|
|
760
|
-
* @returns {string} Friendly display label
|
|
761
|
-
*/
|
|
762
|
-
formatLabel(name) {
|
|
763
|
-
return formatVariableLabel(name);
|
|
764
|
-
}
|
|
765
|
-
/**
|
|
766
|
-
* Look up the unit string for a variable from analytics metadata.
|
|
767
|
-
* Checks stocks, flows, and indicators for unit metadata.
|
|
768
|
-
* @param {string} varName - Variable name
|
|
769
|
-
* @returns {string|null} Unit string or null
|
|
770
|
-
*/
|
|
771
|
-
getVariableUnit(varName) {
|
|
772
|
-
if (!varName || !this.data) return null;
|
|
773
|
-
const sources = [this.data.stocks, this.data.flows, this.data.indicators];
|
|
774
|
-
for (const source of sources) {
|
|
775
|
-
const varData = source?.[varName];
|
|
776
|
-
if (varData?.unit) return varData.unit;
|
|
777
|
-
}
|
|
778
|
-
return null;
|
|
779
|
-
}
|
|
780
|
-
/**
|
|
781
|
-
* Look up a variable across stocks, flows, and indicators.
|
|
782
|
-
* If the variable is not found, returns the first available variable
|
|
783
|
-
* and updates the config key so the widget self-heals.
|
|
784
|
-
* @param {Object} data - Analytics data
|
|
785
|
-
* @param {string} configKey - Config property name (e.g. 'variable', 'xVariable')
|
|
786
|
-
* @returns {{varData: any, varName: string}|null} Resolved data or null if no data at all
|
|
787
|
-
*/
|
|
788
|
-
resolveVariable(data, configKey = "variable") {
|
|
789
|
-
const varName = this.config[configKey];
|
|
790
|
-
if (varName) {
|
|
791
|
-
const varData = data.stocks?.[varName] || data.indicators?.[varName] || data.flows?.[varName];
|
|
792
|
-
if (varData) return { varData, varName };
|
|
793
|
-
const dotIdx = varName.indexOf(".");
|
|
794
|
-
if (dotIdx >= 0) {
|
|
795
|
-
const bare = varName.slice(dotIdx + 1);
|
|
796
|
-
const bareData = data.stocks?.[bare] || data.indicators?.[bare] || data.flows?.[bare];
|
|
797
|
-
if (bareData) return { varData: bareData, varName: bare };
|
|
798
|
-
}
|
|
799
|
-
if (dotIdx < 0) {
|
|
800
|
-
const suffix = `.${varName}`;
|
|
801
|
-
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
802
|
-
if (!source) continue;
|
|
803
|
-
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
804
|
-
if (match) return { varData: source[match], varName: match };
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
if (this.fullData && this.fullData !== data) {
|
|
808
|
-
const fullVarData = this.fullData.stocks?.[varName] || this.fullData.indicators?.[varName] || this.fullData.flows?.[varName];
|
|
809
|
-
if (fullVarData) return { varData: fullVarData, varName };
|
|
810
|
-
if (dotIdx >= 0) {
|
|
811
|
-
const bare = varName.slice(dotIdx + 1);
|
|
812
|
-
const fullBareData = this.fullData.stocks?.[bare] || this.fullData.indicators?.[bare] || this.fullData.flows?.[bare];
|
|
813
|
-
if (fullBareData) return { varData: fullBareData, varName: bare };
|
|
814
|
-
}
|
|
815
|
-
if (dotIdx < 0) {
|
|
816
|
-
const suffix = `.${varName}`;
|
|
817
|
-
for (const source of [this.fullData.stocks, this.fullData.indicators, this.fullData.flows]) {
|
|
818
|
-
if (!source) continue;
|
|
819
|
-
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
820
|
-
if (match) return { varData: source[match], varName: match };
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
const sources = [data.stocks, data.indicators, data.flows];
|
|
826
|
-
for (const source of sources) {
|
|
827
|
-
if (!source) continue;
|
|
828
|
-
const keys = Object.keys(source);
|
|
829
|
-
if (keys.length > 0) {
|
|
830
|
-
this.config[configKey] = keys[0];
|
|
831
|
-
return { varData: source[keys[0]], varName: keys[0] };
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
return null;
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* Find a variable by name in data, trying exact match then suffix match.
|
|
838
|
-
* Does NOT auto-fallback to first available variable.
|
|
839
|
-
* @param {Object} data - Analytics data with stocks/indicators/flows
|
|
840
|
-
* @param {string} varName - Variable name (bare or namespace-prefixed)
|
|
841
|
-
* @returns {{varData: any, varName: string}|null}
|
|
842
|
-
*/
|
|
843
|
-
static findVariable(data, varName) {
|
|
844
|
-
if (!data || !varName) return null;
|
|
845
|
-
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
846
|
-
if (!source) continue;
|
|
847
|
-
if (source[varName]) return { varData: source[varName], varName };
|
|
848
|
-
}
|
|
849
|
-
const dotIdx = varName.indexOf(".");
|
|
850
|
-
if (dotIdx < 0) {
|
|
851
|
-
const suffix = `.${varName}`;
|
|
852
|
-
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
853
|
-
if (!source) continue;
|
|
854
|
-
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
855
|
-
if (match) return { varData: source[match], varName: match };
|
|
856
|
-
}
|
|
857
|
-
} else {
|
|
858
|
-
const bare = varName.slice(dotIdx + 1);
|
|
859
|
-
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
860
|
-
if (!source) continue;
|
|
861
|
-
if (source[bare]) return { varData: source[bare], varName: bare };
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
return null;
|
|
865
|
-
}
|
|
866
|
-
/**
|
|
867
|
-
* Get default configuration for this widget type.
|
|
868
|
-
* Override in subclass.
|
|
869
|
-
* @returns {Object} Default config object
|
|
870
|
-
*/
|
|
871
|
-
getDefaultConfig() {
|
|
872
|
-
return {};
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* Get configuration schema for the config modal.
|
|
876
|
-
* Override in subclass.
|
|
877
|
-
* @returns {Object} Schema definition
|
|
878
|
-
*/
|
|
879
|
-
getConfigSchema() {
|
|
880
|
-
return { fields: [] };
|
|
881
|
-
}
|
|
882
|
-
/**
|
|
883
|
-
* Render widget content with data.
|
|
884
|
-
* Override in subclass.
|
|
885
|
-
* @param {Object} data - Analytics data
|
|
886
|
-
*/
|
|
887
|
-
render(data) {
|
|
888
|
-
throw new Error("TileBase.render() must be implemented by subclass");
|
|
889
|
-
}
|
|
890
|
-
/**
|
|
891
|
-
* Mount the tile into the container.
|
|
892
|
-
* @param {HTMLElement} container - Parent container
|
|
893
|
-
*/
|
|
894
|
-
mount(container) {
|
|
895
|
-
if (this._disposed) return;
|
|
896
|
-
this.element = document.createElement("div");
|
|
897
|
-
this.element.className = `tile${this.readonly ? " tile--readonly" : ""}`;
|
|
898
|
-
const stateGuard = this.stateGuard;
|
|
899
|
-
const setDataset = () => {
|
|
900
|
-
this.element.dataset.tileId = this.id;
|
|
901
|
-
this.element.dataset.tileType = this.constructor.TYPE;
|
|
902
|
-
};
|
|
903
|
-
if (stateGuard?.executeWithBypass) {
|
|
904
|
-
stateGuard.executeWithBypass("tile-mount", setDataset);
|
|
905
|
-
} else {
|
|
906
|
-
setDataset();
|
|
907
|
-
}
|
|
908
|
-
if (this.headless) {
|
|
909
|
-
this.contentElement = document.createElement("div");
|
|
910
|
-
this.contentElement.className = "tile-content";
|
|
911
|
-
this.element.appendChild(this.contentElement);
|
|
912
|
-
} else {
|
|
913
|
-
this._buildChrome();
|
|
914
|
-
}
|
|
915
|
-
container.appendChild(this.element);
|
|
916
|
-
}
|
|
917
|
-
/**
|
|
918
|
-
* Update the widget with new data and/or config.
|
|
919
|
-
* @param {Object} [data] - New analytics data
|
|
920
|
-
* @param {Object} [config] - New config values to merge
|
|
921
|
-
*/
|
|
922
|
-
update(data, config) {
|
|
923
|
-
if (this._disposed) return;
|
|
924
|
-
if (config) {
|
|
925
|
-
const prevDocs = (this.config?.docs || "").trim();
|
|
926
|
-
this.config = { ...this.config, ...config };
|
|
927
|
-
const nextDocs = (this.config?.docs || "").trim();
|
|
928
|
-
if (this.element && !this.headless && prevDocs !== nextDocs) {
|
|
929
|
-
this._syncInfoButton();
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
if (data !== void 0) {
|
|
933
|
-
this.data = data;
|
|
934
|
-
}
|
|
935
|
-
if (this.data && this.contentElement) {
|
|
936
|
-
this.render(this.data);
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
/**
|
|
940
|
-
* Load this tile's own data from `this.dataSource` and render it.
|
|
941
|
-
*
|
|
942
|
-
* A NO-OP HERE ON PURPOSE. The base class knows nothing about what a binding
|
|
943
|
-
* is or what a dataset looks like — those belong to the consumer, and the
|
|
944
|
-
* consumer that has them (Tables' `TablesTile`) implements this in ~30 lines
|
|
945
|
-
* over `showLoading()` / `render()` / `showError()`. What upstream owns is the
|
|
946
|
-
* CALL SITES: `TileGrid` invokes this after mount, after a resize settles and
|
|
947
|
-
* after a config save, but only when a `dataSource` was injected. Making it a
|
|
948
|
-
* hook rather than an implementation is what keeps EcoAgent's broadcast path
|
|
949
|
-
* (`setData` -> `update`) bit-identical: with no data source nothing here ever
|
|
950
|
-
* runs.
|
|
951
|
-
*
|
|
952
|
-
* @param {{force?: boolean}} [_opts] - `force` bypasses the source's cache.
|
|
953
|
-
* @returns {Promise<void>}
|
|
954
|
-
*/
|
|
955
|
-
async loadData(_opts = {}) {
|
|
956
|
-
}
|
|
957
|
-
/**
|
|
958
|
-
* Called once after a drag-resize settles, for widgets whose content does not
|
|
959
|
-
* reflow on its own — a Plotly figure sizes itself at draw time and stays that
|
|
960
|
-
* size until something calls `Plotly.Plots.resize`. Deliberately NOT wired to a
|
|
961
|
-
* ResizeObserver: the grid already knows when a resize ended, and an observer
|
|
962
|
-
* per tile fires during the drag, which is a relayout per pointer-move.
|
|
963
|
-
*/
|
|
964
|
-
onResize() {
|
|
965
|
-
}
|
|
966
|
-
/** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
|
|
967
|
-
_syncInfoButton() {
|
|
968
|
-
const header = this.element?.querySelector(".tile-header");
|
|
969
|
-
const controls = header?.querySelector(".tile-controls");
|
|
970
|
-
if (!controls) return;
|
|
971
|
-
const docs = (this.config?.docs || "").trim();
|
|
972
|
-
let btn = controls.querySelector(".tile-info-btn");
|
|
973
|
-
if (docs) {
|
|
974
|
-
if (!btn) {
|
|
975
|
-
btn = document.createElement("button");
|
|
976
|
-
btn.className = "tile-info-btn twm-has-tooltip";
|
|
977
|
-
btn.type = "button";
|
|
978
|
-
btn.tabIndex = -1;
|
|
979
|
-
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;";
|
|
980
|
-
btn.setAttribute("data-tooltip-placement", "bottom");
|
|
981
|
-
btn.setAttribute("data-tooltip-max-width", "420px");
|
|
982
|
-
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">info</span>';
|
|
983
|
-
btn.addEventListener("click", (e) => e.stopPropagation());
|
|
984
|
-
const anchor = controls.querySelector(".tile-expand-btn") ?? controls.querySelector(".tile-remove-btn");
|
|
985
|
-
if (anchor) controls.insertBefore(btn, anchor);
|
|
986
|
-
else controls.appendChild(btn);
|
|
987
|
-
}
|
|
988
|
-
btn.setAttribute("data-tooltip", docs);
|
|
989
|
-
} else if (btn) {
|
|
990
|
-
btn.remove();
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
/**
|
|
994
|
-
* Clean up resources and remove from DOM.
|
|
995
|
-
*/
|
|
996
|
-
dispose() {
|
|
997
|
-
if (this._disposed) return;
|
|
998
|
-
this._disposed = true;
|
|
999
|
-
if (this._expandWindow?.isVisible) {
|
|
1000
|
-
this._expandWindow.close();
|
|
1001
|
-
}
|
|
1002
|
-
this._expandWindow = null;
|
|
1003
|
-
if (this.chartInstance) {
|
|
1004
|
-
this.chartInstance.destroy();
|
|
1005
|
-
this.chartInstance = null;
|
|
1006
|
-
}
|
|
1007
|
-
if (this.element && this.element.parentNode) {
|
|
1008
|
-
this.element.parentNode.removeChild(this.element);
|
|
1009
|
-
}
|
|
1010
|
-
this.element = null;
|
|
1011
|
-
this.contentElement = null;
|
|
1012
|
-
this.data = null;
|
|
1013
|
-
this.fullData = null;
|
|
1014
|
-
}
|
|
1015
|
-
/**
|
|
1016
|
-
* The entries in the tile's ⋯ menu, as `{action, label, icon, hidden}`.
|
|
1017
|
-
*
|
|
1018
|
-
* This existed as a LITERAL inside `_buildChrome`, and the literal was
|
|
1019
|
-
* EcoAgent's: "Add to Documentation" and "Show in Documentation". Both are
|
|
1020
|
-
* meaningful only for a tile whose `config.sourceCellId` names a notebook
|
|
1021
|
-
* cell, and the first one's hide condition is `sourceCellId && already-added` —
|
|
1022
|
-
* so on a tile that has no `sourceCellId` at all it renders VISIBLE, and
|
|
1023
|
-
* choosing it emits `tile:add-to-documentation` at an application that has no
|
|
1024
|
-
* documentation. Every consumer other than EcoAgent therefore shipped a menu
|
|
1025
|
-
* item that did nothing, and could not remove it without editing this file.
|
|
1026
|
-
*
|
|
1027
|
-
* Returning `[]` drops the whole wrapper — button, dropdown and all — because
|
|
1028
|
-
* a ⋯ button that opens an empty menu reads as a broken feature rather than
|
|
1029
|
-
* an absent one. The default is the two entries above, so EcoAgent's chrome
|
|
1030
|
-
* is unchanged.
|
|
1031
|
-
*
|
|
1032
|
-
* Selection is routed through `_onMenuAction()`; override both together.
|
|
1033
|
-
* @returns {Array<{action: string, label: string, icon?: string, hidden?: boolean}>}
|
|
1034
|
-
*/
|
|
1035
|
-
menuItems() {
|
|
1036
|
-
const linked = !!(this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId));
|
|
1037
|
-
return [
|
|
1038
|
-
{
|
|
1039
|
-
action: "add-to-documentation",
|
|
1040
|
-
label: "Add to Documentation",
|
|
1041
|
-
icon: "post_add",
|
|
1042
|
-
hidden: linked
|
|
1043
|
-
},
|
|
1044
|
-
{
|
|
1045
|
-
action: "show-in-documentation",
|
|
1046
|
-
label: "Show in Documentation",
|
|
1047
|
-
icon: "description",
|
|
1048
|
-
hidden: !linked
|
|
1049
|
-
}
|
|
1050
|
-
];
|
|
1051
|
-
}
|
|
1052
|
-
/**
|
|
1053
|
-
* Act on a ⋯ menu selection. One dispatch point for every entry, so an
|
|
1054
|
-
* override of `menuItems()` cannot add a row that has nothing behind it, and
|
|
1055
|
-
* an unrecognised action is silently ignored rather than throwing into a
|
|
1056
|
-
* click handler.
|
|
1057
|
-
* @param {string} action
|
|
1058
|
-
* @protected
|
|
1059
|
-
*/
|
|
1060
|
-
_onMenuAction(action) {
|
|
1061
|
-
if (action === "add-to-documentation") {
|
|
1062
|
-
this.eventBus?.emit?.("tile:add-to-documentation", {
|
|
1063
|
-
tileType: this.constructor.TYPE,
|
|
1064
|
-
config: { ...this.config }
|
|
1065
|
-
});
|
|
1066
|
-
} else if (action === "show-in-documentation") {
|
|
1067
|
-
if (this.config?.sourceCellId) {
|
|
1068
|
-
this.eventBus?.emit?.("tile:show-in-documentation", {
|
|
1069
|
-
sourceCellId: this.config.sourceCellId
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
/**
|
|
1075
|
-
* Build the tile chrome (header, content area, resize handles).
|
|
1076
|
-
* @private
|
|
1077
|
-
*/
|
|
1078
|
-
_buildChrome() {
|
|
1079
|
-
const header = document.createElement("div");
|
|
1080
|
-
header.className = "tile-header";
|
|
1081
|
-
const expandBtnHtml = this.constructor.EXPANDABLE ? `<button class="tile-expand-btn twm-has-tooltip" data-tooltip="Open in window">
|
|
1082
|
-
<span class="material-symbols-outlined">open_in_new</span>
|
|
1083
|
-
</button>` : "";
|
|
1084
|
-
const docs = (this.config?.docs || "").trim();
|
|
1085
|
-
const infoBtnHtml = docs ? `<button class="tile-info-btn twm-has-tooltip" type="button" tabindex="-1"
|
|
1086
|
-
data-tooltip="${this.#escapeAttr(docs)}"
|
|
1087
|
-
data-tooltip-placement="bottom"
|
|
1088
|
-
data-tooltip-max-width="420px"
|
|
1089
|
-
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;">
|
|
1090
|
-
<span class="material-symbols-outlined" style="font-size:18px;">info</span>
|
|
1091
|
-
</button>` : "";
|
|
1092
|
-
const menuEntries = (this.readonly ? [] : this.menuItems()) ?? [];
|
|
1093
|
-
const menuHtml = menuEntries.length ? `<div class="tile-menu-wrapper">
|
|
1094
|
-
<button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
|
|
1095
|
-
<span class="material-symbols-outlined">more_vert</span>
|
|
1096
|
-
</button>
|
|
1097
|
-
<div class="tile-menu-dropdown" hidden>
|
|
1098
|
-
${menuEntries.map((item) => `
|
|
1099
|
-
<button class="tile-menu-item" data-action="${this.#escapeAttr(item.action)}"${item.hidden ? ' style="display:none"' : ""}>
|
|
1100
|
-
${item.icon ? `<span class="material-symbols-outlined">${this.#escapeAttr(item.icon)}</span>` : ""}
|
|
1101
|
-
<span>${this.#escapeAttr(item.label ?? "")}</span>
|
|
1102
|
-
</button>`).join("")}
|
|
1103
|
-
</div>
|
|
1104
|
-
</div>` : "";
|
|
1105
|
-
if (this.readonly) {
|
|
1106
|
-
header.innerHTML = `
|
|
1107
|
-
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
1108
|
-
<div class="tile-controls">
|
|
1109
|
-
${infoBtnHtml}
|
|
1110
|
-
${expandBtnHtml}
|
|
1111
|
-
</div>
|
|
1112
|
-
`;
|
|
1113
|
-
} else {
|
|
1114
|
-
header.innerHTML = `
|
|
1115
|
-
<span class="tile-drag-handle twm-has-tooltip" data-tooltip="Drag to reposition">
|
|
1116
|
-
<span class="material-symbols-outlined">drag_indicator</span>
|
|
1117
|
-
</span>
|
|
1118
|
-
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
1119
|
-
<div class="tile-controls">
|
|
1120
|
-
<button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
|
|
1121
|
-
<span class="material-symbols-outlined">settings</span>
|
|
1122
|
-
</button>
|
|
1123
|
-
${menuHtml}
|
|
1124
|
-
${infoBtnHtml}
|
|
1125
|
-
${expandBtnHtml}
|
|
1126
|
-
<button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
|
|
1127
|
-
<span class="material-symbols-outlined">close</span>
|
|
1128
|
-
</button>
|
|
1129
|
-
</div>
|
|
1130
|
-
`;
|
|
1131
|
-
}
|
|
1132
|
-
const expandBtn = header.querySelector(".tile-expand-btn");
|
|
1133
|
-
expandBtn?.addEventListener("click", (e) => {
|
|
1134
|
-
e.stopPropagation();
|
|
1135
|
-
this._onExpandClick();
|
|
1136
|
-
});
|
|
1137
|
-
const infoBtn = header.querySelector(".tile-info-btn");
|
|
1138
|
-
infoBtn?.addEventListener("click", (e) => e.stopPropagation());
|
|
1139
|
-
if (!this.readonly) {
|
|
1140
|
-
const configBtn = header.querySelector(".tile-config-btn");
|
|
1141
|
-
configBtn?.addEventListener("click", (e) => {
|
|
1142
|
-
e.stopPropagation();
|
|
1143
|
-
this._onConfigClick();
|
|
1144
|
-
});
|
|
1145
|
-
const menuBtn = header.querySelector(".tile-menu-btn");
|
|
1146
|
-
const menuDropdown = header.querySelector(".tile-menu-dropdown");
|
|
1147
|
-
if (menuBtn && menuDropdown) {
|
|
1148
|
-
menuBtn.addEventListener("click", (e) => {
|
|
1149
|
-
e.stopPropagation();
|
|
1150
|
-
const isOpen = !menuDropdown.hidden;
|
|
1151
|
-
menuDropdown.hidden = !menuDropdown.hidden;
|
|
1152
|
-
if (!isOpen) {
|
|
1153
|
-
const closeMenu = (ev) => {
|
|
1154
|
-
if (!menuDropdown.contains(ev.target) && ev.target !== menuBtn) {
|
|
1155
|
-
menuDropdown.hidden = true;
|
|
1156
|
-
document.removeEventListener("pointerdown", closeMenu, true);
|
|
1157
|
-
}
|
|
1158
|
-
};
|
|
1159
|
-
requestAnimationFrame(() => {
|
|
1160
|
-
document.addEventListener("pointerdown", closeMenu, true);
|
|
1161
|
-
});
|
|
1162
|
-
}
|
|
1163
|
-
});
|
|
1164
|
-
menuDropdown.querySelectorAll(".tile-menu-item").forEach((itemEl) => {
|
|
1165
|
-
itemEl.addEventListener("click", (e) => {
|
|
1166
|
-
e.stopPropagation();
|
|
1167
|
-
menuDropdown.hidden = true;
|
|
1168
|
-
this._onMenuAction(itemEl.dataset.action);
|
|
1169
|
-
});
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
const removeBtn = header.querySelector(".tile-remove-btn");
|
|
1173
|
-
removeBtn?.addEventListener("click", (e) => {
|
|
1174
|
-
e.stopPropagation();
|
|
1175
|
-
this._onRemoveClick();
|
|
1176
|
-
});
|
|
1177
|
-
}
|
|
1178
|
-
this.contentElement = document.createElement("div");
|
|
1179
|
-
this.contentElement.className = "tile-content";
|
|
1180
|
-
this.element.appendChild(header);
|
|
1181
|
-
this.element.appendChild(this.contentElement);
|
|
1182
|
-
if (!this.readonly) {
|
|
1183
|
-
const resizeHandle = document.createElement("div");
|
|
1184
|
-
resizeHandle.className = "tile-resize-handle se";
|
|
1185
|
-
resizeHandle.innerHTML = '<span class="material-symbols-outlined">drag_handle</span>';
|
|
1186
|
-
this.element.appendChild(resizeHandle);
|
|
1187
|
-
this.element.addEventListener("contextmenu", (e) => {
|
|
1188
|
-
e.preventDefault();
|
|
1189
|
-
e.stopPropagation();
|
|
1190
|
-
this.eventBus?.emit?.("tile:context-menu", {
|
|
1191
|
-
tileId: this.id,
|
|
1192
|
-
tileType: this.constructor.TYPE,
|
|
1193
|
-
tileTitle: this.constructor.TITLE,
|
|
1194
|
-
config: { ...this.config },
|
|
1195
|
-
clientX: e.clientX,
|
|
1196
|
-
clientY: e.clientY
|
|
1197
|
-
});
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
/**
|
|
1202
|
-
* Handle config button click — highlight tile and emit config request.
|
|
1203
|
-
* @private
|
|
1204
|
-
*/
|
|
1205
|
-
_onConfigClick() {
|
|
1206
|
-
this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el) => el.classList.remove("tile--selected"));
|
|
1207
|
-
this.element?.classList.add("tile--selected");
|
|
1208
|
-
this.eventBus?.emit?.("tile:config-request", {
|
|
1209
|
-
tileId: this.id,
|
|
1210
|
-
tileType: this.constructor.TYPE,
|
|
1211
|
-
config: this.config,
|
|
1212
|
-
schema: this.getConfigSchema()
|
|
1213
|
-
});
|
|
1214
|
-
this.element?.dispatchEvent(new CustomEvent("tile:config-request", {
|
|
1215
|
-
bubbles: true,
|
|
1216
|
-
detail: {
|
|
1217
|
-
tileId: this.id,
|
|
1218
|
-
tileType: this.constructor.TYPE,
|
|
1219
|
-
config: this.config,
|
|
1220
|
-
schema: this.getConfigSchema()
|
|
1221
|
-
}
|
|
1222
|
-
}));
|
|
1223
|
-
}
|
|
1224
|
-
/**
|
|
1225
|
-
* Handle remove button click.
|
|
1226
|
-
* @param {Event} [event] - Original click event
|
|
1227
|
-
* @private
|
|
1228
|
-
*/
|
|
1229
|
-
_onRemoveClick(event) {
|
|
1230
|
-
const removeBtn = this.element?.querySelector(".tile-remove-btn");
|
|
1231
|
-
this.element?.dispatchEvent(new CustomEvent("tile:remove-request", {
|
|
1232
|
-
bubbles: true,
|
|
1233
|
-
detail: {
|
|
1234
|
-
tileId: this.id,
|
|
1235
|
-
anchorElement: removeBtn
|
|
1236
|
-
}
|
|
1237
|
-
}));
|
|
1238
|
-
}
|
|
1239
|
-
/**
|
|
1240
|
-
* Set the title displayed in the header.
|
|
1241
|
-
* @param {string} title - New title text
|
|
1242
|
-
*/
|
|
1243
|
-
setTitle(title) {
|
|
1244
|
-
const titleEl = this.element?.querySelector(".tile-title");
|
|
1245
|
-
if (titleEl) {
|
|
1246
|
-
titleEl.textContent = title;
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
/**
|
|
1250
|
-
* Show loading state in content area.
|
|
1251
|
-
*/
|
|
1252
|
-
showLoading() {
|
|
1253
|
-
if (this.contentElement) {
|
|
1254
|
-
this.contentElement.innerHTML = `
|
|
1255
|
-
<div class="tile-loading">
|
|
1256
|
-
<span class="material-symbols-outlined spinning">progress_activity</span>
|
|
1257
|
-
<span>Loading...</span>
|
|
1258
|
-
</div>
|
|
1259
|
-
`;
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
/**
|
|
1263
|
-
* Show empty state in content area.
|
|
1264
|
-
*
|
|
1265
|
-
* The message is ESCAPED. Both this and `showError` interpolated straight
|
|
1266
|
-
* into `innerHTML`, and the string reaching `showError` is the one place a
|
|
1267
|
-
* caller has least control over — a server's error text, echoed back with a
|
|
1268
|
-
* column name or a filter value in it. Callers pass plain text; this decides
|
|
1269
|
-
* what that means in HTML.
|
|
1270
|
-
*
|
|
1271
|
-
* @param {string} [message] - Custom message
|
|
1272
|
-
*/
|
|
1273
|
-
showEmpty(message = "No data available") {
|
|
1274
|
-
if (this.contentElement) {
|
|
1275
|
-
this.contentElement.innerHTML = `
|
|
1276
|
-
<div class="tile-empty">
|
|
1277
|
-
<span class="material-symbols-outlined">inbox</span>
|
|
1278
|
-
<span>${this.#escapeAttr(message)}</span>
|
|
1279
|
-
</div>
|
|
1280
|
-
`;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
/**
|
|
1284
|
-
* Show error state in content area. The message is escaped — see `showEmpty`.
|
|
1285
|
-
* @param {string} [message] - Error message
|
|
1286
|
-
*/
|
|
1287
|
-
showError(message = "Failed to load data") {
|
|
1288
|
-
if (this.contentElement) {
|
|
1289
|
-
this.contentElement.innerHTML = `
|
|
1290
|
-
<div class="twm-tile-error">
|
|
1291
|
-
<span class="material-symbols-outlined">error</span>
|
|
1292
|
-
<span>${this.#escapeAttr(message)}</span>
|
|
1293
|
-
</div>
|
|
1294
|
-
`;
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
/**
|
|
1298
|
-
* Format a number for display.
|
|
1299
|
-
* @param {number} value - Number to format
|
|
1300
|
-
* @param {number} [decimals=2] - Decimal places
|
|
1301
|
-
* @returns {string} Formatted string
|
|
1302
|
-
*/
|
|
1303
|
-
formatNumber(value, decimals = 2) {
|
|
1304
|
-
if (value == null || !Number.isFinite(value)) return "\u2014";
|
|
1305
|
-
const abs = Math.abs(value);
|
|
1306
|
-
if (abs >= 1e9) {
|
|
1307
|
-
return (value / 1e9).toFixed(1) + "B";
|
|
1308
|
-
} else if (abs >= 1e6) {
|
|
1309
|
-
return (value / 1e6).toFixed(1) + "M";
|
|
1310
|
-
} else if (abs >= 1e3) {
|
|
1311
|
-
return (value / 1e3).toFixed(1) + "K";
|
|
1312
|
-
} else if (abs < 0.01 && abs > 0) {
|
|
1313
|
-
return value.toExponential(decimals);
|
|
1314
|
-
}
|
|
1315
|
-
return value.toFixed(decimals);
|
|
1316
|
-
}
|
|
1317
|
-
/**
|
|
1318
|
-
* Get available variables from data for config dropdowns.
|
|
1319
|
-
* @param {Object} data - Analytics data
|
|
1320
|
-
* @returns {Array<{value: string, label: string}>} Variable options
|
|
1321
|
-
*/
|
|
1322
|
-
getAvailableVariables(data) {
|
|
1323
|
-
if (!data) return [];
|
|
1324
|
-
const variables = [];
|
|
1325
|
-
if (data.stocks) {
|
|
1326
|
-
Object.keys(data.stocks).forEach((name) => {
|
|
1327
|
-
variables.push({ value: name, label: name, category: "Stocks" });
|
|
1328
|
-
});
|
|
1329
|
-
}
|
|
1330
|
-
if (data.flows) {
|
|
1331
|
-
Object.keys(data.flows).forEach((name) => {
|
|
1332
|
-
variables.push({ value: name, label: name, category: "Flows" });
|
|
1333
|
-
});
|
|
1334
|
-
}
|
|
1335
|
-
if (data.indicators) {
|
|
1336
|
-
Object.keys(data.indicators).forEach((name) => {
|
|
1337
|
-
variables.push({ value: name, label: name, category: "Indicators" });
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
return variables;
|
|
1341
|
-
}
|
|
1342
|
-
// =========================================================================
|
|
1343
|
-
// EXPAND WINDOW FUNCTIONALITY
|
|
1344
|
-
// Reuses PlotPopoutWindow's patterns and CSS classes
|
|
1345
|
-
// =========================================================================
|
|
1346
|
-
/**
|
|
1347
|
-
* Handle expand button click.
|
|
1348
|
-
* @private
|
|
1349
|
-
*/
|
|
1350
|
-
_onExpandClick() {
|
|
1351
|
-
if (!this.constructor.EXPANDABLE) return;
|
|
1352
|
-
const expandData = this.getExpandData();
|
|
1353
|
-
if (!expandData) {
|
|
1354
|
-
console.warn(`[TileBase] No expand data from widget ${this.id}`);
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
this._openExpandWindow(expandData);
|
|
1358
|
-
}
|
|
1359
|
-
/**
|
|
1360
|
-
* Get data for the expanded window.
|
|
1361
|
-
* Override in subclass for expandable widgets.
|
|
1362
|
-
* @returns {Object|null} { title, traces, layout, tableHeaders, tableRows }
|
|
1363
|
-
*/
|
|
1364
|
-
getExpandData() {
|
|
1365
|
-
return null;
|
|
1366
|
-
}
|
|
1367
|
-
/**
|
|
1368
|
-
* Open the expand window with plot and data tabs.
|
|
1369
|
-
* Delegates to openRawTracesWindow (PlotPopoutWindow module) which
|
|
1370
|
-
* provides proper toolbar layout, download buttons, and data table.
|
|
1371
|
-
* @param {Object} expandData - Data from getExpandData()
|
|
1372
|
-
* @private
|
|
1373
|
-
*/
|
|
1374
|
-
async _openExpandWindow(expandData) {
|
|
1375
|
-
if (this._expandWindow?.isVisible) {
|
|
1376
|
-
this._expandWindow.bringToFront();
|
|
1377
|
-
return;
|
|
1378
|
-
}
|
|
1379
|
-
this._expandWindow = await openRawTracesWindow({
|
|
1380
|
-
host: this.host,
|
|
1381
|
-
id: `tile-expand-${this.id}`,
|
|
1382
|
-
title: expandData.title || this.constructor.TITLE,
|
|
1383
|
-
traces: expandData.traces,
|
|
1384
|
-
layout: expandData.layout,
|
|
1385
|
-
frames: expandData.frames,
|
|
1386
|
-
tableHeaders: expandData.tableHeaders,
|
|
1387
|
-
tableRows: expandData.tableRows,
|
|
1388
|
-
services: { eventBus: this.eventBus }
|
|
1389
|
-
});
|
|
1390
|
-
}
|
|
1391
|
-
/**
|
|
1392
|
-
* Escape a string for safe insertion into HTML — an attribute value or a text
|
|
1393
|
-
* node, since the five characters that matter are the same five in both.
|
|
1394
|
-
* @private
|
|
1395
|
-
*/
|
|
1396
|
-
#escapeAttr(s) {
|
|
1397
|
-
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
1398
|
-
"&": "&",
|
|
1399
|
-
"<": "<",
|
|
1400
|
-
">": ">",
|
|
1401
|
-
'"': """,
|
|
1402
|
-
"'": "'"
|
|
1403
|
-
})[c]);
|
|
1404
|
-
}
|
|
1405
|
-
/**
|
|
1406
|
-
* Show toast notification via eventBus.
|
|
1407
|
-
* @private
|
|
1408
|
-
*/
|
|
1409
|
-
_notify(title, message, severity = "info") {
|
|
1410
|
-
this.eventBus?.emit?.("toast:show", {
|
|
1411
|
-
title,
|
|
1412
|
-
message,
|
|
1413
|
-
type: severity
|
|
1414
|
-
});
|
|
1415
|
-
}
|
|
1416
|
-
};
|
|
1417
|
-
|
|
1418
|
-
// src/tiles/tile_grid.js
|
|
1419
|
-
init_tile_registry();
|
|
1420
|
-
var TileGrid = class {
|
|
1421
|
-
/**
|
|
1422
|
-
* @param {Object} options
|
|
1423
|
-
* @param {HTMLElement} options.container - Container element for the grid
|
|
1424
|
-
* @param {number} [options.columns=12] - Number of grid columns
|
|
1425
|
-
* @param {number} [options.rowHeight=80] - Height of each grid row in pixels
|
|
1426
|
-
* @param {number} [options.gap=16] - Gap between tiles in pixels
|
|
1427
|
-
* @param {Object} [options.eventBus] - Event bus for cross-component communication
|
|
1428
|
-
* @param {boolean} [options.showToolbar=true] - Whether to show built-in toolbar
|
|
1429
|
-
* @param {Function} [options.onLayoutChange] - Callback when layout changes
|
|
1430
|
-
* @param {Function} [options.onAddWidget] - Callback for add widget action
|
|
1431
|
-
* @param {Function} [options.onResetLayout] - Callback for reset layout action
|
|
1432
|
-
* @param {Function} [options.onExport] - Callback for export action
|
|
1433
|
-
* @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
|
|
1434
|
-
* handed to every tile. With none, the grid is the broadcast-only grid
|
|
1435
|
-
* it has always been and no tile is ever asked to load anything.
|
|
1436
|
-
*/
|
|
1437
|
-
constructor({
|
|
1438
|
-
container,
|
|
1439
|
-
columns = 12,
|
|
1440
|
-
rowHeight = 80,
|
|
1441
|
-
gap = 16,
|
|
1442
|
-
eventBus = null,
|
|
1443
|
-
showToolbar = true,
|
|
1444
|
-
readonly = false,
|
|
1445
|
-
onLayoutChange,
|
|
1446
|
-
onBeforeLayoutChange,
|
|
1447
|
-
onAddWidget,
|
|
1448
|
-
onResetLayout,
|
|
1449
|
-
onExport,
|
|
1450
|
-
// Handed down to every tile. TileBase used to import appHost() from
|
|
1451
|
-
// ui/js/ecoagent/ to back Expand -> Export CSV/PNG; a grid that has
|
|
1452
|
-
// no host simply gets the Blob-download fallback the host contract
|
|
1453
|
-
// promises. It is not an error to have none.
|
|
1454
|
-
host = null,
|
|
1455
|
-
stateGuard = null,
|
|
1456
|
-
// Injected, never constructed here: the grid does not know what
|
|
1457
|
-
// a binding is, only that a tile may have somewhere to ask.
|
|
1458
|
-
dataSource = null
|
|
1459
|
-
}) {
|
|
1460
|
-
this.host = host;
|
|
1461
|
-
this.stateGuard = stateGuard;
|
|
1462
|
-
this.dataSource = dataSource;
|
|
1463
|
-
this.container = container;
|
|
1464
|
-
this.columns = columns;
|
|
1465
|
-
this.rowHeight = rowHeight;
|
|
1466
|
-
this.gap = gap;
|
|
1467
|
-
this.eventBus = eventBus;
|
|
1468
|
-
this.showToolbar = showToolbar;
|
|
1469
|
-
this.readonly = readonly;
|
|
1470
|
-
this._onLayoutChangeCallback = onLayoutChange;
|
|
1471
|
-
this._onBeforeLayoutChangeCallback = onBeforeLayoutChange;
|
|
1472
|
-
this._onAddWidgetCallback = onAddWidget;
|
|
1473
|
-
this._onResetLayoutCallback = onResetLayout;
|
|
1474
|
-
this._onExportCallback = onExport;
|
|
1475
|
-
this.tiles = /* @__PURE__ */ new Map();
|
|
1476
|
-
this.layout = [];
|
|
1477
|
-
this.documentationCellIds = null;
|
|
1478
|
-
this.gridElement = null;
|
|
1479
|
-
this.data = null;
|
|
1480
|
-
this.fullData = null;
|
|
1481
|
-
this._dragState = null;
|
|
1482
|
-
this._resizeState = null;
|
|
1483
|
-
this._ghostElement = null;
|
|
1484
|
-
this._boundOnPointerMove = this._onPointerMove.bind(this);
|
|
1485
|
-
this._boundOnPointerUp = this._onPointerUp.bind(this);
|
|
1486
|
-
this._init();
|
|
1487
|
-
}
|
|
1488
|
-
/**
|
|
1489
|
-
* Initialize the grid container.
|
|
1490
|
-
* @private
|
|
1491
|
-
*/
|
|
1492
|
-
_init() {
|
|
1493
|
-
this.gridElement = document.createElement("div");
|
|
1494
|
-
this.gridElement.className = "tile-grid";
|
|
1495
|
-
this.gridElement.style.setProperty("--grid-columns", this.columns);
|
|
1496
|
-
this.gridElement.style.setProperty("--grid-row-height", `${this.rowHeight}px`);
|
|
1497
|
-
this.gridElement.style.setProperty("--grid-gap", `${this.gap}px`);
|
|
1498
|
-
this.container.innerHTML = "";
|
|
1499
|
-
this.container.appendChild(this.gridElement);
|
|
1500
|
-
if (this.showToolbar) {
|
|
1501
|
-
this._buildToolbar();
|
|
1502
|
-
}
|
|
1503
|
-
this.gridElement.addEventListener("tile:config-request", this._onTileConfigRequest.bind(this));
|
|
1504
|
-
this.gridElement.addEventListener("tile:remove-request", this._onTileRemoveRequest.bind(this));
|
|
1505
|
-
document.addEventListener("pointermove", this._boundOnPointerMove);
|
|
1506
|
-
document.addEventListener("pointerup", this._boundOnPointerUp);
|
|
1507
|
-
}
|
|
1508
|
-
/**
|
|
1509
|
-
* Build the dashboard bottom toolbar with add widget dropdown.
|
|
1510
|
-
* @private
|
|
1511
|
-
*/
|
|
1512
|
-
_buildToolbar() {
|
|
1513
|
-
const toolbar = document.createElement("div");
|
|
1514
|
-
toolbar.className = "tile-grid-toolbar tile-grid-toolbar--bottom";
|
|
1515
|
-
this._toolbarElement = toolbar;
|
|
1516
|
-
toolbar.innerHTML = `
|
|
1517
|
-
<div class="toolbar-group toolbar-group--main">
|
|
1518
|
-
<div class="add-widget-container">
|
|
1519
|
-
<button class="toolbar-btn add-widget-btn twm-has-tooltip" data-tooltip="Add widget">
|
|
1520
|
-
<span class="material-symbols-outlined">add</span>
|
|
1521
|
-
<span>Add Widget</span>
|
|
1522
|
-
<span class="material-symbols-outlined dropdown-arrow">expand_less</span>
|
|
1523
|
-
</button>
|
|
1524
|
-
</div>
|
|
1525
|
-
<button class="toolbar-btn reset-layout-btn twm-has-tooltip" data-tooltip="Reset to default layout">
|
|
1526
|
-
<span class="material-symbols-outlined">restart_alt</span>
|
|
1527
|
-
<span class="btn-text">Reset</span>
|
|
1528
|
-
</button>
|
|
1529
|
-
<button class="toolbar-btn export-btn twm-has-tooltip" data-tooltip="Export report">
|
|
1530
|
-
<span class="material-symbols-outlined">download</span>
|
|
1531
|
-
<span class="btn-text">Export</span>
|
|
1532
|
-
</button>
|
|
1533
|
-
</div>
|
|
1534
|
-
`;
|
|
1535
|
-
const addBtn = toolbar.querySelector(".add-widget-btn");
|
|
1536
|
-
const catalog = getWidgetCatalog();
|
|
1537
|
-
this._addWidgetDropdown = new ActionDropdown({
|
|
1538
|
-
trigger: addBtn,
|
|
1539
|
-
options: catalog.map((w) => ({ type: w.type, label: w.title, icon: w.icon, description: w.description })),
|
|
1540
|
-
onSelect: (opt) => this._addWidgetFromDropdown(opt.type),
|
|
1541
|
-
className: "add-widget-dropdown-menu"
|
|
1542
|
-
});
|
|
1543
|
-
toolbar.querySelector(".reset-layout-btn")?.addEventListener("click", () => this._resetLayout());
|
|
1544
|
-
toolbar.querySelector(".export-btn")?.addEventListener("click", () => this._exportReport());
|
|
1545
|
-
this.container.appendChild(toolbar);
|
|
1546
|
-
}
|
|
1547
|
-
/**
|
|
1548
|
-
* Add a widget from the dropdown selection.
|
|
1549
|
-
* @param {string} type - Widget type
|
|
1550
|
-
* @private
|
|
1551
|
-
*/
|
|
1552
|
-
_addWidgetFromDropdown(type) {
|
|
1553
|
-
import("./tile_registry-6WZPTUZV.js").then(({ getWidget: getWidget2 }) => {
|
|
1554
|
-
const WidgetClass = getWidget2(type);
|
|
1555
|
-
if (WidgetClass) {
|
|
1556
|
-
const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
|
|
1557
|
-
const pos = this.findNextPosition(size.w, size.h);
|
|
1558
|
-
this.addTile(type, { ...pos, ...size });
|
|
1559
|
-
}
|
|
1560
|
-
});
|
|
1561
|
-
}
|
|
1562
|
-
/**
|
|
1563
|
-
* Add a tile to the grid.
|
|
1564
|
-
* @param {string} type - Widget type
|
|
1565
|
-
* @param {Object} position - Grid position {x, y, w, h}
|
|
1566
|
-
* @param {Object} [config] - Widget configuration
|
|
1567
|
-
* @param {string} [id] - Optional tile ID (generated if not provided)
|
|
1568
|
-
* @returns {import('./tile_base.js').TileBase | null} The created tile or null
|
|
1569
|
-
*/
|
|
1570
|
-
addTile(type, position, config = {}, id = null) {
|
|
1571
|
-
const tileId = id || `tile-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
1572
|
-
const tile = createWidget(type, {
|
|
1573
|
-
id: tileId,
|
|
1574
|
-
grid: this,
|
|
1575
|
-
eventBus: this.eventBus,
|
|
1576
|
-
config,
|
|
1577
|
-
readonly: this.readonly,
|
|
1578
|
-
host: this.host,
|
|
1579
|
-
stateGuard: this.stateGuard,
|
|
1580
|
-
dataSource: this.dataSource
|
|
1581
|
-
});
|
|
1582
|
-
if (!tile) return null;
|
|
1583
|
-
const layoutEntry = {
|
|
1584
|
-
id: tileId,
|
|
1585
|
-
type,
|
|
1586
|
-
x: position.x,
|
|
1587
|
-
y: position.y,
|
|
1588
|
-
w: position.w,
|
|
1589
|
-
h: position.h,
|
|
1590
|
-
config
|
|
1591
|
-
};
|
|
1592
|
-
this.layout.push(layoutEntry);
|
|
1593
|
-
this.tiles.set(tileId, tile);
|
|
1594
|
-
tile.mount(this.gridElement);
|
|
1595
|
-
this._applyTilePosition(tile, layoutEntry);
|
|
1596
|
-
this._setupTileDragHandlers(tile);
|
|
1597
|
-
if (this.data) {
|
|
1598
|
-
tile.fullData = this.fullData;
|
|
1599
|
-
tile.update(this.data);
|
|
1600
|
-
} else if (this.dataSource) {
|
|
1601
|
-
tile.loadData?.();
|
|
1602
|
-
}
|
|
1603
|
-
this._emitLayoutChanged();
|
|
1604
|
-
return tile;
|
|
1605
|
-
}
|
|
1606
|
-
/**
|
|
1607
|
-
* Remove a tile from the grid.
|
|
1608
|
-
* @param {string} tileId - Tile ID to remove
|
|
1609
|
-
*/
|
|
1610
|
-
removeTile(tileId) {
|
|
1611
|
-
const tile = this.tiles.get(tileId);
|
|
1612
|
-
if (!tile) return;
|
|
1613
|
-
tile.dispose();
|
|
1614
|
-
this.tiles.delete(tileId);
|
|
1615
|
-
this.layout = this.layout.filter((l) => l.id !== tileId);
|
|
1616
|
-
this._emitLayoutChanged();
|
|
1617
|
-
}
|
|
1618
|
-
/**
|
|
1619
|
-
* Update all tiles with new data.
|
|
1620
|
-
* @param {Object} data - Analytics data
|
|
1621
|
-
*/
|
|
1622
|
-
setData(data) {
|
|
1623
|
-
this.data = data;
|
|
1624
|
-
this.tiles.forEach((tile) => {
|
|
1625
|
-
tile.fullData = this.fullData;
|
|
1626
|
-
tile.update(data);
|
|
1627
|
-
});
|
|
1628
|
-
}
|
|
1629
|
-
/**
|
|
1630
|
-
* Ask every self-fetching tile to load again, bypassing whatever the data
|
|
1631
|
-
* source has cached.
|
|
1632
|
-
*
|
|
1633
|
-
* The counterpart of `setData` for the pull model: `setData` is how a caller
|
|
1634
|
-
* with one dataset for the whole board pushes it, and this is how a caller
|
|
1635
|
-
* whose tiles each own a query says "that underlying data changed". Passing
|
|
1636
|
-
* `ids` narrows it to the tiles a change actually touched, which is what an
|
|
1637
|
-
* invalidation carrying a table id can work out and a broadcast cannot.
|
|
1638
|
-
*
|
|
1639
|
-
* Not awaited and returns nothing: the tiles render themselves as their own
|
|
1640
|
-
* requests land, and a caller that waited for all of them would be waiting on
|
|
1641
|
-
* the slowest tile to show the fastest one.
|
|
1642
|
-
*
|
|
1643
|
-
* @param {string[]|Set<string>|null} [ids] - Tile ids, or null for all.
|
|
1644
|
-
*/
|
|
1645
|
-
reloadAll(ids = null) {
|
|
1646
|
-
if (!this.dataSource) return;
|
|
1647
|
-
const wanted = ids ? new Set(ids) : null;
|
|
1648
|
-
this.tiles.forEach((tile, tileId) => {
|
|
1649
|
-
if (wanted && !wanted.has(tileId)) return;
|
|
1650
|
-
tile.loadData?.({ force: true });
|
|
1651
|
-
});
|
|
1652
|
-
}
|
|
1653
|
-
/**
|
|
1654
|
-
* Get current layout.
|
|
1655
|
-
* @returns {Array} Layout array
|
|
1656
|
-
*/
|
|
1657
|
-
getLayout() {
|
|
1658
|
-
return this.layout.map((l) => ({
|
|
1659
|
-
id: l.id,
|
|
1660
|
-
type: l.type,
|
|
1661
|
-
x: l.x,
|
|
1662
|
-
y: l.y,
|
|
1663
|
-
w: l.w,
|
|
1664
|
-
h: l.h,
|
|
1665
|
-
config: this.tiles.get(l.id)?.config || l.config
|
|
1666
|
-
}));
|
|
1667
|
-
}
|
|
1668
|
-
/**
|
|
1669
|
-
* Set layout and rebuild tiles.
|
|
1670
|
-
* @param {Array|Object} layout - Layout array or object with tiles property
|
|
1671
|
-
*/
|
|
1672
|
-
setLayout(layout) {
|
|
1673
|
-
this.tiles.forEach((tile) => tile.dispose());
|
|
1674
|
-
this.tiles.clear();
|
|
1675
|
-
this.layout = [];
|
|
1676
|
-
this.gridElement.innerHTML = "";
|
|
1677
|
-
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
1678
|
-
tilesArray.forEach((item) => {
|
|
1679
|
-
this.addTile(item.type, {
|
|
1680
|
-
x: item.x,
|
|
1681
|
-
y: item.y,
|
|
1682
|
-
w: item.w,
|
|
1683
|
-
h: item.h
|
|
1684
|
-
}, item.config, item.id);
|
|
1685
|
-
});
|
|
1686
|
-
}
|
|
1687
|
-
/**
|
|
1688
|
-
* Apply CSS grid position to a tile element.
|
|
1689
|
-
* @param {import('./tile_base.js').TileBase} tile
|
|
1690
|
-
* @param {{x: number, y: number, w: number, h: number}} position
|
|
1691
|
-
* @private
|
|
1692
|
-
*/
|
|
1693
|
-
_applyTilePosition(tile, position) {
|
|
1694
|
-
if (!tile.element) return;
|
|
1695
|
-
tile.element.style.gridColumn = `${position.x + 1} / span ${position.w}`;
|
|
1696
|
-
tile.element.style.gridRow = `${position.y + 1} / span ${position.h}`;
|
|
1697
|
-
}
|
|
1698
|
-
/**
|
|
1699
|
-
* Setup drag and resize handlers for a tile.
|
|
1700
|
-
* @param {import('./tile_base.js').TileBase} tile
|
|
1701
|
-
* @private
|
|
1702
|
-
*/
|
|
1703
|
-
_setupTileDragHandlers(tile) {
|
|
1704
|
-
if (!tile.element || this.readonly) return;
|
|
1705
|
-
const dragHandle = tile.element.querySelector(".tile-drag-handle");
|
|
1706
|
-
const resizeHandle = tile.element.querySelector(".tile-resize-handle");
|
|
1707
|
-
if (dragHandle) {
|
|
1708
|
-
dragHandle.addEventListener("pointerdown", (e) => this._onDragStart(tile, e));
|
|
1709
|
-
}
|
|
1710
|
-
if (resizeHandle) {
|
|
1711
|
-
resizeHandle.addEventListener("pointerdown", (e) => this._onResizeStart(tile, e));
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
/**
|
|
1715
|
-
* Handle drag start.
|
|
1716
|
-
* @param {import('./tile_base.js').TileBase} tile
|
|
1717
|
-
* @param {PointerEvent} e
|
|
1718
|
-
* @private
|
|
1719
|
-
*/
|
|
1720
|
-
_onDragStart(tile, e) {
|
|
1721
|
-
e.preventDefault();
|
|
1722
|
-
e.stopPropagation();
|
|
1723
|
-
const rect = tile.element.getBoundingClientRect();
|
|
1724
|
-
const gridRect = this.gridElement.getBoundingClientRect();
|
|
1725
|
-
const layoutItem = this.layout.find((l) => l.id === tile.id);
|
|
1726
|
-
if (!layoutItem) return;
|
|
1727
|
-
this._emitBeforeLayoutChange();
|
|
1728
|
-
this._dragState = {
|
|
1729
|
-
tile,
|
|
1730
|
-
tileId: tile.id,
|
|
1731
|
-
startX: e.clientX,
|
|
1732
|
-
startY: e.clientY,
|
|
1733
|
-
startGridX: layoutItem.x,
|
|
1734
|
-
startGridY: layoutItem.y,
|
|
1735
|
-
tileRect: rect,
|
|
1736
|
-
gridRect,
|
|
1737
|
-
w: layoutItem.w,
|
|
1738
|
-
h: layoutItem.h
|
|
1739
|
-
};
|
|
1740
|
-
tile.element.classList.add("dragging");
|
|
1741
|
-
this._createGhost(rect);
|
|
1742
|
-
}
|
|
1743
|
-
/**
|
|
1744
|
-
* Handle resize start.
|
|
1745
|
-
* @param {import('./tile_base.js').TileBase} tile
|
|
1746
|
-
* @param {PointerEvent} e
|
|
1747
|
-
* @private
|
|
1748
|
-
*/
|
|
1749
|
-
_onResizeStart(tile, e) {
|
|
1750
|
-
e.preventDefault();
|
|
1751
|
-
e.stopPropagation();
|
|
1752
|
-
const layoutItem = this.layout.find((l) => l.id === tile.id);
|
|
1753
|
-
if (!layoutItem) return;
|
|
1754
|
-
const gridRect = this.gridElement.getBoundingClientRect();
|
|
1755
|
-
const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
|
|
1756
|
-
this._emitBeforeLayoutChange();
|
|
1757
|
-
this._resizeState = {
|
|
1758
|
-
tile,
|
|
1759
|
-
tileId: tile.id,
|
|
1760
|
-
startX: e.clientX,
|
|
1761
|
-
startY: e.clientY,
|
|
1762
|
-
startW: layoutItem.w,
|
|
1763
|
-
startH: layoutItem.h,
|
|
1764
|
-
gridRect,
|
|
1765
|
-
cellWidth,
|
|
1766
|
-
x: layoutItem.x,
|
|
1767
|
-
y: layoutItem.y
|
|
1768
|
-
};
|
|
1769
|
-
tile.element.classList.add("resizing");
|
|
1770
|
-
}
|
|
1771
|
-
/**
|
|
1772
|
-
* Handle pointer move (drag/resize).
|
|
1773
|
-
* @param {PointerEvent} e
|
|
1774
|
-
* @private
|
|
1775
|
-
*/
|
|
1776
|
-
_onPointerMove(e) {
|
|
1777
|
-
if (this._dragState) {
|
|
1778
|
-
this._onDragMove(e);
|
|
1779
|
-
} else if (this._resizeState) {
|
|
1780
|
-
this._onResizeMove(e);
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
/**
|
|
1784
|
-
* Handle drag move.
|
|
1785
|
-
* @param {PointerEvent} e
|
|
1786
|
-
* @private
|
|
1787
|
-
*/
|
|
1788
|
-
_onDragMove(e) {
|
|
1789
|
-
const state = this._dragState;
|
|
1790
|
-
if (!state) return;
|
|
1791
|
-
const dx = e.clientX - state.startX;
|
|
1792
|
-
const dy = e.clientY - state.startY;
|
|
1793
|
-
const gridRect = state.gridRect;
|
|
1794
|
-
const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
|
|
1795
|
-
const cellHeight = this.rowHeight;
|
|
1796
|
-
const deltaGridX = Math.round(dx / (cellWidth + this.gap));
|
|
1797
|
-
const deltaGridY = Math.round(dy / (cellHeight + this.gap));
|
|
1798
|
-
let newX = state.startGridX + deltaGridX;
|
|
1799
|
-
let newY = state.startGridY + deltaGridY;
|
|
1800
|
-
newX = Math.max(0, Math.min(newX, this.columns - state.w));
|
|
1801
|
-
newY = Math.max(0, newY);
|
|
1802
|
-
if (this._ghostElement) {
|
|
1803
|
-
const ghostLeft = newX * (cellWidth + this.gap);
|
|
1804
|
-
const ghostTop = newY * (cellHeight + this.gap);
|
|
1805
|
-
this._ghostElement.style.transform = `translate(${ghostLeft}px, ${ghostTop}px)`;
|
|
1806
|
-
this._ghostElement.style.width = `${state.w * cellWidth + (state.w - 1) * this.gap}px`;
|
|
1807
|
-
this._ghostElement.style.height = `${state.h * cellHeight + (state.h - 1) * this.gap}px`;
|
|
1808
|
-
}
|
|
1809
|
-
state.pendingX = newX;
|
|
1810
|
-
state.pendingY = newY;
|
|
1811
|
-
}
|
|
1812
|
-
/**
|
|
1813
|
-
* Handle resize move.
|
|
1814
|
-
* @param {PointerEvent} e
|
|
1815
|
-
* @private
|
|
1816
|
-
*/
|
|
1817
|
-
_onResizeMove(e) {
|
|
1818
|
-
const state = this._resizeState;
|
|
1819
|
-
if (!state) return;
|
|
1820
|
-
const dx = e.clientX - state.startX;
|
|
1821
|
-
const dy = e.clientY - state.startY;
|
|
1822
|
-
const deltaW = Math.round(dx / (state.cellWidth + this.gap));
|
|
1823
|
-
const deltaH = Math.round(dy / (this.rowHeight + this.gap));
|
|
1824
|
-
let newW = state.startW + deltaW;
|
|
1825
|
-
let newH = state.startH + deltaH;
|
|
1826
|
-
const tileClass = state.tile.constructor;
|
|
1827
|
-
const constraints = tileClass.SIZE_CONSTRAINTS || { minW: 2, minH: 2, maxW: 12, maxH: 8 };
|
|
1828
|
-
newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
|
|
1829
|
-
newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
|
|
1830
|
-
newW = Math.min(newW, this.columns - state.x);
|
|
1831
|
-
state.tile.element.style.gridColumn = `${state.x + 1} / span ${newW}`;
|
|
1832
|
-
state.tile.element.style.gridRow = `${state.y + 1} / span ${newH}`;
|
|
1833
|
-
state.pendingW = newW;
|
|
1834
|
-
state.pendingH = newH;
|
|
1835
|
-
}
|
|
1836
|
-
/**
|
|
1837
|
-
* Handle pointer up (end drag/resize).
|
|
1838
|
-
* @param {PointerEvent} e
|
|
1839
|
-
* @private
|
|
1840
|
-
*/
|
|
1841
|
-
_onPointerUp(e) {
|
|
1842
|
-
if (this._dragState) {
|
|
1843
|
-
this._onDragEnd();
|
|
1844
|
-
} else if (this._resizeState) {
|
|
1845
|
-
this._onResizeEnd();
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
/**
|
|
1849
|
-
* Handle drag end.
|
|
1850
|
-
* @private
|
|
1851
|
-
*/
|
|
1852
|
-
_onDragEnd() {
|
|
1853
|
-
const state = this._dragState;
|
|
1854
|
-
if (!state) return;
|
|
1855
|
-
state.tile.element.classList.remove("dragging");
|
|
1856
|
-
this._removeGhost();
|
|
1857
|
-
if (state.pendingX !== void 0 && state.pendingY !== void 0) {
|
|
1858
|
-
const layoutItem = this.layout.find((l) => l.id === state.tileId);
|
|
1859
|
-
if (layoutItem) {
|
|
1860
|
-
layoutItem.x = state.pendingX;
|
|
1861
|
-
layoutItem.y = state.pendingY;
|
|
1862
|
-
this._applyTilePosition(state.tile, layoutItem);
|
|
1863
|
-
this._resolveOverlaps(state.tileId);
|
|
1864
|
-
this._emitLayoutChanged();
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
this._dragState = null;
|
|
1868
|
-
}
|
|
1869
|
-
/**
|
|
1870
|
-
* Handle resize end.
|
|
1871
|
-
* @private
|
|
1872
|
-
*/
|
|
1873
|
-
_onResizeEnd() {
|
|
1874
|
-
const state = this._resizeState;
|
|
1875
|
-
if (!state) return;
|
|
1876
|
-
state.tile.element.classList.remove("resizing");
|
|
1877
|
-
if (state.pendingW !== void 0 && state.pendingH !== void 0) {
|
|
1878
|
-
const layoutItem = this.layout.find((l) => l.id === state.tileId);
|
|
1879
|
-
if (layoutItem) {
|
|
1880
|
-
layoutItem.w = state.pendingW;
|
|
1881
|
-
layoutItem.h = state.pendingH;
|
|
1882
|
-
this._resolveOverlaps(state.tileId);
|
|
1883
|
-
this._emitLayoutChanged();
|
|
1884
|
-
if (this.data) {
|
|
1885
|
-
state.tile.fullData = this.fullData;
|
|
1886
|
-
state.tile.update(this.data);
|
|
1887
|
-
} else if (this.dataSource) {
|
|
1888
|
-
state.tile.onResize?.();
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
this._resizeState = null;
|
|
1893
|
-
}
|
|
1894
|
-
/**
|
|
1895
|
-
* Check if two layout items overlap.
|
|
1896
|
-
* @param {Object} a - First layout item {x, y, w, h}
|
|
1897
|
-
* @param {Object} b - Second layout item {x, y, w, h}
|
|
1898
|
-
* @returns {boolean}
|
|
1899
|
-
* @private
|
|
1900
|
-
*/
|
|
1901
|
-
_tilesOverlap(a, b) {
|
|
1902
|
-
return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
|
|
1903
|
-
}
|
|
1904
|
-
/**
|
|
1905
|
-
* Resolve overlaps by pushing tiles down.
|
|
1906
|
-
* @param {string} sourceTileId - The tile that was resized/moved
|
|
1907
|
-
* @private
|
|
1908
|
-
*/
|
|
1909
|
-
_resolveOverlaps(sourceTileId) {
|
|
1910
|
-
const sourceItem = this.layout.find((l) => l.id === sourceTileId);
|
|
1911
|
-
if (!sourceItem) return;
|
|
1912
|
-
const otherTiles = this.layout.filter((l) => l.id !== sourceTileId).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1913
|
-
let hasChanges = true;
|
|
1914
|
-
let iterations = 0;
|
|
1915
|
-
const maxIterations = 100;
|
|
1916
|
-
while (hasChanges && iterations < maxIterations) {
|
|
1917
|
-
hasChanges = false;
|
|
1918
|
-
iterations++;
|
|
1919
|
-
for (const item of otherTiles) {
|
|
1920
|
-
if (this._tilesOverlap(sourceItem, item)) {
|
|
1921
|
-
const newY = sourceItem.y + sourceItem.h;
|
|
1922
|
-
if (item.y !== newY) {
|
|
1923
|
-
item.y = newY;
|
|
1924
|
-
hasChanges = true;
|
|
1925
|
-
const tile = this.tiles.get(item.id);
|
|
1926
|
-
if (tile) {
|
|
1927
|
-
this._applyTilePosition(tile, item);
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
for (let i = 0; i < otherTiles.length; i++) {
|
|
1933
|
-
for (let j = i + 1; j < otherTiles.length; j++) {
|
|
1934
|
-
if (this._tilesOverlap(otherTiles[i], otherTiles[j])) {
|
|
1935
|
-
const pusher = otherTiles[i];
|
|
1936
|
-
const pushed = otherTiles[j];
|
|
1937
|
-
const newY = pusher.y + pusher.h;
|
|
1938
|
-
if (pushed.y < newY && this._tilesOverlap(pusher, pushed)) {
|
|
1939
|
-
pushed.y = newY;
|
|
1940
|
-
hasChanges = true;
|
|
1941
|
-
const tile = this.tiles.get(pushed.id);
|
|
1942
|
-
if (tile) {
|
|
1943
|
-
this._applyTilePosition(tile, pushed);
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
this._compactLayout();
|
|
1951
|
-
}
|
|
1952
|
-
/**
|
|
1953
|
-
* Compact the layout by removing vertical gaps.
|
|
1954
|
-
* @private
|
|
1955
|
-
*/
|
|
1956
|
-
_compactLayout() {
|
|
1957
|
-
const sortedTiles = [...this.layout].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1958
|
-
for (const item of sortedTiles) {
|
|
1959
|
-
let newY = 0;
|
|
1960
|
-
while (newY < item.y) {
|
|
1961
|
-
const testPosition = { ...item, y: newY };
|
|
1962
|
-
let hasOverlap = false;
|
|
1963
|
-
for (const other of this.layout) {
|
|
1964
|
-
if (other.id === item.id) continue;
|
|
1965
|
-
if (this._tilesOverlap(testPosition, other)) {
|
|
1966
|
-
hasOverlap = true;
|
|
1967
|
-
newY = other.y + other.h;
|
|
1968
|
-
break;
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
if (!hasOverlap) {
|
|
1972
|
-
break;
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
if (newY < item.y) {
|
|
1976
|
-
item.y = newY;
|
|
1977
|
-
const tile = this.tiles.get(item.id);
|
|
1978
|
-
if (tile) {
|
|
1979
|
-
this._applyTilePosition(tile, item);
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
/**
|
|
1985
|
-
* Create ghost element for drag preview.
|
|
1986
|
-
* @param {DOMRect} rect - Original tile rect
|
|
1987
|
-
* @private
|
|
1988
|
-
*/
|
|
1989
|
-
_createGhost(rect) {
|
|
1990
|
-
this._ghostElement = document.createElement("div");
|
|
1991
|
-
this._ghostElement.className = "tile-ghost";
|
|
1992
|
-
this.gridElement.appendChild(this._ghostElement);
|
|
1993
|
-
}
|
|
1994
|
-
/**
|
|
1995
|
-
* Remove ghost element.
|
|
1996
|
-
* @private
|
|
1997
|
-
*/
|
|
1998
|
-
_removeGhost() {
|
|
1999
|
-
if (this._ghostElement) {
|
|
2000
|
-
this._ghostElement.remove();
|
|
2001
|
-
this._ghostElement = null;
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
/**
|
|
2005
|
-
* Find next available position for a new tile.
|
|
2006
|
-
* @param {number} w - Width in cells
|
|
2007
|
-
* @param {number} h - Height in cells
|
|
2008
|
-
* @returns {{x: number, y: number}}
|
|
2009
|
-
*/
|
|
2010
|
-
findNextPosition(w, h) {
|
|
2011
|
-
const maxY = Math.max(...this.layout.map((l) => l.y + l.h), 0);
|
|
2012
|
-
const occupied = /* @__PURE__ */ new Set();
|
|
2013
|
-
this.layout.forEach((l) => {
|
|
2014
|
-
for (let x = l.x; x < l.x + l.w; x++) {
|
|
2015
|
-
for (let y = l.y; y < l.y + l.h; y++) {
|
|
2016
|
-
occupied.add(`${x},${y}`);
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
});
|
|
2020
|
-
for (let y = 0; y <= maxY + 1; y++) {
|
|
2021
|
-
for (let x = 0; x <= this.columns - w; x++) {
|
|
2022
|
-
let fits = true;
|
|
2023
|
-
for (let dx = 0; dx < w && fits; dx++) {
|
|
2024
|
-
for (let dy = 0; dy < h && fits; dy++) {
|
|
2025
|
-
if (occupied.has(`${x + dx},${y + dy}`)) {
|
|
2026
|
-
fits = false;
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
|
-
if (fits) {
|
|
2031
|
-
return { x, y };
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
return { x: 0, y: maxY + 1 };
|
|
2036
|
-
}
|
|
2037
|
-
/**
|
|
2038
|
-
* Handle tile config request.
|
|
2039
|
-
* @param {CustomEvent} e
|
|
2040
|
-
* @private
|
|
2041
|
-
*/
|
|
2042
|
-
_onTileConfigRequest(e) {
|
|
2043
|
-
const { tileId, tileType, config, schema } = e.detail;
|
|
2044
|
-
this.eventBus?.emit?.("grid:config-modal-show", {
|
|
2045
|
-
tileId,
|
|
2046
|
-
tileType,
|
|
2047
|
-
config,
|
|
2048
|
-
schema,
|
|
2049
|
-
variables: this.data ? this._getVariableList() : [],
|
|
2050
|
-
onSave: (newConfig) => {
|
|
2051
|
-
const tile = this.tiles.get(tileId);
|
|
2052
|
-
if (tile) {
|
|
2053
|
-
tile.fullData = this.fullData;
|
|
2054
|
-
tile.update(this.data, newConfig);
|
|
2055
|
-
if (this.dataSource) tile.loadData?.({ force: true });
|
|
2056
|
-
const layoutItem = this.layout.find((l) => l.id === tileId);
|
|
2057
|
-
if (layoutItem) {
|
|
2058
|
-
layoutItem.config = newConfig;
|
|
2059
|
-
}
|
|
2060
|
-
this._emitLayoutChanged();
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
});
|
|
2064
|
-
}
|
|
2065
|
-
/**
|
|
2066
|
-
* Handle tile remove request.
|
|
2067
|
-
* Note: Let the event bubble up to the dashboard which handles confirmation.
|
|
2068
|
-
* @param {CustomEvent} e
|
|
2069
|
-
* @private
|
|
2070
|
-
*/
|
|
2071
|
-
_onTileRemoveRequest(e) {
|
|
2072
|
-
}
|
|
2073
|
-
/**
|
|
2074
|
-
* Get list of available variables from data.
|
|
2075
|
-
* @returns {Array<{value: string, label: string}>}
|
|
2076
|
-
* @private
|
|
2077
|
-
*/
|
|
2078
|
-
_getVariableList() {
|
|
2079
|
-
if (!this.data) return [];
|
|
2080
|
-
const variables = [];
|
|
2081
|
-
if (this.data.stocks) {
|
|
2082
|
-
Object.keys(this.data.stocks).forEach((name) => {
|
|
2083
|
-
variables.push({ value: name, label: name });
|
|
2084
|
-
});
|
|
2085
|
-
}
|
|
2086
|
-
if (this.data.indicators) {
|
|
2087
|
-
Object.keys(this.data.indicators).forEach((name) => {
|
|
2088
|
-
variables.push({ value: name, label: name });
|
|
2089
|
-
});
|
|
2090
|
-
}
|
|
2091
|
-
return variables;
|
|
2092
|
-
}
|
|
2093
|
-
/**
|
|
2094
|
-
* Show add widget menu.
|
|
2095
|
-
* @private
|
|
2096
|
-
*/
|
|
2097
|
-
_showAddWidgetMenu() {
|
|
2098
|
-
this.eventBus?.emit?.("grid:add-widget-menu-show", {
|
|
2099
|
-
onSelect: (type) => {
|
|
2100
|
-
const { getWidget: getWidget2 } = (init_tile_registry(), __toCommonJS(tile_registry_exports));
|
|
2101
|
-
const WidgetClass = getWidget2(type);
|
|
2102
|
-
if (WidgetClass) {
|
|
2103
|
-
const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
|
|
2104
|
-
const pos = this.findNextPosition(size.w, size.h);
|
|
2105
|
-
this.addTile(type, { ...pos, ...size });
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
});
|
|
2109
|
-
}
|
|
2110
|
-
/**
|
|
2111
|
-
* Reset to default layout.
|
|
2112
|
-
* @private
|
|
2113
|
-
*/
|
|
2114
|
-
_resetLayout() {
|
|
2115
|
-
this.eventBus?.emit?.("grid:reset-layout-confirm", {
|
|
2116
|
-
onConfirm: () => {
|
|
2117
|
-
this.eventBus?.emit?.("grid:layout-reset-requested");
|
|
2118
|
-
}
|
|
2119
|
-
});
|
|
2120
|
-
}
|
|
2121
|
-
/**
|
|
2122
|
-
* Export report.
|
|
2123
|
-
* @private
|
|
2124
|
-
*/
|
|
2125
|
-
_exportReport() {
|
|
2126
|
-
this.eventBus?.emit?.("grid:export-requested", {
|
|
2127
|
-
gridElement: this.gridElement,
|
|
2128
|
-
layout: this.getLayout()
|
|
2129
|
-
});
|
|
2130
|
-
}
|
|
2131
|
-
/**
|
|
2132
|
-
* Notify that a layout mutation is about to begin (drag/resize start).
|
|
2133
|
-
* @private
|
|
2134
|
-
*/
|
|
2135
|
-
_emitBeforeLayoutChange() {
|
|
2136
|
-
if (typeof this._onBeforeLayoutChangeCallback === "function") {
|
|
2137
|
-
this._onBeforeLayoutChangeCallback(this.getLayout());
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
/**
|
|
2141
|
-
* Emit layout changed event and call the onLayoutChange callback.
|
|
2142
|
-
* @private
|
|
2143
|
-
*/
|
|
2144
|
-
_emitLayoutChanged() {
|
|
2145
|
-
const layout = this.getLayout();
|
|
2146
|
-
this.eventBus?.emit?.("grid:layout-changed", { layout });
|
|
2147
|
-
if (typeof this._onLayoutChangeCallback === "function") {
|
|
2148
|
-
this._onLayoutChangeCallback(layout);
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
/**
|
|
2152
|
-
* Clean up resources.
|
|
2153
|
-
*/
|
|
2154
|
-
dispose() {
|
|
2155
|
-
document.removeEventListener("pointermove", this._boundOnPointerMove);
|
|
2156
|
-
document.removeEventListener("pointerup", this._boundOnPointerUp);
|
|
2157
|
-
this._addWidgetDropdown?.destroy();
|
|
2158
|
-
this._addWidgetDropdown = null;
|
|
2159
|
-
this.tiles.forEach((tile) => tile.dispose());
|
|
2160
|
-
this.tiles.clear();
|
|
2161
|
-
this.layout = [];
|
|
2162
|
-
if (this.gridElement) {
|
|
2163
|
-
this.gridElement.remove();
|
|
2164
|
-
this.gridElement = null;
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
};
|
|
2168
|
-
|
|
2169
|
-
// tiles.js
|
|
2170
|
-
init_tile_registry();
|
|
2171
|
-
export {
|
|
2172
|
-
FieldTypes,
|
|
2173
|
-
LayoutPersistence,
|
|
2174
|
-
TileBase,
|
|
2175
|
-
TileGrid,
|
|
2176
|
-
TileRegistry,
|
|
2177
|
-
configureLayoutPersistence,
|
|
2178
|
-
createWidget,
|
|
2179
|
-
formatVariableLabel,
|
|
2180
|
-
getAllWidgetTypes,
|
|
2181
|
-
getFieldOptions,
|
|
2182
|
-
getLayoutPersistence,
|
|
2183
|
-
getNamespaceOptions,
|
|
2184
|
-
getVariableOptions,
|
|
2185
|
-
getWidget,
|
|
2186
|
-
getWidgetCatalog,
|
|
2187
|
-
getWidgetMetadata,
|
|
2188
|
-
hasWidget,
|
|
2189
|
-
mergeWithDefaults,
|
|
2190
|
-
registerWidget,
|
|
2191
|
-
setTileLogger,
|
|
2192
|
-
validateConfig,
|
|
2193
|
-
validateField
|
|
2194
|
-
};
|
|
2195
|
-
//# sourceMappingURL=tiles.js.map
|
|
1
|
+
import {
|
|
2
|
+
ActionDropdown
|
|
3
|
+
} from "./chunk-O5OHMWBB.js";
|
|
4
|
+
import {
|
|
5
|
+
openRawTracesWindow
|
|
6
|
+
} from "./chunk-F5FYDCOA.js";
|
|
7
|
+
import "./chunk-WMGOJ3I6.js";
|
|
8
|
+
import "./chunk-LH5TSOZW.js";
|
|
9
|
+
import "./chunk-FL5KFNQH.js";
|
|
10
|
+
import {
|
|
11
|
+
TileRegistry,
|
|
12
|
+
createWidget,
|
|
13
|
+
getAllWidgetTypes,
|
|
14
|
+
getWidget,
|
|
15
|
+
getWidgetCatalog,
|
|
16
|
+
getWidgetMetadata,
|
|
17
|
+
hasWidget,
|
|
18
|
+
init_tile_registry,
|
|
19
|
+
registerWidget,
|
|
20
|
+
setTileLogger,
|
|
21
|
+
tile_registry_exports
|
|
22
|
+
} from "./chunk-FOOS3T5L.js";
|
|
23
|
+
import {
|
|
24
|
+
__toCommonJS
|
|
25
|
+
} from "./chunk-JYWURG5T.js";
|
|
26
|
+
|
|
27
|
+
// src/tiles/config_schema.js
|
|
28
|
+
var FieldTypes = {
|
|
29
|
+
TEXT: "text",
|
|
30
|
+
NUMBER: "number",
|
|
31
|
+
CHECKBOX: "checkbox",
|
|
32
|
+
SELECT: "select",
|
|
33
|
+
MULTISELECT: "multiselect",
|
|
34
|
+
COLOR: "color",
|
|
35
|
+
RANGE: "range"
|
|
36
|
+
};
|
|
37
|
+
function validateField(value, field) {
|
|
38
|
+
if (field.required && (value == null || value === "")) {
|
|
39
|
+
return { valid: false, error: `${field.label} is required` };
|
|
40
|
+
}
|
|
41
|
+
switch (field.type) {
|
|
42
|
+
case FieldTypes.NUMBER:
|
|
43
|
+
case FieldTypes.RANGE:
|
|
44
|
+
if (value != null && value !== "") {
|
|
45
|
+
const num = parseFloat(value);
|
|
46
|
+
if (isNaN(num)) {
|
|
47
|
+
return { valid: false, error: `${field.label} must be a number` };
|
|
48
|
+
}
|
|
49
|
+
if (field.min != null && num < field.min) {
|
|
50
|
+
return { valid: false, error: `${field.label} must be at least ${field.min}` };
|
|
51
|
+
}
|
|
52
|
+
if (field.max != null && num > field.max) {
|
|
53
|
+
return { valid: false, error: `${field.label} must be at most ${field.max}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
case FieldTypes.SELECT:
|
|
58
|
+
if (field.required && !value) {
|
|
59
|
+
return { valid: false, error: `${field.label} must be selected` };
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case FieldTypes.MULTISELECT:
|
|
63
|
+
if (field.required && (!Array.isArray(value) || value.length === 0)) {
|
|
64
|
+
return { valid: false, error: `${field.label} must have at least one selection` };
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
return { valid: true };
|
|
69
|
+
}
|
|
70
|
+
function validateConfig(config, schema) {
|
|
71
|
+
const errors = {};
|
|
72
|
+
let valid = true;
|
|
73
|
+
if (!schema?.fields) {
|
|
74
|
+
return { valid: true, errors: {} };
|
|
75
|
+
}
|
|
76
|
+
schema.fields.forEach((field) => {
|
|
77
|
+
const result = validateField(config[field.key], field);
|
|
78
|
+
if (!result.valid) {
|
|
79
|
+
errors[field.key] = result.error;
|
|
80
|
+
valid = false;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return { valid, errors };
|
|
84
|
+
}
|
|
85
|
+
function mergeWithDefaults(config, schema) {
|
|
86
|
+
const result = { ...config };
|
|
87
|
+
if (schema?.fields) {
|
|
88
|
+
schema.fields.forEach((field) => {
|
|
89
|
+
if (result[field.key] === void 0 && field.default !== void 0) {
|
|
90
|
+
result[field.key] = field.default;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
function getFieldOptions(field, data) {
|
|
97
|
+
if (Array.isArray(field.options)) {
|
|
98
|
+
return field.options;
|
|
99
|
+
}
|
|
100
|
+
if (field.options === "variables") {
|
|
101
|
+
return getVariableOptions(data);
|
|
102
|
+
}
|
|
103
|
+
if (field.options === "namespaces") {
|
|
104
|
+
return getNamespaceOptions(data);
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
function formatVariableLabel(name) {
|
|
109
|
+
if (!name) return "";
|
|
110
|
+
let label = name;
|
|
111
|
+
const dotIdx = label.indexOf(".");
|
|
112
|
+
if (dotIdx > 0) {
|
|
113
|
+
label = label.slice(dotIdx + 1);
|
|
114
|
+
}
|
|
115
|
+
label = label.replace(/^__\w+__::/, "");
|
|
116
|
+
label = label.replace(/\[State\]$/i, "");
|
|
117
|
+
label = label.replace(/\[Rate\]$/i, "");
|
|
118
|
+
label = label.replace(/\[\]$/, "");
|
|
119
|
+
label = label.replace(/::/g, " / ");
|
|
120
|
+
label = label.replace(/:/g, "_");
|
|
121
|
+
return label.replace(/\s{2,}/g, " ").trim();
|
|
122
|
+
}
|
|
123
|
+
function getVariableOptions(data) {
|
|
124
|
+
const options = [];
|
|
125
|
+
if (!data) return options;
|
|
126
|
+
if (data.stocks) {
|
|
127
|
+
Object.keys(data.stocks).forEach((name) => {
|
|
128
|
+
options.push({
|
|
129
|
+
value: name,
|
|
130
|
+
label: formatVariableLabel(name),
|
|
131
|
+
category: "Stocks"
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (data.flows) {
|
|
136
|
+
Object.keys(data.flows).forEach((name) => {
|
|
137
|
+
options.push({
|
|
138
|
+
value: name,
|
|
139
|
+
label: formatVariableLabel(name),
|
|
140
|
+
category: "Flows"
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (data.indicators) {
|
|
145
|
+
Object.keys(data.indicators).forEach((name) => {
|
|
146
|
+
options.push({
|
|
147
|
+
value: name,
|
|
148
|
+
label: formatVariableLabel(name),
|
|
149
|
+
category: "Indicators"
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return options;
|
|
154
|
+
}
|
|
155
|
+
function getNamespaceOptions(data) {
|
|
156
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
157
|
+
const sources = [data?.stocks, data?.flows, data?.indicators];
|
|
158
|
+
for (const source of sources) {
|
|
159
|
+
if (!source) continue;
|
|
160
|
+
for (const key of Object.keys(source)) {
|
|
161
|
+
const dotIdx = key.indexOf(".");
|
|
162
|
+
if (dotIdx > 0) {
|
|
163
|
+
namespaces.add(key.slice(0, dotIdx));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (namespaces.size === 0) namespaces.add("Main");
|
|
168
|
+
return Array.from(namespaces).sort().map((ns) => ({ value: ns, label: ns }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/tiles/layout_persistence.js
|
|
172
|
+
var LAYOUT_VERSION = 3;
|
|
173
|
+
var DEFAULT_LAYOUTS_KEY = "twm.tiles.layouts";
|
|
174
|
+
var DEFAULT_CONFIGS_KEY = "twm.tiles.configs";
|
|
175
|
+
var LayoutPersistence = class {
|
|
176
|
+
constructor({
|
|
177
|
+
storage = typeof localStorage !== "undefined" ? localStorage : null,
|
|
178
|
+
layoutsKey = DEFAULT_LAYOUTS_KEY,
|
|
179
|
+
configsKey = DEFAULT_CONFIGS_KEY,
|
|
180
|
+
resolveTemplate = null,
|
|
181
|
+
logger = null
|
|
182
|
+
} = {}) {
|
|
183
|
+
this._cache = null;
|
|
184
|
+
this._storage = storage;
|
|
185
|
+
this._logger = logger;
|
|
186
|
+
this._layoutsKey = layoutsKey;
|
|
187
|
+
this._configsKey = configsKey;
|
|
188
|
+
this._resolveTemplate = typeof resolveTemplate === "function" ? resolveTemplate : null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get layout for a specific context.
|
|
192
|
+
* Falls back to template resolution when no saved layout exists.
|
|
193
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
194
|
+
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
195
|
+
* @returns {Object} Layout object with tiles array
|
|
196
|
+
*/
|
|
197
|
+
getLayout(contextId, context) {
|
|
198
|
+
const all = this._loadAll();
|
|
199
|
+
const layout = all[contextId];
|
|
200
|
+
if (layout && layout.version === LAYOUT_VERSION) {
|
|
201
|
+
return layout;
|
|
202
|
+
}
|
|
203
|
+
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
204
|
+
if (instantiated) {
|
|
205
|
+
return {
|
|
206
|
+
tiles: instantiated.tiles,
|
|
207
|
+
version: LAYOUT_VERSION,
|
|
208
|
+
templateId: instantiated.templateId,
|
|
209
|
+
templateModified: false
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return { tiles: [], version: LAYOUT_VERSION };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Save layout for a specific context.
|
|
216
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
217
|
+
* @param {Array|Object} layout - Layout array or object with tiles property
|
|
218
|
+
*/
|
|
219
|
+
saveLayout(contextId, layout) {
|
|
220
|
+
const all = this._loadAll();
|
|
221
|
+
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
222
|
+
all[contextId] = {
|
|
223
|
+
tiles: tilesArray,
|
|
224
|
+
version: LAYOUT_VERSION,
|
|
225
|
+
updatedAt: Date.now(),
|
|
226
|
+
templateId: layout?.templateId || all[contextId]?.templateId || null,
|
|
227
|
+
templateModified: layout?.templateModified ?? true
|
|
228
|
+
};
|
|
229
|
+
this._saveAll(all);
|
|
230
|
+
this._logger?.info?.(`[LayoutPersistence] Saved layout for context ${contextId}`);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Delete layout for a specific context.
|
|
234
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
235
|
+
*/
|
|
236
|
+
deleteLayout(contextId) {
|
|
237
|
+
const all = this._loadAll();
|
|
238
|
+
if (all[contextId]) {
|
|
239
|
+
delete all[contextId];
|
|
240
|
+
this._saveAll(all);
|
|
241
|
+
this._logger?.info?.(`[LayoutPersistence] Deleted layout for context ${contextId}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Reset a layout by re-resolving the best-matching template.
|
|
246
|
+
*
|
|
247
|
+
* With no `resolveTemplate` supplied, "reset" means "empty grid" — which is the
|
|
248
|
+
* honest answer for a library that has no templates. The previous code called
|
|
249
|
+
* `TemplateRegistry.instantiate()` unconditionally and read `.tiles` off the
|
|
250
|
+
* result, so a null resolver here would have been a TypeError.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
253
|
+
* @param {Object} [context] - Passed opaquely to `resolveTemplate`.
|
|
254
|
+
* @returns {Object} The resolved layout (empty if nothing resolves).
|
|
255
|
+
*/
|
|
256
|
+
resetLayout(contextId, context) {
|
|
257
|
+
const instantiated = this._resolveTemplate?.(context) ?? null;
|
|
258
|
+
const layout = {
|
|
259
|
+
tiles: instantiated?.tiles ?? [],
|
|
260
|
+
version: LAYOUT_VERSION,
|
|
261
|
+
templateId: instantiated?.templateId ?? null,
|
|
262
|
+
templateModified: false
|
|
263
|
+
};
|
|
264
|
+
this.saveLayout(contextId, layout);
|
|
265
|
+
return layout;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Check if a context has a saved layout.
|
|
269
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
270
|
+
* @returns {boolean}
|
|
271
|
+
*/
|
|
272
|
+
hasLayout(contextId) {
|
|
273
|
+
const all = this._loadAll();
|
|
274
|
+
return !!all[contextId];
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Get all context IDs with saved layouts.
|
|
278
|
+
* @returns {Array<string>}
|
|
279
|
+
*/
|
|
280
|
+
getAllContextIds() {
|
|
281
|
+
const all = this._loadAll();
|
|
282
|
+
return Object.keys(all);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Export all layouts as JSON string.
|
|
286
|
+
* @returns {string}
|
|
287
|
+
*/
|
|
288
|
+
exportAll() {
|
|
289
|
+
const all = this._loadAll();
|
|
290
|
+
return JSON.stringify(all, null, 2);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Import layouts from JSON string.
|
|
294
|
+
* @param {string} json - JSON string of layouts
|
|
295
|
+
* @param {boolean} [merge=false] - Whether to merge with existing or replace
|
|
296
|
+
*/
|
|
297
|
+
importAll(json, merge = false) {
|
|
298
|
+
try {
|
|
299
|
+
const imported = JSON.parse(json);
|
|
300
|
+
if (merge) {
|
|
301
|
+
const existing = this._loadAll();
|
|
302
|
+
this._saveAll({ ...existing, ...imported });
|
|
303
|
+
} else {
|
|
304
|
+
this._saveAll(imported);
|
|
305
|
+
}
|
|
306
|
+
this._cache = null;
|
|
307
|
+
this._logger?.info?.("[LayoutPersistence] Imported layouts");
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error("[LayoutPersistence] Failed to import layouts:", err);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Clear all saved layouts.
|
|
314
|
+
*/
|
|
315
|
+
clearAll() {
|
|
316
|
+
try {
|
|
317
|
+
this._storage?.removeItem(this._layoutsKey);
|
|
318
|
+
this._cache = null;
|
|
319
|
+
this._logger?.info?.("[LayoutPersistence] Cleared all layouts");
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error("[LayoutPersistence] Failed to clear layouts:", err);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Load all layouts from storage.
|
|
326
|
+
* @returns {Object}
|
|
327
|
+
* @private
|
|
328
|
+
*/
|
|
329
|
+
_loadAll() {
|
|
330
|
+
if (this._cache) {
|
|
331
|
+
return this._cache;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const raw = this._storage?.getItem(this._layoutsKey);
|
|
335
|
+
if (raw) {
|
|
336
|
+
this._cache = JSON.parse(raw);
|
|
337
|
+
return this._cache;
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error("[LayoutPersistence] Failed to load layouts:", err);
|
|
341
|
+
}
|
|
342
|
+
this._cache = {};
|
|
343
|
+
return this._cache;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Save all layouts to storage.
|
|
347
|
+
* @param {Object} data
|
|
348
|
+
* @private
|
|
349
|
+
*/
|
|
350
|
+
_saveAll(data) {
|
|
351
|
+
try {
|
|
352
|
+
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
353
|
+
this._cache = data;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
console.error("[LayoutPersistence] Failed to save layouts:", err);
|
|
356
|
+
if (err.name === "QuotaExceededError") {
|
|
357
|
+
this._pruneOldLayouts(data);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Remove oldest layouts to free up space.
|
|
363
|
+
* @param {Object} data
|
|
364
|
+
* @private
|
|
365
|
+
*/
|
|
366
|
+
_pruneOldLayouts(data) {
|
|
367
|
+
const entries = Object.entries(data);
|
|
368
|
+
entries.sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0));
|
|
369
|
+
const removeCount = Math.ceil(entries.length * 0.25);
|
|
370
|
+
for (let i = 0; i < removeCount; i++) {
|
|
371
|
+
delete data[entries[i][0]];
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
|
|
375
|
+
this._cache = data;
|
|
376
|
+
this._logger?.warn?.("[LayoutPersistence] Pruned old layouts due to quota");
|
|
377
|
+
} catch (err) {
|
|
378
|
+
console.error("[LayoutPersistence] Still cannot save after pruning:", err);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Get the template ID associated with a saved layout.
|
|
383
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
384
|
+
* @returns {string|null} Template ID or null
|
|
385
|
+
*/
|
|
386
|
+
getTemplateId(contextId) {
|
|
387
|
+
const all = this._loadAll();
|
|
388
|
+
return all[contextId]?.templateId || null;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Check if the user has modified the layout since the template was applied.
|
|
392
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
393
|
+
* @returns {boolean}
|
|
394
|
+
*/
|
|
395
|
+
isTemplateModified(contextId) {
|
|
396
|
+
const all = this._loadAll();
|
|
397
|
+
return all[contextId]?.templateModified ?? false;
|
|
398
|
+
}
|
|
399
|
+
// =========================================================================
|
|
400
|
+
// NAMED CONFIGURATION MANAGEMENT
|
|
401
|
+
// =========================================================================
|
|
402
|
+
/**
|
|
403
|
+
* Get all saved configurations for a context.
|
|
404
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
405
|
+
* @returns {Object} { activeId: string, configs: { id: { name, tiles, createdAt } } }
|
|
406
|
+
*/
|
|
407
|
+
getConfigurations(contextId) {
|
|
408
|
+
const all = this._loadConfigs();
|
|
409
|
+
const contextConfigs = all[contextId];
|
|
410
|
+
if (contextConfigs && Object.keys(contextConfigs.configs || {}).length > 0) {
|
|
411
|
+
return contextConfigs;
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
activeId: null,
|
|
415
|
+
configs: {}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Save the current layout as a named configuration.
|
|
420
|
+
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
421
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
422
|
+
* @param {string} name - Configuration name
|
|
423
|
+
* @param {Array|Object} layout - Layout to save
|
|
424
|
+
* @param {Object} [options] - Additional options
|
|
425
|
+
* @param {string} [options.templateId] - Template ID that generated this layout
|
|
426
|
+
* @returns {string} The new configuration ID
|
|
427
|
+
*/
|
|
428
|
+
saveConfiguration(contextId, name, layout, options) {
|
|
429
|
+
const all = this._loadConfigs();
|
|
430
|
+
if (!all[contextId]) {
|
|
431
|
+
all[contextId] = { activeId: null, configs: {} };
|
|
432
|
+
}
|
|
433
|
+
const uniqueName = this._getUniqueName(all[contextId].configs, name);
|
|
434
|
+
const configId = `config-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
435
|
+
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
436
|
+
all[contextId].configs[configId] = {
|
|
437
|
+
name: uniqueName,
|
|
438
|
+
tiles: tilesArray,
|
|
439
|
+
version: LAYOUT_VERSION,
|
|
440
|
+
createdAt: Date.now(),
|
|
441
|
+
templateId: options?.templateId || null
|
|
442
|
+
};
|
|
443
|
+
if (!all[contextId].activeId) {
|
|
444
|
+
all[contextId].activeId = configId;
|
|
445
|
+
}
|
|
446
|
+
this._saveConfigs(all);
|
|
447
|
+
this._logger?.info?.(`[LayoutPersistence] Saved configuration "${uniqueName}" for context ${contextId}`);
|
|
448
|
+
return configId;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Generate a unique name by appending (2), (3), etc. if name already exists.
|
|
452
|
+
* @param {Object} configs - Existing configurations object
|
|
453
|
+
* @param {string} name - Desired name
|
|
454
|
+
* @returns {string} Unique name
|
|
455
|
+
* @private
|
|
456
|
+
*/
|
|
457
|
+
_getUniqueName(configs, name) {
|
|
458
|
+
if (!configs) return name;
|
|
459
|
+
const existingNames = new Set(Object.values(configs).map((c) => c.name));
|
|
460
|
+
if (!existingNames.has(name)) {
|
|
461
|
+
return name;
|
|
462
|
+
}
|
|
463
|
+
let counter = 2;
|
|
464
|
+
let candidate = `${name} (${counter})`;
|
|
465
|
+
while (existingNames.has(candidate)) {
|
|
466
|
+
counter++;
|
|
467
|
+
candidate = `${name} (${counter})`;
|
|
468
|
+
}
|
|
469
|
+
return candidate;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Load a specific configuration.
|
|
473
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
474
|
+
* @param {string} configId - Configuration ID
|
|
475
|
+
* @returns {Object|null} Configuration layout or null if not found
|
|
476
|
+
*/
|
|
477
|
+
loadConfiguration(contextId, configId) {
|
|
478
|
+
const all = this._loadConfigs();
|
|
479
|
+
const config = all[contextId]?.configs?.[configId];
|
|
480
|
+
if (config) {
|
|
481
|
+
all[contextId].activeId = configId;
|
|
482
|
+
this._saveConfigs(all);
|
|
483
|
+
return config;
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Delete a saved configuration.
|
|
489
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
490
|
+
* @param {string} configId - Configuration ID
|
|
491
|
+
* @returns {boolean} True if deleted, false if not found
|
|
492
|
+
*/
|
|
493
|
+
deleteConfiguration(contextId, configId) {
|
|
494
|
+
const all = this._loadConfigs();
|
|
495
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
delete all[contextId].configs[configId];
|
|
499
|
+
if (all[contextId].activeId === configId) {
|
|
500
|
+
const remaining = Object.keys(all[contextId].configs);
|
|
501
|
+
all[contextId].activeId = remaining.length > 0 ? remaining[0] : null;
|
|
502
|
+
}
|
|
503
|
+
this._saveConfigs(all);
|
|
504
|
+
this._logger?.info?.(`[LayoutPersistence] Deleted configuration ${configId} for context ${contextId}`);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Update an existing configuration's layout (without creating a new ID).
|
|
509
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
510
|
+
* @param {string} configId - Configuration ID
|
|
511
|
+
* @param {Array|Object} layout - New layout to save
|
|
512
|
+
* @returns {boolean} True if updated, false if not found
|
|
513
|
+
*/
|
|
514
|
+
updateConfiguration(contextId, configId, layout) {
|
|
515
|
+
const all = this._loadConfigs();
|
|
516
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
520
|
+
all[contextId].configs[configId].tiles = tilesArray;
|
|
521
|
+
all[contextId].configs[configId].version = LAYOUT_VERSION;
|
|
522
|
+
all[contextId].configs[configId].updatedAt = Date.now();
|
|
523
|
+
this._saveConfigs(all);
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Update the templateId on an existing configuration.
|
|
528
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
529
|
+
* @param {string} configId - Configuration ID
|
|
530
|
+
* @param {string} templateId - New template ID
|
|
531
|
+
* @returns {boolean} True if updated, false if not found
|
|
532
|
+
*/
|
|
533
|
+
updateConfigurationTemplateId(contextId, configId, templateId) {
|
|
534
|
+
const all = this._loadConfigs();
|
|
535
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
all[contextId].configs[configId].templateId = templateId;
|
|
539
|
+
this._saveConfigs(all);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Rename a configuration.
|
|
544
|
+
* Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
|
|
545
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
546
|
+
* @param {string} configId - Configuration ID
|
|
547
|
+
* @param {string} newName - New name
|
|
548
|
+
* @returns {string|false} The actual name used (may differ if duplicate), or false if not found
|
|
549
|
+
*/
|
|
550
|
+
renameConfiguration(contextId, configId, newName) {
|
|
551
|
+
const all = this._loadConfigs();
|
|
552
|
+
if (!all[contextId]?.configs?.[configId]) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
const otherConfigs = {};
|
|
556
|
+
Object.entries(all[contextId].configs).forEach(([id, config]) => {
|
|
557
|
+
if (id !== configId) {
|
|
558
|
+
otherConfigs[id] = config;
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
const uniqueName = this._getUniqueName(otherConfigs, newName);
|
|
562
|
+
all[contextId].configs[configId].name = uniqueName;
|
|
563
|
+
this._saveConfigs(all);
|
|
564
|
+
return uniqueName;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Get the active configuration for a context.
|
|
568
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
569
|
+
* @returns {Object|null} { id, name, tiles } or null
|
|
570
|
+
*/
|
|
571
|
+
getActiveConfiguration(contextId) {
|
|
572
|
+
const all = this._loadConfigs();
|
|
573
|
+
const contextConfigs = all[contextId];
|
|
574
|
+
if (!contextConfigs?.activeId) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
const activeConfig = contextConfigs.configs[contextConfigs.activeId];
|
|
578
|
+
if (activeConfig) {
|
|
579
|
+
return {
|
|
580
|
+
id: contextConfigs.activeId,
|
|
581
|
+
...activeConfig
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Set the active configuration.
|
|
588
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
589
|
+
* @param {string} configId - Configuration ID
|
|
590
|
+
*/
|
|
591
|
+
setActiveConfiguration(contextId, configId) {
|
|
592
|
+
const all = this._loadConfigs();
|
|
593
|
+
if (all[contextId]?.configs?.[configId]) {
|
|
594
|
+
all[contextId].activeId = configId;
|
|
595
|
+
this._saveConfigs(all);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* List all configurations for a context.
|
|
600
|
+
* @param {string} contextId - Whatever the app keys layouts by.
|
|
601
|
+
* @returns {Array} Array of { id, name, createdAt, isActive }
|
|
602
|
+
*/
|
|
603
|
+
listConfigurations(contextId) {
|
|
604
|
+
const all = this._loadConfigs();
|
|
605
|
+
const contextConfigs = all[contextId];
|
|
606
|
+
if (!contextConfigs?.configs) {
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
return Object.entries(contextConfigs.configs).map(([id, config]) => ({
|
|
610
|
+
id,
|
|
611
|
+
name: config.name,
|
|
612
|
+
createdAt: config.createdAt,
|
|
613
|
+
isActive: id === contextConfigs.activeId
|
|
614
|
+
})).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Get the template set fingerprint for a context.
|
|
618
|
+
* Used to detect when templates have changed and configs need regeneration.
|
|
619
|
+
* @param {string} contextId
|
|
620
|
+
* @returns {string|null}
|
|
621
|
+
*/
|
|
622
|
+
getTemplateSetId(contextId) {
|
|
623
|
+
const all = this._loadConfigs();
|
|
624
|
+
return all[contextId]?.templateSetId || null;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Store the template set fingerprint for a context.
|
|
628
|
+
* @param {string} contextId
|
|
629
|
+
* @param {string} templateSetId
|
|
630
|
+
*/
|
|
631
|
+
setTemplateSetId(contextId, templateSetId) {
|
|
632
|
+
const all = this._loadConfigs();
|
|
633
|
+
if (!all[contextId]) {
|
|
634
|
+
all[contextId] = { activeId: null, configs: {} };
|
|
635
|
+
}
|
|
636
|
+
all[contextId].templateSetId = templateSetId;
|
|
637
|
+
this._saveConfigs(all);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Delete ALL configurations for a context.
|
|
641
|
+
* @param {string} contextId
|
|
642
|
+
*/
|
|
643
|
+
clearConfigurations(contextId) {
|
|
644
|
+
const all = this._loadConfigs();
|
|
645
|
+
delete all[contextId];
|
|
646
|
+
this._saveConfigs(all);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Load all configurations from storage.
|
|
650
|
+
* @returns {Object}
|
|
651
|
+
* @private
|
|
652
|
+
*/
|
|
653
|
+
_loadConfigs() {
|
|
654
|
+
try {
|
|
655
|
+
const raw = this._storage?.getItem(this._configsKey);
|
|
656
|
+
if (raw) {
|
|
657
|
+
return JSON.parse(raw);
|
|
658
|
+
}
|
|
659
|
+
} catch (err) {
|
|
660
|
+
console.error("[LayoutPersistence] Failed to load configurations:", err);
|
|
661
|
+
}
|
|
662
|
+
return {};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Save all configurations to storage.
|
|
666
|
+
* @param {Object} data
|
|
667
|
+
* @private
|
|
668
|
+
*/
|
|
669
|
+
_saveConfigs(data) {
|
|
670
|
+
try {
|
|
671
|
+
this._storage?.setItem(this._configsKey, JSON.stringify(data));
|
|
672
|
+
} catch (err) {
|
|
673
|
+
console.error("[LayoutPersistence] Failed to save configurations:", err);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
var instance = null;
|
|
678
|
+
var _options = {};
|
|
679
|
+
function configureLayoutPersistence(options = {}) {
|
|
680
|
+
_options = options;
|
|
681
|
+
instance = null;
|
|
682
|
+
}
|
|
683
|
+
function getLayoutPersistence() {
|
|
684
|
+
if (!instance) {
|
|
685
|
+
instance = new LayoutPersistence(_options);
|
|
686
|
+
}
|
|
687
|
+
return instance;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/tiles/tile_base.js
|
|
691
|
+
var TileBase = class {
|
|
692
|
+
/** @type {string} Widget type identifier - override in subclass */
|
|
693
|
+
static TYPE = "base";
|
|
694
|
+
/** @type {string} Display title - override in subclass */
|
|
695
|
+
static TITLE = "Widget";
|
|
696
|
+
/** @type {{w: number, h: number}} Default grid size - override in subclass */
|
|
697
|
+
static DEFAULT_SIZE = { w: 4, h: 3 };
|
|
698
|
+
/** @type {{minW: number, minH: number, maxW: number, maxH: number}} Size constraints */
|
|
699
|
+
static SIZE_CONSTRAINTS = { minW: 2, minH: 2, maxW: 12, maxH: 8 };
|
|
700
|
+
/** @type {boolean} Whether widget can be expanded to a separate window - override in subclass */
|
|
701
|
+
static EXPANDABLE = false;
|
|
702
|
+
/** @type {boolean} Whether widget supports scenario comparison overlay - override in subclass */
|
|
703
|
+
static SUPPORTS_COMPARISON = false;
|
|
704
|
+
/**
|
|
705
|
+
* @param {Object} options
|
|
706
|
+
* @param {string} options.id - Unique tile identifier
|
|
707
|
+
* @param {Object} options.grid - Parent TileGrid instance
|
|
708
|
+
* @param {Object} [options.eventBus] - Event bus for cross-component communication
|
|
709
|
+
* @param {Object} [options.config] - Widget-specific configuration
|
|
710
|
+
* @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
|
|
711
|
+
* @param {{resolve: Function}} [options.dataSource] - Where this tile asks for its
|
|
712
|
+
* own rows. Handed down by `TileGrid`; absent for a grid whose data is
|
|
713
|
+
* broadcast with `setData()`, and then nothing ever calls `loadData()`.
|
|
714
|
+
*/
|
|
715
|
+
constructor({
|
|
716
|
+
id,
|
|
717
|
+
grid,
|
|
718
|
+
eventBus = null,
|
|
719
|
+
config = {},
|
|
720
|
+
readonly = false,
|
|
721
|
+
headless = false,
|
|
722
|
+
host = null,
|
|
723
|
+
stateGuard = null,
|
|
724
|
+
dataSource = null
|
|
725
|
+
}) {
|
|
726
|
+
this.host = host;
|
|
727
|
+
this.dataSource = dataSource;
|
|
728
|
+
this.stateGuard = stateGuard;
|
|
729
|
+
this.id = id;
|
|
730
|
+
this.grid = grid;
|
|
731
|
+
this.eventBus = eventBus;
|
|
732
|
+
this.config = { ...this.getDefaultConfig(), ...config };
|
|
733
|
+
this.readonly = readonly;
|
|
734
|
+
this.headless = headless;
|
|
735
|
+
this.element = null;
|
|
736
|
+
this.contentElement = null;
|
|
737
|
+
this.chartInstance = null;
|
|
738
|
+
this.data = null;
|
|
739
|
+
this.fullData = null;
|
|
740
|
+
this._disposed = false;
|
|
741
|
+
this._lastLoadedAt = null;
|
|
742
|
+
this._loadSeq = 0;
|
|
743
|
+
this.comparisonData = null;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Set comparison data for overlay rendering.
|
|
747
|
+
* Only meaningful for widgets where SUPPORTS_COMPARISON = true.
|
|
748
|
+
* @param {Map<string, Object>|null} comparisonData
|
|
749
|
+
*/
|
|
750
|
+
setComparisonData(comparisonData) {
|
|
751
|
+
this.comparisonData = comparisonData;
|
|
752
|
+
if (this.data && this.contentElement) {
|
|
753
|
+
this.render(this.data);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Format a raw analytics variable key into a user-friendly display label.
|
|
758
|
+
* Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
|
|
759
|
+
* @param {string} name - Raw variable key
|
|
760
|
+
* @returns {string} Friendly display label
|
|
761
|
+
*/
|
|
762
|
+
formatLabel(name) {
|
|
763
|
+
return formatVariableLabel(name);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Look up the unit string for a variable from analytics metadata.
|
|
767
|
+
* Checks stocks, flows, and indicators for unit metadata.
|
|
768
|
+
* @param {string} varName - Variable name
|
|
769
|
+
* @returns {string|null} Unit string or null
|
|
770
|
+
*/
|
|
771
|
+
getVariableUnit(varName) {
|
|
772
|
+
if (!varName || !this.data) return null;
|
|
773
|
+
const sources = [this.data.stocks, this.data.flows, this.data.indicators];
|
|
774
|
+
for (const source of sources) {
|
|
775
|
+
const varData = source?.[varName];
|
|
776
|
+
if (varData?.unit) return varData.unit;
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Look up a variable across stocks, flows, and indicators.
|
|
782
|
+
* If the variable is not found, returns the first available variable
|
|
783
|
+
* and updates the config key so the widget self-heals.
|
|
784
|
+
* @param {Object} data - Analytics data
|
|
785
|
+
* @param {string} configKey - Config property name (e.g. 'variable', 'xVariable')
|
|
786
|
+
* @returns {{varData: any, varName: string}|null} Resolved data or null if no data at all
|
|
787
|
+
*/
|
|
788
|
+
resolveVariable(data, configKey = "variable") {
|
|
789
|
+
const varName = this.config[configKey];
|
|
790
|
+
if (varName) {
|
|
791
|
+
const varData = data.stocks?.[varName] || data.indicators?.[varName] || data.flows?.[varName];
|
|
792
|
+
if (varData) return { varData, varName };
|
|
793
|
+
const dotIdx = varName.indexOf(".");
|
|
794
|
+
if (dotIdx >= 0) {
|
|
795
|
+
const bare = varName.slice(dotIdx + 1);
|
|
796
|
+
const bareData = data.stocks?.[bare] || data.indicators?.[bare] || data.flows?.[bare];
|
|
797
|
+
if (bareData) return { varData: bareData, varName: bare };
|
|
798
|
+
}
|
|
799
|
+
if (dotIdx < 0) {
|
|
800
|
+
const suffix = `.${varName}`;
|
|
801
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
802
|
+
if (!source) continue;
|
|
803
|
+
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
804
|
+
if (match) return { varData: source[match], varName: match };
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (this.fullData && this.fullData !== data) {
|
|
808
|
+
const fullVarData = this.fullData.stocks?.[varName] || this.fullData.indicators?.[varName] || this.fullData.flows?.[varName];
|
|
809
|
+
if (fullVarData) return { varData: fullVarData, varName };
|
|
810
|
+
if (dotIdx >= 0) {
|
|
811
|
+
const bare = varName.slice(dotIdx + 1);
|
|
812
|
+
const fullBareData = this.fullData.stocks?.[bare] || this.fullData.indicators?.[bare] || this.fullData.flows?.[bare];
|
|
813
|
+
if (fullBareData) return { varData: fullBareData, varName: bare };
|
|
814
|
+
}
|
|
815
|
+
if (dotIdx < 0) {
|
|
816
|
+
const suffix = `.${varName}`;
|
|
817
|
+
for (const source of [this.fullData.stocks, this.fullData.indicators, this.fullData.flows]) {
|
|
818
|
+
if (!source) continue;
|
|
819
|
+
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
820
|
+
if (match) return { varData: source[match], varName: match };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const sources = [data.stocks, data.indicators, data.flows];
|
|
826
|
+
for (const source of sources) {
|
|
827
|
+
if (!source) continue;
|
|
828
|
+
const keys = Object.keys(source);
|
|
829
|
+
if (keys.length > 0) {
|
|
830
|
+
this.config[configKey] = keys[0];
|
|
831
|
+
return { varData: source[keys[0]], varName: keys[0] };
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Find a variable by name in data, trying exact match then suffix match.
|
|
838
|
+
* Does NOT auto-fallback to first available variable.
|
|
839
|
+
* @param {Object} data - Analytics data with stocks/indicators/flows
|
|
840
|
+
* @param {string} varName - Variable name (bare or namespace-prefixed)
|
|
841
|
+
* @returns {{varData: any, varName: string}|null}
|
|
842
|
+
*/
|
|
843
|
+
static findVariable(data, varName) {
|
|
844
|
+
if (!data || !varName) return null;
|
|
845
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
846
|
+
if (!source) continue;
|
|
847
|
+
if (source[varName]) return { varData: source[varName], varName };
|
|
848
|
+
}
|
|
849
|
+
const dotIdx = varName.indexOf(".");
|
|
850
|
+
if (dotIdx < 0) {
|
|
851
|
+
const suffix = `.${varName}`;
|
|
852
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
853
|
+
if (!source) continue;
|
|
854
|
+
const match = Object.keys(source).find((k) => k.endsWith(suffix));
|
|
855
|
+
if (match) return { varData: source[match], varName: match };
|
|
856
|
+
}
|
|
857
|
+
} else {
|
|
858
|
+
const bare = varName.slice(dotIdx + 1);
|
|
859
|
+
for (const source of [data.stocks, data.indicators, data.flows]) {
|
|
860
|
+
if (!source) continue;
|
|
861
|
+
if (source[bare]) return { varData: source[bare], varName: bare };
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Get default configuration for this widget type.
|
|
868
|
+
* Override in subclass.
|
|
869
|
+
* @returns {Object} Default config object
|
|
870
|
+
*/
|
|
871
|
+
getDefaultConfig() {
|
|
872
|
+
return {};
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Get configuration schema for the config modal.
|
|
876
|
+
* Override in subclass.
|
|
877
|
+
* @returns {Object} Schema definition
|
|
878
|
+
*/
|
|
879
|
+
getConfigSchema() {
|
|
880
|
+
return { fields: [] };
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Render widget content with data.
|
|
884
|
+
* Override in subclass.
|
|
885
|
+
* @param {Object} data - Analytics data
|
|
886
|
+
*/
|
|
887
|
+
render(data) {
|
|
888
|
+
throw new Error("TileBase.render() must be implemented by subclass");
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Mount the tile into the container.
|
|
892
|
+
* @param {HTMLElement} container - Parent container
|
|
893
|
+
*/
|
|
894
|
+
mount(container) {
|
|
895
|
+
if (this._disposed) return;
|
|
896
|
+
this.element = document.createElement("div");
|
|
897
|
+
this.element.className = `tile${this.readonly ? " tile--readonly" : ""}`;
|
|
898
|
+
const stateGuard = this.stateGuard;
|
|
899
|
+
const setDataset = () => {
|
|
900
|
+
this.element.dataset.tileId = this.id;
|
|
901
|
+
this.element.dataset.tileType = this.constructor.TYPE;
|
|
902
|
+
};
|
|
903
|
+
if (stateGuard?.executeWithBypass) {
|
|
904
|
+
stateGuard.executeWithBypass("tile-mount", setDataset);
|
|
905
|
+
} else {
|
|
906
|
+
setDataset();
|
|
907
|
+
}
|
|
908
|
+
if (this.headless) {
|
|
909
|
+
this.contentElement = document.createElement("div");
|
|
910
|
+
this.contentElement.className = "tile-content";
|
|
911
|
+
this.element.appendChild(this.contentElement);
|
|
912
|
+
} else {
|
|
913
|
+
this._buildChrome();
|
|
914
|
+
}
|
|
915
|
+
container.appendChild(this.element);
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Update the widget with new data and/or config.
|
|
919
|
+
* @param {Object} [data] - New analytics data
|
|
920
|
+
* @param {Object} [config] - New config values to merge
|
|
921
|
+
*/
|
|
922
|
+
update(data, config) {
|
|
923
|
+
if (this._disposed) return;
|
|
924
|
+
if (config) {
|
|
925
|
+
const prevDocs = (this.config?.docs || "").trim();
|
|
926
|
+
this.config = { ...this.config, ...config };
|
|
927
|
+
const nextDocs = (this.config?.docs || "").trim();
|
|
928
|
+
if (this.element && !this.headless && prevDocs !== nextDocs) {
|
|
929
|
+
this._syncInfoButton();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (data !== void 0) {
|
|
933
|
+
this.data = data;
|
|
934
|
+
}
|
|
935
|
+
if (this.data && this.contentElement) {
|
|
936
|
+
this.render(this.data);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Load this tile's own data from `this.dataSource` and render it.
|
|
941
|
+
*
|
|
942
|
+
* A NO-OP HERE ON PURPOSE. The base class knows nothing about what a binding
|
|
943
|
+
* is or what a dataset looks like — those belong to the consumer, and the
|
|
944
|
+
* consumer that has them (Tables' `TablesTile`) implements this in ~30 lines
|
|
945
|
+
* over `showLoading()` / `render()` / `showError()`. What upstream owns is the
|
|
946
|
+
* CALL SITES: `TileGrid` invokes this after mount, after a resize settles and
|
|
947
|
+
* after a config save, but only when a `dataSource` was injected. Making it a
|
|
948
|
+
* hook rather than an implementation is what keeps EcoAgent's broadcast path
|
|
949
|
+
* (`setData` -> `update`) bit-identical: with no data source nothing here ever
|
|
950
|
+
* runs.
|
|
951
|
+
*
|
|
952
|
+
* @param {{force?: boolean}} [_opts] - `force` bypasses the source's cache.
|
|
953
|
+
* @returns {Promise<void>}
|
|
954
|
+
*/
|
|
955
|
+
async loadData(_opts = {}) {
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Called once after a drag-resize settles, for widgets whose content does not
|
|
959
|
+
* reflow on its own — a Plotly figure sizes itself at draw time and stays that
|
|
960
|
+
* size until something calls `Plotly.Plots.resize`. Deliberately NOT wired to a
|
|
961
|
+
* ResizeObserver: the grid already knows when a resize ended, and an observer
|
|
962
|
+
* per tile fires during the drag, which is a relayout per pointer-move.
|
|
963
|
+
*/
|
|
964
|
+
onResize() {
|
|
965
|
+
}
|
|
966
|
+
/** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
|
|
967
|
+
_syncInfoButton() {
|
|
968
|
+
const header = this.element?.querySelector(".tile-header");
|
|
969
|
+
const controls = header?.querySelector(".tile-controls");
|
|
970
|
+
if (!controls) return;
|
|
971
|
+
const docs = (this.config?.docs || "").trim();
|
|
972
|
+
let btn = controls.querySelector(".tile-info-btn");
|
|
973
|
+
if (docs) {
|
|
974
|
+
if (!btn) {
|
|
975
|
+
btn = document.createElement("button");
|
|
976
|
+
btn.className = "tile-info-btn twm-has-tooltip";
|
|
977
|
+
btn.type = "button";
|
|
978
|
+
btn.tabIndex = -1;
|
|
979
|
+
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;";
|
|
980
|
+
btn.setAttribute("data-tooltip-placement", "bottom");
|
|
981
|
+
btn.setAttribute("data-tooltip-max-width", "420px");
|
|
982
|
+
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">info</span>';
|
|
983
|
+
btn.addEventListener("click", (e) => e.stopPropagation());
|
|
984
|
+
const anchor = controls.querySelector(".tile-expand-btn") ?? controls.querySelector(".tile-remove-btn");
|
|
985
|
+
if (anchor) controls.insertBefore(btn, anchor);
|
|
986
|
+
else controls.appendChild(btn);
|
|
987
|
+
}
|
|
988
|
+
btn.setAttribute("data-tooltip", docs);
|
|
989
|
+
} else if (btn) {
|
|
990
|
+
btn.remove();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Clean up resources and remove from DOM.
|
|
995
|
+
*/
|
|
996
|
+
dispose() {
|
|
997
|
+
if (this._disposed) return;
|
|
998
|
+
this._disposed = true;
|
|
999
|
+
if (this._expandWindow?.isVisible) {
|
|
1000
|
+
this._expandWindow.close();
|
|
1001
|
+
}
|
|
1002
|
+
this._expandWindow = null;
|
|
1003
|
+
if (this.chartInstance) {
|
|
1004
|
+
this.chartInstance.destroy();
|
|
1005
|
+
this.chartInstance = null;
|
|
1006
|
+
}
|
|
1007
|
+
if (this.element && this.element.parentNode) {
|
|
1008
|
+
this.element.parentNode.removeChild(this.element);
|
|
1009
|
+
}
|
|
1010
|
+
this.element = null;
|
|
1011
|
+
this.contentElement = null;
|
|
1012
|
+
this.data = null;
|
|
1013
|
+
this.fullData = null;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* The entries in the tile's ⋯ menu, as `{action, label, icon, hidden}`.
|
|
1017
|
+
*
|
|
1018
|
+
* This existed as a LITERAL inside `_buildChrome`, and the literal was
|
|
1019
|
+
* EcoAgent's: "Add to Documentation" and "Show in Documentation". Both are
|
|
1020
|
+
* meaningful only for a tile whose `config.sourceCellId` names a notebook
|
|
1021
|
+
* cell, and the first one's hide condition is `sourceCellId && already-added` —
|
|
1022
|
+
* so on a tile that has no `sourceCellId` at all it renders VISIBLE, and
|
|
1023
|
+
* choosing it emits `tile:add-to-documentation` at an application that has no
|
|
1024
|
+
* documentation. Every consumer other than EcoAgent therefore shipped a menu
|
|
1025
|
+
* item that did nothing, and could not remove it without editing this file.
|
|
1026
|
+
*
|
|
1027
|
+
* Returning `[]` drops the whole wrapper — button, dropdown and all — because
|
|
1028
|
+
* a ⋯ button that opens an empty menu reads as a broken feature rather than
|
|
1029
|
+
* an absent one. The default is the two entries above, so EcoAgent's chrome
|
|
1030
|
+
* is unchanged.
|
|
1031
|
+
*
|
|
1032
|
+
* Selection is routed through `_onMenuAction()`; override both together.
|
|
1033
|
+
* @returns {Array<{action: string, label: string, icon?: string, hidden?: boolean}>}
|
|
1034
|
+
*/
|
|
1035
|
+
menuItems() {
|
|
1036
|
+
const linked = !!(this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId));
|
|
1037
|
+
return [
|
|
1038
|
+
{
|
|
1039
|
+
action: "add-to-documentation",
|
|
1040
|
+
label: "Add to Documentation",
|
|
1041
|
+
icon: "post_add",
|
|
1042
|
+
hidden: linked
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
action: "show-in-documentation",
|
|
1046
|
+
label: "Show in Documentation",
|
|
1047
|
+
icon: "description",
|
|
1048
|
+
hidden: !linked
|
|
1049
|
+
}
|
|
1050
|
+
];
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Act on a ⋯ menu selection. One dispatch point for every entry, so an
|
|
1054
|
+
* override of `menuItems()` cannot add a row that has nothing behind it, and
|
|
1055
|
+
* an unrecognised action is silently ignored rather than throwing into a
|
|
1056
|
+
* click handler.
|
|
1057
|
+
* @param {string} action
|
|
1058
|
+
* @protected
|
|
1059
|
+
*/
|
|
1060
|
+
_onMenuAction(action) {
|
|
1061
|
+
if (action === "add-to-documentation") {
|
|
1062
|
+
this.eventBus?.emit?.("tile:add-to-documentation", {
|
|
1063
|
+
tileType: this.constructor.TYPE,
|
|
1064
|
+
config: { ...this.config }
|
|
1065
|
+
});
|
|
1066
|
+
} else if (action === "show-in-documentation") {
|
|
1067
|
+
if (this.config?.sourceCellId) {
|
|
1068
|
+
this.eventBus?.emit?.("tile:show-in-documentation", {
|
|
1069
|
+
sourceCellId: this.config.sourceCellId
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Build the tile chrome (header, content area, resize handles).
|
|
1076
|
+
* @private
|
|
1077
|
+
*/
|
|
1078
|
+
_buildChrome() {
|
|
1079
|
+
const header = document.createElement("div");
|
|
1080
|
+
header.className = "tile-header";
|
|
1081
|
+
const expandBtnHtml = this.constructor.EXPANDABLE ? `<button class="tile-expand-btn twm-has-tooltip" data-tooltip="Open in window">
|
|
1082
|
+
<span class="material-symbols-outlined">open_in_new</span>
|
|
1083
|
+
</button>` : "";
|
|
1084
|
+
const docs = (this.config?.docs || "").trim();
|
|
1085
|
+
const infoBtnHtml = docs ? `<button class="tile-info-btn twm-has-tooltip" type="button" tabindex="-1"
|
|
1086
|
+
data-tooltip="${this.#escapeAttr(docs)}"
|
|
1087
|
+
data-tooltip-placement="bottom"
|
|
1088
|
+
data-tooltip-max-width="420px"
|
|
1089
|
+
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;">
|
|
1090
|
+
<span class="material-symbols-outlined" style="font-size:18px;">info</span>
|
|
1091
|
+
</button>` : "";
|
|
1092
|
+
const menuEntries = (this.readonly ? [] : this.menuItems()) ?? [];
|
|
1093
|
+
const menuHtml = menuEntries.length ? `<div class="tile-menu-wrapper">
|
|
1094
|
+
<button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
|
|
1095
|
+
<span class="material-symbols-outlined">more_vert</span>
|
|
1096
|
+
</button>
|
|
1097
|
+
<div class="tile-menu-dropdown" hidden>
|
|
1098
|
+
${menuEntries.map((item) => `
|
|
1099
|
+
<button class="tile-menu-item" data-action="${this.#escapeAttr(item.action)}"${item.hidden ? ' style="display:none"' : ""}>
|
|
1100
|
+
${item.icon ? `<span class="material-symbols-outlined">${this.#escapeAttr(item.icon)}</span>` : ""}
|
|
1101
|
+
<span>${this.#escapeAttr(item.label ?? "")}</span>
|
|
1102
|
+
</button>`).join("")}
|
|
1103
|
+
</div>
|
|
1104
|
+
</div>` : "";
|
|
1105
|
+
if (this.readonly) {
|
|
1106
|
+
header.innerHTML = `
|
|
1107
|
+
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
1108
|
+
<div class="tile-controls">
|
|
1109
|
+
${infoBtnHtml}
|
|
1110
|
+
${expandBtnHtml}
|
|
1111
|
+
</div>
|
|
1112
|
+
`;
|
|
1113
|
+
} else {
|
|
1114
|
+
header.innerHTML = `
|
|
1115
|
+
<span class="tile-drag-handle twm-has-tooltip" data-tooltip="Drag to reposition">
|
|
1116
|
+
<span class="material-symbols-outlined">drag_indicator</span>
|
|
1117
|
+
</span>
|
|
1118
|
+
<span class="tile-title">${this.constructor.TITLE}</span>
|
|
1119
|
+
<div class="tile-controls">
|
|
1120
|
+
<button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
|
|
1121
|
+
<span class="material-symbols-outlined">settings</span>
|
|
1122
|
+
</button>
|
|
1123
|
+
${menuHtml}
|
|
1124
|
+
${infoBtnHtml}
|
|
1125
|
+
${expandBtnHtml}
|
|
1126
|
+
<button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
|
|
1127
|
+
<span class="material-symbols-outlined">close</span>
|
|
1128
|
+
</button>
|
|
1129
|
+
</div>
|
|
1130
|
+
`;
|
|
1131
|
+
}
|
|
1132
|
+
const expandBtn = header.querySelector(".tile-expand-btn");
|
|
1133
|
+
expandBtn?.addEventListener("click", (e) => {
|
|
1134
|
+
e.stopPropagation();
|
|
1135
|
+
this._onExpandClick();
|
|
1136
|
+
});
|
|
1137
|
+
const infoBtn = header.querySelector(".tile-info-btn");
|
|
1138
|
+
infoBtn?.addEventListener("click", (e) => e.stopPropagation());
|
|
1139
|
+
if (!this.readonly) {
|
|
1140
|
+
const configBtn = header.querySelector(".tile-config-btn");
|
|
1141
|
+
configBtn?.addEventListener("click", (e) => {
|
|
1142
|
+
e.stopPropagation();
|
|
1143
|
+
this._onConfigClick();
|
|
1144
|
+
});
|
|
1145
|
+
const menuBtn = header.querySelector(".tile-menu-btn");
|
|
1146
|
+
const menuDropdown = header.querySelector(".tile-menu-dropdown");
|
|
1147
|
+
if (menuBtn && menuDropdown) {
|
|
1148
|
+
menuBtn.addEventListener("click", (e) => {
|
|
1149
|
+
e.stopPropagation();
|
|
1150
|
+
const isOpen = !menuDropdown.hidden;
|
|
1151
|
+
menuDropdown.hidden = !menuDropdown.hidden;
|
|
1152
|
+
if (!isOpen) {
|
|
1153
|
+
const closeMenu = (ev) => {
|
|
1154
|
+
if (!menuDropdown.contains(ev.target) && ev.target !== menuBtn) {
|
|
1155
|
+
menuDropdown.hidden = true;
|
|
1156
|
+
document.removeEventListener("pointerdown", closeMenu, true);
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
requestAnimationFrame(() => {
|
|
1160
|
+
document.addEventListener("pointerdown", closeMenu, true);
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
menuDropdown.querySelectorAll(".tile-menu-item").forEach((itemEl) => {
|
|
1165
|
+
itemEl.addEventListener("click", (e) => {
|
|
1166
|
+
e.stopPropagation();
|
|
1167
|
+
menuDropdown.hidden = true;
|
|
1168
|
+
this._onMenuAction(itemEl.dataset.action);
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
const removeBtn = header.querySelector(".tile-remove-btn");
|
|
1173
|
+
removeBtn?.addEventListener("click", (e) => {
|
|
1174
|
+
e.stopPropagation();
|
|
1175
|
+
this._onRemoveClick();
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
this.contentElement = document.createElement("div");
|
|
1179
|
+
this.contentElement.className = "tile-content";
|
|
1180
|
+
this.element.appendChild(header);
|
|
1181
|
+
this.element.appendChild(this.contentElement);
|
|
1182
|
+
if (!this.readonly) {
|
|
1183
|
+
const resizeHandle = document.createElement("div");
|
|
1184
|
+
resizeHandle.className = "tile-resize-handle se";
|
|
1185
|
+
resizeHandle.innerHTML = '<span class="material-symbols-outlined">drag_handle</span>';
|
|
1186
|
+
this.element.appendChild(resizeHandle);
|
|
1187
|
+
this.element.addEventListener("contextmenu", (e) => {
|
|
1188
|
+
e.preventDefault();
|
|
1189
|
+
e.stopPropagation();
|
|
1190
|
+
this.eventBus?.emit?.("tile:context-menu", {
|
|
1191
|
+
tileId: this.id,
|
|
1192
|
+
tileType: this.constructor.TYPE,
|
|
1193
|
+
tileTitle: this.constructor.TITLE,
|
|
1194
|
+
config: { ...this.config },
|
|
1195
|
+
clientX: e.clientX,
|
|
1196
|
+
clientY: e.clientY
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Handle config button click — highlight tile and emit config request.
|
|
1203
|
+
* @private
|
|
1204
|
+
*/
|
|
1205
|
+
_onConfigClick() {
|
|
1206
|
+
this.element?.closest(".tile-grid")?.querySelectorAll(".tile.tile--selected").forEach((el) => el.classList.remove("tile--selected"));
|
|
1207
|
+
this.element?.classList.add("tile--selected");
|
|
1208
|
+
this.eventBus?.emit?.("tile:config-request", {
|
|
1209
|
+
tileId: this.id,
|
|
1210
|
+
tileType: this.constructor.TYPE,
|
|
1211
|
+
config: this.config,
|
|
1212
|
+
schema: this.getConfigSchema()
|
|
1213
|
+
});
|
|
1214
|
+
this.element?.dispatchEvent(new CustomEvent("tile:config-request", {
|
|
1215
|
+
bubbles: true,
|
|
1216
|
+
detail: {
|
|
1217
|
+
tileId: this.id,
|
|
1218
|
+
tileType: this.constructor.TYPE,
|
|
1219
|
+
config: this.config,
|
|
1220
|
+
schema: this.getConfigSchema()
|
|
1221
|
+
}
|
|
1222
|
+
}));
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Handle remove button click.
|
|
1226
|
+
* @param {Event} [event] - Original click event
|
|
1227
|
+
* @private
|
|
1228
|
+
*/
|
|
1229
|
+
_onRemoveClick(event) {
|
|
1230
|
+
const removeBtn = this.element?.querySelector(".tile-remove-btn");
|
|
1231
|
+
this.element?.dispatchEvent(new CustomEvent("tile:remove-request", {
|
|
1232
|
+
bubbles: true,
|
|
1233
|
+
detail: {
|
|
1234
|
+
tileId: this.id,
|
|
1235
|
+
anchorElement: removeBtn
|
|
1236
|
+
}
|
|
1237
|
+
}));
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Set the title displayed in the header.
|
|
1241
|
+
* @param {string} title - New title text
|
|
1242
|
+
*/
|
|
1243
|
+
setTitle(title) {
|
|
1244
|
+
const titleEl = this.element?.querySelector(".tile-title");
|
|
1245
|
+
if (titleEl) {
|
|
1246
|
+
titleEl.textContent = title;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Show loading state in content area.
|
|
1251
|
+
*/
|
|
1252
|
+
showLoading() {
|
|
1253
|
+
if (this.contentElement) {
|
|
1254
|
+
this.contentElement.innerHTML = `
|
|
1255
|
+
<div class="tile-loading">
|
|
1256
|
+
<span class="material-symbols-outlined spinning">progress_activity</span>
|
|
1257
|
+
<span>Loading...</span>
|
|
1258
|
+
</div>
|
|
1259
|
+
`;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Show empty state in content area.
|
|
1264
|
+
*
|
|
1265
|
+
* The message is ESCAPED. Both this and `showError` interpolated straight
|
|
1266
|
+
* into `innerHTML`, and the string reaching `showError` is the one place a
|
|
1267
|
+
* caller has least control over — a server's error text, echoed back with a
|
|
1268
|
+
* column name or a filter value in it. Callers pass plain text; this decides
|
|
1269
|
+
* what that means in HTML.
|
|
1270
|
+
*
|
|
1271
|
+
* @param {string} [message] - Custom message
|
|
1272
|
+
*/
|
|
1273
|
+
showEmpty(message = "No data available") {
|
|
1274
|
+
if (this.contentElement) {
|
|
1275
|
+
this.contentElement.innerHTML = `
|
|
1276
|
+
<div class="tile-empty">
|
|
1277
|
+
<span class="material-symbols-outlined">inbox</span>
|
|
1278
|
+
<span>${this.#escapeAttr(message)}</span>
|
|
1279
|
+
</div>
|
|
1280
|
+
`;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Show error state in content area. The message is escaped — see `showEmpty`.
|
|
1285
|
+
* @param {string} [message] - Error message
|
|
1286
|
+
*/
|
|
1287
|
+
showError(message = "Failed to load data") {
|
|
1288
|
+
if (this.contentElement) {
|
|
1289
|
+
this.contentElement.innerHTML = `
|
|
1290
|
+
<div class="twm-tile-error">
|
|
1291
|
+
<span class="material-symbols-outlined">error</span>
|
|
1292
|
+
<span>${this.#escapeAttr(message)}</span>
|
|
1293
|
+
</div>
|
|
1294
|
+
`;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Format a number for display.
|
|
1299
|
+
* @param {number} value - Number to format
|
|
1300
|
+
* @param {number} [decimals=2] - Decimal places
|
|
1301
|
+
* @returns {string} Formatted string
|
|
1302
|
+
*/
|
|
1303
|
+
formatNumber(value, decimals = 2) {
|
|
1304
|
+
if (value == null || !Number.isFinite(value)) return "\u2014";
|
|
1305
|
+
const abs = Math.abs(value);
|
|
1306
|
+
if (abs >= 1e9) {
|
|
1307
|
+
return (value / 1e9).toFixed(1) + "B";
|
|
1308
|
+
} else if (abs >= 1e6) {
|
|
1309
|
+
return (value / 1e6).toFixed(1) + "M";
|
|
1310
|
+
} else if (abs >= 1e3) {
|
|
1311
|
+
return (value / 1e3).toFixed(1) + "K";
|
|
1312
|
+
} else if (abs < 0.01 && abs > 0) {
|
|
1313
|
+
return value.toExponential(decimals);
|
|
1314
|
+
}
|
|
1315
|
+
return value.toFixed(decimals);
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Get available variables from data for config dropdowns.
|
|
1319
|
+
* @param {Object} data - Analytics data
|
|
1320
|
+
* @returns {Array<{value: string, label: string}>} Variable options
|
|
1321
|
+
*/
|
|
1322
|
+
getAvailableVariables(data) {
|
|
1323
|
+
if (!data) return [];
|
|
1324
|
+
const variables = [];
|
|
1325
|
+
if (data.stocks) {
|
|
1326
|
+
Object.keys(data.stocks).forEach((name) => {
|
|
1327
|
+
variables.push({ value: name, label: name, category: "Stocks" });
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
if (data.flows) {
|
|
1331
|
+
Object.keys(data.flows).forEach((name) => {
|
|
1332
|
+
variables.push({ value: name, label: name, category: "Flows" });
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
if (data.indicators) {
|
|
1336
|
+
Object.keys(data.indicators).forEach((name) => {
|
|
1337
|
+
variables.push({ value: name, label: name, category: "Indicators" });
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
return variables;
|
|
1341
|
+
}
|
|
1342
|
+
// =========================================================================
|
|
1343
|
+
// EXPAND WINDOW FUNCTIONALITY
|
|
1344
|
+
// Reuses PlotPopoutWindow's patterns and CSS classes
|
|
1345
|
+
// =========================================================================
|
|
1346
|
+
/**
|
|
1347
|
+
* Handle expand button click.
|
|
1348
|
+
* @private
|
|
1349
|
+
*/
|
|
1350
|
+
_onExpandClick() {
|
|
1351
|
+
if (!this.constructor.EXPANDABLE) return;
|
|
1352
|
+
const expandData = this.getExpandData();
|
|
1353
|
+
if (!expandData) {
|
|
1354
|
+
console.warn(`[TileBase] No expand data from widget ${this.id}`);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
this._openExpandWindow(expandData);
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Get data for the expanded window.
|
|
1361
|
+
* Override in subclass for expandable widgets.
|
|
1362
|
+
* @returns {Object|null} { title, traces, layout, tableHeaders, tableRows }
|
|
1363
|
+
*/
|
|
1364
|
+
getExpandData() {
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Open the expand window with plot and data tabs.
|
|
1369
|
+
* Delegates to openRawTracesWindow (PlotPopoutWindow module) which
|
|
1370
|
+
* provides proper toolbar layout, download buttons, and data table.
|
|
1371
|
+
* @param {Object} expandData - Data from getExpandData()
|
|
1372
|
+
* @private
|
|
1373
|
+
*/
|
|
1374
|
+
async _openExpandWindow(expandData) {
|
|
1375
|
+
if (this._expandWindow?.isVisible) {
|
|
1376
|
+
this._expandWindow.bringToFront();
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
this._expandWindow = await openRawTracesWindow({
|
|
1380
|
+
host: this.host,
|
|
1381
|
+
id: `tile-expand-${this.id}`,
|
|
1382
|
+
title: expandData.title || this.constructor.TITLE,
|
|
1383
|
+
traces: expandData.traces,
|
|
1384
|
+
layout: expandData.layout,
|
|
1385
|
+
frames: expandData.frames,
|
|
1386
|
+
tableHeaders: expandData.tableHeaders,
|
|
1387
|
+
tableRows: expandData.tableRows,
|
|
1388
|
+
services: { eventBus: this.eventBus }
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Escape a string for safe insertion into HTML — an attribute value or a text
|
|
1393
|
+
* node, since the five characters that matter are the same five in both.
|
|
1394
|
+
* @private
|
|
1395
|
+
*/
|
|
1396
|
+
#escapeAttr(s) {
|
|
1397
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
1398
|
+
"&": "&",
|
|
1399
|
+
"<": "<",
|
|
1400
|
+
">": ">",
|
|
1401
|
+
'"': """,
|
|
1402
|
+
"'": "'"
|
|
1403
|
+
})[c]);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Show toast notification via eventBus.
|
|
1407
|
+
* @private
|
|
1408
|
+
*/
|
|
1409
|
+
_notify(title, message, severity = "info") {
|
|
1410
|
+
this.eventBus?.emit?.("toast:show", {
|
|
1411
|
+
title,
|
|
1412
|
+
message,
|
|
1413
|
+
type: severity
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
// src/tiles/tile_grid.js
|
|
1419
|
+
init_tile_registry();
|
|
1420
|
+
var TileGrid = class {
|
|
1421
|
+
/**
|
|
1422
|
+
* @param {Object} options
|
|
1423
|
+
* @param {HTMLElement} options.container - Container element for the grid
|
|
1424
|
+
* @param {number} [options.columns=12] - Number of grid columns
|
|
1425
|
+
* @param {number} [options.rowHeight=80] - Height of each grid row in pixels
|
|
1426
|
+
* @param {number} [options.gap=16] - Gap between tiles in pixels
|
|
1427
|
+
* @param {Object} [options.eventBus] - Event bus for cross-component communication
|
|
1428
|
+
* @param {boolean} [options.showToolbar=true] - Whether to show built-in toolbar
|
|
1429
|
+
* @param {Function} [options.onLayoutChange] - Callback when layout changes
|
|
1430
|
+
* @param {Function} [options.onAddWidget] - Callback for add widget action
|
|
1431
|
+
* @param {Function} [options.onResetLayout] - Callback for reset layout action
|
|
1432
|
+
* @param {Function} [options.onExport] - Callback for export action
|
|
1433
|
+
* @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
|
|
1434
|
+
* handed to every tile. With none, the grid is the broadcast-only grid
|
|
1435
|
+
* it has always been and no tile is ever asked to load anything.
|
|
1436
|
+
*/
|
|
1437
|
+
constructor({
|
|
1438
|
+
container,
|
|
1439
|
+
columns = 12,
|
|
1440
|
+
rowHeight = 80,
|
|
1441
|
+
gap = 16,
|
|
1442
|
+
eventBus = null,
|
|
1443
|
+
showToolbar = true,
|
|
1444
|
+
readonly = false,
|
|
1445
|
+
onLayoutChange,
|
|
1446
|
+
onBeforeLayoutChange,
|
|
1447
|
+
onAddWidget,
|
|
1448
|
+
onResetLayout,
|
|
1449
|
+
onExport,
|
|
1450
|
+
// Handed down to every tile. TileBase used to import appHost() from
|
|
1451
|
+
// ui/js/ecoagent/ to back Expand -> Export CSV/PNG; a grid that has
|
|
1452
|
+
// no host simply gets the Blob-download fallback the host contract
|
|
1453
|
+
// promises. It is not an error to have none.
|
|
1454
|
+
host = null,
|
|
1455
|
+
stateGuard = null,
|
|
1456
|
+
// Injected, never constructed here: the grid does not know what
|
|
1457
|
+
// a binding is, only that a tile may have somewhere to ask.
|
|
1458
|
+
dataSource = null
|
|
1459
|
+
}) {
|
|
1460
|
+
this.host = host;
|
|
1461
|
+
this.stateGuard = stateGuard;
|
|
1462
|
+
this.dataSource = dataSource;
|
|
1463
|
+
this.container = container;
|
|
1464
|
+
this.columns = columns;
|
|
1465
|
+
this.rowHeight = rowHeight;
|
|
1466
|
+
this.gap = gap;
|
|
1467
|
+
this.eventBus = eventBus;
|
|
1468
|
+
this.showToolbar = showToolbar;
|
|
1469
|
+
this.readonly = readonly;
|
|
1470
|
+
this._onLayoutChangeCallback = onLayoutChange;
|
|
1471
|
+
this._onBeforeLayoutChangeCallback = onBeforeLayoutChange;
|
|
1472
|
+
this._onAddWidgetCallback = onAddWidget;
|
|
1473
|
+
this._onResetLayoutCallback = onResetLayout;
|
|
1474
|
+
this._onExportCallback = onExport;
|
|
1475
|
+
this.tiles = /* @__PURE__ */ new Map();
|
|
1476
|
+
this.layout = [];
|
|
1477
|
+
this.documentationCellIds = null;
|
|
1478
|
+
this.gridElement = null;
|
|
1479
|
+
this.data = null;
|
|
1480
|
+
this.fullData = null;
|
|
1481
|
+
this._dragState = null;
|
|
1482
|
+
this._resizeState = null;
|
|
1483
|
+
this._ghostElement = null;
|
|
1484
|
+
this._boundOnPointerMove = this._onPointerMove.bind(this);
|
|
1485
|
+
this._boundOnPointerUp = this._onPointerUp.bind(this);
|
|
1486
|
+
this._init();
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Initialize the grid container.
|
|
1490
|
+
* @private
|
|
1491
|
+
*/
|
|
1492
|
+
_init() {
|
|
1493
|
+
this.gridElement = document.createElement("div");
|
|
1494
|
+
this.gridElement.className = "tile-grid";
|
|
1495
|
+
this.gridElement.style.setProperty("--grid-columns", this.columns);
|
|
1496
|
+
this.gridElement.style.setProperty("--grid-row-height", `${this.rowHeight}px`);
|
|
1497
|
+
this.gridElement.style.setProperty("--grid-gap", `${this.gap}px`);
|
|
1498
|
+
this.container.innerHTML = "";
|
|
1499
|
+
this.container.appendChild(this.gridElement);
|
|
1500
|
+
if (this.showToolbar) {
|
|
1501
|
+
this._buildToolbar();
|
|
1502
|
+
}
|
|
1503
|
+
this.gridElement.addEventListener("tile:config-request", this._onTileConfigRequest.bind(this));
|
|
1504
|
+
this.gridElement.addEventListener("tile:remove-request", this._onTileRemoveRequest.bind(this));
|
|
1505
|
+
document.addEventListener("pointermove", this._boundOnPointerMove);
|
|
1506
|
+
document.addEventListener("pointerup", this._boundOnPointerUp);
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Build the dashboard bottom toolbar with add widget dropdown.
|
|
1510
|
+
* @private
|
|
1511
|
+
*/
|
|
1512
|
+
_buildToolbar() {
|
|
1513
|
+
const toolbar = document.createElement("div");
|
|
1514
|
+
toolbar.className = "tile-grid-toolbar tile-grid-toolbar--bottom";
|
|
1515
|
+
this._toolbarElement = toolbar;
|
|
1516
|
+
toolbar.innerHTML = `
|
|
1517
|
+
<div class="toolbar-group toolbar-group--main">
|
|
1518
|
+
<div class="add-widget-container">
|
|
1519
|
+
<button class="toolbar-btn add-widget-btn twm-has-tooltip" data-tooltip="Add widget">
|
|
1520
|
+
<span class="material-symbols-outlined">add</span>
|
|
1521
|
+
<span>Add Widget</span>
|
|
1522
|
+
<span class="material-symbols-outlined dropdown-arrow">expand_less</span>
|
|
1523
|
+
</button>
|
|
1524
|
+
</div>
|
|
1525
|
+
<button class="toolbar-btn reset-layout-btn twm-has-tooltip" data-tooltip="Reset to default layout">
|
|
1526
|
+
<span class="material-symbols-outlined">restart_alt</span>
|
|
1527
|
+
<span class="btn-text">Reset</span>
|
|
1528
|
+
</button>
|
|
1529
|
+
<button class="toolbar-btn export-btn twm-has-tooltip" data-tooltip="Export report">
|
|
1530
|
+
<span class="material-symbols-outlined">download</span>
|
|
1531
|
+
<span class="btn-text">Export</span>
|
|
1532
|
+
</button>
|
|
1533
|
+
</div>
|
|
1534
|
+
`;
|
|
1535
|
+
const addBtn = toolbar.querySelector(".add-widget-btn");
|
|
1536
|
+
const catalog = getWidgetCatalog();
|
|
1537
|
+
this._addWidgetDropdown = new ActionDropdown({
|
|
1538
|
+
trigger: addBtn,
|
|
1539
|
+
options: catalog.map((w) => ({ type: w.type, label: w.title, icon: w.icon, description: w.description })),
|
|
1540
|
+
onSelect: (opt) => this._addWidgetFromDropdown(opt.type),
|
|
1541
|
+
className: "add-widget-dropdown-menu"
|
|
1542
|
+
});
|
|
1543
|
+
toolbar.querySelector(".reset-layout-btn")?.addEventListener("click", () => this._resetLayout());
|
|
1544
|
+
toolbar.querySelector(".export-btn")?.addEventListener("click", () => this._exportReport());
|
|
1545
|
+
this.container.appendChild(toolbar);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Add a widget from the dropdown selection.
|
|
1549
|
+
* @param {string} type - Widget type
|
|
1550
|
+
* @private
|
|
1551
|
+
*/
|
|
1552
|
+
_addWidgetFromDropdown(type) {
|
|
1553
|
+
import("./tile_registry-6WZPTUZV.js").then(({ getWidget: getWidget2 }) => {
|
|
1554
|
+
const WidgetClass = getWidget2(type);
|
|
1555
|
+
if (WidgetClass) {
|
|
1556
|
+
const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
|
|
1557
|
+
const pos = this.findNextPosition(size.w, size.h);
|
|
1558
|
+
this.addTile(type, { ...pos, ...size });
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Add a tile to the grid.
|
|
1564
|
+
* @param {string} type - Widget type
|
|
1565
|
+
* @param {Object} position - Grid position {x, y, w, h}
|
|
1566
|
+
* @param {Object} [config] - Widget configuration
|
|
1567
|
+
* @param {string} [id] - Optional tile ID (generated if not provided)
|
|
1568
|
+
* @returns {import('./tile_base.js').TileBase | null} The created tile or null
|
|
1569
|
+
*/
|
|
1570
|
+
addTile(type, position, config = {}, id = null) {
|
|
1571
|
+
const tileId = id || `tile-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
1572
|
+
const tile = createWidget(type, {
|
|
1573
|
+
id: tileId,
|
|
1574
|
+
grid: this,
|
|
1575
|
+
eventBus: this.eventBus,
|
|
1576
|
+
config,
|
|
1577
|
+
readonly: this.readonly,
|
|
1578
|
+
host: this.host,
|
|
1579
|
+
stateGuard: this.stateGuard,
|
|
1580
|
+
dataSource: this.dataSource
|
|
1581
|
+
});
|
|
1582
|
+
if (!tile) return null;
|
|
1583
|
+
const layoutEntry = {
|
|
1584
|
+
id: tileId,
|
|
1585
|
+
type,
|
|
1586
|
+
x: position.x,
|
|
1587
|
+
y: position.y,
|
|
1588
|
+
w: position.w,
|
|
1589
|
+
h: position.h,
|
|
1590
|
+
config
|
|
1591
|
+
};
|
|
1592
|
+
this.layout.push(layoutEntry);
|
|
1593
|
+
this.tiles.set(tileId, tile);
|
|
1594
|
+
tile.mount(this.gridElement);
|
|
1595
|
+
this._applyTilePosition(tile, layoutEntry);
|
|
1596
|
+
this._setupTileDragHandlers(tile);
|
|
1597
|
+
if (this.data) {
|
|
1598
|
+
tile.fullData = this.fullData;
|
|
1599
|
+
tile.update(this.data);
|
|
1600
|
+
} else if (this.dataSource) {
|
|
1601
|
+
tile.loadData?.();
|
|
1602
|
+
}
|
|
1603
|
+
this._emitLayoutChanged();
|
|
1604
|
+
return tile;
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Remove a tile from the grid.
|
|
1608
|
+
* @param {string} tileId - Tile ID to remove
|
|
1609
|
+
*/
|
|
1610
|
+
removeTile(tileId) {
|
|
1611
|
+
const tile = this.tiles.get(tileId);
|
|
1612
|
+
if (!tile) return;
|
|
1613
|
+
tile.dispose();
|
|
1614
|
+
this.tiles.delete(tileId);
|
|
1615
|
+
this.layout = this.layout.filter((l) => l.id !== tileId);
|
|
1616
|
+
this._emitLayoutChanged();
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Update all tiles with new data.
|
|
1620
|
+
* @param {Object} data - Analytics data
|
|
1621
|
+
*/
|
|
1622
|
+
setData(data) {
|
|
1623
|
+
this.data = data;
|
|
1624
|
+
this.tiles.forEach((tile) => {
|
|
1625
|
+
tile.fullData = this.fullData;
|
|
1626
|
+
tile.update(data);
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Ask every self-fetching tile to load again, bypassing whatever the data
|
|
1631
|
+
* source has cached.
|
|
1632
|
+
*
|
|
1633
|
+
* The counterpart of `setData` for the pull model: `setData` is how a caller
|
|
1634
|
+
* with one dataset for the whole board pushes it, and this is how a caller
|
|
1635
|
+
* whose tiles each own a query says "that underlying data changed". Passing
|
|
1636
|
+
* `ids` narrows it to the tiles a change actually touched, which is what an
|
|
1637
|
+
* invalidation carrying a table id can work out and a broadcast cannot.
|
|
1638
|
+
*
|
|
1639
|
+
* Not awaited and returns nothing: the tiles render themselves as their own
|
|
1640
|
+
* requests land, and a caller that waited for all of them would be waiting on
|
|
1641
|
+
* the slowest tile to show the fastest one.
|
|
1642
|
+
*
|
|
1643
|
+
* @param {string[]|Set<string>|null} [ids] - Tile ids, or null for all.
|
|
1644
|
+
*/
|
|
1645
|
+
reloadAll(ids = null) {
|
|
1646
|
+
if (!this.dataSource) return;
|
|
1647
|
+
const wanted = ids ? new Set(ids) : null;
|
|
1648
|
+
this.tiles.forEach((tile, tileId) => {
|
|
1649
|
+
if (wanted && !wanted.has(tileId)) return;
|
|
1650
|
+
tile.loadData?.({ force: true });
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Get current layout.
|
|
1655
|
+
* @returns {Array} Layout array
|
|
1656
|
+
*/
|
|
1657
|
+
getLayout() {
|
|
1658
|
+
return this.layout.map((l) => ({
|
|
1659
|
+
id: l.id,
|
|
1660
|
+
type: l.type,
|
|
1661
|
+
x: l.x,
|
|
1662
|
+
y: l.y,
|
|
1663
|
+
w: l.w,
|
|
1664
|
+
h: l.h,
|
|
1665
|
+
config: this.tiles.get(l.id)?.config || l.config
|
|
1666
|
+
}));
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Set layout and rebuild tiles.
|
|
1670
|
+
* @param {Array|Object} layout - Layout array or object with tiles property
|
|
1671
|
+
*/
|
|
1672
|
+
setLayout(layout) {
|
|
1673
|
+
this.tiles.forEach((tile) => tile.dispose());
|
|
1674
|
+
this.tiles.clear();
|
|
1675
|
+
this.layout = [];
|
|
1676
|
+
this.gridElement.innerHTML = "";
|
|
1677
|
+
const tilesArray = Array.isArray(layout) ? layout : layout?.tiles || [];
|
|
1678
|
+
tilesArray.forEach((item) => {
|
|
1679
|
+
this.addTile(item.type, {
|
|
1680
|
+
x: item.x,
|
|
1681
|
+
y: item.y,
|
|
1682
|
+
w: item.w,
|
|
1683
|
+
h: item.h
|
|
1684
|
+
}, item.config, item.id);
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Apply CSS grid position to a tile element.
|
|
1689
|
+
* @param {import('./tile_base.js').TileBase} tile
|
|
1690
|
+
* @param {{x: number, y: number, w: number, h: number}} position
|
|
1691
|
+
* @private
|
|
1692
|
+
*/
|
|
1693
|
+
_applyTilePosition(tile, position) {
|
|
1694
|
+
if (!tile.element) return;
|
|
1695
|
+
tile.element.style.gridColumn = `${position.x + 1} / span ${position.w}`;
|
|
1696
|
+
tile.element.style.gridRow = `${position.y + 1} / span ${position.h}`;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Setup drag and resize handlers for a tile.
|
|
1700
|
+
* @param {import('./tile_base.js').TileBase} tile
|
|
1701
|
+
* @private
|
|
1702
|
+
*/
|
|
1703
|
+
_setupTileDragHandlers(tile) {
|
|
1704
|
+
if (!tile.element || this.readonly) return;
|
|
1705
|
+
const dragHandle = tile.element.querySelector(".tile-drag-handle");
|
|
1706
|
+
const resizeHandle = tile.element.querySelector(".tile-resize-handle");
|
|
1707
|
+
if (dragHandle) {
|
|
1708
|
+
dragHandle.addEventListener("pointerdown", (e) => this._onDragStart(tile, e));
|
|
1709
|
+
}
|
|
1710
|
+
if (resizeHandle) {
|
|
1711
|
+
resizeHandle.addEventListener("pointerdown", (e) => this._onResizeStart(tile, e));
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Handle drag start.
|
|
1716
|
+
* @param {import('./tile_base.js').TileBase} tile
|
|
1717
|
+
* @param {PointerEvent} e
|
|
1718
|
+
* @private
|
|
1719
|
+
*/
|
|
1720
|
+
_onDragStart(tile, e) {
|
|
1721
|
+
e.preventDefault();
|
|
1722
|
+
e.stopPropagation();
|
|
1723
|
+
const rect = tile.element.getBoundingClientRect();
|
|
1724
|
+
const gridRect = this.gridElement.getBoundingClientRect();
|
|
1725
|
+
const layoutItem = this.layout.find((l) => l.id === tile.id);
|
|
1726
|
+
if (!layoutItem) return;
|
|
1727
|
+
this._emitBeforeLayoutChange();
|
|
1728
|
+
this._dragState = {
|
|
1729
|
+
tile,
|
|
1730
|
+
tileId: tile.id,
|
|
1731
|
+
startX: e.clientX,
|
|
1732
|
+
startY: e.clientY,
|
|
1733
|
+
startGridX: layoutItem.x,
|
|
1734
|
+
startGridY: layoutItem.y,
|
|
1735
|
+
tileRect: rect,
|
|
1736
|
+
gridRect,
|
|
1737
|
+
w: layoutItem.w,
|
|
1738
|
+
h: layoutItem.h
|
|
1739
|
+
};
|
|
1740
|
+
tile.element.classList.add("dragging");
|
|
1741
|
+
this._createGhost(rect);
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Handle resize start.
|
|
1745
|
+
* @param {import('./tile_base.js').TileBase} tile
|
|
1746
|
+
* @param {PointerEvent} e
|
|
1747
|
+
* @private
|
|
1748
|
+
*/
|
|
1749
|
+
_onResizeStart(tile, e) {
|
|
1750
|
+
e.preventDefault();
|
|
1751
|
+
e.stopPropagation();
|
|
1752
|
+
const layoutItem = this.layout.find((l) => l.id === tile.id);
|
|
1753
|
+
if (!layoutItem) return;
|
|
1754
|
+
const gridRect = this.gridElement.getBoundingClientRect();
|
|
1755
|
+
const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
|
|
1756
|
+
this._emitBeforeLayoutChange();
|
|
1757
|
+
this._resizeState = {
|
|
1758
|
+
tile,
|
|
1759
|
+
tileId: tile.id,
|
|
1760
|
+
startX: e.clientX,
|
|
1761
|
+
startY: e.clientY,
|
|
1762
|
+
startW: layoutItem.w,
|
|
1763
|
+
startH: layoutItem.h,
|
|
1764
|
+
gridRect,
|
|
1765
|
+
cellWidth,
|
|
1766
|
+
x: layoutItem.x,
|
|
1767
|
+
y: layoutItem.y
|
|
1768
|
+
};
|
|
1769
|
+
tile.element.classList.add("resizing");
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Handle pointer move (drag/resize).
|
|
1773
|
+
* @param {PointerEvent} e
|
|
1774
|
+
* @private
|
|
1775
|
+
*/
|
|
1776
|
+
_onPointerMove(e) {
|
|
1777
|
+
if (this._dragState) {
|
|
1778
|
+
this._onDragMove(e);
|
|
1779
|
+
} else if (this._resizeState) {
|
|
1780
|
+
this._onResizeMove(e);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Handle drag move.
|
|
1785
|
+
* @param {PointerEvent} e
|
|
1786
|
+
* @private
|
|
1787
|
+
*/
|
|
1788
|
+
_onDragMove(e) {
|
|
1789
|
+
const state = this._dragState;
|
|
1790
|
+
if (!state) return;
|
|
1791
|
+
const dx = e.clientX - state.startX;
|
|
1792
|
+
const dy = e.clientY - state.startY;
|
|
1793
|
+
const gridRect = state.gridRect;
|
|
1794
|
+
const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
|
|
1795
|
+
const cellHeight = this.rowHeight;
|
|
1796
|
+
const deltaGridX = Math.round(dx / (cellWidth + this.gap));
|
|
1797
|
+
const deltaGridY = Math.round(dy / (cellHeight + this.gap));
|
|
1798
|
+
let newX = state.startGridX + deltaGridX;
|
|
1799
|
+
let newY = state.startGridY + deltaGridY;
|
|
1800
|
+
newX = Math.max(0, Math.min(newX, this.columns - state.w));
|
|
1801
|
+
newY = Math.max(0, newY);
|
|
1802
|
+
if (this._ghostElement) {
|
|
1803
|
+
const ghostLeft = newX * (cellWidth + this.gap);
|
|
1804
|
+
const ghostTop = newY * (cellHeight + this.gap);
|
|
1805
|
+
this._ghostElement.style.transform = `translate(${ghostLeft}px, ${ghostTop}px)`;
|
|
1806
|
+
this._ghostElement.style.width = `${state.w * cellWidth + (state.w - 1) * this.gap}px`;
|
|
1807
|
+
this._ghostElement.style.height = `${state.h * cellHeight + (state.h - 1) * this.gap}px`;
|
|
1808
|
+
}
|
|
1809
|
+
state.pendingX = newX;
|
|
1810
|
+
state.pendingY = newY;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Handle resize move.
|
|
1814
|
+
* @param {PointerEvent} e
|
|
1815
|
+
* @private
|
|
1816
|
+
*/
|
|
1817
|
+
_onResizeMove(e) {
|
|
1818
|
+
const state = this._resizeState;
|
|
1819
|
+
if (!state) return;
|
|
1820
|
+
const dx = e.clientX - state.startX;
|
|
1821
|
+
const dy = e.clientY - state.startY;
|
|
1822
|
+
const deltaW = Math.round(dx / (state.cellWidth + this.gap));
|
|
1823
|
+
const deltaH = Math.round(dy / (this.rowHeight + this.gap));
|
|
1824
|
+
let newW = state.startW + deltaW;
|
|
1825
|
+
let newH = state.startH + deltaH;
|
|
1826
|
+
const tileClass = state.tile.constructor;
|
|
1827
|
+
const constraints = tileClass.SIZE_CONSTRAINTS || { minW: 2, minH: 2, maxW: 12, maxH: 8 };
|
|
1828
|
+
newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
|
|
1829
|
+
newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
|
|
1830
|
+
newW = Math.min(newW, this.columns - state.x);
|
|
1831
|
+
state.tile.element.style.gridColumn = `${state.x + 1} / span ${newW}`;
|
|
1832
|
+
state.tile.element.style.gridRow = `${state.y + 1} / span ${newH}`;
|
|
1833
|
+
state.pendingW = newW;
|
|
1834
|
+
state.pendingH = newH;
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Handle pointer up (end drag/resize).
|
|
1838
|
+
* @param {PointerEvent} e
|
|
1839
|
+
* @private
|
|
1840
|
+
*/
|
|
1841
|
+
_onPointerUp(e) {
|
|
1842
|
+
if (this._dragState) {
|
|
1843
|
+
this._onDragEnd();
|
|
1844
|
+
} else if (this._resizeState) {
|
|
1845
|
+
this._onResizeEnd();
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Handle drag end.
|
|
1850
|
+
* @private
|
|
1851
|
+
*/
|
|
1852
|
+
_onDragEnd() {
|
|
1853
|
+
const state = this._dragState;
|
|
1854
|
+
if (!state) return;
|
|
1855
|
+
state.tile.element.classList.remove("dragging");
|
|
1856
|
+
this._removeGhost();
|
|
1857
|
+
if (state.pendingX !== void 0 && state.pendingY !== void 0) {
|
|
1858
|
+
const layoutItem = this.layout.find((l) => l.id === state.tileId);
|
|
1859
|
+
if (layoutItem) {
|
|
1860
|
+
layoutItem.x = state.pendingX;
|
|
1861
|
+
layoutItem.y = state.pendingY;
|
|
1862
|
+
this._applyTilePosition(state.tile, layoutItem);
|
|
1863
|
+
this._resolveOverlaps(state.tileId);
|
|
1864
|
+
this._emitLayoutChanged();
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
this._dragState = null;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Handle resize end.
|
|
1871
|
+
* @private
|
|
1872
|
+
*/
|
|
1873
|
+
_onResizeEnd() {
|
|
1874
|
+
const state = this._resizeState;
|
|
1875
|
+
if (!state) return;
|
|
1876
|
+
state.tile.element.classList.remove("resizing");
|
|
1877
|
+
if (state.pendingW !== void 0 && state.pendingH !== void 0) {
|
|
1878
|
+
const layoutItem = this.layout.find((l) => l.id === state.tileId);
|
|
1879
|
+
if (layoutItem) {
|
|
1880
|
+
layoutItem.w = state.pendingW;
|
|
1881
|
+
layoutItem.h = state.pendingH;
|
|
1882
|
+
this._resolveOverlaps(state.tileId);
|
|
1883
|
+
this._emitLayoutChanged();
|
|
1884
|
+
if (this.data) {
|
|
1885
|
+
state.tile.fullData = this.fullData;
|
|
1886
|
+
state.tile.update(this.data);
|
|
1887
|
+
} else if (this.dataSource) {
|
|
1888
|
+
state.tile.onResize?.();
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
this._resizeState = null;
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Check if two layout items overlap.
|
|
1896
|
+
* @param {Object} a - First layout item {x, y, w, h}
|
|
1897
|
+
* @param {Object} b - Second layout item {x, y, w, h}
|
|
1898
|
+
* @returns {boolean}
|
|
1899
|
+
* @private
|
|
1900
|
+
*/
|
|
1901
|
+
_tilesOverlap(a, b) {
|
|
1902
|
+
return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Resolve overlaps by pushing tiles down.
|
|
1906
|
+
* @param {string} sourceTileId - The tile that was resized/moved
|
|
1907
|
+
* @private
|
|
1908
|
+
*/
|
|
1909
|
+
_resolveOverlaps(sourceTileId) {
|
|
1910
|
+
const sourceItem = this.layout.find((l) => l.id === sourceTileId);
|
|
1911
|
+
if (!sourceItem) return;
|
|
1912
|
+
const otherTiles = this.layout.filter((l) => l.id !== sourceTileId).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1913
|
+
let hasChanges = true;
|
|
1914
|
+
let iterations = 0;
|
|
1915
|
+
const maxIterations = 100;
|
|
1916
|
+
while (hasChanges && iterations < maxIterations) {
|
|
1917
|
+
hasChanges = false;
|
|
1918
|
+
iterations++;
|
|
1919
|
+
for (const item of otherTiles) {
|
|
1920
|
+
if (this._tilesOverlap(sourceItem, item)) {
|
|
1921
|
+
const newY = sourceItem.y + sourceItem.h;
|
|
1922
|
+
if (item.y !== newY) {
|
|
1923
|
+
item.y = newY;
|
|
1924
|
+
hasChanges = true;
|
|
1925
|
+
const tile = this.tiles.get(item.id);
|
|
1926
|
+
if (tile) {
|
|
1927
|
+
this._applyTilePosition(tile, item);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
for (let i = 0; i < otherTiles.length; i++) {
|
|
1933
|
+
for (let j = i + 1; j < otherTiles.length; j++) {
|
|
1934
|
+
if (this._tilesOverlap(otherTiles[i], otherTiles[j])) {
|
|
1935
|
+
const pusher = otherTiles[i];
|
|
1936
|
+
const pushed = otherTiles[j];
|
|
1937
|
+
const newY = pusher.y + pusher.h;
|
|
1938
|
+
if (pushed.y < newY && this._tilesOverlap(pusher, pushed)) {
|
|
1939
|
+
pushed.y = newY;
|
|
1940
|
+
hasChanges = true;
|
|
1941
|
+
const tile = this.tiles.get(pushed.id);
|
|
1942
|
+
if (tile) {
|
|
1943
|
+
this._applyTilePosition(tile, pushed);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
this._compactLayout();
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Compact the layout by removing vertical gaps.
|
|
1954
|
+
* @private
|
|
1955
|
+
*/
|
|
1956
|
+
_compactLayout() {
|
|
1957
|
+
const sortedTiles = [...this.layout].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1958
|
+
for (const item of sortedTiles) {
|
|
1959
|
+
let newY = 0;
|
|
1960
|
+
while (newY < item.y) {
|
|
1961
|
+
const testPosition = { ...item, y: newY };
|
|
1962
|
+
let hasOverlap = false;
|
|
1963
|
+
for (const other of this.layout) {
|
|
1964
|
+
if (other.id === item.id) continue;
|
|
1965
|
+
if (this._tilesOverlap(testPosition, other)) {
|
|
1966
|
+
hasOverlap = true;
|
|
1967
|
+
newY = other.y + other.h;
|
|
1968
|
+
break;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
if (!hasOverlap) {
|
|
1972
|
+
break;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (newY < item.y) {
|
|
1976
|
+
item.y = newY;
|
|
1977
|
+
const tile = this.tiles.get(item.id);
|
|
1978
|
+
if (tile) {
|
|
1979
|
+
this._applyTilePosition(tile, item);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Create ghost element for drag preview.
|
|
1986
|
+
* @param {DOMRect} rect - Original tile rect
|
|
1987
|
+
* @private
|
|
1988
|
+
*/
|
|
1989
|
+
_createGhost(rect) {
|
|
1990
|
+
this._ghostElement = document.createElement("div");
|
|
1991
|
+
this._ghostElement.className = "tile-ghost";
|
|
1992
|
+
this.gridElement.appendChild(this._ghostElement);
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* Remove ghost element.
|
|
1996
|
+
* @private
|
|
1997
|
+
*/
|
|
1998
|
+
_removeGhost() {
|
|
1999
|
+
if (this._ghostElement) {
|
|
2000
|
+
this._ghostElement.remove();
|
|
2001
|
+
this._ghostElement = null;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Find next available position for a new tile.
|
|
2006
|
+
* @param {number} w - Width in cells
|
|
2007
|
+
* @param {number} h - Height in cells
|
|
2008
|
+
* @returns {{x: number, y: number}}
|
|
2009
|
+
*/
|
|
2010
|
+
findNextPosition(w, h) {
|
|
2011
|
+
const maxY = Math.max(...this.layout.map((l) => l.y + l.h), 0);
|
|
2012
|
+
const occupied = /* @__PURE__ */ new Set();
|
|
2013
|
+
this.layout.forEach((l) => {
|
|
2014
|
+
for (let x = l.x; x < l.x + l.w; x++) {
|
|
2015
|
+
for (let y = l.y; y < l.y + l.h; y++) {
|
|
2016
|
+
occupied.add(`${x},${y}`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
for (let y = 0; y <= maxY + 1; y++) {
|
|
2021
|
+
for (let x = 0; x <= this.columns - w; x++) {
|
|
2022
|
+
let fits = true;
|
|
2023
|
+
for (let dx = 0; dx < w && fits; dx++) {
|
|
2024
|
+
for (let dy = 0; dy < h && fits; dy++) {
|
|
2025
|
+
if (occupied.has(`${x + dx},${y + dy}`)) {
|
|
2026
|
+
fits = false;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
if (fits) {
|
|
2031
|
+
return { x, y };
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return { x: 0, y: maxY + 1 };
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Handle tile config request.
|
|
2039
|
+
* @param {CustomEvent} e
|
|
2040
|
+
* @private
|
|
2041
|
+
*/
|
|
2042
|
+
_onTileConfigRequest(e) {
|
|
2043
|
+
const { tileId, tileType, config, schema } = e.detail;
|
|
2044
|
+
this.eventBus?.emit?.("grid:config-modal-show", {
|
|
2045
|
+
tileId,
|
|
2046
|
+
tileType,
|
|
2047
|
+
config,
|
|
2048
|
+
schema,
|
|
2049
|
+
variables: this.data ? this._getVariableList() : [],
|
|
2050
|
+
onSave: (newConfig) => {
|
|
2051
|
+
const tile = this.tiles.get(tileId);
|
|
2052
|
+
if (tile) {
|
|
2053
|
+
tile.fullData = this.fullData;
|
|
2054
|
+
tile.update(this.data, newConfig);
|
|
2055
|
+
if (this.dataSource) tile.loadData?.({ force: true });
|
|
2056
|
+
const layoutItem = this.layout.find((l) => l.id === tileId);
|
|
2057
|
+
if (layoutItem) {
|
|
2058
|
+
layoutItem.config = newConfig;
|
|
2059
|
+
}
|
|
2060
|
+
this._emitLayoutChanged();
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Handle tile remove request.
|
|
2067
|
+
* Note: Let the event bubble up to the dashboard which handles confirmation.
|
|
2068
|
+
* @param {CustomEvent} e
|
|
2069
|
+
* @private
|
|
2070
|
+
*/
|
|
2071
|
+
_onTileRemoveRequest(e) {
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Get list of available variables from data.
|
|
2075
|
+
* @returns {Array<{value: string, label: string}>}
|
|
2076
|
+
* @private
|
|
2077
|
+
*/
|
|
2078
|
+
_getVariableList() {
|
|
2079
|
+
if (!this.data) return [];
|
|
2080
|
+
const variables = [];
|
|
2081
|
+
if (this.data.stocks) {
|
|
2082
|
+
Object.keys(this.data.stocks).forEach((name) => {
|
|
2083
|
+
variables.push({ value: name, label: name });
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
if (this.data.indicators) {
|
|
2087
|
+
Object.keys(this.data.indicators).forEach((name) => {
|
|
2088
|
+
variables.push({ value: name, label: name });
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
return variables;
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Show add widget menu.
|
|
2095
|
+
* @private
|
|
2096
|
+
*/
|
|
2097
|
+
_showAddWidgetMenu() {
|
|
2098
|
+
this.eventBus?.emit?.("grid:add-widget-menu-show", {
|
|
2099
|
+
onSelect: (type) => {
|
|
2100
|
+
const { getWidget: getWidget2 } = (init_tile_registry(), __toCommonJS(tile_registry_exports));
|
|
2101
|
+
const WidgetClass = getWidget2(type);
|
|
2102
|
+
if (WidgetClass) {
|
|
2103
|
+
const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
|
|
2104
|
+
const pos = this.findNextPosition(size.w, size.h);
|
|
2105
|
+
this.addTile(type, { ...pos, ...size });
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Reset to default layout.
|
|
2112
|
+
* @private
|
|
2113
|
+
*/
|
|
2114
|
+
_resetLayout() {
|
|
2115
|
+
this.eventBus?.emit?.("grid:reset-layout-confirm", {
|
|
2116
|
+
onConfirm: () => {
|
|
2117
|
+
this.eventBus?.emit?.("grid:layout-reset-requested");
|
|
2118
|
+
}
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Export report.
|
|
2123
|
+
* @private
|
|
2124
|
+
*/
|
|
2125
|
+
_exportReport() {
|
|
2126
|
+
this.eventBus?.emit?.("grid:export-requested", {
|
|
2127
|
+
gridElement: this.gridElement,
|
|
2128
|
+
layout: this.getLayout()
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Notify that a layout mutation is about to begin (drag/resize start).
|
|
2133
|
+
* @private
|
|
2134
|
+
*/
|
|
2135
|
+
_emitBeforeLayoutChange() {
|
|
2136
|
+
if (typeof this._onBeforeLayoutChangeCallback === "function") {
|
|
2137
|
+
this._onBeforeLayoutChangeCallback(this.getLayout());
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Emit layout changed event and call the onLayoutChange callback.
|
|
2142
|
+
* @private
|
|
2143
|
+
*/
|
|
2144
|
+
_emitLayoutChanged() {
|
|
2145
|
+
const layout = this.getLayout();
|
|
2146
|
+
this.eventBus?.emit?.("grid:layout-changed", { layout });
|
|
2147
|
+
if (typeof this._onLayoutChangeCallback === "function") {
|
|
2148
|
+
this._onLayoutChangeCallback(layout);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Clean up resources.
|
|
2153
|
+
*/
|
|
2154
|
+
dispose() {
|
|
2155
|
+
document.removeEventListener("pointermove", this._boundOnPointerMove);
|
|
2156
|
+
document.removeEventListener("pointerup", this._boundOnPointerUp);
|
|
2157
|
+
this._addWidgetDropdown?.destroy();
|
|
2158
|
+
this._addWidgetDropdown = null;
|
|
2159
|
+
this.tiles.forEach((tile) => tile.dispose());
|
|
2160
|
+
this.tiles.clear();
|
|
2161
|
+
this.layout = [];
|
|
2162
|
+
if (this.gridElement) {
|
|
2163
|
+
this.gridElement.remove();
|
|
2164
|
+
this.gridElement = null;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
};
|
|
2168
|
+
|
|
2169
|
+
// tiles.js
|
|
2170
|
+
init_tile_registry();
|
|
2171
|
+
export {
|
|
2172
|
+
FieldTypes,
|
|
2173
|
+
LayoutPersistence,
|
|
2174
|
+
TileBase,
|
|
2175
|
+
TileGrid,
|
|
2176
|
+
TileRegistry,
|
|
2177
|
+
configureLayoutPersistence,
|
|
2178
|
+
createWidget,
|
|
2179
|
+
formatVariableLabel,
|
|
2180
|
+
getAllWidgetTypes,
|
|
2181
|
+
getFieldOptions,
|
|
2182
|
+
getLayoutPersistence,
|
|
2183
|
+
getNamespaceOptions,
|
|
2184
|
+
getVariableOptions,
|
|
2185
|
+
getWidget,
|
|
2186
|
+
getWidgetCatalog,
|
|
2187
|
+
getWidgetMetadata,
|
|
2188
|
+
hasWidget,
|
|
2189
|
+
mergeWithDefaults,
|
|
2190
|
+
registerWidget,
|
|
2191
|
+
setTileLogger,
|
|
2192
|
+
validateConfig,
|
|
2193
|
+
validateField
|
|
2194
|
+
};
|
|
2195
|
+
//# sourceMappingURL=tiles.js.map
|