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 = {}