After m/paliad#69's edit-mode overhaul, widgets visually overlapped on mixed-size rows: a 12-col + 6-col swap, an auto-flow widget landing on an explicit blocker, or a resize-grow into a sibling all produced layouts that ignored colspan footprints when computing occupancy. Extracts placement math from dashboard.ts into a pure ./dashboard-grid module and adds an occupancy bitmap. Every visible widget is placed once; explicit-position collisions are resolved by searching downward from the requested row for the first w×h block that fits, preferring the requested column. Resize-grow + drag-drop swap now reliably produce no-overlap layouts because the placer cleans up after them. x+w > GRID_COLUMNS is clamped in the placer instead of rendered as an overflow — matches the validator's hard rule on the wire. Adds 14 dashboard-grid.test.ts regressions covering the mixed-width swap, resize-grow shifting siblings, multi-row widgets, and the overflow clamp. Pure tests — no DOM.
217 lines
7.8 KiB
TypeScript
217 lines
7.8 KiB
TypeScript
// dashboard-grid — pure layout math for the dashboard widget grid.
|
||
//
|
||
// Lives outside dashboard.ts so the placement logic is importable from
|
||
// tests without dragging in the DOM-side rendering code. The grid is a
|
||
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
|
||
// rows grow vertically as widgets are placed.
|
||
//
|
||
// The core invariant is no-overlap: after placeWidgets() returns, every
|
||
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
|
||
// computePlacements() to trust explicit (x, y) without checking — that
|
||
// produced visual overlap whenever a drag or resize landed a widget on
|
||
// cells another widget already covered (m/paliad#70). The collision-
|
||
// aware placer below shifts colliding widgets to the next free row so
|
||
// the rendered grid never overlaps regardless of the input spec.
|
||
|
||
export const GRID_COLUMNS = 12;
|
||
export const MAX_ROW_SPAN = 5;
|
||
|
||
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
|
||
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
|
||
// at least one widget, so 256 rows is an order-of-magnitude buffer
|
||
// against runaway loops on pathological inputs.
|
||
const MAX_SCAN_ROWS = 256;
|
||
|
||
export interface PlacedRect {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
// WidgetSizeBound captures the per-widget min/max/default clamps the
|
||
// catalog publishes. Optional fields keep callers from having to
|
||
// synthesize zeroes when the catalog entry is missing.
|
||
export interface WidgetSizeBound {
|
||
default_w?: number;
|
||
default_h?: number;
|
||
min_w?: number;
|
||
max_w?: number;
|
||
min_h?: number;
|
||
max_h?: number;
|
||
}
|
||
|
||
// WidgetPlacementInput is the per-widget data the placer consumes. The
|
||
// catalog bound is optional — when missing, defaults fall back to a
|
||
// full-width 1-row widget.
|
||
export interface WidgetPlacementInput {
|
||
key: string;
|
||
visible: boolean;
|
||
x?: number;
|
||
y?: number;
|
||
w?: number;
|
||
h?: number;
|
||
bound?: WidgetSizeBound;
|
||
}
|
||
|
||
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
|
||
let v = Math.round(w);
|
||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
|
||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
|
||
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
|
||
return v;
|
||
}
|
||
|
||
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
|
||
let v = Math.round(h);
|
||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
|
||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
|
||
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
|
||
return v;
|
||
}
|
||
|
||
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
|
||
// created lazily so the map only stores rows the layout actually
|
||
// reaches. Cell value 1 = occupied.
|
||
class Occupancy {
|
||
private rows = new Map<number, Uint8Array>();
|
||
|
||
row(y: number): Uint8Array {
|
||
let r = this.rows.get(y);
|
||
if (!r) {
|
||
r = new Uint8Array(GRID_COLUMNS);
|
||
this.rows.set(y, r);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
free(x: number, y: number, w: number, h: number): boolean {
|
||
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
|
||
for (let yy = y; yy < y + h; yy++) {
|
||
const row = this.row(yy);
|
||
for (let xx = x; xx < x + w; xx++) {
|
||
if (row[xx]) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
mark(x: number, y: number, w: number, h: number): void {
|
||
for (let yy = y; yy < y + h; yy++) {
|
||
const row = this.row(yy);
|
||
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// findFreeSlot scans for the first (x, y) where a w×h block fits without
|
||
// collision, starting at row startY. At each row preferX is tried first
|
||
// — that keeps a widget close to its requested column when only the row
|
||
// is blocked. Falls back to left-to-right scan within the row, then to
|
||
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
|
||
function findFreeSlot(
|
||
occ: Occupancy,
|
||
startY: number,
|
||
w: number,
|
||
h: number,
|
||
preferX: number,
|
||
): { x: number; y: number } {
|
||
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
|
||
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
|
||
return { x: preferX, y };
|
||
}
|
||
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
|
||
if (x === preferX) continue;
|
||
if (occ.free(x, y, w, h)) return { x, y };
|
||
}
|
||
}
|
||
// Pathological fallback — caller's widget cap (32) makes this
|
||
// unreachable in practice. Snap to the bottom-left so the widget at
|
||
// least renders somewhere visible instead of vanishing.
|
||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||
}
|
||
|
||
// placeWidgets assigns no-overlap grid coordinates to every visible
|
||
// widget. Hidden widgets are skipped and contribute no placement.
|
||
//
|
||
// Algorithm: iterate widgets in input order. For each visible widget:
|
||
// 1. Clamp w/h against catalog bounds.
|
||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||
// collision, search downward starting at the requested y for the
|
||
// first free w×h block (preferring the requested x).
|
||
// 3. If only x is explicit, search from y=0 at that x.
|
||
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
|
||
// when the row doesn't fit or is blocked by an explicitly-placed
|
||
// widget, wrap to the next free row.
|
||
//
|
||
// The mixed-spec case (some widgets explicit, others auto-flow) is the
|
||
// real-world layout — placing the explicit widgets first would change
|
||
// the visual order, so we keep input order and let auto-flow widgets
|
||
// step around any explicit blockers via the same collision search.
|
||
export function placeWidgets(
|
||
widgets: WidgetPlacementInput[],
|
||
): Map<string, PlacedRect> {
|
||
const out = new Map<string, PlacedRect>();
|
||
const occ = new Occupancy();
|
||
|
||
// Auto-flow cursor — advances as we place flowed widgets. cursorY
|
||
// tracks the row currently being filled; rowMaxH is the tallest
|
||
// widget in that row so wrapping advances past it (not just past the
|
||
// new widget's height — that would let taller previous neighbours
|
||
// overlap into the wrap row).
|
||
let cursorX = 0;
|
||
let cursorY = 0;
|
||
let rowMaxH = 0;
|
||
|
||
for (const w of widgets) {
|
||
if (!w.visible) continue;
|
||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||
|
||
const hasX = typeof w.x === "number";
|
||
const hasY = typeof w.y === "number";
|
||
let placed: { x: number; y: number };
|
||
|
||
if (hasX && hasY) {
|
||
// Clamp x so the widget never overflows the right edge — drag/
|
||
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
|
||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||
const prefY = Math.max(0, w.y as number);
|
||
if (occ.free(prefX, prefY, dw, dh)) {
|
||
placed = { x: prefX, y: prefY };
|
||
} else {
|
||
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
|
||
}
|
||
} else if (hasX) {
|
||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||
placed = findFreeSlot(occ, 0, dw, dh, prefX);
|
||
} else {
|
||
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
|
||
// remaining columns of the current row, then ask findFreeSlot to
|
||
// honour the cursor's preferred (x, y) — that lets it step past
|
||
// any explicit widget that already claimed cells under the
|
||
// cursor.
|
||
if (cursorX + dw > GRID_COLUMNS) {
|
||
cursorY += rowMaxH || 1;
|
||
cursorX = 0;
|
||
rowMaxH = 0;
|
||
}
|
||
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
|
||
if (placed.y > cursorY) {
|
||
// Wrap was forced by a collision deeper than the current row.
|
||
cursorY = placed.y;
|
||
rowMaxH = 0;
|
||
}
|
||
cursorX = placed.x + dw;
|
||
if (dh > rowMaxH) rowMaxH = dh;
|
||
}
|
||
|
||
occ.mark(placed.x, placed.y, dw, dh);
|
||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||
}
|
||
|
||
return out;
|
||
}
|