/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import {HoistModel, PlainObject, Theme} from '@xh/hoist/core'; import {Store, StoreRecord, StoreRecordId} from '@xh/hoist/data'; import {GridModel} from '@xh/hoist/cmp/grid'; import {FilterLike} from '@xh/hoist/data/filter/Types'; import {numberRenderer} from '@xh/hoist/format'; import {action, bindable, computed, makeObservable, observable} from '@xh/hoist/mobx'; import {throwIf, withDefault} from '@xh/hoist/utils/js'; import {ReactNode} from 'react'; import {cloneDeep, get, isEmpty, isFinite, max, set, sortBy, sumBy, unset} from 'lodash'; export interface TreeMapConfig { /** A store containing records to be displayed. */ store?: Store; /** Optional GridModel to bind to. */ gridModel?: GridModel; /** Maximum number of nodes to render. Be aware that increasing this can severely degrade performance. */ maxNodes?: number; /** * Maximum number of node labels to attempt to render. Defaults to 250, based on the fact that * the smaller boxes beyond that count are unlikely to be able to have enough room to render * labels in any case. Be aware that increasing this can degrade performance. */ maxLabels?: number; /** * Highcharts configuration object for the managed map. * Will be recursively merged into the top-level HC config generated by this model, the component. */ highchartsConfig?: PlainObject; /** StoreRecord field to use to determine node label. */ labelField?: string; /** StoreRecord field to use to determine node size. */ valueField?: string; /** StoreRecord field to use to determine node color. */ heatField?: string; /** Renderer to use when displaying value in the default tooltip. Should return HTML. */ valueRenderer?: TreeMapValueRendererFn; /** Renderer to use when displaying heat in the default tooltip. Should return HTML. */ heatRenderer?: TreeMapHeatRendererFn; /** * Value for providing a clamped, stable upper boundary on heat color intensity. * If not provided, maxHeat will be determined by the dataset on each load. */ maxHeat?: number; /** Maximum tree depth to render. */ maxDepth?: number; /** Layout algorithm to use. */ algorithm?: TreeMapAlgorithm; /** Heat color distribution mode. */ colorMode?: TreeMapColorMode; /** Theme to use. Leave undefined to use the global theme. */ theme?: Theme; /** * Callback to call when a node is clicked. * If not provided, by default will select a record when using a GridModel. */ onClick?: (StoreRecord, MouseEvent) => void; /** * Callback to call when a node is double-clicked. * If not provided, by default will expand / collapse a record when using a GridModel. */ onDoubleClick?: (StoreRecord, MouseEvent) => void; /** `true` to use the default tooltip renderer, or a custom tooltipFn. */ tooltip?: boolean | TreeMapTooltipFn; /** Element/text to render if TreeMap has no records. */ emptyText?: ReactNode; /** Data filter to apply to records. */ filter?: FilterLike; } export interface TreeMapModelDefaults { algorithm?: TreeMapAlgorithm; maxNodes?: number; } /** * Core Model for a TreeMap, backed by a data Store with fields mapped to each node's label * (display name), value (size), and heat (color). * * Can optionally bind to a GridModel to enable selection and expand/collapse syncing for * GridModels in `treeMode`. * * Supports Highcharts TreeMap algorithms: 'squarified', 'sliceAndDice', 'stripes', and 'strip'. * * Node colors are normalized to a 0-1 range and mapped to a colorAxis. The `colorMode` config * controls how: * - 'linear' - distributes values across the colorAxis according to the heatField. * - 'wash' - ignores heat intensity, applying a single positive/negative color. * - 'none' - ignores the colorAxis entirely, using the neutral color. * * Color customization can be managed by setting colorAxis stops via `highchartsConfig`. * See {@link https://www.highcharts.com/docs/chart-and-series-types/treemap} for Highcharts * TreeMap config options. */ export class TreeMapModel extends HoistModel { /** App-level defaults for TreeMapModel. Instance config takes precedence. */ static defaults: TreeMapModelDefaults = { algorithm: 'squarified', maxNodes: 1000 }; //------------------------ // Immutable public properties //------------------------ store: Store; gridModel: GridModel; maxNodes: number; maxLabels: number; valueRenderer: TreeMapValueRendererFn; heatRenderer: TreeMapHeatRendererFn; onClick: (StoreRecord, MouseEvent) => void; onDoubleClick: (StoreRecord, MouseEvent) => void; tooltip: boolean | TreeMapTooltipFn; emptyText: ReactNode; //------------------------ // Observable API //------------------------ @bindable.ref highchartsConfig: any = {}; @observable.ref data: TreeMapRecord[] = []; @bindable labelField: string; @bindable valueField: string; @bindable heatField: string; @bindable maxHeat: number; @bindable maxDepth: number; @bindable algorithm: TreeMapAlgorithm; @bindable colorMode: TreeMapColorMode; @bindable theme: Theme; @bindable isMasking: boolean; _filter; constructor(config?: TreeMapConfig) { super(); makeObservable(this); const { store, gridModel, maxNodes = TreeMapModel.defaults.maxNodes, maxLabels = 250, highchartsConfig = {}, labelField = 'name', valueField = 'value', heatField = 'value', valueRenderer = numberRenderer({asHtml: true}) as TreeMapValueRendererFn, heatRenderer = numberRenderer({asHtml: true}) as TreeMapValueRendererFn, maxHeat, maxDepth, algorithm = TreeMapModel.defaults.algorithm, colorMode = 'linear', theme, onClick, onDoubleClick, tooltip = true, emptyText, filter }: TreeMapConfig = config ?? {}; this.gridModel = gridModel; this.store = store ? store : gridModel ? gridModel.store : null; throwIf(!this.store, 'TreeMapModel requires either a Store or a GridModel'); this.maxNodes = maxNodes; this.maxLabels = maxLabels; this.highchartsConfig = highchartsConfig; this.labelField = labelField; this.valueField = valueField; this.heatField = heatField; this.valueRenderer = valueRenderer; this.heatRenderer = heatRenderer; this.maxHeat = maxHeat; this.maxDepth = maxDepth; throwIf( !['sliceAndDice', 'stripes', 'squarified', 'strip'].includes(algorithm), `Algorithm "${algorithm}" not recognised.` ); this.algorithm = algorithm; throwIf( !['linear', 'wash', 'none'].includes(colorMode), `Color mode "${colorMode}" not recognised.` ); this.colorMode = colorMode; throwIf( theme && !['system', 'light', 'dark'].includes(theme), `Theme "${theme}" not recognised.` ); this.theme = theme; this.onClick = withDefault(onClick, this.defaultOnClick); this.onDoubleClick = withDefault(onDoubleClick, this.defaultOnDoubleClick); this.tooltip = tooltip; this.emptyText = emptyText; this._filter = filter; this.addReaction({ track: () => [ this.store.rootRecords, this.expandState, this.colorMode, this.labelField, this.valueField, this.heatField, this.maxDepth, this.maxHeat ], run: ([rawData]) => { this.processAndSetData(rawData); }, debounce: 100, fireImmediately: true }); } @computed get total(): number { const {valueField} = this; return sumBy(this.store.rootRecords, record => { if (this._filter && !this._filter(record)) return 0; return Math.abs(record.data[valueField]); }); } @computed get valueFieldLabel(): string { const field = this.store.getField(this.valueField); return field ? field.displayName : this.valueField; } @computed get heatFieldLabel(): string { const field = this.store.getField(this.heatField); return field ? field.displayName : this.heatField; } get selectedIds(): StoreRecordId[] { return this.gridModel?.selModel.selectedIds ?? []; } @computed get expandState(): PlainObject { const {gridModel} = this; return gridModel?.treeMode ? gridModel.expandState : {}; } @computed get empty(): boolean { return isEmpty(this.data); } @computed get error(): string { return this.data.length > this.maxNodes ? 'Data node limit reached. Unable to render TreeMap.' : null; } //------------------------- // Data //------------------------- @action processAndSetData(sourceRecords) { let data = this.processRecordsRecursive(sourceRecords); data = this.limitLabels(data); this.data = this.normaliseColorValues(data); } /** * Create a flat list of TreeMapRecords from hierarchical store data, ready to be * passed to HighCharts for rendering. Drilldown children are included according * to the bound GridModel's expandState. */ processRecordsRecursive(sourceRecords, parentId = null, depth = 1): Partial[] { const {labelField, valueField, heatField, maxDepth} = this, ret = []; sourceRecords.forEach(record => { const {id, children, data, treePath} = record, name = data[labelField], value = data[valueField], heatValue = data[heatField]; // Skip records without value if (!value) return; // Create TreeMapRecord const treeRec: Partial = { id, record, name, heatValue, value: Math.abs(value) }; if (parentId) treeRec.parent = parentId; // Process children if: // a) There are children // b) This node is expanded // c) The children do not exceed any specified maxDepth let childTreeRecs = []; if (children && this.nodeIsExpanded(treePath) && (!maxDepth || depth < maxDepth)) { childTreeRecs = this.processRecordsRecursive(children, id, depth + 1); } // Include record and its children if: // a) There is no filter // b) There is a filter and the record passes it // c) There is a filter and any of its children passes it if (!this._filter || this._filter(record) || childTreeRecs.length) { ret.push(treeRec); ret.push(...childTreeRecs); } }); return ret; } /** * Removes node labels if the number of nodes exceeds maxLabels. * This is a performance optimisation to mitigate slow SVG text rendering. Labels are prioritized * according to node size, from largest to smallest, on the basis that removed labels would * likely be hidden due to the size of the node. */ limitLabels(data) { const {maxLabels} = this; if (data.length <= maxLabels) return data; const ret = sortBy(data, it => -it.value); for (let i = maxLabels; i < ret.length; i++) { delete ret[i].name; } return ret; } /** * Normalizes heatValues to colorValues between 0-1, where 0 is the maximum negative heat, * 1 is the maximum positive heat, and 0.5 is no heat. This allows the colorValue to map to * the colorAxis provided to Highcharts. * * Takes the colorMode into account: * a) 'linear' distributes normalized color values across the colorAxis. * b) 'wash' ignores the intensity of the heat value, applying a flat positive or negative color. * c) 'none' ignores the heat value altogether, coloring all nodes with the neutral color */ normaliseColorValues(data) { const {colorMode, heatField, valueField} = this; //--------------------- // ColorMode === 'none' //--------------------- if (!data.length || colorMode === 'none') { data.forEach(it => (it.colorValue = 0.5)); return data; } //--------------------- // ColorMode === 'wash' //--------------------- if (colorMode === 'wash') { data.forEach(it => { const {heatValue, record} = it, isValid = this.valueIsValid(heatValue); if (!isValid) { it.colorValue = 0.5; return; } // If heatValue equals 0, defer to the value for sign to determine color. const checkValue = heatValue !== 0 ? heatValue : record.data[valueField]; it.colorValue = checkValue !== 0 ? (checkValue > 0 ? 0.8 : 0.2) : 0.5; }); return data; } //------------------------ // ColorMode === 'linear' //------------------------ // 1) Extract valid heat values const heatValues = []; this.store.records.forEach(it => { const val = it.get(heatField); if (this.valueIsValid(val)) heatValues.push(Math.abs(val)); }); // 2) Transform heatValue into a normalized colorValue, according to the colorMode. const maxHeat = isFinite(this.maxHeat) ? this.maxHeat : max(heatValues); data.forEach(it => { const {heatValue, record} = it; if (!this.valueIsValid(heatValue)) { it.colorValue = 0.5; return; } // If heatValue equals 0, defer to the value for sign to determine color. const checkValue = heatValue !== 0 ? heatValue : record.data[valueField]; if (checkValue > 0) { // Normalize positive values between 0.6-1 it.colorValue = this.normalizeToRange(heatValue, 0, maxHeat, 0.6, 1); } else if (checkValue < 0) { // Normalize negative values between 0-0.4 it.colorValue = this.normalizeToRange(Math.abs(heatValue), maxHeat, 0, 0, 0.4); } else { it.colorValue = 0.5; } }); return data; } normalizeToRange(value, fromMin, fromMax, toMin, toMax) { const fromRange = fromMax - fromMin, toRange = toMax - toMin; return ((value - fromMin) * toRange) / fromRange + toMin; } valueIsValid(value) { return isFinite(value); } //---------------------- // Expand / Collapse //---------------------- nodeIsExpanded(treePath) { if (isEmpty(this.expandState)) return false; return get(this.expandState, treePath, false); } toggleNodeExpanded(treePath) { const {gridModel} = this, expandState = cloneDeep(gridModel.expandState); if (get(expandState, treePath)) { unset(expandState, treePath); } else { set(expandState, treePath, true); } gridModel.setExpandState(expandState); } //---------------------- // Click handling //---------------------- defaultOnClick = (record, e) => { const {gridModel} = this; if (!gridModel || !record) return; // Select nodes in grid const {selModel} = gridModel; if (selModel.mode === 'disabled') return; const multiSelect = selModel.mode === 'multiple' && e.shiftKey; selModel.select(record, !multiSelect); gridModel.ensureSelectionVisibleAsync(); }; defaultOnDoubleClick = record => { if (!this.gridModel?.treeMode || isEmpty(record?.children)) return; this.toggleNodeExpanded(record.treePath); }; } export interface TreeMapRecord { /** StoreRecordId of the StoreRecord from which TreeMapRecord was created. */ id: StoreRecordId; /** StoreRecord from which TreeMapRecord was created. */ record: StoreRecord; /** Used by Highcharts to determine the node label. */ name: string; /** Used by Highcharts to determine the node size. */ value: number; /** Transient property used to determine the Highcharts colorValue. */ heatValue: number; /** Used by Highcharts to determine the color in a heat map. */ colorValue: number; /** StoreRecordId of parent StoreRecord in hierarchical data. */ parent?: StoreRecordId; } /** Layout algorithm to use. */ export type TreeMapAlgorithm = 'squarified' | 'sliceAndDice' | 'stripes' | 'strip'; /** Heat color distribution mode. */ export type TreeMapColorMode = 'linear' | 'wash' | 'none'; /** * Normalized renderer function to display value in the tree map tooltip. * @returns the formatted value (html) for display. */ export type TreeMapValueRendererFn = (value: any, record: StoreRecord) => string; /** * Normalized renderer function to display heat in the tree map tooltip. * @returns the formatted value (html) for display. */ export type TreeMapHeatRendererFn = (value: any, record: StoreRecord) => string; /** * Normalized renderer function to produce a tree map tooltip. * @returns the formatted value (html) for display. */ export type TreeMapTooltipFn = (record: StoreRecord) => string;