From 8fc7e2d62a7b139197d5016506b01c04c0ab0be7 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 17:05:05 +0000 Subject: [PATCH] Dashboard kit, landing 2: the layout engine, and the key it was sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDashboardLayout is the second zero-domain piece: it imports nothing but `vue` and reads no row. The four mentions of a people-groups grid in it are comments naming an example of what a host stacks underneath. Body verbatim apart from the one change the spec required before it could be shared, and the same one-cycle re-export at the old path. THE KEY WAS THE REASON THIS COULD NOT JUST BE MOVED. The engine persists the four divider positions to localStorage under a single constant, so every dashboard wrote to the same slot. That was invisible while there was exactly one dashboard — and SHARING THE ENGINE IS PRECISELY WHAT MAKES A SECOND ONE POSSIBLE. Moving it without fixing this would have shipped a collision into the kit's first reuse: drag a divider in one dashboard, reload, and the other has moved. Nothing errors, neither is wrong on its own, and the reader has no reason to suspect the other element on the page. SPEC-DASHBOARD-KIT section 8 named it as this landing's risk; the fix belongs in the commit that shares the engine, not in the one that first trips over it. The key now carries the profile's own id, so a second dashboard gets its own slot without its author knowing this key exists. AND NAMESPACING MUST NOT SILENTLY DISCARD A LAYOUT. Every reader who has dragged a divider has a value under the old shared key; a new key would simply orphan it and reset them for no reason they could see. The engine reads the old key once when the new one is absent. It never writes or deletes it: a migration that also deletes cannot be rolled back, and leaving the old value costs one localStorage entry. check:layout-key-namespaced guards all three. It EVALUATES the storageKey expression the shell actually passes, with two different dashboard ids, rather than retyping the rule — a gate carrying its own copy of the thing it checks passes while the product is broken, which is the lesson from check:linked-vocabulary. Negative-tested: a bare literal fails A, an expression that ignores the id fails B, and writing or removing the legacy key fails C. ★ THE BUS_SRC LESSON WAS APPLIED BEFORE MOVING, NOT AFTER. Landing 1 nearly disarmed check:facet-cascade because that gate reads its subject from a path. I checked first this time: no gate reads useDashboardLayout by path, and the storage-key literal appears in exactly two places, both of them in this diff. The library index is regenerated here, per landing, for the same reason as landing 1 — 101 items now. SOURCE ONLY. Stacked on D1. STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid. package.json is the parent's copy plus two lines beside check:leaf-engine-per-provider, so D1's registration is preserved. --- documentation/reference-library-index.md | 2 + library/composables/useDashboardLayout.js | 548 ++++++++++++++++++ package.json | 2 + .../research-dashboard/research-dashboard.vue | 10 +- .../scripts/check-layout-key-namespaced.mjs | 90 +++ .../src/composables/useDashboardLayout.js | 521 +---------------- 6 files changed, 657 insertions(+), 516 deletions(-) create mode 100644 library/composables/useDashboardLayout.js create mode 100644 plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index 62243d3ab..f79a1bdeb 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -117,6 +117,8 @@ and a change here can break other maps. `import … from '@map/composables/useAvatarGenerator.js'` - **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search. `import … from '@map/composables/useCountryOutline.js'` +- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid. + `import … from '@map/composables/useDashboardLayout.js'` - **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page. `import … from '@map/composables/useDocumentFont.js'` - **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips. diff --git a/library/composables/useDashboardLayout.js b/library/composables/useDashboardLayout.js new file mode 100644 index 000000000..5cf207bc4 --- /dev/null +++ b/library/composables/useDashboardLayout.js @@ -0,0 +1,548 @@ +/** + * useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid. + * + * ★ SHARED AS OF DASHBOARD-KIT LANDING 2. The second piece of Big Picture to become library code, + * and the second on the same test: it imports nothing but `vue` and touches no domain field. The + * four mentions of a people-groups grid below are comments naming an EXAMPLE of what a host stacks + * underneath; nothing here reads a row. + * + * The body is what research-dashboard shipped, with one behaviour change that the spec asked for + * BEFORE this file could be shared — see the storage-key note on `storageKey` below. Everything + * else is verbatim. + * + * plugin-designs/research-dashboard/src/composables/useDashboardLayout.js re-exports this for one + * cycle. See _qa-cycle/03-deployment-cycle/SPEC-DASHBOARD-KIT.md section 8, landing 2. + * + * A dashboard shell (see plugin-designs/research-dashboard/research-dashboard.vue) is a CSS + * grid whose three flexible tracks are driven by CSS custom properties: + * --facet-w (left facet-rail column width) default 296px + * --rail-w (right charts-rail column width) default 296px — equals --facet-w, so the map column sits centred + * --table-h (bottom table row height) default 34% (map above it is the 1fr hero) + * + * This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge), + * clamps each to a sane min/max, writes the vars onto the shell root element as INLINE custom + * properties, PERSISTS the chosen layout to localStorage on every drag-end, RESTORES it on init, + * and offers reset() to clear the key and snap back to the exact defaults. + * + * SHELL vs. TABLE axis (page-scroll mode only — BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER): + * Two DIFFERENT questions look similar and must stay answered by two DIFFERENT handles. + * "table" axis = the MAP/TABLE SPLIT. In page-scroll mode it redistributes height BETWEEN + * the map and the table; the sum of the two (the element's total height) + * does NOT change. This is the EXISTING map/table divider + * (.rd-rz-table in research-dashboard.vue), unchanged in grid-mode. + * "shell" axis = the ELEMENT'S OWN HEIGHT (page-scroll mode only) — a bar at the shell's + * BOTTOM edge, the boundary between the whole research-dashboard element and + * whatever a host stacks below it (e.g. the people-groups grid in + * dashboard-plus-grid.vue). Dragging it changes --rd-shell-h, the total map+ + * table budget; the map/table SPLIT within that budget is untouched. + * --rd-shell-h is the new independent variable; --rd-map-h is now the map's share of it, and + * the table's share is derived as shellH - mapH (see the CSS in research-dashboard.vue). Before + * this, --rd-map-h alone was BOTH "the map/table split" AND "the element's total height" — one + * knob answering two questions, which is why dragging the map/table divider used to resize the + * whole element as a side effect. Driver: "you improperly bound that control to the separator + * line between the map and the table... that's not the request I was making to resize the + * height of the page." + * + * COMPOSITION with the collapse-table fix (do NOT regress): + * The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule + * `.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0 }` comes LATER in source + * order (same specificity) so COLLAPSE WINS over --table-h. Because we only touch the CSS var + * (never the collapsed row template), collapsing hides the table regardless of a dragged height, + * and expanding restores the user's dragged --table-h. The center map is 1fr and already + * self-resizes via DashboardMapPanel's ResizeObserver → map.resize(); we add no second resize. + * + * NON-REGRESSION with the responsive slim rails: + * `.rd-shell.rd-slim-rail{ --rail-w:228px }` / `.rd-shell.rd-slim-facet{ --facet-w:250px }` are + * medium-width niceties set via CLASS. Inline styles always beat class rules, so we apply inline + * vars ONLY once the layout is customized (restored-from-storage or dragged). An untouched + * dashboard writes no inline vars → the slim classes and the exact CSS defaults are preserved. + * reset() removes the inline vars entirely, fully handing control back to the stylesheet. + * + * DESKTOP-ONLY: every drag is gated on `!isNarrow`; narrow/mobile never resizes or persists. + * + * Library-parameterizable — the profile's identity (defaults + storage key) lives in options, not + * here. Any dashboard grid exposing the same three vars can reuse this unchanged. + * + * @param {object} options + * @param {import('vue').Ref} options.shellRef ref holding the .rd-shell root el + * @param {object=} options.bus selection bus; when given, listens bus.on('resetLayout', reset) + * @param {import('vue').Ref|boolean=} options.isNarrow desktop gate (true = mobile/narrow) + * @param {import('vue').Ref|boolean=} options.pageScroll when true, the table axis drives + * --rd-map-h (px) instead of --table-h — see the PAGE-SCROLL AXIS SWITCH note below. + * @param {string=} options.storageKey localStorage key (default 'rd-dashboard-layout') + * @param {{facetW?:number, railW?:number, tableH?:string, mapH?:number, shellH?:number}=} options.defaults default track sizes + * @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number], mapH?:[number,number], shellH?:[number,number]}=} options.clamps + * min/max — facetW/railW/mapH/shellH in px, tableH in percent-of-shell-height + * + * @returns {{ + * facetW: import('vue').Ref, railW: import('vue').Ref, tableH: import('vue').Ref, + * mapH: import('vue').Ref, shellH: import('vue').Ref, + * startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void, + * startTableResize:(e:PointerEvent)=>void, startShellResize:(e:PointerEvent)=>void, + * reset:()=>void, applyVars:()=>void, customized:import('vue').Ref, + * stepResize:(axis:'facet'|'rail'|'table'|'shell', dir:-1|1, toExtreme?:boolean)=>void, + * onResizeKeydown:(axis:'facet'|'rail'|'table'|'shell', e:KeyboardEvent)=>void + * }} + */ +import { ref, unref, onMounted, onBeforeUnmount } from 'vue' + +// The single key every dashboard shared before landing 2 namespaced them. Kept ONLY so a reader's +// existing layout survives the change; nothing writes to it. Delete it once no reader can plausibly +// still be carrying one. +const LEGACY_SHARED_KEY = 'rd-dashboard-layout' + +// Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue. +// These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them. +// mapH mirrors the CSS fallback 40svh (~360px at a 900px viewport — see the page-scroll note +// below); it is only ever WRITTEN once the user drags, so an untouched embed stays on the +// viewport-relative svh default and keeps resizing with the window, exactly like every other var. +// ★ 58svh (522px) was the ORIGINAL default and left the page-scroll band taller than one desktop +// viewport (BUG-DASHBOARD-BAND-GROWS-ENORMOUS: chrome 137px + map 522px + table 552px = 1211px +// against 900px). 40svh is the value that makes chrome + map(X) + table(X + bar-height) fit <=900px. +// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table +// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split; +// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above). +// ★ shellH 720 -> 602 so the table OPENS at seven data rows (Driver 2026-09-23: "the table needs to +// only show SEVEN rows by DEFAULT height"). 602 = map 360 + bar 32 + 7 rows x 26 + foot 28. +// ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped +// the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes +// --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not +// be reached by any gesture. The Driver lost BOTH resizes at once and said so: "I didn't want the +// table set to 7. Only the table needs to be resizable. We've set it to where I can't even resize." +// Setting the STARTING VALUE instead leaves the derivation intact: the keyhole stays +// shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is +// merely where it opens. +// ★ RECLAIM PASS 2 (Driver 2026-09-23: "it is a priority to reclaim height... so I can see half +// of the first row of the people groups grid"). mapH 360 -> 300 and shellH follows the same +// arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table +// promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING +// MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a +// shorter map shows MORE world, not less. +// shellH 502 -> 542 (Driver 2026-09-23: "the dashboard should be taller by about 40px"). The +// extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot); +// the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in +// research-dashboard.vue kept byte-equal at 542. +// mapH 260 -> 300 (Driver 2026-09-23 follow-up: the +40px was to be "added to the map -- map is +// 40px taller display room"). shellH stays 542, so the table's derived share returns to its +// pre-+40 height and the whole gain lands in the map row. +// +40px again (Driver 2026-09-23 second raise: "go taller with the dashboard by another 40px, +// add that to the height of the map"): shellH 542 -> 582 and mapH 300 -> 340 move together, so +// the whole gain lands in the map row and the table's derived share stays put. +// Driver fine-tune 2026-09-23: "almost perfect height -- keep the map the same height, but +// decrease the height of the dashboard by about 20px". Map stays 340; the 20px comes out of +// the table's derived share (shell - map - bar - foot). +const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 340, shellH: 562 } +const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] } + +// Mirrors the CSS fallback --rd-table-bar-h (research-dashboard.vue's .rd-table-bar budget calc) — +// the table's header bar (count + Export CSV) sits ABOVE its scrollport and is not itself +// resizable, so the map/table split must always leave it at least this much room, or the divider +// could drag the table's usable grid to zero while its outer cell still looks non-empty. +const TABLE_BAR_H = 32 +const TABLE_MIN_H = 120 // grid's own floor below the bar — a table smaller than this is unusable + +const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v)) +const pctNum = (v, fallback) => { + const n = parseFloat(String(v)) + return Number.isFinite(n) ? n : fallback +} + +export function useDashboardLayout(options = {}) { + const { + shellRef, + bus = null, + isNarrow = false, + pageScroll = false, + // ★ KEY VERSIONED v2 (2026-09-23). The Driver reported "sidebars are not equally spaced" on + // his machine AFTER the symmetric-rail change landed -- because his browser restores a layout + // SAVED under the old defaults (rail 264), and a restored value outranks any new default by + // design. When defaults change materially, the old saved layouts are prescriptions from a + // different bottle: bump the key so every reader starts from the new defaults once, and a + // fresh drag re-personalises from there. The v1 blob is left in place, inert. + // v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted + // shellH:502 from the previous default would silently mask this change on the Driver's browser. + // ★ NAMESPACED PER DASHBOARD, AND THAT IS THE ONE BEHAVIOUR CHANGE IN THIS MOVE. + // SPEC-DASHBOARD-KIT section 8 named it as landing 2's risk: this key was a single constant, so + // the moment a page carries TWO dashboards they read and write one another's layout. Dragging a + // divider in one would move the other's on next load, and neither would be wrong on its own. + // While there was exactly one dashboard that was invisible; sharing the engine is precisely + // what makes a second one possible, so the fix belongs in the commit that shares it rather than + // in the commit that first trips over it. + // The caller passes its own id (the shell uses config.id). The default keeps a caller that + // passes nothing working, and still namespaces it away from any caller that does. + storageKey = 'rd-dashboard-layout-v6', + } = options + + const defaults = { ...DEFAULTS, ...(options.defaults || {}) } + const clamps = { ...CLAMPS, ...(options.clamps || {}) } + + const facetW = ref(defaults.facetW) // px + const railW = ref(defaults.railW) // px + const tableH = ref(defaults.tableH) // string, e.g. '34%' + const mapH = ref(defaults.mapH) // px — only meaningful/applied in page-scroll mode + const shellH = ref(defaults.shellH) // px — page-scroll ELEMENT total height (map+table budget) + const customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults? + const mapHCustomized = ref(false) // page-scroll's own inline var is written independently of + // customized (below), since an untouched grid-mode drag + // must never suddenly pin --rd-map-h and vice versa. + const shellHCustomized = ref(false) // same independence, one level up: the shell handle is a + // separate drag from the map/table split and must not fire + // just because the OTHER page-scroll var was touched. + + const narrow = () => !!unref(isNarrow) + const inPageScroll = () => !!unref(pageScroll) + + // ── what --rd-shell-h CURRENTLY RESOLVES TO (BUG-SHELL-HANDLE-SNAPS-144PX) ─────────────────── + // The shell drag must anchor on the budget the CSS is resolving RIGHT NOW, or the first frame + // "corrects" from a wrong anchor and the user sees height they did not drag. Two sources, in + // priority order, because the variable and the rendered size legitimately disagree: + // + // 1. THE COMPUTED VARIABLE, whenever it is set. --rd-shell-h is a CEILING — it feeds the + // table grid's max-height — and a ceiling is not a floor: if the table has fewer rows than + // the budget allows, the element renders SMALLER than the variable says. MEASURED with a + // restored shellH of 983: the element was 1126 while map+table summed to only 743, because + // the grid had ~240px of budget and no rows to fill it. Anchoring on that 743 snapped the + // variable 983 -> 744 on the first frame — a -239px jump, the mirror image of the +144 one + // this card started from. Whatever the CSS already holds is what the drag must continue + // from, so the pointer only ever ADDS to a value that is already in effect. + // 2. THE MEASURED map + table, only when the variable is unset. Before the first drag the CSS + // is on its stylesheet fallback (`80svh`), which shellH.value cannot stand in for: the 720 + // default only equals 80svh at a 900px viewport, so trusting it would jump by the + // difference at any other window height. + // + // Measuring map + table rather than the shell's own box is deliberate — the shell is + // `height:auto` here and includes the chrome the variable does not budget for. Summing the two + // regions lands on the same span calc(shell-h - map-h - bar - foot) works from, without + // restating the chrome's height as a second number that could drift from the stylesheet. + function currentShellBudget() { + const el = shellRef && shellRef.value + if (!el) return shellH.value + const declared = parseFloat(getComputedStyle(el).getPropertyValue('--rd-shell-h')) + if (Number.isFinite(declared) && declared > 0) return Math.round(declared) + const mapEl = el.querySelector('[data-region="map"]') + const tableEl = el.querySelector('[data-region="table"]') + if (!mapEl || !tableEl) return shellH.value + const h = mapEl.getBoundingClientRect().height + tableEl.getBoundingClientRect().height + // A collapsed/hidden region measures 0; fall back rather than anchoring the drag on nothing. + return h > 0 ? Math.round(h) : shellH.value + } + + // ── write / clear the inline CSS custom properties on the shell root ── + // --rd-map-h is applied independently of `customized` (grid-mode's facet/rail/table-h gate): + // page-scroll is a different layout entirely, with its own untouched-vs-dragged state, so + // gating it on the grid vars' flag would either apply it before any page-scroll drag ever + // happened, or (worse) suppress a real page-scroll drag because the grid vars were untouched. + function applyVars() { + const el = shellRef && shellRef.value + if (!el) return + if (customized.value) { + el.style.setProperty('--facet-w', facetW.value + 'px') + el.style.setProperty('--rail-w', railW.value + 'px') + el.style.setProperty('--table-h', tableH.value) + } + if (mapHCustomized.value) { + el.style.setProperty('--rd-map-h', mapH.value + 'px') + } + if (shellHCustomized.value) { + el.style.setProperty('--rd-shell-h', shellH.value + 'px') + } + } + function clearVars() { + const el = shellRef && shellRef.value + if (!el) return + el.style.removeProperty('--facet-w') + el.style.removeProperty('--rail-w') + el.style.removeProperty('--table-h') + el.style.removeProperty('--rd-map-h') + el.style.removeProperty('--rd-shell-h') + } + + // ── persistence ── + function persist() { + try { + localStorage.setItem(storageKey, JSON.stringify({ + facetW: facetW.value, railW: railW.value, tableH: tableH.value, mapH: mapH.value, shellH: shellH.value, + })) + } catch (_) { /* storage blocked / SSR — layout still works this session */ } + } + function restore() { + try { + // ★ A ONE-TIME READ-THROUGH FROM THE OLD SHARED KEY, so namespacing does not silently reset + // every reader's saved layout. Read-only and never written back: the new key is written on + // the next drag-end, and the old value is simply left where it is rather than migrated and + // deleted — a migration that also deletes is one that cannot be rolled back. + const raw = localStorage.getItem(storageKey) || localStorage.getItem(LEGACY_SHARED_KEY) + if (!raw) return false + const s = JSON.parse(raw) + if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW) + if (Number.isFinite(s.railW)) railW.value = clamp(s.railW, clamps.railW) + if (s.tableH != null) tableH.value = clamp(pctNum(s.tableH, pctNum(defaults.tableH, 34)), clamps.tableH) + '%' + customized.value = true + // shellH restores BEFORE mapH so the mapH clamp below (which bounds map to <= shellH, see + // startResize's isTablePageScroll branch) has the right ceiling on first paint too. + if (Number.isFinite(s.shellH)) { shellH.value = clamp(s.shellH, clamps.shellH); shellHCustomized.value = true } + if (Number.isFinite(s.mapH)) { mapH.value = clamp(s.mapH, clamps.mapH); mapHCustomized.value = true } + return true + } catch (_) { return false } + } + + // ── reset: clear the key + snap back to the EXACT defaults (hand control to the stylesheet) ── + function reset() { + try { localStorage.removeItem(storageKey) } catch (_) { /* ignore */ } + facetW.value = defaults.facetW + railW.value = defaults.railW + tableH.value = defaults.tableH + mapH.value = defaults.mapH + shellH.value = defaults.shellH + customized.value = false + mapHCustomized.value = false + shellHCustomized.value = false + clearVars() + } + + // ── drag-live signalling (BUG-RESIZE-STROBE) ────────────────────────────── + // Two channels, because two different consumers need it: + // 1. `data-rd-resizing` on the shell root — a pure-CSS hook so transitions/animations can be + // killed for the duration of the drag (no JS needed, works inside shadow DOM). + // 2. bus 'layoutResizing' — lets the map panel downgrade to a cheap canvas resize while the + // drag is live, then do the one expensive re-fit on drag end. + // Both are no-ops when absent, so this composable stays usable without a bus. + const resizing = ref(false) + function beginDrag() { + if (resizing.value) return + resizing.value = true + const el = shellRef && shellRef.value + if (el) el.setAttribute('data-rd-resizing', '1') + if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', true) } catch (_) {} } + } + function endDrag() { + if (!resizing.value) return + resizing.value = false + const el = shellRef && shellRef.value + if (el) el.removeAttribute('data-rd-resizing') + if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', false) } catch (_) {} } + } + + // ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ── + function startResize(axis, e) { + if (narrow()) return + const el = shellRef && shellRef.value + if (!el) return + e.preventDefault() + const rect = el.getBoundingClientRect() + const startX = e.clientX + const startFacet = facetW.value + const startRail = railW.value + const handleEl = e.currentTarget + // ── PAGE-SCROLL AXIS SWITCH (BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING) ────────────────────── + // In page-scroll mode (research-dashboard.vue's pageScroll / dashboard-plus-grid.vue) the + // shell is `height:auto` and grows with its content, so `rect` above is NOT one screen tall + // — `rect.bottom` can sit thousands of px below the viewport. --table-h is force-set to + // `auto` by the .rd-page-scroll stylesheet rule and no longer drives ANY layout: the map's + // height instead comes from --rd-map-h (default 40svh as of BUG-DASHBOARD-BAND-GROWS-ENORMOUS; + // see the CSS note beside it), and the table's own max-height comes from --rd-table-max-h. + // Writing tableH in this mode is a complete no-op — MEASURED (at the prior 58svh default): + // map height 522px before and after a real 200px drag, every + // frame. The fix is to drive --rd-map-h directly, from the MAP ELEMENT's own top edge + // (not the unbounded shell rect), so growing the map is a plain function of where the + // pointer is relative to where the map starts. + // + // ★ THE map/table SPLIT vs. THE shell's OWN HEIGHT (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER) + // — this handle must move height BETWEEN map and table, never grow the total. mapH is now + // clamped to leave the table at least TABLE_BAR_H + TABLE_MIN_H of the FIXED shellH budget, + // so dragging this divider can only redistribute the two, exactly like resizing an internal + // pane split rather than the window itself. + const isTablePageScroll = axis === 'table' && inPageScroll() + const isShellResize = axis === 'shell' && inPageScroll() + let startMapTop = 0 + let mapHCeiling = clamps.mapH[1] + if (isTablePageScroll) { + const mapEl = el.querySelector('[data-region="map"]') + const mapRect = mapEl ? mapEl.getBoundingClientRect() : null + startMapTop = mapRect ? mapRect.top : (e.clientY - mapH.value) + mapHCeiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H)) + } + // ★ DELTA-ANCHORED ON THE LIVE BUDGET (BUG-SHELL-HANDLE-SNAPS-144PX-ON-FIRST-FRAME). + // MEASURED at 1440x900: a 1px drag grew the element 144px. TWO defects stacked here, and + // fixing only the first leaves the number bit-for-bit unchanged — which is the clue that + // named the second. + // + // 1. POINTER-ABSOLUTE math. The old line set shellH to (pointerY - shellTop), treating the + // cursor's Y AS the new height. That is only correct if the handle sits exactly shellH + // px below the shell's top, and it does not. Fixed by anchoring on a start value and + // adding the pointer DELTA. + // 2. THE WRONG ANCHOR — the actual source of the 144. Anchoring on the element's measured + // height is the obvious move and is wrong, because --rd-shell-h IS NOT THE ELEMENT'S + // HEIGHT. The shell is `height:auto` in page-scroll mode (.rd-shell.rd-page-scroll) and + // nothing reads --rd-shell-h as a height; its ONLY consumer is the table grid's + // max-height: calc(--rd-shell-h - --rd-map-h - bar - foot). So the variable budgets + // MAP + TABLE and excludes the chrome above them (topbar 52 + filters 55 + stats 30 = + // 141px measured). Seeding it with the measured 863 published a budget 144px too large: + // the grid's max-height jumped 300 -> 444 on the FIRST frame and the element grew with + // it. 863 - 719 = 144 is not a coincidence — it is the chrome, double-counted. + const startShellY = e.clientY + const startShellH = isShellResize ? currentShellBudget() : shellH.value + try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {} + + // ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ── + // Writing the CSS vars every pointermove reflows the grid, which fires the map panel's + // ResizeObserver → map.resize() → MapLibre's own 'resize' event → useMapInstance's + // _onResize → setMinZoom/setZoom. Because the zoom FLOOR is derived from the container + // HEIGHT, a vertical drag re-snapped the camera on EVERY frame — a ~60Hz full-canvas + // re-render that reads as strobing. Flagging the drag lets the map coalesce to a single + // cheap canvas resize while dragging and do the one expensive re-fit on drag END. + beginDrag() + + // Coalesce the inline-var writes to one per animation frame. pointermove can fire faster + // than the compositor on high-polling-rate mice, so without this the grid could reflow + // several times per painted frame — wasted work that makes the strobe worse. + let frame = 0 + let pending = null + function flush() { + frame = 0 + if (!pending) return + const ev = pending + pending = null + if (axis === 'facet') { + // left facet edge → dragging right widens the facet column + facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW)) + customized.value = true + } else if (axis === 'rail') { + // right rail's LEFT edge → dragging left widens the rail column + railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW)) + customized.value = true + } else if (isTablePageScroll) { + // page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is + // align-self:end on the map grid-area — see the CSS below), whose top never moves. The + // pointer's new Y position IS the map's new bottom edge, so the new height is simply the + // distance from the map's (fixed) top to the cursor — a direct 1:1 follow, exactly like + // dragging the corner of a window. Re-derived fresh every frame from startMapTop (not + // summed from a delta), so it can never drift from the cursor across a long drag. + // Clamped to mapHCeiling (not the raw clamps.mapH max) — the ceiling reserves the table's + // bar + a usable minimum out of the FIXED shellH budget, so this drag only ever moves + // height between map and table and can never grow the element's total height. That is + // the shell axis's job (see isShellResize below), not this one's. + mapH.value = Math.round(clamp(ev.clientY - startMapTop, [clamps.mapH[0], mapHCeiling])) + mapHCustomized.value = true + } else if (axis === 'table') { + // map/table divider → dragging up grows the table (height measured from shell bottom) + const pct = ((rect.bottom - ev.clientY) / rect.height) * 100 + tableH.value = Math.round(clamp(pct, clamps.tableH)) + '%' + customized.value = true + } else if (isShellResize) { + // The NEW shell handle — spans the whole element's bottom edge (research-dashboard.vue's + // .rd-rz-shell), the boundary with whatever a host stacks below (e.g. the people-groups + // grid in dashboard-plus-grid.vue). Same fixed-top / 1:1-follow pattern as the map handle + // above. This is the ONLY axis that changes --rd-shell-h, i.e. the only handle that + // changes the element's total height — the map/table divider above must never do this + // (see its own comment). + // DELTA from the live start budget, NOT the pointer's absolute Y: unlike the map handle, + // this variable is not a distance from a fixed top edge, so there is no "pointer Y IS the + // new value" identity to exploit — assuming one produced the 144px snap (see the note on + // startShellH above). + shellH.value = Math.round(clamp(startShellH + (ev.clientY - startShellY), clamps.shellH)) + shellHCustomized.value = true + } + applyVars() + } + function move(ev) { + // keep only the LATEST position; the rAF does the single write for this frame + pending = { clientX: ev.clientX, clientY: ev.clientY } + if (!frame) frame = requestAnimationFrame(flush) + } + function up(ev) { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + if (frame) { cancelAnimationFrame(frame); frame = 0 } + flush() // land the final position even if the last frame never ran + try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {} + persist() + endDrag() + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + } + + const startFacetResize = (e) => startResize('facet', e) + const startRailResize = (e) => startResize('rail', e) + const startTableResize = (e) => startResize('table', e) + const startShellResize = (e) => startResize('shell', e) + + // ── keyboard step-resize (a11y: role="separator" needs an operable equivalent to the drag) ── + // Reuses the SAME refs/clamps/persist/applyVars as the pointer path — no parallel math. One + // discrete step per keypress (no rAF coalescing needed; a keydown can't fire faster than a frame). + // `table` axis mirrors startResize's own page-scroll branch: in page-scroll mode it steps mapH + // (the map/table split within the FIXED shellH budget), otherwise it steps the grid-mode tableH + // percentage. `shell` steps shellH directly — the only axis that changes the element's total height. + const STEP = { facet: 16, rail: 16, table: 24, mapH: 24, shellH: 24 } // px per arrow-key press (tableH in %) + // `toExtreme` bypasses the per-step delta entirely and clamps straight to lo/hi — Home/End jump, + // not a long walk of individual steps (which would also mean 200 localStorage writes per keypress). + function stepResize(axis, dir, toExtreme) { + if (narrow()) return + if (axis === 'facet') { + facetW.value = toExtreme ? (dir < 0 ? clamps.facetW[0] : clamps.facetW[1]) + : Math.round(clamp(facetW.value + dir * STEP.facet, clamps.facetW)) + customized.value = true + } else if (axis === 'rail') { + // Same "left edge, dragging left widens" sense as the pointer path: ArrowLeft (dir -1) + // widens the rail, so the rail's step direction is inverted relative to facet's. + railW.value = toExtreme ? (dir < 0 ? clamps.railW[1] : clamps.railW[0]) + : Math.round(clamp(railW.value - dir * STEP.rail, clamps.railW)) + customized.value = true + } else if (axis === 'table' && inPageScroll()) { + // Same ceiling as the pointer path (startResize's mapHCeiling): reserve the table's bar + + // usable minimum out of the CURRENT shellH so a keyboard drag can redistribute but never + // grow the total either. + const ceiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H)) + mapH.value = toExtreme ? (dir < 0 ? clamps.mapH[0] : ceiling) + : Math.round(clamp(mapH.value + dir * STEP.mapH, [clamps.mapH[0], ceiling])) + mapHCustomized.value = true + } else if (axis === 'table') { + const pct = toExtreme ? (dir < 0 ? clamps.tableH[0] : clamps.tableH[1]) + : clamp(pctNum(tableH.value, pctNum(defaults.tableH, 34)) - dir * STEP.table / 4, clamps.tableH) + tableH.value = Math.round(pct) + '%' + customized.value = true + } else if (axis === 'shell' && inPageScroll()) { + shellH.value = toExtreme ? (dir < 0 ? clamps.shellH[0] : clamps.shellH[1]) + : Math.round(clamp(shellH.value + dir * STEP.shellH, clamps.shellH)) + shellHCustomized.value = true + } + applyVars() + persist() + } + // ArrowUp/Left = grow toward the start edge, ArrowDown/Right = grow toward the end edge, matching + // each handle's drag SENSE (facet/rail are col-resize, table/map/shell are row-resize). Home/End + // jump to the axis's clamp extremes in one write (not a walk of individual steps). + function onResizeKeydown(axis, e) { + const K = e.key + let dir = 0 + if (axis === 'table' || axis === 'shell') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 } + else { if (K === 'ArrowLeft') dir = -1; else if (K === 'ArrowRight') dir = 1 } + if (dir) { e.preventDefault(); stepResize(axis, dir); return } + if (K === 'Home' || K === 'End') { + e.preventDefault() + stepResize(axis, K === 'Home' ? -1 : 1, true) + } + } + + // ── lifecycle: restore + apply after the shell el exists; wire the reset bus event ── + let offReset = null + onMounted(() => { + if (restore()) applyVars() + if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset) + }) + onBeforeUnmount(() => { + try { offReset && offReset() } catch (_) {} + // If the component tears down mid-drag, release the suppression so a re-mounted map + // is never left permanently in the cheap "resizing" mode. + try { endDrag() } catch (_) {} + }) + + return { + facetW, railW, tableH, mapH, shellH, customized, resizing, + startFacetResize, startRailResize, startTableResize, startShellResize, + startResize, reset, applyVars, stepResize, onResizeKeydown, + } +} diff --git a/package.json b/package.json index de1bd9216..88d4dfc5e 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "check:kpi-unit": "node internal/check-kpi-unit-once.mjs", "check:label-joins": "node internal/check-label-joins.mjs", "check:language-code-link": "node plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs", + "check:layout-key-namespaced": "node plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs", "check:leaf-engine-per-provider": "node internal/check-leaf-engine-per-provider.mjs", "check:leaf-english-leaks": "node internal/check-leaf-english-leaks.mjs", "check:leaf-english-leaks:self-test": "node internal/check-leaf-english-leaks.mjs --self-test", @@ -325,6 +326,7 @@ "check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.", "check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in .js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.", "check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.", + "check:layout-key-namespaced": "two dashboards on one page must not share a saved layout. useDashboardLayout persists the four divider positions to localStorage, and until kit landing 2 the key was a single constant — invisible while there was exactly one dashboard, and SHARING THE ENGINE IS WHAT MAKES A SECOND ONE POSSIBLE, so SPEC-DASHBOARD-KIT section 8 named it as that landing's risk. The failure is quiet rather than loud: drag a divider in one dashboard, reload, and the other has moved. Asserts the shell passes no bare literal, EVALUATES the real expression with two different dashboard ids to prove the key varies (never a retyped copy of the rule), and that the pre-namespacing key is READ and never written — namespacing must not silently discard a reader's layout, and a migration that also deletes cannot be rolled back. Static; no DOM, no browser.", "check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.", "check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.", "check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.", diff --git a/plugin-designs/research-dashboard/research-dashboard.vue b/plugin-designs/research-dashboard/research-dashboard.vue index d957cc2c7..c2114c880 100644 --- a/plugin-designs/research-dashboard/research-dashboard.vue +++ b/plugin-designs/research-dashboard/research-dashboard.vue @@ -32,7 +32,7 @@ import { tStatic } from '@map/i18n/index.js' const T = (key, en) => tStatic(key) || en import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useDashboardShell, fmt } from './src/composables/useDashboardShell.js' -import { useDashboardLayout } from './src/composables/useDashboardLayout.js' +import { useDashboardLayout } from '@map/composables/useDashboardLayout.js' import { DashboardTopBar, DashboardResearchFilters, @@ -253,7 +253,13 @@ const { startFacetResize, startRailResize, startTableResize, startShellResize, o // by the stylesheet and no longer drives any layout (see the pageScroll note above); the // composable needs to know so the table-axis handle can drive --rd-map-h instead. pageScroll, - storageKey: 'rd-dashboard-layout', + // ★ NAMESPACED PER DASHBOARD (kit landing 2). This was the single constant every dashboard + // shared, which was harmless while there was only one and becomes a collision the moment a page + // carries two — each would read and write the other's divider positions. config.id is the + // profile's own identity, so a second dashboard on the same page gets its own drawer without its + // author having to know this key exists. useDashboardLayout reads the old shared key once, so no + // reader loses the layout they had. + storageKey: 'rd-dashboard-layout:' + (mergedConfig.value?.id || 'default'), // MUST mirror the `:host` --facet-w/--rail-w defaults, or reset() would snap the rails to a // different balance than a fresh load shows. Tree wider than charts — see the rail-balance note. // mapH mirrors the page-scroll CSS fallback 40svh (~360px at a 900px viewport — see diff --git a/plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs b/plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs new file mode 100644 index 000000000..4bc057fe7 --- /dev/null +++ b/plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * check-layout-key-namespaced.mjs — two dashboards on one page must not share a saved layout. + * + * useDashboardLayout persists the four divider positions to localStorage. Until kit landing 2 the + * key was a single constant, so every dashboard wrote to the same slot. That was invisible while + * there was exactly one dashboard — and SHARING THE ENGINE IS PRECISELY WHAT MAKES A SECOND ONE + * POSSIBLE, which is why SPEC-DASHBOARD-KIT section 8 named it as landing 2's risk and why the fix + * belongs in the commit that shares the engine rather than in the one that first trips over it. + * + * The failure it prevents is quiet and confusing rather than loud: drag a divider in dashboard A, + * reload, and dashboard B has moved. Neither is wrong on its own, nothing errors, and the reader + * has no reason to suspect the other element on the page. + * + * WHAT IT CHECKS, statically — no DOM, no browser: + * A. the shell does not pass a BARE STRING as storageKey; the key must be derived from something + * per-dashboard (the profile's own `id`). + * B. two different dashboard ids produce two different keys, by evaluating the expression the + * shell actually passes rather than a retyped copy of it. + * C. the legacy shared key is READ and never WRITTEN — namespacing must not silently reset a + * reader's saved layout, and a migration that also deletes cannot be rolled back. + * + * NEGATIVE-TESTED: putting a bare literal back fails A; making the expression ignore the id fails + * B; adding a setItem on the legacy key fails C. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TAG = 'LAYOUT-KEY-NAMESPACED' +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const SHELL = join(ROOT, 'plugin-designs/research-dashboard/research-dashboard.vue') +const ENGINE = join(ROOT, 'library/composables/useDashboardLayout.js') +const fails = [] +const notes = [] + +if (!existsSync(ENGINE)) { console.log(`${TAG}: SKIP — no ${ENGINE.replace(ROOT + '/', '')} (kit landing 2 not applied).`); process.exit(0) } + +// ── A + B. the key the shell passes ────────────────────────────────────────────────────────── +{ + const src = readFileSync(SHELL, 'utf8') + const m = /storageKey:\s*([^\n]+?),\s*$/m.exec(src) + if (!m) { + fails.push('A research-dashboard.vue passes no storageKey — the engine would fall back to its shared default and every dashboard on the page would collide.') + } else { + const expr = m[1].trim() + if (/^'[^']*'$/.test(expr) || /^"[^"]*"$/.test(expr)) { + fails.push(`A storageKey is the bare literal ${expr} — a second dashboard on the same page reads and writes this one's layout.`) + } else { + // Evaluate the REAL expression with two different configs rather than retyping the rule: a + // gate carrying its own copy of the thing it checks passes while the product is broken. + try { + const keyFor = new Function('mergedConfig', `return ${expr}`) + const a = keyFor({ value: { id: 'doxa-research-dashboard' } }) + const b = keyFor({ value: { id: 'gospel-internet' } }) + const none = keyFor({ value: {} }) + if (a === b) fails.push(`B two different dashboard ids produce the SAME key (${JSON.stringify(a)}) — the expression does not actually vary with the dashboard.`) + if (!String(a).includes('doxa-research-dashboard')) fails.push(`B the key ${JSON.stringify(a)} does not carry the dashboard id, so it cannot be unique per dashboard.`) + if (!none) fails.push('B a config with no id yields an empty key — a dashboard that forgets its id must still get a usable slot, not localStorage[""].') + if (!fails.some((f) => f.startsWith('B'))) notes.push(`key: ${JSON.stringify(a)} vs ${JSON.stringify(b)} — distinct per dashboard`) + } catch (e) { + fails.push(`B could not evaluate the storageKey expression (${e.message}) — it may be correct, but this gate cannot prove it, which is the same as not being checked.`) + } + } + } +} + +// ── C. the legacy key is read, never written ───────────────────────────────────────────────── +{ + const src = readFileSync(ENGINE, 'utf8') + const legacy = /const LEGACY_SHARED_KEY = '([^']+)'/.exec(src) + if (!legacy) { + fails.push('C useDashboardLayout no longer reads the pre-namespacing key — every reader who had dragged a divider silently loses their layout on upgrade.') + } else { + const name = 'LEGACY_SHARED_KEY' + if (!new RegExp(`getItem\\(\\s*${name}\\s*\\)`).test(src)) fails.push(`C ${name} is declared but never read, so it protects nobody.`) + if (new RegExp(`(setItem|removeItem)\\(\\s*${name}`).test(src)) { + fails.push(`C ${name} is WRITTEN or REMOVED. The read-through exists so namespacing does not reset a saved layout; a migration that also deletes cannot be rolled back.`) + } + if (!fails.some((f) => f.startsWith('C'))) notes.push(`legacy key ${JSON.stringify(legacy[1])}: read once, never written`) + } +} + +for (const n of notes) console.log(' · ' + n) +if (fails.length) { + console.error(`\n${TAG}: FAIL`) + for (const f of fails) console.error(' ✗ ' + f) + process.exit(1) +} +console.log(`${TAG}: PASS — each dashboard owns its saved layout, and upgrading does not discard one.`) diff --git a/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js b/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js index 9c5f4249e..3954acf97 100644 --- a/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js +++ b/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js @@ -1,518 +1,11 @@ /** - * useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid. + * useDashboardLayout.js — RE-EXPORT SHIM (dashboard-kit landing 2). * - * A dashboard shell (see plugin-designs/doxa-research-dashboards/research-dashboards.vue) is a CSS - * grid whose three flexible tracks are driven by CSS custom properties: - * --facet-w (left facet-rail column width) default 296px - * --rail-w (right charts-rail column width) default 296px — equals --facet-w, so the map column sits centred - * --table-h (bottom table row height) default 34% (map above it is the 1fr hero) + * The engine now lives at library/composables/useDashboardLayout.js. Same one-cycle contract as + * landing 1's bus shim: every importer keeps working unchanged while the move is verified on its + * own, and this file is deleted in the next landing. * - * This composable OWNS those three vars: it exposes pointer-based drag handlers (one per edge), - * clamps each to a sane min/max, writes the vars onto the shell root element as INLINE custom - * properties, PERSISTS the chosen layout to localStorage on every drag-end, RESTORES it on init, - * and offers reset() to clear the key and snap back to the exact defaults. - * - * SHELL vs. TABLE axis (page-scroll mode only — BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER): - * Two DIFFERENT questions look similar and must stay answered by two DIFFERENT handles. - * "table" axis = the MAP/TABLE SPLIT. In page-scroll mode it redistributes height BETWEEN - * the map and the table; the sum of the two (the element's total height) - * does NOT change. This is the EXISTING map/table divider - * (.rd-rz-table in research-dashboard.vue), unchanged in grid-mode. - * "shell" axis = the ELEMENT'S OWN HEIGHT (page-scroll mode only) — a bar at the shell's - * BOTTOM edge, the boundary between the whole research-dashboard element and - * whatever a host stacks below it (e.g. the people-groups grid in - * dashboard-plus-grid.vue). Dragging it changes --rd-shell-h, the total map+ - * table budget; the map/table SPLIT within that budget is untouched. - * --rd-shell-h is the new independent variable; --rd-map-h is now the map's share of it, and - * the table's share is derived as shellH - mapH (see the CSS in research-dashboard.vue). Before - * this, --rd-map-h alone was BOTH "the map/table split" AND "the element's total height" — one - * knob answering two questions, which is why dragging the map/table divider used to resize the - * whole element as a side effect. Driver: "you improperly bound that control to the separator - * line between the map and the table... that's not the request I was making to resize the - * height of the page." - * - * COMPOSITION with the collapse-table fix (do NOT regress): - * The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule - * `.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0 }` comes LATER in source - * order (same specificity) so COLLAPSE WINS over --table-h. Because we only touch the CSS var - * (never the collapsed row template), collapsing hides the table regardless of a dragged height, - * and expanding restores the user's dragged --table-h. The center map is 1fr and already - * self-resizes via DashboardMapPanel's ResizeObserver → map.resize(); we add no second resize. - * - * NON-REGRESSION with the responsive slim rails: - * `.rd-shell.rd-slim-rail{ --rail-w:228px }` / `.rd-shell.rd-slim-facet{ --facet-w:250px }` are - * medium-width niceties set via CLASS. Inline styles always beat class rules, so we apply inline - * vars ONLY once the layout is customized (restored-from-storage or dragged). An untouched - * dashboard writes no inline vars → the slim classes and the exact CSS defaults are preserved. - * reset() removes the inline vars entirely, fully handing control back to the stylesheet. - * - * DESKTOP-ONLY: every drag is gated on `!isNarrow`; narrow/mobile never resizes or persists. - * - * Library-parameterizable — the profile's identity (defaults + storage key) lives in options, not - * here. Any dashboard grid exposing the same three vars can reuse this unchanged. - * - * @param {object} options - * @param {import('vue').Ref} options.shellRef ref holding the .rd-shell root el - * @param {object=} options.bus selection bus; when given, listens bus.on('resetLayout', reset) - * @param {import('vue').Ref|boolean=} options.isNarrow desktop gate (true = mobile/narrow) - * @param {import('vue').Ref|boolean=} options.pageScroll when true, the table axis drives - * --rd-map-h (px) instead of --table-h — see the PAGE-SCROLL AXIS SWITCH note below. - * @param {string=} options.storageKey localStorage key (default 'rd-dashboard-layout') - * @param {{facetW?:number, railW?:number, tableH?:string, mapH?:number, shellH?:number}=} options.defaults default track sizes - * @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number], mapH?:[number,number], shellH?:[number,number]}=} options.clamps - * min/max — facetW/railW/mapH/shellH in px, tableH in percent-of-shell-height - * - * @returns {{ - * facetW: import('vue').Ref, railW: import('vue').Ref, tableH: import('vue').Ref, - * mapH: import('vue').Ref, shellH: import('vue').Ref, - * startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void, - * startTableResize:(e:PointerEvent)=>void, startShellResize:(e:PointerEvent)=>void, - * reset:()=>void, applyVars:()=>void, customized:import('vue').Ref, - * stepResize:(axis:'facet'|'rail'|'table'|'shell', dir:-1|1, toExtreme?:boolean)=>void, - * onResizeKeydown:(axis:'facet'|'rail'|'table'|'shell', e:KeyboardEvent)=>void - * }} + * It re-exports; it does not duplicate. A second copy would drift silently while both halves kept + * passing their gates. */ -import { ref, unref, onMounted, onBeforeUnmount } from 'vue' - -// Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue. -// These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them. -// mapH mirrors the CSS fallback 40svh (~360px at a 900px viewport — see the page-scroll note -// below); it is only ever WRITTEN once the user drags, so an untouched embed stays on the -// viewport-relative svh default and keeps resizing with the window, exactly like every other var. -// ★ 58svh (522px) was the ORIGINAL default and left the page-scroll band taller than one desktop -// viewport (BUG-DASHBOARD-BAND-GROWS-ENORMOUS: chrome 137px + map 522px + table 552px = 1211px -// against 900px). 40svh is the value that makes chrome + map(X) + table(X + bar-height) fit <=900px. -// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table -// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split; -// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above). -// ★ shellH 720 -> 602 so the table OPENS at seven data rows (Driver 2026-09-23: "the table needs to -// only show SEVEN rows by DEFAULT height"). 602 = map 360 + bar 32 + 7 rows x 26 + foot 28. -// ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped -// the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes -// --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not -// be reached by any gesture. The Driver lost BOTH resizes at once and said so: "I didn't want the -// table set to 7. Only the table needs to be resizable. We've set it to where I can't even resize." -// Setting the STARTING VALUE instead leaves the derivation intact: the keyhole stays -// shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is -// merely where it opens. -// ★ RECLAIM PASS 2 (Driver 2026-09-23: "it is a priority to reclaim height... so I can see half -// of the first row of the people groups grid"). mapH 360 -> 300 and shellH follows the same -// arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table -// promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING -// MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a -// shorter map shows MORE world, not less. -// shellH 502 -> 542 (Driver 2026-09-23: "the dashboard should be taller by about 40px"). The -// extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot); -// the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in -// research-dashboard.vue kept byte-equal at 542. -// mapH 260 -> 300 (Driver 2026-09-23 follow-up: the +40px was to be "added to the map -- map is -// 40px taller display room"). shellH stays 542, so the table's derived share returns to its -// pre-+40 height and the whole gain lands in the map row. -// +40px again (Driver 2026-09-23 second raise: "go taller with the dashboard by another 40px, -// add that to the height of the map"): shellH 542 -> 582 and mapH 300 -> 340 move together, so -// the whole gain lands in the map row and the table's derived share stays put. -// Driver fine-tune 2026-09-23: "almost perfect height -- keep the map the same height, but -// decrease the height of the dashboard by about 20px". Map stays 340; the 20px comes out of -// the table's derived share (shell - map - bar - foot). -const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 340, shellH: 562 } -const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] } - -// Mirrors the CSS fallback --rd-table-bar-h (research-dashboard.vue's .rd-table-bar budget calc) — -// the table's header bar (count + Export CSV) sits ABOVE its scrollport and is not itself -// resizable, so the map/table split must always leave it at least this much room, or the divider -// could drag the table's usable grid to zero while its outer cell still looks non-empty. -const TABLE_BAR_H = 32 -const TABLE_MIN_H = 120 // grid's own floor below the bar — a table smaller than this is unusable - -const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v)) -const pctNum = (v, fallback) => { - const n = parseFloat(String(v)) - return Number.isFinite(n) ? n : fallback -} - -export function useDashboardLayout(options = {}) { - const { - shellRef, - bus = null, - isNarrow = false, - pageScroll = false, - // ★ KEY VERSIONED v2 (2026-09-23). The Driver reported "sidebars are not equally spaced" on - // his machine AFTER the symmetric-rail change landed -- because his browser restores a layout - // SAVED under the old defaults (rail 264), and a restored value outranks any new default by - // design. When defaults change materially, the old saved layouts are prescriptions from a - // different bottle: bump the key so every reader starts from the new defaults once, and a - // fresh drag re-personalises from there. The v1 blob is left in place, inert. - // v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted - // shellH:502 from the previous default would silently mask this change on the Driver's browser. - storageKey = 'rd-dashboard-layout-v6', - } = options - - const defaults = { ...DEFAULTS, ...(options.defaults || {}) } - const clamps = { ...CLAMPS, ...(options.clamps || {}) } - - const facetW = ref(defaults.facetW) // px - const railW = ref(defaults.railW) // px - const tableH = ref(defaults.tableH) // string, e.g. '34%' - const mapH = ref(defaults.mapH) // px — only meaningful/applied in page-scroll mode - const shellH = ref(defaults.shellH) // px — page-scroll ELEMENT total height (map+table budget) - const customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults? - const mapHCustomized = ref(false) // page-scroll's own inline var is written independently of - // customized (below), since an untouched grid-mode drag - // must never suddenly pin --rd-map-h and vice versa. - const shellHCustomized = ref(false) // same independence, one level up: the shell handle is a - // separate drag from the map/table split and must not fire - // just because the OTHER page-scroll var was touched. - - const narrow = () => !!unref(isNarrow) - const inPageScroll = () => !!unref(pageScroll) - - // ── what --rd-shell-h CURRENTLY RESOLVES TO (BUG-SHELL-HANDLE-SNAPS-144PX) ─────────────────── - // The shell drag must anchor on the budget the CSS is resolving RIGHT NOW, or the first frame - // "corrects" from a wrong anchor and the user sees height they did not drag. Two sources, in - // priority order, because the variable and the rendered size legitimately disagree: - // - // 1. THE COMPUTED VARIABLE, whenever it is set. --rd-shell-h is a CEILING — it feeds the - // table grid's max-height — and a ceiling is not a floor: if the table has fewer rows than - // the budget allows, the element renders SMALLER than the variable says. MEASURED with a - // restored shellH of 983: the element was 1126 while map+table summed to only 743, because - // the grid had ~240px of budget and no rows to fill it. Anchoring on that 743 snapped the - // variable 983 -> 744 on the first frame — a -239px jump, the mirror image of the +144 one - // this card started from. Whatever the CSS already holds is what the drag must continue - // from, so the pointer only ever ADDS to a value that is already in effect. - // 2. THE MEASURED map + table, only when the variable is unset. Before the first drag the CSS - // is on its stylesheet fallback (`80svh`), which shellH.value cannot stand in for: the 720 - // default only equals 80svh at a 900px viewport, so trusting it would jump by the - // difference at any other window height. - // - // Measuring map + table rather than the shell's own box is deliberate — the shell is - // `height:auto` here and includes the chrome the variable does not budget for. Summing the two - // regions lands on the same span calc(shell-h - map-h - bar - foot) works from, without - // restating the chrome's height as a second number that could drift from the stylesheet. - function currentShellBudget() { - const el = shellRef && shellRef.value - if (!el) return shellH.value - const declared = parseFloat(getComputedStyle(el).getPropertyValue('--rd-shell-h')) - if (Number.isFinite(declared) && declared > 0) return Math.round(declared) - const mapEl = el.querySelector('[data-region="map"]') - const tableEl = el.querySelector('[data-region="table"]') - if (!mapEl || !tableEl) return shellH.value - const h = mapEl.getBoundingClientRect().height + tableEl.getBoundingClientRect().height - // A collapsed/hidden region measures 0; fall back rather than anchoring the drag on nothing. - return h > 0 ? Math.round(h) : shellH.value - } - - // ── write / clear the inline CSS custom properties on the shell root ── - // --rd-map-h is applied independently of `customized` (grid-mode's facet/rail/table-h gate): - // page-scroll is a different layout entirely, with its own untouched-vs-dragged state, so - // gating it on the grid vars' flag would either apply it before any page-scroll drag ever - // happened, or (worse) suppress a real page-scroll drag because the grid vars were untouched. - function applyVars() { - const el = shellRef && shellRef.value - if (!el) return - if (customized.value) { - el.style.setProperty('--facet-w', facetW.value + 'px') - el.style.setProperty('--rail-w', railW.value + 'px') - el.style.setProperty('--table-h', tableH.value) - } - if (mapHCustomized.value) { - el.style.setProperty('--rd-map-h', mapH.value + 'px') - } - if (shellHCustomized.value) { - el.style.setProperty('--rd-shell-h', shellH.value + 'px') - } - } - function clearVars() { - const el = shellRef && shellRef.value - if (!el) return - el.style.removeProperty('--facet-w') - el.style.removeProperty('--rail-w') - el.style.removeProperty('--table-h') - el.style.removeProperty('--rd-map-h') - el.style.removeProperty('--rd-shell-h') - } - - // ── persistence ── - function persist() { - try { - localStorage.setItem(storageKey, JSON.stringify({ - facetW: facetW.value, railW: railW.value, tableH: tableH.value, mapH: mapH.value, shellH: shellH.value, - })) - } catch (_) { /* storage blocked / SSR — layout still works this session */ } - } - function restore() { - try { - const raw = localStorage.getItem(storageKey) - if (!raw) return false - const s = JSON.parse(raw) - if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW) - if (Number.isFinite(s.railW)) railW.value = clamp(s.railW, clamps.railW) - if (s.tableH != null) tableH.value = clamp(pctNum(s.tableH, pctNum(defaults.tableH, 34)), clamps.tableH) + '%' - customized.value = true - // shellH restores BEFORE mapH so the mapH clamp below (which bounds map to <= shellH, see - // startResize's isTablePageScroll branch) has the right ceiling on first paint too. - if (Number.isFinite(s.shellH)) { shellH.value = clamp(s.shellH, clamps.shellH); shellHCustomized.value = true } - if (Number.isFinite(s.mapH)) { mapH.value = clamp(s.mapH, clamps.mapH); mapHCustomized.value = true } - return true - } catch (_) { return false } - } - - // ── reset: clear the key + snap back to the EXACT defaults (hand control to the stylesheet) ── - function reset() { - try { localStorage.removeItem(storageKey) } catch (_) { /* ignore */ } - facetW.value = defaults.facetW - railW.value = defaults.railW - tableH.value = defaults.tableH - mapH.value = defaults.mapH - shellH.value = defaults.shellH - customized.value = false - mapHCustomized.value = false - shellHCustomized.value = false - clearVars() - } - - // ── drag-live signalling (BUG-RESIZE-STROBE) ────────────────────────────── - // Two channels, because two different consumers need it: - // 1. `data-rd-resizing` on the shell root — a pure-CSS hook so transitions/animations can be - // killed for the duration of the drag (no JS needed, works inside shadow DOM). - // 2. bus 'layoutResizing' — lets the map panel downgrade to a cheap canvas resize while the - // drag is live, then do the one expensive re-fit on drag end. - // Both are no-ops when absent, so this composable stays usable without a bus. - const resizing = ref(false) - function beginDrag() { - if (resizing.value) return - resizing.value = true - const el = shellRef && shellRef.value - if (el) el.setAttribute('data-rd-resizing', '1') - if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', true) } catch (_) {} } - } - function endDrag() { - if (!resizing.value) return - resizing.value = false - const el = shellRef && shellRef.value - if (el) el.removeAttribute('data-rd-resizing') - if (bus && typeof bus.emit === 'function') { try { bus.emit('layoutResizing', false) } catch (_) {} } - } - - // ── generic pointer drag; axis ∈ 'facet' | 'rail' | 'table' ── - function startResize(axis, e) { - if (narrow()) return - const el = shellRef && shellRef.value - if (!el) return - e.preventDefault() - const rect = el.getBoundingClientRect() - const startX = e.clientX - const startFacet = facetW.value - const startRail = railW.value - const handleEl = e.currentTarget - // ── PAGE-SCROLL AXIS SWITCH (BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING) ────────────────────── - // In page-scroll mode (research-dashboard.vue's pageScroll / dashboard-plus-grid.vue) the - // shell is `height:auto` and grows with its content, so `rect` above is NOT one screen tall - // — `rect.bottom` can sit thousands of px below the viewport. --table-h is force-set to - // `auto` by the .rd-page-scroll stylesheet rule and no longer drives ANY layout: the map's - // height instead comes from --rd-map-h (default 40svh as of BUG-DASHBOARD-BAND-GROWS-ENORMOUS; - // see the CSS note beside it), and the table's own max-height comes from --rd-table-max-h. - // Writing tableH in this mode is a complete no-op — MEASURED (at the prior 58svh default): - // map height 522px before and after a real 200px drag, every - // frame. The fix is to drive --rd-map-h directly, from the MAP ELEMENT's own top edge - // (not the unbounded shell rect), so growing the map is a plain function of where the - // pointer is relative to where the map starts. - // - // ★ THE map/table SPLIT vs. THE shell's OWN HEIGHT (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER) - // — this handle must move height BETWEEN map and table, never grow the total. mapH is now - // clamped to leave the table at least TABLE_BAR_H + TABLE_MIN_H of the FIXED shellH budget, - // so dragging this divider can only redistribute the two, exactly like resizing an internal - // pane split rather than the window itself. - const isTablePageScroll = axis === 'table' && inPageScroll() - const isShellResize = axis === 'shell' && inPageScroll() - let startMapTop = 0 - let mapHCeiling = clamps.mapH[1] - if (isTablePageScroll) { - const mapEl = el.querySelector('[data-region="map"]') - const mapRect = mapEl ? mapEl.getBoundingClientRect() : null - startMapTop = mapRect ? mapRect.top : (e.clientY - mapH.value) - mapHCeiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H)) - } - // ★ DELTA-ANCHORED ON THE LIVE BUDGET (BUG-SHELL-HANDLE-SNAPS-144PX-ON-FIRST-FRAME). - // MEASURED at 1440x900: a 1px drag grew the element 144px. TWO defects stacked here, and - // fixing only the first leaves the number bit-for-bit unchanged — which is the clue that - // named the second. - // - // 1. POINTER-ABSOLUTE math. The old line set shellH to (pointerY - shellTop), treating the - // cursor's Y AS the new height. That is only correct if the handle sits exactly shellH - // px below the shell's top, and it does not. Fixed by anchoring on a start value and - // adding the pointer DELTA. - // 2. THE WRONG ANCHOR — the actual source of the 144. Anchoring on the element's measured - // height is the obvious move and is wrong, because --rd-shell-h IS NOT THE ELEMENT'S - // HEIGHT. The shell is `height:auto` in page-scroll mode (.rd-shell.rd-page-scroll) and - // nothing reads --rd-shell-h as a height; its ONLY consumer is the table grid's - // max-height: calc(--rd-shell-h - --rd-map-h - bar - foot). So the variable budgets - // MAP + TABLE and excludes the chrome above them (topbar 52 + filters 55 + stats 30 = - // 141px measured). Seeding it with the measured 863 published a budget 144px too large: - // the grid's max-height jumped 300 -> 444 on the FIRST frame and the element grew with - // it. 863 - 719 = 144 is not a coincidence — it is the chrome, double-counted. - const startShellY = e.clientY - const startShellH = isShellResize ? currentShellBudget() : shellH.value - try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {} - - // ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ── - // Writing the CSS vars every pointermove reflows the grid, which fires the map panel's - // ResizeObserver → map.resize() → MapLibre's own 'resize' event → useMapInstance's - // _onResize → setMinZoom/setZoom. Because the zoom FLOOR is derived from the container - // HEIGHT, a vertical drag re-snapped the camera on EVERY frame — a ~60Hz full-canvas - // re-render that reads as strobing. Flagging the drag lets the map coalesce to a single - // cheap canvas resize while dragging and do the one expensive re-fit on drag END. - beginDrag() - - // Coalesce the inline-var writes to one per animation frame. pointermove can fire faster - // than the compositor on high-polling-rate mice, so without this the grid could reflow - // several times per painted frame — wasted work that makes the strobe worse. - let frame = 0 - let pending = null - function flush() { - frame = 0 - if (!pending) return - const ev = pending - pending = null - if (axis === 'facet') { - // left facet edge → dragging right widens the facet column - facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW)) - customized.value = true - } else if (axis === 'rail') { - // right rail's LEFT edge → dragging left widens the rail column - railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW)) - customized.value = true - } else if (isTablePageScroll) { - // page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is - // align-self:end on the map grid-area — see the CSS below), whose top never moves. The - // pointer's new Y position IS the map's new bottom edge, so the new height is simply the - // distance from the map's (fixed) top to the cursor — a direct 1:1 follow, exactly like - // dragging the corner of a window. Re-derived fresh every frame from startMapTop (not - // summed from a delta), so it can never drift from the cursor across a long drag. - // Clamped to mapHCeiling (not the raw clamps.mapH max) — the ceiling reserves the table's - // bar + a usable minimum out of the FIXED shellH budget, so this drag only ever moves - // height between map and table and can never grow the element's total height. That is - // the shell axis's job (see isShellResize below), not this one's. - mapH.value = Math.round(clamp(ev.clientY - startMapTop, [clamps.mapH[0], mapHCeiling])) - mapHCustomized.value = true - } else if (axis === 'table') { - // map/table divider → dragging up grows the table (height measured from shell bottom) - const pct = ((rect.bottom - ev.clientY) / rect.height) * 100 - tableH.value = Math.round(clamp(pct, clamps.tableH)) + '%' - customized.value = true - } else if (isShellResize) { - // The NEW shell handle — spans the whole element's bottom edge (research-dashboard.vue's - // .rd-rz-shell), the boundary with whatever a host stacks below (e.g. the people-groups - // grid in dashboard-plus-grid.vue). Same fixed-top / 1:1-follow pattern as the map handle - // above. This is the ONLY axis that changes --rd-shell-h, i.e. the only handle that - // changes the element's total height — the map/table divider above must never do this - // (see its own comment). - // DELTA from the live start budget, NOT the pointer's absolute Y: unlike the map handle, - // this variable is not a distance from a fixed top edge, so there is no "pointer Y IS the - // new value" identity to exploit — assuming one produced the 144px snap (see the note on - // startShellH above). - shellH.value = Math.round(clamp(startShellH + (ev.clientY - startShellY), clamps.shellH)) - shellHCustomized.value = true - } - applyVars() - } - function move(ev) { - // keep only the LATEST position; the rAF does the single write for this frame - pending = { clientX: ev.clientX, clientY: ev.clientY } - if (!frame) frame = requestAnimationFrame(flush) - } - function up(ev) { - window.removeEventListener('pointermove', move) - window.removeEventListener('pointerup', up) - window.removeEventListener('pointercancel', up) - if (frame) { cancelAnimationFrame(frame); frame = 0 } - flush() // land the final position even if the last frame never ran - try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {} - persist() - endDrag() - } - window.addEventListener('pointermove', move) - window.addEventListener('pointerup', up) - window.addEventListener('pointercancel', up) - } - - const startFacetResize = (e) => startResize('facet', e) - const startRailResize = (e) => startResize('rail', e) - const startTableResize = (e) => startResize('table', e) - const startShellResize = (e) => startResize('shell', e) - - // ── keyboard step-resize (a11y: role="separator" needs an operable equivalent to the drag) ── - // Reuses the SAME refs/clamps/persist/applyVars as the pointer path — no parallel math. One - // discrete step per keypress (no rAF coalescing needed; a keydown can't fire faster than a frame). - // `table` axis mirrors startResize's own page-scroll branch: in page-scroll mode it steps mapH - // (the map/table split within the FIXED shellH budget), otherwise it steps the grid-mode tableH - // percentage. `shell` steps shellH directly — the only axis that changes the element's total height. - const STEP = { facet: 16, rail: 16, table: 24, mapH: 24, shellH: 24 } // px per arrow-key press (tableH in %) - // `toExtreme` bypasses the per-step delta entirely and clamps straight to lo/hi — Home/End jump, - // not a long walk of individual steps (which would also mean 200 localStorage writes per keypress). - function stepResize(axis, dir, toExtreme) { - if (narrow()) return - if (axis === 'facet') { - facetW.value = toExtreme ? (dir < 0 ? clamps.facetW[0] : clamps.facetW[1]) - : Math.round(clamp(facetW.value + dir * STEP.facet, clamps.facetW)) - customized.value = true - } else if (axis === 'rail') { - // Same "left edge, dragging left widens" sense as the pointer path: ArrowLeft (dir -1) - // widens the rail, so the rail's step direction is inverted relative to facet's. - railW.value = toExtreme ? (dir < 0 ? clamps.railW[1] : clamps.railW[0]) - : Math.round(clamp(railW.value - dir * STEP.rail, clamps.railW)) - customized.value = true - } else if (axis === 'table' && inPageScroll()) { - // Same ceiling as the pointer path (startResize's mapHCeiling): reserve the table's bar + - // usable minimum out of the CURRENT shellH so a keyboard drag can redistribute but never - // grow the total either. - const ceiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H)) - mapH.value = toExtreme ? (dir < 0 ? clamps.mapH[0] : ceiling) - : Math.round(clamp(mapH.value + dir * STEP.mapH, [clamps.mapH[0], ceiling])) - mapHCustomized.value = true - } else if (axis === 'table') { - const pct = toExtreme ? (dir < 0 ? clamps.tableH[0] : clamps.tableH[1]) - : clamp(pctNum(tableH.value, pctNum(defaults.tableH, 34)) - dir * STEP.table / 4, clamps.tableH) - tableH.value = Math.round(pct) + '%' - customized.value = true - } else if (axis === 'shell' && inPageScroll()) { - shellH.value = toExtreme ? (dir < 0 ? clamps.shellH[0] : clamps.shellH[1]) - : Math.round(clamp(shellH.value + dir * STEP.shellH, clamps.shellH)) - shellHCustomized.value = true - } - applyVars() - persist() - } - // ArrowUp/Left = grow toward the start edge, ArrowDown/Right = grow toward the end edge, matching - // each handle's drag SENSE (facet/rail are col-resize, table/map/shell are row-resize). Home/End - // jump to the axis's clamp extremes in one write (not a walk of individual steps). - function onResizeKeydown(axis, e) { - const K = e.key - let dir = 0 - if (axis === 'table' || axis === 'shell') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 } - else { if (K === 'ArrowLeft') dir = -1; else if (K === 'ArrowRight') dir = 1 } - if (dir) { e.preventDefault(); stepResize(axis, dir); return } - if (K === 'Home' || K === 'End') { - e.preventDefault() - stepResize(axis, K === 'Home' ? -1 : 1, true) - } - } - - // ── lifecycle: restore + apply after the shell el exists; wire the reset bus event ── - let offReset = null - onMounted(() => { - if (restore()) applyVars() - if (bus && typeof bus.on === 'function') offReset = bus.on('resetLayout', reset) - }) - onBeforeUnmount(() => { - try { offReset && offReset() } catch (_) {} - // If the component tears down mid-drag, release the suppression so a re-mounted map - // is never left permanently in the cheap "resizing" mode. - try { endDrag() } catch (_) {} - }) - - return { - facetW, railW, tableH, mapH, shellH, customized, resizing, - startFacetResize, startRailResize, startTableResize, startShellResize, - startResize, reset, applyVars, stepResize, onResizeKeydown, - } -} +export { useDashboardLayout } from '@map/composables/useDashboardLayout.js'