flexdesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,905 @@
1
+ /**
2
+ * tile_grid.js
3
+ *
4
+ * Core draggable tile grid system.
5
+ * Manages a 12-column CSS grid with drag-and-drop repositioning and resize functionality.
6
+ */
7
+
8
+ import { createWidget, getWidgetCatalog } from './tile_registry.js';
9
+ import { ActionDropdown } from '../ui/components/action_dropdown.js';
10
+
11
+ export class TileGrid {
12
+ /**
13
+ * @param {Object} options
14
+ * @param {HTMLElement} options.container - Container element for the grid
15
+ * @param {number} [options.columns=12] - Number of grid columns
16
+ * @param {number} [options.rowHeight=80] - Height of each grid row in pixels
17
+ * @param {number} [options.gap=16] - Gap between tiles in pixels
18
+ * @param {Object} [options.eventBus] - Event bus for cross-component communication
19
+ * @param {boolean} [options.showToolbar=true] - Whether to show built-in toolbar
20
+ * @param {Function} [options.onLayoutChange] - Callback when layout changes
21
+ * @param {Function} [options.onAddWidget] - Callback for add widget action
22
+ * @param {Function} [options.onResetLayout] - Callback for reset layout action
23
+ * @param {Function} [options.onExport] - Callback for export action
24
+ */
25
+ constructor({ container, columns = 12, rowHeight = 80, gap = 16, eventBus = null,
26
+ showToolbar = true, readonly = false, onLayoutChange, onBeforeLayoutChange,
27
+ onAddWidget, onResetLayout, onExport,
28
+ // Handed down to every tile. TileBase used to import appHost() from
29
+ // ui/js/ecoagent/ to back Expand -> Export CSV/PNG; a grid that has
30
+ // no host simply gets the Blob-download fallback the host contract
31
+ // promises. It is not an error to have none.
32
+ host = null, stateGuard = null }) {
33
+ this.host = host;
34
+ this.stateGuard = stateGuard;
35
+ this.container = container;
36
+ this.columns = columns;
37
+ this.rowHeight = rowHeight;
38
+ this.gap = gap;
39
+ this.eventBus = eventBus;
40
+ this.showToolbar = showToolbar;
41
+ this.readonly = readonly;
42
+ this._onLayoutChangeCallback = onLayoutChange;
43
+ this._onBeforeLayoutChangeCallback = onBeforeLayoutChange;
44
+ this._onAddWidgetCallback = onAddWidget;
45
+ this._onResetLayoutCallback = onResetLayout;
46
+ this._onExportCallback = onExport;
47
+
48
+ /** @type {Map<string, import('./tile_base.js').TileBase>} */
49
+ this.tiles = new Map();
50
+
51
+ /** @type {Array<{id: string, x: number, y: number, w: number, h: number}>} */
52
+ this.layout = [];
53
+
54
+ /** @type {Set<string>|null} IDs of documentation cells (for "Show in Documentation" visibility) */
55
+ this.documentationCellIds = null;
56
+
57
+ this.gridElement = null;
58
+ this.data = null;
59
+ this.fullData = null;
60
+
61
+ // Drag state
62
+ this._dragState = null;
63
+ this._resizeState = null;
64
+ this._ghostElement = null;
65
+
66
+ // Bound handlers for cleanup
67
+ this._boundOnPointerMove = this._onPointerMove.bind(this);
68
+ this._boundOnPointerUp = this._onPointerUp.bind(this);
69
+
70
+ this._init();
71
+ }
72
+
73
+ /**
74
+ * Initialize the grid container.
75
+ * @private
76
+ */
77
+ _init() {
78
+ // Create grid element
79
+ this.gridElement = document.createElement('div');
80
+ this.gridElement.className = 'tile-grid';
81
+ this.gridElement.style.setProperty('--grid-columns', this.columns);
82
+ this.gridElement.style.setProperty('--grid-row-height', `${this.rowHeight}px`);
83
+ this.gridElement.style.setProperty('--grid-gap', `${this.gap}px`);
84
+
85
+ // Add grid to container
86
+ this.container.innerHTML = '';
87
+ this.container.appendChild(this.gridElement);
88
+
89
+ // Add toolbar (can be disabled for external toolbar)
90
+ if (this.showToolbar) {
91
+ this._buildToolbar();
92
+ }
93
+
94
+ // Listen for tile events
95
+ this.gridElement.addEventListener('tile:config-request', this._onTileConfigRequest.bind(this));
96
+ this.gridElement.addEventListener('tile:remove-request', this._onTileRemoveRequest.bind(this));
97
+
98
+ // Global pointer events for drag/resize
99
+ document.addEventListener('pointermove', this._boundOnPointerMove);
100
+ document.addEventListener('pointerup', this._boundOnPointerUp);
101
+ }
102
+
103
+ /**
104
+ * Build the dashboard bottom toolbar with add widget dropdown.
105
+ * @private
106
+ */
107
+ _buildToolbar() {
108
+ const toolbar = document.createElement('div');
109
+ toolbar.className = 'tile-grid-toolbar tile-grid-toolbar--bottom';
110
+ this._toolbarElement = toolbar;
111
+
112
+ toolbar.innerHTML = `
113
+ <div class="toolbar-group toolbar-group--main">
114
+ <div class="add-widget-container">
115
+ <button class="toolbar-btn add-widget-btn twm-has-tooltip" data-tooltip="Add widget">
116
+ <span class="material-symbols-outlined">add</span>
117
+ <span>Add Widget</span>
118
+ <span class="material-symbols-outlined dropdown-arrow">expand_less</span>
119
+ </button>
120
+ </div>
121
+ <button class="toolbar-btn reset-layout-btn twm-has-tooltip" data-tooltip="Reset to default layout">
122
+ <span class="material-symbols-outlined">restart_alt</span>
123
+ <span class="btn-text">Reset</span>
124
+ </button>
125
+ <button class="toolbar-btn export-btn twm-has-tooltip" data-tooltip="Export report">
126
+ <span class="material-symbols-outlined">download</span>
127
+ <span class="btn-text">Export</span>
128
+ </button>
129
+ </div>
130
+ `;
131
+
132
+ // Create add-widget dropdown via ActionDropdown
133
+ const addBtn = toolbar.querySelector('.add-widget-btn');
134
+ const catalog = getWidgetCatalog();
135
+ this._addWidgetDropdown = new ActionDropdown({
136
+ trigger: addBtn,
137
+ options: catalog.map(w => ({ type: w.type, label: w.title, icon: w.icon, description: w.description })),
138
+ onSelect: (opt) => this._addWidgetFromDropdown(opt.type),
139
+ className: 'add-widget-dropdown-menu'
140
+ });
141
+
142
+ toolbar.querySelector('.reset-layout-btn')?.addEventListener('click', () => this._resetLayout());
143
+ toolbar.querySelector('.export-btn')?.addEventListener('click', () => this._exportReport());
144
+
145
+ // Insert after grid element (at bottom)
146
+ this.container.appendChild(toolbar);
147
+ }
148
+
149
+ /**
150
+ * Add a widget from the dropdown selection.
151
+ * @param {string} type - Widget type
152
+ * @private
153
+ */
154
+ _addWidgetFromDropdown(type) {
155
+ import('./tile_registry.js').then(({ getWidget }) => {
156
+ const WidgetClass = getWidget(type);
157
+ if (WidgetClass) {
158
+ const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
159
+ const pos = this.findNextPosition(size.w, size.h);
160
+ this.addTile(type, { ...pos, ...size });
161
+ }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Add a tile to the grid.
167
+ * @param {string} type - Widget type
168
+ * @param {Object} position - Grid position {x, y, w, h}
169
+ * @param {Object} [config] - Widget configuration
170
+ * @param {string} [id] - Optional tile ID (generated if not provided)
171
+ * @returns {import('./tile_base.js').TileBase | null} The created tile or null
172
+ */
173
+ addTile(type, position, config = {}, id = null) {
174
+ const tileId = id || `tile-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
175
+
176
+ const tile = createWidget(type, {
177
+ id: tileId,
178
+ grid: this,
179
+ eventBus: this.eventBus,
180
+ config,
181
+ readonly: this.readonly,
182
+ host: this.host,
183
+ stateGuard: this.stateGuard,
184
+ });
185
+
186
+ if (!tile) return null;
187
+
188
+ // Add to layout
189
+ const layoutEntry = {
190
+ id: tileId,
191
+ type,
192
+ x: position.x,
193
+ y: position.y,
194
+ w: position.w,
195
+ h: position.h,
196
+ config
197
+ };
198
+ this.layout.push(layoutEntry);
199
+
200
+ // Store tile instance
201
+ this.tiles.set(tileId, tile);
202
+
203
+ // Mount to grid
204
+ tile.mount(this.gridElement);
205
+ this._applyTilePosition(tile, layoutEntry);
206
+
207
+ // Setup drag handles
208
+ this._setupTileDragHandlers(tile);
209
+
210
+ // Render with current data if available
211
+ if (this.data) {
212
+ tile.fullData = this.fullData;
213
+ tile.update(this.data);
214
+ }
215
+
216
+ // Emit layout changed
217
+ this._emitLayoutChanged();
218
+
219
+ return tile;
220
+ }
221
+
222
+ /**
223
+ * Remove a tile from the grid.
224
+ * @param {string} tileId - Tile ID to remove
225
+ */
226
+ removeTile(tileId) {
227
+ const tile = this.tiles.get(tileId);
228
+ if (!tile) return;
229
+
230
+ tile.dispose();
231
+ this.tiles.delete(tileId);
232
+
233
+ // Remove from layout
234
+ this.layout = this.layout.filter(l => l.id !== tileId);
235
+
236
+ this._emitLayoutChanged();
237
+ }
238
+
239
+ /**
240
+ * Update all tiles with new data.
241
+ * @param {Object} data - Analytics data
242
+ */
243
+ setData(data) {
244
+ this.data = data;
245
+ this.tiles.forEach(tile => {
246
+ tile.fullData = this.fullData;
247
+ tile.update(data);
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Get current layout.
253
+ * @returns {Array} Layout array
254
+ */
255
+ getLayout() {
256
+ return this.layout.map(l => ({
257
+ id: l.id,
258
+ type: l.type,
259
+ x: l.x,
260
+ y: l.y,
261
+ w: l.w,
262
+ h: l.h,
263
+ config: this.tiles.get(l.id)?.config || l.config
264
+ }));
265
+ }
266
+
267
+ /**
268
+ * Set layout and rebuild tiles.
269
+ * @param {Array|Object} layout - Layout array or object with tiles property
270
+ */
271
+ setLayout(layout) {
272
+ // Clear existing tiles
273
+ this.tiles.forEach(tile => tile.dispose());
274
+ this.tiles.clear();
275
+ this.layout = [];
276
+
277
+ // Clear grid content (keep toolbar)
278
+ this.gridElement.innerHTML = '';
279
+
280
+ // Handle both array and object with tiles property
281
+ const tilesArray = Array.isArray(layout) ? layout : (layout?.tiles || []);
282
+
283
+ // Rebuild from layout
284
+ tilesArray.forEach(item => {
285
+ this.addTile(item.type, {
286
+ x: item.x,
287
+ y: item.y,
288
+ w: item.w,
289
+ h: item.h
290
+ }, item.config, item.id);
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Apply CSS grid position to a tile element.
296
+ * @param {import('./tile_base.js').TileBase} tile
297
+ * @param {{x: number, y: number, w: number, h: number}} position
298
+ * @private
299
+ */
300
+ _applyTilePosition(tile, position) {
301
+ if (!tile.element) return;
302
+
303
+ tile.element.style.gridColumn = `${position.x + 1} / span ${position.w}`;
304
+ tile.element.style.gridRow = `${position.y + 1} / span ${position.h}`;
305
+ }
306
+
307
+ /**
308
+ * Setup drag and resize handlers for a tile.
309
+ * @param {import('./tile_base.js').TileBase} tile
310
+ * @private
311
+ */
312
+ _setupTileDragHandlers(tile) {
313
+ if (!tile.element || this.readonly) return;
314
+
315
+ const dragHandle = tile.element.querySelector('.tile-drag-handle');
316
+ const resizeHandle = tile.element.querySelector('.tile-resize-handle');
317
+
318
+ // Drag handle
319
+ if (dragHandle) {
320
+ dragHandle.addEventListener('pointerdown', (e) => this._onDragStart(tile, e));
321
+ }
322
+
323
+ // Resize handle
324
+ if (resizeHandle) {
325
+ resizeHandle.addEventListener('pointerdown', (e) => this._onResizeStart(tile, e));
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Handle drag start.
331
+ * @param {import('./tile_base.js').TileBase} tile
332
+ * @param {PointerEvent} e
333
+ * @private
334
+ */
335
+ _onDragStart(tile, e) {
336
+ e.preventDefault();
337
+ e.stopPropagation();
338
+
339
+ const rect = tile.element.getBoundingClientRect();
340
+ const gridRect = this.gridElement.getBoundingClientRect();
341
+
342
+ const layoutItem = this.layout.find(l => l.id === tile.id);
343
+ if (!layoutItem) return;
344
+
345
+ // Notify before mutation so caller can snapshot current state
346
+ this._emitBeforeLayoutChange();
347
+
348
+ this._dragState = {
349
+ tile,
350
+ tileId: tile.id,
351
+ startX: e.clientX,
352
+ startY: e.clientY,
353
+ startGridX: layoutItem.x,
354
+ startGridY: layoutItem.y,
355
+ tileRect: rect,
356
+ gridRect,
357
+ w: layoutItem.w,
358
+ h: layoutItem.h
359
+ };
360
+
361
+ tile.element.classList.add('dragging');
362
+
363
+ // Create ghost element
364
+ this._createGhost(rect);
365
+ }
366
+
367
+ /**
368
+ * Handle resize start.
369
+ * @param {import('./tile_base.js').TileBase} tile
370
+ * @param {PointerEvent} e
371
+ * @private
372
+ */
373
+ _onResizeStart(tile, e) {
374
+ e.preventDefault();
375
+ e.stopPropagation();
376
+
377
+ const layoutItem = this.layout.find(l => l.id === tile.id);
378
+ if (!layoutItem) return;
379
+
380
+ const gridRect = this.gridElement.getBoundingClientRect();
381
+ const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
382
+
383
+ // Notify before mutation so caller can snapshot current state
384
+ this._emitBeforeLayoutChange();
385
+
386
+ this._resizeState = {
387
+ tile,
388
+ tileId: tile.id,
389
+ startX: e.clientX,
390
+ startY: e.clientY,
391
+ startW: layoutItem.w,
392
+ startH: layoutItem.h,
393
+ gridRect,
394
+ cellWidth,
395
+ x: layoutItem.x,
396
+ y: layoutItem.y
397
+ };
398
+
399
+ tile.element.classList.add('resizing');
400
+ }
401
+
402
+ /**
403
+ * Handle pointer move (drag/resize).
404
+ * @param {PointerEvent} e
405
+ * @private
406
+ */
407
+ _onPointerMove(e) {
408
+ if (this._dragState) {
409
+ this._onDragMove(e);
410
+ } else if (this._resizeState) {
411
+ this._onResizeMove(e);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Handle drag move.
417
+ * @param {PointerEvent} e
418
+ * @private
419
+ */
420
+ _onDragMove(e) {
421
+ const state = this._dragState;
422
+ if (!state) return;
423
+
424
+ const dx = e.clientX - state.startX;
425
+ const dy = e.clientY - state.startY;
426
+
427
+ // Calculate cell dimensions
428
+ const gridRect = state.gridRect;
429
+ const cellWidth = (gridRect.width - (this.columns - 1) * this.gap) / this.columns;
430
+ const cellHeight = this.rowHeight;
431
+
432
+ // Calculate new grid position
433
+ const deltaGridX = Math.round(dx / (cellWidth + this.gap));
434
+ const deltaGridY = Math.round(dy / (cellHeight + this.gap));
435
+
436
+ let newX = state.startGridX + deltaGridX;
437
+ let newY = state.startGridY + deltaGridY;
438
+
439
+ // Clamp to grid bounds
440
+ newX = Math.max(0, Math.min(newX, this.columns - state.w));
441
+ newY = Math.max(0, newY);
442
+
443
+ // Update ghost position
444
+ if (this._ghostElement) {
445
+ const ghostLeft = newX * (cellWidth + this.gap);
446
+ const ghostTop = newY * (cellHeight + this.gap);
447
+ this._ghostElement.style.transform = `translate(${ghostLeft}px, ${ghostTop}px)`;
448
+ this._ghostElement.style.width = `${state.w * cellWidth + (state.w - 1) * this.gap}px`;
449
+ this._ghostElement.style.height = `${state.h * cellHeight + (state.h - 1) * this.gap}px`;
450
+ }
451
+
452
+ // Store pending position
453
+ state.pendingX = newX;
454
+ state.pendingY = newY;
455
+ }
456
+
457
+ /**
458
+ * Handle resize move.
459
+ * @param {PointerEvent} e
460
+ * @private
461
+ */
462
+ _onResizeMove(e) {
463
+ const state = this._resizeState;
464
+ if (!state) return;
465
+
466
+ const dx = e.clientX - state.startX;
467
+ const dy = e.clientY - state.startY;
468
+
469
+ // Calculate new size in grid cells
470
+ const deltaW = Math.round(dx / (state.cellWidth + this.gap));
471
+ const deltaH = Math.round(dy / (this.rowHeight + this.gap));
472
+
473
+ let newW = state.startW + deltaW;
474
+ let newH = state.startH + deltaH;
475
+
476
+ // Get constraints
477
+ const tileClass = state.tile.constructor;
478
+ const constraints = tileClass.SIZE_CONSTRAINTS || { minW: 2, minH: 2, maxW: 12, maxH: 8 };
479
+
480
+ // Apply constraints
481
+ newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
482
+ newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
483
+
484
+ // Don't exceed grid width
485
+ newW = Math.min(newW, this.columns - state.x);
486
+
487
+ // Live update tile size
488
+ state.tile.element.style.gridColumn = `${state.x + 1} / span ${newW}`;
489
+ state.tile.element.style.gridRow = `${state.y + 1} / span ${newH}`;
490
+
491
+ state.pendingW = newW;
492
+ state.pendingH = newH;
493
+ }
494
+
495
+ /**
496
+ * Handle pointer up (end drag/resize).
497
+ * @param {PointerEvent} e
498
+ * @private
499
+ */
500
+ _onPointerUp(e) {
501
+ if (this._dragState) {
502
+ this._onDragEnd();
503
+ } else if (this._resizeState) {
504
+ this._onResizeEnd();
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Handle drag end.
510
+ * @private
511
+ */
512
+ _onDragEnd() {
513
+ const state = this._dragState;
514
+ if (!state) return;
515
+
516
+ state.tile.element.classList.remove('dragging');
517
+ this._removeGhost();
518
+
519
+ // Update layout if position changed
520
+ if (state.pendingX !== undefined && state.pendingY !== undefined) {
521
+ const layoutItem = this.layout.find(l => l.id === state.tileId);
522
+ if (layoutItem) {
523
+ layoutItem.x = state.pendingX;
524
+ layoutItem.y = state.pendingY;
525
+ this._applyTilePosition(state.tile, layoutItem);
526
+
527
+ // Resolve overlaps - push other tiles down
528
+ this._resolveOverlaps(state.tileId);
529
+
530
+ this._emitLayoutChanged();
531
+ }
532
+ }
533
+
534
+ this._dragState = null;
535
+ }
536
+
537
+ /**
538
+ * Handle resize end.
539
+ * @private
540
+ */
541
+ _onResizeEnd() {
542
+ const state = this._resizeState;
543
+ if (!state) return;
544
+
545
+ state.tile.element.classList.remove('resizing');
546
+
547
+ // Update layout if size changed
548
+ if (state.pendingW !== undefined && state.pendingH !== undefined) {
549
+ const layoutItem = this.layout.find(l => l.id === state.tileId);
550
+ if (layoutItem) {
551
+ layoutItem.w = state.pendingW;
552
+ layoutItem.h = state.pendingH;
553
+
554
+ // Resolve overlaps - push other tiles down
555
+ this._resolveOverlaps(state.tileId);
556
+
557
+ this._emitLayoutChanged();
558
+
559
+ // Re-render tile content for new size
560
+ if (this.data) {
561
+ state.tile.fullData = this.fullData;
562
+ state.tile.update(this.data);
563
+ }
564
+ }
565
+ }
566
+
567
+ this._resizeState = null;
568
+ }
569
+
570
+ /**
571
+ * Check if two layout items overlap.
572
+ * @param {Object} a - First layout item {x, y, w, h}
573
+ * @param {Object} b - Second layout item {x, y, w, h}
574
+ * @returns {boolean}
575
+ * @private
576
+ */
577
+ _tilesOverlap(a, b) {
578
+ return !(
579
+ a.x + a.w <= b.x ||
580
+ b.x + b.w <= a.x ||
581
+ a.y + a.h <= b.y ||
582
+ b.y + b.h <= a.y
583
+ );
584
+ }
585
+
586
+ /**
587
+ * Resolve overlaps by pushing tiles down.
588
+ * @param {string} sourceTileId - The tile that was resized/moved
589
+ * @private
590
+ */
591
+ _resolveOverlaps(sourceTileId) {
592
+ const sourceItem = this.layout.find(l => l.id === sourceTileId);
593
+ if (!sourceItem) return;
594
+
595
+ // Sort tiles by y position (top to bottom), then by x
596
+ const otherTiles = this.layout
597
+ .filter(l => l.id !== sourceTileId)
598
+ .sort((a, b) => a.y - b.y || a.x - b.x);
599
+
600
+ let hasChanges = true;
601
+ let iterations = 0;
602
+ const maxIterations = 100; // Prevent infinite loops
603
+
604
+ while (hasChanges && iterations < maxIterations) {
605
+ hasChanges = false;
606
+ iterations++;
607
+
608
+ for (const item of otherTiles) {
609
+ if (this._tilesOverlap(sourceItem, item)) {
610
+ // Push this tile down to be below the source tile
611
+ const newY = sourceItem.y + sourceItem.h;
612
+ if (item.y !== newY) {
613
+ item.y = newY;
614
+ hasChanges = true;
615
+
616
+ // Apply the new position visually
617
+ const tile = this.tiles.get(item.id);
618
+ if (tile) {
619
+ this._applyTilePosition(tile, item);
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ // Check for cascading overlaps among the moved tiles
626
+ for (let i = 0; i < otherTiles.length; i++) {
627
+ for (let j = i + 1; j < otherTiles.length; j++) {
628
+ if (this._tilesOverlap(otherTiles[i], otherTiles[j])) {
629
+ // Push the lower-index tile (earlier in sort order) doesn't need to move
630
+ // Push the higher one down
631
+ const pusher = otherTiles[i];
632
+ const pushed = otherTiles[j];
633
+ const newY = pusher.y + pusher.h;
634
+ if (pushed.y < newY && this._tilesOverlap(pusher, pushed)) {
635
+ pushed.y = newY;
636
+ hasChanges = true;
637
+
638
+ const tile = this.tiles.get(pushed.id);
639
+ if (tile) {
640
+ this._applyTilePosition(tile, pushed);
641
+ }
642
+ }
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ // Compact the layout - remove vertical gaps
649
+ this._compactLayout();
650
+ }
651
+
652
+ /**
653
+ * Compact the layout by removing vertical gaps.
654
+ * @private
655
+ */
656
+ _compactLayout() {
657
+ // Sort tiles by y position
658
+ const sortedTiles = [...this.layout].sort((a, b) => a.y - b.y || a.x - b.x);
659
+
660
+ for (const item of sortedTiles) {
661
+ // Try to move up as far as possible
662
+ let newY = 0;
663
+
664
+ while (newY < item.y) {
665
+ const testPosition = { ...item, y: newY };
666
+
667
+ // Check for overlaps with tiles above this one
668
+ let hasOverlap = false;
669
+ for (const other of this.layout) {
670
+ if (other.id === item.id) continue;
671
+ if (this._tilesOverlap(testPosition, other)) {
672
+ hasOverlap = true;
673
+ // Move below this overlapping tile
674
+ newY = other.y + other.h;
675
+ break;
676
+ }
677
+ }
678
+
679
+ if (!hasOverlap) {
680
+ break;
681
+ }
682
+ }
683
+
684
+ if (newY < item.y) {
685
+ item.y = newY;
686
+ const tile = this.tiles.get(item.id);
687
+ if (tile) {
688
+ this._applyTilePosition(tile, item);
689
+ }
690
+ }
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Create ghost element for drag preview.
696
+ * @param {DOMRect} rect - Original tile rect
697
+ * @private
698
+ */
699
+ _createGhost(rect) {
700
+ this._ghostElement = document.createElement('div');
701
+ this._ghostElement.className = 'tile-ghost';
702
+ this.gridElement.appendChild(this._ghostElement);
703
+ }
704
+
705
+ /**
706
+ * Remove ghost element.
707
+ * @private
708
+ */
709
+ _removeGhost() {
710
+ if (this._ghostElement) {
711
+ this._ghostElement.remove();
712
+ this._ghostElement = null;
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Find next available position for a new tile.
718
+ * @param {number} w - Width in cells
719
+ * @param {number} h - Height in cells
720
+ * @returns {{x: number, y: number}}
721
+ */
722
+ findNextPosition(w, h) {
723
+ // Build occupancy grid
724
+ const maxY = Math.max(...this.layout.map(l => l.y + l.h), 0);
725
+ const occupied = new Set();
726
+
727
+ this.layout.forEach(l => {
728
+ for (let x = l.x; x < l.x + l.w; x++) {
729
+ for (let y = l.y; y < l.y + l.h; y++) {
730
+ occupied.add(`${x},${y}`);
731
+ }
732
+ }
733
+ });
734
+
735
+ // Find first available position
736
+ for (let y = 0; y <= maxY + 1; y++) {
737
+ for (let x = 0; x <= this.columns - w; x++) {
738
+ let fits = true;
739
+ for (let dx = 0; dx < w && fits; dx++) {
740
+ for (let dy = 0; dy < h && fits; dy++) {
741
+ if (occupied.has(`${x + dx},${y + dy}`)) {
742
+ fits = false;
743
+ }
744
+ }
745
+ }
746
+ if (fits) {
747
+ return { x, y };
748
+ }
749
+ }
750
+ }
751
+
752
+ // Default: place at bottom
753
+ return { x: 0, y: maxY + 1 };
754
+ }
755
+
756
+ /**
757
+ * Handle tile config request.
758
+ * @param {CustomEvent} e
759
+ * @private
760
+ */
761
+ _onTileConfigRequest(e) {
762
+ const { tileId, tileType, config, schema } = e.detail;
763
+ this.eventBus?.emit?.('grid:config-modal-show', {
764
+ tileId,
765
+ tileType,
766
+ config,
767
+ schema,
768
+ variables: this.data ? this._getVariableList() : [],
769
+ onSave: (newConfig) => {
770
+ const tile = this.tiles.get(tileId);
771
+ if (tile) {
772
+ tile.fullData = this.fullData;
773
+ tile.update(this.data, newConfig);
774
+ // Update layout config
775
+ const layoutItem = this.layout.find(l => l.id === tileId);
776
+ if (layoutItem) {
777
+ layoutItem.config = newConfig;
778
+ }
779
+ this._emitLayoutChanged();
780
+ }
781
+ }
782
+ });
783
+ }
784
+
785
+ /**
786
+ * Handle tile remove request.
787
+ * Note: Let the event bubble up to the dashboard which handles confirmation.
788
+ * @param {CustomEvent} e
789
+ * @private
790
+ */
791
+ _onTileRemoveRequest(e) {
792
+ // Don't auto-remove - let event bubble up to dashboard
793
+ // Dashboard shows a confirm dialog before calling removeTile()
794
+ // Do NOT stop propagation - dashboard listener needs to receive this event
795
+ }
796
+
797
+ /**
798
+ * Get list of available variables from data.
799
+ * @returns {Array<{value: string, label: string}>}
800
+ * @private
801
+ */
802
+ _getVariableList() {
803
+ if (!this.data) return [];
804
+
805
+ const variables = [];
806
+ if (this.data.stocks) {
807
+ Object.keys(this.data.stocks).forEach(name => {
808
+ variables.push({ value: name, label: name });
809
+ });
810
+ }
811
+ if (this.data.indicators) {
812
+ Object.keys(this.data.indicators).forEach(name => {
813
+ variables.push({ value: name, label: name });
814
+ });
815
+ }
816
+ return variables;
817
+ }
818
+
819
+ /**
820
+ * Show add widget menu.
821
+ * @private
822
+ */
823
+ _showAddWidgetMenu() {
824
+ this.eventBus?.emit?.('grid:add-widget-menu-show', {
825
+ onSelect: (type) => {
826
+ const { getWidget } = require('./tile_registry.js');
827
+ const WidgetClass = getWidget(type);
828
+ if (WidgetClass) {
829
+ const size = WidgetClass.DEFAULT_SIZE || { w: 4, h: 3 };
830
+ const pos = this.findNextPosition(size.w, size.h);
831
+ this.addTile(type, { ...pos, ...size });
832
+ }
833
+ }
834
+ });
835
+ }
836
+
837
+ /**
838
+ * Reset to default layout.
839
+ * @private
840
+ */
841
+ _resetLayout() {
842
+ this.eventBus?.emit?.('grid:reset-layout-confirm', {
843
+ onConfirm: () => {
844
+ this.eventBus?.emit?.('grid:layout-reset-requested');
845
+ }
846
+ });
847
+ }
848
+
849
+ /**
850
+ * Export report.
851
+ * @private
852
+ */
853
+ _exportReport() {
854
+ this.eventBus?.emit?.('grid:export-requested', {
855
+ gridElement: this.gridElement,
856
+ layout: this.getLayout()
857
+ });
858
+ }
859
+
860
+ /**
861
+ * Notify that a layout mutation is about to begin (drag/resize start).
862
+ * @private
863
+ */
864
+ _emitBeforeLayoutChange() {
865
+ if (typeof this._onBeforeLayoutChangeCallback === 'function') {
866
+ this._onBeforeLayoutChangeCallback(this.getLayout());
867
+ }
868
+ }
869
+
870
+ /**
871
+ * Emit layout changed event and call the onLayoutChange callback.
872
+ * @private
873
+ */
874
+ _emitLayoutChanged() {
875
+ const layout = this.getLayout();
876
+ this.eventBus?.emit?.('grid:layout-changed', { layout });
877
+
878
+ // Call the callback if provided (used by ResultsTileDashboard for autosave)
879
+ if (typeof this._onLayoutChangeCallback === 'function') {
880
+ this._onLayoutChangeCallback(layout);
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Clean up resources.
886
+ */
887
+ dispose() {
888
+ document.removeEventListener('pointermove', this._boundOnPointerMove);
889
+ document.removeEventListener('pointerup', this._boundOnPointerUp);
890
+
891
+ this._addWidgetDropdown?.destroy();
892
+ this._addWidgetDropdown = null;
893
+
894
+ this.tiles.forEach(tile => tile.dispose());
895
+ this.tiles.clear();
896
+ this.layout = [];
897
+
898
+ if (this.gridElement) {
899
+ this.gridElement.remove();
900
+ this.gridElement = null;
901
+ }
902
+ }
903
+ }
904
+
905
+ export default TileGrid;