Grid density: cut the board finer or coarser, per browser #14
@@ -273,7 +273,14 @@ below. Nothing is installed without someone clicking.
|
||||
place and shows nothing, then appears when it is; it is never replaced by
|
||||
the module's default tile. Saving over an existing name asks first, and
|
||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||
Saved layouts swaps it back (and swapping again undoes that).
|
||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
|
||||
4K wall), coarser suits a small screen read close up; row height follows,
|
||||
so tiles keep their shape and simply change size. Going coarser, a tile
|
||||
that no longer fits where it was moves to the first free spot rather than
|
||||
onto another tile. It saves with the layout, so a named layout comes back
|
||||
at the density it was arranged on.
|
||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||
automatically.
|
||||
|
||||
@@ -136,7 +136,7 @@ the app); its settings are kept for a later reinstall.
|
||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||
| `style` | no | A stylesheet the shell injects once per module. |
|
||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
||||
| `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
|
||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
+126
-7
@@ -5,7 +5,18 @@
|
||||
settings) autosaves to this browser's localStorage — every browser is fully
|
||||
independent. Modules render inside tile bodies and never touch the shell. */
|
||||
|
||||
const COLS = 12;
|
||||
/* ── grid density ─────────────────────────────────────────────────────
|
||||
How many columns the board is cut into. Twelve is the default; a finer
|
||||
grid lets tiles be smaller and more of them fit across (a 4K wall), a
|
||||
coarser one suits a small screen read close up. It is this browser's
|
||||
choice — two displays at one location may want different densities — so
|
||||
it saves with the layout, local and named, and a named layout comes back
|
||||
at the density it was arranged on. Row height follows the column width, so
|
||||
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
|
||||
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
|
||||
const GRID_DEFAULT = 12;
|
||||
const CELL_H_DEFAULT = 72; // px per row at the default density
|
||||
let COLS = GRID_DEFAULT;
|
||||
const LS_LAYOUT = 'proddash:layout';
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
@@ -224,7 +235,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
|
||||
|
||||
function saveLayout() {
|
||||
try {
|
||||
const next = JSON.stringify({ v: 1, autoHideHeads, tiles });
|
||||
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
|
||||
const current = localStorage.getItem(LS_LAYOUT);
|
||||
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
||||
localStorage.setItem(LS_LAYOUT, next);
|
||||
@@ -275,6 +286,87 @@ function newId() {
|
||||
|
||||
/* ── grid geometry & collision ──────────────────────────────────────── */
|
||||
|
||||
function normalizeGridCols(value) {
|
||||
const n = Number(value);
|
||||
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
|
||||
}
|
||||
|
||||
function cellHeightFor(cols) {
|
||||
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
|
||||
}
|
||||
|
||||
/** Cut the board into `cols` columns. Tiles keep their grid units — the
|
||||
board gets denser or coarser and everything on it changes size with it.
|
||||
A tile now past the right edge is pulled back in, and one that would then
|
||||
sit on another moves to the first free spot instead. */
|
||||
function setGridCols(value, { save = true } = {}) {
|
||||
const next = normalizeGridCols(value);
|
||||
const changed = next !== COLS;
|
||||
COLS = next;
|
||||
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
|
||||
if (changed) {
|
||||
// A coarser board may not have room for every tile where it was. Settle
|
||||
// them in reading order: pull a tile past the right edge back in, and
|
||||
// give one that would then land on a tile already settled the first free
|
||||
// spot instead — tiles never overlap here, any more than under a drag.
|
||||
const settled = [];
|
||||
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
|
||||
tile.w = Math.max(1, Math.min(COLS, tile.w));
|
||||
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
|
||||
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
|
||||
settled.push(tile);
|
||||
applyRect(tile);
|
||||
notifyResize(tile);
|
||||
}
|
||||
rebuildDividers();
|
||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||
}
|
||||
if (save) saveLayout();
|
||||
}
|
||||
|
||||
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
|
||||
/** The Layouts menu's density row — Grid Size − 12 + — which steps without
|
||||
closing the menu, so the board can be watched changing behind it. */
|
||||
function gridStepper() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'menu-stepper';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'menu-stepper-label';
|
||||
label.textContent = 'Grid Size';
|
||||
const minus = document.createElement('button');
|
||||
minus.type = 'button';
|
||||
minus.className = 'menu-stepper-btn';
|
||||
minus.setAttribute('data-tip', 'Coarser');
|
||||
minus.innerHTML = STEP_MINUS_SVG;
|
||||
const value = document.createElement('span');
|
||||
value.className = 'menu-stepper-value';
|
||||
const plus = document.createElement('button');
|
||||
plus.type = 'button';
|
||||
plus.className = 'menu-stepper-btn';
|
||||
plus.setAttribute('data-tip', 'Finer');
|
||||
plus.innerHTML = STEP_PLUS_SVG;
|
||||
const paint = () => {
|
||||
const i = GRID_STEPS.indexOf(COLS);
|
||||
value.textContent = String(COLS);
|
||||
minus.disabled = i <= 0;
|
||||
plus.disabled = i >= GRID_STEPS.length - 1;
|
||||
};
|
||||
const step = (dir) => {
|
||||
const i = GRID_STEPS.indexOf(COLS) + dir;
|
||||
if (i < 0 || i >= GRID_STEPS.length) return;
|
||||
setGridCols(GRID_STEPS[i]);
|
||||
paint();
|
||||
};
|
||||
minus.addEventListener('click', () => step(-1));
|
||||
plus.addEventListener('click', () => step(1));
|
||||
paint();
|
||||
row.append(label, minus, value, plus);
|
||||
return row;
|
||||
}
|
||||
|
||||
function cellMetrics() {
|
||||
const style = getComputedStyle(grid);
|
||||
const gapRaw = parseFloat(style.columnGap);
|
||||
@@ -297,6 +389,17 @@ function isFree(rect, excludeId) {
|
||||
return isFreeExcept(rect, [excludeId]);
|
||||
}
|
||||
|
||||
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
|
||||
function spotAmong(w, h, others) {
|
||||
for (let y = 0; y < 400; y += 1) {
|
||||
for (let x = 0; x <= COLS - w; x += 1) {
|
||||
const rect = { x, y, w, h };
|
||||
if (!others.some((o) => overlaps(rect, o))) return { x, y };
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/** First free spot scanning top-to-bottom, left-to-right. */
|
||||
function findSpot(w, h) {
|
||||
for (let y = 0; y < 200; y += 1) {
|
||||
@@ -990,10 +1093,14 @@ function addTile(moduleId, entry = null) {
|
||||
toast(`${entry.name} is already on this dashboard`);
|
||||
return;
|
||||
}
|
||||
// Sizes are declared for the default twelve-column grid; scaled so a new
|
||||
// tile lands at about the size its author meant, whatever the density here.
|
||||
const scale = COLS / GRID_DEFAULT;
|
||||
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
|
||||
const size = {
|
||||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
||||
h: Math.max(1, defaultSize?.h || 3),
|
||||
w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
|
||||
h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
|
||||
};
|
||||
const spot = findSpot(size.w, size.h);
|
||||
const settings = {};
|
||||
@@ -1991,6 +2098,8 @@ function renderLayout(layout) {
|
||||
// "reset" can simply hand over no tiles.
|
||||
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
||||
clearAllTiles();
|
||||
// Density first: the tiles are clamped and placed against it.
|
||||
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
|
||||
tiles = list.map(sanitizeTile);
|
||||
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
||||
@@ -2057,6 +2166,13 @@ function renderAddMenu(menu) {
|
||||
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
menu.innerHTML = '';
|
||||
|
||||
// Density first: the one thing in here that is not a layout but the board
|
||||
// the layouts are arranged on.
|
||||
menu.appendChild(gridStepper());
|
||||
const stepperDivider = document.createElement('div');
|
||||
stepperDivider.className = 'menu-divider';
|
||||
menu.appendChild(stepperDivider);
|
||||
|
||||
menu.appendChild(menuItem('Save as named layout…', async () => {
|
||||
menu.hidden = true;
|
||||
if (!tiles.length) {
|
||||
@@ -2088,7 +2204,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
|
||||
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||
@@ -2136,15 +2252,17 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
// does not live-link browsers together.
|
||||
let outgoing = null;
|
||||
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
||||
const gridBefore = COLS;
|
||||
renderLayout(body.layout);
|
||||
saveLayout();
|
||||
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
||||
const unshown = unshownTileNames();
|
||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||
const note = unshown.length
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||
: '';
|
||||
toast(`Loaded “${l.name}”: ${count}${note}`, false, {
|
||||
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||
ms: unshown.length ? 10000 : 7000,
|
||||
action: outgoing ? {
|
||||
label: 'Undo',
|
||||
@@ -2300,7 +2418,8 @@ function applyFullscreenFit() {
|
||||
}
|
||||
|
||||
function clearFullscreenFit() {
|
||||
grid.style.gridTemplateColumns = '';
|
||||
// Back to this browser's density, not the stylesheet's twelve.
|
||||
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||
grid.style.gridAutoRows = '';
|
||||
gridwrap.classList.remove('no-scroll');
|
||||
for (const tile of tiles) {
|
||||
|
||||
@@ -187,6 +187,36 @@ body.menu-hidden #topbar {
|
||||
white-space: normal;
|
||||
}
|
||||
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
||||
|
||||
/* the Layouts menu's grid-density row: Grid Size − 12 + */
|
||||
.menu-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu-stepper-label { flex: 1; }
|
||||
.menu-stepper-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-stepper-btn:hover { border-color: var(--muted); }
|
||||
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
|
||||
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
|
||||
.menu-stepper-value {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.menu-title {
|
||||
padding: 6px 10px 3px;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -1619,6 +1619,7 @@ function previousLayoutPath(name) {
|
||||
so an old file does not haunt a new client. */
|
||||
|
||||
const LAYOUT_VERSION = 2;
|
||||
const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities
|
||||
const MAX_LAYOUT_TILES = 100;
|
||||
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
||||
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
||||
@@ -1644,6 +1645,9 @@ function sanitizeLayout(raw) {
|
||||
// Whether tile title bars auto-hide is part of how a dashboard is
|
||||
// arranged, so it travels with the layout like the tiles themselves.
|
||||
autoHideHeads: Boolean(raw?.autoHideHeads),
|
||||
// The grid density the layout was arranged on, when it says and it is one
|
||||
// the shell knows; a browser loading it cuts its board the same way.
|
||||
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
|
||||
tiles: tiles.map((t) => {
|
||||
const tile = {
|
||||
id: String(t?.id || ''),
|
||||
|
||||
@@ -88,7 +88,7 @@ async function waitFor(url, ms = 15000) {
|
||||
headHidden: true,
|
||||
};
|
||||
|
||||
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) });
|
||||
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
|
||||
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
||||
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
||||
|
||||
@@ -97,6 +97,7 @@ async function waitFor(url, ms = 15000) {
|
||||
const got = r.body.layout;
|
||||
assert.equal(got.v, 2, 'the file carries a schema version');
|
||||
assert.equal(got.autoHideHeads, true);
|
||||
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
|
||||
const t = got.tiles[0];
|
||||
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
||||
assert.deepEqual(t.settings, tile.settings, 'settings');
|
||||
@@ -107,7 +108,7 @@ async function waitFor(url, ms = 15000) {
|
||||
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
||||
|
||||
// Saving over the name keeps the copy it replaced.
|
||||
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
||||
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
||||
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
||||
@@ -125,6 +126,7 @@ async function waitFor(url, ms = 15000) {
|
||||
assert.equal(r.status, 200);
|
||||
r = await api('/api/layouts/Booth%20A');
|
||||
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
||||
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
|
||||
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user