| // 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))); |
| } |
| } |