Keep the selection on its rows when the sort moves them
A highlight is a rectangle of grid coordinates. Post a decrease on a grid sorted by sales descending and the customer drops down the list, while the highlight holds the coordinate and comes to rest over whoever took that position. `slices` is value-based, so the panel goes on writing to the customer it always meant -- the grid is the only thing that lies, which is the worst place for the lie to be. The rectangles are re-derived from the slices now rather than tracked: after a post or an undo, sweep the grid the way a drag does (slicesFromRegion, quietly) and keep the cells whose slice is still selected. That also survives the column axis shifting, which it does on almost every post -- a new segment column group moves every x by one. - Bounded at 5,000 displayed rows. The sweep reads what the pivot shows, not the grain, so at any ordinary depth it is a handful; fully expanded it is the whole traversal and not worth a highlight. - Past the bound, or on any failure, the highlight is cleared rather than left in place: losing it says "I don't know where it went", which is true, and leaving it says "it is here", which is not. - A slice may now land on more cells than it did -- one that never named the segment column matches the customer under the old segment and under the adjustment's own. That is what the slice covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4bce3bbd17
commit
b4e7604dd1
@ -293,6 +293,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const tableNameRef = useRef(null)
|
const tableNameRef = useRef(null)
|
||||||
// { x, y } of the body cell the last mousedown landed on, or null
|
// { x, y } of the body cell the last mousedown landed on, or null
|
||||||
const downCellRef = useRef(null)
|
const downCellRef = useRef(null)
|
||||||
|
// Past this many displayed rows a relocate gives up and clears instead --
|
||||||
|
// see relocateAreas.
|
||||||
|
const MAX_RELOCATE_ROWS = 5000
|
||||||
// the region a drag is building, as a Perspective ViewWindow; committed on mouseup
|
// the region a drag is building, as a Perspective ViewWindow; committed on mouseup
|
||||||
const regionRef = useRef(null)
|
const regionRef = useRef(null)
|
||||||
// sliceKey -> the grid rectangles that produced it. Kept parallel to `slices` so
|
// sliceKey -> the grid rectangles that produced it. Kept parallel to `slices` so
|
||||||
@ -363,7 +366,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// attaches to a click. Row dimensions come from the view's __ROW_PATH__ (raw values,
|
// 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
|
// 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.
|
// column dimensions from the split_by segments of the column name.
|
||||||
async function slicesFromRegion(win) {
|
async function slicesFromRegion(win, { quiet = false } = {}) {
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return []
|
if (!viewer) return []
|
||||||
const cfg = await viewer.save()
|
const cfg = await viewer.save()
|
||||||
@ -398,7 +401,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
|
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (blankCols) {
|
// Quiet for a relocate, which sweeps the whole grid rather than a region the
|
||||||
|
// user dragged: the blanks it passes over are not cells anyone asked for.
|
||||||
|
if (blankCols && !quiet) {
|
||||||
flash(`${blankCols} cell${blankCols > 1 ? 's' : ''} skipped: a blank column-axis value cannot be isolated`, 'error')
|
flash(`${blankCols} cell${blankCols > 1 ? 's' : ''} skipped: a blank column-axis value cannot be isolated`, 'error')
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -408,20 +413,69 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// list on every mousedown — so a ctrl-click selection built up over several clicks
|
// 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
|
// 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.
|
// redraw, which gets the native highlight for every selected cell for free.
|
||||||
useEffect(() => {
|
function paintAreas() {
|
||||||
slicesRef.current = slices
|
|
||||||
const grid = gridRef.current
|
const grid = gridRef.current
|
||||||
const state = grid?.model?._selection_state
|
const state = grid?.model?._selection_state
|
||||||
if (!state) return
|
if (!state) return
|
||||||
const live = new Set(slices.map(sliceKey))
|
const live = new Set(slicesRef.current.map(sliceKey))
|
||||||
for (const k of [...areasRef.current.keys()]) {
|
for (const k of [...areasRef.current.keys()]) {
|
||||||
if (!live.has(k)) areasRef.current.delete(k)
|
if (!live.has(k)) areasRef.current.delete(k)
|
||||||
}
|
}
|
||||||
state.selected_areas = [...areasRef.current.values()].flat()
|
state.selected_areas = [...areasRef.current.values()].flat()
|
||||||
state.dirty = true
|
state.dirty = true
|
||||||
grid.regular_table?.draw({ preserve_width: true })?.catch?.(() => {})
|
grid.regular_table?.draw({ preserve_width: true })?.catch?.(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
slicesRef.current = slices
|
||||||
|
paintAreas()
|
||||||
}, [slices])
|
}, [slices])
|
||||||
|
|
||||||
|
// A highlight is a rectangle of grid coordinates, and an adjustment moves the
|
||||||
|
// rows under it: post a decrease on a grid sorted by sales descending and the
|
||||||
|
// customer drops down the list, while the highlight stays on the coordinate
|
||||||
|
// and now sits over whoever took that position. `slices` is value-based and
|
||||||
|
// still names the right customer, so the panel goes on writing to the row it
|
||||||
|
// always meant -- the grid is the only thing that lies, which is the worst
|
||||||
|
// place for the lie to be.
|
||||||
|
//
|
||||||
|
// So the rectangles are re-derived from the slices rather than tracked: sweep
|
||||||
|
// the grid the way a drag does, keep the cells whose slice is still selected.
|
||||||
|
// That also survives the column axis moving, which it does on almost every
|
||||||
|
// post -- a new segment column group shifts every x by one.
|
||||||
|
//
|
||||||
|
// One consequence worth expecting: a slice can legitimately land on more cells
|
||||||
|
// than it did. A selection that never named the segment column now matches the
|
||||||
|
// customer's cell under the old segment *and* under the adjustment's own, and
|
||||||
|
// both are highlighted. That is what the slice covers.
|
||||||
|
async function relocateAreas() {
|
||||||
|
const viewer = viewerRef.current
|
||||||
|
if (!viewer || !slicesRef.current.length) return
|
||||||
|
try {
|
||||||
|
await viewer.flush()
|
||||||
|
const view = await viewer.getView()
|
||||||
|
const n = await view.num_rows()
|
||||||
|
// The sweep reads the rows the pivot is displaying, not the grain, so at
|
||||||
|
// any ordinary depth it is a handful. Fully expanded it is the whole
|
||||||
|
// traversal, and a slow redraw of a highlight is not worth having.
|
||||||
|
if (!n || n > MAX_RELOCATE_ROWS) { areasRef.current.clear(); paintAreas(); return }
|
||||||
|
const found = await slicesFromRegion({ start_row: 0, end_row: n }, { quiet: true })
|
||||||
|
const live = new Set(slicesRef.current.map(sliceKey))
|
||||||
|
const next = new Map()
|
||||||
|
for (const { slice, area } of found) {
|
||||||
|
const k = sliceKey(slice)
|
||||||
|
if (!live.has(k)) continue
|
||||||
|
next.set(k, [...(next.get(k) || []), area])
|
||||||
|
}
|
||||||
|
areasRef.current = next
|
||||||
|
} catch {
|
||||||
|
// Losing the highlight says "I don't know where it went", which is true.
|
||||||
|
// Leaving it where it was says "it is here", which is not.
|
||||||
|
areasRef.current.clear()
|
||||||
|
}
|
||||||
|
paintAreas()
|
||||||
|
}
|
||||||
|
|
||||||
function onDragStart(e) {
|
function onDragStart(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const vertical = dock === 'bottom'
|
const vertical = dock === 'bottom'
|
||||||
@ -1732,6 +1786,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// effect that normally recounts is keyed on `slices`, and none of these
|
// effect that normally recounts is keyed on `slices`, and none of these
|
||||||
// change the selection.
|
// change the selection.
|
||||||
fetchCurrentTotals(slices)
|
fetchCurrentTotals(slices)
|
||||||
|
// The rows just moved under the highlight — see relocateAreas.
|
||||||
|
relocateAreas()
|
||||||
if (op === 'scale') { setScaleInputs({}); setScaleNote('') }
|
if (op === 'scale') { setScaleInputs({}); setScaleNote('') }
|
||||||
if (op === 'recode') { setRecodeNote('') }
|
if (op === 'recode') { setRecodeNote('') }
|
||||||
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
if (op === 'clone') { setCloneNote(''); setCloneScale('1') }
|
||||||
@ -1947,6 +2003,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
window.dispatchEvent(new CustomEvent('pf-data-changed'))
|
window.dispatchEvent(new CustomEvent('pf-data-changed'))
|
||||||
refreshLogMeta(versionId)
|
refreshLogMeta(versionId)
|
||||||
if (slicesRef.current.length) fetchCurrentTotals(slicesRef.current)
|
if (slicesRef.current.length) fetchCurrentTotals(slicesRef.current)
|
||||||
|
// Undo moves the rows back up the sort as surely as the post moved them
|
||||||
|
// down, and drops the adjustment's column group with them.
|
||||||
|
relocateAreas()
|
||||||
flash(`Undone — ${data.rows_deleted} rows removed`)
|
flash(`Undone — ${data.rows_deleted} rows removed`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
flash(err.message, 'error')
|
flash(err.message, 'error')
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user