diff --git a/ui/package.json b/ui/package.json index 05ab06c9c0..4c7ff42bf9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,6 +34,7 @@ "codemirror": "6.0.1", "color-convert": "^2.0.1", "d3": "^7.9.0", + "echarts": "^5.6.0", "devtools-protocol": "0.0.1561482", "esbuild": "^0.21.5", "events": "^3.3.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 140769e8f7..3ca4201815 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -89,6 +89,9 @@ dependencies: devtools-protocol: specifier: 0.0.1561482 version: 0.0.1561482 + echarts: + specifier: ^5.6.0 + version: 5.6.0 esbuild: specifier: ^0.21.5 version: 0.21.5 @@ -3401,6 +3404,13 @@ packages: safe-buffer: 5.2.1 dev: false + /echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -6615,6 +6625,10 @@ packages: typescript: 5.5.2 dev: true + /tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + dev: false + /tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} @@ -7265,3 +7279,9 @@ packages: /zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} dev: false + + /zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + dependencies: + tslib: 2.3.0 + dev: false diff --git a/ui/src/assets/theme_provider.scss b/ui/src/assets/theme_provider.scss index c2ad233a9e..c15361e4e8 100644 --- a/ui/src/assets/theme_provider.scss +++ b/ui/src/assets/theme_provider.scss @@ -50,6 +50,16 @@ --pf-color-warning: rgb(232, 158, 0); --pf-color-text-on-warning: var(--pf-color-text); + // Chart color palette for data visualization + --pf-chart-color-1: #4285f4; // Google Blue + --pf-chart-color-2: #ea4335; // Google Red + --pf-chart-color-3: #fbbc04; // Google Yellow + --pf-chart-color-4: #34a853; // Google Green + --pf-chart-color-5: #ff6d01; // Orange + --pf-chart-color-6: #46bdc6; // Cyan + --pf-chart-color-7: #9334e6; // Purple + --pf-chart-color-8: #185abc; // Dark Blue + --pf-color-track-summary-collapsed: #f4fafb; --pf-color-track-summary-expanded: #262f3b; --pf-color-track-summary-expanded-text: #e8eaed; @@ -90,6 +100,16 @@ --pf-color-warning: rgb(244, 188, 67); --pf-color-text-on-warning: #333; + // Chart color palette for data visualization (brighter for dark mode) + --pf-chart-color-1: #5e97f6; // Lighter Blue + --pf-chart-color-2: #f28b82; // Lighter Red + --pf-chart-color-3: #fdd663; // Lighter Yellow + --pf-chart-color-4: #81c995; // Lighter Green + --pf-chart-color-5: #ff8866; // Lighter Orange + --pf-chart-color-6: #78d9e4; // Lighter Cyan + --pf-chart-color-7: #c58af9; // Lighter Purple + --pf-chart-color-8: #669df6; // Lighter Dark Blue + --pf-color-track-summary-collapsed: #2f3437; --pf-color-track-summary-expanded: #454d55; --pf-color-track-summary-expanded-text: #e8eaed; diff --git a/ui/src/assets/widgets/charts.scss b/ui/src/assets/widgets/charts.scss index 453a5af98f..2340b4ab07 100644 --- a/ui/src/assets/widgets/charts.scss +++ b/ui/src/assets/widgets/charts.scss @@ -36,332 +36,23 @@ } } -// Histogram component styles -.pf-histogram { - --pf-histogram-bar-color: var(--pf-color-primary); - --pf-histogram-bar-hover-color: var(--pf-color-accent); - --pf-histogram-axis-color: var(--pf-color-border); - --pf-histogram-text-color: var(--pf-color-text); - --pf-histogram-tooltip-bg: var(--pf-color-background-secondary); - --pf-histogram-tooltip-border: var(--pf-color-border); - - position: relative; - width: 100%; - display: flex; - flex-direction: column; - - &--fill-parent { - height: 100%; - } - - &__svg { - width: 100%; - height: 100%; - overflow: visible; - } - - &__loading, - &__empty { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: var(--pf-color-text-muted); - font-size: 14px; - } - - &__bar { - fill: var(--pf-histogram-bar-color); - - &--hover, - &:hover { - fill: var(--pf-histogram-bar-hover-color); - } - } - - &__brush-overlay { - cursor: col-resize; - } - - &__brush-selection { - fill: var(--pf-color-text); - opacity: 0.15; - pointer-events: none; - } - - &__axis-line { - stroke: var(--pf-histogram-axis-color); - stroke-width: 1; - } - - &__tick { - stroke: var(--pf-histogram-axis-color); - stroke-width: 1; - } - - &__tick-label { - fill: var(--pf-histogram-text-color); - font-size: 10px; - font-family: inherit; - } - - &__axis-label { - fill: var(--pf-histogram-text-color); - font-size: 11px; - font-family: inherit; - font-weight: 500; - } - - &__tooltip { - position: absolute; - top: 10px; - right: 10px; - pointer-events: none; - z-index: 10; - } - - &__tooltip-content { - background: var(--pf-histogram-tooltip-bg); - border: 1px solid var(--pf-histogram-tooltip-border); - border-radius: 4px; - padding: 8px 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - font-size: 12px; - color: var(--pf-histogram-text-color); - } - - &__tooltip-row { - white-space: nowrap; - - &:not(:last-child) { - margin-bottom: 4px; - } - } -} - -// BarChart component styles -.pf-bar-chart { - --pf-bar-chart-bar-color: var(--pf-color-primary); - --pf-bar-chart-bar-hover-color: var(--pf-color-accent); - --pf-bar-chart-axis-color: var(--pf-color-border); - --pf-bar-chart-text-color: var(--pf-color-text); - --pf-bar-chart-tooltip-bg: var(--pf-color-background-secondary); - --pf-bar-chart-tooltip-border: var(--pf-color-border); - - position: relative; - width: 100%; - display: flex; - flex-direction: column; - - &--fill-parent { - height: 100%; - } - - &__svg { - width: 100%; - height: 100%; - overflow: visible; - } - - &__loading, - &__empty { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: var(--pf-color-text-muted); - font-size: 14px; - } - - &__bar { - fill: var(--pf-bar-chart-bar-color); - - &--hover, - &:hover { - fill: var(--pf-bar-chart-bar-hover-color); - } - } - - &__brush-selection { - fill: var(--pf-color-text); - opacity: 0.15; - pointer-events: none; - } - - &__axis-line { - stroke: var(--pf-bar-chart-axis-color); - stroke-width: 1; - } - - &__tick { - stroke: var(--pf-bar-chart-axis-color); - stroke-width: 1; - } - - &__tick-label { - fill: var(--pf-bar-chart-text-color); - font-size: 10px; - font-family: inherit; - } - - &__axis-label { - fill: var(--pf-bar-chart-text-color); - font-size: 11px; - font-family: inherit; - font-weight: 500; - } - - &__tooltip { - position: absolute; - top: 10px; - right: 10px; - pointer-events: none; - z-index: 10; - } - - &__tooltip-content { - background: var(--pf-bar-chart-tooltip-bg); - border: 1px solid var(--pf-bar-chart-tooltip-border); - border-radius: 4px; - padding: 8px 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - font-size: 12px; - color: var(--pf-bar-chart-text-color); - } - - &__tooltip-row { - white-space: nowrap; - - &:not(:last-child) { - margin-bottom: 4px; - } - } -} - -// LineChart component styles -.pf-line-chart { - --pf-line-chart-axis-color: var(--pf-color-border); - --pf-line-chart-text-color: var(--pf-color-text); - --pf-line-chart-tooltip-bg: var(--pf-color-background-secondary); - --pf-line-chart-tooltip-border: var(--pf-color-border); - +// EChartView component styles +.pf-echart-view { position: relative; width: 100%; - display: flex; - flex-direction: column; &--fill-parent { height: 100%; } - &__svg { + &__canvas { width: 100%; height: 100%; - overflow: visible; - } - - &__loading, - &__empty { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: var(--pf-color-text-muted); - font-size: 14px; - } - &__line { - fill: none; - stroke-linejoin: round; - stroke-linecap: round; - } - - &__point { - transition: r 0.1s ease; - } - - &__brush-selection { - fill: var(--pf-color-text); - opacity: 0.15; - pointer-events: none; - } - - &__axis-line { - stroke: var(--pf-line-chart-axis-color); - stroke-width: 1; - } - - &__tick { - stroke: var(--pf-line-chart-axis-color); - stroke-width: 1; - } - - &__tick-label { - fill: var(--pf-line-chart-text-color); - font-size: 10px; - font-family: inherit; - } - - &__axis-label { - fill: var(--pf-line-chart-text-color); - font-size: 11px; - font-family: inherit; - font-weight: 500; - } - - &__legend-label { - fill: var(--pf-line-chart-text-color); - font-size: 10px; - font-family: inherit; - } - - &__tooltip { - position: absolute; - top: 10px; - right: 10px; - pointer-events: none; - z-index: 10; - } - - &__tooltip-content { - background: var(--pf-line-chart-tooltip-bg); - border: 1px solid var(--pf-line-chart-tooltip-border); - border-radius: 4px; - padding: 8px 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - font-size: 12px; - color: var(--pf-line-chart-text-color); - } - - &__tooltip-row { - white-space: nowrap; - - &:not(:last-child) { - margin-bottom: 4px; + &--hidden { + display: none; } } -} - -// PieChart component styles -.pf-pie-chart { - --pf-pie-chart-text-color: var(--pf-color-text); - --pf-pie-chart-tooltip-bg: var(--pf-color-background-secondary); - --pf-pie-chart-tooltip-border: var(--pf-color-border); - - position: relative; - width: 100%; - display: flex; - flex-direction: column; - - &--fill-parent { - height: 100%; - } - - &__svg { - width: 100%; - height: 100%; - overflow: visible; - } &__loading, &__empty { @@ -372,62 +63,4 @@ color: var(--pf-color-text-muted); font-size: 14px; } - - &__slice { - transition: transform 0.1s ease; - - &--hover { - filter: brightness(1.1); - } - } - - &__slice-label { - fill: white; - font-size: 10px; - font-family: inherit; - font-weight: 500; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); - } - - &__legend-label { - fill: var(--pf-pie-chart-text-color); - font-size: 11px; - font-family: inherit; - - &--hover { - font-weight: 600; - } - } - - &__legend-value { - fill: var(--pf-color-text-muted); - font-size: 10px; - font-family: inherit; - } - - &__tooltip { - position: absolute; - top: 10px; - right: 10px; - pointer-events: none; - z-index: 10; - } - - &__tooltip-content { - background: var(--pf-pie-chart-tooltip-bg); - border: 1px solid var(--pf-pie-chart-tooltip-border); - border-radius: 4px; - padding: 8px 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - font-size: 12px; - color: var(--pf-pie-chart-text-color); - } - - &__tooltip-row { - white-space: nowrap; - - &:not(:last-child) { - margin-bottom: 4px; - } - } } diff --git a/ui/src/components/widgets/charts/bar_chart.ts b/ui/src/components/widgets/charts/bar_chart.ts index 3672b0c015..a41a20fd52 100644 --- a/ui/src/components/widgets/charts/bar_chart.ts +++ b/ui/src/components/widgets/charts/bar_chart.ts @@ -13,16 +13,18 @@ // limitations under the License. import m from 'mithril'; -import {classNames} from '../../../base/classnames'; -import {Spinner} from '../../../widgets/spinner'; +import type {EChartsCoreOption} from 'echarts/core'; +import {AggregationType, extractBrushRange, formatNumber} from './chart_utils'; import { - AggregationType, - formatNumber, - generateLogTicks, - generateTicks, - truncateLabel, -} from './chart_utils'; -import {BrushDirection, SvgBrush} from './svg_brush'; + EChartView, + EChartEventHandler, + getPerfettoThemeColors, +} from './echart_view'; +import { + buildAxisOption, + buildGridOption, + buildBrushOption, +} from './chart_option_builder'; /** * A single bar in the bar chart. @@ -82,12 +84,12 @@ export interface BarChartAttrs { readonly formatMeasure?: (value: number) => string; /** - * Bar color. Defaults to CSS variable. + * Bar color. Defaults to theme primary color. */ readonly barColor?: string; /** - * Bar hover color. Defaults to CSS variable. + * Bar hover color. Defaults to theme accent color. */ readonly barHoverColor?: string; @@ -115,437 +117,157 @@ export interface BarChartAttrs { readonly onBrush?: (labels: Array) => void; } -const DEFAULT_HEIGHT = 200; -const VIEWBOX_WIDTH = 400; -const MARGIN_VERTICAL = {top: 10, right: 10, bottom: 50, left: 65}; -const MARGIN_HORIZONTAL = {top: 10, right: 10, bottom: 40, left: 80}; - export class BarChart implements m.ClassComponent { - private hoveredItem?: BarChartItem; - private readonly brush = new SvgBrush(); - view({attrs}: m.Vnode) { - const { - data, - height = DEFAULT_HEIGHT, - dimensionLabel, - measureLabel = 'Value', - fillParent, - className, - formatMeasure = (v) => formatNumber(v), - barColor, - barHoverColor, - logScale = false, - integerMeasure = false, - orientation = 'vertical', - onBrush, - } = attrs; - + const {data, height, fillParent, className, onBrush, orientation} = attrs; const horizontal = orientation === 'horizontal'; - const margin = horizontal ? MARGIN_HORIZONTAL : MARGIN_VERTICAL; - - if (data === undefined) { - return m( - '.pf-bar-chart', - { - className: classNames( - fillParent && 'pf-bar-chart--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-bar-chart__loading', m(Spinner)), - ); - } - if (data.items.length === 0) { - return m( - '.pf-bar-chart', - { - className: classNames( - fillParent && 'pf-bar-chart--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-bar-chart__empty', 'No data to display'), - ); - } + const isEmpty = data !== undefined && data.items.length === 0; + const option = + data !== undefined && !isEmpty ? buildBarOption(attrs, data) : undefined; - const chartWidth = VIEWBOX_WIDTH - margin.left - margin.right; - const chartHeight = height - margin.top - margin.bottom; - - const maxValue = Math.max(1, ...data.items.map((item) => item.value)); - - // Slot size is the space each bar gets along the dimension axis - const slotSize = horizontal - ? chartHeight / data.items.length - : chartWidth / data.items.length; - const barPadding = Math.max(slotSize * 0.1, 1); - - // Generate measure axis ticks - const measureTicks = logScale - ? generateLogTicks(maxValue) - : generateTicks(0, maxValue, 5, integerMeasure); - - // Convert value to pixel position on the measure axis - const valueToMeasure = (value: number): number => { - if (logScale) { - if (value <= 0) return 0; - const ratio = Math.log10(value) / Math.log10(maxValue); - return horizontal ? ratio * chartWidth : chartHeight * (1 - ratio); - } - const ratio = value / maxValue; - return horizontal ? ratio * chartWidth : chartHeight * (1 - ratio); - }; - - // Format a label for display - const formatLabel = (label: string | number): string => - typeof label === 'number' ? formatNumber(label) : label; - - const style: Record = {height: `${height}px`}; - if (barColor) style['--pf-bar-chart-bar-color'] = barColor; - if (barHoverColor) style['--pf-bar-chart-bar-hover-color'] = barHoverColor; - - // Convert a pixel range on the dimension axis to overlapping bar labels - const rangeToLabels = ( - start: number, - end: number, - ): Array => { - const labels: Array = []; - for (let i = 0; i < data.items.length; i++) { - const slotStart = i * slotSize; - const slotEnd = (i + 1) * slotSize; - if (slotEnd > start && slotStart < end) { - labels.push(data.items[i].label); - } - } - return labels; - }; - - // Brush direction: across the dimension axis to select bars - const brushDir: BrushDirection = horizontal ? 'vertical' : 'horizontal'; + return m(EChartView, { + option, + height, + fillParent, + className, + empty: isEmpty, + eventHandlers: buildBarEventHandlers(attrs, data), + activeBrushType: + onBrush !== undefined ? (horizontal ? 'lineY' : 'lineX') : undefined, + }); + } +} - return m( - '.pf-bar-chart', +function buildBarOption( + attrs: BarChartAttrs, + data: BarChartData, +): EChartsCoreOption { + const { + dimensionLabel, + measureLabel = 'Value', + formatMeasure, + barColor, + barHoverColor, + logScale = false, + integerMeasure = false, + orientation = 'vertical', + } = attrs; + const fmtMeasure = formatMeasure ?? formatNumber; + + // Only get theme for custom color overrides + const theme = + barColor === undefined || barHoverColor === undefined + ? getPerfettoThemeColors() + : undefined; + + const horizontal = orientation === 'horizontal'; + const labels = data.items.map((item) => String(item.label)); + + const categoryAxis = buildAxisOption( + { + type: 'category', + data: labels, + name: dimensionLabel, + nameGap: horizontal ? 55 : 35, + labelOverflow: 'truncate', + labelWidth: horizontal ? 65 : undefined, + }, + !horizontal, + ); + + const valueAxis = buildAxisOption( + { + type: logScale ? 'log' : 'value', + name: measureLabel, + nameGap: horizontal ? 25 : undefined, + formatter: + formatMeasure !== undefined + ? (v) => formatMeasure(v as number) + : undefined, + minInterval: integerMeasure ? 1 : undefined, + }, + horizontal, + ); + + const option: Record = { + animation: false, + grid: buildGridOption({ + bottom: dimensionLabel && !horizontal ? 45 : 25, + }), + tooltip: { + trigger: 'axis' as const, + axisPointer: {type: 'shadow' as const}, + formatter: (params: Array<{name?: string; value?: number}>) => { + const p = Array.isArray(params) ? params[0] : params; + return `${p.name ?? ''}
${measureLabel}: ${fmtMeasure(p.value ?? 0)}`; + }, + }, + xAxis: horizontal ? valueAxis : categoryAxis, + yAxis: horizontal ? categoryAxis : valueAxis, + series: [ { - className: classNames( - fillParent && 'pf-bar-chart--fill-parent', - className, - ), - style, + type: 'bar', + data: data.items.map((item) => item.value), + itemStyle: barColor !== undefined ? {color: barColor} : undefined, + emphasis: + barHoverColor !== undefined + ? {itemStyle: {color: barHoverColor}} + : theme !== undefined + ? {itemStyle: {color: theme.accentColor}} + : undefined, }, - m( - 'svg.pf-bar-chart__svg', - { - viewBox: `0 0 ${VIEWBOX_WIDTH} ${height}`, - preserveAspectRatio: 'xMidYMid meet', - }, - [ - m( - 'g.pf-bar-chart__chart-area', - { - transform: `translate(${margin.left}, ${margin.top})`, - ...(onBrush - ? this.brush.chartAreaAttrs( - {left: margin.left, top: margin.top}, - brushDir, - (start, end) => { - const labels = rangeToLabels(start, end); - if (labels.length > 0) { - onBrush(labels); - } - }, - ) - : {}), - }, - [ - // Background - m('rect.pf-bar-chart__background', { - x: 0, - y: 0, - width: chartWidth, - height: chartHeight, - fill: 'transparent', - }), - - // Bars - ...data.items.map((item, i) => { - if (logScale && item.value === 0) return null; - const isHovered = this.hoveredItem === item; - - if (horizontal) { - const barW = valueToMeasure(item.value); - const barY = i * slotSize + barPadding; - const barH = slotSize - barPadding * 2; - return m('rect.pf-bar-chart__bar', { - x: 0, - y: barY, - width: Math.max(barW, 1), - height: Math.max(barH, 1), - className: classNames( - isHovered && 'pf-bar-chart__bar--hover', - ), - onmouseenter: () => { - this.hoveredItem = item; - }, - onmouseleave: () => { - this.hoveredItem = undefined; - }, - }); - } - - const y = valueToMeasure(item.value); - const barHeight = chartHeight - y; - const x = i * slotSize + barPadding; - const w = slotSize - barPadding * 2; - return m('rect.pf-bar-chart__bar', { - x, - y, - width: Math.max(w, 1), - height: barHeight, - className: classNames( - isHovered && 'pf-bar-chart__bar--hover', - ), - onmouseenter: () => { - this.hoveredItem = item; - }, - onmouseleave: () => { - this.hoveredItem = undefined; - }, - }); - }), - - // Brush selection rectangle - this.brush.renderSelection( - chartWidth, - chartHeight, - brushDir, - 'pf-bar-chart__brush-selection', - ), - - // Axes - ...(horizontal - ? renderHorizontalAxes({ - chartWidth, - chartHeight, - slotSize, - data, - measureTicks, - valueToMeasure, - formatMeasure, - formatLabel, - dimensionLabel, - measureLabel, - }) - : renderVerticalAxes({ - chartWidth, - chartHeight, - slotSize, - data, - measureTicks, - valueToMeasure, - formatMeasure, - formatLabel, - dimensionLabel, - measureLabel, - })), - ], - ), - ], - ), - // Tooltip - this.hoveredItem && - m( - '.pf-bar-chart__tooltip', - m('.pf-bar-chart__tooltip-content', [ - m( - '.pf-bar-chart__tooltip-row', - formatLabel(this.hoveredItem.label), - ), - m( - '.pf-bar-chart__tooltip-row', - `${measureLabel}: ${formatMeasure(this.hoveredItem.value)}`, - ), - ]), - ), - ); + ], + }; + + if (attrs.onBrush) { + option.brush = buildBrushOption({ + xAxisIndex: horizontal ? undefined : 0, + yAxisIndex: horizontal ? 0 : undefined, + brushType: horizontal ? 'lineY' : 'lineX', + }); + // Hide the default brush toolbox; we activate brush programmatically. + option.toolbox = {show: false}; } -} -interface AxesParams { - chartWidth: number; - chartHeight: number; - slotSize: number; - data: BarChartData; - measureTicks: number[]; - valueToMeasure: (v: number) => number; - formatMeasure: (v: number) => string; - formatLabel: (l: string | number) => string; - dimensionLabel: string | undefined; - measureLabel: string | undefined; + return option as EChartsCoreOption; } -/** - * Render axes for vertical orientation (dimension on X, measure on Y). - */ -function renderVerticalAxes(p: AxesParams): m.Children[] { - return [ - // X Axis (dimension: labels) - m('g.pf-bar-chart__x-axis', {transform: `translate(0, ${p.chartHeight})`}, [ - m('line.pf-bar-chart__axis-line', { - x1: 0, - y1: 0, - x2: p.chartWidth, - y2: 0, - }), - ...p.data.items.map((item, i) => { - const x = i * p.slotSize + p.slotSize / 2; - return m( - 'text.pf-bar-chart__tick-label', - { - 'x': x, - 'y': 15, - 'text-anchor': 'middle', - 'dominant-baseline': 'middle', - }, - truncateLabelToWidth(p.formatLabel(item.label), p.slotSize), - ); - }), - p.dimensionLabel && - m( - 'text.pf-bar-chart__axis-label', - { - 'x': p.chartWidth / 2, - 'y': 35, - 'text-anchor': 'middle', - }, - p.dimensionLabel, - ), - ]), - - // Y Axis (measure: values) - m('g.pf-bar-chart__y-axis', [ - m('line.pf-bar-chart__axis-line', { - x1: 0, - y1: 0, - x2: 0, - y2: p.chartHeight, - }), - ...p.measureTicks.map((tick) => { - const y = p.valueToMeasure(tick); - return m('g', {transform: `translate(0, ${y})`}, [ - m('line.pf-bar-chart__tick', {x2: -5}), - m( - 'text.pf-bar-chart__tick-label', - { - 'x': -8, - 'text-anchor': 'end', - 'dominant-baseline': 'middle', - }, - p.formatMeasure(tick), - ), - ]); - }), - p.measureLabel && - m( - 'text.pf-bar-chart__axis-label', - { - 'transform': `translate(-50, ${p.chartHeight / 2}) rotate(-90)`, - 'text-anchor': 'middle', - }, - p.measureLabel, - ), - ]), - ]; -} +function buildBarEventHandlers( + attrs: BarChartAttrs, + data: BarChartData | undefined, +): ReadonlyArray { + if (!attrs.onBrush || data === undefined || data.items.length === 0) { + return []; + } + const onBrush = attrs.onBrush; + const items = data.items; -/** - * Render axes for horizontal orientation (measure on X, dimension on Y). - */ -function renderHorizontalAxes(p: AxesParams): m.Children[] { return [ - // X Axis (measure: values) - m('g.pf-bar-chart__x-axis', {transform: `translate(0, ${p.chartHeight})`}, [ - m('line.pf-bar-chart__axis-line', { - x1: 0, - y1: 0, - x2: p.chartWidth, - y2: 0, - }), - ...p.measureTicks.map((tick) => { - const x = p.valueToMeasure(tick); - return m('g', {transform: `translate(${x}, 0)`}, [ - m('line.pf-bar-chart__tick', {y2: 5}), - m( - 'text.pf-bar-chart__tick-label', - { - 'y': 15, - 'text-anchor': 'middle', - 'dominant-baseline': 'middle', - }, - p.formatMeasure(tick), - ), - ]); - }), - p.measureLabel && - m( - 'text.pf-bar-chart__axis-label', - { - 'x': p.chartWidth / 2, - 'y': 30, - 'text-anchor': 'middle', - }, - p.measureLabel, - ), - ]), - - // Y Axis (dimension: labels) - m('g.pf-bar-chart__y-axis', [ - m('line.pf-bar-chart__axis-line', { - x1: 0, - y1: 0, - x2: 0, - y2: p.chartHeight, - }), - ...p.data.items.map((item, i) => { - const y = i * p.slotSize + p.slotSize / 2; - return m( - 'text.pf-bar-chart__tick-label', - { - 'x': -8, - 'y': y, - 'text-anchor': 'end', - 'dominant-baseline': 'middle', - }, - truncateLabelToWidth( - p.formatLabel(item.label), - MARGIN_HORIZONTAL.left - 12, - ), - ); - }), - p.dimensionLabel && - m( - 'text.pf-bar-chart__axis-label', - { - 'transform': `translate(-65, ${p.chartHeight / 2}) rotate(-90)`, - 'text-anchor': 'middle', - }, - p.dimensionLabel, - ), - ]), + { + eventName: 'brushEnd', + handler: (params) => { + // For category axes, coordRange returns category indices + const range = extractBrushRange(params); + if (range !== undefined) { + const [startIdx, endIdx] = range; + const minIdx = Math.max(0, startIdx); + const maxIdx = Math.min(items.length - 1, endIdx); + if (minIdx <= maxIdx) { + const labels: Array = []; + for (let i = minIdx; i <= maxIdx; i++) { + labels.push(items[i].label); + } + if (labels.length > 0) { + onBrush(labels); + } + } + } + }, + }, ]; } -/** - * Truncate a label to fit within a given pixel width (approximate). - * Uses ~6px per character estimate at font-size 10px in SVG. - */ -function truncateLabelToWidth(label: string, maxWidth: number): string { - const maxChars = Math.max(3, Math.floor(maxWidth / 6)); - return truncateLabel(label, maxChars); -} - /** * Aggregate raw data into BarChartData by grouping on a dimension and * applying an aggregation function to the measure values. diff --git a/ui/src/components/widgets/charts/bar_chart_loader.ts b/ui/src/components/widgets/charts/bar_chart_loader.ts index 864df844ea..a3618d204f 100644 --- a/ui/src/components/widgets/charts/bar_chart_loader.ts +++ b/ui/src/components/widgets/charts/bar_chart_loader.ts @@ -16,7 +16,12 @@ import {QuerySlot, SerialTaskQueue} from '../../../base/query_slot'; import {Engine} from '../../../trace_processor/engine'; import {NUM, STR_NULL} from '../../../trace_processor/query_result'; import {BarChartData, BarChartItem} from './bar_chart'; -import {AggregationType, sqlAggExpression, sqlInClause} from './chart_utils'; +import { + AggregationType, + sqlAggExpression, + sqlInClause, + validateColumnName, +} from './chart_utils'; /** * Configuration for SQLBarChartLoader. @@ -113,6 +118,8 @@ export class SQLBarChartLoader { private readonly querySlot = new QuerySlot(this.taskQueue); constructor(opts: SQLBarChartLoaderOpts) { + validateColumnName(opts.dimensionColumn); + validateColumnName(opts.measureColumn); this.engine = opts.engine; this.baseQuery = opts.query; this.dimensionColumn = opts.dimensionColumn; diff --git a/ui/src/components/widgets/charts/chart_option_builder.ts b/ui/src/components/widgets/charts/chart_option_builder.ts new file mode 100644 index 0000000000..176d67d5c0 --- /dev/null +++ b/ui/src/components/widgets/charts/chart_option_builder.ts @@ -0,0 +1,212 @@ +// 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 type {EChartsCoreOption} from 'echarts/core'; +import {getChartThemeColors} from './chart_theme'; + +/** + * Configuration for an axis in a chart. + */ +export interface AxisConfig { + readonly type: 'value' | 'category' | 'log'; + 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. + * Explicitly includes theme colors because ECharts doesn't deep merge + * option objects with theme objects - setting axisLabel overrides the theme's + * axisLabel entirely, so we must include colors here. + */ +export function buildAxisOption( + config: AxisConfig, + isXAxis: boolean, +): Record { + const theme = getChartThemeColors(); + const axis: Record = { + type: config.type, + name: config.name, + nameLocation: isXAxis ? ('middle' as const) : ('end' as const), + nameGap: config.nameGap ?? (isXAxis ? 25 : 10), + nameTextStyle: {fontSize: 11, color: theme.textColor}, + axisLabel: { + fontSize: 10, + color: theme.textColor, + ...(config.formatter !== undefined && {formatter: config.formatter}), + ...(config.labelOverflow !== undefined && { + overflow: config.labelOverflow, + }), + ...(config.labelWidth !== undefined && {width: config.labelWidth}), + }, + axisTick: {lineStyle: {color: theme.borderColor}}, + axisLine: {lineStyle: {color: theme.borderColor}}, + splitLine: { + show: config.showSplitLine ?? false, + lineStyle: {color: theme.borderColor}, + }, + }; + + 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 themed grid option. + */ +export function buildGridOption(opts?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + containLabel?: boolean; +}): Record { + return { + top: opts?.top ?? 20, + right: opts?.right ?? 10, + bottom: opts?.bottom ?? 25, + left: opts?.left ?? 10, + containLabel: opts?.containLabel ?? true, + }; +} + +/** + * Build a brush configuration. + * Uses accent color from theme (ECharts doesn't theme brush colors). + */ +export function buildBrushOption(config: BrushConfig): Record { + const theme = getChartThemeColors(); + 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)', + borderColor: theme.accentColor, + }, + throttleType: 'debounce' as const, + throttleDelay: 100, + }; +} + +/** + * Build a legend option. + * Explicitly includes theme colors because ECharts doesn't deep merge + * option objects with theme objects. + */ +export function buildLegendOption( + position: 'top' | 'right' = 'top', +): Record { + const theme = getChartThemeColors(); + if (position === 'right') { + return { + show: true, + type: 'scroll', + orient: 'vertical', + right: 0, + top: 20, + bottom: 20, + textStyle: { + fontSize: 10, + width: 120, + overflow: 'truncate', + ellipsis: '\u2026', + color: theme.textColor, + }, + tooltip: {show: true}, + pageButtonPosition: 'end', + }; + } + return { + show: true, + top: 0, + textStyle: {fontSize: 10, color: theme.textColor}, + }; +} + +/** + * Build a complete base chart option with grid, axes, and optional + * tooltip/brush/legend. Charts add their own `series` on top. + * Theme colors are applied by ECharts theme system. + */ +export function buildChartOption(config: { + readonly grid?: Parameters[0]; + readonly xAxis: AxisConfig; + readonly yAxis: AxisConfig; + readonly tooltip?: Record; + readonly brush?: BrushConfig; + readonly legend?: Record; +}): EChartsCoreOption { + const {grid, xAxis, yAxis, tooltip, brush, legend} = config; + + const option: Record = { + animation: false, + grid: buildGridOption(grid), + xAxis: buildAxisOption(xAxis, true), + yAxis: buildAxisOption(yAxis, false), + }; + + if (tooltip !== undefined) { + option.tooltip = 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; +} diff --git a/ui/src/components/widgets/charts/chart_theme.ts b/ui/src/components/widgets/charts/chart_theme.ts new file mode 100644 index 0000000000..c76612ba74 --- /dev/null +++ b/ui/src/components/widgets/charts/chart_theme.ts @@ -0,0 +1,79 @@ +// 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 theme utilities for reading Perfetto theme colors. + * + * DESIGN NOTES: + * + * 1. Colors are read from CSS variables defined in theme_provider.scss, NOT + * hardcoded. This ensures charts automatically adapt when theme colors + * are updated in SCSS. + * + * 2. We read from the `.pf-theme-provider` element (not document.documentElement) + * because that's where theme classes (.pf-theme-provider--light/--dark) are + * applied and where CSS variables are scoped. + * + * 3. ECharts doesn't automatically pick up CSS variable changes, so chart + * components must call getChartThemeColors() when building options and + * rebuild when the theme changes (see EChartView.onThemeChange). + * + * 4. Chart options that set sub-objects (like axisLabel: {fontSize: 10}) + * override theme values entirely - ECharts doesn't deep merge. Therefore, + * chart_option_builder.ts explicitly includes theme colors in axis options. + */ + +/** + * Theme colors for charts and visualizations. + */ +export interface ChartThemeColors { + readonly textColor: string; + readonly borderColor: string; + readonly backgroundColor: string; + readonly accentColor: string; + readonly chartColors: readonly string[]; +} + +/** + * Returns true if dark theme is currently active. + */ +export function isDarkTheme(): boolean { + const themeProvider = document.querySelector('.pf-theme-provider'); + return themeProvider?.classList.contains('pf-theme-provider--dark') ?? false; +} + +/** + * Returns the current theme colors by reading CSS variables from the + * theme provider element. Colors are defined in theme_provider.scss. + */ +export function getChartThemeColors(): ChartThemeColors { + const themeProvider = document.querySelector('.pf-theme-provider'); + if (themeProvider === null) { + throw new Error('Theme provider element not found'); + } + const style = getComputedStyle(themeProvider); + + const chartColors: string[] = []; + for (let i = 1; i <= 8; i++) { + chartColors.push(style.getPropertyValue(`--pf-chart-color-${i}`).trim()); + } + + return { + textColor: style.getPropertyValue('--pf-color-text').trim(), + borderColor: style.getPropertyValue('--pf-color-border').trim(), + backgroundColor: style.getPropertyValue('--pf-color-background').trim(), + accentColor: style.getPropertyValue('--pf-color-accent').trim(), + chartColors, + }; +} diff --git a/ui/src/components/widgets/charts/chart_utils.ts b/ui/src/components/widgets/charts/chart_utils.ts index 0e1161b4d4..1951a400c9 100644 --- a/ui/src/components/widgets/charts/chart_utils.ts +++ b/ui/src/components/widgets/charts/chart_utils.ts @@ -12,61 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -/** - * Generate nice tick values for a chart axis. - * - * @param min Minimum value of the axis range. - * @param max Maximum value of the axis range. - * @param count Number of ticks to generate. - * @param integer When true, ticks are rounded to integers and deduplicated. - */ -export function generateTicks( - min: number, - max: number, - count: number, - integer = false, -): number[] { - if (min === max) return [min]; - if (count <= 1) return [min]; - - const range = max - min; - const step = range / (count - 1); - const ticks: number[] = []; - - for (let i = 0; i < count; i++) { - let tick = min + i * step; - if (integer) tick = Math.round(tick); - ticks.push(tick); - } - - // Deduplicate when rounding creates duplicates (small integer ranges) - if (integer) { - return [...new Set(ticks)]; - } - return ticks; -} - -/** - * Estimate how many axis ticks fit without overlapping, given the available - * width (in SVG viewbox units) and a formatter. Samples several values - * across the range to find the widest label, then computes how many fit. - */ -export function estimateTickCount( - availableWidth: number, - min: number, - max: number, - formatter: (v: number) => string = formatNumber, -): number { - const charWidth = 6; - const minGap = 15; - // Sample min, max, and a few intermediate values to find worst-case width. - const samples = [min, max, (min + max) / 2, min + (max - min) * 0.25]; - const maxLen = Math.max(...samples.map((v) => formatter(v).length)); - const labelWidth = maxLen * charWidth; - const tickSlotWidth = labelWidth + minGap; - return Math.min(7, Math.max(2, Math.floor(availableWidth / tickSlotWidth))); -} - /** * Format a number for display on chart axes. */ @@ -84,20 +29,6 @@ export function formatNumber(value: number): string { return value.toPrecision(3); } -/** - * Generate tick values for a logarithmic scale (powers of 10). - */ -export function generateLogTicks(max: number): number[] { - if (max <= 1) return [1]; - const ticks: number[] = [1]; - let power = 1; - while (Math.pow(10, power) <= max) { - ticks.push(Math.pow(10, power)); - power++; - } - return ticks; -} - /** * Aggregation types supported by chart loaders. */ @@ -116,36 +47,23 @@ export function isIntegerAggregation(agg: AggregationType): boolean { return agg === 'COUNT' || agg === 'COUNT_DISTINCT'; } -/** - * Default chart colors for multi-series charts. - */ -export const CHART_COLORS = [ - 'var(--pf-chart-color-1, #4285f4)', - 'var(--pf-chart-color-2, #ea4335)', - 'var(--pf-chart-color-3, #fbbc04)', - 'var(--pf-chart-color-4, #34a853)', - 'var(--pf-chart-color-5, #ff6d01)', - 'var(--pf-chart-color-6, #46bdc6)', - 'var(--pf-chart-color-7, #9334e6)', - 'var(--pf-chart-color-8, #185abc)', -]; +// --------------------------------------------------------------------------- +// SQL helpers shared across chart loaders +// --------------------------------------------------------------------------- + +// Valid SQL column name: identifier chars only (letters, digits, underscore). +const VALID_COLUMN_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; /** - * Truncate a label to fit within a maximum character count. - * Adds an ellipsis if truncation is needed. - * - * @param label The text to truncate. - * @param maxChars Maximum characters to allow. + * Validate that a string is a safe SQL column identifier. + * Throws if the name contains non-identifier characters. */ -export function truncateLabel(label: string, maxChars: number): string { - if (label.length <= maxChars) return label; - return label.substring(0, maxChars - 1) + '\u2026'; +export function validateColumnName(name: string): void { + if (!VALID_COLUMN_RE.test(name)) { + throw new Error(`Invalid SQL column name: '${name}'`); + } } -// --------------------------------------------------------------------------- -// SQL helpers shared across chart loaders -// --------------------------------------------------------------------------- - /** * Build the SQL aggregation expression for a column. */ @@ -190,3 +108,31 @@ export function sqlRangeClause( ): string { return `${column} >= ${range.min} AND ${column} <= ${range.max}`; } + +// --------------------------------------------------------------------------- +// ECharts brush helpers +// --------------------------------------------------------------------------- + +// Re-export EChartBrushEndParams for use by other chart modules. +export {type EChartBrushEndParams} from './echart_view'; + +/** + * Extract the numeric brush range from an ECharts brushEnd event. + * Returns [min, max] if a valid range was selected, undefined otherwise. + * + * This utility centralizes the brush range extraction logic used across + * different chart types (line, scatter, bar, histogram). + */ +export function extractBrushRange( + params: unknown, +): [number, number] | undefined { + const p = params as { + areas?: ReadonlyArray<{coordRange?: [number, number]}>; + }; + const areas = p.areas; + if (areas !== undefined && areas.length > 0 && areas[0].coordRange) { + const [a, b] = areas[0].coordRange; + return [Math.min(a, b), Math.max(a, b)]; + } + return undefined; +} diff --git a/ui/src/components/widgets/charts/echart_view.ts b/ui/src/components/widgets/charts/echart_view.ts new file mode 100644 index 0000000000..dda7e17dac --- /dev/null +++ b/ui/src/components/widgets/charts/echart_view.ts @@ -0,0 +1,481 @@ +// 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: + * + * ECharts themes are registered at initialization time by reading CSS variables + * from the theme provider (see chart_theme.ts). When the user switches themes: + * + * 1. A MutationObserver detects the class change on .pf-theme-provider + * 2. onThemeChange() re-registers ECharts themes with fresh CSS variable values + * 3. The chart is disposed and re-initialized with the new theme + * 4. m.redraw() triggers parent components to rebuild options with new colors + * + * This approach ensures charts respond to theme changes without page reload. + */ + +import m from 'mithril'; +import * as echarts from 'echarts/core'; +import { + BarChart as EBarChart, + LineChart as ELineChart, + PieChart as EPieChart, + ScatterChart as EScatterChart, + TreemapChart as ETreemapChart, +} from 'echarts/charts'; +import { + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + BrushComponent, + ToolboxComponent, +} from 'echarts/components'; +import {CanvasRenderer} from 'echarts/renderers'; +import type {EChartsType} from 'echarts/core'; +import {classNames} from '../../../base/classnames'; +import {SimpleResizeObserver} from '../../../base/resize_observer'; +import {Spinner} from '../../../widgets/spinner'; +import { + isDarkTheme, + getChartThemeColors, + type ChartThemeColors, +} from './chart_theme'; + +// Re-export for backward compatibility +export {getChartThemeColors as getPerfettoThemeColors}; +export type {ChartThemeColors as ThemeColors}; + +let echartsInitialized = false; + +function ensureEChartsSetup(): void { + if (echartsInitialized) return; + echartsInitialized = true; + echarts.use([ + EBarChart, + ELineChart, + EPieChart, + EScatterChart, + ETreemapChart, + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + BrushComponent, + ToolboxComponent, + CanvasRenderer, + ]); + registerPerfettoThemes(); +} + +/** + * Returns the ECharts theme name based on current theme. + */ +function getCurrentThemeName(): 'perfetto-light' | 'perfetto-dark' { + return isDarkTheme() ? 'perfetto-dark' : 'perfetto-light'; +} + +/** + * Builds an ECharts theme object by reading CSS variables. + */ +function buildEChartsTheme(): Record { + const theme = getChartThemeColors(); + + return { + color: theme.chartColors, + backgroundColor: 'transparent', + textStyle: { + color: theme.textColor, + fontFamily: 'inherit', + }, + title: { + textStyle: { + color: theme.textColor, + }, + }, + legend: { + textStyle: { + color: theme.textColor, + }, + }, + tooltip: { + backgroundColor: theme.backgroundColor, + borderColor: theme.borderColor, + textStyle: { + color: theme.textColor, + }, + }, + axisPointer: { + lineStyle: { + color: theme.borderColor, + }, + crossStyle: { + color: theme.borderColor, + }, + }, + xAxis: { + axisLine: { + lineStyle: { + color: theme.borderColor, + }, + }, + axisTick: { + lineStyle: { + color: theme.borderColor, + }, + }, + axisLabel: { + color: theme.textColor, + }, + splitLine: { + lineStyle: { + color: theme.borderColor, + }, + }, + nameTextStyle: { + color: theme.textColor, + }, + }, + yAxis: { + axisLine: { + lineStyle: { + color: theme.borderColor, + }, + }, + axisTick: { + lineStyle: { + color: theme.borderColor, + }, + }, + axisLabel: { + color: theme.textColor, + }, + splitLine: { + lineStyle: { + color: theme.borderColor, + }, + }, + nameTextStyle: { + color: theme.textColor, + }, + }, + }; +} + +/** + * Registers both light and dark Perfetto themes with ECharts. + * Called once during ECharts initialization. + */ +function registerPerfettoThemes(): void { + // Register themes with current CSS variable values. + // Note: Theme registration happens once at init time. For dynamic theme + // switching, we re-initialize the chart instance with the new theme name. + const theme = buildEChartsTheme(); + echarts.registerTheme('perfetto-light', theme); + echarts.registerTheme('perfetto-dark', theme); +} + +// Global set to track all mounted EChartView instances +const mountedCharts = new Set(); + +// Single MutationObserver for all charts +let themeObserver: MutationObserver | undefined; + +/** + * Starts observing theme provider class changes to detect theme switches. + * Only creates the observer when the first chart mounts. + */ +function startThemeObserver(): void { + if (themeObserver !== undefined) return; + + const themeProvider = document.querySelector('.pf-theme-provider'); + if (themeProvider === null) return; + + themeObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'class' + ) { + const newTheme = getCurrentThemeName(); + // Notify all mounted charts + for (const chart of mountedCharts) { + chart.onThemeChange(newTheme); + } + break; + } + } + }); + + themeObserver.observe(themeProvider, { + attributes: true, + attributeFilter: ['class'], + }); +} + +/** + * Stops the theme observer when no charts are mounted. + */ +function stopThemeObserver(): void { + if (themeObserver !== undefined && mountedCharts.size === 0) { + themeObserver.disconnect(); + themeObserver = undefined; + } +} + +/** + * Typed params for the ECharts `brushEnd` event. + * Used by chart brush handlers to extract selected ranges. + */ +export interface EChartBrushEndParams { + readonly areas?: ReadonlyArray<{ + readonly coordRange?: [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; + + /** + * 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; + + /** + * 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; + +export class EChartView implements m.ClassComponent { + private chart?: EChartsType; + private container?: HTMLElement; + private resizeObs?: Disposable; + private prevHandlers: ReadonlyArray = []; + private prevOptionJson?: string; + private currentTheme: 'perfetto-light' | 'perfetto-dark' = 'perfetto-light'; + + oncreate({dom, attrs}: m.CVnodeDOM) { + ensureEChartsSetup(); + this.currentTheme = getCurrentThemeName(); + + const container = dom.querySelector( + '.pf-echart-view__canvas', + ) as HTMLElement | null; + if (container === null) return; + this.container = container; + + // 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); + } + + // 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()); + }); + + // Register for theme changes + mountedCharts.add(this); + startThemeObserver(); + } + + onupdate({attrs}: m.CVnodeDOM) { + if (attrs.option === undefined) return; + + // Lazy init: first option arrived after a loading state. + if (this.chart === undefined) { + this.initChart(attrs); + return; + } + + const optionJson = JSON.stringify(attrs.option); + if (optionJson !== this.prevOptionJson) { + this.prevOptionJson = optionJson; + this.chart.setOption(attrs.option, {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): void { + if (this.container === undefined || attrs.option === undefined) return; + this.chart = echarts.init(this.container, this.currentTheme); + this.chart.setOption(attrs.option); + this.prevOptionJson = JSON.stringify(attrs.option); + this.syncHandlers(attrs.eventHandlers ?? []); + this.activateBrush(attrs.activeBrushType); + } + + 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() { + mountedCharts.delete(this); + stopThemeObserver(); + + if (this.resizeObs) { + this.resizeObs[Symbol.dispose](); + this.resizeObs = undefined; + } + this.detachAllHandlers(); + if (this.chart) { + this.chart.dispose(); + this.chart = undefined; + } + } + + /** + * Called when the document theme changes. + * Re-registers ECharts themes with new CSS values and reinitializes charts. + */ + onThemeChange(newTheme: 'perfetto-light' | 'perfetto-dark'): void { + if (this.currentTheme === newTheme) return; + this.currentTheme = newTheme; + + // Re-register themes with updated CSS variable values + const theme = buildEChartsTheme(); + echarts.registerTheme('perfetto-light', theme); + echarts.registerTheme('perfetto-dark', theme); + + // Re-initialize chart with new theme + if (this.chart !== undefined && this.container !== undefined) { + const currentOption = this.chart.getOption(); + this.chart.dispose(); + this.chart = echarts.init(this.container, newTheme); + this.chart.setOption(currentOption, {notMerge: true}); + this.syncHandlers(this.prevHandlers); + this.chart.resize(); + m.redraw(); + } + } + + view({attrs}: m.Vnode) { + 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): 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 = []; + } +} diff --git a/ui/src/components/widgets/charts/histogram.ts b/ui/src/components/widgets/charts/histogram.ts index ae23aaa51e..9b98f018a1 100644 --- a/ui/src/components/widgets/charts/histogram.ts +++ b/ui/src/components/widgets/charts/histogram.ts @@ -13,21 +13,20 @@ // limitations under the License. import m from 'mithril'; -import {classNames} from '../../../base/classnames'; -import {Spinner} from '../../../widgets/spinner'; -import { - estimateTickCount, - formatNumber, - generateLogTicks, - generateTicks, -} from './chart_utils'; +import type {EChartsCoreOption} from 'echarts/core'; +import {extractBrushRange, formatNumber} from './chart_utils'; import { HistogramBucket, HistogramData, HistogramConfig, computeHistogram, } from './histogram_loader'; -import {SvgBrush} from './svg_brush'; +import { + EChartView, + EChartEventHandler, + getPerfettoThemeColors, +} from './echart_view'; +import {buildChartOption} from './chart_option_builder'; // Re-export data types for convenience export {HistogramBucket, HistogramData, HistogramConfig, computeHistogram}; @@ -82,12 +81,12 @@ export interface HistogramAttrs { readonly formatYValue?: (value: number) => string; /** - * Bar color. Defaults to CSS variable. + * Bar color. Defaults to theme primary color. */ readonly barColor?: string; /** - * Bar hover color. Defaults to CSS variable. + * Bar hover color. Defaults to theme accent color. */ readonly barHoverColor?: string; @@ -105,289 +104,136 @@ export interface HistogramAttrs { readonly integerDimension?: boolean; } -const DEFAULT_HEIGHT = 200; -const VIEWBOX_WIDTH = 400; -const MARGIN = {top: 10, right: 10, bottom: 40, left: 65}; - export class Histogram implements m.ClassComponent { - private hoveredBucket?: HistogramBucket; - private readonly brush = new SvgBrush(); - view({attrs}: m.Vnode) { - const { - data, - height = DEFAULT_HEIGHT, - xAxisLabel, - yAxisLabel = 'Count', - onBrush, - fillParent, - className, - formatXValue = (v) => formatNumber(v), - formatYValue = (v) => formatNumber(v), - barColor, - barHoverColor, - logScale = false, - integerDimension = false, - } = attrs; - - if (data === undefined) { - return m( - '.pf-histogram', - { - className: classNames( - fillParent && 'pf-histogram--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-histogram__loading', m(Spinner)), - ); - } - - if (data.buckets.length === 0) { - return m( - '.pf-histogram', - { - className: classNames( - fillParent && 'pf-histogram--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-histogram__empty', 'No data to display'), - ); - } - - const chartWidth = VIEWBOX_WIDTH - MARGIN.left - MARGIN.right; - const chartHeight = height - MARGIN.top - MARGIN.bottom; - - const maxCount = Math.max( - ...data.buckets.map((b: HistogramBucket) => b.count), - ); - const bucketWidth = chartWidth / data.buckets.length; - - // Generate Y axis ticks (counts are always integers) - const yTicks = logScale - ? generateLogTicks(maxCount) - : generateTicks(0, maxCount, 5, true); - - // Helper to convert count value to Y position - const countToY = (count: number): number => { - if (logScale) { - if (count <= 0) return chartHeight; - return ( - chartHeight - (Math.log10(count) / Math.log10(maxCount)) * chartHeight - ); - } - return chartHeight - (count / maxCount) * chartHeight; - }; + const {data, height, fillParent, className, onBrush} = attrs; - // Generate X axis ticks - const xTickCount = estimateTickCount( - chartWidth, - data.min, - data.max, - formatXValue, - ); - const xTicks = generateTicks( - data.min, - data.max, - xTickCount, - integerDimension, - ); + const isEmpty = data !== undefined && data.buckets.length === 0; + const option = + data !== undefined && !isEmpty ? buildOption(attrs, data) : undefined; - const style: Record = {height: `${height}px`}; - if (barColor) style['--pf-histogram-bar-color'] = barColor; - if (barHoverColor) style['--pf-histogram-bar-hover-color'] = barHoverColor; - - // Convert chart-pixel X to data value - const chartXToValue = (chartX: number): number => { - const ratio = Math.max(0, Math.min(1, chartX / chartWidth)); - return data.min + ratio * (data.max - data.min); - }; + return m(EChartView, { + option, + height, + fillParent, + className, + empty: isEmpty, + eventHandlers: buildEventHandlers(attrs, data), + activeBrushType: onBrush !== undefined ? 'lineX' : undefined, + }); + } +} - return m( - '.pf-histogram', - { - className: classNames( - fillParent && 'pf-histogram--fill-parent', - className, - ), - style, +function buildOption( + attrs: HistogramAttrs, + data: HistogramData, +): EChartsCoreOption { + const { + xAxisLabel, + yAxisLabel = 'Count', + formatXValue = (v: number) => formatNumber(v), + formatYValue, + barColor, + barHoverColor, + logScale = false, + } = attrs; + const fmtY = formatYValue ?? formatNumber; + + // Only get theme for custom color overrides + const theme = + barColor === undefined || barHoverColor === undefined + ? getPerfettoThemeColors() + : undefined; + const categories = data.buckets.map((b) => formatXValue(b.start)); + + const option = buildChartOption({ + grid: {bottom: xAxisLabel ? 40 : 25}, + xAxis: { + type: 'category', + data: categories, + name: xAxisLabel, + }, + yAxis: { + type: logScale ? 'log' : 'value', + name: yAxisLabel, + formatter: + formatYValue !== undefined + ? (v) => formatYValue(v as number) + : undefined, + minInterval: 1, + }, + tooltip: { + trigger: 'axis' as const, + axisPointer: {type: 'shadow' as const}, + formatter: (params: Array<{dataIndex?: number}>) => { + const p = Array.isArray(params) ? params[0] : params; + const idx = p?.dataIndex; + if (idx === undefined || idx < 0 || idx >= data.buckets.length) { + return ''; + } + const bucket = data.buckets[idx]; + const pct = + data.totalCount > 0 + ? ((bucket.count / data.totalCount) * 100).toFixed(1) + : '0'; + return [ + `Range: ${formatXValue(bucket.start)} - ${formatXValue(bucket.end)}`, + `Count: ${fmtY(bucket.count)}`, + `${pct}%`, + ].join('
'); }, - m( - 'svg.pf-histogram__svg', - { - viewBox: `0 0 ${VIEWBOX_WIDTH} ${height}`, - preserveAspectRatio: 'xMidYMid meet', - }, - [ - // Chart area group with margins and brush event handlers - m( - 'g.pf-histogram__chart-area', - { - transform: `translate(${MARGIN.left}, ${MARGIN.top})`, - ...(onBrush - ? this.brush.chartAreaAttrs( - {left: MARGIN.left, top: MARGIN.top}, - 'horizontal', - (startX, endX) => { - onBrush({ - start: chartXToValue(startX), - end: chartXToValue(endX), - }); - }, - ) - : {}), - }, - [ - // Background rect to catch clicks in gaps between bars - m('rect.pf-histogram__background', { - x: 0, - y: 0, - width: chartWidth, - height: chartHeight, - fill: 'transparent', - }), - - // Bars (hover events work directly, mousedown bubbles to parent) - data.buckets.map((bucket: HistogramBucket, i: number) => { - // Skip zero-count bars in log scale (log(0) is undefined) - if (logScale && bucket.count === 0) return null; - - const y = countToY(bucket.count); - const barHeight = chartHeight - y; - const x = i * bucketWidth; - const isHovered = this.hoveredBucket === bucket; - - return m('rect.pf-histogram__bar', { - x, - y, - width: Math.max(bucketWidth - 1, 1), - height: barHeight, - className: classNames( - isHovered && 'pf-histogram__bar--hover', - ), - onmouseenter: () => { - this.hoveredBucket = bucket; - }, - onmouseleave: () => { - this.hoveredBucket = undefined; - }, - }); - }), - - // Brush selection rectangle (visual only, no pointer events) - this.brush.renderSelection( - chartWidth, - chartHeight, - 'horizontal', - 'pf-histogram__brush-selection', - ), - - // X Axis - m( - 'g.pf-histogram__x-axis', - {transform: `translate(0, ${chartHeight})`}, - [ - // Axis line - m('line.pf-histogram__axis-line', { - x1: 0, - y1: 0, - x2: chartWidth, - y2: 0, - }), - // Ticks - ...xTicks.map((tick) => { - const x = - ((tick - data.min) / (data.max - data.min)) * chartWidth; - return m('g', {transform: `translate(${x}, 0)`}, [ - m('line.pf-histogram__tick', {y2: 5}), - m( - 'text.pf-histogram__tick-label', - { - 'y': 15, - 'text-anchor': 'middle', - 'dominant-baseline': 'middle', - }, - formatXValue(tick), - ), - ]); - }), - // Axis label - xAxisLabel && - m( - 'text.pf-histogram__axis-label', - { - 'x': chartWidth / 2, - 'y': 30, - 'text-anchor': 'middle', - }, - xAxisLabel, - ), - ], - ), + }, + brush: attrs.onBrush ? {xAxisIndex: 0, brushType: 'lineX'} : undefined, + }); + + // Add series on top of the base option + (option as Record).series = [ + { + type: 'bar', + data: data.buckets.map((b) => b.count), + barWidth: '100%', + barCategoryGap: '0%', + itemStyle: barColor !== undefined ? {color: barColor} : undefined, + emphasis: + barHoverColor !== undefined + ? {itemStyle: {color: barHoverColor}} + : theme !== undefined + ? {itemStyle: {color: theme.accentColor}} + : undefined, + }, + ]; + + return option; +} - // Y Axis - m('g.pf-histogram__y-axis', [ - // Axis line - m('line.pf-histogram__axis-line', { - x1: 0, - y1: 0, - x2: 0, - y2: chartHeight, - }), - // Ticks - ...yTicks.map((tick) => { - const y = countToY(tick); - return m('g', {transform: `translate(0, ${y})`}, [ - m('line.pf-histogram__tick', {x2: -5}), - m( - 'text.pf-histogram__tick-label', - { - 'x': -8, - 'text-anchor': 'end', - 'dominant-baseline': 'middle', - }, - formatYValue(tick), - ), - ]); - }), - // Axis label - yAxisLabel && - m( - 'text.pf-histogram__axis-label', - { - 'transform': `translate(-50, ${chartHeight / 2}) rotate(-90)`, - 'text-anchor': 'middle', - }, - yAxisLabel, - ), - ]), - ], - ), - ], - ), - // Tooltip - this.hoveredBucket && - m( - '.pf-histogram__tooltip', - m('.pf-histogram__tooltip-content', [ - m( - '.pf-histogram__tooltip-row', - `Range: ${formatXValue(this.hoveredBucket.start)} - ${formatXValue(this.hoveredBucket.end)}`, - ), - m( - '.pf-histogram__tooltip-row', - `Count: ${formatYValue(this.hoveredBucket.count)}`, - ), - data.totalCount > 0 && - m( - '.pf-histogram__tooltip-row', - `${((this.hoveredBucket.count / data.totalCount) * 100).toFixed(1)}%`, - ), - ]), - ), - ); +function buildEventHandlers( + attrs: HistogramAttrs, + data: HistogramData | undefined, +): ReadonlyArray { + if (!attrs.onBrush || data === undefined || data.buckets.length === 0) { + return []; } + const onBrush = attrs.onBrush; + const buckets = data.buckets; + + return [ + { + eventName: 'brushEnd', + handler: (params) => { + // For category axes, coordRange returns category indices + const range = extractBrushRange(params); + if (range !== undefined) { + const [startIdx, endIdx] = range; + const minIdx = Math.max(0, startIdx); + const maxIdx = Math.min(buckets.length - 1, endIdx); + if (minIdx <= maxIdx && minIdx < buckets.length) { + onBrush({ + start: buckets[minIdx].start, + end: buckets[maxIdx].end, + }); + } + } + }, + }, + ]; } diff --git a/ui/src/components/widgets/charts/histogram_loader.ts b/ui/src/components/widgets/charts/histogram_loader.ts index 18aa8ac9e5..1dd40c567a 100644 --- a/ui/src/components/widgets/charts/histogram_loader.ts +++ b/ui/src/components/widgets/charts/histogram_loader.ts @@ -15,6 +15,7 @@ import {QuerySlot, SerialTaskQueue} from '../../../base/query_slot'; import {Engine} from '../../../trace_processor/engine'; import {NUM} from '../../../trace_processor/query_result'; +import {validateColumnName} from './chart_utils'; /** * A single bucket in the histogram. @@ -405,6 +406,7 @@ export class SQLHistogramLoader implements HistogramLoader { * Creates a new SQL histogram loader. */ constructor(opts: SQLHistogramLoaderOpts) { + validateColumnName(opts.valueColumn); this.engine = opts.engine; this.baseQuery = opts.query; this.valueColumn = opts.valueColumn; diff --git a/ui/src/components/widgets/charts/line_chart.ts b/ui/src/components/widgets/charts/line_chart.ts index 878bc2b714..dea7058d50 100644 --- a/ui/src/components/widgets/charts/line_chart.ts +++ b/ui/src/components/widgets/charts/line_chart.ts @@ -13,17 +13,10 @@ // limitations under the License. import m from 'mithril'; -import {classNames} from '../../../base/classnames'; -import {Spinner} from '../../../widgets/spinner'; -import { - CHART_COLORS, - estimateTickCount, - formatNumber, - generateLogTicks, - generateTicks, - truncateLabel, -} from './chart_utils'; -import {SvgBrush} from './svg_brush'; +import type {EChartsCoreOption} from 'echarts/core'; +import {extractBrushRange, formatNumber} from './chart_utils'; +import {EChartView, EChartEventHandler} from './echart_view'; +import {buildChartOption, buildLegendOption} from './chart_option_builder'; /** * A single data point in a line chart series. @@ -132,381 +125,162 @@ export interface LineChartAttrs { * Line width in pixels. Defaults to 2. */ readonly lineWidth?: number; -} -const DEFAULT_HEIGHT = 200; -const VIEWBOX_WIDTH = 400; -const MARGIN = {top: 10, right: 10, bottom: 40, left: 65}; -const LEGEND_HEIGHT = 20; + /** + * Explicit minimum value for X axis. When set, the axis starts at this value. + */ + readonly xAxisMin?: number; -export class LineChart implements m.ClassComponent { - private hoveredPoint?: {series: LineChartSeries; point: LineChartPoint}; - private readonly brush = new SvgBrush(); + /** + * Explicit maximum value for X axis. When set, the axis ends at this value. + */ + readonly xAxisMax?: number; + /** + * When true, axis ranges are computed from data min/max instead of + * always including zero. Defaults to false. + */ + readonly scaleAxes?: boolean; +} + +export class LineChart implements m.ClassComponent { view({attrs}: m.Vnode) { - const { - data, - height = DEFAULT_HEIGHT, - xAxisLabel, - yAxisLabel, - onBrush, + const {data, height, fillParent, className, onBrush} = attrs; + + const isEmpty = + data !== undefined && + (data.series.length === 0 || + data.series.every((s) => s.points.length === 0)); + const option = + data !== undefined && !isEmpty ? buildLineOption(attrs, data) : undefined; + + return m(EChartView, { + option, + height, fillParent, className, - formatXValue = (v) => formatNumber(v), - formatYValue = (v) => formatNumber(v), - logScale = false, - integerX = false, - integerY = false, - showLegend, - showPoints = true, - lineWidth = 2, - } = attrs; - - if (data === undefined) { - return m( - '.pf-line-chart', - { - className: classNames( - fillParent && 'pf-line-chart--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-line-chart__loading', m(Spinner)), - ); - } - - if ( - data.series.length === 0 || - data.series.every((s) => s.points.length === 0) - ) { - return m( - '.pf-line-chart', - { - className: classNames( - fillParent && 'pf-line-chart--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-line-chart__empty', 'No data to display'), - ); - } - - // Determine if legend should be shown - const displayLegend = showLegend ?? data.series.length > 1; - const legendOffset = displayLegend ? LEGEND_HEIGHT : 0; - - const chartWidth = VIEWBOX_WIDTH - MARGIN.left - MARGIN.right; - const chartHeight = height - MARGIN.top - MARGIN.bottom - legendOffset; - - // Compute bounds across all series - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - - for (const series of data.series) { - for (const point of series.points) { - if (point.x < minX) minX = point.x; - if (point.x > maxX) maxX = point.x; - if (point.y < minY) minY = point.y; - if (point.y > maxY) maxY = point.y; - } - } - - // Handle edge cases - if (minX === maxX) { - minX -= 1; - maxX += 1; - } - if (minY === maxY) { - minY = 0; - maxY = maxY === 0 ? 1 : maxY * 1.1; - } - - // Start Y axis at 0 unless all values are positive and far from 0 - if (minY > 0 && minY < maxY * 0.3) { - minY = 0; - } - - // Generate ticks - const xTickCount = estimateTickCount(chartWidth, minX, maxX, formatXValue); - const xTicks = generateTicks(minX, maxX, xTickCount, integerX); - const yTicks = logScale - ? generateLogTicks(maxY) - : generateTicks(minY, maxY, 5, integerY); - - // Coordinate converters - const xToChart = (x: number): number => { - return ((x - minX) / (maxX - minX)) * chartWidth; - }; - - const yToChart = (y: number): number => { - if (logScale) { - if (y <= 0) return chartHeight; - const logMin = minY > 0 ? Math.log10(minY) : 0; - const logMax = Math.log10(maxY); - const logY = Math.log10(y); - return ( - chartHeight - ((logY - logMin) / (logMax - logMin)) * chartHeight - ); - } - return chartHeight - ((y - minY) / (maxY - minY)) * chartHeight; - }; + empty: isEmpty, + eventHandlers: buildLineEventHandlers(attrs, data), + activeBrushType: onBrush !== undefined ? 'lineX' : undefined, + }); + } +} - // Convert chart X to data value - const chartXToValue = (chartX: number): number => { - const ratio = Math.max(0, Math.min(1, chartX / chartWidth)); - return minX + ratio * (maxX - minX); +function buildLineOption( + attrs: LineChartAttrs, + data: LineChartData, +): EChartsCoreOption { + const { + xAxisLabel, + yAxisLabel, + formatXValue, + formatYValue, + logScale = false, + integerX = false, + integerY = false, + showLegend, + showPoints = true, + lineWidth = 2, + } = attrs; + const fmtX = formatXValue ?? formatNumber; + const fmtY = formatYValue ?? formatNumber; + + const displayLegend = showLegend ?? data.series.length > 1; + + const series = data.series.map((s) => { + return { + type: 'line' as const, + name: s.name, + data: s.points.map((p) => [p.x, p.y]), + lineStyle: + s.color !== undefined + ? {width: lineWidth, color: s.color} + : {width: lineWidth}, + itemStyle: s.color !== undefined ? {color: s.color} : undefined, + showSymbol: showPoints, + symbolSize: 6, + emphasis: {itemStyle: {borderWidth: 2}}, }; + }); + + const option = buildChartOption({ + grid: { + top: displayLegend ? 30 : 10, + bottom: xAxisLabel ? 40 : 25, + }, + xAxis: { + type: 'value', + name: xAxisLabel, + formatter: + formatXValue !== undefined + ? (v) => formatXValue(v as number) + : undefined, + minInterval: integerX ? 1 : undefined, + min: attrs.xAxisMin, + max: attrs.xAxisMax, + scale: attrs.scaleAxes, + }, + yAxis: { + type: logScale ? 'log' : 'value', + name: yAxisLabel, + formatter: + formatYValue !== undefined + ? (v) => formatYValue(v as number) + : undefined, + minInterval: integerY ? 1 : undefined, + scale: attrs.scaleAxes, + }, + tooltip: { + trigger: 'axis' as const, + formatter: ( + params: Array<{ + seriesName?: string; + data?: [number, number]; + marker?: string; + }>, + ) => { + if (!Array.isArray(params) || params.length === 0) return ''; + const xVal = params[0].data?.[0]; + const header = xVal !== undefined ? `X: ${fmtX(xVal)}` : ''; + const lines = params.map( + (p) => + `${p.marker ?? ''} ${p.seriesName ?? ''}: ${fmtY(p.data?.[1] ?? 0)}`, + ); + return [header, ...lines].join('
'); + }, + }, + brush: attrs.onBrush ? {xAxisIndex: 0, brushType: 'lineX'} : undefined, + legend: displayLegend ? buildLegendOption() : {show: false}, + }); - // Build path for each series - const seriesPaths = data.series.map((series, seriesIdx) => { - const color = - series.color ?? CHART_COLORS[seriesIdx % CHART_COLORS.length]; - - if (series.points.length === 0) return null; - - // Build path string - const pathParts: string[] = []; - for (let i = 0; i < series.points.length; i++) { - const point = series.points[i]; - const cx = xToChart(point.x); - const cy = yToChart(point.y); - pathParts.push(i === 0 ? `M${cx},${cy}` : `L${cx},${cy}`); - } - - return m('g.pf-line-chart__series', [ - // Line - m('path.pf-line-chart__line', { - 'd': pathParts.join(' '), - 'stroke': color, - 'stroke-width': lineWidth, - }), - // Points - showPoints && - series.points.map((point) => { - const cx = xToChart(point.x); - const cy = yToChart(point.y); - const isHovered = - this.hoveredPoint?.series === series && - this.hoveredPoint?.point === point; - return m('circle.pf-line-chart__point', { - 'cx': cx, - 'cy': cy, - 'r': isHovered ? 5 : 3, - 'fill': color, - 'stroke': 'var(--pf-color-background)', - 'stroke-width': 1, - 'onmouseenter': () => { - this.hoveredPoint = {series, point}; - }, - 'onmouseleave': () => { - this.hoveredPoint = undefined; - }, - }); - }), - ]); - }); - - const style: Record = {height: `${height}px`}; + (option as Record).series = series; + return option; +} - return m( - '.pf-line-chart', - { - className: classNames( - fillParent && 'pf-line-chart--fill-parent', - className, - ), - style, - }, - [ - m( - 'svg.pf-line-chart__svg', - { - viewBox: `0 0 ${VIEWBOX_WIDTH} ${height}`, - preserveAspectRatio: 'xMidYMid meet', - }, - [ - // Chart area - m( - 'g.pf-line-chart__chart-area', - { - transform: `translate(${MARGIN.left}, ${MARGIN.top})`, - ...(onBrush - ? this.brush.chartAreaAttrs( - {left: MARGIN.left, top: MARGIN.top}, - 'horizontal', - (startX, endX) => { - onBrush({ - start: chartXToValue(startX), - end: chartXToValue(endX), - }); - }, - ) - : {}), - }, - [ - // Background - m('rect.pf-line-chart__background', { - x: 0, - y: 0, - width: chartWidth, - height: chartHeight, - fill: 'transparent', - }), - - // Series lines and points - ...seriesPaths, - - // Brush selection - this.brush.renderSelection( - chartWidth, - chartHeight, - 'horizontal', - 'pf-line-chart__brush-selection', - ), - - // X Axis - m( - 'g.pf-line-chart__x-axis', - {transform: `translate(0, ${chartHeight})`}, - [ - m('line.pf-line-chart__axis-line', { - x1: 0, - y1: 0, - x2: chartWidth, - y2: 0, - }), - ...xTicks.map((tick) => { - const x = xToChart(tick); - return m('g', {transform: `translate(${x}, 0)`}, [ - m('line.pf-line-chart__tick', {y2: 5}), - m( - 'text.pf-line-chart__tick-label', - { - 'y': 15, - 'text-anchor': 'middle', - 'dominant-baseline': 'middle', - }, - formatXValue(tick), - ), - ]); - }), - xAxisLabel && - m( - 'text.pf-line-chart__axis-label', - { - 'x': chartWidth / 2, - 'y': 30, - 'text-anchor': 'middle', - }, - xAxisLabel, - ), - ], - ), - - // Y Axis - m('g.pf-line-chart__y-axis', [ - m('line.pf-line-chart__axis-line', { - x1: 0, - y1: 0, - x2: 0, - y2: chartHeight, - }), - ...yTicks.map((tick) => { - const y = yToChart(tick); - return m('g', {transform: `translate(0, ${y})`}, [ - m('line.pf-line-chart__tick', {x2: -5}), - m( - 'text.pf-line-chart__tick-label', - { - 'x': -8, - 'text-anchor': 'end', - 'dominant-baseline': 'middle', - }, - formatYValue(tick), - ), - ]); - }), - yAxisLabel && - m( - 'text.pf-line-chart__axis-label', - { - 'transform': `translate(-50, ${chartHeight / 2}) rotate(-90)`, - 'text-anchor': 'middle', - }, - yAxisLabel, - ), - ]), - ], - ), - - // Legend - displayLegend && - m( - 'g.pf-line-chart__legend', - { - transform: `translate(${MARGIN.left}, ${height - LEGEND_HEIGHT + 5})`, - }, - (() => { - const SWATCH_WIDTH = 15; - const GAP = 8; - const TEXT_OFFSET = SWATCH_WIDTH + 5; - const CHAR_WIDTH = 6; - const MAX_LABEL_CHARS = 10; - let xOffset = 0; - return data.series.map((series, idx) => { - const color = - series.color ?? CHART_COLORS[idx % CHART_COLORS.length]; - const label = truncateLabel(series.name, MAX_LABEL_CHARS); - const itemX = xOffset; - xOffset += TEXT_OFFSET + label.length * CHAR_WIDTH + GAP; - return m('g', {transform: `translate(${itemX}, 0)`}, [ - m('line', { - 'x1': 0, - 'y1': 5, - 'x2': SWATCH_WIDTH, - 'y2': 5, - 'stroke': color, - 'stroke-width': 2, - }), - m( - 'text.pf-line-chart__legend-label', - { - 'x': TEXT_OFFSET, - 'y': 5, - 'dominant-baseline': 'middle', - }, - label, - ), - ]); - }); - })(), - ), - ], - ), - // Tooltip - this.hoveredPoint && - m( - '.pf-line-chart__tooltip', - m('.pf-line-chart__tooltip-content', [ - m( - '.pf-line-chart__tooltip-row', - `${this.hoveredPoint.series.name}`, - ), - m( - '.pf-line-chart__tooltip-row', - `X: ${formatXValue(this.hoveredPoint.point.x)}`, - ), - m( - '.pf-line-chart__tooltip-row', - `Y: ${formatYValue(this.hoveredPoint.point.y)}`, - ), - ]), - ), - ], - ); +function buildLineEventHandlers( + attrs: LineChartAttrs, + data: LineChartData | undefined, +): ReadonlyArray { + if ( + !attrs.onBrush || + data === undefined || + data.series.length === 0 || + data.series.every((s) => s.points.length === 0) + ) { + return []; } + const onBrush = attrs.onBrush; + + return [ + { + eventName: 'brushEnd', + handler: (params) => { + const range = extractBrushRange(params); + if (range !== undefined) { + const [start, end] = range; + onBrush({start, end}); + } + }, + }, + ]; } diff --git a/ui/src/components/widgets/charts/line_chart_loader.ts b/ui/src/components/widgets/charts/line_chart_loader.ts index 5e262a345e..30d64b2d63 100644 --- a/ui/src/components/widgets/charts/line_chart_loader.ts +++ b/ui/src/components/widgets/charts/line_chart_loader.ts @@ -15,7 +15,7 @@ import {QuerySlot, SerialTaskQueue} from '../../../base/query_slot'; import {Engine} from '../../../trace_processor/engine'; import {NUM, STR_NULL} from '../../../trace_processor/query_result'; -import {sqlRangeClause} from './chart_utils'; +import {sqlRangeClause, validateColumnName} from './chart_utils'; import {LineChartData, LineChartPoint, LineChartSeries} from './line_chart'; /** @@ -89,6 +89,9 @@ export class SQLLineChartLoader { private readonly querySlot = new QuerySlot(this.taskQueue); constructor(opts: SQLLineChartLoaderOpts) { + validateColumnName(opts.xColumn); + validateColumnName(opts.yColumn); + if (opts.seriesColumn !== undefined) validateColumnName(opts.seriesColumn); this.engine = opts.engine; this.baseQuery = opts.query; this.xColumn = opts.xColumn; diff --git a/ui/src/components/widgets/charts/pie_chart.ts b/ui/src/components/widgets/charts/pie_chart.ts index 276a0ba35b..1f3bcd8f66 100644 --- a/ui/src/components/widgets/charts/pie_chart.ts +++ b/ui/src/components/widgets/charts/pie_chart.ts @@ -13,9 +13,15 @@ // limitations under the License. import m from 'mithril'; -import {classNames} from '../../../base/classnames'; -import {Spinner} from '../../../widgets/spinner'; -import {CHART_COLORS, formatNumber, truncateLabel} from './chart_utils'; +import type {EChartsCoreOption} from 'echarts/core'; +import {formatNumber} from './chart_utils'; +import { + EChartView, + EChartEventHandler, + EChartClickParams, + getPerfettoThemeColors, +} from './echart_view'; +import {buildLegendOption} from './chart_option_builder'; /** * A single slice in the pie chart. @@ -86,268 +92,105 @@ export interface PieChartAttrs { readonly onSliceClick?: (slice: PieChartSlice) => void; } -const DEFAULT_HEIGHT = 200; -const VIEWBOX_SIZE = 200; -const CENTER = VIEWBOX_SIZE / 2; -const LEGEND_WIDTH = 120; - export class PieChart implements m.ClassComponent { - private hoveredSlice?: PieChartSlice; - view({attrs}: m.Vnode) { - const { - data, - height = DEFAULT_HEIGHT, - fillParent, - className, - formatValue = (v) => formatNumber(v), - showLegend = true, - showLabels = false, - innerRadiusRatio = 0, - onSliceClick, - } = attrs; - - if (data === undefined) { - return m( - '.pf-pie-chart', - { - className: classNames( - fillParent && 'pf-pie-chart--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-pie-chart__loading', m(Spinner)), - ); - } - - const validSlices = data.slices.filter((s) => s.value > 0); - if (validSlices.length === 0) { - return m( - '.pf-pie-chart', - { - className: classNames( - fillParent && 'pf-pie-chart--fill-parent', - className, - ), - style: {height: `${height}px`}, - }, - m('.pf-pie-chart__empty', 'No data to display'), - ); - } - - const total = validSlices.reduce((sum, s) => sum + s.value, 0); - const outerRadius = Math.min(height, VIEWBOX_SIZE) / 2 - 10; - const innerRadius = outerRadius * innerRadiusRatio; + const {data, height, fillParent, className} = attrs; - // Calculate viewBox width based on whether legend is shown - const viewBoxWidth = showLegend - ? VIEWBOX_SIZE + LEGEND_WIDTH - : VIEWBOX_SIZE; + const validSlices = data?.slices.filter((s) => s.value > 0) ?? []; + const isEmpty = data !== undefined && validSlices.length === 0; + const option = + validSlices.length > 0 ? buildPieOption(attrs, validSlices) : undefined; - // Build slice paths - let currentAngle = -Math.PI / 2; // Start at top - const sliceElements = validSlices.map((slice, idx) => { - const sliceAngle = (slice.value / total) * 2 * Math.PI; - const startAngle = currentAngle; - const endAngle = currentAngle + sliceAngle; - currentAngle = endAngle; - - const color = slice.color ?? CHART_COLORS[idx % CHART_COLORS.length]; - const isHovered = this.hoveredSlice === slice; - - // Calculate arc path - const path = describeArc( - CENTER, - CENTER, - isHovered ? outerRadius + 5 : outerRadius, - innerRadius, - startAngle, - endAngle, - ); - - // Calculate label position (middle of slice) - const labelAngle = startAngle + sliceAngle / 2; - const labelRadius = (outerRadius + innerRadius) / 2; - const labelX = CENTER + Math.cos(labelAngle) * labelRadius; - const labelY = CENTER + Math.sin(labelAngle) * labelRadius; - const percentage = ((slice.value / total) * 100).toFixed(1); - - return m('g.pf-pie-chart__slice-group', [ - m('path.pf-pie-chart__slice', { - 'd': path, - 'fill': color, - 'stroke': 'var(--pf-color-background)', - 'stroke-width': 2, - 'className': classNames(isHovered && 'pf-pie-chart__slice--hover'), - 'onmouseenter': () => { - this.hoveredSlice = slice; - }, - 'onmouseleave': () => { - this.hoveredSlice = undefined; - }, - 'onclick': onSliceClick - ? () => { - onSliceClick(slice); - } - : undefined, - 'style': { - cursor: onSliceClick ? 'pointer' : 'default', - }, - }), - showLabels && - sliceAngle > 0.3 && // Only show label if slice is large enough - m( - 'text.pf-pie-chart__slice-label', - { - 'x': labelX, - 'y': labelY, - 'text-anchor': 'middle', - 'dominant-baseline': 'middle', - 'pointer-events': 'none', - }, - `${percentage}%`, - ), - ]); + return m(EChartView, { + option, + height, + fillParent, + className, + empty: isEmpty, + eventHandlers: buildPieEventHandlers(attrs, validSlices), }); + } +} - // Build legend - const legendElements = showLegend - ? validSlices.map((slice, idx) => { - const color = slice.color ?? CHART_COLORS[idx % CHART_COLORS.length]; - const percentage = ((slice.value / total) * 100).toFixed(1); - const yOffset = idx * 18; - const isHovered = this.hoveredSlice === slice; - return m( - 'g.pf-pie-chart__legend-item', - { - transform: `translate(${VIEWBOX_SIZE + 10}, ${20 + yOffset})`, - onmouseenter: () => { - this.hoveredSlice = slice; - }, - onmouseleave: () => { - this.hoveredSlice = undefined; - }, - }, - [ - m('rect', { - x: 0, - y: -6, - width: 12, - height: 12, - fill: color, - rx: 2, - }), - m( - 'text.pf-pie-chart__legend-label', - { - 'x': 18, - 'y': 0, - 'dominant-baseline': 'middle', - 'className': classNames( - isHovered && 'pf-pie-chart__legend-label--hover', - ), - }, - `${truncateLabel(slice.label, 12)} `, - m('tspan.pf-pie-chart__legend-value', `(${percentage}%)`), - ), - ], - ); - }) - : null; - - const style: Record = {height: `${height}px`}; +function buildPieOption( + attrs: PieChartAttrs, + slices: readonly PieChartSlice[], +): EChartsCoreOption { + const { + formatValue = (v: number) => formatNumber(v), + showLegend = true, + showLabels = false, + innerRadiusRatio = 0, + } = attrs; + + // Only get theme for border color (not themed by ECharts) + const theme = getPerfettoThemeColors(); + + const pieData = slices.map((s) => ({ + name: s.label, + value: s.value, + itemStyle: s.color !== undefined ? {color: s.color} : undefined, + })); + + const outerPct = showLegend ? 65 : 75; + const outerRadius = `${outerPct}%`; + const innerRadius = `${Math.round(outerPct * innerRadiusRatio)}%`; - return m( - '.pf-pie-chart', + return { + animation: false, + tooltip: { + trigger: 'item' as const, + formatter: (params: { + name?: string; + value?: number; + percent?: number; + }) => { + const name = params.name ?? ''; + const value = params.value ?? 0; + const pct = params.percent?.toFixed(1) ?? '0'; + return [name, `Value: ${formatValue(value)}`, `${pct}%`].join('
'); + }, + }, + legend: showLegend ? buildLegendOption('right') : {show: false}, + series: [ { - className: classNames( - fillParent && 'pf-pie-chart--fill-parent', - className, - ), - style, + type: 'pie', + radius: [innerRadius, outerRadius], + center: showLegend ? ['35%', '50%'] : ['50%', '50%'], + data: pieData, + label: { + show: showLabels, + formatter: '{d}%', + fontSize: 10, + }, + emphasis: { + scaleSize: 5, + }, + itemStyle: { + borderColor: theme.backgroundColor, + borderWidth: 2, + }, }, - [ - m( - 'svg.pf-pie-chart__svg', - { - viewBox: `0 0 ${viewBoxWidth} ${VIEWBOX_SIZE}`, - preserveAspectRatio: 'xMidYMid meet', - }, - [ - // Slices - m('g.pf-pie-chart__slices', sliceElements), - // Legend - legendElements, - ], - ), - // Tooltip - this.hoveredSlice && - m( - '.pf-pie-chart__tooltip', - m('.pf-pie-chart__tooltip-content', [ - m('.pf-pie-chart__tooltip-row', this.hoveredSlice.label), - m( - '.pf-pie-chart__tooltip-row', - `Value: ${formatValue(this.hoveredSlice.value)}`, - ), - m( - '.pf-pie-chart__tooltip-row', - `${((this.hoveredSlice.value / total) * 100).toFixed(1)}%`, - ), - ]), - ), - ], - ); - } + ], + }; } -/** - * Generate an SVG arc path for a slice. - */ -function describeArc( - cx: number, - cy: number, - outerRadius: number, - innerRadius: number, - startAngle: number, - endAngle: number, -): string { - const startOuter = polarToCartesian(cx, cy, outerRadius, startAngle); - const endOuter = polarToCartesian(cx, cy, outerRadius, endAngle); - const startInner = polarToCartesian(cx, cy, innerRadius, startAngle); - const endInner = polarToCartesian(cx, cy, innerRadius, endAngle); - - const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0; - - if (innerRadius === 0) { - // Pie slice (no hole) - return [ - `M ${cx} ${cy}`, - `L ${startOuter.x} ${startOuter.y}`, - `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${endOuter.x} ${endOuter.y}`, - 'Z', - ].join(' '); - } - - // Donut slice (with hole) +function buildPieEventHandlers( + attrs: PieChartAttrs, + slices: readonly PieChartSlice[], +): ReadonlyArray { + if (!attrs.onSliceClick || slices.length === 0) return []; + const onSliceClick = attrs.onSliceClick; return [ - `M ${startOuter.x} ${startOuter.y}`, - `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${endOuter.x} ${endOuter.y}`, - `L ${endInner.x} ${endInner.y}`, - `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${startInner.x} ${startInner.y}`, - 'Z', - ].join(' '); -} - -function polarToCartesian( - cx: number, - cy: number, - radius: number, - angle: number, -): {x: number; y: number} { - return { - x: cx + radius * Math.cos(angle), - y: cy + radius * Math.sin(angle), - }; + { + eventName: 'click', + handler: (params) => { + const p = params as EChartClickParams; + const idx = p.dataIndex; + if (idx !== undefined && idx >= 0 && idx < slices.length) { + onSliceClick(slices[idx]); + } + }, + }, + ]; } diff --git a/ui/src/components/widgets/charts/pie_chart_loader.ts b/ui/src/components/widgets/charts/pie_chart_loader.ts index 32c8c7ed11..a7942c7c9d 100644 --- a/ui/src/components/widgets/charts/pie_chart_loader.ts +++ b/ui/src/components/widgets/charts/pie_chart_loader.ts @@ -15,7 +15,12 @@ import {QuerySlot, SerialTaskQueue} from '../../../base/query_slot'; import {Engine} from '../../../trace_processor/engine'; import {NUM, STR_NULL} from '../../../trace_processor/query_result'; -import {AggregationType, sqlAggExpression, sqlInClause} from './chart_utils'; +import { + AggregationType, + sqlAggExpression, + sqlInClause, + validateColumnName, +} from './chart_utils'; import {PieChartData, PieChartSlice} from './pie_chart'; /** @@ -89,6 +94,8 @@ export class SQLPieChartLoader { private readonly querySlot = new QuerySlot(this.taskQueue); constructor(opts: SQLPieChartLoaderOpts) { + validateColumnName(opts.dimensionColumn); + validateColumnName(opts.measureColumn); this.engine = opts.engine; this.baseQuery = opts.query; this.dimensionColumn = opts.dimensionColumn; diff --git a/ui/src/components/widgets/charts/scatter_chart.ts b/ui/src/components/widgets/charts/scatter_chart.ts new file mode 100644 index 0000000000..d1ad22764e --- /dev/null +++ b/ui/src/components/widgets/charts/scatter_chart.ts @@ -0,0 +1,320 @@ +// 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 {extractBrushRange, formatNumber} from './chart_utils'; +import { + EChartView, + EChartEventHandler, + getPerfettoThemeColors, +} from './echart_view'; +import {buildChartOption, buildLegendOption} from './chart_option_builder'; + +/** + * A single data point in a scatter chart. + */ +export interface ScatterChartPoint { + /** X-axis value */ + readonly x: number; + /** Y-axis value */ + readonly y: number; + /** Optional bubble size (for bubble charts) */ + readonly size?: number; + /** Optional per-point color */ + readonly color?: string; + /** Optional tooltip label */ + readonly label?: string; +} + +/** + * A series (group) of points in the scatter chart. + */ +export interface ScatterChartSeries { + /** Display name for this series (shown in legend) */ + readonly name: string; + /** Data points for this series */ + readonly points: readonly ScatterChartPoint[]; + /** Optional custom color for this series (applies to all points without individual color) */ + readonly color?: string; +} + +/** + * Data provided to a ScatterChart. + */ +export interface ScatterChartData { + /** The series to display */ + readonly series: readonly ScatterChartSeries[]; +} + +export interface ScatterChartAttrs { + /** + * Scatter chart data to display, or undefined if loading. + * When undefined, a loading spinner is shown. + */ + readonly data: ScatterChartData | undefined; + + /** + * Height of the chart in pixels. Defaults to 200. + */ + readonly height?: number; + + /** + * Label for the X axis. + */ + readonly xAxisLabel?: string; + + /** + * Label for the Y axis. + */ + readonly yAxisLabel?: string; + + /** + * Callback when brush selection completes (on mouseup). + * Called with the selected X range. + */ + readonly onBrush?: (range: {start: number; end: number}) => void; + + /** + * Fill parent container. Defaults to false. + */ + readonly fillParent?: boolean; + + /** + * Custom class name for the container. + */ + readonly className?: string; + + /** + * Format function for X axis tick values. + */ + readonly formatXValue?: (value: number) => string; + + /** + * Format function for Y axis tick values. + */ + readonly formatYValue?: (value: number) => string; + + /** + * Use logarithmic scale for X axis. Defaults to false. + */ + readonly logScaleX?: boolean; + + /** + * Use logarithmic scale for Y axis. Defaults to false. + */ + readonly logScaleY?: boolean; + + /** + * Show legend. Defaults to true when multiple series. + */ + readonly showLegend?: boolean; + + /** + * Default symbol size for points without explicit size. + * Defaults to 8. + */ + readonly symbolSize?: number; + + /** + * Min/max symbol size for bubble charts (when points have size values). + * Defaults to [5, 30]. + */ + readonly symbolSizeRange?: [number, number]; + + /** + * When true, axis ranges are computed from data min/max instead of + * always including zero. Defaults to false. + */ + readonly scaleAxes?: boolean; +} + +export class ScatterChart implements m.ClassComponent { + view({attrs}: m.Vnode) { + const {data, height, fillParent, className, onBrush} = attrs; + + const isEmpty = + data !== undefined && + (data.series.length === 0 || + data.series.every((s) => s.points.length === 0)); + const option = + data !== undefined && !isEmpty + ? buildScatterOption(attrs, data) + : undefined; + + return m(EChartView, { + option, + height, + fillParent, + className, + empty: isEmpty, + eventHandlers: buildScatterEventHandlers(attrs), + activeBrushType: onBrush !== undefined ? 'lineX' : undefined, + }); + } +} + +function buildScatterOption( + attrs: ScatterChartAttrs, + data: ScatterChartData, +): EChartsCoreOption { + const { + xAxisLabel, + yAxisLabel, + formatXValue, + formatYValue, + logScaleX = false, + logScaleY = false, + showLegend, + symbolSize = 8, + symbolSizeRange = [5, 30], + } = attrs; + const fmtX = formatXValue ?? formatNumber; + const fmtY = formatYValue ?? formatNumber; + + // Only get theme for emphasis border color (not themed by ECharts) + const theme = getPerfettoThemeColors(); + const displayLegend = showLegend ?? data.series.length > 1; + + // Compute size range for normalization if any points have sizes + let minSize = Infinity; + let maxSize = -Infinity; + for (const s of data.series) { + for (const p of s.points) { + if (p.size !== undefined) { + minSize = Math.min(minSize, p.size); + maxSize = Math.max(maxSize, p.size); + } + } + } + const hasSizes = minSize !== Infinity; + const sizeRange = maxSize - minSize || 1; + + const series = data.series.map((s) => { + return { + type: 'scatter' as const, + name: s.name, + // ECharts scatter series requires data as arrays with positional indices: + // [0]: x value (number) + // [1]: y value (number) + // [2]: size value (number | null) - used for bubble sizing + // [3]: label (string | undefined) - used for tooltip display + // This positional format is mandated by ECharts API for scatter/bubble. + data: s.points.map((p) => { + const pointData: [number, number, ...unknown[]] = [p.x, p.y]; + if (p.size !== undefined) { + pointData.push(p.size); + } else if (p.label !== undefined) { + // Placeholder null so label is always at index 3 + pointData.push(null); + } + if (p.label !== undefined) pointData.push(p.label); + return { + value: pointData, + itemStyle: p.color !== undefined ? {color: p.color} : undefined, + }; + }), + symbolSize: hasSizes + ? (value: Array) => { + const size = value.length > 2 ? value[2] : undefined; + if (size === undefined || size === null) return symbolSize; + const normalized = (size - minSize) / sizeRange; + return ( + symbolSizeRange[0] + + normalized * (symbolSizeRange[1] - symbolSizeRange[0]) + ); + } + : symbolSize, + itemStyle: s.color !== undefined ? {color: s.color} : undefined, + emphasis: { + itemStyle: {borderWidth: 2, borderColor: theme.backgroundColor}, + }, + }; + }); + + const option = buildChartOption({ + grid: { + top: displayLegend ? 30 : 10, + bottom: xAxisLabel !== undefined ? 40 : 25, + }, + xAxis: { + type: logScaleX ? 'log' : 'value', + name: xAxisLabel, + formatter: + formatXValue !== undefined + ? (v) => formatXValue(v as number) + : undefined, + scale: attrs.scaleAxes, + }, + yAxis: { + type: logScaleY ? 'log' : 'value', + name: yAxisLabel, + formatter: + formatYValue !== undefined + ? (v) => formatYValue(v as number) + : undefined, + scale: attrs.scaleAxes, + }, + tooltip: { + trigger: 'item' as const, + formatter: (params: { + seriesName?: string; + value?: [number, number, (number | null)?, string?]; + color?: string; + marker?: string; + }) => { + const value = params.value; + if (value === undefined) return ''; + const [x, y, size, label] = value; + const lines = [ + `${params.marker ?? ''} ${params.seriesName ?? ''}`, + `X: ${fmtX(x)}`, + `Y: ${fmtY(y)}`, + ]; + if (size !== undefined && size !== null) { + lines.push(`Size: ${formatNumber(size)}`); + } + if (label !== undefined) lines.push(label); + return lines.join('
'); + }, + }, + brush: attrs.onBrush + ? {xAxisIndex: 0, brushType: 'lineX' as const} + : undefined, + legend: displayLegend ? buildLegendOption() : {show: false}, + }); + + (option as Record).series = series; + return option; +} + +function buildScatterEventHandlers( + attrs: ScatterChartAttrs, +): ReadonlyArray { + if (!attrs.onBrush) return []; + const onBrush = attrs.onBrush; + + return [ + { + eventName: 'brushEnd', + handler: (params) => { + const range = extractBrushRange(params); + if (range !== undefined) { + const [start, end] = range; + onBrush({start, end}); + } + }, + }, + ]; +} diff --git a/ui/src/components/widgets/charts/scatter_chart_loader.ts b/ui/src/components/widgets/charts/scatter_chart_loader.ts new file mode 100644 index 0000000000..71ee8c88d8 --- /dev/null +++ b/ui/src/components/widgets/charts/scatter_chart_loader.ts @@ -0,0 +1,249 @@ +// 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 {QuerySlot, SerialTaskQueue} from '../../../base/query_slot'; +import {Engine} from '../../../trace_processor/engine'; +import {NUM, NUM_NULL, STR_NULL} from '../../../trace_processor/query_result'; +import {sqlRangeClause, validateColumnName} from './chart_utils'; +import { + ScatterChartData, + ScatterChartPoint, + ScatterChartSeries, +} from './scatter_chart'; + +/** + * Configuration for SQLScatterChartLoader. + */ +export interface SQLScatterChartLoaderOpts { + /** The trace processor engine to run queries against. */ + readonly engine: Engine; + + /** + * SQL query that provides the raw data. + * Must include x and y columns (and optionally size, color, series columns). + */ + readonly query: string; + + /** Column name for the X axis (numeric). */ + readonly xColumn: string; + + /** Column name for the Y axis (numeric). */ + readonly yColumn: string; + + /** Optional column for bubble size (numeric). */ + readonly sizeColumn?: string; + + /** Optional column for per-point label. */ + readonly labelColumn?: string; + + /** + * Optional column for grouping points into separate series. + * When omitted, all points belong to a single series. + */ + readonly seriesColumn?: string; +} + +/** + * Per-use configuration for the scatter chart loader. + */ +export interface ScatterChartLoaderConfig { + /** + * Filter to only include points within this X range. + */ + readonly xRange?: {readonly min: number; readonly max: number}; + + /** + * Filter to only include points within this Y range. + */ + readonly yRange?: {readonly min: number; readonly max: number}; + + /** + * Maximum number of points per series. When exceeded, random sampling + * is applied. Defaults to no limit. + */ + readonly maxPoints?: number; +} + +/** + * Result returned by the scatter chart loader. + */ +export interface ScatterChartLoaderResult { + /** The computed scatter chart data, or undefined if loading. */ + readonly data: ScatterChartData | undefined; + + /** Whether a query is currently pending. */ + readonly isPending: boolean; +} + +/** + * SQL-based scatter chart loader with async loading and caching. + * + * Fetches (x, y) points with optional size and series grouping from SQL. + * Uses QuerySlot for caching and request deduplication. + */ +export class SQLScatterChartLoader { + private readonly engine: Engine; + private readonly baseQuery: string; + private readonly xColumn: string; + private readonly yColumn: string; + private readonly sizeColumn: string | undefined; + private readonly labelColumn: string | undefined; + private readonly seriesColumn: string | undefined; + private readonly taskQueue = new SerialTaskQueue(); + private readonly querySlot = new QuerySlot(this.taskQueue); + + constructor(opts: SQLScatterChartLoaderOpts) { + validateColumnName(opts.xColumn); + validateColumnName(opts.yColumn); + if (opts.sizeColumn !== undefined) validateColumnName(opts.sizeColumn); + if (opts.labelColumn !== undefined) validateColumnName(opts.labelColumn); + if (opts.seriesColumn !== undefined) validateColumnName(opts.seriesColumn); + this.engine = opts.engine; + this.baseQuery = opts.query; + this.xColumn = opts.xColumn; + this.yColumn = opts.yColumn; + this.sizeColumn = opts.sizeColumn; + this.labelColumn = opts.labelColumn; + this.seriesColumn = opts.seriesColumn; + } + + use(config: ScatterChartLoaderConfig): ScatterChartLoaderResult { + const result = this.querySlot.use({ + key: { + baseQuery: this.baseQuery, + xColumn: this.xColumn, + yColumn: this.yColumn, + sizeColumn: this.sizeColumn, + labelColumn: this.labelColumn, + seriesColumn: this.seriesColumn, + xRange: config.xRange, + yRange: config.yRange, + maxPoints: config.maxPoints, + }, + queryFn: async () => { + const xCol = this.xColumn; + const yCol = this.yColumn; + + const filterClauses: string[] = []; + if (config.xRange !== undefined) { + filterClauses.push(sqlRangeClause(xCol, config.xRange)); + } + if (config.yRange !== undefined) { + filterClauses.push(sqlRangeClause(yCol, config.yRange)); + } + const whereClause = + filterClauses.length > 0 + ? `WHERE ${filterClauses.join(' AND ')}` + : ''; + + const sizeExpr = + this.sizeColumn !== undefined + ? `CAST(${this.sizeColumn} AS REAL)` + : 'NULL'; + const labelExpr = + this.labelColumn !== undefined + ? `CAST(${this.labelColumn} AS TEXT)` + : 'NULL'; + const seriesExpr = + this.seriesColumn !== undefined + ? `CAST(${this.seriesColumn} AS TEXT)` + : 'NULL'; + + const orderBy = + this.seriesColumn !== undefined ? 'ORDER BY _series' : ''; + + const sql = ` + SELECT + CAST(${xCol} AS REAL) AS _x, + CAST(${yCol} AS REAL) AS _y, + ${sizeExpr} AS _size, + ${labelExpr} AS _label, + ${seriesExpr} AS _series + FROM (${this.baseQuery}) + ${whereClause} + ${orderBy} + `; + + const queryResult = await this.engine.query(sql); + + // Group points by series + const seriesMap = new Map(); + const defaultName = this.seriesColumn !== undefined ? '' : 'Points'; + + const iter = queryResult.iter({ + _x: NUM, + _y: NUM, + _size: NUM_NULL, + _label: STR_NULL, + _series: STR_NULL, + }); + + for (; iter.valid(); iter.next()) { + const name = iter._series ?? defaultName; + let points = seriesMap.get(name); + if (points === undefined) { + points = []; + seriesMap.set(name, points); + } + const point: ScatterChartPoint = { + x: iter._x, + y: iter._y, + ...(iter._size !== null && {size: iter._size}), + ...(iter._label !== null && {label: iter._label}), + }; + points.push(point); + } + + // Apply max points limit with sampling if needed + const series: ScatterChartSeries[] = []; + for (const [name, points] of seriesMap) { + const sampledPoints = + config.maxPoints !== undefined && points.length > config.maxPoints + ? samplePoints(points, config.maxPoints) + : points; + series.push({name, points: sampledPoints}); + } + + return {series}; + }, + }); + + return { + data: result.data, + isPending: result.isPending, + }; + } + + dispose(): void { + this.querySlot.dispose(); + } +} + +/** + * Deterministically sample points to reduce to maxPoints. + * Uses stride-based sampling for uniform distribution across the dataset. + */ +function samplePoints( + points: ScatterChartPoint[], + maxPoints: number, +): ScatterChartPoint[] { + if (points.length <= maxPoints) return points; + + const step = points.length / maxPoints; + const result: ScatterChartPoint[] = []; + for (let i = 0; i < maxPoints; i++) { + result.push(points[Math.floor(i * step)]); + } + return result; +} diff --git a/ui/src/components/widgets/charts/svg_brush.ts b/ui/src/components/widgets/charts/svg_brush.ts deleted file mode 100644 index 74a802d4e4..0000000000 --- a/ui/src/components/widgets/charts/svg_brush.ts +++ /dev/null @@ -1,155 +0,0 @@ -// 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'; - -export type BrushDirection = 'horizontal' | 'vertical'; - -/** - * Reusable SVG brush controller for chart components. - * - * Manages brush state (start/end positions in chart-pixel space), - * provides pointer event handlers for the chart area element, - * and renders the visual selection rectangle. - * - * Supports both horizontal (X-axis) and vertical (Y-axis) brushing. - * Each chart maps the resulting pixel range to its own domain - * (e.g. continuous data values for Histogram, bar labels for BarChart). - */ -export class SvgBrush { - private start?: number; - private end?: number; - - /** - * Whether a brush drag is currently in progress. - */ - get isActive(): boolean { - return this.start !== undefined && this.end !== undefined; - } - - /** - * Returns the current brush range in chart-pixel coordinates, - * with start <= end. Returns undefined if no brush is active. - */ - get range(): {start: number; end: number} | undefined { - if (this.start === undefined || this.end === undefined) { - return undefined; - } - return { - start: Math.min(this.start, this.end), - end: Math.max(this.start, this.end), - }; - } - - /** - * Returns mithril attrs to apply to the chart area element. - * Handles pointer capture, tracking, and completion. - * - * @param margin The margin offset of the chart area within the SVG viewBox. - * @param margin.left Left margin in viewBox units. - * @param margin.top Top margin in viewBox units. - * @param direction Which axis the brush operates on. - * @param onComplete Called with (start, end) in chart-pixel space when the - * brush drag completes. start <= end is guaranteed. - */ - chartAreaAttrs( - margin: {left: number; top: number}, - direction: BrushDirection, - onComplete: (start: number, end: number) => void, - ): object { - const toChartCoord = (e: PointerEvent) => - clientToChartCoord(e, margin, direction); - return { - style: { - cursor: direction === 'horizontal' ? 'col-resize' : 'row-resize', - }, - onpointerdown: (e: PointerEvent) => { - (e.currentTarget as Element).setPointerCapture(e.pointerId); - const coord = toChartCoord(e); - this.start = coord; - this.end = coord; - }, - onpointermove: (e: PointerEvent) => { - if (this.start === undefined) return; - this.end = toChartCoord(e); - }, - onpointerup: (e: PointerEvent) => { - (e.currentTarget as Element).releasePointerCapture(e.pointerId); - if (this.start !== undefined && this.end !== undefined) { - const lo = Math.min(this.start, this.end); - const hi = Math.max(this.start, this.end); - onComplete(lo, hi); - } - this.start = undefined; - this.end = undefined; - }, - }; - } - - /** - * Renders the brush selection rectangle overlay. - * Returns null if no brush is active. - * - * @param chartWidth Width of the chart area in viewBox units. - * @param chartHeight Height of the chart area in viewBox units. - * @param direction Which axis the brush operates on. - * @param cssClass CSS class for the rectangle. - */ - renderSelection( - chartWidth: number, - chartHeight: number, - direction: BrushDirection, - cssClass: string, - ): m.Children { - const r = this.range; - if (r === undefined) return null; - if (direction === 'horizontal') { - return m(`rect.${cssClass}`, { - x: r.start, - y: 0, - width: Math.max(0, r.end - r.start), - height: chartHeight, - }); - } - return m(`rect.${cssClass}`, { - x: 0, - y: r.start, - width: chartWidth, - height: Math.max(0, r.end - r.start), - }); - } -} - -/** - * Convert a pointer event to a chart-relative coordinate. - * Uses SVG's coordinate transformation to handle viewBox scaling. - */ -function clientToChartCoord( - e: PointerEvent, - margin: {left: number; top: number}, - direction: BrushDirection, -): number { - const group = e.currentTarget as SVGGElement; - const svg = group.ownerSVGElement; - if (svg === null) return 0; - const point = svg.createSVGPoint(); - point.x = e.clientX; - point.y = e.clientY; - const ctm = svg.getScreenCTM(); - if (ctm === null) return 0; - const svgPoint = point.matrixTransform(ctm.inverse()); - return direction === 'horizontal' - ? svgPoint.x - margin.left - : svgPoint.y - margin.top; -} diff --git a/ui/src/components/widgets/charts/treemap_chart.ts b/ui/src/components/widgets/charts/treemap_chart.ts new file mode 100644 index 0000000000..fdfd68b351 --- /dev/null +++ b/ui/src/components/widgets/charts/treemap_chart.ts @@ -0,0 +1,317 @@ +// 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 {formatNumber} from './chart_utils'; +import { + EChartView, + EChartEventHandler, + EChartClickParams, + getPerfettoThemeColors, +} from './echart_view'; + +/** + * A node in the treemap hierarchy. + */ +export interface TreemapNode { + /** Display name for this node */ + readonly name: string; + /** Size value (determines rectangle area) */ + readonly value: number; + /** Optional category for coloring (uses theme chart color palette) */ + readonly category?: string; + /** Optional children for hierarchical treemaps */ + readonly children?: readonly TreemapNode[]; +} + +/** + * Data provided to a TreemapChart. + */ +export interface TreemapData { + /** Top-level nodes (can have nested children) */ + readonly nodes: readonly TreemapNode[]; +} + +export interface TreemapChartAttrs { + /** + * Treemap data to display, or undefined if loading. + * When undefined, a loading spinner is shown. + */ + readonly data: TreemapData | undefined; + + /** + * 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; + + /** + * Format function for values in tooltips. + */ + readonly formatValue?: (value: number) => string; + + /** + * Callback when a node is clicked. + */ + readonly onNodeClick?: (node: TreemapNode) => void; + + /** + * Minimum visible rectangle size. Nodes smaller than this are hidden. + * Defaults to 10. + */ + readonly visibleMin?: number; + + /** + * Show labels on rectangles. Defaults to true. + */ + readonly showLabels?: boolean; + + /** + * Enable drill-down on click. Defaults to false. + * When true, clicking a parent node zooms into it. + */ + readonly enableDrillDown?: boolean; +} + +export class TreemapChart implements m.ClassComponent { + view({attrs}: m.Vnode) { + const {data, height, fillParent, className} = attrs; + + const isEmpty = data !== undefined && data.nodes.length === 0; + const option = + data !== undefined && !isEmpty + ? buildTreemapOption(attrs, data) + : undefined; + + return m(EChartView, { + option, + height, + fillParent, + className, + empty: isEmpty, + eventHandlers: buildTreemapEventHandlers(attrs, data), + }); + } +} + +function buildTreemapOption( + attrs: TreemapChartAttrs, + data: TreemapData, +): EChartsCoreOption { + const { + formatValue = (v: number) => formatNumber(v), + visibleMin = 10, + showLabels = true, + enableDrillDown = false, + } = attrs; + + const theme = getPerfettoThemeColors(); + + // Build category-to-color mapping + const categoryColors = new Map(); + assignColors(data.nodes, categoryColors, theme.chartColors); + + const total = data.nodes.reduce((sum, n) => sum + computeTotal(n), 0); + + return { + animation: false, + tooltip: { + trigger: 'item' as const, + formatter: (params: {name?: string; value?: number}) => { + const name = params.name ?? ''; + const value = params.value ?? 0; + const pct = total > 0 ? ((value / total) * 100).toFixed(1) : '0'; + return [name, `Value: ${formatValue(value)}`, `${pct}%`].join('
'); + }, + }, + series: [ + { + type: 'treemap', + data: convertNodes(data.nodes, categoryColors), + roam: enableDrillDown ? 'move' : false, + nodeClick: enableDrillDown ? 'zoomToNode' : false, + visibleMin, + label: { + show: showLabels, + formatter: '{b}', + fontSize: 11, + color: theme.textColor, + }, + itemStyle: { + borderColor: theme.backgroundColor, + borderWidth: 2, + gapWidth: 2, + }, + breadcrumb: enableDrillDown + ? { + show: true, + itemStyle: { + textStyle: {color: theme.textColor}, + }, + } + : {show: false}, + levels: [ + { + // Level 0: parent groups + itemStyle: { + borderColor: theme.borderColor, + borderWidth: 3, + gapWidth: 3, + }, + upperLabel: { + show: true, + height: 20, + color: theme.textColor, + fontSize: 12, + fontWeight: 'bold' as const, + }, + }, + { + // Level 1: children + colorSaturation: [0.35, 0.65], + itemStyle: { + borderColor: theme.backgroundColor, + borderWidth: 1, + gapWidth: 1, + }, + }, + { + // Level 2+: deeper children (if any) + colorSaturation: [0.25, 0.55], + itemStyle: { + borderColor: theme.backgroundColor, + borderWidth: 1, + gapWidth: 1, + }, + }, + ], + }, + ], + }; +} + +/** + * Recursively assign colors to categories found in nodes. + */ +function assignColors( + nodes: readonly TreemapNode[], + categoryColors: Map, + chartColors: readonly string[], +): void { + for (const node of nodes) { + const category = node.category ?? node.name; + if (!categoryColors.has(category)) { + categoryColors.set( + category, + chartColors[categoryColors.size % chartColors.length], + ); + } + if (node.children !== undefined) { + assignColors(node.children, categoryColors, chartColors); + } + } +} + +/** + * Convert TreemapNode tree to ECharts data format. + */ +function convertNodes( + nodes: readonly TreemapNode[], + categoryColors: Map, +): Array> { + return nodes.map((node) => { + const category = node.category ?? node.name; + const color = categoryColors.get(category); + const children = node.children; + const hasChildren = children !== undefined && children.length > 0; + // For nodes with children, use computed value from children if value is 0 + const value = + hasChildren && node.value === 0 ? computeTotal(node) : node.value; + const result: Record = { + name: node.name, + value, + itemStyle: {color}, + }; + if (hasChildren) { + result.children = convertNodes(children, categoryColors); + } + return result; + }); +} + +/** + * Compute total value including children. + */ +function computeTotal(node: TreemapNode): number { + if (node.children !== undefined && node.children.length > 0) { + return node.children.reduce((sum, c) => sum + computeTotal(c), 0); + } + return node.value; +} + +function buildTreemapEventHandlers( + attrs: TreemapChartAttrs, + data: TreemapData | undefined, +): ReadonlyArray { + if (!attrs.onNodeClick || data === undefined) return []; + + // Build name-to-node mapping for click handler + const nodeMap = new Map(); + buildNodeMap(data.nodes, nodeMap); + + const onNodeClick = attrs.onNodeClick; + + return [ + { + eventName: 'click', + handler: (params) => { + const p = params as EChartClickParams; + if (p.name !== undefined) { + const node = nodeMap.get(p.name); + if (node !== undefined) { + onNodeClick(node); + } + } + }, + }, + ]; +} + +/** + * Recursively build a name-to-node map for click handler lookups. + * First occurrence wins when names collide across tree levels. + */ +function buildNodeMap( + nodes: readonly TreemapNode[], + nodeMap: Map, +): void { + for (const node of nodes) { + if (!nodeMap.has(node.name)) { + nodeMap.set(node.name, node); + } + if (node.children !== undefined) { + buildNodeMap(node.children, nodeMap); + } + } +} diff --git a/ui/src/components/widgets/charts/treemap_loader.ts b/ui/src/components/widgets/charts/treemap_loader.ts new file mode 100644 index 0000000000..98dcda9c5b --- /dev/null +++ b/ui/src/components/widgets/charts/treemap_loader.ts @@ -0,0 +1,262 @@ +// 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 {QuerySlot, SerialTaskQueue} from '../../../base/query_slot'; +import {Engine} from '../../../trace_processor/engine'; +import {NUM, STR_NULL} from '../../../trace_processor/query_result'; +import { + AggregationType, + sqlAggExpression, + sqlInClause, + validateColumnName, +} from './chart_utils'; +import {TreemapData, TreemapNode} from './treemap_chart'; + +/** + * Configuration for SQLTreemapLoader. + */ +export interface SQLTreemapLoaderOpts { + /** The trace processor engine to run queries against. */ + readonly engine: Engine; + + /** + * SQL query that provides the raw data. + * Must include label and size columns (and optionally group column). + */ + readonly query: string; + + /** Column name for leaf node labels. */ + readonly labelColumn: string; + + /** Column name for rectangle size (measure). */ + readonly sizeColumn: string; + + /** + * Optional column for parent grouping (creates 2-level hierarchy). + * When omitted, all nodes are at the top level. + */ + readonly groupColumn?: string; +} + +/** + * Per-use configuration for the treemap loader. + */ +export interface TreemapLoaderConfig { + /** Aggregation function to apply to the size column. Defaults to 'SUM'. */ + readonly aggregation?: AggregationType; + + /** + * Maximum number of leaf nodes to return per group. + * Nodes are sorted by size descending. Defaults to no limit. + */ + readonly limit?: number; + + /** + * Filter to only include specific labels. + */ + readonly labelFilter?: ReadonlyArray; + + /** + * Filter to only include specific groups. + */ + readonly groupFilter?: ReadonlyArray; +} + +/** + * Result returned by the treemap loader. + */ +export interface TreemapLoaderResult { + /** The computed treemap data, or undefined if loading. */ + readonly data: TreemapData | undefined; + + /** Whether a query is currently pending. */ + readonly isPending: boolean; +} + +/** + * SQL-based treemap loader with async loading and caching. + * + * Creates 1 or 2 level hierarchy from SQL data. When groupColumn is + * provided, creates parent nodes for each group with children for labels. + * Uses QuerySlot for caching and request deduplication. + */ +export class SQLTreemapLoader { + private readonly engine: Engine; + private readonly baseQuery: string; + private readonly labelColumn: string; + private readonly sizeColumn: string; + private readonly groupColumn: string | undefined; + private readonly taskQueue = new SerialTaskQueue(); + private readonly querySlot = new QuerySlot(this.taskQueue); + + constructor(opts: SQLTreemapLoaderOpts) { + validateColumnName(opts.labelColumn); + validateColumnName(opts.sizeColumn); + if (opts.groupColumn !== undefined) validateColumnName(opts.groupColumn); + this.engine = opts.engine; + this.baseQuery = opts.query; + this.labelColumn = opts.labelColumn; + this.sizeColumn = opts.sizeColumn; + this.groupColumn = opts.groupColumn; + } + + use(config: TreemapLoaderConfig): TreemapLoaderResult { + const aggregation = config.aggregation ?? 'SUM'; + + const result = this.querySlot.use({ + key: { + baseQuery: this.baseQuery, + labelColumn: this.labelColumn, + sizeColumn: this.sizeColumn, + groupColumn: this.groupColumn, + aggregation, + limit: config.limit, + labelFilter: config.labelFilter, + groupFilter: config.groupFilter, + }, + queryFn: async () => { + const label = this.labelColumn; + const size = this.sizeColumn; + const aggExpr = sqlAggExpression(size, aggregation); + + const filterClauses: string[] = []; + if (config.labelFilter !== undefined) { + const clause = sqlInClause(label, config.labelFilter); + if (clause !== '') filterClauses.push(clause); + } + if ( + config.groupFilter !== undefined && + this.groupColumn !== undefined + ) { + const clause = sqlInClause(this.groupColumn, config.groupFilter); + if (clause !== '') filterClauses.push(clause); + } + const whereClause = + filterClauses.length > 0 + ? `WHERE ${filterClauses.join(' AND ')}` + : ''; + + let sql: string; + + if (this.groupColumn !== undefined) { + // Two-level hierarchy: group -> label + const group = this.groupColumn; + + // Use window function to rank within each group + sql = ` + WITH _agg AS ( + SELECT + CAST(${group} AS TEXT) AS _group, + CAST(${label} AS TEXT) AS _label, + ${aggExpr} AS _value + FROM (${this.baseQuery}) + ${whereClause} + GROUP BY ${group}, ${label} + ), + _ranked AS ( + SELECT + _group, + _label, + _value, + ROW_NUMBER() OVER (PARTITION BY _group ORDER BY _value DESC) AS _rank + FROM _agg + ) + SELECT _group, _label, _value + FROM _ranked + ${config.limit !== undefined ? `WHERE _rank <= ${config.limit}` : ''} + ORDER BY _group, _value DESC + `; + } else { + // Single-level: just labels + const limitClause = + config.limit !== undefined ? `LIMIT ${config.limit}` : ''; + + sql = ` + SELECT + NULL AS _group, + CAST(${label} AS TEXT) AS _label, + ${aggExpr} AS _value + FROM (${this.baseQuery}) + ${whereClause} + GROUP BY ${label} + ORDER BY _value DESC + ${limitClause} + `; + } + + const queryResult = await this.engine.query(sql); + + // Build hierarchical structure + const groupMap = new Map(); + + const iter = queryResult.iter({ + _group: STR_NULL, + _label: STR_NULL, + _value: NUM, + }); + + for (; iter.valid(); iter.next()) { + const groupName = iter._group; + const labelName = iter._label ?? '(null)'; + const value = iter._value; + + let children = groupMap.get(groupName); + if (children === undefined) { + children = []; + groupMap.set(groupName, children); + } + + children.push({ + name: labelName, + value, + category: groupName ?? undefined, + }); + } + + // Convert to nodes + const nodes: TreemapNode[] = []; + + if (this.groupColumn !== undefined) { + // Two-level: create parent nodes for each group + for (const [groupName, children] of groupMap) { + const name = groupName ?? '(uncategorized)'; + nodes.push({ + name, + value: children.reduce((sum, c) => sum + c.value, 0), + category: name, + children, + }); + } + } else { + // Single-level: flat list of nodes + const flatNodes = groupMap.get(null); + if (flatNodes !== undefined) { + nodes.push(...flatNodes); + } + } + + return {nodes}; + }, + }); + + return { + data: result.data, + isPending: result.isPending, + }; + } + + dispose(): void { + this.querySlot.dispose(); + } +} diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts index b34d6119d3..68faf778d5 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/charts_demo.ts @@ -48,6 +48,22 @@ import { SQLPieChartLoader, PieChartLoaderConfig, } from '../../../components/widgets/charts/pie_chart_loader'; +import { + ScatterChart, + ScatterChartData, +} from '../../../components/widgets/charts/scatter_chart'; +import { + SQLScatterChartLoader, + ScatterChartLoaderConfig, +} from '../../../components/widgets/charts/scatter_chart_loader'; +import { + TreemapChart, + TreemapData, +} from '../../../components/widgets/charts/treemap_chart'; +import { + SQLTreemapLoader, + TreemapLoaderConfig, +} from '../../../components/widgets/charts/treemap_loader'; import {App} from '../../../public/app'; import {EnumOption, renderWidgetShowcase} from '../widgets_page_utils'; import {Trace} from '../../../public/trace'; @@ -106,8 +122,8 @@ export function renderCharts(app: App): m.Children { '.pf-widget-intro', m('h1', 'Charts'), m('p', [ - 'Pure SVG-based chart components for visualizing data. ', - 'Includes BarChart, LineChart, PieChart, and Histogram.', + 'ECharts-based chart components for visualizing data. ', + 'Includes BarChart, LineChart, PieChart, Histogram, ScatterChart, and Treemap.', ]), ), @@ -200,6 +216,42 @@ export function renderCharts(app: App): m.Children { }, }), + // ScatterChart section + m('h2', {style: {marginTop: '32px'}}, 'ScatterChart'), + renderWidgetShowcase({ + renderWidget: (opts) => { + return m(ScatterChartDemo, { + height: opts.height, + showLegend: opts.showLegend, + bubbleMode: opts.bubbleMode, + scaleAxes: opts.scaleAxes, + }); + }, + initialOpts: { + height: 250, + showLegend: true, + bubbleMode: false, + scaleAxes: false, + }, + }), + + // TreemapChart section + m('h2', {style: {marginTop: '32px'}}, 'TreemapChart'), + renderWidgetShowcase({ + renderWidget: (opts) => { + return m(TreemapChartDemo, { + height: opts.height, + showLabels: opts.showLabels, + hierarchical: opts.hierarchical, + }); + }, + initialOpts: { + height: 300, + showLabels: true, + hierarchical: true, + }, + }), + // SQL loader demos (only shown when a trace is loaded) ...renderSQLDemos(app), app.trace === undefined && @@ -251,6 +303,7 @@ function renderSQLDemos(app: App): m.Children[] { enableBrush: opts.enableBrush, showPoints: opts.showPoints, maxPoints: opts.maxPoints, + scaleAxes: opts.scaleAxes, }); }, initialOpts: { @@ -258,6 +311,7 @@ function renderSQLDemos(app: App): m.Children[] { enableBrush: true, showPoints: true, maxPoints: 200, + scaleAxes: true, }, }), m('h3', {style: {marginTop: '32px'}}, 'SQLPieChartLoader'), @@ -305,6 +359,40 @@ function renderSQLDemos(app: App): m.Children[] { logScale: false, }, }), + m('h3', {style: {marginTop: '32px'}}, 'SQLScatterChartLoader'), + renderWidgetShowcase({ + renderWidget: (opts) => { + return m(SQLScatterChartDemo, { + trace, + height: opts.height, + showLegend: opts.showLegend, + maxPoints: opts.maxPoints, + scaleAxes: opts.scaleAxes, + }); + }, + initialOpts: { + height: 250, + showLegend: true, + maxPoints: 500, + scaleAxes: true, + }, + }), + m('h3', {style: {marginTop: '32px'}}, 'SQLTreemapLoader'), + renderWidgetShowcase({ + renderWidget: (opts) => { + return m(SQLTreemapDemo, { + trace, + height: opts.height, + showLabels: opts.showLabels, + limit: opts.limit, + }); + }, + initialOpts: { + height: 300, + showLabels: true, + limit: 10, + }, + }), ]; } @@ -633,6 +721,7 @@ function SQLLineChartDemo(): m.Component<{ enableBrush: boolean; showPoints: boolean; maxPoints: number; + scaleAxes: boolean; }> { let loader: SQLLineChartLoader | undefined; let xRange: {min: number; max: number} | undefined; @@ -661,6 +750,7 @@ function SQLLineChartDemo(): m.Component<{ xAxisLabel: 'Timestamp', yAxisLabel: 'Value', showPoints: attrs.showPoints, + scaleAxes: attrs.scaleAxes, onBrush: attrs.enableBrush ? (range) => { xRange = {min: range.start, max: range.end}; @@ -813,21 +903,76 @@ function LineChartDemo(): m.Component<{ }> { let brushRange: {start: number; end: number} | undefined; + // Helper to interpolate Y value between two points at a given X + function interpolateY( + p1: {x: number; y: number}, + p2: {x: number; y: number}, + x: number, + ): number { + if (p1.x === p2.x) return p1.y; + const t = (x - p1.x) / (p2.x - p1.x); + return p1.y + t * (p2.y - p1.y); + } + + // Filter points to range, with interpolation at boundaries for continuity + function filterPointsWithInterpolation( + points: ReadonlyArray<{x: number; y: number}>, + start: number, + end: number, + ): Array<{x: number; y: number}> { + if (points.length === 0) return []; + + // Sort points by X (should already be sorted, but just in case) + const sorted = [...points].sort((a, b) => a.x - b.x); + + const result: Array<{x: number; y: number}> = []; + + // Find points within range and interpolate at boundaries + for (let i = 0; i < sorted.length; i++) { + const curr = sorted[i]; + const prev = i > 0 ? sorted[i - 1] : undefined; + + // Add interpolated start point if we're crossing into the range + if (prev !== undefined && prev.x < start && curr.x >= start) { + if (curr.x > start) { + result.push({x: start, y: interpolateY(prev, curr, start)}); + } + } + + // Add current point if within range + if (curr.x >= start && curr.x <= end) { + result.push({x: curr.x, y: curr.y}); + } + + // Add interpolated end point if we're leaving the range + const next = i < sorted.length - 1 ? sorted[i + 1] : undefined; + if (next !== undefined && curr.x <= end && next.x > end) { + if (curr.x < end) { + result.push({x: end, y: interpolateY(curr, next, end)}); + } + } + } + + return result; + } + return { view: ({attrs}) => { const fullData = attrs.multiSeries ? LINE_CHART_MULTI_SERIES_DATA : LINE_CHART_SAMPLE_DATA; - // Filter data to the brushed X range + // Filter data to the brushed X range with interpolation at boundaries const range = brushRange; const data: LineChartData = range !== undefined ? { series: fullData.series.map((s) => ({ ...s, - points: s.points.filter( - (p) => p.x >= range.start && p.x <= range.end, + points: filterPointsWithInterpolation( + s.points, + range.start, + range.end, ), })), } @@ -841,9 +986,11 @@ function LineChartDemo(): m.Component<{ yAxisLabel: 'Value', logScale: attrs.logScale, showPoints: attrs.showPoints, + xAxisMin: range?.start, + xAxisMax: range?.end, onBrush: attrs.enableBrush - ? (range) => { - brushRange = range; + ? (newRange) => { + brushRange = newRange; } : undefined, }), @@ -940,3 +1087,318 @@ function PieChartDemo(): m.Component<{ }, }; } + +// Simple seeded pseudo-random number generator for reproducible demo data. +function seededRandom(seed: number): () => number { + let s = seed; + return () => { + s = (s * 16807) % 2147483647; + return s / 2147483647; + }; +} + +// Static sample data for ScatterChart demo +const SCATTER_SAMPLE_DATA: ScatterChartData = (() => { + const rng = seededRandom(42); + return { + series: [ + { + name: 'Group A', + points: Array.from({length: 30}, () => ({ + x: rng() * 100, + y: rng() * 50 + 25, + })), + }, + { + name: 'Group B', + points: Array.from({length: 25}, () => ({ + x: rng() * 100, + y: rng() * 50 + 50, + })), + }, + ], + }; +})(); + +const SCATTER_BUBBLE_DATA: ScatterChartData = { + series: [ + { + name: 'Processes', + points: [ + {x: 10, y: 20, size: 100, label: 'Chrome'}, + {x: 25, y: 45, size: 200, label: 'Firefox'}, + {x: 40, y: 30, size: 150, label: 'Safari'}, + {x: 55, y: 60, size: 80, label: 'Edge'}, + {x: 70, y: 35, size: 300, label: 'System'}, + {x: 85, y: 50, size: 120, label: 'Launcher'}, + ], + }, + ], +}; + +function ScatterChartDemo(): m.Component<{ + height: number; + showLegend: boolean; + bubbleMode: boolean; + scaleAxes: boolean; +}> { + return { + view: ({attrs}) => { + const data = attrs.bubbleMode ? SCATTER_BUBBLE_DATA : SCATTER_SAMPLE_DATA; + return m('div', [ + m(ScatterChart, { + data, + height: attrs.height, + xAxisLabel: 'X Value', + yAxisLabel: 'Y Value', + showLegend: attrs.showLegend, + scaleAxes: attrs.scaleAxes, + }), + m( + 'pre', + { + style: { + marginTop: '8px', + fontSize: '11px', + background: 'var(--pf-color-background-secondary)', + padding: '8px', + borderRadius: '4px', + }, + }, + [ + attrs.bubbleMode + ? 'Bubble mode: size encodes a third dimension' + : 'Regular scatter plot with two series', + attrs.scaleAxes + ? '\nscaleAxes: true (axis range from data min/max)' + : '\nscaleAxes: false (axis range includes zero)', + ], + ), + ]); + }, + }; +} + +// Static sample data for TreemapChart demo +const TREEMAP_FLAT_DATA: TreemapData = { + nodes: [ + {name: 'Chrome', value: 350}, + {name: 'SurfaceFlinger', value: 150}, + {name: 'SystemUI', value: 200}, + {name: 'Launcher', value: 100}, + {name: 'InputDispatcher', value: 75}, + {name: 'AudioFlinger', value: 125}, + ], +}; + +const TREEMAP_HIERARCHICAL_DATA: TreemapData = { + nodes: [ + { + name: 'UI Processes', + value: 650, // Sum of children: 350 + 200 + 100 + category: 'ui', + children: [ + {name: 'Chrome', value: 350, category: 'ui'}, + {name: 'SystemUI', value: 200, category: 'ui'}, + {name: 'Launcher', value: 100, category: 'ui'}, + ], + }, + { + name: 'System Services', + value: 350, // Sum of children: 150 + 75 + 125 + category: 'system', + children: [ + {name: 'SurfaceFlinger', value: 150, category: 'system'}, + {name: 'InputDispatcher', value: 75, category: 'system'}, + {name: 'AudioFlinger', value: 125, category: 'system'}, + ], + }, + ], +}; + +function TreemapChartDemo(): m.Component<{ + height: number; + showLabels: boolean; + hierarchical: boolean; +}> { + let clickedNode: string | undefined; + + return { + view: ({attrs}) => { + const data = attrs.hierarchical + ? TREEMAP_HIERARCHICAL_DATA + : TREEMAP_FLAT_DATA; + return m('div', [ + m(TreemapChart, { + data, + height: attrs.height, + showLabels: attrs.showLabels, + onNodeClick: (node) => { + clickedNode = node.name; + }, + }), + m( + 'pre', + { + style: { + marginTop: '8px', + fontSize: '11px', + background: 'var(--pf-color-background-secondary)', + padding: '8px', + borderRadius: '4px', + }, + }, + clickedNode ? `Clicked: ${clickedNode}` : 'Click a node to select it', + ), + clickedNode && + m( + 'button', + { + style: {marginTop: '8px', fontSize: '12px'}, + onclick: () => { + clickedNode = undefined; + }, + }, + 'Clear selection', + ), + ]); + }, + }; +} + +function SQLScatterChartDemo(): m.Component<{ + trace: Trace; + height: number; + showLegend: boolean; + maxPoints: number; + scaleAxes: boolean; +}> { + let loader: SQLScatterChartLoader | undefined; + + return { + view: ({attrs}) => { + if (!loader) { + loader = new SQLScatterChartLoader({ + engine: attrs.trace.engine, + query: 'SELECT ts, dur, name FROM slice WHERE dur > 0 LIMIT 1000', + xColumn: 'ts', + yColumn: 'dur', + seriesColumn: 'name', + }); + } + + const config: ScatterChartLoaderConfig = { + maxPoints: attrs.maxPoints, + }; + const {data, isPending} = loader.use(config); + + return m('div', [ + m(ScatterChart, { + data, + height: attrs.height, + xAxisLabel: 'Timestamp', + yAxisLabel: 'Duration', + showLegend: attrs.showLegend, + scaleAxes: attrs.scaleAxes, + }), + m( + 'pre', + { + style: { + marginTop: '8px', + fontSize: '11px', + background: 'var(--pf-color-background-secondary)', + padding: '8px', + borderRadius: '4px', + }, + }, + [ + `query: 'SELECT ts, dur, name FROM slice WHERE dur > 0 LIMIT 1000'\n`, + `xColumn: 'ts', yColumn: 'dur', seriesColumn: 'name'\n`, + `loader.use(${JSON.stringify(config, null, 2)})`, + isPending ? '\n(loading...)' : '', + ], + ), + ]); + }, + onremove: () => { + loader?.dispose(); + loader = undefined; + }, + }; +} + +function SQLTreemapDemo(): m.Component<{ + trace: Trace; + height: number; + showLabels: boolean; + limit: number; +}> { + let loader: SQLTreemapLoader | undefined; + let clickedNode: string | undefined; + + return { + view: ({attrs}) => { + if (!loader) { + loader = new SQLTreemapLoader({ + engine: attrs.trace.engine, + query: 'SELECT name, dur, category FROM slice WHERE dur > 0', + labelColumn: 'name', + sizeColumn: 'dur', + groupColumn: 'category', + }); + } + + const config: TreemapLoaderConfig = { + aggregation: 'SUM', + limit: attrs.limit, + }; + const {data, isPending} = loader.use(config); + + return m('div', [ + m(TreemapChart, { + data, + height: attrs.height, + showLabels: attrs.showLabels, + onNodeClick: (node) => { + clickedNode = node.name; + }, + }), + m( + 'pre', + { + style: { + marginTop: '8px', + fontSize: '11px', + background: 'var(--pf-color-background-secondary)', + padding: '8px', + borderRadius: '4px', + }, + }, + [ + `query: 'SELECT name, dur, category FROM slice WHERE dur > 0'\n`, + `labelColumn: 'name', sizeColumn: 'dur', groupColumn: 'category'\n`, + `loader.use(${JSON.stringify(config, null, 2)})`, + isPending ? '\n(loading...)' : '', + clickedNode ? `\nClicked: ${clickedNode}` : '', + ], + ), + clickedNode && + m( + 'button', + { + style: {marginTop: '8px', fontSize: '12px'}, + onclick: () => { + clickedNode = undefined; + }, + }, + 'Clear selection', + ), + ]); + }, + onremove: () => { + loader?.dispose(); + loader = undefined; + }, + }; +}