blob: bbb420978e33227818657128464a023de76c535e [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import type {EChartsCoreOption} from 'echarts/core';
import {formatNumber} from './chart_utils';
import {
EChartView,
type EChartEventHandler,
type EChartClickParams,
} from './echart_view';
import {buildTooltipOption} from './chart_option_builder';
/**
* A node in the treemap hierarchy.
*/
export interface TreemapNode {
/** Display name for this node */
readonly name: string;
/** Size value (determines rectangle area) */
readonly value: number;
/** Optional children for hierarchical treemaps */
readonly children?: readonly TreemapNode[];
}
/**
* Data provided to a TreemapChart.
*/
export interface TreemapData {
/** Top-level nodes (can have nested children) */
readonly nodes: readonly TreemapNode[];
}
export interface TreemapChartAttrs {
/**
* Treemap data to display, or undefined if loading.
* When undefined, a loading spinner is shown.
*/
readonly data: TreemapData | undefined;
/**
* Height of the chart in pixels. Defaults to 200.
*/
readonly height?: number;
/**
* Fill parent container. Defaults to false.
*/
readonly fillParent?: boolean;
/**
* Custom class name for the container.
*/
readonly className?: string;
/**
* Format function for values in tooltips.
*/
readonly formatValue?: (value: number) => string;
/**
* Callback when a node is clicked.
*/
readonly onNodeClick?: (node: TreemapNode) => void;
/**
* Minimum visible rectangle size. Nodes smaller than this are hidden.
* Defaults to 10.
*/
readonly visibleMin?: number;
/**
* Show labels on rectangles. Defaults to true.
*/
readonly showLabels?: boolean;
/**
* Enable drill-down on click. Defaults to false.
* When true, clicking a parent node zooms into it.
*/
readonly enableDrillDown?: boolean;
}
export class Treemap implements m.ClassComponent<TreemapChartAttrs> {
view({attrs}: m.Vnode<TreemapChartAttrs>) {
const {data, height, fillParent, className} = attrs;
const isEmpty = data !== undefined && data.nodes.length === 0;
const option =
data !== undefined && !isEmpty
? buildTreemapOption(attrs, data)
: undefined;
return m(EChartView, {
option,
height,
fillParent,
className,
empty: isEmpty,
eventHandlers: buildTreemapEventHandlers(attrs, data),
});
}
}
function buildTreemapOption(
attrs: TreemapChartAttrs,
data: TreemapData,
): EChartsCoreOption {
const {
formatValue = (v: number) => formatNumber(v),
visibleMin = 10,
showLabels = true,
enableDrillDown = false,
} = attrs;
const total = data.nodes.reduce((sum, n) => sum + computeTotal(n), 0);
return {
animation: false,
tooltip: buildTooltipOption({
trigger: 'item' as const,
formatter: (params: {name?: string; value?: number}) => {
const name = params.name ?? '';
const value = params.value ?? 0;
const pct = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
return [name, `Value: ${formatValue(value)}`, `${pct}%`].join('<br>');
},
}),
series: [
{
type: 'treemap',
data: convertNodes(data.nodes),
// Series-level insets (NOT grid): treemap has no axes, so fill the
// full container instead of leaving axis-placeholder padding.
left: 0,
right: 0,
top: 0,
bottom: 0,
roam: enableDrillDown ? 'move' : false,
nodeClick: enableDrillDown ? 'zoomToNode' : false,
visibleMin,
label: {
show: showLabels,
formatter: '{b}',
fontSize: 11,
},
itemStyle: {
borderWidth: 2,
gapWidth: 2,
},
breadcrumb: enableDrillDown
? {
show: true,
}
: {show: false},
levels: [
{
// Level 0: parent groups
itemStyle: {
borderWidth: 3,
gapWidth: 3,
},
upperLabel: {
show: true,
height: 20,
fontSize: 12,
fontWeight: 'bold' as const,
},
},
{
// Level 1: children
colorSaturation: [0.35, 0.65],
itemStyle: {
borderWidth: 1,
gapWidth: 1,
},
},
{
// Level 2+: deeper children (if any)
colorSaturation: [0.25, 0.55],
itemStyle: {
borderWidth: 1,
gapWidth: 1,
},
},
],
},
],
};
}
/**
* Convert TreemapNode tree to ECharts data format.
*/
function convertNodes(
nodes: readonly TreemapNode[],
): Array<Record<string, unknown>> {
return nodes.map((node) => {
const children = node.children;
const hasChildren = children !== undefined && children.length > 0;
// For nodes with children, use computed value from children if value is 0
const value =
hasChildren && node.value === 0 ? computeTotal(node) : node.value;
const result: Record<string, unknown> = {
name: node.name,
value,
};
if (hasChildren) {
result.children = convertNodes(children);
}
return result;
});
}
/**
* Compute total value including children.
*/
function computeTotal(node: TreemapNode): number {
if (node.children !== undefined && node.children.length > 0) {
return node.children.reduce((sum, c) => sum + computeTotal(c), 0);
}
return node.value;
}
function buildTreemapEventHandlers(
attrs: TreemapChartAttrs,
data: TreemapData | undefined,
): ReadonlyArray<EChartEventHandler> {
if (!attrs.onNodeClick || data === undefined) return [];
// Build name-to-node mapping for click handler
const nodeMap = new Map<string, TreemapNode>();
buildNodeMap(data.nodes, nodeMap);
const onNodeClick = attrs.onNodeClick;
return [
{
eventName: 'click',
handler: (params) => {
const p = params as EChartClickParams;
if (p.name !== undefined) {
const node = nodeMap.get(p.name);
if (node !== undefined) {
onNodeClick(node);
}
}
},
},
];
}
/**
* Recursively build a name-to-node map for click handler lookups.
* First occurrence wins when names collide across tree levels.
*/
function buildNodeMap(
nodes: readonly TreemapNode[],
nodeMap: Map<string, TreemapNode>,
): void {
for (const node of nodes) {
if (!nodeMap.has(node.name)) {
nodeMap.set(node.name, node);
}
if (node.children !== undefined) {
buildNodeMap(node.children, nodeMap);
}
}
}