doxa-maps/library/composables/useDashboardLayout.js
Doxa Maps 9c9fcd5572 RTL drag: the direction probe reads the handle, and two handle margins become logical
Two certain defects from source. What I could NOT settle without a browser is stated at the end
rather than guessed at.

1. THE PROBE READ THE WRONG NODE. The sign has to match the element the GRID lays out with. Both
   probes read the SHELL; the handle is a grid child, so whatever direction the grid resolves the
   handle inherits — and the shell is one node further out. A host that sets dir between the two, or
   a shellRef that is not the grid itself, yields a sign that disagrees with the mirroring the reader
   can see. Both the pointer and the keyboard path now try the handle first and fall back to the
   shell, then to ltr, so a detached handle cannot throw mid-gesture.
2. research-dashboard's two handles used PHYSICAL margins (margin-right / margin-left) while the grid
   they sit in mirrors — grid-template-columns and justify-self both follow the inline direction — so
   in Arabic each handle's 4px overhang sat on the wrong side of its own column. go-int's board
   already used the logical pair; this makes them agree.

WHAT WAS ALREADY RIGHT, so nobody re-fixes it: the xSign inversion itself is correct for a mirroring
grid and is DEPLOYED — 2 of 2 rtl checks present in the built gospel-internet, dashboard-plus-grid and
research-dashboard bundles, so this was never a stale-rebuild problem.

MEASUREMENT I NEED (rega, one line): during a drag in ar on the Gospel x Internet board, log
getComputedStyle on BOTH the handle and the shell and report each .direction. If the handle says rtl
and the shell says ltr, item 1 above is the whole bug. If both say ltr while the grid is visibly
mirrored, the mirroring comes from somewhere my probe cannot see and I will need that.

Source only. Rebuild: research-dashboard, dashboard-plus-grid, gospel-internet (library change).
2026-09-27 06:42:54 +00:00

589 lines
37 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. 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<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'
// The default key, named once so the legacy read-through can ask "am I the default?" instead of
// re-typing the literal — a fallback comparing against a copy of its own default is a fallback that
// silently stops matching the day someone bumps the version suffix.
const DEFAULT_STORAGE_KEY = 'rd-dashboard-layout-v6'
// 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 = DEFAULT_STORAGE_KEY,
} = 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.
// ★ ONLY FOR THE DEFAULT KEY (go-int, 2026-09-27) — and this was my own regression from kit
// landing 2. I added the read-through so namespacing would not discard a reader's saved
// layout, and applied it unconditionally: so a caller that passes its OWN storageKey, which
// is the entire point of namespacing, still inherited Big Picture's rail widths on first
// load. The migration meant to protect one dashboard's layout was handing it to every other
// dashboard. A fallback that ignores the key it is falling back FROM is not a fallback.
const legacyOk = storageKey === DEFAULT_STORAGE_KEY
const raw = localStorage.getItem(storageKey) || (legacyOk ? localStorage.getItem(LEGACY_SHARED_KEY) : null)
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
// ★ THE X DELTA HAS A READING DIRECTION (go-int, 2026-09-27). Under dir=rtl the grid mirrors —
// the facet rail sits on the RIGHT — so dragging its handle toward the map INCREASES clientX
// where in LTR it decreases it. The maths below was written once, in LTR, and the result in
// Arabic is that dragging the facet handle shrinks the CHARTS rail: both handles fight, and
// neither does what the cursor says. This reaches Big Picture in ar today, not only go-int.
// Read per drag rather than cached: a host can flip dir between gestures (our own composites
// set it on the custom-element host from the runtime locale), and a cached sign would be wrong
// for the rest of the session.
// ★ PROBE THE HANDLE, NOT THE SHELL. The sign has to match the element the GRID lays out with,
// and the handle is a grid child: whatever direction the grid resolves, the handle inherits. The
// shell is one node further out, so a host that sets dir between the two — or a shellRef that is
// not the grid itself — gives a direction that disagrees with the mirroring the user can see.
// Falls back to the shell, then to ltr, so a detached handle cannot throw mid-gesture.
const rtl = (() => {
for (const node of [e.currentTarget, unref(shellRef)]) {
try { const d = node && getComputedStyle(node).direction; if (d) return d === 'rtl' } catch (_) { /* next */ }
}
return false
})()
const xSign = rtl ? -1 : 1
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 + xSign * (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 - xSign * (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 {
// ★ AND THE ARROWS FOLLOW THE SAME SIGN (go-int). The pointer path now mirrors under rtl; if
// the keyboard did not, the two input methods would disagree about which way widens — and a
// keyboard user in Arabic would find the arrows inverted relative to the drag they just
// watched. aria-valuenow reports the same ref for both, so a disagreement here would also
// make the announced value move the wrong way.
let kSign = 1
// Same node choice as the pointer path above, for the same reason.
try { for (const node of [e.currentTarget, unref(shellRef)]) { const d = node && getComputedStyle(node).direction; if (d) { if (d === 'rtl') kSign = -1; break } } } catch (_) { /* no element */ }
if (K === 'ArrowLeft') dir = -1 * kSign; else if (K === 'ArrowRight') dir = 1 * kSign
}
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,
}
}