blob: 068c63370dc0b1aae01f6e3b9c3d10cdc81c2696 [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 {buildLegendOption, buildTooltipOption} from './chart_option_builder';
import type {LegendPosition} from './common';
/**
* A single slice in the pie chart.
*/
export interface PieChartSlice {
/** Label for this slice */
readonly label: string;
/** Numeric value for this slice */
readonly value: number;
/** Optional custom color for this slice */
readonly color?: string;
}
/**
* Data provided to a PieChart.
*/
export interface PieChartData {
/** The slices to display */
readonly slices: readonly PieChartSlice[];
}
export interface PieChartAttrs {
/**
* Pie chart data to display, or undefined if loading.
* When undefined, a loading spinner is shown.
*/
readonly data: PieChartData | 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;
/**
* Show legend. Defaults to true.
*/
readonly showLegend?: boolean;
/**
* Where the legend sits relative to the chart. Defaults to 'right'.
*/
readonly legendPosition?: LegendPosition;
/**
* Show percentage labels on slices. Defaults to false.
*/
readonly showLabels?: boolean;
/**
* Inner radius ratio for donut chart (0-1). 0 = pie, >0 = donut.
* Defaults to 0 (pie chart).
*/
readonly innerRadiusRatio?: number;
/**
* Callback when a slice is clicked.
*/
readonly onSliceClick?: (slice: PieChartSlice) => void;
}
export class PieChart implements m.ClassComponent<PieChartAttrs> {
view({attrs}: m.Vnode<PieChartAttrs>) {
const {data, height, fillParent, className} = attrs;
const validSlices = data?.slices.filter((s) => s.value > 0) ?? [];
const isEmpty = data !== undefined && validSlices.length === 0;
const option =
validSlices.length > 0 ? buildPieOption(attrs, validSlices) : undefined;
return m(EChartView, {
option,
height,
fillParent,
className,
empty: isEmpty,
eventHandlers: buildPieEventHandlers(attrs, validSlices),
});
}
}
function buildPieOption(
attrs: PieChartAttrs,
slices: readonly PieChartSlice[],
): EChartsCoreOption {
const {
formatValue = (v: number) => formatNumber(v),
showLegend = true,
legendPosition = 'right',
showLabels = false,
innerRadiusRatio = 0,
} = attrs;
const legendOnRight = showLegend && legendPosition === 'right';
const pieData = slices.map((s) => ({
name: s.label,
value: s.value,
itemStyle: s.color !== undefined ? {color: s.color} : undefined,
}));
const outerPct = legendOnRight ? 65 : 75;
const outerRadius = `${outerPct}%`;
const innerRadius = `${Math.round(outerPct * innerRadiusRatio)}%`;
return {
animation: false,
tooltip: buildTooltipOption({
trigger: 'item' as const,
formatter: (params: {
name?: string;
value?: number;
percent?: number;
}) => {
const name = params.name ?? '';
const value = params.value ?? 0;
const pct = params.percent?.toFixed(1) ?? '0';
return [name, `Value: ${formatValue(value)}`, `${pct}%`].join('<br>');
},
}),
legend: showLegend ? buildLegendOption(legendPosition) : {show: false},
series: [
{
type: 'pie',
radius: [innerRadius, outerRadius],
center: legendOnRight ? ['35%', '50%'] : ['50%', '50%'],
data: pieData,
label: {
show: showLabels,
formatter: '{d}%',
fontSize: 10,
},
emphasis: {
scaleSize: 5,
},
itemStyle: {
borderWidth: 2,
},
},
],
};
}
function buildPieEventHandlers(
attrs: PieChartAttrs,
slices: readonly PieChartSlice[],
): ReadonlyArray<EChartEventHandler> {
if (!attrs.onSliceClick || slices.length === 0) return [];
const onSliceClick = attrs.onSliceClick;
return [
{
eventName: 'click',
handler: (params) => {
const p = params as EChartClickParams;
const idx = p.dataIndex;
if (idx !== undefined && idx >= 0 && idx < slices.length) {
onSliceClick(slices[idx]);
}
},
},
];
}