// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ 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). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ /** * Struct-of-arrays storage for a hierarchical tree (treemap + sunburst). * A node is a numeric `id` into parallel typed arrays; hot numeric * fields live in `Float32Array` / `Int32Array` so iteration is * cache-linear and heap pressure stays flat. * * Children are a singly-linked list per parent (`firstChild` / * `nextSibling`, with `lastChild` for O(1) append). The `NULL_NODE = -1` * sentinel marks "no node" for `parent`, end-of-list for `nextSibling`, * and "leaf" for `firstChild`. * * Layout fields come in two flavors: * - `x0 / y0 / x1 / y1` — rectangular coords (treemap, future bar * hierarchies) * - `a0 / a1 / r0 / r1` — polar coords (sunburst) * Each chart populates only its own flavor; the other set wastes ~16 B * per node (32 MB at 2M nodes) — a small price for keeping a single * unified store across hierarchical chart types. * * At typical tree shapes the SOA + linked-list representation is ~10× * cheaper on heap and ~4× faster to build than per-node `Object` + * `Array` (which allocates ~300 B / node and is O(N²) for naive * child-name lookup). At 2M nodes the former OOMs the tab; the latter * stays under 100 MB. */ export const NULL_NODE = -1; export class NodeStore { // Hot numeric fields (tight-loop iteration in layout / render). size!: Float32Array; value!: Float32Array; colorValue!: Float32Array; /** * Sign of the leaf's raw size column value: `-1` when the source row * was negative, `1` otherwise. `size` itself always stores the * magnitude so layout code continues to treat negatives as positive * area; render code uses `sizeSign` to apply a lower alpha on * negative leaves. Always `1` for branches. */ sizeSign!: Int8Array; // Rectangular layout (treemap). x0!: Float32Array; y0!: Float32Array; x1!: Float32Array; y1!: Float32Array; // Polar layout (sunburst). `a0 / a1` in radians, `r0 / r1` in pixels. a0!: Float32Array; a1!: Float32Array; r0!: Float32Array; r1!: Float32Array; depth!: Int32Array; // Tree topology. parent!: Int32Array; firstChild!: Int32Array; nextSibling!: Int32Array; lastChild!: Int32Array; childCount!: Int32Array; // Leaf source-row link. `-1` for branches; for leaves, the index // into the chart's row-data column buffers (enables O(1) tooltip // lookup without per-node `Map` allocations). leafRowIdx!: Int32Array; // Cold (only read on hover / label draw): plain JS arrays. name!: string[]; colorLabel!: string[]; count = 0; capacity = 0; constructor(initialCapacity = 1024) { this._allocate(initialCapacity); } reset(): void { // Zero the layout typed arrays for slots that were occupied // before. `squarify` / `partitionSunburst` overwrite visited // nodes before any read, but early-bail branches (node area // below `MIN_VISIBLE_AREA`) skip the recursion and leave // descendants untouched. Re-using those slots in the next // render with stale `x0/y0/x1/y1/a0/a1/r0/r1` leaks the // previous render's rectangles / arcs through `collectVisible` // into the new scene — the "leftover hoverable cells" bug. // // Fill only up to the prior `count` (the tree's logical size) // — capacity can be much larger after growth and filling it // whole is O(capacity) unnecessary work. if (this.count > 0) { this.x0.fill(0, 0, this.count); this.y0.fill(0, 0, this.count); this.x1.fill(0, 0, this.count); this.y1.fill(0, 0, this.count); this.a0.fill(0, 0, this.count); this.a1.fill(0, 0, this.count); this.r0.fill(0, 0, this.count); this.r1.fill(0, 0, this.count); } this.count = 0; } /** * Reserve a new node id. Caller must immediately initialize the * hot numeric fields and set `parent` / `depth`; topology fields * default to `NULL_NODE`. */ allocate(): number { if (this.count === this.capacity) { this._allocate(this.capacity * 2); } const id = this.count++; this.firstChild[id] = NULL_NODE; this.nextSibling[id] = NULL_NODE; this.lastChild[id] = NULL_NODE; this.childCount[id] = 0; this.leafRowIdx[id] = NULL_NODE; this.size[id] = 0; this.value[id] = 0; this.colorValue[id] = NaN; this.sizeSign[id] = 1; this.name[id] = ""; this.colorLabel[id] = ""; return id; } /** * O(1) append `childId` as the last child of `parentId`. */ appendChild(parentId: number, childId: number): void { this.parent[childId] = parentId; this.depth[childId] = this.depth[parentId] + 1; this.nextSibling[childId] = NULL_NODE; const last = this.lastChild[parentId]; if (last === NULL_NODE) { this.firstChild[parentId] = childId; } else { this.nextSibling[last] = childId; } this.lastChild[parentId] = childId; this.childCount[parentId]++; } /** * `true` if the node has no children (branches set firstChild when they acquire one). */ isLeaf(id: number): boolean { return this.firstChild[id] === NULL_NODE; } private _allocate(newCapacity: number): void { const cap = Math.max(newCapacity, 1024); const grow = ( old: T | undefined, ctor: { new (n: number): T }, ): T => { const next = new ctor(cap); if (old && old.length > 0) { next.set(old); } return next; }; this.size = grow(this.size, Float32Array); this.value = grow(this.value, Float32Array); this.colorValue = grow(this.colorValue, Float32Array); this.sizeSign = grow(this.sizeSign, Int8Array); this.x0 = grow(this.x0, Float32Array); this.y0 = grow(this.y0, Float32Array); this.x1 = grow(this.x1, Float32Array); this.y1 = grow(this.y1, Float32Array); this.a0 = grow(this.a0, Float32Array); this.a1 = grow(this.a1, Float32Array); this.r0 = grow(this.r0, Float32Array); this.r1 = grow(this.r1, Float32Array); this.depth = grow(this.depth, Int32Array); this.parent = grow(this.parent, Int32Array); this.firstChild = grow(this.firstChild, Int32Array); this.nextSibling = grow(this.nextSibling, Int32Array); this.lastChild = grow(this.lastChild, Int32Array); this.childCount = grow(this.childCount, Int32Array); this.leafRowIdx = grow(this.leafRowIdx, Int32Array); // JS arrays: preserve existing, extend with empty slots. if (!this.name) { this.name = new Array(cap); } else { this.name.length = cap; } if (!this.colorLabel) { this.colorLabel = new Array(cap); } else { this.colorLabel.length = cap; } this.capacity = cap; } } /** * Walk the ancestor chain back to (but not including) the synthetic * root, topmost first. Allocates a fresh string array — callers that * care about allocations should inline the walk. */ export function ancestorNames(store: NodeStore, id: number): string[] { const out: string[] = []; let n = id; while (store.parent[n] !== NULL_NODE) { out.push(store.name[n]); n = store.parent[n]; } return out.reverse(); }