blob: 5e4f4964e57511ba6ce2144117463a781d10e2ed [file] [edit]
// 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 {extractBrushRect, formatNumber} from './chart_utils';
import {EChartView, type EChartEventHandler} from './echart_view';
import {
buildAxisOption,
buildBrushOption,
buildGridOption,
buildTooltipOption,
SELECTION_COLOR,
} from './chart_option_builder';
/**
* Data provided to a HeatmapChart.
* The grid is defined by xLabels, yLabels, and a values matrix.
*/
export interface HeatmapData {
/** Labels for the X axis (columns). */
readonly xLabels: readonly string[];
/** Labels for the Y axis (rows). */
readonly yLabels: readonly string[];
/**
* Values as [xIndex, yIndex, value] triples.
* Missing entries are treated as 0.
*/
readonly values: ReadonlyArray<readonly [number, number, number]>;
/** Minimum value in the dataset (for color scale). */
readonly min: number;
/** Maximum value in the dataset (for color scale). */
readonly max: number;
}
export interface HeatmapAttrs {
/**
* Heatmap data to display, or undefined if loading.
* When undefined, a loading spinner is shown.
*/
readonly data: HeatmapData | undefined;
/**
* Height of the chart in pixels. Defaults to 300.
*/
readonly height?: number;
/**
* Label for the X axis.
*/
readonly xAxisLabel?: string;
/**
* Label for the Y axis.
*/
readonly yAxisLabel?: string;
/**
* Fill parent container. Defaults to false.
*/
readonly fillParent?: boolean;
/**
* Custom class name for the container.
*/
readonly className?: string;
/**
* Format function for cell values.
*/
readonly formatValue?: (value: number) => string;
/**
* Callback when brush selection completes (on mouseup).
* Called with the x and y labels of all cells in the brushed rectangle.
*/
readonly onBrush?: (selection: {
xLabels: string[];
yLabels: string[];
}) => void;
/**
* Selection to highlight on the chart. Cells at the intersection of
* the selected xLabels and yLabels are drawn with a highlight color.
* The consumer controls this state — typically by feeding the
* `onBrush` output back in.
*/
readonly selection?: {
readonly xLabels: ReadonlyArray<string>;
readonly yLabels: ReadonlyArray<string>;
};
}
export class HeatmapChart implements m.ClassComponent<HeatmapAttrs> {
view({attrs}: m.CVnode<HeatmapAttrs>) {
const {data, height = 300, fillParent, className, onBrush} = attrs;
const isEmpty =
data !== undefined &&
(data.values.length === 0 ||
data.xLabels.length === 0 ||
data.yLabels.length === 0);
const option =
data !== undefined && !isEmpty
? buildHeatmapOption(attrs, data)
: undefined;
return m(EChartView, {
option,
height,
fillParent,
className,
empty: isEmpty,
eventHandlers: buildHeatmapEventHandlers(attrs, data),
activeBrushType: onBrush !== undefined ? 'rect' : undefined,
});
}
}
function buildHeatmapOption(
attrs: HeatmapAttrs,
data: HeatmapData,
): EChartsCoreOption {
const {xAxisLabel, yAxisLabel, formatValue} = attrs;
const fmtVal = formatValue ?? formatNumber;
const option: Record<string, unknown> = {
animation: false,
grid: buildGridOption({right: 80}),
xAxis: {
...buildAxisOption(
{
type: 'category',
data: [...data.xLabels],
name: xAxisLabel,
labelOverflow: 'truncate',
labelWidth: 60,
},
true,
),
splitArea: {show: true},
},
yAxis: {
...buildAxisOption(
{
type: 'category',
data: [...data.yLabels],
name: yAxisLabel,
labelOverflow: 'truncate',
labelWidth: 80,
},
false,
),
splitArea: {show: true},
},
tooltip: buildTooltipOption({
trigger: 'item',
formatter: (params: {
value?: [number, number, number];
name?: string;
}) => {
const v = params.value;
if (v === undefined) return '';
const xLabel = data.xLabels[v[0]] ?? '';
const yLabel = data.yLabels[v[1]] ?? '';
return [`${xLabel} / ${yLabel}`, `Value: ${fmtVal(v[2])}`].join('<br>');
},
}),
visualMap: {
min: data.min,
max: data.max,
calculable: true,
orient: 'vertical',
right: 0,
top: 'center',
textStyle: {fontSize: 10},
},
series: [
{
type: 'heatmap',
data: buildHeatmapSeriesData(data, attrs.selection),
label: {show: false},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
};
if (attrs.onBrush) {
option.brush = buildBrushOption({
xAxisIndex: 0,
yAxisIndex: 0,
brushType: 'rect',
});
option.toolbox = {show: false};
}
return option as EChartsCoreOption;
}
function buildHeatmapSeriesData(
data: HeatmapData,
selection: HeatmapAttrs['selection'],
): Array<Record<string, unknown>> {
const xSet = selection !== undefined ? new Set(selection.xLabels) : undefined;
const ySet = selection !== undefined ? new Set(selection.yLabels) : undefined;
return data.values.map((triple) => {
const [xIdx, yIdx, value] = triple;
const item: Record<string, unknown> = {value: [xIdx, yIdx, value]};
if (xSet !== undefined && ySet !== undefined) {
const xLabel = data.xLabels[xIdx];
const yLabel = data.yLabels[yIdx];
if (
xLabel !== undefined &&
yLabel !== undefined &&
xSet.has(xLabel) &&
ySet.has(yLabel)
) {
item.itemStyle = {
color: SELECTION_COLOR,
borderColor: 'rgba(0, 120, 212, 0.6)',
borderWidth: 2,
};
}
}
return item;
});
}
function buildHeatmapEventHandlers(
attrs: HeatmapAttrs,
data: HeatmapData | undefined,
): ReadonlyArray<EChartEventHandler> {
if (
!attrs.onBrush ||
data === undefined ||
data.xLabels.length === 0 ||
data.yLabels.length === 0
) {
return [];
}
const onBrush = attrs.onBrush;
const xLabels = data.xLabels;
const yLabels = data.yLabels;
return [
{
eventName: 'brushEnd',
handler: (params) => {
const range = extractBrushRect(params);
if (range !== undefined) {
const xMin = Math.max(0, Math.round(range.xMin));
const xMax = Math.min(xLabels.length - 1, Math.round(range.xMax));
const yMin = Math.max(0, Math.round(range.yMin));
const yMax = Math.min(yLabels.length - 1, Math.round(range.yMax));
if (xMin <= xMax && yMin <= yMax) {
const selectedX: string[] = [];
for (let i = xMin; i <= xMax; i++) {
selectedX.push(xLabels[i]);
}
const selectedY: string[] = [];
for (let i = yMin; i <= yMax; i++) {
selectedY.push(yLabels[i]);
}
if (selectedX.length > 0 && selectedY.length > 0) {
onBrush({xLabels: selectedX, yLabels: selectedY});
}
}
}
},
},
];
}