From 2119d20f18f8dbf1d3c963b87aa5cf696b965a90 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 14 Oct 2026 11:32:23 -0400 Subject: [PATCH] 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 --- steward/static/dash-layout.js | 46 ++++++++++++++------- steward/templates/dashboard/index.html | 14 +++++++ steward/templates/dashboard/share_view.html | 2 +- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/steward/static/dash-layout.js b/steward/static/dash-layout.js index 78b5787..2167535 100644 --- a/steward/static/dash-layout.js +++ b/steward/static/dash-layout.js @@ -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; } diff --git a/steward/templates/dashboard/index.html b/steward/templates/dashboard/index.html index 762fd01..1a06674 100644 --- a/steward/templates/dashboard/index.html +++ b/steward/templates/dashboard/index.html @@ -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); } diff --git a/steward/templates/dashboard/share_view.html b/steward/templates/dashboard/share_view.html index 277caea..11f9034 100644 --- a/steward/templates/dashboard/share_view.html +++ b/steward/templates/dashboard/share_view.html @@ -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) {