blob: 825ee2f3347f07f21e294749e212ef749bd728d4 [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 {
type ChartAggregation,
extractBrushRange,
formatNumber,
percentile,
} from './chart_utils';
import {EChartView, type EChartEventHandler} from './echart_view';
import type {LegendPosition} from './common';
import {
buildAxisOption,
buildGridOption,
buildBrushOption,
buildLegendOption,
buildTooltipOption,
SELECTION_COLOR,
} from './chart_option_builder';
/**
* A single bar in the bar chart.
*/
export interface BarChartItem {
/** Label for this bar (displayed on the dimension axis). */
readonly label: string | number;
/** Numeric value for this bar. */
readonly value: number;
}
/**
* A named series of bars (used for stacked/grouped bar charts).
*/
export interface BarChartSeries {
/** Display name for this series (shown in legend). */
readonly name: string;
/** Bars in this series. */
readonly items: readonly BarChartItem[];
}
/**
* Data provided to a BarChart.
*
* Use `items` for a simple single-series bar chart, or `series` for
* stacked/grouped bar charts. When `series` is provided, `items` is ignored.
*/
export interface BarChartData {
/** The bars to display (single series). */
readonly items: readonly BarChartItem[];
/** Multiple named series for stacked bar charts. */
readonly series?: readonly BarChartSeries[];
}
export interface BarChartAttrs {
/**
* Bar chart data to display, or undefined if loading.
* When undefined, a loading spinner is shown.
*/
readonly data: BarChartData | undefined;
/**
* Height of the chart in pixels. Defaults to 200.
*/
readonly height?: number;
/**
* Label for the dimension axis (the categorical/label axis).
* Placed on the X axis in vertical mode, Y axis in horizontal mode.
*/
readonly dimensionLabel?: string;
/**
* Label for the measure axis (the numeric value axis).
* Placed on the Y axis in vertical mode, X axis in horizontal mode.
*/
readonly measureLabel?: string;
/**
* Fill parent container. Defaults to false.
*/
readonly fillParent?: boolean;
/**
* Custom class name for the container.
*/
readonly className?: string;
/**
* Format function for dimension axis tick values (bar labels).
* When provided, this function is used to format the label of each bar.
*/
readonly formatDimension?: (value: string | number) => string;
/**
* Format function for measure axis tick values.
*/
readonly formatMeasure?: (value: number) => string;
/**
* Bar color. Defaults to theme primary color.
*/
readonly barColor?: string;
/**
* Bar hover color. Defaults to theme accent color.
*/
readonly barHoverColor?: string;
/**
* Use logarithmic scale for the measure axis. Defaults to false.
*/
readonly logScale?: boolean;
/**
* When true, measure axis ticks will be snapped to integer values.
*/
readonly integerMeasure?: boolean;
/**
* Chart orientation. Defaults to 'vertical'.
* - 'vertical': bars grow upward, dimension on X axis, measure on Y axis.
* - 'horizontal': bars grow rightward, dimension on Y axis, measure on X.
*/
readonly orientation?: 'vertical' | 'horizontal';
/**
* Show grid lines. 'horizontal' draws lines parallel to the X axis,
* 'vertical' draws lines parallel to the Y axis, 'both' shows both.
* Defaults to no grid lines.
*/
readonly gridLines?: 'horizontal' | 'vertical' | 'both';
/**
* Callback when brush selection completes (on mouseup).
* Called with the labels of all bars in the brushed range.
*/
readonly onBrush?: (labels: Array<string | number>) => void;
/**
* Selection labels to highlight on the chart. Bars whose labels are in
* this array are drawn with a highlight color. The consumer controls
* this state — typically by feeding the `onBrush` output back in.
*/
readonly selection?: ReadonlyArray<string | number>;
/**
* Where the legend sits relative to the chart (stacked charts only).
* Defaults to 'top'.
*/
readonly legendPosition?: LegendPosition;
}
export class BarChart implements m.ClassComponent<BarChartAttrs> {
view({attrs}: m.Vnode<BarChartAttrs>) {
const {data, height, fillParent, className, onBrush, orientation} = attrs;
const horizontal = orientation === 'horizontal';
const isStacked = data?.series !== undefined && data.series.length > 0;
const isEmpty = data !== undefined && !isStacked && data.items.length === 0;
const option =
data !== undefined && !isEmpty ? buildBarOption(attrs, data) : undefined;
return m(EChartView, {
option,
height,
fillParent,
className,
empty: isEmpty,
eventHandlers: buildBarEventHandlers(attrs, data),
activeBrushType:
onBrush !== undefined ? (horizontal ? 'lineY' : 'lineX') : undefined,
});
}
}
function buildBarOption(
attrs: BarChartAttrs,
data: BarChartData,
): EChartsCoreOption {
const {
dimensionLabel,
measureLabel = 'Value',
formatDimension,
formatMeasure,
barColor,
barHoverColor,
logScale = false,
integerMeasure = false,
orientation = 'vertical',
gridLines,
} = attrs;
const fmtDimension = formatDimension ?? String;
const fmtMeasure = formatMeasure ?? formatNumber;
const horizontal = orientation === 'horizontal';
const isStacked = data.series !== undefined && data.series.length > 0;
// Collect unique dimension labels across all series.
const labels = isStacked
? collectStackedLabels(data.series, fmtDimension)
: data.items.map((item) => fmtDimension(item.label));
// Map visual grid line direction to axis splitLine settings.
// Horizontal visual lines come from the Y axis; vertical from the X axis.
// In horizontal bar orientation the axes are swapped, so the mapping flips.
const showXAxisGrid = gridLines === 'vertical' || gridLines === 'both';
const showYAxisGrid = gridLines === 'horizontal' || gridLines === 'both';
const categoryAxis = buildAxisOption(
{
type: 'category',
data: labels,
name: dimensionLabel,
nameGap: horizontal ? 55 : 35,
labelOverflow: 'truncate',
labelWidth: horizontal ? 65 : undefined,
showSplitLine: horizontal ? showYAxisGrid : showXAxisGrid,
},
!horizontal,
);
const valueAxis = buildAxisOption(
{
type: logScale ? 'log' : 'value',
name: measureLabel,
nameGap: horizontal ? 25 : undefined,
formatter:
formatMeasure !== undefined
? (v) => formatMeasure(v as number)
: undefined,
minInterval: integerMeasure ? 1 : undefined,
showSplitLine: horizontal ? showXAxisGrid : showYAxisGrid,
},
horizontal,
);
// Build ECharts series — one per named series for stacked charts, or a
// single series for simple bar charts.
const echartsSeries = isStacked
? buildStackedSeries(data.series, labels, fmtDimension)
: [buildSingleSeries(data, attrs, barColor, barHoverColor)];
const option: Record<string, unknown> = {
animation: false,
grid: buildGridOption(),
tooltip: buildTooltipOption({
trigger: 'axis' as const,
axisPointer: {type: 'shadow' as const},
formatter: isStacked
? undefined
: (params: Array<{name?: string; value?: number}>) => {
const p = Array.isArray(params) ? params[0] : params;
return `${p.name ?? ''}<br>${measureLabel}: ${fmtMeasure(p.value ?? 0)}`;
},
}),
xAxis: horizontal ? valueAxis : categoryAxis,
yAxis: horizontal ? categoryAxis : valueAxis,
legend: isStacked ? buildLegendOption(attrs.legendPosition) : {show: false},
series: echartsSeries,
};
if (attrs.onBrush) {
option.brush = buildBrushOption({
xAxisIndex: horizontal ? undefined : 0,
yAxisIndex: horizontal ? 0 : undefined,
brushType: horizontal ? 'lineY' : 'lineX',
});
// Hide the default brush toolbox; we activate brush programmatically.
option.toolbox = {show: false};
}
return option;
}
/** Build a single ECharts bar series (non-stacked). */
function buildSingleSeries(
data: BarChartData,
attrs: BarChartAttrs,
barColor: string | undefined,
barHoverColor: string | undefined,
): Record<string, unknown> {
return {
type: 'bar',
data: data.items.map((item) => {
const selected =
attrs.selection !== undefined && attrs.selection.includes(item.label);
return {
value: item.value,
...(selected ? {itemStyle: {color: SELECTION_COLOR}} : {}),
};
}),
itemStyle: barColor !== undefined ? {color: barColor} : undefined,
emphasis:
barHoverColor !== undefined
? {itemStyle: {color: barHoverColor}}
: undefined,
};
}
/**
* Collect unique dimension labels across all series, preserving the order
* from the first series they appear in.
*/
function collectStackedLabels(
series: readonly BarChartSeries[],
fmtDimension: (v: string | number) => string,
): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const s of series) {
for (const item of s.items) {
const label = fmtDimension(item.label);
if (!seen.has(label)) {
seen.add(label);
labels.push(label);
}
}
}
return labels;
}
/**
* Collect unique items (by label) across all series, preserving order.
* Used so brush handlers have a flat item list for stacked charts.
*/
function collectAllStackedItems(
series: readonly BarChartSeries[],
): BarChartItem[] {
const seen = new Set<string | number>();
const items: BarChartItem[] = [];
for (const s of series) {
for (const item of s.items) {
if (!seen.has(item.label)) {
seen.add(item.label);
items.push(item);
}
}
}
return items;
}
/** Build ECharts series array for stacked bar charts. */
function buildStackedSeries(
series: readonly BarChartSeries[],
labels: readonly string[],
fmtDimension: (v: string | number) => string,
): Array<Record<string, unknown>> {
return series.map((s) => {
// Build a lookup from formatted label → value for this series.
const valueByLabel = new Map<string, number>();
for (const item of s.items) {
valueByLabel.set(fmtDimension(item.label), item.value);
}
return {
type: 'bar',
name: s.name,
stack: 'total',
data: labels.map((label) => valueByLabel.get(label) ?? 0),
emphasis: {focus: 'series' as const},
};
});
}
function buildBarEventHandlers(
attrs: BarChartAttrs,
data: BarChartData | undefined,
): ReadonlyArray<EChartEventHandler> {
const isStacked = data?.series !== undefined && data.series.length > 0;
if (
!attrs.onBrush ||
data === undefined ||
(!isStacked && data.items.length === 0)
) {
return [];
}
const onBrush = attrs.onBrush;
// For stacked charts, collect all unique labels from all series;
// for single-series charts, use items directly.
const items: readonly BarChartItem[] = isStacked
? collectAllStackedItems(data.series)
: data.items;
return [
{
eventName: 'brushEnd',
handler: (params) => {
// For category axes, coordRange returns category indices
const range = extractBrushRange(params);
if (range !== undefined) {
const [startIdx, endIdx] = range;
const minIdx = Math.max(0, startIdx);
const maxIdx = Math.min(items.length - 1, endIdx);
if (minIdx <= maxIdx) {
const labels: Array<string | number> = [];
for (let i = minIdx; i <= maxIdx; i++) {
labels.push(items[i].label);
}
if (labels.length > 0) {
onBrush(labels);
}
}
}
},
},
];
}
/**
* Aggregate raw data into BarChartData by grouping on a dimension and
* applying an aggregation function to the measure values.
*
* Results are sorted by aggregated value (descending).
*
* @param items The raw data items to aggregate.
* @param dimension Extracts the grouping key (bar label) from each item.
* @param measure Extracts the numeric value from each item.
* @param aggregation The aggregation function to apply per group.
*/
export function aggregateBarChartData<T>(
items: readonly T[],
dimension: (item: T) => string | number,
measure: (item: T) => number,
aggregation: ChartAggregation,
): BarChartData {
const groups = new Map<string | number, number[]>();
for (const item of items) {
const key = dimension(item);
let values = groups.get(key);
if (values === undefined) {
values = [];
groups.set(key, values);
}
values.push(measure(item));
}
const result: BarChartItem[] = [];
for (const [label, values] of groups) {
result.push({label, value: aggregate(values, aggregation)});
}
result.sort((a, b) => b.value - a.value);
return {items: result};
}
function aggregate(values: number[], agg: ChartAggregation): number {
switch (agg) {
case 'ANY':
case 'MIN':
return values.reduce((a, b) => Math.min(a, b), Infinity);
case 'COUNT':
return values.length;
case 'SUM':
return values.reduce((a, b) => a + b, 0);
case 'AVG':
return values.reduce((a, b) => a + b, 0) / values.length;
case 'MAX':
return values.reduce((a, b) => Math.max(a, b), -Infinity);
case 'COUNT_DISTINCT':
return new Set(values).size;
case 'P25':
case 'P50':
case 'P75':
case 'P90':
case 'P95':
case 'P99':
return percentile(values, Number(agg.slice(1)));
}
}