fix(dashboard): widgets hold their place as content loads and the window resizes (#5713)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s

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>
This commit is contained in:
2026-10-14 11:32:23 -04:00
co-authored by Claude Opus 5.5
parent 630dcc867e
commit 2119d20f18
3 changed files with 47 additions and 15 deletions
+32 -14
View File
@@ -4,7 +4,8 @@
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. Edit mode always shows the saved 12-column
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
@@ -21,13 +22,19 @@
return Math.max(MIN_COLS, Math.min(MAX_COLS, Math.floor(width / COL_PX)));
}
/* items: [{id, x, y, w, h}] from the saved layout; h is the height to place
(the content-fitted one). Returns {id: {x, y, w, h}}.
/* 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}}.
Reading order (y, then x) is kept: nothing is placed above the item before
it. At 12 columns an item keeps its saved column and floats up, which is the
saved layout minus the space short widgets no longer need. Narrower or wider,
it takes the first free spot. */
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) {
@@ -43,21 +50,32 @@
for (var c = x; c < x + w; c++) taken[r][c] = true;
}
}
var out = {}, minY = 0;
// 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), h = it.h, x = 0, y;
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);
for (y = 0; !free(x, y, w, h); y++) { /* float up as far as it fits */ }
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, h)) break search;
for (x = 0; x + w <= cols; x++) if (free(x, y, w, full)) break search;
}
take(x, y, w, full);
minY = y;
}
take(x, y, w, h);
minY = y;
out[it.id] = { x: x, y: y, w: w, h: h };
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;
}
+14
View File
@@ -87,11 +87,25 @@
var pos = L.pack(els.map(function (el) {
var s = L.viewed(el);
s.id = el.getAttribute('gs-id');
s.full = s.h;
s.h = el._fitH || s.h;
return s;
}), cols);
grid.batchUpdate();
if (grid.getColumn() !== cols) grid.column(cols, 'none');
// Two passes (#5713): moved into place one by one, a widget could land on
// one not moved yet, and Gridstack would push that one down out of its
// place. So everything first goes below the lowest widget, keeping its
// new shape, then up into place, where nothing is in the way.
var below = 0;
els.forEach(function (el) {
var n = el.gridstackNode, p = pos[el.getAttribute('gs-id')];
below = Math.max(below, p.y + p.h, n ? n.y + n.h : 0);
});
els.forEach(function (el) {
var p = pos[el.getAttribute('gs-id')];
grid.update(el, { x: p.x, y: below + p.y, w: p.w, h: p.h });
});
els.forEach(function (el) { grid.update(el, pos[el.getAttribute('gs-id')]); });
grid.batchUpdate(false);
}
+1 -1
View File
@@ -76,7 +76,7 @@ document.addEventListener('DOMContentLoaded', function () {
function relayout() {
var cols = L.columnsFor(grid.clientWidth);
var pos = L.pack(cells().map(function (el) {
var s = L.viewed(el); s.id = el.dataset.id; s.h = el._fitH || s.h; return s;
var s = L.viewed(el); s.id = el.dataset.id; s.full = s.h; s.h = el._fitH || s.h; return s;
}), cols);
grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
cells().forEach(function (el) {