// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ Copyright (c) 2017, the Perspective Authors. ┃ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ /** * Streaming tree pipeline shared by treemap and sunburst. Rows arrive * incrementally; each chunk inserts its rows directly into the SOA * tree. Per-leaf tooltip columns are fetched lazily on pin via the * chart's `_lazyRows`; the tree only retains `leafRowIdx` per leaf * (small, O(leaves)) as the handle back to the source view row. * After a chunk is processed, `finalizeTree` recomputes `value` * bottom-up. * * Color mode: * - `"numeric"` — `readColor` reads the row's numeric value; the * render path maps it through the continuous gradient. * - `"series"` — `readColor` reads the row's string value from the * color column's dictionary, and `seedColorLabels` pre-populates * `_uniqueColorLabels` in dictionary-index order. Render picks * `palette[dictIdx % paletteSize]`. * - `"empty"` — no color column; every leaf gets `palette[0]`. * * When `_splitBy` is populated, every row is duplicated — one insertion * per split prefix, with that prefix pushed as the top-level path * segment so the top-level children of the synthetic root become * facet roots. The per-prefix `size` / `color` columns (named * `${prefix}|${base}`) feed the facet's values. `seedColorLabels` * runs once per split so every split's dictionary contributes to the * shared legend. */ import type { ColumnDataMap, ColumnData } from "../../data/view-reader"; import { buildSplitGroups } from "../../data/split-groups"; import { synthesizeStringLevel } from "./category-axis-resolver"; import { NULL_NODE } from "./node-store"; import type { TreeChartBase } from "./tree-chart"; // Reset /** * Reset the shared tree state. Called on the first chunk * (`startRow === 0`) of each dataset load. */ export function resetTreeState(chart: TreeChartBase): void { chart._nodeStore.reset(); chart._childLookup.clear(); // Allocate the synthetic root (id 0 by convention). const rootId = chart._nodeStore.allocate(); chart._nodeStore.name[rootId] = "Total"; chart._nodeStore.depth[rootId] = 0; chart._nodeStore.parent[rootId] = NULL_NODE; chart._childLookup.set(rootId, new Map()); chart._rootId = rootId; chart._currentRootId = rootId; chart._breadcrumbIds = [rootId]; chart._rowCount = 0; chart._colorMin = Infinity; chart._colorMax = -Infinity; chart._uniqueColorLabels.clear(); chart._visibleNodeIds = null; chart._visibleNodeCount = 0; } // Tree insertion /** * Find-or-create a child of `parentId` named `segment`. Uses a per- * parent `Map` for O(1) lookup. */ function childByName( chart: TreeChartBase, parentId: number, segment: string, ): number { let lookup = chart._childLookup.get(parentId); if (!lookup) { lookup = new Map(); chart._childLookup.set(parentId, lookup); } const existing = lookup.get(segment); if (existing !== undefined) { return existing; } const childId = chart._nodeStore.allocate(); chart._nodeStore.name[childId] = segment; chart._nodeStore.appendChild(parentId, childId); lookup.set(segment, childId); return childId; } /** * Insert one row into the tree. */ function insertRow( chart: TreeChartBase, rowPath: string[], sizeValue: number, colorValue: number, colorLabel: string, rowIdx: number, groupByLen: number, ): void { let currentId = chart._rootId; const depth = rowPath.length; for (let d = 0; d < depth; d++) { const childId = childByName(chart, currentId, rowPath[d]); if (d === depth - 1) { if (groupByLen === 0 || depth === groupByLen) { // Store `|size|` for layout; remember the sign so // render can dim negative leaves (matches the area // chart's `theme.areaOpacity`). chart._nodeStore.size[childId] = Math.abs(sizeValue); chart._nodeStore.sizeSign[childId] = sizeValue < 0 ? -1 : 1; chart._nodeStore.leafRowIdx[childId] = rowIdx; } if (!isNaN(colorValue)) { chart._nodeStore.colorValue[childId] = colorValue; if (colorValue < chart._colorMin) { chart._colorMin = colorValue; } if (colorValue > chart._colorMax) { chart._colorMax = colorValue; } } if (colorLabel) { chart._nodeStore.colorLabel[childId] = colorLabel; if (!chart._uniqueColorLabels.has(colorLabel)) { chart._uniqueColorLabels.set( colorLabel, chart._uniqueColorLabels.size, ); } } } currentId = childId; } } function readColor( chart: TreeChartBase, colorCol: ColumnData | null | undefined, rowIdx: number, ): { colorValue: number; colorLabel: string } { let colorValue = NaN; let colorLabel = ""; if (!colorCol) { return { colorValue, colorLabel }; } if (chart._colorMode === "numeric" && colorCol.values) { colorValue = colorCol.values[rowIdx] as number; } else if ( chart._colorMode === "series" && colorCol.indices && colorCol.dictionary ) { // Read the dictionary-decoded string for this row. The palette // index that render uses is `_uniqueColorLabels.get(label)`, // which `seedColorLabels` seeds in dictionary-index order so // the end result is `palette[dictIdx % paletteSize]`. colorLabel = colorCol.dictionary[colorCol.indices[rowIdx]]; } return { colorValue, colorLabel }; } /** * Seed `_uniqueColorLabels` with the color column's dictionary in * index order. Using insertion-order-guarded `.set` means later * chunks (or later splits in split_by mode) append new entries * without disturbing already-assigned indices; for a single stable * dictionary this yields `_uniqueColorLabels.get(dict[i]) === i`. * * No-op outside `"series"` mode or when the column lacks a * dictionary. */ function seedColorLabels( chart: TreeChartBase, colorCol: ColumnData | null | undefined, ): void { if (chart._colorMode !== "series") { return; } if (!colorCol?.dictionary) { return; } const dict = colorCol.dictionary; for (let i = 0; i < dict.length; i++) { const s = dict[i]; if (!chart._uniqueColorLabels.has(s)) { chart._uniqueColorLabels.set(s, chart._uniqueColorLabels.size); } } } // Chunk processor interface SplitSource { prefix: string; sizeCol: ColumnData | null; colorCol: ColumnData | null; } /** * Resolve the per-split size / color columns. Returns `null` when * `_splitBy` is empty — callers then take the non-split fast path. */ function resolveSplitSources( chart: TreeChartBase, columns: ColumnDataMap, ): SplitSource[] | null { if (chart._splitBy.length === 0) { return null; } const required: string[] = chart._sizeName ? [chart._sizeName] : []; const optional: string[] = chart._colorName ? [chart._colorName] : []; const groups = buildSplitGroups(columns, required, optional); if (groups.length === 0) { return null; } return groups.map((g) => ({ prefix: g.prefix, sizeCol: chart._sizeName ? (columns.get(`${g.prefix}|${chart._sizeName}`) ?? null) : null, colorCol: chart._colorName ? (columns.get(`${g.prefix}|${chart._colorName}`) ?? null) : null, })); } /** * Process one incoming chunk: grow row-data buffers, walk every row, * capture column values, and insert into the tree. */ export function processTreeChunk( chart: TreeChartBase, columns: ColumnDataMap, ): void { const rpCols: { indices: Int32Array; dictionary: string[] }[] = []; for (let n = 0; ; n++) { const rp = columns.get(`__ROW_PATH_${n}__`); if (!rp) { break; } if (rp.type === "string" && rp.indices && rp.dictionary) { rpCols.push({ indices: rp.indices, dictionary: rp.dictionary }); } else if (rp.values) { // Non-string group_by (integer / float / date / datetime / // boolean) — synthesize a string-indexed dictionary so the // tree insertion loop can treat every level uniformly. // Uses the same formatter as `resolveCategoryAxis`. const levelName = chart._groupBy[n]; const levelType = chart._groupByTypes[levelName] ?? "string"; rpCols.push(synthesizeStringLevel(rp, rp.values.length, levelType)); } else { break; } } const hasGroupBy = rpCols.length > 0; const groupByLen = chart._groupBy.length; const splitSources = resolveSplitSources(chart, columns); const hasSplits = splitSources !== null; const sizeCol = chart._sizeName ? columns.get(chart._sizeName) : null; const colorCol = chart._colorName ? columns.get(chart._colorName) : null; const firstSizeCol = hasSplits ? splitSources![0].sizeCol : sizeCol; const numRows = hasGroupBy ? rpCols[0].indices.length : (firstSizeCol?.values?.length ?? 0); if (numRows === 0) { return; } // Seed palette label indices from the color column's dictionary // BEFORE inserting rows, so the first row doesn't assign label 0 // to whichever dict value it happens to reference. For splits we // seed once per split's own color column so every dict value is // known to the shared legend. if (hasSplits) { for (const src of splitSources!) { seedColorLabels(chart, src.colorCol); } } else { seedColorLabels(chart, colorCol); } // `base` is the source-view row offset the tree should tag its // leaves with. `_rowCount` tracks how many rows prior chunks // occupied so `leafRowIdx[childId] = base + i` still points back // to the correct view row after multiple chunk arrivals. const base = chart._rowCount; // The split expansion inserts every row under one extra leading // path segment — the split prefix. When `group_by` is also active // the path is `[prefix, ...groupByLevels]` (length `groupByLen + 1`) // and the leaf store gate inside `insertRow` (`depth === groupByLen`) // needs the `+1` to find the leaf. When `group_by` is empty we fall // through the no-group-by branch below, which writes paths of the // form `[prefix, label]`; that path's leaf is the row label, and // sizing it relies on `insertRow`'s `groupByLen === 0` // "every leaf gets sized" branch — passing `0` here lets that // branch fire so each row's leaf actually receives a size. The // historical `+1`-always form quietly produced an effective // `groupByLen=1` against a depth-2 path, so the size store was // skipped and treemap / sunburst rendered nothing. const effectiveGroupLen = hasSplits && hasGroupBy ? groupByLen + 1 : groupByLen; if (!hasGroupBy) { // Flat fallback: synthesize a single-segment path per row from // the first string column (or a "Row N" sentinel). // // In `split_by` mode every column the view emits is pivoted as // `${splitPrefix}|${baseName}`, so two correctness traps need // to be avoided here: // // 1. The size / color column the user picked appears under // its prefixed form (`First Class|Region`), so a literal // `name === chart._sizeName/_colorName` skip would miss // them and the loop would happily pick the color column // itself as the label source. // 2. Reading a pivoted column by row index returns values // keyed to ONE specific split (the prefix that happened // to win the iteration order), not to that row's actual // split — so even a "different" pivoted dictionary column // isn't a legitimate per-row label. And once any pivoted // column is used as a label source, `childByName` collapses // every row sharing a `(prefix, label)` pair onto a single // node — last-write-wins for `size` — which truncates the // visible cell count to `cardinality(label) × cardinality(splits)`. // // The two checks below address both: compare base names (post-`|`) // for the size/color skip, and reject any pivoted column outright // in split mode. If nothing remains, `labelCol` stays undefined // and the per-row loop falls through to the `Row N` sentinel, // which gives every row a unique key. let labelCol: ColumnData | undefined; for (const [name, col] of columns) { if (name.startsWith("__")) { continue; } if (hasSplits && name.includes("|")) { continue; } const pipeIdx = name.lastIndexOf("|"); const baseName = pipeIdx === -1 ? name : name.substring(pipeIdx + 1); if (baseName === chart._sizeName || baseName === chart._colorName) { continue; } if (col.type === "string" && col.indices && col.dictionary) { labelCol = col; break; } } const sources = hasSplits ? splitSources! : null; for (let i = 0; i < numRows; i++) { const label = labelCol?.indices && labelCol?.dictionary ? labelCol.dictionary[labelCol.indices[i]] : `Row ${base + i}`; if (sources) { for (const src of sources) { // Pass the signed value through; `insertRow` stores // `|size|` and `sizeSign` separately so the // render pass can dim negative leaves. const sizeValue = src.sizeCol?.values ? (src.sizeCol.values[i] as number) : 1; const { colorValue, colorLabel } = readColor( chart, src.colorCol, i, ); insertRow( chart, [src.prefix, label], sizeValue, colorValue, colorLabel, base + i, effectiveGroupLen, ); } } else { const sizeValue = sizeCol?.values ? (sizeCol.values[i] as number) : 1; const { colorValue, colorLabel } = readColor( chart, colorCol, i, ); insertRow( chart, [label], sizeValue, colorValue, colorLabel, base + i, effectiveGroupLen, ); } } chart._rowCount = base + numRows; return; } // Hierarchical (group_by present): reuse a scratch path buffer // across rows to avoid per-row array allocation. When splits are // active the scratch is one slot longer to hold the leading prefix. const extra = hasSplits ? 1 : 0; const pathScratch: string[] = new Array(rpCols.length + extra); for (let i = 0; i < numRows; i++) { let pathLen = 0; for (let d = 0; d < rpCols.length; d++) { const rp = rpCols[d]; const label = rp.dictionary[rp.indices[i]]; if (!label && label !== "0") { break; } pathScratch[extra + pathLen++] = label; } if (pathLen === 0) { continue; } // skip total row if (hasSplits) { for (const src of splitSources!) { pathScratch[0] = src.prefix; const rowPath = pathScratch.slice(0, pathLen + 1); const sizeValue = src.sizeCol?.values ? (src.sizeCol.values[i] as number) : 1; const { colorValue, colorLabel } = readColor( chart, src.colorCol, i, ); insertRow( chart, rowPath, sizeValue, colorValue, colorLabel, base + i, effectiveGroupLen, ); } } else { const rowPath = pathScratch.slice(0, pathLen); const sizeValue = sizeCol?.values ? (sizeCol.values[i] as number) : 1; const { colorValue, colorLabel } = readColor(chart, colorCol, i); insertRow( chart, rowPath, sizeValue, colorValue, colorLabel, base + i, effectiveGroupLen, ); } } chart._rowCount = base + numRows; } // Finalize /** * Post-chunk finalization. * 1. Recompute `value` bottom-up from `size` via an iterative * post-order walk. * 2. Re-resolve `_currentRootId` from the breadcrumb name-path so * drill state survives incremental chunk arrivals. * * `colorLabel` is set at insert time (`readColor`) and needs no * post-pass: in `"series"` mode it comes from the color column's * dictionary, and in `"numeric"` / `"empty"` modes it's unused. */ export function finalizeTree(chart: TreeChartBase): void { const store = chart._nodeStore; const value = store.value; const size = store.size; const firstChild = store.firstChild; const nextSibling = store.nextSibling; // Iterative post-order. Stack holds `(id, state)` pairs; state // 0 = pre-visit, 1 = post-visit. let stack = new Int32Array(128); stack[0] = chart._rootId; stack[1] = 0; let sp = 1; // Reset value accumulators. for (let i = 0; i < store.count; i++) { value[i] = 0; } while (sp > 0) { sp--; const id = stack[sp * 2]; const s = stack[sp * 2 + 1]; if (s === 0) { stack[sp * 2 + 1] = 1; sp++; for (let c = firstChild[id]; c !== NULL_NODE; c = nextSibling[c]) { if ((sp + 1) * 2 > stack.length) { const bigger = new Int32Array(stack.length * 2); bigger.set(stack); stack = bigger; } stack[sp * 2] = c; stack[sp * 2 + 1] = 0; sp++; } } else { if (firstChild[id] === NULL_NODE) { value[id] = Math.max(0, size[id]); } else { let sum = 0; for ( let c = firstChild[id]; c !== NULL_NODE; c = nextSibling[c] ) { sum += value[c]; } value[id] = sum; } } } // Preserve drill state across incremental chunk arrivals: walk the // breadcrumb name-path from the root and re-resolve. If a segment // is missing (shouldn't happen with incremental build, but // defensively), fall back to the root. if (chart._breadcrumbIds.length > 1) { const breadcrumbNames: string[] = []; for (let i = 1; i < chart._breadcrumbIds.length; i++) { breadcrumbNames.push(store.name[chart._breadcrumbIds[i]]); } let node = chart._rootId; let valid = true; for (const seg of breadcrumbNames) { const lookup = chart._childLookup.get(node); const next = lookup?.get(seg); if (next === undefined) { valid = false; break; } node = next; } if (valid && store.firstChild[node] !== NULL_NODE) { chart._currentRootId = node; rebuildBreadcrumbs(chart, node); return; } } chart._currentRootId = chart._rootId; chart._breadcrumbIds = [chart._rootId]; } // Breadcrumbs /** * Rebuild `chart._breadcrumbIds` by walking up from `nodeId`. */ export function rebuildBreadcrumbs(chart: TreeChartBase, nodeId: number): void { const ids: number[] = []; let n = nodeId; while (n !== NULL_NODE) { ids.unshift(n); n = chart._nodeStore.parent[n]; } chart._breadcrumbIds = ids; }