| // 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. |
| |
| /** |
| * Chart option builder utilities. |
| * |
| * These functions build ECharts option objects with a consistent structure. |
| * Theme colors are NOT included here - EChartView applies theme colors |
| * automatically by reading CSS variables from the DOM element. |
| */ |
| |
| import type {EChartsCoreOption} from 'echarts/core'; |
| import {assertUnreachable} from '../../../base/assert'; |
| import type {LegendPosition} from './common'; |
| |
| /** Font size used for axis tick labels across all charts. */ |
| export const AXIS_LABEL_FONT_SIZE = 10; |
| |
| /** |
| * Configuration for an axis in a chart. |
| */ |
| export interface AxisConfig { |
| readonly type: 'value' | 'category' | 'log' | 'time'; |
| readonly name?: string; |
| readonly nameGap?: number; |
| readonly data?: readonly string[]; |
| readonly minInterval?: number; |
| readonly min?: number; |
| readonly max?: number; |
| readonly formatter?: (v: number | string) => string; |
| readonly labelOverflow?: 'truncate'; |
| readonly labelWidth?: number; |
| readonly showSplitLine?: boolean; |
| /** |
| * When true, axis range is computed from data min/max instead of |
| * always including zero. Maps to ECharts `scale: true`. |
| */ |
| readonly scale?: boolean; |
| } |
| |
| /** |
| * Brush configuration for interactive selection. |
| */ |
| export interface BrushConfig { |
| readonly xAxisIndex?: number; |
| readonly yAxisIndex?: number; |
| readonly brushType: 'lineX' | 'lineY' | 'rect'; |
| } |
| |
| /** |
| * Build an axis option from config. |
| * Theme colors are applied by EChartView, so we omit color settings here. |
| * |
| * Only X-axis gets a default `nameGap`. Y-axis `nameGap` is intentionally |
| * left unset when the caller doesn't provide one, so that EChartView's |
| * auto-spacing can measure tick-label width and set it dynamically. |
| */ |
| export function buildAxisOption( |
| config: AxisConfig, |
| isXAxis: boolean, |
| ): Record<string, unknown> { |
| const nameGap = config.nameGap ?? (isXAxis ? 25 : undefined); |
| const axis: Record<string, unknown> = { |
| type: config.type, |
| name: config.name, |
| nameLocation: 'middle' as const, |
| ...(nameGap !== undefined && {nameGap}), |
| nameTextStyle: {fontSize: 11}, |
| axisLabel: { |
| fontSize: AXIS_LABEL_FONT_SIZE, |
| ...(config.formatter !== undefined && {formatter: config.formatter}), |
| ...(config.labelOverflow !== undefined && { |
| overflow: config.labelOverflow, |
| }), |
| ...(config.labelWidth !== undefined && {width: config.labelWidth}), |
| }, |
| splitLine: { |
| show: config.showSplitLine ?? false, |
| }, |
| }; |
| |
| if (config.type === 'category' && config.data !== undefined) { |
| axis.data = config.data; |
| } |
| if (config.minInterval !== undefined) { |
| axis.minInterval = config.minInterval; |
| } |
| if (config.min !== undefined) { |
| axis.min = config.min; |
| } |
| if (config.max !== undefined) { |
| axis.max = config.max; |
| } |
| if (config.scale === true) { |
| axis.scale = true; |
| } |
| |
| return axis; |
| } |
| |
| /** |
| * Build a grid option. Spacing is auto-computed by EChartView; only pass |
| * explicit overrides for non-standard needs (e.g. heatmap color legend). |
| */ |
| export function buildGridOption(opts?: { |
| top?: number; |
| right?: number; |
| bottom?: number; |
| left?: number; |
| containLabel?: boolean; |
| }): Record<string, unknown> { |
| return { |
| ...(opts?.top !== undefined && {top: opts.top}), |
| ...(opts?.right !== undefined && {right: opts.right}), |
| ...(opts?.bottom !== undefined && {bottom: opts.bottom}), |
| ...(opts?.left !== undefined && {left: opts.left}), |
| containLabel: opts?.containLabel ?? true, |
| }; |
| } |
| |
| /** |
| * Build a tooltip option. |
| * Theme colors are applied by EChartView. |
| * Extra options are merged in (e.g. trigger, formatter). |
| */ |
| export function buildTooltipOption( |
| extra?: Record<string, unknown>, |
| ): Record<string, unknown> { |
| return { |
| showDelay: 0, |
| transitionDuration: 0, |
| ...extra, |
| }; |
| } |
| |
| /** |
| * Build a brush configuration. |
| * Theme colors are applied by EChartView. |
| */ |
| export function buildBrushOption(config: BrushConfig): Record<string, unknown> { |
| return { |
| ...(config.xAxisIndex !== undefined && {xAxisIndex: config.xAxisIndex}), |
| ...(config.yAxisIndex !== undefined && {yAxisIndex: config.yAxisIndex}), |
| brushType: config.brushType, |
| brushMode: 'single' as const, |
| brushStyle: { |
| borderWidth: 1, |
| color: 'rgba(0, 0, 0, 0.1)', |
| }, |
| throttleType: 'debounce' as const, |
| throttleDelay: 100, |
| }; |
| } |
| |
| /** |
| * Build a legend option. |
| * Theme colors are applied by EChartView. |
| */ |
| export function buildLegendOption( |
| position: LegendPosition = 'bottom', |
| ): Record<string, unknown> { |
| switch (position) { |
| case 'top': |
| return { |
| show: true, |
| type: 'scroll', |
| top: 0, |
| textStyle: {fontSize: 10}, |
| }; |
| case 'bottom': |
| return { |
| show: true, |
| type: 'scroll', |
| bottom: 0, |
| textStyle: {fontSize: 10}, |
| }; |
| case 'right': |
| return { |
| show: true, |
| type: 'scroll', |
| orient: 'vertical', |
| right: 0, |
| top: 20, |
| bottom: 20, |
| textStyle: { |
| fontSize: 10, |
| width: 120, |
| overflow: 'truncate', |
| ellipsis: '\u2026', |
| }, |
| tooltip: {show: true}, |
| pageButtonPosition: 'end', |
| }; |
| default: |
| assertUnreachable(position); |
| } |
| } |
| |
| /** |
| * Color used to highlight selected items (bars, buckets) in charts. |
| */ |
| export const SELECTION_COLOR = 'rgba(0, 120, 212, 0.45)'; |
| |
| /** |
| * Build a markArea option to visually highlight a selection region. |
| * Each entry in `areas` is a pair of coordinate objects that define |
| * opposite corners of the highlighted rectangle. |
| * |
| * Coordinate objects use axis-specific keys (e.g. `{xAxis: 10}` or |
| * `{yAxis: 'label'}`). |
| */ |
| export function buildSelectionMarkArea( |
| areas: Array<[Record<string, unknown>, Record<string, unknown>]>, |
| ): Record<string, unknown> { |
| return { |
| silent: true, |
| itemStyle: { |
| color: 'rgba(0, 120, 212, 0.08)', |
| borderColor: 'rgba(0, 120, 212, 0.3)', |
| borderWidth: 1, |
| }, |
| data: areas, |
| }; |
| } |
| |
| /** |
| * Build a complete base chart option with grid, axes, and optional |
| * tooltip/brush/legend. Charts add their own `series` on top. |
| * |
| * Theme colors (series colors, text colors, etc.) are applied by EChartView |
| * which reads CSS variables from the DOM element. |
| */ |
| export function buildChartOption(config: { |
| readonly grid?: Parameters<typeof buildGridOption>[0]; |
| readonly xAxis: AxisConfig; |
| readonly yAxis: AxisConfig; |
| readonly tooltip?: Record<string, unknown>; |
| readonly brush?: BrushConfig; |
| readonly legend?: Record<string, unknown>; |
| }): EChartsCoreOption { |
| const {grid, xAxis, yAxis, tooltip, brush, legend} = config; |
| |
| const option: Record<string, unknown> = { |
| animation: false, |
| grid: buildGridOption(grid), |
| xAxis: buildAxisOption(xAxis, true), |
| yAxis: buildAxisOption(yAxis, false), |
| tooltip: buildTooltipOption(tooltip), |
| }; |
| |
| if (brush !== undefined) { |
| option.brush = buildBrushOption(brush); |
| // Hide the default brush toolbox; we activate brush programmatically. |
| option.toolbox = {show: false}; |
| } |
| if (legend !== undefined) { |
| option.legend = legend; |
| } |
| |
| return option as EChartsCoreOption; |
| } |
| |
| export type LabelFormatter = (value: number | string) => string; |
| |
| /** Format a label value using an ECharts formatter (function or string). */ |
| export function formatLabel( |
| value: number | string, |
| formatter: LabelFormatter | string | undefined, |
| ): string { |
| if (typeof formatter === 'function') return formatter(value); |
| if (typeof formatter === 'string') { |
| return formatter.replace('{value}', String(value)); |
| } |
| return String(value); |
| } |
| |
| /** Extract representative Y-axis label values for width measurement. */ |
| export function collectYAxisLabels( |
| opt: Record<string, unknown>, |
| ): Array<number | string> { |
| if (Array.isArray(opt.yAxis)) return []; |
| const yAxis = opt.yAxis as Record<string, unknown> | undefined; |
| if (yAxis === undefined) return []; |
| const axisType = (yAxis.type as string) ?? 'value'; |
| |
| if (axisType === 'category') { |
| const data = yAxis.data; |
| if (Array.isArray(data)) return data as string[]; |
| return []; |
| } |
| |
| const series = opt.series; |
| if (!Array.isArray(series)) return []; |
| |
| let min = Infinity; |
| let max = -Infinity; |
| for (const s of series as Array<Record<string, unknown>>) { |
| const data = s.data; |
| if (!Array.isArray(data)) continue; |
| for (const d of data) { |
| if (typeof d === 'number') { |
| if (isFinite(d)) { |
| if (d < min) min = d; |
| if (d > max) max = d; |
| } |
| } else if (Array.isArray(d)) { |
| // Handles [x, y] pairs and boxplot [min, Q1, median, Q3, max]. |
| for (const el of d) { |
| if (typeof el === 'number' && isFinite(el)) { |
| if (el < min) min = el; |
| if (el > max) max = el; |
| } |
| } |
| } else if (typeof d === 'object' && d !== null) { |
| const v = (d as {value?: unknown}).value; |
| if (typeof v === 'number' && isFinite(v)) { |
| if (v < min) min = v; |
| if (v > max) max = v; |
| } |
| } |
| } |
| } |
| |
| if (!isFinite(min) || !isFinite(max)) return []; |
| |
| return generateNiceTicks(min, max, axisType === 'log'); |
| } |
| |
| /** Generate ~5 nice tick values for a range (approximate, for width estimation). */ |
| export function generateNiceTicks( |
| min: number, |
| max: number, |
| isLog: boolean, |
| ): number[] { |
| if (isLog) { |
| const minExp = Math.floor(Math.log10(Math.max(min, 1))); |
| const maxExp = Math.ceil(Math.log10(Math.max(max, 1))); |
| const ticks: number[] = []; |
| for (let e = minExp; e <= maxExp; e++) { |
| ticks.push(Math.pow(10, e)); |
| } |
| return ticks; |
| } |
| |
| const range = max - min; |
| if (range <= 0) return [min]; |
| |
| const roughInterval = range / 5; |
| const magnitude = Math.pow(10, Math.floor(Math.log10(roughInterval))); |
| const residual = roughInterval / magnitude; |
| const niceMultiplier = |
| residual <= 1.5 ? 1 : residual <= 3 ? 2 : residual <= 7 ? 5 : 10; |
| const interval = niceMultiplier * magnitude; |
| |
| const ticks: number[] = []; |
| const start = Math.floor(min / interval) * interval; |
| for (let v = start; v <= max + interval * 0.5; v += interval) { |
| ticks.push(v); |
| } |
| return ticks; |
| } |