Files
dyolink/frontend/src/components/today/today-dashboard-layout.ts

136 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (12 or 34 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';