| // 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} from './echart_view'; |
| import { |
| buildAxisOption, |
| buildGridOption, |
| buildTooltipOption, |
| } from './chart_option_builder'; |
| |
| /** |
| * A single box in a boxplot chart. |
| */ |
| export interface BoxplotItem { |
| /** Label for this box (category name) */ |
| readonly label: string; |
| /** Minimum value (lower whisker) */ |
| readonly min: number; |
| /** First quartile (Q1 / 25th percentile) */ |
| readonly q1: number; |
| /** Median (Q2 / 50th percentile) */ |
| readonly median: number; |
| /** Third quartile (Q3 / 75th percentile) */ |
| readonly q3: number; |
| /** Maximum value (upper whisker) */ |
| readonly max: number; |
| } |
| |
| /** |
| * Data provided to a BoxplotChart. |
| */ |
| export interface BoxplotData { |
| readonly items: readonly BoxplotItem[]; |
| } |
| |
| export interface BoxplotAttrs { |
| /** |
| * Boxplot data to display, or undefined if loading. |
| * When undefined, a loading spinner is shown. |
| */ |
| readonly data: BoxplotData | undefined; |
| |
| /** |
| * Height of the chart in pixels. Defaults to 200. |
| */ |
| readonly height?: number; |
| |
| /** |
| * Label for the category axis. |
| */ |
| readonly categoryLabel?: string; |
| |
| /** |
| * Label for the value axis. |
| */ |
| readonly valueLabel?: string; |
| |
| /** |
| * Orientation: 'vertical' (categories on X) or 'horizontal' (categories on Y). |
| * Defaults to 'vertical'. |
| */ |
| readonly orientation?: 'vertical' | 'horizontal'; |
| |
| /** |
| * Fill parent container. Defaults to false. |
| */ |
| readonly fillParent?: boolean; |
| |
| /** |
| * Custom class name for the container. |
| */ |
| readonly className?: string; |
| |
| /** |
| * Format function for value axis tick values. |
| */ |
| readonly formatValue?: (value: number) => string; |
| |
| /** |
| * 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'; |
| } |
| |
| export class BoxplotChart implements m.ClassComponent<BoxplotAttrs> { |
| view({attrs}: m.CVnode<BoxplotAttrs>) { |
| const {data, height, fillParent, className} = attrs; |
| |
| const isEmpty = data !== undefined && data.items.length === 0; |
| const option = |
| data !== undefined && !isEmpty |
| ? buildBoxplotOption(attrs, data) |
| : undefined; |
| |
| return m(EChartView, { |
| option, |
| height, |
| fillParent, |
| className, |
| empty: isEmpty, |
| }); |
| } |
| } |
| |
| function buildBoxplotOption( |
| attrs: BoxplotAttrs, |
| data: BoxplotData, |
| ): EChartsCoreOption { |
| const { |
| categoryLabel, |
| valueLabel, |
| orientation = 'vertical', |
| formatValue, |
| gridLines, |
| } = attrs; |
| const fmtVal = formatValue ?? formatNumber; |
| const horizontal = orientation === 'horizontal'; |
| |
| const showXAxisGrid = gridLines === 'vertical' || gridLines === 'both'; |
| const showYAxisGrid = gridLines === 'horizontal' || gridLines === 'both'; |
| |
| const categories = data.items.map((item) => item.label); |
| // ECharts boxplot series data format: [min, Q1, median, Q3, max]. |
| // In tooltip params.value, ECharts prepends the x-axis index, so the |
| // actual values are at indices 1-5. |
| const boxData = data.items.map((item) => [ |
| item.min, |
| item.q1, |
| item.median, |
| item.q3, |
| item.max, |
| ]); |
| |
| const categoryAxis = buildAxisOption( |
| { |
| type: 'category', |
| data: categories, |
| name: categoryLabel, |
| labelOverflow: 'truncate', |
| labelWidth: 80, |
| showSplitLine: horizontal ? showYAxisGrid : showXAxisGrid, |
| }, |
| !horizontal, |
| ); |
| |
| const valueAxis = buildAxisOption( |
| { |
| type: 'value', |
| name: valueLabel, |
| formatter: |
| formatValue !== undefined |
| ? (v: number | string) => fmtVal(v as number) |
| : undefined, |
| scale: true, |
| showSplitLine: horizontal ? showXAxisGrid : showYAxisGrid, |
| }, |
| horizontal, |
| ); |
| |
| const option: Record<string, unknown> = { |
| animation: false, |
| grid: buildGridOption(), |
| xAxis: horizontal ? valueAxis : categoryAxis, |
| yAxis: horizontal ? categoryAxis : valueAxis, |
| tooltip: buildTooltipOption({ |
| trigger: 'item' as const, |
| formatter: (params: {name?: string; value?: number[]}) => { |
| const v = params.value; |
| if (v === undefined) return ''; |
| return [ |
| `<b>${params.name ?? ''}</b>`, |
| `Max: ${fmtVal(v[5] ?? v[4])}`, |
| `Q3: ${fmtVal(v[4] ?? v[3])}`, |
| `Median: ${fmtVal(v[3] ?? v[2])}`, |
| `Q1: ${fmtVal(v[2] ?? v[1])}`, |
| `Min: ${fmtVal(v[1] ?? v[0])}`, |
| ].join('<br>'); |
| }, |
| }), |
| series: [ |
| { |
| type: 'boxplot', |
| data: boxData, |
| }, |
| ], |
| }; |
| |
| return option as EChartsCoreOption; |
| } |