feat(dashboard): add auto-layout button with smart widget packing

Implements intelligent auto-layout system that efficiently arranges widgets to maximize space usage while respecting panel width constraints.

**Key Features:**
- Smart packing algorithm that sorts by widget area and finds optimal positions
- Respects responsive column count (2-4 columns based on panel width)
- Prefers full-width widgets when possible to eliminate gaps
- Fallback to narrower widths for better vertical packing
- Maintains minimum widget sizes

**Implementation:**
- GridEngine.autoLayout() - Core packing algorithm with collision detection
- DashboardManager.autoLayoutWidgets() - High-level API that re-renders after layout
- Auto-Arrange button in dashboard header (uses fa-table-cells-large icon)
- Event handler wired to call autoLayoutWidgets with preferFullWidth=true

**Algorithm Strategy:**
1. Sort widgets by area (largest first) for efficient packing
2. For each widget, try full-width placement first
3. Find first available position using row-by-row scan
4. If position is too far down, try narrower widths
5. Mark cells as occupied to prevent overlaps

**Testing Notes:**
- Works with current responsive column system (2-4 columns)
- Respects minimum sizes and column constraints
- Re-renders all widgets after repositioning
- Auto-saves layout changes

Part of Epic 2: Dashboard Widget Library
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-23 14:00:00 +11:00
parent e32a008f0b
commit 122bb3194a
13 changed files with 668 additions and 87 deletions
+145 -21
View File
@@ -95,16 +95,20 @@ export class DashboardManager {
// Create container structure
this.createContainerStructure();
// Initialize Grid Engine
// Initialize Grid Engine (columns calculated dynamically)
this.gridEngine = new GridEngine({
columns: this.config.columns,
rowHeight: this.config.rowHeight,
gap: this.config.gap,
container: this.gridContainer
container: this.gridContainer,
onColumnsChange: (newCols, oldCols) => {
console.log('[DashboardManager] Grid columns changed:', oldCols, '→', newCols);
// Re-render all widgets when column count changes
this.renderAllWidgets();
}
});
// Initialize Widget Registry
this.registry = new WidgetRegistry();
// Initialize Widget Registry (use provided registry or create new one)
this.registry = this.config.registry || new WidgetRegistry();
// Initialize Tab Manager with dashboard data structure
// Create default tab if no tabs exist
@@ -122,11 +126,11 @@ export class DashboardManager {
this.tabManager = new TabManager(this.dashboard);
// Set current tab to active tab from TabManager
this.currentTabId = this.tabManager.getActiveTabId();
this.currentTabId = this.tabManager.activeTabId;
// Register tab change listener
this.tabManager.onChange((event, data) => {
if (event === 'tabChanged') {
if (event === 'activeTabChanged') {
this.onTabChange(data.tabId);
}
});
@@ -139,9 +143,9 @@ export class DashboardManager {
// Initialize Resize Handler
this.resizeHandler = new ResizeHandler(this.gridEngine, {
minWidth: 2,
minWidth: 1,
minHeight: 2,
maxWidth: this.config.columns,
maxWidth: 4, // Max 4 columns (will be clamped to actual column count)
maxHeight: 10
});
@@ -175,6 +179,9 @@ export class DashboardManager {
// Try to load saved layout
await this.loadLayout();
// Measure container width and set up responsive sizing
this.setupContainerSizing();
console.log('[DashboardManager] All systems initialized');
this.notifyChange('initialized');
}
@@ -201,6 +208,46 @@ export class DashboardManager {
this.container.appendChild(this.gridContainer);
}
/**
* Set up container sizing and responsive behavior
* Measures container width and sets up ResizeObserver
* Also listens for viewport resize to recalculate vw/vh positions
*/
setupContainerSizing() {
// Measure actual container width
const width = this.gridContainer.clientWidth || this.gridContainer.offsetWidth || 350;
console.log('[DashboardManager] Measured container width:', width);
// Set container width in GridEngine (triggers column calculation)
this.gridEngine.setContainerWidth(width);
// Set up ResizeObserver to track container width changes
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = entry.contentRect.width;
console.log('[DashboardManager] Container resized to:', newWidth);
this.gridEngine.setContainerWidth(newWidth);
}
});
this.resizeObserver.observe(this.gridContainer);
console.log('[DashboardManager] ResizeObserver set up');
} else {
console.warn('[DashboardManager] ResizeObserver not supported, responsive sizing disabled');
}
// Listen for window resize to recalculate vh positions
// Viewport height changes affect vh calculations for vertical positioning
// Horizontal (%) automatically adapts to container width changes via ResizeObserver
this.viewportResizeHandler = () => {
console.log('[DashboardManager] Viewport resized, recalculating vh positions');
this.renderAllWidgets(); // Re-render with new vh values
};
window.addEventListener('resize', this.viewportResizeHandler);
console.log('[DashboardManager] Viewport resize listener added');
}
/**
* Add a new widget to the dashboard
* @param {string} type - Widget type (must be registered)
@@ -356,13 +403,13 @@ export class DashboardManager {
element.dataset.widgetId = widget.id;
element.dataset.widgetType = widget.type;
// Position widget using grid engine
const pos = this.gridEngine.getPixelPosition(widget);
// Position widget using grid engine (responsive units for scaling)
const pos = this.gridEngine.getWidgetPosition(widget);
element.style.position = 'absolute';
element.style.left = `${pos.left}px`;
element.style.top = `${pos.top}px`;
element.style.width = `${pos.width}px`;
element.style.height = `${pos.height}px`;
element.style.left = pos.left; // % of container (e.g., "5.23%")
element.style.top = pos.top; // vh units (e.g., "10.45vh")
element.style.width = pos.width; // % of container (e.g., "45.67%")
element.style.height = pos.height; // vh units (e.g., "20.12vh")
// Add to grid
this.gridContainer.appendChild(element);
@@ -418,6 +465,8 @@ export class DashboardManager {
* @param {Object} definition - Widget definition
*/
renderWidgetContent(element, widget, definition) {
console.log(`[DashboardManager] renderWidgetContent called for ${widget.type}`);
// Clear existing content (except resize handles and controls)
const handles = element.querySelector('.resize-handles');
const controls = element.querySelector('.widget-edit-controls');
@@ -427,7 +476,11 @@ export class DashboardManager {
// Call widget render function
if (definition && definition.render) {
console.log(`[DashboardManager] Calling render for ${widget.type}`, element);
definition.render(element, widget.config || {});
console.log(`[DashboardManager] After render, element children:`, element.children.length);
} else {
console.warn(`[DashboardManager] No render function for ${widget.type}`);
}
}
@@ -437,11 +490,21 @@ export class DashboardManager {
* @param {Object} widget - Widget data
*/
repositionWidget(element, widget) {
const pos = this.gridEngine.getPixelPosition(widget);
element.style.left = `${pos.left}px`;
element.style.top = `${pos.top}px`;
element.style.width = `${pos.width}px`;
element.style.height = `${pos.height}px`;
const pos = this.gridEngine.getWidgetPosition(widget);
element.style.left = pos.left;
element.style.top = pos.top;
element.style.width = pos.width;
element.style.height = pos.height;
}
/**
* Re-render all widgets (repositions all widgets with current grid calculations)
*/
renderAllWidgets() {
this.widgets.forEach((widgetData) => {
this.repositionWidget(widgetData.element, widgetData.widget);
});
console.log('[DashboardManager] Repositioned all widgets');
}
/**
@@ -491,7 +554,7 @@ export class DashboardManager {
* @param {string} tabId - Tab ID to switch to
*/
switchTab(tabId) {
this.tabManager.switchTab(tabId);
this.tabManager.setActiveTab(tabId);
}
/**
@@ -507,11 +570,17 @@ export class DashboardManager {
// Render all widgets in this tab
const tab = this.tabManager.getTab(tabId);
console.log(`[DashboardManager] Tab data:`, tab);
console.log(`[DashboardManager] Tab has ${tab?.widgets?.length || 0} widgets`);
if (tab && tab.widgets) {
tab.widgets.forEach(widget => {
console.log(`[DashboardManager] Rendering widget:`, widget.type, widget.id);
const definition = this.registry.get(widget.type);
if (definition) {
this.renderWidget(widget, definition);
} else {
console.warn(`[DashboardManager] Widget type "${widget.type}" not found in registry`);
}
});
}
@@ -748,6 +817,49 @@ export class DashboardManager {
this.defaultLayout = layout;
}
/**
* Auto-layout widgets on current tab to efficiently use all available space
*
* Sorts and packs widgets to maximize space usage with no gaps.
* Respects current panel width (responsive column count).
* Re-renders all widgets after repositioning.
*
* @param {Object} options - Layout options
* @param {boolean} [options.preferFullWidth=true] - Prefer full-width widgets when possible
*/
autoLayoutWidgets(options = {}) {
console.log('[DashboardManager] Auto-layout widgets requested');
// Get current tab
const currentTab = this.tabManager.getTab(this.currentTabId);
if (!currentTab || !currentTab.widgets || currentTab.widgets.length === 0) {
console.warn('[DashboardManager] No widgets to auto-layout');
return;
}
// Run auto-layout algorithm on widgets
const widgetsToLayout = [...currentTab.widgets];
this.gridEngine.autoLayout(widgetsToLayout, options);
// Update tab widgets with new positions
currentTab.widgets = widgetsToLayout;
// Re-render all widgets with new positions
this.clearGrid();
widgetsToLayout.forEach(widget => {
const definition = this.registry.get(widget.type);
if (definition) {
this.renderWidget(widget, definition);
}
});
console.log('[DashboardManager] Auto-layout complete, re-rendered widgets');
// Save changes
this.triggerAutoSave();
this.notifyChange('autoLayoutApplied', { tabId: this.currentTabId });
}
/**
* Trigger auto-save
*/
@@ -797,6 +909,18 @@ export class DashboardManager {
// Clear grid
this.clearGrid();
// Disconnect ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Remove viewport resize listener
if (this.viewportResizeHandler) {
window.removeEventListener('resize', this.viewportResizeHandler);
this.viewportResizeHandler = null;
}
// Destroy systems
if (this.editManager) this.editManager.destroy();
if (this.dragHandler) this.dragHandler.destroy();