blob: f2ee696d302a7ca460e93b1f2a1842ff9c0f6501 [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 ThemeColors, EChartView} from './echart_view';
import type {SingleValueData} from './single_value_loader';
export interface GaugeAttrs {
/** The aggregated data to display, or undefined if loading. */
readonly data: SingleValueData | undefined;
/** Whether the data is still being fetched. */
readonly isPending: boolean;
/** Label to display below the value. */
readonly label: string;
/** Height of the chart in pixels. Defaults to 300. */
readonly height?: number;
/** Fill parent container. Defaults to false. */
readonly fillParent?: boolean;
/** Minimum value of the gauge scale. Defaults to 0. */
readonly min?: number;
/** Maximum value of the gauge scale. Defaults to 100. */
readonly max?: number;
/**
* Gauge diameter as a CSS-style percentage string (e.g. '80%').
* Controls the radius of the gauge arc relative to the container.
* Defaults to '75%'.
*/
readonly diameter?: string;
/** Custom formatter for the displayed value. */
readonly formatValue?: (value: number) => string;
}
/**
* A gauge widget that displays a single aggregated value as an ECharts
* gauge with arc, progress, and pointer.
*/
export class Gauge implements m.ClassComponent<GaugeAttrs> {
view({attrs}: m.CVnode<GaugeAttrs>) {
const {data, isPending} = attrs;
const height = attrs.height ?? 300;
const fillParent = attrs.fillParent ?? false;
const option = buildGaugeOption(attrs, data);
return m(EChartView, {
option: isPending && data === undefined ? undefined : option,
resolveOption: applyGaugeTheme,
height,
fillParent,
empty: !isPending && data === undefined,
});
}
}
/**
* Applies theme-aware colors to gauge axis line track.
* The ECharts theme system does not cover gauge-specific properties like
* axisLine.lineStyle.color, so we resolve them from the Perfetto theme.
*/
interface GaugeSeries {
type?: string;
axisLine?: {lineStyle?: {color?: unknown}};
splitLine?: {lineStyle?: {color?: unknown}};
axisLabel?: {color?: unknown};
anchor?: {itemStyle?: {borderColor?: unknown}};
title?: {color?: unknown};
detail?: {color?: unknown};
}
function applyGaugeTheme(
option: EChartsCoreOption,
colors: ThemeColors,
): EChartsCoreOption {
const series = (option as {series?: unknown[]}).series;
if (!Array.isArray(series) || series.length === 0) return option;
// Mutate in-place: the option is freshly built by buildGaugeOption() on
// every render, so there is no shared-reference risk. structuredClone is
// not viable here because the option contains function values (formatter).
const gauge = series[0] as GaugeSeries;
if (gauge.type !== 'gauge') return option;
if (gauge.axisLine?.lineStyle) {
gauge.axisLine.lineStyle.color = [[1, colors.borderColor]];
}
if (gauge.splitLine?.lineStyle) {
gauge.splitLine.lineStyle.color = colors.textColor;
}
if (gauge.axisLabel) {
gauge.axisLabel.color = colors.textColor;
}
if (gauge.anchor?.itemStyle) {
gauge.anchor.itemStyle.borderColor = colors.accentColor;
}
if (gauge.title) {
gauge.title.color = colors.textColor;
}
if (gauge.detail) {
gauge.detail.color = colors.textColor;
}
return option;
}
function buildGaugeOption(
attrs: GaugeAttrs,
data: SingleValueData | undefined,
): EChartsCoreOption {
const value = data?.value ?? 0;
const min = attrs.min ?? 0;
const max = attrs.max ?? 100;
const formatter = attrs.formatValue ?? formatGaugeValue;
const formatted = formatter(value);
const diameter = attrs.diameter ?? '75%';
return {
series: [
{
type: 'gauge',
radius: diameter,
// Shifted up to visually center the arc + value/label text below.
// The 270° arc has a gap at the bottom, while value (70%) and
// label (90%) text extend below the center point.
center: ['50%', '45%'],
min,
max,
progress: {
show: true,
width: 18,
},
axisLine: {
lineStyle: {
width: 18,
// Placeholder — replaced by applyGaugeTheme with the actual
// theme border color.
color: [[1, '#e6ebf8']],
},
},
axisTick: {show: false},
splitLine: {
length: 15,
lineStyle: {
width: 2,
// Placeholder — replaced by applyGaugeTheme.
color: '#999',
},
},
axisLabel: {
distance: 25,
// Placeholder — replaced by applyGaugeTheme.
color: '#999',
fontSize: 14,
},
anchor: {
show: true,
showAbove: true,
size: 25,
itemStyle: {
borderWidth: 10,
borderColor: '#999',
},
},
pointer: {show: true},
title: {
show: true,
offsetCenter: [0, '90%'],
},
detail: {
valueAnimation: true,
fontSize: 40,
offsetCenter: [0, '70%'],
formatter: () => formatted,
fontWeight: 700,
},
data: [{value, name: attrs.label}],
},
],
};
}
function formatGaugeValue(value: number): string {
if (Number.isInteger(value)) return value.toLocaleString();
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}