234 lines
6.4 KiB
TypeScript
234 lines
6.4 KiB
TypeScript
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) {
|
|
const swapTarget = previousPosition ?? original;
|
|
otherClone.x = swapTarget.x;
|
|
otherClone.y = swapTarget.y;
|
|
return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id };
|
|
}
|
|
}
|
|
}
|
|
|
|
// attempt displacement cascade
|
|
const movedIds = new Set<string>([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;
|
|
});
|
|
};
|