import type { DashboardResolvedMargins, DashboardMargin, DashboardWidget, DashboardLayoutItem, GridCellMetrics, LayoutDirection, } from './types.js'; export const DEFAULT_MARGIN = 10; export const resolveMargins = (margin: DashboardMargin): DashboardResolvedMargins => { if (typeof margin === 'number') { return { horizontal: margin, vertical: margin, top: margin, right: margin, bottom: margin, left: margin, }; } const resolved = { top: margin.top ?? DEFAULT_MARGIN, right: margin.right ?? DEFAULT_MARGIN, bottom: margin.bottom ?? DEFAULT_MARGIN, left: margin.left ?? DEFAULT_MARGIN, }; return { ...resolved, horizontal: (resolved.left + resolved.right) / 2, vertical: (resolved.top + resolved.bottom) / 2, }; }; export const calculateCellMetrics = ( containerWidth: number, columns: number, margins: DashboardResolvedMargins, cellHeight: number, cellHeightUnit: string, ): GridCellMetrics => { const totalMarginWidth = margins.horizontal * (columns + 1); const availableWidth = Math.max(containerWidth - totalMarginWidth, 0); const cellWidthPx = columns > 0 ? availableWidth / columns : 0; const cellHeightPx = cellHeightUnit === 'auto' ? cellWidthPx : cellHeight; return { containerWidth, cellWidthPx, marginHorizontalPx: margins.horizontal, cellHeightPx, marginVerticalPx: margins.vertical, }; }; export const calculateGridHeight = ( widgets: DashboardWidget[], margins: DashboardResolvedMargins, cellHeight: number, ): number => { if (widgets.length === 0) return 0; const maxY = Math.max(...widgets.map(widget => widget.y + widget.h), 0); return maxY * cellHeight + (maxY + 1) * margins.vertical; }; const overlaps = ( widget: DashboardWidget, x: number, y: number, w: number, h: number, ) => x < widget.x + widget.w && x + w > widget.x && y < widget.y + widget.h && y + h > widget.y; export const collectCollisions = ( widgets: DashboardWidget[], target: DashboardWidget, nextX: number, nextY: number, nextW: number = target.w, nextH: number = target.h, ): DashboardWidget[] => { return widgets.filter(widget => { if (widget.id === target.id) return false; return overlaps(widget, nextX, nextY, nextW, nextH); }); }; export const checkCollision = ( widgets: DashboardWidget[], target: DashboardWidget, nextX: number, nextY: number, ): boolean => collectCollisions(widgets, target, nextX, nextY).length > 0; export const cloneWidget = (widget: DashboardWidget): DashboardWidget => ({ ...widget }); export const cloneWidgets = (widgets: DashboardWidget[]): DashboardWidget[] => widgets.map(cloneWidget); export const findAvailablePosition = ( widgets: DashboardWidget[], width: number, height: number, columns: number, ): { x: number; y: number } => { for (let y = 0; y < 200; y++) { for (let x = 0; x <= columns - width; x++) { const isFree = !widgets.some(widget => overlaps(widget, x, y, width, height)); if (isFree) { return { x, y }; } } } const maxY = widgets.reduce((acc, widget) => Math.max(acc, widget.y + widget.h), 0); return { x: 0, y: maxY }; }; export interface PlacementResult { widgets: DashboardWidget[]; movedWidgets: string[]; swappedWith?: string; } export const resolveWidgetPlacement = ( widgets: DashboardWidget[], widgetId: string, next: { x: number; y: number; w?: number; h?: number }, columns: number, previousPosition?: DashboardLayoutItem, ): PlacementResult | null => { const sourceWidgets = cloneWidgets(widgets); const moving = sourceWidgets.find(widget => widget.id === widgetId); const original = widgets.find(widget => widget.id === widgetId); if (!moving || !original) { return null; } const target = { x: next.x, y: next.y, w: next.w ?? moving.w, h: next.h ?? moving.h, }; moving.x = target.x; moving.y = target.y; moving.w = target.w; moving.h = target.h; const collisions = collectCollisions(sourceWidgets, moving, target.x, target.y, target.w, target.h); if (collisions.length === 0) { return { widgets: sourceWidgets, movedWidgets: [moving.id] }; } if (collisions.length === 1) { const other = collisions[0]; if (!other.locked && !other.noMove && other.w === moving.w && other.h === moving.h) { const otherClone = sourceWidgets.find(widget => widget.id === other.id); if (otherClone) { // Use the original position of the moving widget for a clean swap // This prevents the "snapping together" issue where both widgets end up at the same position const swapTarget = original; const previousOtherPosition = { x: otherClone.x, y: otherClone.y }; otherClone.x = swapTarget.x; otherClone.y = swapTarget.y; const swapValid = collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h).length === 0 && collectCollisions(sourceWidgets, otherClone, otherClone.x, otherClone.y, otherClone.w, otherClone.h).length === 0; if (swapValid) { return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id }; } otherClone.x = previousOtherPosition.x; otherClone.y = previousOtherPosition.y; } } } // attempt displacement cascade const movedIds = new Set([moving.id]); for (const offending of collisions) { if (offending.locked || offending.noMove) { return null; } const clone = sourceWidgets.find(widget => widget.id === offending.id); if (!clone) continue; const remaining = sourceWidgets.filter(widget => widget.id !== offending.id); const position = findAvailablePosition(remaining, clone.w, clone.h, columns); clone.x = position.x; clone.y = position.y; movedIds.add(clone.id); } // verify no overlaps remain const verify = collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h); if (verify.length > 0) { return null; } return { widgets: sourceWidgets, movedWidgets: Array.from(movedIds) }; }; export const compactLayout = ( widgets: DashboardWidget[], direction: LayoutDirection = 'vertical', ) => { const sorted = [...widgets].sort((a, b) => { if (direction === 'vertical') { if (a.y !== b.y) return a.y - b.y; return a.x - b.x; } if (a.x !== b.x) return a.x - b.x; return a.y - b.y; }); for (const widget of sorted) { if (widget.locked || widget.noMove) continue; if (direction === 'vertical') { while (widget.y > 0 && !checkCollision(widgets, widget, widget.x, widget.y - 1)) { widget.y -= 1; } } else { while (widget.x > 0 && !checkCollision(widgets, widget, widget.x - 1, widget.y)) { widget.x -= 1; } } } }; export const applyLayout = ( widgets: DashboardWidget[], layout: DashboardLayoutItem[], ): DashboardWidget[] => { return widgets.map(widget => { const layoutItem = layout.find(item => item.id === widget.id); return layoutItem ? { ...widget, ...layoutItem } : widget; }); };