// 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(); 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 }; } // PlaceOptions tunes the placer for the caller's render-vs-persist // needs. export interface PlaceOptions { // When true, hidden widgets are placed too — for edit-mode rendering // where the user can see + un-hide them inline. The two-pass order // (visible first, then hidden) guarantees hidden widgets never // displace visible ones: they get whatever cells are left below the // active layout. Default false matches view-mode behaviour and the // persistence path (materializePositions) where hidden widgets // retain their stored coordinates instead of being repacked. // // Without this option, hidden widgets in edit mode were left without // an explicit grid-column inline style by applyLayout(), so CSS Grid // auto-flowed them into the next free cell at 1×1 — the "super slim // greyed-out column" symptom of m/paliad#73 / t-paliad-238. includeHidden?: boolean; } // placeWidgets assigns no-overlap grid coordinates to widgets. By // default only visible widgets receive placements; pass // {includeHidden:true} to also place hidden widgets after the visible // pass (used by applyLayout in edit mode). // // Algorithm — per pass: // 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. // // Two-pass behaviour for hidden widgets: the visible pass owns its // own auto-flow cursor; the hidden pass continues from where the // visible pass left off so the hidden widgets stack right under the // active layout. The shared Occupancy bitmap guarantees the second // pass can never overlap a placed visible widget. export function placeWidgets( widgets: WidgetPlacementInput[], options: PlaceOptions = {}, ): Map { const out = new Map(); 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; const placeOne = (w: WidgetPlacementInput): void => { 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 }); }; // Pass 1: visible widgets. They own the active layout. for (const w of widgets) { if (!w.visible) continue; placeOne(w); } // Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the // start of the next row before the second pass so the hidden tray // visually separates from the active layout — even if the last // visible widget left half a row open. if (options.includeHidden) { if (cursorX > 0) { cursorY += rowMaxH || 1; cursorX = 0; rowMaxH = 0; } for (const w of widgets) { if (w.visible) continue; placeOne(w); } } return out; }