136 lines
3.9 KiB
TypeScript
136 lines
3.9 KiB
TypeScript
|
|
import type { ReactNode } from 'react';
|
|||
|
|
|
|||
|
|
/** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */
|
|||
|
|
export type TodayDashboardWidth = 1 | 2;
|
|||
|
|
export type TodayDashboardHeight = 1 | 2 | 3;
|
|||
|
|
|
|||
|
|
export interface TodayDashboardLayout {
|
|||
|
|
width: TodayDashboardWidth;
|
|||
|
|
height: TodayDashboardHeight;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Shared layout presets — assign when registering a dashboard widget. */
|
|||
|
|
export const TODAY_DASHBOARD_LAYOUT = {
|
|||
|
|
kpi: { width: 1, height: 1 },
|
|||
|
|
subscription: { width: 1, height: 2 },
|
|||
|
|
upcoming: { width: 2, height: 3 },
|
|||
|
|
/** Tall charts: area, stacked bar, pie with side legend */
|
|||
|
|
chart: { width: 2, height: 3 },
|
|||
|
|
/** Medium charts: horizontal bar, vertical bar, radial gauge */
|
|||
|
|
chartMedium: { width: 2, height: 2 },
|
|||
|
|
} as const satisfies Record<string, TodayDashboardLayout>;
|
|||
|
|
|
|||
|
|
export interface TodayDashboardCell {
|
|||
|
|
id: string;
|
|||
|
|
layout: TodayDashboardLayout;
|
|||
|
|
content: ReactNode;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface PackedDashboardCell extends TodayDashboardCell {
|
|||
|
|
gridColumn: string;
|
|||
|
|
gridRow: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function compareDashboardLayout(
|
|||
|
|
a: TodayDashboardLayout,
|
|||
|
|
b: TodayDashboardLayout,
|
|||
|
|
): number {
|
|||
|
|
if (a.width !== b.width) return a.width - b.width;
|
|||
|
|
return a.height - b.height;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function sortDashboardCells<T extends { layout: TodayDashboardLayout; id: string }>(
|
|||
|
|
cells: T[],
|
|||
|
|
): T[] {
|
|||
|
|
return [...cells].sort((a, b) => {
|
|||
|
|
const byLayout = compareDashboardLayout(a.layout, b.layout);
|
|||
|
|
if (byLayout !== 0) return byLayout;
|
|||
|
|
return a.id.localeCompare(b.id);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Wide widgets (width > 1) anchor to column pairs — never straddle the grid center. */
|
|||
|
|
export function allowedStartColumns(
|
|||
|
|
width: number,
|
|||
|
|
columns: number,
|
|||
|
|
): number[] {
|
|||
|
|
if (width <= 1) {
|
|||
|
|
return Array.from({ length: columns }, (_, index) => index);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (width === 2 && columns === 4) {
|
|||
|
|
return [0, 2];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Array.from({ length: columns - width + 1 }, (_, index) => index);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* First-fit placement in ascending layout order (top-left scan).
|
|||
|
|
* Multi-column widgets may only start at aligned column pairs (1–2 or 3–4 on a 4-col grid).
|
|||
|
|
*/
|
|||
|
|
export function packDashboardCells(
|
|||
|
|
cells: TodayDashboardCell[],
|
|||
|
|
columns = 4,
|
|||
|
|
): PackedDashboardCell[] {
|
|||
|
|
const sorted = sortDashboardCells(cells);
|
|||
|
|
const occupied = new Set<string>();
|
|||
|
|
|
|||
|
|
function canPlace(row: number, col: number, width: number, height: number): boolean {
|
|||
|
|
if (col + width > columns) return false;
|
|||
|
|
for (let r = row; r < row + height; r += 1) {
|
|||
|
|
for (let c = col; c < col + width; c += 1) {
|
|||
|
|
if (occupied.has(`${r}-${c}`)) return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function mark(row: number, col: number, width: number, height: number) {
|
|||
|
|
for (let r = row; r < row + height; r += 1) {
|
|||
|
|
for (let c = col; c < col + width; c += 1) {
|
|||
|
|
occupied.add(`${r}-${c}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const placed: PackedDashboardCell[] = [];
|
|||
|
|
|
|||
|
|
for (const cell of sorted) {
|
|||
|
|
const { width, height } = cell.layout;
|
|||
|
|
let found = false;
|
|||
|
|
const startColumns = allowedStartColumns(width, columns);
|
|||
|
|
|
|||
|
|
for (let row = 0; !found; row += 1) {
|
|||
|
|
for (const col of startColumns) {
|
|||
|
|
if (!canPlace(row, col, width, height)) continue;
|
|||
|
|
mark(row, col, width, height);
|
|||
|
|
placed.push({
|
|||
|
|
...cell,
|
|||
|
|
gridColumn: `${col + 1} / span ${width}`,
|
|||
|
|
gridRow: `${row + 1} / span ${height}`,
|
|||
|
|
});
|
|||
|
|
found = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return placed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function packedCellClassName(layout: TodayDashboardLayout): string {
|
|||
|
|
const rowSpan =
|
|||
|
|
layout.height === 3 ? 'row-span-3' : layout.height === 2 ? 'row-span-2' : 'row-span-1';
|
|||
|
|
|
|||
|
|
const colSpan =
|
|||
|
|
layout.width === 2
|
|||
|
|
? 'col-span-2 max-sm:col-span-1'
|
|||
|
|
: 'col-span-1';
|
|||
|
|
|
|||
|
|
return `${colSpan} ${rowSpan} min-h-0 min-w-0 overflow-hidden flex flex-col max-lg:${colSpan}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const TODAY_DASHBOARD_GRID_CLASS =
|
|||
|
|
'today-dashboard-grid grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1 gap-4';
|