blob: e18b802e47ff5ddce9a0d11d0854d19e32ab1ccf [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.
/**
* 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;
}