a7ed100780
Resolved issues where inventory and quests widgets didn't properly adapt to narrow desktop widths (~296px, 2 columns): - Assets tab was cut off (1/3 off-screen) - Widgets shrank vertically instead of expanding - Sub-tabs overflowed horizontally Changes: 1. Widget onResize Handlers (inventoryWidget.js, questsWidget.js): - Strengthened inventory onResize to call render() for full re-layout - Added quests onResize handler with re-render + width-aware styling - Both apply responsive CSS classes at narrow widths 2. Increased maxAutoSize for 2-Column Layouts: - inventoryWidget.js: h: 6 → h: 8 (creates expansion headroom) - questsWidget.js: h: 5 → h: 7 (user requested height) - Previously: defaultSize = maxAutoSize → zero expansion possible - Now: defaultSize < maxAutoSize → widgets can expand vertically 3. Added Missing Quests Widget Container Styles (style.css): - Added .rpg-quests-widget and .rpg-quests-views flex container styles - Proper overflow handling (hidden on container, auto on content) - Prevents Assets tab horizontal cut-off 4. Implemented Compact Mode CSS (style.css): - .rpg-inventory-compact: reduced padding, icon-only sub-tabs - Applied when widget width < 6 grid units - Prevents 3 sub-tabs from overflowing 296px container - Icons remain visible, labels hidden for space savings 5. Grid Engine Boolean Return (gridEngine.js): - setContainerWidth now returns true/false for column changes - Allows DashboardManager to optimize resize handling Why This Works: - Auto-layout expansion requires defaultSize < maxAutoSize for headroom - Flex container styles prevent overflow with proper scroll handling - Compact mode makes sub-tabs fit within narrow containers - onResize handlers ensure internal layouts adapt to dimension changes Fixes: Assets tab cut-off, vertical shrinking, sub-tab overflow at ~296px
711 lines
28 KiB
JavaScript
711 lines
28 KiB
JavaScript
/**
|
||
* GridEngine - Core grid layout engine for widget dashboard
|
||
*
|
||
* Handles grid-based positioning, snapping, collision detection, and auto-reflow.
|
||
* Uses a responsive 2-4 column grid system that adapts to panel width.
|
||
* Mobile devices (≤1000px screen width) always use 2 columns.
|
||
*
|
||
* @class GridEngine
|
||
*/
|
||
|
||
// Performance: Disable console logging (console.error still active)
|
||
// Temporarily enabled for debugging auto-arrange onResize issue
|
||
const DEBUG = true;
|
||
const console = DEBUG ? window.console : {
|
||
log: () => {},
|
||
warn: () => {},
|
||
error: window.console.error.bind(window.console)
|
||
};
|
||
|
||
export class GridEngine {
|
||
/**
|
||
* Initialize grid engine with configuration
|
||
*
|
||
* @param {Object} config - Grid configuration
|
||
* @param {number} [config.rowHeight=5] - Height of each row in rem units
|
||
* @param {number} [config.gap=0.75] - Gap between widgets in rem units
|
||
* @param {boolean} [config.snapToGrid=true] - Enable auto-snapping to grid
|
||
* @param {HTMLElement} [config.container=null] - Container element
|
||
*/
|
||
constructor(config = {}) {
|
||
// Start with 2 columns (safest default for side panel)
|
||
this.columns = 2;
|
||
// Use rem for responsive sizing across all resolutions (1080p, 4K, mobile)
|
||
// Mobile uses smaller rowHeight (3.5rem) to prevent vertical squashing
|
||
const isMobileViewport = window.innerWidth <= 1000;
|
||
const defaultRowHeight = isMobileViewport ? 3.5 : 5;
|
||
this.rowHeight = config.rowHeight || defaultRowHeight; // rem
|
||
this.gap = config.gap || 0.75; // rem (was 12px)
|
||
this.snapToGrid = config.snapToGrid !== false;
|
||
this.container = config.container || null;
|
||
|
||
// Widget registry for accessing widget definitions (e.g., maxAutoSize)
|
||
this.registry = config.registry || null;
|
||
|
||
// Container width will be set dynamically
|
||
this.containerWidth = 0;
|
||
|
||
// Callback for column changes (so DashboardManager can re-render)
|
||
this.onColumnsChange = config.onColumnsChange || null;
|
||
|
||
console.log('[GridEngine] Initialized:', {
|
||
columns: this.columns,
|
||
rowHeight: this.rowHeight + 'rem',
|
||
gap: this.gap + 'rem',
|
||
snapToGrid: this.snapToGrid,
|
||
isMobile: this.isMobile()
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Convert rem to pixels using current browser font size
|
||
* @param {number} rem - Value in rem units
|
||
* @returns {number} Value in pixels
|
||
*/
|
||
remToPixels(rem) {
|
||
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||
return rem * fontSize;
|
||
}
|
||
|
||
/**
|
||
* Convert pixels to rem using current browser font size
|
||
* @param {number} pixels - Value in pixels
|
||
* @returns {number} Value in rem
|
||
*/
|
||
pixelsToRem(pixels) {
|
||
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||
return pixels / fontSize;
|
||
}
|
||
|
||
/**
|
||
* Check if we're on a mobile device
|
||
* Mobile is defined as screen width ≤ 1000px
|
||
*
|
||
* @returns {boolean} True if mobile
|
||
*/
|
||
isMobile() {
|
||
return window.innerWidth <= 1000;
|
||
}
|
||
|
||
/**
|
||
* Calculate optimal number of columns based on container width
|
||
*
|
||
* Desktop (>1000px screen):
|
||
* - < 370px: 2 columns
|
||
* - 370-449px: 3 columns
|
||
* - ≥ 450px: 4 columns
|
||
*
|
||
* Mobile (≤1000px screen):
|
||
* - Always 2 columns
|
||
*
|
||
* @param {number} containerWidth - Container width in pixels
|
||
* @returns {number} Number of columns (2-4)
|
||
*/
|
||
calculateColumns(containerWidth) {
|
||
// Mobile always uses 2 columns
|
||
if (this.isMobile()) {
|
||
return 2;
|
||
}
|
||
|
||
// Desktop: dynamic 2-4 columns based on panel width
|
||
if (containerWidth < 370) return 2;
|
||
if (containerWidth < 450) return 3;
|
||
return 4;
|
||
}
|
||
|
||
/**
|
||
* Set container width (called when container is measured or resized)
|
||
*
|
||
* Recalculates column count based on new width and notifies if changed.
|
||
*
|
||
* @param {number} width - Container width in pixels
|
||
* @returns {boolean} True if column count changed, false otherwise
|
||
*/
|
||
setContainerWidth(width) {
|
||
const oldColumns = this.columns;
|
||
this.containerWidth = width;
|
||
this.columns = this.calculateColumns(width);
|
||
|
||
console.log('[GridEngine] Container width set to:', width, 'Columns:', this.columns);
|
||
|
||
// Notify if column count changed (so dashboard can re-render)
|
||
if (oldColumns !== this.columns && this.onColumnsChange) {
|
||
console.log('[GridEngine] Column count changed from', oldColumns, 'to', this.columns);
|
||
this.onColumnsChange(this.columns, oldColumns);
|
||
return true; // Signal that columns changed
|
||
}
|
||
|
||
return false; // Columns did NOT change
|
||
}
|
||
|
||
/**
|
||
* Calculate pixel position from grid coordinates
|
||
*
|
||
* Converts grid-based widget position (x, y, w, h) to actual pixel values
|
||
* (left, top, width, height) for CSS positioning.
|
||
* Note: rowHeight and gap are stored in rem, converted to pixels here.
|
||
*
|
||
* @param {Object} widget - Widget with grid coordinates
|
||
* @param {number} widget.x - Grid column position (0-based)
|
||
* @param {number} widget.y - Grid row position (0-based)
|
||
* @param {number} widget.w - Width in grid columns
|
||
* @param {number} widget.h - Height in grid rows
|
||
* @returns {Object} Pixel coordinates {left, top, width, height}
|
||
*
|
||
* @example
|
||
* // Widget at column 2, row 1, size 4x3
|
||
* const pixels = gridEngine.getPixelPosition({ x: 2, y: 1, w: 4, h: 3 });
|
||
* // Returns: { left: 200, top: 100, width: 300, height: 250 }
|
||
*/
|
||
getPixelPosition(widget) {
|
||
if (this.containerWidth === 0) {
|
||
console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)');
|
||
this.containerWidth = 350;
|
||
this.columns = this.calculateColumns(350); // Recalculate columns for fallback
|
||
}
|
||
|
||
// Convert rem to pixels for calculations
|
||
const gapPx = this.remToPixels(this.gap);
|
||
const rowHeightPx = this.remToPixels(this.rowHeight);
|
||
|
||
// Calculate column width
|
||
// Formula: (containerWidth - gaps) / columns
|
||
// Gaps: (columns + 1) gaps total (one before each column + one after last)
|
||
const totalGaps = gapPx * (this.columns + 1);
|
||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||
|
||
// Calculate positions
|
||
// Left: x columns * (colWidth + gap) + initial gap
|
||
const left = widget.x * (colWidth + gapPx) + gapPx;
|
||
|
||
// Top: y rows * (rowHeight + gap) + initial gap
|
||
const top = widget.y * (rowHeightPx + gapPx) + gapPx;
|
||
|
||
// Width: w columns * colWidth + (w - 1) inner gaps
|
||
const width = widget.w * colWidth + (widget.w - 1) * gapPx;
|
||
|
||
// Height: h rows * rowHeight + (h - 1) inner gaps
|
||
const height = widget.h * rowHeightPx + (widget.h - 1) * gapPx;
|
||
|
||
return { left, top, width, height };
|
||
}
|
||
|
||
/**
|
||
* Calculate responsive position from grid coordinates
|
||
*
|
||
* Returns positions as % of container width (for horizontal) and vh (for vertical).
|
||
* Widgets are positioned absolutely within the container, so % is relative to container.
|
||
*
|
||
* @param {Object} widget - Widget with grid coordinates
|
||
* @param {number} widget.x - Grid column position (0-based)
|
||
* @param {number} widget.y - Grid row position (0-based)
|
||
* @param {number} widget.w - Width in grid columns
|
||
* @param {number} widget.h - Height in grid rows
|
||
* @returns {Object} Responsive coordinates {left, top, width, height}
|
||
*
|
||
* @example
|
||
* // Widget at column 0, row 0, size 2x3 in 2-column grid
|
||
* const pos = gridEngine.getViewportPosition({ x: 0, y: 0, w: 2, h: 3 });
|
||
* // Returns: { left: "3%", top: "2vh", width: "94%", height: "25vh" }
|
||
*/
|
||
getViewportPosition(widget) {
|
||
if (this.containerWidth === 0) {
|
||
console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)');
|
||
this.containerWidth = 350;
|
||
this.columns = this.calculateColumns(350);
|
||
}
|
||
|
||
console.log('[GridEngine] getViewportPosition DEBUG:', {
|
||
widgetId: widget.id,
|
||
widgetSize: `${widget.w}×${widget.h}`,
|
||
containerWidth: this.containerWidth,
|
||
columns: this.columns,
|
||
gap: this.gap
|
||
});
|
||
|
||
// Calculate column width as % of container
|
||
const gapPercent = (this.gap / this.containerWidth) * 100;
|
||
const totalGapsPercent = gapPercent * (this.columns + 1);
|
||
const colWidthPercent = (100 - totalGapsPercent) / this.columns;
|
||
|
||
console.log('[GridEngine] Calculation values:', {
|
||
gapPercent: gapPercent.toFixed(2) + '%',
|
||
totalGapsPercent: totalGapsPercent.toFixed(2) + '%',
|
||
colWidthPercent: colWidthPercent.toFixed(2) + '%'
|
||
});
|
||
|
||
// Calculate positions
|
||
// Horizontal: % of container (since widgets are absolutely positioned within container)
|
||
const left = widget.x * (colWidthPercent + gapPercent) + gapPercent;
|
||
const width = widget.w * colWidthPercent + (widget.w - 1) * gapPercent;
|
||
|
||
console.log('[GridEngine] Position calc:', {
|
||
left: left.toFixed(2) + '%',
|
||
width: width.toFixed(2) + '%',
|
||
formula: `${widget.w} * ${colWidthPercent.toFixed(2)}% + ${widget.w - 1} * ${gapPercent.toFixed(2)}%`
|
||
});
|
||
|
||
// Vertical: rem units (scales across all resolutions - 1080p, 4K, mobile)
|
||
// rem scales with browser font size, which adapts to screen DPI
|
||
const top = widget.y * (this.rowHeight + this.gap) + this.gap;
|
||
const height = widget.h * this.rowHeight + (widget.h - 1) * this.gap;
|
||
|
||
return {
|
||
left: `${left.toFixed(2)}%`,
|
||
top: `${top.toFixed(2)}rem`,
|
||
width: `${width.toFixed(2)}%`,
|
||
height: `${height.toFixed(2)}rem`
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get widget position for CSS styling
|
||
* Returns responsive units for scaling across all screen sizes.
|
||
* Uses % of container for horizontal (adapts to panel width)
|
||
* Uses vh for vertical (adapts to viewport height)
|
||
*
|
||
* @param {Object} widget - Widget with grid coordinates
|
||
* @returns {Object} Position with %, vh units {left, top, width, height}
|
||
*/
|
||
getWidgetPosition(widget) {
|
||
return this.getViewportPosition(widget);
|
||
}
|
||
|
||
/**
|
||
* Snap pixel coordinates to nearest grid cell
|
||
*
|
||
* Converts pixel position (from drag-and-drop) to grid coordinates.
|
||
* Clamps to valid grid bounds.
|
||
*
|
||
* @param {number} pixelX - X coordinate in pixels
|
||
* @param {number} pixelY - Y coordinate in pixels
|
||
* @returns {Object} Grid coordinates {x, y}
|
||
*
|
||
* @example
|
||
* // Mouse dragged to pixel (250, 175)
|
||
* const gridPos = gridEngine.snapToCell(250, 175);
|
||
* // Returns: { x: 3, y: 2 } (nearest grid cell)
|
||
*/
|
||
snapToCell(pixelX, pixelY) {
|
||
if (this.containerWidth === 0) {
|
||
console.warn('[GridEngine] Container width not set, using default 350px (side panel estimate)');
|
||
this.containerWidth = 350;
|
||
this.columns = this.calculateColumns(350); // Recalculate columns for fallback
|
||
}
|
||
|
||
// Convert rem to pixels for calculations
|
||
const gapPx = this.remToPixels(this.gap);
|
||
const rowHeightPx = this.remToPixels(this.rowHeight);
|
||
|
||
// Calculate column width
|
||
const totalGaps = gapPx * (this.columns + 1);
|
||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||
|
||
// Convert pixel to grid coordinates
|
||
// Reverse of getPixelPosition formula
|
||
// x = (pixelX - gap) / (colWidth + gap)
|
||
const x = Math.round((pixelX - gapPx) / (colWidth + gapPx));
|
||
const y = Math.round((pixelY - gapPx) / (rowHeightPx + gapPx));
|
||
|
||
// Clamp to valid grid bounds
|
||
return {
|
||
x: Math.max(0, Math.min(x, this.columns - 1)),
|
||
y: Math.max(0, y) // No maximum Y (infinite rows)
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Detect if widget collides with any other widgets
|
||
*
|
||
* Uses rectangle intersection algorithm. Two rectangles DON'T intersect if:
|
||
* - rect1 is completely left of rect2, OR
|
||
* - rect1 is completely right of rect2, OR
|
||
* - rect1 is completely above rect2, OR
|
||
* - rect1 is completely below rect2
|
||
*
|
||
* If none of the above are true, they must intersect.
|
||
*
|
||
* @param {Object} widget - Widget to check for collisions
|
||
* @param {Array<Object>} widgets - Array of other widgets to check against
|
||
* @returns {boolean} True if widget collides with any other widget
|
||
*
|
||
* @example
|
||
* const widget = { x: 2, y: 1, w: 4, h: 3 };
|
||
* const others = [{ x: 4, y: 2, w: 2, h: 2 }];
|
||
* const collides = gridEngine.detectCollision(widget, others);
|
||
* // Returns: true (widgets overlap)
|
||
*/
|
||
detectCollision(widget, widgets) {
|
||
return widgets.some(other => {
|
||
// Don't collide with self
|
||
if (other.id === widget.id) return false;
|
||
|
||
// Check if rectangles DON'T intersect (then negate)
|
||
const noIntersect = (
|
||
widget.x + widget.w <= other.x || // widget is left of other
|
||
widget.x >= other.x + other.w || // widget is right of other
|
||
widget.y + widget.h <= other.y || // widget is above other
|
||
widget.y >= other.y + other.h // widget is below other
|
||
);
|
||
|
||
return !noIntersect; // If they don't NOT intersect, they DO intersect
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Reflow widgets to remove overlaps
|
||
*
|
||
* When a widget is moved and causes collisions, this pushes overlapping
|
||
* widgets down to make room. Processes widgets in order (top to bottom,
|
||
* left to right) to ensure consistent layout.
|
||
*
|
||
* @param {Array<Object>} widgets - Array of widgets to reflow
|
||
* @returns {Array<Object>} Reflowed widgets (same array, modified in place)
|
||
*
|
||
* @example
|
||
* // Widget moved to position that overlaps another
|
||
* const widgets = [
|
||
* { x: 0, y: 0, w: 4, h: 2 },
|
||
* { x: 2, y: 0, w: 4, h: 2 } // Overlaps first widget!
|
||
* ];
|
||
* gridEngine.reflow(widgets);
|
||
* // Second widget pushed down: { x: 2, y: 2, w: 4, h: 2 }
|
||
*/
|
||
reflow(widgets) {
|
||
// Sort widgets by position (top to bottom, left to right)
|
||
// This ensures we process in reading order
|
||
const sorted = [...widgets].sort((a, b) => {
|
||
if (a.y !== b.y) return a.y - b.y; // Sort by Y first
|
||
return a.x - b.x; // Then by X
|
||
});
|
||
|
||
// Process each widget
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
const widget = sorted[i];
|
||
|
||
// Keep pushing widget down while it collides with any widget before it
|
||
// (widgets before it in sorted order are already positioned correctly)
|
||
while (this.detectCollision(widget, sorted.slice(0, i))) {
|
||
widget.y++;
|
||
}
|
||
}
|
||
|
||
console.log('[GridEngine] Reflowed', widgets.length, 'widgets');
|
||
return sorted;
|
||
}
|
||
|
||
/**
|
||
* Validate widget dimensions
|
||
*
|
||
* Ensures widget fits within grid bounds and has valid size.
|
||
*
|
||
* @param {Object} widget - Widget to validate
|
||
* @param {Object} minSize - Minimum allowed size {w, h}
|
||
* @returns {Object} Validated widget (clamped to valid values)
|
||
*/
|
||
validateWidget(widget, minSize = { w: 1, h: 1 }) {
|
||
return {
|
||
...widget,
|
||
x: Math.max(0, Math.min(widget.x, this.columns - 1)),
|
||
y: Math.max(0, widget.y),
|
||
w: Math.max(minSize.w, Math.min(widget.w, this.columns)),
|
||
h: Math.max(minSize.h, widget.h)
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Calculate total grid height needed for all widgets
|
||
*
|
||
* @param {Array<Object>} widgets - Array of widgets
|
||
* @returns {number} Total height in rem units
|
||
*/
|
||
calculateGridHeight(widgets) {
|
||
if (widgets.length === 0) return 0;
|
||
|
||
// Find the bottom-most widget
|
||
const maxY = Math.max(...widgets.map(w => w.y + w.h));
|
||
|
||
// Calculate total height including gaps (in rem)
|
||
return maxY * (this.rowHeight + this.gap) + this.gap;
|
||
}
|
||
|
||
/**
|
||
* Auto-layout widgets to efficiently use all available space
|
||
*
|
||
* Packs widgets in reading order (left to right, top to bottom) with no gaps.
|
||
* Respects each widget's defined size - only repositions, doesn't resize.
|
||
* Respects current column count (responsive to panel width).
|
||
*
|
||
* Strategy:
|
||
* 1. Sort widgets (by area or preserve order if requested)
|
||
* 2. For each widget, keep its defined size (w, h)
|
||
* 3. Find first available position from top-left
|
||
* 4. Ensure no overlaps
|
||
* 5. If widget doesn't fit at preferred size, try narrower widths
|
||
*
|
||
* @param {Array<Object>} widgets - Array of widgets to auto-layout
|
||
* @param {Object} options - Layout options
|
||
* @param {boolean} [options.preserveOrder=false] - Keep input order instead of sorting by area
|
||
* @returns {Array<Object>} Re-positioned widgets (same array, modified in place)
|
||
*/
|
||
autoLayout(widgets, options = {}) {
|
||
if (widgets.length === 0) return widgets;
|
||
|
||
const preserveOrder = options.preserveOrder || false;
|
||
|
||
// Calculate maximum visible rows based on grid container's actual viewport height
|
||
let maxVisibleRows = 100; // Fallback
|
||
if (this.container) {
|
||
// Use grid container's own clientHeight (actual visible viewport area)
|
||
// Don't use parentElement which includes the header (tabs + buttons)
|
||
const viewportHeight = this.container.clientHeight; // pixels
|
||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); // px per rem
|
||
const viewportHeightRem = viewportHeight / rootFontSize;
|
||
const rowHeightWithGap = this.rowHeight + this.gap;
|
||
// Add gap to calculation because last row doesn't need trailing gap
|
||
// Formula: (height + gap) / (rowHeight + gap) accounts for N rows with N-1 gaps
|
||
maxVisibleRows = Math.floor((viewportHeightRem + this.gap) / rowHeightWithGap);
|
||
console.log('[GridEngine] Viewport height:', viewportHeight + 'px', '=', viewportHeightRem.toFixed(2) + 'rem', '→', maxVisibleRows, 'visible rows');
|
||
}
|
||
|
||
console.log('[GridEngine] Auto-layout started:', {
|
||
widgetCount: widgets.length,
|
||
columns: this.columns,
|
||
preserveOrder,
|
||
maxVisibleRows
|
||
});
|
||
|
||
// Sort widgets (or preserve input order for category-aware layout)
|
||
const sorted = preserveOrder ? [...widgets] : [...widgets].sort((a, b) => {
|
||
const areaA = a.w * a.h;
|
||
const areaB = b.w * b.h;
|
||
if (areaB !== areaA) return areaB - areaA;
|
||
// If same area, sort by height (taller first)
|
||
return b.h - a.h;
|
||
});
|
||
|
||
// Track occupied cells in a 2D grid
|
||
const occupied = new Map(); // key: "x,y" => widget
|
||
|
||
/**
|
||
* Check if position is free
|
||
*/
|
||
const isFree = (x, y, w, h) => {
|
||
for (let row = y; row < y + h; row++) {
|
||
for (let col = x; col < x + w; col++) {
|
||
const key = `${col},${row}`;
|
||
if (occupied.has(key)) return false;
|
||
if (col >= this.columns) return false; // Out of bounds
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
/**
|
||
* Mark cells as occupied
|
||
*/
|
||
const markOccupied = (widget, x, y, w, h) => {
|
||
for (let row = y; row < y + h; row++) {
|
||
for (let col = x; col < x + w; col++) {
|
||
occupied.set(`${col},${row}`, widget.id);
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Find first available position for widget of given size
|
||
*/
|
||
const findPosition = (w, h) => {
|
||
// Start from top-left, scan row by row
|
||
for (let y = 0; y < 1000; y++) { // Max 1000 rows (practical limit)
|
||
for (let x = 0; x <= this.columns - w; x++) {
|
||
if (isFree(x, y, w, h)) {
|
||
return { x, y };
|
||
}
|
||
}
|
||
}
|
||
// Fallback: stack at bottom (should never happen)
|
||
return { x: 0, y: 1000 };
|
||
};
|
||
|
||
// Process each widget
|
||
sorted.forEach(widget => {
|
||
// Respect widget's defined size - only clamp to grid bounds
|
||
// Don't force sizes - widgets define their own optimal dimensions
|
||
let targetW = Math.min(widget.w, this.columns); // Clamp to column count
|
||
let targetH = widget.h; // Respect widget's height
|
||
|
||
// Try to find position for preferred size
|
||
let pos = findPosition(targetW, targetH);
|
||
|
||
// If preferred size doesn't fit well, try smaller widths
|
||
// (but never go below 1 column)
|
||
if (pos.y > 100 && targetW > 1) {
|
||
// Widget would be placed very far down, try narrower width
|
||
for (let tryW = targetW - 1; tryW >= 1; tryW--) {
|
||
const tryPos = findPosition(tryW, targetH);
|
||
if (tryPos.y < pos.y) {
|
||
// Found better position with narrower width
|
||
pos = tryPos;
|
||
targetW = tryW;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update widget position and size
|
||
widget.x = pos.x;
|
||
widget.y = pos.y;
|
||
widget.w = targetW;
|
||
widget.h = targetH;
|
||
|
||
// Mark cells as occupied
|
||
markOccupied(widget, pos.x, pos.y, targetW, targetH);
|
||
|
||
console.log(`[GridEngine] Auto-layout positioned: ${widget.id} at (${pos.x},${pos.y}) size ${targetW}×${targetH}`);
|
||
});
|
||
|
||
// Compact pass: Move widgets up to fill gaps
|
||
console.log('[GridEngine] Compacting layout to fill gaps...');
|
||
let compactedCount = 0;
|
||
|
||
// Sort widgets by current Y position (process top to bottom)
|
||
const sortedForCompact = [...sorted].sort((a, b) => a.y - b.y);
|
||
|
||
sortedForCompact.forEach(widget => {
|
||
const originalY = widget.y;
|
||
|
||
// Try to move widget up as far as possible
|
||
for (let tryY = 0; tryY < originalY; tryY++) {
|
||
// Clear current position from occupied map
|
||
for (let row = originalY; row < originalY + widget.h; row++) {
|
||
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||
occupied.delete(`${col},${row}`);
|
||
}
|
||
}
|
||
|
||
// Check if new position is free
|
||
if (isFree(widget.x, tryY, widget.w, widget.h)) {
|
||
// Move widget up
|
||
widget.y = tryY;
|
||
markOccupied(widget, widget.x, tryY, widget.w, widget.h);
|
||
compactedCount++;
|
||
console.log(`[GridEngine] Compacted ${widget.id} from y=${originalY} to y=${tryY}`);
|
||
break;
|
||
} else {
|
||
// Re-mark original position and continue
|
||
markOccupied(widget, widget.x, originalY, widget.w, widget.h);
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log(`[GridEngine] Compaction complete (${compactedCount} widgets moved up)`);
|
||
|
||
// Expansion pass: Try to expand widgets to fill available space
|
||
console.log('[GridEngine] Expanding widgets to fill available space...');
|
||
let expandedCount = 0;
|
||
|
||
// Sort widgets by position (top-to-bottom, left-to-right) for orderly expansion
|
||
const sortedForExpand = [...sorted].sort((a, b) => {
|
||
if (a.y !== b.y) return a.y - b.y; // Top to bottom
|
||
return a.x - b.x; // Left to right
|
||
});
|
||
|
||
// Helper to get widget max size from registry
|
||
const getWidgetMaxSize = (widget) => {
|
||
// Try to get widget definition from registry
|
||
if (this.registry && widget.type) {
|
||
const definition = this.registry.get(widget.type);
|
||
if (definition && definition.maxAutoSize) {
|
||
// Support maxAutoSize as function (column-aware sizing)
|
||
if (typeof definition.maxAutoSize === 'function') {
|
||
return definition.maxAutoSize(this.columns);
|
||
}
|
||
// Static maxAutoSize object
|
||
return definition.maxAutoSize;
|
||
}
|
||
}
|
||
// Default max size if not specified (conservative expansion)
|
||
return { w: this.columns, h: 3 };
|
||
};
|
||
|
||
sortedForExpand.forEach(widget => {
|
||
const maxSize = getWidgetMaxSize(widget);
|
||
const originalW = widget.w;
|
||
const originalH = widget.h;
|
||
|
||
// Try expanding height first (fills vertical gaps) - keep trying until maxSize or collision
|
||
let expandedH = false;
|
||
for (let tryH = originalH + 1; tryH <= maxSize.h; tryH++) {
|
||
// Check if expansion would go beyond visible area
|
||
// y + h represents the row AFTER the widget ends, so > check (not >=) is correct
|
||
if (widget.y + tryH > maxVisibleRows) {
|
||
console.log(`[GridEngine] ${widget.id} cannot expand to h=${tryH} (would exceed visible area: row ${widget.y + tryH} > ${maxVisibleRows})`);
|
||
break;
|
||
}
|
||
|
||
// Clear current position
|
||
for (let row = widget.y; row < widget.y + widget.h; row++) {
|
||
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||
occupied.delete(`${col},${row}`);
|
||
}
|
||
}
|
||
|
||
// Check if expanded height is free
|
||
if (isFree(widget.x, widget.y, widget.w, tryH)) {
|
||
widget.h = tryH;
|
||
markOccupied(widget, widget.x, widget.y, widget.w, tryH);
|
||
expandedH = true;
|
||
expandedCount++;
|
||
// Continue trying to expand further
|
||
} else {
|
||
// Hit a collision, stop expanding height
|
||
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (expandedH) {
|
||
console.log(`[GridEngine] Expanded ${widget.id} height: ${originalH} → ${widget.h}`);
|
||
}
|
||
|
||
// Try expanding width (fills horizontal gaps) - keep trying until maxSize or collision
|
||
let expandedW = false;
|
||
for (let tryW = originalW + 1; tryW <= Math.min(maxSize.w, this.columns); tryW++) {
|
||
// Clear current position
|
||
for (let row = widget.y; row < widget.y + widget.h; row++) {
|
||
for (let col = widget.x; col < widget.x + widget.w; col++) {
|
||
occupied.delete(`${col},${row}`);
|
||
}
|
||
}
|
||
|
||
// Check if expanded width is free
|
||
if (isFree(widget.x, widget.y, tryW, widget.h)) {
|
||
widget.w = tryW;
|
||
markOccupied(widget, widget.x, widget.y, tryW, widget.h);
|
||
expandedW = true;
|
||
expandedCount++;
|
||
// Continue trying to expand further
|
||
} else {
|
||
// Hit a collision, stop expanding width
|
||
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (expandedW) {
|
||
console.log(`[GridEngine] Expanded ${widget.id} width: ${originalW} → ${widget.w}`);
|
||
}
|
||
|
||
if (!expandedH && !expandedW) {
|
||
// Widget couldn't expand - ensure it's still marked in grid
|
||
markOccupied(widget, widget.x, widget.y, widget.w, widget.h);
|
||
}
|
||
});
|
||
|
||
console.log(`[GridEngine] Expansion complete (${expandedCount} expansions made)`);
|
||
console.log(`[GridEngine] Auto-layout complete`);
|
||
return widgets;
|
||
}
|
||
}
|