Files
dees-catalog/ts_web/elements/dees-dashboardgrid/layout.ts
Juergen Kunz 6f9c92a866 feat: implement DeesDashboardgrid component with drag-and-drop functionality
- Added DeesDashboardgrid class for managing a grid of dashboard widgets.
- Implemented widget dragging and resizing capabilities.
- Introduced layout management with collision detection and margin resolution.
- Created styles for grid layout, widget appearance, and animations.
- Added support for customizable margins, cell height, and grid lines.
- Included methods for adding, removing, and updating widgets dynamically.
- Implemented context menu for widget actions and keyboard navigation support.
- Established a responsive design with breakpoint handling for different layouts.
2025-09-17 21:46:44 +00:00

232 lines
6.3 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,
): 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) {
otherClone.x = original.x;
otherClone.y = original.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;
});
};