Read a null on the column axis as null, not as the text "null"
The engine renders a null split_by value as the literal "null" in the column name (t_tscalar::to_string on an invalid scalar) and an empty string as ''. cellFilters() passed "null" through as a value, so a slice over a blank segment_new became segment_new = 'null', matched no row, and the adjustment wrote nothing. '' was refused outright, on the mistaken belief that the two blanks joined to the same text. Both are now mapped back: "null" to null, '' to ''. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
94af6a5f13
commit
709c9d0395
16
CLAUDE.md
16
CLAUDE.md
@ -697,12 +697,16 @@ The panel prints `(null)` and `(blank)` rather than nothing, and `—` only when
|
|||||||
the column is genuinely absent from the slice: a slice isolated to the blank node
|
the column is genuinely absent from the slice: a slice isolated to the blank node
|
||||||
and one the column plays no part in are opposite statements and looked identical.
|
and one the column plays no part in are opposite statements and looked identical.
|
||||||
|
|
||||||
**A blank on the column axis cannot be isolated.** A column name is its values
|
**A blank on the column axis is read back from the column name.** A column
|
||||||
joined with `|`, and a null joins as the empty string, so by the time the name
|
name is its values joined with `|`, each rendered by `t_tscalar::to_string` in
|
||||||
reaches the client the two blanks are the same text. Those cells are refused —
|
`scalar.cpp`, which gives `''` for an empty string and the literal text `"null"`
|
||||||
with a message, and counted once for a drag — rather than widened, since a slice
|
for a null. So `cellFilters()` maps `"null"` to null and `''` to `''`, and both
|
||||||
missing a split_by level means every value of it. Filtering on the column
|
blanks are isolatable on either axis. The one ambiguity left is a dimension
|
||||||
instead (`scope`, which carries `is null`) is the way to reach them.
|
whose value is really the text `null`; none exists in this data.
|
||||||
|
|
||||||
|
This used to be believed impossible — the claim was that a null joined as `''`
|
||||||
|
too — so `''` cells were refused, and `"null"` was taken at its word: the slice
|
||||||
|
became `segment_new = 'null'`, matched no row, and the adjustment reported zero.
|
||||||
|
|
||||||
**The pivot's own filter travels alongside, as `scope`.** A slice is
|
**The pivot's own filter travels alongside, as `scope`.** A slice is
|
||||||
`{col: value}` and can only mean equality, so a view narrowed to
|
`{col: value}` and can only mean equality, so a view narrowed to
|
||||||
|
|||||||
@ -369,7 +369,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, { quiet = false } = {}) {
|
async function slicesFromRegion(win) {
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return []
|
if (!viewer) return []
|
||||||
const cfg = await viewer.save()
|
const cfg = await viewer.save()
|
||||||
@ -382,10 +382,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const c0 = win.start_col ?? 0
|
const c0 = win.start_col ?? 0
|
||||||
const c1 = win.end_col ?? userKeys.length
|
const c1 = win.end_col ?? userKeys.length
|
||||||
const out = []
|
const out = []
|
||||||
// cells whose column-axis value is blank, which cannot be isolated (see
|
|
||||||
// cellFilters) -- counted so the drag can say why it skipped them once,
|
|
||||||
// rather than per cell
|
|
||||||
let blankCols = 0
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const path = rows[i].__ROW_PATH__ || []
|
const path = rows[i].__ROW_PATH__ || []
|
||||||
// the grand-total row resolves to no dimension at all, which would mean
|
// the grand-total row resolves to no dimension at all, which would mean
|
||||||
@ -394,8 +390,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
for (let c = c0; c < c1; c++) {
|
for (let c = c0; c < c1; c++) {
|
||||||
const key = userKeys[c]
|
const key = userKeys[c]
|
||||||
if (!key) continue
|
if (!key) continue
|
||||||
const { rowFilters, colFilters, colBlank } = cellFilters(cfg, rows[i], key)
|
const { rowFilters, colFilters } = cellFilters(cfg, rows[i], key)
|
||||||
if (colBlank) { blankCols++; continue }
|
|
||||||
if (groupBy.length && !rowFilters.length) continue
|
if (groupBy.length && !rowFilters.length) continue
|
||||||
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
|
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
|
||||||
(cfg.columns || []).filter(Boolean))
|
(cfg.columns || []).filter(Boolean))
|
||||||
@ -404,11 +399,6 @@ 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 } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 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')
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,7 +452,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// any ordinary depth it is a handful. Fully expanded it is the whole
|
// 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.
|
// traversal, and a slow redraw of a highlight is not worth having.
|
||||||
if (!n || n > MAX_RELOCATE_ROWS) { areasRef.current.clear(); paintAreas(); return }
|
if (!n || n > MAX_RELOCATE_ROWS) { areasRef.current.clear(); paintAreas(); return }
|
||||||
const found = await slicesFromRegion({ start_row: 0, end_row: n }, { quiet: true })
|
const found = await slicesFromRegion({ start_row: 0, end_row: n })
|
||||||
const live = new Set(slicesRef.current.map(sliceKey))
|
const live = new Set(slicesRef.current.map(sliceKey))
|
||||||
const next = new Map()
|
const next = new Map()
|
||||||
for (const { slice, area } of found) {
|
for (const { slice, area } of found) {
|
||||||
@ -1101,11 +1091,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const measures = (config.columns || []).filter(Boolean)
|
const measures = (config.columns || []).filter(Boolean)
|
||||||
let s
|
let s
|
||||||
if (cell) {
|
if (cell) {
|
||||||
const { rowFilters, colFilters, colBlank } = cellFilters(config, detail.row, colKey)
|
const { rowFilters, colFilters } = cellFilters(config, detail.row, colKey)
|
||||||
if (colBlank) {
|
|
||||||
flash('A blank column-axis value cannot be isolated — filter on it instead', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!rowFilters.length) return
|
if (!rowFilters.length) return
|
||||||
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
|
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
|
||||||
} else {
|
} else {
|
||||||
@ -2590,11 +2576,18 @@ function sliceFromFilters(filters, measures = []) {
|
|||||||
// a dimension whose value is 0 goes the same way. The row path is the raw truth
|
// a dimension whose value is 0 goes the same way. The row path is the raw truth
|
||||||
// and carries the blank, so the slice is built from that instead.
|
// and carries the blank, so the slice is built from that instead.
|
||||||
//
|
//
|
||||||
// A blank on the *column* axis cannot be recovered at all: a column name is its
|
// A blank on the *column* axis arrives as text, and the two blanks arrive as
|
||||||
// values joined with '|', and a null joins as the empty string, so the two are
|
// different text: a column name is its values joined with '|', each rendered by
|
||||||
// the same text by the time it reaches us. Those cells are refused rather than
|
// t_tscalar::to_string, which gives the empty string for '' and the literal
|
||||||
// widened -- `colBlank` says so -- since a slice missing a split_by level means
|
// "null" for a null (scalar.cpp -- an invalid scalar returns {"null"} before the
|
||||||
// every value of it.
|
// type switch). So "null" is read back as null and '' as ''. Both used to be
|
||||||
|
// mishandled: '' was refused as unrecoverable, on the belief that a null joined
|
||||||
|
// as '' too, and "null" was taken at its word -- a slice of segment_new =
|
||||||
|
// 'null', which matches no row, so the adjustment wrote nothing.
|
||||||
|
//
|
||||||
|
// The one thing this cannot tell apart is a null from a dimension value that is
|
||||||
|
// really the four-letter text 'null'. None exists in this data; one would be
|
||||||
|
// read as null.
|
||||||
function cellFilters(cfg, row, colKey) {
|
function cellFilters(cfg, row, colKey) {
|
||||||
const groupBy = cfg.group_by || []
|
const groupBy = cfg.group_by || []
|
||||||
const splitBy = cfg.split_by || []
|
const splitBy = cfg.split_by || []
|
||||||
@ -2603,7 +2596,6 @@ function cellFilters(cfg, row, colKey) {
|
|||||||
.map((col, ix) => (ix < path.length && path[ix] !== undefined ? [col, '==', path[ix]] : null))
|
.map((col, ix) => (ix < path.length && path[ix] !== undefined ? [col, '==', path[ix]] : null))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
let colFilters = []
|
let colFilters = []
|
||||||
let colBlank = false
|
|
||||||
if (colKey != null) {
|
if (colKey != null) {
|
||||||
// A column name is dimension values joined by | with the measure last, and
|
// A column name is dimension values joined by | with the measure last, and
|
||||||
// only as many values as the axis currently shows -- collapse the column
|
// only as many values as the axis currently shows -- collapse the column
|
||||||
@ -2621,12 +2613,11 @@ function cellFilters(cfg, row, colKey) {
|
|||||||
if (v === undefined) return null
|
if (v === undefined) return null
|
||||||
// the measure name read positionally as a dimension value
|
// the measure name read positionally as a dimension value
|
||||||
if (META_COL_RE.test(v)) return null
|
if (META_COL_RE.test(v)) return null
|
||||||
if (v === '') { colBlank = true; return null }
|
return [col, '==', v === 'null' ? null : v]
|
||||||
return [col, '==', v]
|
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
return { rowFilters, colFilters, colBlank }
|
return { rowFilters, colFilters }
|
||||||
}
|
}
|
||||||
|
|
||||||
// the user keys of a to_json row, in the order the grid lays its columns out
|
// the user keys of a to_json row, in the order the grid lays its columns out
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user