Files
bvandeusenandClaude Opus 5.5 2119d20f18
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s
fix(dashboard): widgets hold their place as content loads and the window resizes (#5713)
Wider than 12 columns, pack() took the first free spot for each widget's
content-fitted height, and that height changes as each widget's content
arrives and on every poll, so widgets traded columns and order as the page
loaded (CI 9107: test_dash_layout fails at 16, 18 and 24 columns on the old
pack()). Now columns and order are decided at the saved heights, from the
saved layout and the column count alone; each widget then drops at its
fitted height onto what is above it in its columns, so content only closes
gaps.

The dashboard moves widgets into place in two passes, below everything first
and then up, so Gridstack never pushes a not-yet-moved widget out of its
place on the way. The share view passes saved heights the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:32:23 -04:00

122 lines
5.3 KiB
JavaScript

/* Dashboard layout (#5249): each widget as tall as its content, never taller
than the height saved for it, and extra grid columns on wide screens.
The layout is saved in 12 columns. A viewer on a wider screen gets more
columns (one per COL_PX of grid width, up to MAX_COLS), and the widgets are
placed again in reading order at their saved widths, so more of them sit
side by side; none is stretched. Placement follows the saved layout, not
the content, so widgets hold still as they load (#5713). Edit mode always shows the saved 12-column
layout, so what is saved never depends on the screen it was edited on.
Outside edit mode each saved row is split into ROW_SPLIT finer rows, so a
widget's height fits its content to within 10px rather than to a whole
70px row (#5214). viewed() gives the saved place in those rows.
Used by the dashboard (Gridstack) and the share view (CSS grid). */
(function () {
var COL_PX = 110, MIN_COLS = 12, MAX_COLS = 24, NARROW_PX = 700;
var ROW_SPLIT = 7, MIN_ROWS = ROW_SPLIT; // view rows: one saved row is the least
function columnsFor(width) {
if (width < NARROW_PX) return 1;
return Math.max(MIN_COLS, Math.min(MAX_COLS, Math.floor(width / COL_PX)));
}
/* items: [{id, x, y, w, h, full}] from the saved layout, in view rows: h
is the height to place (the content-fitted one), full the saved height
(h when left out). Returns {id: {x, y, w, h}}.
Where a widget sits depends only on the saved layout and the column
count, never on its content (#5713): content arrives widget by widget
and changes on every poll, and placing by it moved widgets between
columns as the page loaded. So columns are decided at the saved heights:
at 12 columns each widget keeps its saved column; narrower or wider, it
takes the first free spot in reading order, never above the one before
it. Then each widget, in that order, drops at its fitted height to just
below what is already above it in its columns: a short widget closes the
gap under it, and nothing changes column or passes another. */
function pack(items, cols) {
var taken = [];
function free(x, y, w, h) {
for (var r = y; r < y + h; r++) {
if (!taken[r]) continue;
for (var c = x; c < x + w; c++) if (taken[r][c]) return false;
}
return true;
}
function take(x, y, w, h) {
for (var r = y; r < y + h; r++) {
taken[r] = taken[r] || [];
for (var c = x; c < x + w; c++) taken[r][c] = true;
}
}
// 1. Each widget's column and its place in the order, at saved heights.
var slots = [], bottom = [], minY = 0;
items.slice().sort(function (a, b) { return a.y - b.y || a.x - b.x; }).forEach(function (it) {
var w = Math.min(it.w, cols), full = it.full || it.h, x = 0, y;
if (cols === MIN_COLS) {
x = Math.min(it.x, cols - w);
y = 0;
for (var c = x; c < x + w; c++) y = Math.max(y, bottom[c] || 0);
for (c = x; c < x + w; c++) bottom[c] = y + full;
} else {
search:
for (y = minY; ; y++) {
for (x = 0; x + w <= cols; x++) if (free(x, y, w, full)) break search;
}
take(x, y, w, full);
minY = y;
}
slots.push({ it: it, x: x, y: y, w: w });
});
// 2. Each drops at its fitted height, in that order, onto what is above it.
var out = {}, floor = [];
slots.sort(function (a, b) { return a.y - b.y || a.x - b.x; }).forEach(function (s) {
var y = 0;
for (var c = s.x; c < s.x + s.w; c++) y = Math.max(y, floor[c] || 0);
for (c = s.x; c < s.x + s.w; c++) floor[c] = y + s.it.h;
out[s.it.id] = { x: s.x, y: y, w: s.w, h: s.it.h };
});
return out;
}
/* The height a widget's box needs to show everything in it: its padding and
border, plus each child (header, body) at its natural height. The body
normally fills the box and scrolls, so it is measured unstretched. */
function naturalHeight(box) {
var body = box.querySelector('.dash-cell-body');
var cs = getComputedStyle(box);
var h = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom)
+ parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
if (body) { body.style.flex = '0 0 auto'; body.style.overflow = 'visible'; }
Array.prototype.forEach.call(box.children, function (child) {
var s = getComputedStyle(child);
h += child.offsetHeight + parseFloat(s.marginTop) + parseFloat(s.marginBottom);
});
if (body) { body.style.flex = ''; body.style.overflow = ''; }
return h;
}
/* View rows for that height: rowPx is one view row (with its share of the
gap), gapPx the space a cell loses to its margins. Capped at maxRows, the
saved height in view rows, where the body scrolls instead. */
function fitRows(box, rowPx, gapPx, maxRows) {
var rows = Math.ceil((naturalHeight(box) + gapPx) / rowPx);
return Math.max(MIN_ROWS, Math.min(maxRows, rows));
}
function saved(el) {
return { x: +el.dataset.x, y: +el.dataset.y, w: +el.dataset.w, h: +el.dataset.h };
}
function viewed(el) {
var s = saved(el);
return { x: s.x, y: s.y * ROW_SPLIT, w: s.w, h: s.h * ROW_SPLIT };
}
window.StewardDashLayout = {
MIN_COLS: MIN_COLS, ROW_SPLIT: ROW_SPLIT, columnsFor: columnsFor, pack: pack,
fitRows: fitRows, saved: saved, viewed: viewed,
};
})();