185 lines
9.8 KiB
JavaScript
185 lines
9.8 KiB
JavaScript
// Row units for the custom Overview layout. Widths stay on the 12-column grid;
|
|
// heights snap to a shared row unit (the tallest compact stat card), so a tall
|
|
// panel can sit next to several rows of small cards and every edge lines up.
|
|
// Each card gets at least the rows its content needs; an explicit height
|
|
// (dashboard_widget_heights) can only make it taller, and charts stretch into
|
|
// the extra space. Below the narrow breakpoint the grid falls back to content
|
|
// height (CSS sets --overview-row-units: off).
|
|
window.rustProxyOverviewRows = (() => {
|
|
const grid = document.getElementById("dashboard-overview-grid");
|
|
// A compact stat card spans ROW_DIVISIONS rows; finer rows keep taller cards
|
|
// close to their natural height instead of rounding up by a whole stat card.
|
|
const MAX_ROWS = 32, ROW_DIVISIONS = 2, FALLBACK_STAT = 84, MIN_INTERVAL_MS = 1500, SHRINK_CONFIRMATIONS = 3;
|
|
const noop = { schedule() { }, flush() { }, setHeights() { }, rowsFor() { return null; } };
|
|
if (!grid) return noop;
|
|
let heights = null, frame = 0, lastRun = 0, lastWidth = 0;
|
|
const state = new Map();
|
|
const shrinkVotes = new Map();
|
|
const cards = () => [...grid.children].filter(node => node.dataset.dashboardWidget);
|
|
|
|
function enabled() {
|
|
if (grid.dataset.overviewLayoutMode !== "custom") return false;
|
|
return getComputedStyle(grid).getPropertyValue("--overview-row-units").trim() !== "off";
|
|
}
|
|
function clear() {
|
|
grid.classList.remove("has-row-units");
|
|
grid.style.removeProperty("--overview-row-unit");
|
|
for (const node of cards()) { node.style.removeProperty("--overview-rows"); delete node.dataset.dashboardRows; }
|
|
state.clear(); shrinkVotes.clear();
|
|
}
|
|
function apply() {
|
|
frame = 0;
|
|
if (!grid.getClientRects().length || grid.classList.contains("is-overview-sorting")) return;
|
|
if (!enabled()) { if (grid.classList.contains("has-row-units")) clear(); return; }
|
|
lastRun = performance.now();
|
|
lastWidth = grid.clientWidth;
|
|
const items = cards().filter(node => !node.hidden && node.getClientRects().length);
|
|
if (!items.length) return;
|
|
// Measure as the saved (locked) dashboard: editing styles move a few
|
|
// controls inside cards, which must not change the layout being edited.
|
|
const editing = grid.classList.contains("is-unlocked");
|
|
if (editing) grid.classList.remove("is-unlocked");
|
|
grid.classList.add("has-row-units", "is-measuring-rows");
|
|
// Bounding boxes include CSS zoom (Settings → Interface scale); grid tracks,
|
|
// gaps and the row unit are in CSS pixels, so divide measurements by it.
|
|
const zoom = zoomOf();
|
|
const natural = items.map(node => node.getBoundingClientRect().height / zoom);
|
|
grid.classList.remove("is-measuring-rows");
|
|
if (editing) grid.classList.add("is-unlocked");
|
|
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
|
|
const isStat = node => node.classList.contains("stat");
|
|
// The unit is half of the smallest stat card, so every card ends within a
|
|
// few pixels of its natural height instead of being rounded up by a whole tile.
|
|
const stats = natural.filter((_, index) => isStat(items[index]) && natural[index] > 0);
|
|
const statHeight = stats.length ? Math.min(...stats) : FALLBACK_STAT;
|
|
const unit = Math.max(16, (statHeight - gap * (ROW_DIVISIONS - 1)) / ROW_DIVISIONS);
|
|
grid.style.setProperty("--overview-row-unit", `${unit}px`);
|
|
const seen = new Set();
|
|
items.forEach((node, index) => {
|
|
const id = node.dataset.dashboardWidget;
|
|
seen.add(id);
|
|
// Small tolerance so sub-pixel rounding never adds a whole row.
|
|
let min = Math.max(1, Math.ceil((natural[index] + gap) / (unit + gap) - 0.04));
|
|
const previous = state.get(id);
|
|
// Grow at once; shrink only after the smaller size is seen repeatedly,
|
|
// so live data (table rows, wrapping labels) cannot make the grid jump.
|
|
if (previous && min < previous.min) {
|
|
const votes = (shrinkVotes.get(id) || 0) + 1;
|
|
if (votes < SHRINK_CONFIRMATIONS) { shrinkVotes.set(id, votes); min = previous.min; }
|
|
else shrinkVotes.delete(id);
|
|
} else shrinkVotes.delete(id);
|
|
const span = Math.max(min, Math.min(MAX_ROWS, heights?.[id] ?? 0));
|
|
setRows(node, span);
|
|
state.set(id, { span, min });
|
|
});
|
|
for (const id of [...state.keys()]) if (!seen.has(id)) state.delete(id);
|
|
grid.classList.add("has-row-units");
|
|
const geometry = gridGeometry(unit + gap, zoom);
|
|
if (!geometry) return;
|
|
equalizeStatRows(items.filter(isStat), geometry);
|
|
alignBottom(items, geometry);
|
|
}
|
|
function setRows(node, rows) {
|
|
if (node.dataset.dashboardRows === String(rows)) return;
|
|
node.style.setProperty("--overview-rows", String(rows));
|
|
node.dataset.dashboardRows = String(rows);
|
|
}
|
|
const rowsOf = node => Number(node.dataset.dashboardRows) || 1;
|
|
function zoomOf() {
|
|
const css = parseFloat(getComputedStyle(grid).width);
|
|
const visual = grid.getBoundingClientRect().width;
|
|
return css > 0 && visual > 0 ? visual / css : 1;
|
|
}
|
|
// Geometry in the grid's own CSS pixels (content box), independent of zoom.
|
|
function gridGeometry(step, zoom) {
|
|
const style = getComputedStyle(grid);
|
|
const tracks = style.gridTemplateColumns.split(" ").map(parseFloat).filter(Number.isFinite);
|
|
if (tracks.length < 2) return null;
|
|
const columnGap = parseFloat(style.columnGap) || 0;
|
|
const starts = [];
|
|
tracks.reduce((x, width) => { starts.push(x); return x + width + columnGap; }, 0);
|
|
return {
|
|
starts, step, zoom,
|
|
left: (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0),
|
|
top: (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.paddingTop) || 0),
|
|
};
|
|
}
|
|
function place(node, geometry) {
|
|
const box = grid.getBoundingClientRect(), rect = node.getBoundingClientRect();
|
|
const { starts, zoom } = geometry;
|
|
const left = (rect.left - box.left) / zoom - geometry.left;
|
|
const right = (rect.right - box.left) / zoom - geometry.left;
|
|
const top = (rect.top - box.top) / zoom - geometry.top;
|
|
const first = starts.reduce((best, start, index) => Math.abs(start - left) < Math.abs(starts[best] - left) ? index : best, 0);
|
|
let last = first;
|
|
while (last + 1 < starts.length && starts[last + 1] < right - 1) last++;
|
|
const row = Math.max(0, Math.round(top / geometry.step));
|
|
return { node, first, last, row, end: row + rowsOf(node) };
|
|
}
|
|
// Stat cards that start on the same grid row get the height of the tallest of
|
|
// them, like a classic row of tiles. Raising a row can move later cards, so the
|
|
// pass repeats until nothing changes. Large cards never stretch the tiles.
|
|
function equalizeStatRows(stats, geometry) {
|
|
for (let pass = 0; pass < 6; pass++) {
|
|
const groups = new Map();
|
|
for (const node of stats) {
|
|
const { row } = place(node, geometry);
|
|
if (!groups.has(row)) groups.set(row, []);
|
|
groups.get(row).push(node);
|
|
}
|
|
let changed = false;
|
|
for (const group of groups.values()) {
|
|
const target = Math.max(...group.map(rowsOf));
|
|
for (const node of group) if (rowsOf(node) !== target) { setRows(node, target); changed = true; }
|
|
}
|
|
if (!changed) return;
|
|
}
|
|
}
|
|
// A card is stretched down over the empty cells beneath it, up to the next card
|
|
// below (or the grid's last row), so rows end on straight edges instead of
|
|
// jagged ones. Only empty cells are taken, which never changes where any other
|
|
// card is placed. The stored / edited height (state.span) is unaffected.
|
|
// Only small steps are closed (up to two stat tiles or half the card's own
|
|
// height); a large hole is left visible rather than hidden by a stretched card.
|
|
function alignBottom(items, geometry) {
|
|
const cards = items.map(node => place(node, geometry));
|
|
const bottom = Math.max(...cards.map(card => card.end));
|
|
for (const card of cards) {
|
|
const next = cards.reduce((limit, other) => other !== card && other.first <= card.last
|
|
&& other.last >= card.first && other.row >= card.end ? Math.min(limit, other.row) : limit, bottom);
|
|
const rows = card.end - card.row, extra = next - card.end;
|
|
if (extra > 0 && extra <= Math.max(ROW_DIVISIONS * 2, Math.ceil(rows / 2))) setRows(card.node, rows + extra);
|
|
}
|
|
}
|
|
function schedule(force = false) {
|
|
if (frame) return;
|
|
const wait = force ? 0 : Math.max(0, MIN_INTERVAL_MS - (performance.now() - lastRun));
|
|
if (wait > 0) { frame = setTimeout(() => { frame = 0; schedule(true); }, wait); return; }
|
|
frame = requestAnimationFrame(apply);
|
|
}
|
|
function flush() {
|
|
if (frame) { cancelAnimationFrame(frame); clearTimeout(frame); frame = 0; }
|
|
shrinkVotes.clear();
|
|
state.forEach(entry => { entry.min = 1; });
|
|
apply();
|
|
}
|
|
function setHeights(value) {
|
|
heights = value && typeof value === "object" ? { ...value } : null;
|
|
schedule(true);
|
|
}
|
|
if (typeof ResizeObserver === "function") {
|
|
new ResizeObserver(() => {
|
|
if (grid.clientWidth && grid.clientWidth !== lastWidth) { shrinkVotes.clear(); schedule(true); }
|
|
}).observe(grid);
|
|
}
|
|
document.fonts?.ready?.then(() => schedule(true));
|
|
return {
|
|
schedule,
|
|
flush,
|
|
setHeights,
|
|
rowsFor(id) {
|
|
return grid.classList.contains("has-row-units") ? state.get(id) || null : null;
|
|
},
|
|
};
|
|
})();
|