From b3a86d460902e54fcda726ddf01887b6e5f24ed1 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 23 Oct 2025 18:06:44 +1100 Subject: [PATCH] feat(dashboard): implement smart widget collision and category-aware layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.js | 5 + src/systems/dashboard/dashboardIntegration.js | 62 +++-- src/systems/dashboard/dashboardManager.js | 245 +++++++++++++++++- src/systems/dashboard/dashboardTemplate.html | 5 + src/systems/dashboard/defaultLayout.js | 68 +++-- src/systems/dashboard/dragDrop.js | 47 +--- src/systems/dashboard/gridEngine.js | 128 ++++----- .../dashboard/widgets/infoBoxWidgets.js | 5 + style.css | 7 + 9 files changed, 434 insertions(+), 138 deletions(-) diff --git a/index.js b/index.js index b101296..5e5a3f3 100644 --- a/index.js +++ b/index.js @@ -518,12 +518,17 @@ async function initUI() { getFallbackAvatar: () => FALLBACK_AVATAR_DATA_URI, getAvatarUrl: (type, avatar) => getThumbnailUrl(type, avatar), getCharacterThoughts: () => extensionSettings.characterThoughts || '', + getInfoBoxData: () => extensionSettings.infoBoxData || 'Info Box\n---\n', // Data setters setCharacterThoughts: (value) => { extensionSettings.characterThoughts = value; saveSettings(); }, + setInfoBoxData: (value) => { + extensionSettings.infoBoxData = value; + saveSettings(); + }, // Event callbacks onDataChange: (dataType, field, value, extra) => { diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js index ecaba5d..4d14141 100644 --- a/src/systems/dashboard/dashboardIntegration.js +++ b/src/systems/dashboard/dashboardIntegration.js @@ -11,6 +11,7 @@ import { saveSettings } from '../../core/persistence.js'; import { renderExtensionTemplateAsync } from '../../../../../../extensions.js'; import { DashboardManager } from './dashboardManager.js'; import { WidgetRegistry } from './widgetRegistry.js'; +import { generateDefaultDashboard } from './defaultLayout.js'; // Widget imports import { registerUserInfoWidget } from './widgets/userInfoWidget.js'; @@ -92,6 +93,11 @@ export async function initializeDashboard(dependencies) { // Initialize the dashboard await dashboardManager.init(); + // Set default layout (required for reset functionality) + const defaultLayout = generateDefaultDashboard(); + dashboardManager.setDefaultLayout(defaultLayout); + console.log('[RPG Companion] Default layout set with', defaultLayout.tabs.length, 'tabs'); + // Set up dashboard event listeners setupDashboardEventListeners(dependencies); @@ -139,6 +145,9 @@ function getInlineDashboardTemplate() {
+ @@ -194,13 +203,26 @@ function registerAllWidgets(registry, dependencies) { * Set up dashboard event listeners */ function setupDashboardEventListeners(dependencies) { + // Reset layout button + const resetLayoutBtn = document.querySelector('#rpg-dashboard-reset-layout'); + if (resetLayoutBtn) { + resetLayoutBtn.addEventListener('click', () => { + if (dashboardManager) { + if (confirm('Reset dashboard to default layout? This will remove all widgets and reload the defaults.')) { + console.log('[RPG Companion] Reset layout button clicked'); + dashboardManager.resetLayout(); + } + } + }); + } + // Auto-layout button const autoLayoutBtn = document.querySelector('#rpg-dashboard-auto-layout'); if (autoLayoutBtn) { autoLayoutBtn.addEventListener('click', () => { if (dashboardManager) { console.log('[RPG Companion] Auto-layout button clicked'); - dashboardManager.autoLayoutWidgets({ preferFullWidth: true }); + dashboardManager.autoLayoutWidgets(); } }); } @@ -332,32 +354,36 @@ export function createDefaultLayout(manager) { return; } - console.log('[RPG Companion] Creating default dashboard layout (2-column optimized)...'); + console.log('[RPG Companion] Creating default dashboard layout with modular widgets...'); const mainTab = manager.tabManager.getActiveTabId(); - // Add widgets with 2-column layout positions - // Row 1-2: User Stats (full width) - manager.addWidget('userStats', mainTab, { x: 0, y: 0, w: 2, h: 3 }); + // Add modular user widgets + // Row 0: User Info (avatar, name, level) - full width + manager.addWidget('userInfo', mainTab, { x: 0, y: 0, w: 2, h: 1 }); - // Row 3: Calendar (left) + Weather (right) - manager.addWidget('calendar', mainTab, { x: 0, y: 3, w: 1, h: 2 }); - manager.addWidget('weather', mainTab, { x: 1, y: 3, w: 1, h: 2 }); + // Row 1-2: User Stats (health/energy bars) - full width + manager.addWidget('userStats', mainTab, { x: 0, y: 1, w: 2, h: 2 }); - // Row 4: Temperature (left) + Clock (right) - manager.addWidget('temperature', mainTab, { x: 0, y: 5, w: 1, h: 2 }); - manager.addWidget('clock', mainTab, { x: 1, y: 5, w: 1, h: 2 }); + // Row 3-4: User Mood (left) + User Attributes (right) + manager.addWidget('userMood', mainTab, { x: 0, y: 3, w: 1, h: 1 }); + manager.addWidget('userAttributes', mainTab, { x: 1, y: 3, w: 1, h: 2 }); - // Row 5: Location (full width) - manager.addWidget('location', mainTab, { x: 0, y: 7, w: 2, h: 2 }); + // Row 5-6: Calendar (left) + Weather (right) + manager.addWidget('calendar', mainTab, { x: 0, y: 5, w: 1, h: 2 }); + manager.addWidget('weather', mainTab, { x: 1, y: 5, w: 1, h: 2 }); - // Row 6-7: Present Characters (full width) - manager.addWidget('presentCharacters', mainTab, { x: 0, y: 9, w: 2, h: 3 }); + // Row 7-8: Temperature (left) + Clock (right) + manager.addWidget('temperature', mainTab, { x: 0, y: 7, w: 1, h: 2 }); + manager.addWidget('clock', mainTab, { x: 1, y: 7, w: 1, h: 2 }); - // Row 8-13: Inventory (full width) - manager.addWidget('inventory', mainTab, { x: 0, y: 12, w: 2, h: 6 }); + // Row 9-10: Location (full width) + manager.addWidget('location', mainTab, { x: 0, y: 9, w: 2, h: 2 }); - console.log('[RPG Companion] Default layout created (2-column optimized)'); + // Row 11-13: Present Characters (full width) + manager.addWidget('presentCharacters', mainTab, { x: 0, y: 11, w: 2, h: 3 }); + + console.log('[RPG Companion] Default layout created with modular widgets'); } /** diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index f9bc60e..5fe0b23 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -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} 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} 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} widgets - Widgets to sort + * @returns {Array} 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(); diff --git a/src/systems/dashboard/dashboardTemplate.html b/src/systems/dashboard/dashboardTemplate.html index 615f36d..08ea754 100644 --- a/src/systems/dashboard/dashboardTemplate.html +++ b/src/systems/dashboard/dashboardTemplate.html @@ -8,6 +8,11 @@
+ + +