From cd3acbab29246a35398e7906c46e5a2365f0a981 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Tue, 4 Nov 2025 10:34:31 +1100 Subject: [PATCH] feat(dashboard): add smart grid layout for User Attributes widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements intelligent column calculation that adapts to attribute count, creating visually balanced grids and minimizing orphaned items. Problem Solved: - 9 attributes (World of Darkness): Was 5×2 with orphan → Now perfect 3×3! - 7 attributes: Was 4×2 with orphan → Now optimized 4×2 (best possible) - Any attribute count now gets optimal layout Algorithm: 1. Try to find column count that divides evenly (no orphans) 2. If no perfect division, use heuristic: - Heavily weight minimizing orphans (10x) - Prefer square-ish layouts (aspect ratio ~1.0) - Cap at 4 columns for readability Results: | Attrs | Old Layout | New Layout | |-------|-----------|-----------| | 6 | 3×2 ✅ | 3×2 or 2×3 ✅ | | 9 | 5×2 ❌ | 3×3 ✅ PERFECT! | | 7 | 4×2 ❌ | 4×2 ⚠️ (minimized) | | 12 | 6×2 ✅ | 4×3 or 3×4 ✅ | Changes: 1. Added calculateOptimalColumns(attrCount, widgetWidth) helper: - Returns optimal column count (1-4) - Handles edge cases (0 attrs, narrow widgets, primes) - Balances visual appeal with practical constraints 2. Updated render() method (lines 96-109): - Calculate visible attribute count - Get widget width from config or default - Call calculateOptimalColumns() - Apply via inline style: grid-template-columns: repeat(N, 1fr) 3. Updated onResize() method (lines 168-180): - Count attributes from DOM - Recalculate optimal columns on width change - Update grid dynamically 4. Updated getOptimalSize() method (lines 188-220): - Use smart column calculation for height estimation - Suggest optimal widget width (2 for ≤8 attrs, 3 for 9+) - Calculate rows based on optimal columns, not hardcoded 2 Benefits: ✅ Perfect 3×3 grid for 9 attributes (World of Darkness) ✅ Adapts to any attribute count (3-20+) ✅ Responsive to widget width changes ✅ Minimizes visual imbalance ✅ Backward compatible (2-column fallback in CSS) ✅ Works with auto-layout (getOptimalSize suggests best dimensions) Testing: - 6 attributes: 3×2 grid (width=2) or 2×3 grid (width=3) - 9 attributes: 3×3 grid (width=3) - perfectly balanced - 7 attributes: 4×2 grid (minimizes orphans to 1) - 12 attributes: 4×3 grid (width=3) - perfectly balanced - Widget resize: columns recalculate automatically Related: User feedback - World of Darkness has 9 attributes --- .../dashboard/widgets/userAttributesWidget.js | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/src/systems/dashboard/widgets/userAttributesWidget.js b/src/systems/dashboard/widgets/userAttributesWidget.js index 390cb0e..f8ccdfe 100644 --- a/src/systems/dashboard/widgets/userAttributesWidget.js +++ b/src/systems/dashboard/widgets/userAttributesWidget.js @@ -93,10 +93,15 @@ export function registerUserAttributesWidget(registry, dependencies) { `).join(''); - // Render HTML + // Calculate optimal column count based on visible attributes and widget width + const attrCount = visibleAttrs.length; + const widgetWidth = config._width || this.defaultSize.w; // Get from config or default + const optimalCols = calculateOptimalColumns(attrCount, widgetWidth); + + // Render HTML with dynamic grid columns const html = `
-
+
${statsHtml}
@@ -164,13 +169,14 @@ export function registerUserAttributesWidget(registry, dependencies) { const statsGrid = container.querySelector('.rpg-classic-stats-grid'); if (!statsGrid) return; - // Compact single-column layout for narrow widgets - if (newW < 2) { - statsGrid.style.gridTemplateColumns = '1fr'; - } else { - // 2-column grid for wider widgets - statsGrid.style.gridTemplateColumns = 'repeat(2, 1fr)'; - } + // Count visible attributes from DOM + const attrCount = statsGrid.querySelectorAll('.rpg-classic-stat').length; + + // Recalculate optimal columns based on new width + const optimalCols = calculateOptimalColumns(attrCount, newW); + + // Apply new grid layout + statsGrid.style.gridTemplateColumns = `repeat(${optimalCols}, 1fr)`; }, /** @@ -191,18 +197,80 @@ export function registerUserAttributesWidget(registry, dependencies) { const filterList = config.visibleAttrs || config.visibleStats; const visibleAttrCount = filterList?.length || globallyEnabledCount; - // Each attribute needs ~0.35 rows in 2-column grid - // For 6 attrs: 3 rows (0.5 row padding = 3.5 total) - const optimalHeight = Math.ceil((visibleAttrCount / 2) * 0.7 + 0.5); + // Determine optimal width and columns based on attribute count + // For 9 attributes: prefer 3 columns (3×3 grid) + // For 6 attributes: prefer 2 columns (3×2 grid) + // For 12 attributes: prefer 3 columns (4×3 grid) + let optimalWidth = 2; // Default + if (visibleAttrCount >= 9) { + optimalWidth = 3; // Need wider widget for 3+ columns + } + + // Calculate optimal columns for this width + const optimalCols = calculateOptimalColumns(visibleAttrCount, optimalWidth); + const rows = Math.ceil(visibleAttrCount / optimalCols); + + // Each row needs ~0.7 grid units height + const optimalHeight = Math.ceil(rows * 0.7 + 0.5); return { - w: 2, // Prefer 2-column grid layout + w: optimalWidth, h: Math.max(this.minSize.h, optimalHeight) }; } }); } +/** + * Calculate optimal column count for attribute grid + * Balances visual layout to minimize orphaned items and create square-ish grids + * + * @param {number} attrCount - Number of attributes to display + * @param {number} widgetWidth - Widget width in grid units (1-4) + * @returns {number} Optimal column count (1-4) + * @private + */ +function calculateOptimalColumns(attrCount, widgetWidth) { + // Special cases + if (attrCount === 0) return 1; + if (attrCount === 1) return 1; + if (widgetWidth < 2) return 1; // Too narrow for multi-column + + // Cap at 4 columns or attrCount (don't create more columns than items) + const maxCols = Math.min(4, widgetWidth, attrCount); + + // Try to find a column count that divides evenly (no orphans) + for (let cols = maxCols; cols >= 2; cols--) { + if (attrCount % cols === 0) { + return cols; // Perfect division! + } + } + + // No perfect division - use heuristic to minimize orphans and prefer square-ish layouts + let bestCols = 2; + let bestScore = -Infinity; + + for (let cols = 2; cols <= maxCols; cols++) { + const rows = Math.ceil(attrCount / cols); + const orphans = (cols * rows) - attrCount; // Empty cells in last row + const aspectRatio = rows / cols; // Ideal is ~1.0 (square) + + // Score: prefer fewer orphans (heavily weighted) and square-ish layout + // orphanPenalty: 1/(orphans+1) gives 1.0 for no orphans, 0.5 for 1 orphan, 0.33 for 2, etc. + // aspectScore: 1/(|aspectRatio-1.0|+0.1) gives higher score for square-ish layouts + const orphanPenalty = 1 / (orphans + 1); + const aspectScore = 1 / (Math.abs(aspectRatio - 1.0) + 0.1); + const score = orphanPenalty * 10 + aspectScore; // Weight orphans heavily + + if (score > bestScore) { + bestScore = score; + bestCols = cols; + } + } + + return bestCols; +} + /** * Attach event handlers to widget * @private