| // 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. |
| |
| /** |
| * ECharts integration for Perfetto UI. |
| * |
| * THEME HANDLING: |
| * |
| * Theme colors are read from CSS variables and used to build an ECharts theme |
| * object at chart initialization time. When the user switches themes, the |
| * component detects the color change and reinitializes the chart with the |
| * new theme. |
| * |
| * This approach keeps chart options pure (no embedded theme colors) and |
| * leverages ECharts' native theme system. |
| */ |
| |
| import m from 'mithril'; |
| import './charts.scss'; |
| import { |
| AXIS_LABEL_FONT_SIZE, |
| type LabelFormatter, |
| collectYAxisLabels, |
| formatLabel, |
| } from './chart_option_builder'; |
| import * as echarts from 'echarts/core'; |
| import { |
| BarChart as EBarChart, |
| BoxplotChart as EBoxplotChart, |
| HeatmapChart as EHeatmapChart, |
| LineChart as ELineChart, |
| PieChart as EPieChart, |
| ScatterChart as EScatterChart, |
| TreemapChart as ETreemapChart, |
| SankeyChart as ESankeyChart, |
| GaugeChart as EGaugeChart, |
| } from 'echarts/charts'; |
| import { |
| GridComponent, |
| TooltipComponent, |
| LegendComponent, |
| DataZoomComponent, |
| BrushComponent, |
| ToolboxComponent, |
| VisualMapComponent, |
| MarkAreaComponent, |
| } from 'echarts/components'; |
| import {CanvasRenderer} from 'echarts/renderers'; |
| import type {EChartsType} from 'echarts/core'; |
| import {ensureExists, ensureIsInstance} from '../../../base/assert'; |
| import {classNames} from '../../../base/classnames'; |
| import {SimpleResizeObserver} from '../../../base/resize_observer'; |
| import {Spinner} from '../../../widgets/spinner'; |
| import {type ChartThemeColors, getChartThemeColors} from './chart_theme'; |
| |
| // Re-export for backward compatibility |
| export type {ChartThemeColors as ThemeColors}; |
| |
| let echartsInitialized = false; |
| |
| function ensureEChartsSetup(): void { |
| if (echartsInitialized) return; |
| echartsInitialized = true; |
| echarts.use([ |
| EBarChart, |
| EBoxplotChart, |
| EHeatmapChart, |
| ELineChart, |
| EPieChart, |
| EScatterChart, |
| ETreemapChart, |
| ESankeyChart, |
| EGaugeChart, |
| GridComponent, |
| TooltipComponent, |
| LegendComponent, |
| DataZoomComponent, |
| BrushComponent, |
| ToolboxComponent, |
| VisualMapComponent, |
| MarkAreaComponent, |
| CanvasRenderer, |
| ]); |
| } |
| |
| /** |
| * Build an ECharts theme object from Perfetto theme colors. |
| */ |
| function buildEChartsTheme(colors: ChartThemeColors): Record<string, unknown> { |
| return { |
| color: [...colors.chartColors], |
| backgroundColor: 'transparent', |
| textStyle: { |
| color: colors.textColor, |
| fontFamily: colors.fontFamily, |
| }, |
| title: { |
| textStyle: {color: colors.textColor}, |
| subtextStyle: {color: colors.textColor}, |
| }, |
| legend: { |
| textStyle: {color: colors.textColor}, |
| }, |
| tooltip: { |
| backgroundColor: colors.backgroundColor, |
| borderColor: colors.borderColor, |
| textStyle: {color: colors.textColor}, |
| }, |
| categoryAxis: { |
| axisLine: {lineStyle: {color: colors.borderColor}}, |
| axisTick: {lineStyle: {color: colors.borderColor}}, |
| axisLabel: {color: colors.textColor}, |
| splitLine: {lineStyle: {color: colors.borderColor}}, |
| nameTextStyle: {color: colors.textColor}, |
| }, |
| valueAxis: { |
| axisLine: {lineStyle: {color: colors.borderColor}}, |
| axisTick: {lineStyle: {color: colors.borderColor}}, |
| axisLabel: {color: colors.textColor}, |
| splitLine: {lineStyle: {color: colors.borderColor}}, |
| nameTextStyle: {color: colors.textColor}, |
| }, |
| logAxis: { |
| axisLine: {lineStyle: {color: colors.borderColor}}, |
| axisTick: {lineStyle: {color: colors.borderColor}}, |
| axisLabel: {color: colors.textColor}, |
| splitLine: {lineStyle: {color: colors.borderColor}}, |
| nameTextStyle: {color: colors.textColor}, |
| }, |
| timeAxis: { |
| axisLine: {lineStyle: {color: colors.borderColor}}, |
| axisTick: {lineStyle: {color: colors.borderColor}}, |
| axisLabel: {color: colors.textColor}, |
| splitLine: {lineStyle: {color: colors.borderColor}}, |
| nameTextStyle: {color: colors.textColor}, |
| }, |
| visualMap: { |
| textStyle: {color: colors.textColor}, |
| inRange: { |
| color: [colors.chartColors[0] + '22', colors.chartColors[0]], |
| }, |
| }, |
| }; |
| } |
| |
| // Top padding (grid.top) defaults. |
| const LEGEND_TOP = 30; |
| const TICK_LABEL_TOP = 10; // Prevents topmost tick label clipping. |
| |
| // Horizontal spacing constants. |
| const NAME_GAP_PADDING = 14; // Extra gap between tick labels and axis name. |
| const GRID_LEFT_INNER_PAD = 6; // Padding between axis line and grid edge. |
| |
| // Bottom spacing (grid.bottom) default when the X-axis has a name. |
| const X_AXIS_NAME_BOTTOM = 30; |
| |
| // Right spacing (grid.right) reserved for a vertical (right-side) legend. |
| // Matches the legend's truncation width plus padding for the colour swatch. |
| const RIGHT_LEGEND_WIDTH = 140; |
| |
| const AXIS_LABEL_FONT = `${AXIS_LABEL_FONT_SIZE}px sans-serif`; |
| |
| // Minimal shape we rely on from a CanvasRenderingContext2D-like object, so we |
| // can substitute a no-op stub when canvas creation fails (e.g. in tests or |
| // restricted environments) without crashing the chart. |
| interface TextMeasurer { |
| font: string; |
| measureText(text: string): {width: number}; |
| } |
| |
| // Fallback used when canvas 2d context isn't available (e.g. in tests or |
| // restricted environments). Auto-spacing becomes a no-op but the chart still |
| // renders. |
| const NOOP_MEASURER: TextMeasurer = { |
| font: '', |
| measureText: () => ({width: 0}), |
| }; |
| |
| // Intentionally cached for the page lifetime — canvas context creation is |
| // expensive and font metrics don't change between chart renders. |
| let measureCtx: TextMeasurer | undefined; |
| |
| function getMeasureCtx(): TextMeasurer { |
| if (measureCtx === undefined) { |
| const canvas = document.createElement('canvas'); |
| measureCtx = canvas.getContext('2d') ?? NOOP_MEASURER; |
| } |
| return measureCtx; |
| } |
| |
| function measureTextWidth(text: string, font: string): number { |
| const ctx = getMeasureCtx(); |
| ctx.font = font; |
| return ctx.measureText(text).width; |
| } |
| |
| /** |
| * Auto-compute grid spacing from axis/legend context before rendering. |
| * Y-axis nameGap and grid.left are measured from actual tick label width. |
| */ |
| function autoAdjustGridSpacing( |
| option: echarts.EChartsCoreOption, |
| ): echarts.EChartsCoreOption { |
| const opt = option as Record<string, unknown>; |
| // Nothing to adjust for charts without axes (e.g. treemap, pie). |
| if (opt.xAxis === undefined && opt.yAxis === undefined) return option; |
| const grid = {...((opt.grid ?? {}) as Record<string, unknown>)}; |
| // Skip array yAxis (dual Y-axis) — auto-spacing only handles single axis. |
| const yAxis = !Array.isArray(opt.yAxis) |
| ? (opt.yAxis as Record<string, unknown> | undefined) |
| : undefined; |
| const xAxis = opt.xAxis as Record<string, unknown> | undefined; |
| const legend = opt.legend as Record<string, unknown> | undefined; |
| let yAxisPatch: Record<string, unknown> | undefined; |
| |
| const hasLegend = legend !== undefined && legend.show !== false; |
| // Horizontal legends (top/bottom) have no `orient: 'vertical'`; we |
| // distinguish top vs bottom by which edge the legend is anchored to. |
| const legendAtRight = hasLegend && legend?.orient === 'vertical'; |
| const isHorizontalLegend = hasLegend && !legendAtRight; |
| const legendAtBottom = isHorizontalLegend && legend?.bottom !== undefined; |
| const legendAtTop = isHorizontalLegend && !legendAtBottom; |
| |
| if (grid.top === undefined) { |
| grid.top = legendAtTop ? LEGEND_TOP : TICK_LABEL_TOP; |
| } |
| |
| if (grid.bottom === undefined) { |
| const xAxisName = xAxis?.name as string | undefined; |
| const hasXAxisName = xAxisName !== undefined && xAxisName !== ''; |
| if (legendAtBottom) { |
| grid.bottom = hasXAxisName ? X_AXIS_NAME_BOTTOM + LEGEND_TOP : LEGEND_TOP; |
| } else if (hasXAxisName) { |
| grid.bottom = X_AXIS_NAME_BOTTOM; |
| } |
| } |
| |
| if (grid.right === undefined && legendAtRight) { |
| grid.right = RIGHT_LEGEND_WIDTH; |
| } |
| |
| if (yAxis !== undefined) { |
| const yName = yAxis.name as string | undefined; |
| const nameLocation = yAxis.nameLocation as string | undefined; |
| const hasRotatedName = |
| yName !== undefined && |
| yName !== '' && |
| (nameLocation === undefined || nameLocation === 'middle'); |
| |
| if (hasRotatedName && yAxis.nameGap === undefined) { |
| const labels = collectYAxisLabels(opt); |
| if (labels.length > 0) { |
| const axisLabel = yAxis.axisLabel as |
| | Record<string, unknown> |
| | undefined; |
| const formatter = axisLabel?.formatter as |
| | LabelFormatter |
| | string |
| | undefined; |
| let maxWidth = 0; |
| for (const l of labels) { |
| const text = formatLabel(l, formatter); |
| const w = measureTextWidth(text, AXIS_LABEL_FONT); |
| if (w > maxWidth) maxWidth = w; |
| } |
| const requiredGap = Math.ceil(maxWidth) + NAME_GAP_PADDING; |
| yAxisPatch = {...yAxis, nameGap: requiredGap}; |
| if (grid.left === undefined) { |
| // The grid's left padding holds the rotated axis name (width |
| // NAME_GAP_PADDING once trimmed) plus a bit of inner padding. |
| grid.left = NAME_GAP_PADDING + GRID_LEFT_INNER_PAD; |
| } |
| } |
| } |
| } |
| |
| return { |
| ...opt, |
| grid, |
| ...(yAxisPatch !== undefined ? {yAxis: yAxisPatch} : {}), |
| }; |
| } |
| |
| /** |
| * Compute a simple hash of theme colors to detect changes. |
| */ |
| function themeHash(colors: ChartThemeColors): string { |
| return `${colors.textColor}|${colors.borderColor}|${colors.backgroundColor}|${colors.chartColors.join(',')}`; |
| } |
| |
| /** |
| * Typed params for the ECharts `brushEnd` event. |
| * Used by chart brush handlers to extract selected ranges. |
| * |
| * coordRange is [min, max] for 1-D brushes (lineX / lineY) and |
| * [[xMin, xMax], [yMin, yMax]] for 2-D rect brushes. |
| */ |
| export interface EChartBrushEndParams { |
| readonly areas?: ReadonlyArray<{ |
| readonly coordRange?: |
| | [number, number] |
| | [[number, number], [number, number]]; |
| }>; |
| } |
| |
| /** |
| * Typed params for ECharts click/interaction events. |
| */ |
| export interface EChartClickParams { |
| readonly name?: string; |
| readonly seriesName?: string; |
| readonly dataIndex?: number; |
| readonly value?: unknown; |
| readonly data?: unknown; |
| readonly marker?: string; |
| readonly color?: string; |
| readonly percent?: number; |
| } |
| |
| /** |
| * Event handler binding for an ECharts instance. |
| * Handlers are wrapped by EChartView to call `m.redraw()` automatically |
| * after each invocation, so callers do not need to trigger redraws. |
| */ |
| export interface EChartEventHandler { |
| readonly eventName: string; |
| readonly handler: (...args: unknown[]) => void; |
| } |
| |
| export interface EChartViewAttrs { |
| /** |
| * ECharts option to render. When undefined, a loading spinner is shown. |
| */ |
| readonly option: echarts.EChartsCoreOption | undefined; |
| |
| /** |
| * Optional callback to transform the option with theme colors. |
| * Called after theme colors are read from the DOM, before the option |
| * is applied to the ECharts instance. Useful for series-level color |
| * overrides (e.g. gauge axis tracks) that the ECharts theme system |
| * does not cover. |
| */ |
| readonly resolveOption?: ( |
| option: echarts.EChartsCoreOption, |
| colors: ChartThemeColors, |
| ) => echarts.EChartsCoreOption; |
| |
| /** |
| * 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; |
| |
| /** |
| * Show empty state instead of loading. Defaults to false. |
| */ |
| readonly empty?: boolean; |
| |
| /** |
| * Event handlers to attach to the ECharts instance. |
| */ |
| readonly eventHandlers?: ReadonlyArray<EChartEventHandler>; |
| |
| /** |
| * Brush type to activate immediately. When set, brush mode is enabled |
| * automatically without requiring user to click a toolbox button. |
| */ |
| readonly activeBrushType?: 'rect' | 'lineX' | 'lineY'; |
| } |
| |
| const DEFAULT_HEIGHT = 200; |
| |
| // Shared theme name for all EChartView instances |
| const THEME_NAME = 'perfetto'; |
| |
| export class EChartView implements m.ClassComponent<EChartViewAttrs> { |
| private chart?: EChartsType; |
| private resizeObs?: Disposable; |
| private prevHandlers: ReadonlyArray<EChartEventHandler> = []; |
| private prevOptionJson?: string; |
| private prevThemeHash?: string; |
| |
| oncreate({dom, attrs}: m.CVnodeDOM<EChartViewAttrs>) { |
| ensureEChartsSetup(); |
| |
| // Only init ECharts when we have an option to render (the canvas |
| // is display:none during loading, so init would get 0×0 dimensions). |
| if (attrs.option !== undefined) { |
| this.initChart(attrs, dom); |
| } |
| |
| // Defer resize to the next frame so that a layout change caused by |
| // chart.resize() doesn't re-trigger the observer in the same frame. |
| this.resizeObs = new SimpleResizeObserver(dom, () => { |
| requestAnimationFrame(() => this.chart?.resize()); |
| }); |
| } |
| |
| onupdate({dom, attrs}: m.CVnodeDOM<EChartViewAttrs>) { |
| if (attrs.option === undefined) return; |
| |
| // Lazy init: first option arrived after a loading state. |
| if (this.chart === undefined) { |
| this.initChart(attrs, dom); |
| return; |
| } |
| |
| // Check if theme changed - if so, reinitialize the chart |
| const colors = getChartThemeColors(dom); |
| const currentThemeHash = themeHash(colors); |
| |
| if (currentThemeHash !== this.prevThemeHash) { |
| // Theme changed - dispose and reinit with new theme |
| this.disposeChart(); |
| this.initChart(attrs, dom); |
| return; |
| } |
| |
| // Check if option changed |
| const resolvedOption = this.applyResolveOption(attrs.option, attrs, colors); |
| const optionJson = JSON.stringify(resolvedOption); |
| if (optionJson !== this.prevOptionJson) { |
| this.prevOptionJson = optionJson; |
| this.chart.setOption(resolvedOption, {notMerge: true}); |
| // The canvas may have been display:none (loading state) since the |
| // last option, so ECharts' cached dimensions could be stale. |
| this.chart.resize(); |
| this.activateBrush(attrs.activeBrushType); |
| } |
| this.syncHandlers(attrs.eventHandlers ?? []); |
| } |
| |
| private initChart(attrs: EChartViewAttrs, dom: Element): void { |
| if (attrs.option === undefined) return; |
| |
| const container = ensureIsInstance( |
| ensureExists(dom.querySelector('.pf-echart-view__canvas')), |
| HTMLElement, |
| ); |
| |
| // Read theme colors and register/update the ECharts theme |
| const colors = getChartThemeColors(container); |
| const theme = buildEChartsTheme(colors); |
| echarts.registerTheme(THEME_NAME, theme); |
| |
| const resolvedOption = this.applyResolveOption(attrs.option, attrs, colors); |
| |
| // Initialize chart with the theme |
| this.chart = echarts.init(container, THEME_NAME); |
| this.chart.setOption(resolvedOption); |
| |
| this.prevOptionJson = JSON.stringify(resolvedOption); |
| this.prevThemeHash = themeHash(colors); |
| this.syncHandlers(attrs.eventHandlers ?? []); |
| this.activateBrush(attrs.activeBrushType); |
| } |
| |
| private applyResolveOption( |
| option: echarts.EChartsCoreOption, |
| attrs: EChartViewAttrs, |
| colors: ChartThemeColors, |
| ): echarts.EChartsCoreOption { |
| const resolved = attrs.resolveOption |
| ? attrs.resolveOption(option, colors) |
| : option; |
| return autoAdjustGridSpacing(resolved); |
| } |
| |
| private disposeChart(): void { |
| this.detachAllHandlers(); |
| if (this.chart) { |
| this.chart.dispose(); |
| this.chart = undefined; |
| } |
| } |
| |
| private activateBrush(brushType: string | undefined): void { |
| if (this.chart === undefined || brushType === undefined) return; |
| this.chart.dispatchAction({ |
| type: 'takeGlobalCursor', |
| key: 'brush', |
| brushOption: { |
| brushType, |
| brushMode: 'single', |
| }, |
| }); |
| } |
| |
| onremove() { |
| if (this.resizeObs) { |
| this.resizeObs[Symbol.dispose](); |
| this.resizeObs = undefined; |
| } |
| this.disposeChart(); |
| } |
| |
| view({attrs}: m.Vnode<EChartViewAttrs>) { |
| const height = attrs.height ?? DEFAULT_HEIGHT; |
| const isLoading = attrs.option === undefined && !attrs.empty; |
| const isEmpty = attrs.empty === true; |
| |
| return m( |
| '.pf-echart-view', |
| { |
| className: classNames( |
| attrs.fillParent && 'pf-echart-view--fill-parent', |
| attrs.className, |
| ), |
| // When fillParent is set, let the CSS class control height (100%) |
| // instead of setting an explicit pixel height via inline style. |
| style: attrs.fillParent ? undefined : {height: `${height}px`}, |
| }, |
| m('.pf-echart-view__canvas', { |
| className: classNames( |
| (isLoading || isEmpty) && 'pf-echart-view__canvas--hidden', |
| ), |
| }), |
| isLoading && m('.pf-echart-view__loading', m(Spinner)), |
| isEmpty && m('.pf-echart-view__empty', 'No data to display'), |
| ); |
| } |
| |
| private syncHandlers(handlers: ReadonlyArray<EChartEventHandler>): void { |
| this.detachAllHandlers(); |
| const wrapped: EChartEventHandler[] = []; |
| for (const h of handlers) { |
| // Wrap each handler to trigger a Mithril redraw after it executes, |
| // so chart event handlers don't need to call m.redraw() manually. |
| const wrappedHandler = (...args: unknown[]) => { |
| h.handler(...args); |
| m.redraw(); |
| }; |
| this.chart?.on(h.eventName, wrappedHandler); |
| wrapped.push({eventName: h.eventName, handler: wrappedHandler}); |
| } |
| this.prevHandlers = wrapped; |
| } |
| |
| private detachAllHandlers(): void { |
| for (const h of this.prevHandlers) { |
| this.chart?.off(h.eventName, h.handler); |
| } |
| this.prevHandlers = []; |
| } |
| } |