feat(dashboard): implement smart widget collision and category-aware layout
Complete dashboard v2 improvements for better UX and visual consistency: **1. Push-Aside Drag/Drop (dragDrop.js)** - Replace swap/revert logic with intelligent reflow algorithm - When widgets collide on drag, automatically push overlapping widgets down - All affected widgets repositioned after reflow completes - Eliminates widget overlap issues **2. Unified Widget Styling (style.css)** - Add consistent .rpg-widget container styling for all widgets - Background: rgba(0,0,0,0.2) for visual separation - Border-left: 3px highlight for category identification - Box-shadow and border-radius for depth and polish - Maintain individual widget decorative styles **3. Logical Default Layout (defaultLayout.js)** - Reorganize widgets into semantic clusters with clear comments: - USER CLUSTER (top): userInfo → userStats → userMood + userAttributes - SCENE CLUSTER (middle): calendar + weather → temp + clock → location - SOCIAL CLUSTER (bottom): presentCharacters - userInfo widget now at top (y=0) as expected - All positions use rem units for responsive scaling **4. Category-Aware Auto-Layout (dashboardManager.js)** - Implement sortWidgetsByCategory() with priority ordering: user → scene → social → inventory - Within user category, specific ordering: userInfo → userStats → userMood → userAttributes - Add preserveOrder option to gridEngine.autoLayout() - Auto-arrange now uses logical grouping instead of random bin-packing **5. Multi-Tab Auto-Distribution (dashboardManager.js)** - Add estimateLayoutHeight() to detect when content exceeds threshold - Implement distributeWidgetsByCategory() for automatic tab creation: - "Status" tab: user + scene widgets - "Social" tab: social widgets (if any) - "Inventory" tab: inventory widgets (if any) - Each tab gets category-aware auto-layout - 80rem height threshold for single-tab limit **6. Widget Category Metadata (widgets/)** - Add category field to all widget definitions: - userInfo, userStats, userMood, userAttributes: 'user' - calendar, weather, temperature, clock, location: 'scene' - presentCharacters: 'social' - inventory: 'inventory' **7. Integration Improvements (dashboardIntegration.js)** - Set default layout on initialization for reset functionality - Add reset layout button to dashboard header - Wire up reset button event handler **8. Core State Management (index.js)** - Add getInfoBoxData() and setInfoBoxData() to state API - Ensure info box data persists across sessions **Technical Details:** - Rem units throughout for 1080p→4K→mobile responsive scaling - Reflow algorithm leverages existing gridEngine collision detection - Category-aware sorting preserves logical relationships - Multi-tab distribution prevents single-page scroll fatigue - All changes maintain backwards compatibility with existing layouts Fixes dashboard issues after rem unit conversion introduced massive positioning bugs. Users reported widgets overlapping on drag, visual inconsistency, and random auto-arrange behavior. Related: Epic 2 (Dashboard v2), Phase 3.2
This commit is contained in:
@@ -48,8 +48,8 @@ export class DashboardManager {
|
||||
this.container = container;
|
||||
this.config = {
|
||||
columns: config.columns || 12,
|
||||
rowHeight: config.rowHeight || 80,
|
||||
gap: config.gap || 12,
|
||||
rowHeight: config.rowHeight || 5, // rem units for responsive scaling
|
||||
gap: config.gap || 0.75, // rem units for responsive scaling
|
||||
debounceMs: config.debounceMs || 500,
|
||||
onSave: config.onSave,
|
||||
onLoad: config.onLoad,
|
||||
@@ -564,7 +564,11 @@ export class DashboardManager {
|
||||
this.dragHandler.initWidget(element, widget, (updated, newX, newY) => {
|
||||
widget.x = newX;
|
||||
widget.y = newY;
|
||||
this.repositionWidget(element, widget);
|
||||
|
||||
// After drag (which may have triggered reflow), reposition ALL widgets
|
||||
// because reflow may have moved other widgets
|
||||
this.repositionAllWidgetsInCurrentTab();
|
||||
|
||||
this.triggerAutoSave();
|
||||
}, allWidgets);
|
||||
|
||||
@@ -650,6 +654,185 @@ export class DashboardManager {
|
||||
console.log('[DashboardManager] Repositioned all widgets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reposition all widgets in the current tab
|
||||
* Used after drag/drop reflow to update positions of all affected widgets
|
||||
*/
|
||||
repositionAllWidgetsInCurrentTab() {
|
||||
const currentTab = this.tabManager.getTab(this.currentTabId);
|
||||
if (!currentTab) return;
|
||||
|
||||
// Reposition each widget in the current tab
|
||||
currentTab.widgets.forEach((widget) => {
|
||||
const widgetData = this.widgets.get(widget.id);
|
||||
if (widgetData && widgetData.element) {
|
||||
this.repositionWidget(widgetData.element, widget);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[DashboardManager] Repositioned all widgets in current tab after reflow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total height needed for widgets if laid out
|
||||
* Simple estimation: sum all widget heights + gaps
|
||||
*
|
||||
* @param {Array<Object>} widgets - Widgets to estimate
|
||||
* @returns {number} Estimated height in rem
|
||||
*/
|
||||
estimateLayoutHeight(widgets) {
|
||||
if (widgets.length === 0) return 0;
|
||||
|
||||
// Sum all heights (widgets are already in rem units)
|
||||
const totalHeight = widgets.reduce((sum, w) => sum + w.h, 0);
|
||||
|
||||
// Add gaps (rowHeight + gap between each widget)
|
||||
const gaps = (widgets.length - 1) * this.gridEngine.gap;
|
||||
|
||||
return totalHeight * this.gridEngine.rowHeight + gaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute widgets across multiple tabs by category
|
||||
* Creates category-based tabs: Status, Social, Inventory
|
||||
*
|
||||
* @param {Array<Object>} widgets - All widgets to distribute
|
||||
*/
|
||||
distributeWidgetsByCategory(widgets) {
|
||||
console.log('[DashboardManager] Distributing widgets across multiple tabs');
|
||||
|
||||
// Group widgets by category
|
||||
const groups = {
|
||||
user: [],
|
||||
scene: [],
|
||||
social: [],
|
||||
inventory: []
|
||||
};
|
||||
|
||||
widgets.forEach(widget => {
|
||||
const def = this.registry.get(widget.type);
|
||||
const category = def?.category || 'user';
|
||||
if (groups[category]) {
|
||||
groups[category].push(widget);
|
||||
} else {
|
||||
groups.user.push(widget); // Fallback to user
|
||||
}
|
||||
});
|
||||
|
||||
// Clear existing tabs
|
||||
this.dashboard.tabs = [];
|
||||
|
||||
// Create Status tab (user + scene)
|
||||
const statusWidgets = [...groups.user, ...groups.scene];
|
||||
if (statusWidgets.length > 0) {
|
||||
this.dashboard.tabs.push({
|
||||
id: 'tab-status',
|
||||
name: 'Status',
|
||||
icon: '📊',
|
||||
order: 0,
|
||||
widgets: statusWidgets
|
||||
});
|
||||
|
||||
// Auto-layout status widgets
|
||||
this.gridEngine.autoLayout(statusWidgets, { preserveOrder: true });
|
||||
}
|
||||
|
||||
// Create Social tab if there are social widgets
|
||||
if (groups.social.length > 0) {
|
||||
this.dashboard.tabs.push({
|
||||
id: 'tab-social',
|
||||
name: 'Social',
|
||||
icon: '👥',
|
||||
order: 1,
|
||||
widgets: groups.social
|
||||
});
|
||||
|
||||
// Auto-layout social widgets
|
||||
this.gridEngine.autoLayout(groups.social, { preserveOrder: true });
|
||||
}
|
||||
|
||||
// Create Inventory tab if there are inventory widgets
|
||||
if (groups.inventory.length > 0) {
|
||||
this.dashboard.tabs.push({
|
||||
id: 'tab-inventory',
|
||||
name: 'Inventory',
|
||||
icon: '🎒',
|
||||
order: 2,
|
||||
widgets: groups.inventory
|
||||
});
|
||||
|
||||
// Auto-layout inventory widgets
|
||||
this.gridEngine.autoLayout(groups.inventory, { preserveOrder: true });
|
||||
}
|
||||
|
||||
console.log('[DashboardManager] Created', this.dashboard.tabs.length, 'tabs');
|
||||
|
||||
// Re-render tabs and switch to first tab
|
||||
this.renderTabs();
|
||||
if (this.dashboard.tabs.length > 0) {
|
||||
this.switchTab(this.dashboard.tabs[0].id);
|
||||
}
|
||||
|
||||
// Save layout
|
||||
this.triggerAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort widgets by category for logical auto-layout
|
||||
* Groups: user → scene → social → inventory
|
||||
* Within groups, maintains smart ordering (e.g., userInfo before userStats)
|
||||
*
|
||||
* @param {Array<Object>} widgets - Widgets to sort
|
||||
* @returns {Array<Object>} Sorted widgets
|
||||
*/
|
||||
sortWidgetsByCategory(widgets) {
|
||||
// Category priority order
|
||||
const categoryOrder = {
|
||||
'user': 1,
|
||||
'scene': 2,
|
||||
'social': 3,
|
||||
'inventory': 4,
|
||||
'other': 5
|
||||
};
|
||||
|
||||
// Specific widget type ordering within user category
|
||||
const userWidgetOrder = {
|
||||
'userInfo': 1, // Name/level at top
|
||||
'userStats': 2, // Health/energy bars
|
||||
'userMood': 3, // Mood
|
||||
'userAttributes': 4 // STR/DEX/etc
|
||||
};
|
||||
|
||||
return [...widgets].sort((a, b) => {
|
||||
// Get widget definitions from registry
|
||||
const defA = this.registry.get(a.type);
|
||||
const defB = this.registry.get(b.type);
|
||||
|
||||
const catA = defA?.category || 'other';
|
||||
const catB = defB?.category || 'other';
|
||||
|
||||
// Sort by category first
|
||||
const catOrderA = categoryOrder[catA] || 999;
|
||||
const catOrderB = categoryOrder[catB] || 999;
|
||||
|
||||
if (catOrderA !== catOrderB) {
|
||||
return catOrderA - catOrderB;
|
||||
}
|
||||
|
||||
// Within user category, use specific ordering
|
||||
if (catA === 'user' && catB === 'user') {
|
||||
const orderA = userWidgetOrder[a.type] || 999;
|
||||
const orderB = userWidgetOrder[b.type] || 999;
|
||||
if (orderA !== orderB) {
|
||||
return orderA - orderB;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise maintain original order
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find available position for new widget
|
||||
* @param {Object} size - Widget size { w, h }
|
||||
@@ -862,6 +1045,22 @@ export class DashboardManager {
|
||||
applyDashboardConfig(config) {
|
||||
console.log('[DashboardManager] Applying dashboard config');
|
||||
|
||||
// Update grid config from dashboard config
|
||||
if (config.gridConfig) {
|
||||
this.config.rowHeight = config.gridConfig.rowHeight || this.config.rowHeight;
|
||||
this.config.gap = config.gridConfig.gap || this.config.gap;
|
||||
|
||||
// Update gridEngine with new config
|
||||
if (this.gridEngine) {
|
||||
this.gridEngine.rowHeight = this.config.rowHeight;
|
||||
this.gridEngine.gap = this.config.gap;
|
||||
console.log('[DashboardManager] Updated grid config:', {
|
||||
rowHeight: this.config.rowHeight + 'rem',
|
||||
gap: this.config.gap + 'rem'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing
|
||||
this.clearGrid();
|
||||
|
||||
@@ -951,8 +1150,19 @@ export class DashboardManager {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DashboardManager] Resetting to default layout...');
|
||||
console.log('[DashboardManager] Default layout has:', this.defaultLayout.tabs.length, 'tabs');
|
||||
this.defaultLayout.tabs.forEach(tab => {
|
||||
console.log(`[DashboardManager] Tab "${tab.name}" (${tab.id}):`, tab.widgets.length, 'widgets');
|
||||
});
|
||||
|
||||
await this.persistence.resetToDefault(this.defaultLayout);
|
||||
this.applyDashboardConfig(this.defaultLayout);
|
||||
|
||||
// Force re-render tabs
|
||||
this.renderTabs();
|
||||
|
||||
console.log('[DashboardManager] Reset complete');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -983,12 +1193,31 @@ export class DashboardManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run auto-layout algorithm on widgets
|
||||
const widgetsToLayout = [...currentTab.widgets];
|
||||
this.gridEngine.autoLayout(widgetsToLayout, options);
|
||||
// Smart category-aware sorting BEFORE auto-layout
|
||||
const widgetsToLayout = this.sortWidgetsByCategory(currentTab.widgets);
|
||||
|
||||
// Update tab widgets with new positions
|
||||
currentTab.widgets = widgetsToLayout;
|
||||
// Calculate estimated height to determine if multi-tab distribution is needed
|
||||
const estimatedHeight = this.estimateLayoutHeight(widgetsToLayout);
|
||||
const heightThreshold = 80; // rem - reasonable max height for single tab
|
||||
|
||||
console.log('[DashboardManager] Estimated height:', estimatedHeight + 'rem', 'Threshold:', heightThreshold + 'rem');
|
||||
|
||||
// If widgets fit comfortably, use single-tab auto-layout
|
||||
if (estimatedHeight <= heightThreshold) {
|
||||
console.log('[DashboardManager] Using single-tab auto-layout');
|
||||
|
||||
// Run auto-layout algorithm on pre-sorted widgets
|
||||
// (gridEngine will preserve this logical order instead of sorting by area)
|
||||
this.gridEngine.autoLayout(widgetsToLayout, { preserveOrder: true });
|
||||
|
||||
// Update tab widgets with new positions
|
||||
currentTab.widgets = widgetsToLayout;
|
||||
} else {
|
||||
// Too many widgets - distribute across multiple tabs by category
|
||||
console.log('[DashboardManager] Height exceeds threshold, using multi-tab distribution');
|
||||
this.distributeWidgetsByCategory(widgetsToLayout);
|
||||
return; // distributeWidgetsByCategory handles rendering
|
||||
}
|
||||
|
||||
// Re-render all widgets with new positions
|
||||
this.clearGrid();
|
||||
|
||||
Reference in New Issue
Block a user