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