548 lines
34 KiB
JavaScript
548 lines
34 KiB
JavaScript
/**
|
|
* 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. (internal measurement) 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. the agent: "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<HTMLElement|null>} 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>|boolean=} options.isNarrow desktop gate (true = mobile/narrow)
|
|
* @param {import('vue').Ref<boolean>|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<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
|
|
* mapH: import('vue').Ref<number>, shellH: import('vue').Ref<number>,
|
|
* startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void,
|
|
* startTableResize:(e:PointerEvent)=>void, startShellResize:(e:PointerEvent)=>void,
|
|
* reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>,
|
|
* 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 (the agent 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 agent 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 (the agent 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 (the agent 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 (the agent 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 (the agent 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.
|
|
// the agent 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 agent 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 agent'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,
|
|
}
|
|
}
|