From 099925b1215e0ea7b9d783a79639d087f55d6b72 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 12 Sep 2026 08:51:14 -0400 Subject: [PATCH] Drag to select a region, and show the selection on the grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in how the pivot reports a selection. Dragging across cells did nothing, and a selection built from several ctrl-clicks was invisible on the grid — the panel listed the slices but nothing on screen said which cells they came from. Drag-select - The perspective-select handler was reading detail.selected and detail.insertConfigs. In 5.2.0 that event carries a ViewWindow — { start_row, end_row, start_col, end_col }; insertConfigs only appears on perspective-global-filter, and only in SELECT_ROW_TREE mode. So the handler always returned early and the whole path was dead. - It fires on every mouseover as the region grows, so the handler now records the latest window and a window-level mouseup commits it. A single-cell region is skipped there: perspective-click already owns plain and modifier clicks, and handling it in both places would undo a ctrl-click toggle. Modifier+drag adds to the selection. - Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's __ROW_PATH__, column dimensions from the split_by segments of the column name. Reading the path rather than the rendered cell is what keeps dates as epoch millis instead of whatever the grid formatted them as. The grand-total row resolves to no dimension at all — that would mean the whole version — and is skipped. Highlight - The datagrid already highlights whatever sits in its own model._selection_state.selected_areas, and wipes that list on every mousedown, so a multi-click selection only ever showed the last cell. Keep a sliceKey -> rectangles map parallel to `slices` and push the full set back after each change, which gets the native highlight for every selected cell without any styling of our own. - Deselecting anywhere — ctrl-click, the panel's x, Clear selection — prunes the map by live slice key, so the grid and the panel cannot disagree. Also: edit_mode is forced to SELECT_REGION on restore rather than defaulted, since a saved layout's plugin_config could previously override it and turn selection off entirely. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH --- CLAUDE.md | 6 ++ ui/src/views/Forecast.jsx | 168 ++++++++++++++++++++++++++++++++++---- 2 files changed, 156 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3231201..54ca367 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,12 @@ POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNIN When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side). +Dragging across a block of cells selects a region. The datagrid runs in `edit_mode: SELECT_REGION` (forced on restore, so a saved layout can't switch it off) and reports the region as a `perspective-select` event carrying a Perspective **ViewWindow** — `{ start_row, end_row, start_col, end_col }`, *not* the per-row `insertConfigs` payload an older API used. It fires on every mouseover as the region grows, so the handler only records the latest window and a window-level `mouseup` commits it. A single-cell region is ignored there: `perspective-click` already owns plain and modifier clicks, and handling it in both places would undo a ctrl-click toggle. + +Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's `__ROW_PATH__` (raw values, so dates stay epoch millis rather than whatever the grid formatted them as), column dimensions from the split_by segments of the column name. The grand-total row resolves to no dimension at all and is skipped; that would mean "the whole version". + +**Selection highlight.** The datagrid highlights whatever sits in its own `model._selection_state.selected_areas`, and wipes that list on every mousedown — so a multi-slice selection built up over several ctrl-clicks would only ever show the last cell. `Forecast.jsx` keeps `areasRef`, a `sliceKey -> rectangles` map parallel to `slices`, and an effect pushes the full set back and redraws after every change. Deselecting anywhere (ctrl-click, the panel's ×, Clear selection) prunes the map by live slice key, so the grid and the panel can't disagree. + `pf_iter` is not a col_meta column, so it is stripped when a slice is built: two cells differing only by iter band produce the same effective slice. Duplicates are collapsed before the request — without that, `apply_mode: each` would apply the same change twice. **Limitation:** computed columns created by Perspective's split_by (e.g. Month, YearDate) don't map back to raw rows — only native dimension columns work for slice extraction. diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 1b31ad4..648586b 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -169,6 +169,18 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const expandDepthRef = useRef(null) const initIdRef = useRef(0) const modifierRef = useRef(false) + // the datagrid plugin element, for reading cell coordinates and driving its + // own selection highlight (see the selection-highlight effect below) + const gridRef = useRef(null) + // { x, y } of the body cell the last mousedown landed on, or null + const downCellRef = useRef(null) + // the region a drag is building, as a Perspective ViewWindow; committed on mouseup + const regionRef = useRef(null) + // sliceKey -> the grid rectangles that produced it. Kept parallel to `slices` so + // deselecting a slice removes exactly the cells that contributed it. + const areasRef = useRef(new Map()) + // the current selection, readable from event handlers that were registered once + const slicesRef = useRef([]) // Perspective's ROLLUP view contains every level of the hierarchy; view.set_depth() // is the only thing hiding the deeper ones, and it lives on the view rather than in @@ -202,13 +214,125 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio }, []) // perspective-click fires as a CustomEvent with no modifier state of its own, - // so record it from the mousedown that precedes it + // and carries no cell coordinates either, so record both from the mousedown that + // precedes it. The grid lives in a shadow root, so the real target is the head of + // the composed path, not event.target (which retargets to the host). useEffect(() => { - const onDown = (e) => { modifierRef.current = e.ctrlKey || e.metaKey || e.shiftKey } + const onDown = (e) => { + modifierRef.current = e.ctrlKey || e.metaKey || e.shiftKey + downCellRef.current = cellAt(e) + } window.addEventListener('mousedown', onDown, true) return () => window.removeEventListener('mousedown', onDown, true) }, []) + // Reading the coordinates of the body cell under a mouse event, or null if the + // event did not land on one. + function cellAt(e) { + const table = gridRef.current?.regular_table + if (!table) return null + const target = (e.composedPath?.()[0]) || e.target + try { + const meta = table.getMeta(target) + if (meta?.type === 'body' && meta.x != null && meta.y != null) return { x: meta.x, y: meta.y } + } catch { + return null + } + return null + } + + // Drag-select commit. perspective-select fires continuously while the mouse moves + // (once per mouseover, as the potential region grows), so the event handler only + // records the latest window and the commit happens here, on mouseup. A single-cell + // region is left alone: perspective-click already owns plain and modifier clicks, + // and handling it twice would undo a ctrl-click toggle. + useEffect(() => { + const onUp = async () => { + const win = regionRef.current + regionRef.current = null + if (!win || win.start_row == null || win.end_row == null) return + const nRows = win.end_row - win.start_row + const nCols = (win.end_col ?? 1) - (win.start_col ?? 0) + if (nRows <= 1 && nCols <= 1) return + const found = await slicesFromRegion(win) + if (!found.length) return + const additive = modifierRef.current + if (!additive) areasRef.current.clear() + let next = additive ? slicesRef.current : [] + for (const { slice, area } of found) { + const k = sliceKey(slice) + areasRef.current.set(k, [...(areasRef.current.get(k) || []), area]) + next = addSlice(next, slice) + } + setSlices(next) + } + window.addEventListener('mouseup', onUp) + return () => window.removeEventListener('mouseup', onUp) + }, []) + + // A selected region is a rectangle of grid coordinates; turning it back into slices + // means re-deriving, per cell, the same [col, '==', value] filters Perspective + // attaches to a click. Row dimensions come from the view's __ROW_PATH__ (raw values, + // so dates stay epoch millis rather than whatever the grid formatted them as), and + // column dimensions from the split_by segments of the column name. + async function slicesFromRegion(win) { + const viewer = viewerRef.current + if (!viewer) return [] + const cfg = await viewer.save() + const view = await viewer.getView() + const groupBy = cfg.group_by || [] + const splitBy = cfg.split_by || [] + const base = cfg.filter || [] + const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row }) + if (!rows.length) return [] + const userKeys = Object.keys(rows[0]).filter(k => !META_COL_RE.test(k)) + const c0 = win.start_col ?? 0 + const c1 = win.end_col ?? userKeys.length + const out = [] + for (let i = 0; i < rows.length; i++) { + const path = rows[i].__ROW_PATH__ || [] + const rowFilters = groupBy + .map((col, ix) => (path[ix] ? [col, '==', path[ix]] : null)) + .filter(Boolean) + // the grand-total row resolves to no dimension at all, which would mean + // "the whole version" — never what dragging over it is asking for + if (groupBy.length && !rowFilters.length) continue + for (let c = c0; c < c1; c++) { + const key = userKeys[c] + if (!key) continue + const colFilters = splitBy + .map((col, ix) => { + const v = key.split('|')[ix] + return (v && !META_COL_RE.test(v)) ? [col, '==', v] : null + }) + .filter(Boolean) + const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters]) + if (!Object.keys(slice).length) continue + const y = win.start_row + i + out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } }) + } + } + return out + } + + // The datagrid highlights whatever sits in its own `selected_areas`, and wipes that + // list on every mousedown — so a ctrl-click selection built up over several clicks + // would only ever show the last cell. Push the full set back after each change and + // redraw, which gets the native highlight for every selected cell for free. + useEffect(() => { + slicesRef.current = slices + const grid = gridRef.current + const state = grid?.model?._selection_state + if (!state) return + const live = new Set(slices.map(sliceKey)) + for (const k of [...areasRef.current.keys()]) { + if (!live.has(k)) areasRef.current.delete(k) + } + state.selected_areas = [...areasRef.current.values()].flat() + state.dirty = true + grid.regular_table?.draw({ preserve_width: true })?.catch?.(() => {}) + }, [slices]) + function onDragStart(e) { e.preventDefault() const vertical = dock === 'bottom' @@ -490,7 +614,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const saved = localStorage.getItem(LAYOUT_KEY(vid)) if (saved) { const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols) - const cfg = { ...rest, plugin_config: { edit_mode: 'SELECT_REGION', ...(rest.plugin_config || {}) } } + const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } } await viewer.restore(cfg) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) } else { @@ -498,7 +622,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio let cfg if (sourceDefault && Object.keys(sourceDefault).length > 0) { const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols) - cfg = { ...rest, plugin_config: { edit_mode: 'SELECT_REGION', ...(rest.plugin_config || {}) } } + cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } } } else { const valueCol = meta.find(c => c.role === 'value')?.cname cfg = { @@ -535,24 +659,27 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio // the CustomEvent carries no modifier flags, so read them off the // mousedown that produced it (captured on window below) const additive = modifierRef.current - setSlices(prev => additive ? toggleSlice(prev, s) : [s]) + // the datagrid wipes its highlight on mousedown, so keep this cell's + // rectangle alongside the slice and re-apply the lot (see the effect above) + const cell = downCellRef.current + const k = sliceKey(s) + const next = additive ? toggleSlice(slicesRef.current, s) : [s] + if (!additive) areasRef.current.clear() + if (!next.some(x => sliceKey(x) === k)) areasRef.current.delete(k) + else if (cell) areasRef.current.set(k, [{ x0: cell.x, x1: cell.x, y0: cell.y, y1: cell.y }]) + setSlices(next) } viewer.addEventListener('perspective-click', viewer._pspClick) - // Region selection (drag across rows) arrives as perspective-select, one event - // per row with a `selected` toggle. The payload is built WASM-side, so treat it - // defensively: if no filters can be extracted, fall through and change nothing. + // Region selection (click and drag) reaches us as perspective-select carrying a + // ViewWindow — { start_row, end_row, start_col, end_col } — and fires on every + // mouseover as the region grows. Record the latest and let the window-level + // mouseup commit it; a null detail is the datagrid clearing its selection. if (viewer._pspSelect) viewer.removeEventListener('perspective-select', viewer._pspSelect) - viewer._pspSelect = (e) => { - const detail = e.detail || {} - const configs = detail.selected ? detail.insertConfigs : detail.removeConfigs - const filters = (Array.isArray(configs) ? configs : []) - .flatMap(c => (c && Array.isArray(c.filter)) ? c.filter : []) - const s = sliceFromFilters(filters) - if (!Object.keys(s).length) return - setSlices(prev => detail.selected ? addSlice(prev, s) : removeSlice(prev, s)) - } + viewer._pspSelect = (e) => { regionRef.current = e.detail || null } viewer.addEventListener('perspective-select', viewer._pspSelect) + + gridRef.current = await viewer.getPlugin() setLargeDataset(false) } catch (err) { @@ -633,7 +760,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio if (!viewer) return const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : []) const cfg = cleanLayout(layout.config, validCols) - cfg.plugin_config = { edit_mode: 'SELECT_REGION', ...(cfg.plugin_config || {}) } + cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } await viewer.restore(cfg) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) setActiveLayoutId(layout.id) @@ -1228,6 +1355,11 @@ function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listI ) } +// Perspective's internal columns, which are never dimensions. Mirrors isMetaColumn() +// in @perspective-dev/viewer-datagrid — the DuckDB backend emits per-level +// __ROW_PATH___ columns alongside the __ROW_PATH__ sidecar. +const META_COL_RE = /^__(?:ROW_PATH(?:_\d+)?|ID|GROUPING_ID)__$/ + // Perspective encodes a clicked/selected row position as [col, '==', value] triples function sliceFromFilters(filters) { const s = {}