feat(dashboard): add smart grid layout for User Attributes widget

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
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-11-04 10:34:31 +11:00
parent 5f1310cf62
commit cd3acbab29
@@ -93,10 +93,15 @@ export function registerUserAttributesWidget(registry, dependencies) {
</div> </div>
`).join(''); `).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 = ` const html = `
<div class="rpg-classic-stats"> <div class="rpg-classic-stats">
<div class="rpg-classic-stats-grid"> <div class="rpg-classic-stats-grid" style="grid-template-columns: repeat(${optimalCols}, 1fr);">
${statsHtml} ${statsHtml}
</div> </div>
</div> </div>
@@ -164,13 +169,14 @@ export function registerUserAttributesWidget(registry, dependencies) {
const statsGrid = container.querySelector('.rpg-classic-stats-grid'); const statsGrid = container.querySelector('.rpg-classic-stats-grid');
if (!statsGrid) return; if (!statsGrid) return;
// Compact single-column layout for narrow widgets // Count visible attributes from DOM
if (newW < 2) { const attrCount = statsGrid.querySelectorAll('.rpg-classic-stat').length;
statsGrid.style.gridTemplateColumns = '1fr';
} else { // Recalculate optimal columns based on new width
// 2-column grid for wider widgets const optimalCols = calculateOptimalColumns(attrCount, newW);
statsGrid.style.gridTemplateColumns = 'repeat(2, 1fr)';
} // 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 filterList = config.visibleAttrs || config.visibleStats;
const visibleAttrCount = filterList?.length || globallyEnabledCount; const visibleAttrCount = filterList?.length || globallyEnabledCount;
// Each attribute needs ~0.35 rows in 2-column grid // Determine optimal width and columns based on attribute count
// For 6 attrs: 3 rows (0.5 row padding = 3.5 total) // For 9 attributes: prefer 3 columns (3×3 grid)
const optimalHeight = Math.ceil((visibleAttrCount / 2) * 0.7 + 0.5); // 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 { return {
w: 2, // Prefer 2-column grid layout w: optimalWidth,
h: Math.max(this.minSize.h, optimalHeight) 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 * Attach event handlers to widget
* @private * @private