Drag to select a region, and show the selection on the grid

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
This commit is contained in:
Paul Trowbridge 2026-09-12 08:51:14 -04:00
parent 119065ef59
commit 099925b121
2 changed files with 156 additions and 18 deletions

View File

@ -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). 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. `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. **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.

View File

@ -169,6 +169,18 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const expandDepthRef = useRef(null) const expandDepthRef = useRef(null)
const initIdRef = useRef(0) const initIdRef = useRef(0)
const modifierRef = useRef(false) 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() // 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 // 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, // 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(() => { 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) window.addEventListener('mousedown', onDown, true)
return () => window.removeEventListener('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) { function onDragStart(e) {
e.preventDefault() e.preventDefault()
const vertical = dock === 'bottom' const vertical = dock === 'bottom'
@ -490,7 +614,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const saved = localStorage.getItem(LAYOUT_KEY(vid)) const saved = localStorage.getItem(LAYOUT_KEY(vid))
if (saved) { if (saved) {
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols) 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) await viewer.restore(cfg)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
} else { } else {
@ -498,7 +622,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
let cfg let cfg
if (sourceDefault && Object.keys(sourceDefault).length > 0) { if (sourceDefault && Object.keys(sourceDefault).length > 0) {
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols) 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 { } else {
const valueCol = meta.find(c => c.role === 'value')?.cname const valueCol = meta.find(c => c.role === 'value')?.cname
cfg = { cfg = {
@ -535,24 +659,27 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// the CustomEvent carries no modifier flags, so read them off the // the CustomEvent carries no modifier flags, so read them off the
// mousedown that produced it (captured on window below) // mousedown that produced it (captured on window below)
const additive = modifierRef.current 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) viewer.addEventListener('perspective-click', viewer._pspClick)
// Region selection (drag across rows) arrives as perspective-select, one event // Region selection (click and drag) reaches us as perspective-select carrying a
// per row with a `selected` toggle. The payload is built WASM-side, so treat it // ViewWindow { start_row, end_row, start_col, end_col } and fires on every
// defensively: if no filters can be extracted, fall through and change nothing. // 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) if (viewer._pspSelect) viewer.removeEventListener('perspective-select', viewer._pspSelect)
viewer._pspSelect = (e) => { viewer._pspSelect = (e) => { regionRef.current = e.detail || null }
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.addEventListener('perspective-select', viewer._pspSelect) viewer.addEventListener('perspective-select', viewer._pspSelect)
gridRef.current = await viewer.getPlugin()
setLargeDataset(false) setLargeDataset(false)
} catch (err) { } catch (err) {
@ -633,7 +760,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (!viewer) return if (!viewer) return
const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : []) const validCols = new Set(tableRef.current ? Object.keys(await tableRef.current.schema()) : [])
const cfg = cleanLayout(layout.config, validCols) 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) await viewer.restore(cfg)
if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
setActiveLayoutId(layout.id) 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_<n>__ 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 // Perspective encodes a clicked/selected row position as [col, '==', value] triples
function sliceFromFilters(filters) { function sliceFromFilters(filters) {
const s = {} const s = {}