doxa-maps/plugin-designs/research-dashboard/src/composables/useDashboardLayout.js

183 lines
8.3 KiB
JavaScript

/**
* useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
*
* 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 248px
* --rail-w (right charts-rail column width) default 312px
* --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.
*
* 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:240px }` / `.rd-shell.rd-slim-facet{ --facet-w:196px }` 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 {string=} options.storageKey localStorage key (default 'rd-dashboard-layout')
* @param {{facetW?:number, railW?:number, tableH?:string}=} options.defaults default track sizes
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number]}=} options.clamps
* min/max — facetW/railW in px, tableH in percent-of-shell-height
*
* @returns {{
* facetW: import('vue').Ref<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
* startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void,
* startTableResize:(e:PointerEvent)=>void, reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>
* }}
*/
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
const DEFAULTS = { facetW: 248, railW: 312, tableH: '34%' }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70] }
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,
storageKey = 'rd-dashboard-layout',
} = 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 customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults?
const narrow = () => !!unref(isNarrow)
// ── write / clear the three inline CSS custom properties on the shell root ──
function applyVars() {
const el = shellRef && shellRef.value
if (!el || !customized.value) return
el.style.setProperty('--facet-w', facetW.value + 'px')
el.style.setProperty('--rail-w', railW.value + 'px')
el.style.setProperty('--table-h', tableH.value)
}
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')
}
// ── persistence ──
function persist() {
try {
localStorage.setItem(storageKey, JSON.stringify({
facetW: facetW.value, railW: railW.value, tableH: tableH.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
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
customized.value = false
clearVars()
}
// ── 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
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
function move(ev) {
if (axis === 'facet') {
// left facet edge → dragging right widens the facet column
facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW))
} 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))
} 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
applyVars()
}
function up(ev) {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
try { handleEl && handleEl.releasePointerCapture && handleEl.releasePointerCapture(ev.pointerId) } catch (_) {}
persist()
}
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)
// ── 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 (_) {} })
return {
facetW, railW, tableH, customized,
startFacetResize, startRailResize, startTableResize,
startResize, reset, applyVars,
}
}