| // 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 type {HistogramBucket, HistogramData} from './histogram_loader'; |
| import {EChartView, type EChartEventHandler} from './echart_view'; |
| import {buildChartOption, SELECTION_COLOR} from './chart_option_builder'; |
| import type {ChartThemeColors} from './chart_theme'; |
| |
| const PALETTE_INDEX_KEY = '__pfPaletteIdx'; |
| |
| function resolveBucketColors( |
| option: EChartsCoreOption, |
| colors: ChartThemeColors, |
| ): EChartsCoreOption { |
| const palette = colors.chartColors; |
| if (palette.length === 0) return option; |
| const series = (option as {series?: ReadonlyArray<Record<string, unknown>>}) |
| .series; |
| if (series === undefined) return option; |
| for (const s of series) { |
| const data = s.data as |
| | ReadonlyArray<{itemStyle?: Record<string, unknown>}> |
| | undefined; |
| if (data === undefined) continue; |
| for (const item of data) { |
| const style = item.itemStyle; |
| if (style === undefined) continue; |
| const idx = style[PALETTE_INDEX_KEY]; |
| if (typeof idx === 'number') { |
| style.color = |
| palette[((idx % palette.length) + palette.length) % palette.length]; |
| delete style[PALETTE_INDEX_KEY]; |
| } |
| } |
| } |
| return option; |
| } |
| |
| export interface HistogramAttrs { |
| /** |
| * Histogram data to display, or undefined if loading. |
| * When undefined, a loading spinner is shown. |
| * Use the computeHistogram() utility function to compute this from raw values. |
| */ |
| readonly data: HistogramData | undefined; |
| |
| /** |
| * Height of the histogram in pixels. Defaults to 200. |
| */ |
| readonly height?: number; |
| |
| /** |
| * Label for the X axis. |
| */ |
| readonly xAxisLabel?: string; |
| |
| /** |
| * Label for the Y axis. Defaults to 'Count'. |
| */ |
| readonly yAxisLabel?: string; |
| |
| /** |
| * Callback when brush selection completes (on mouseup). |
| * Called with the selected range based on mousedown and mouseup positions. |
| */ |
| readonly onBrush?: (range: {start: number; end: number}) => void; |
| |
| /** |
| * Selection range to highlight on the chart. Buckets overlapping this |
| * range are drawn with a highlight color. The consumer controls this |
| * state — typically by feeding the `onBrush` output back in. |
| */ |
| readonly selection?: {readonly start: number; readonly end: number}; |
| |
| /** |
| * 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; |
| |
| /** |
| * Bar color. Defaults to theme primary color. |
| */ |
| readonly barColor?: string; |
| |
| /** |
| * Per-bucket color override. Called once per bucket; return an index into |
| * the theme's chart color palette (`chartColors`) to colour that bar, or |
| * undefined to fall back to `barColor`. The index is taken modulo the |
| * palette length, so callers don't need to know how many colors exist. |
| * The NULL bar (when present) is passed `bucket = undefined`. Selection |
| * highlighting still takes precedence over the returned color. |
| */ |
| readonly bucketColor?: ( |
| bucket: HistogramBucket | undefined, |
| index: number, |
| ) => number | undefined; |
| |
| /** |
| * Bar hover color. Defaults to theme accent color. |
| */ |
| readonly barHoverColor?: string; |
| |
| /** |
| * Use logarithmic scale for Y axis. Useful when count values |
| * span multiple orders of magnitude. Defaults to false. |
| */ |
| readonly logScale?: boolean; |
| |
| /** |
| * When true, X axis (dimension) ticks will be snapped to integer values. |
| * Use when the histogram data represents integer-valued quantities. |
| * The Y axis (measure) always uses integer ticks since it shows counts. |
| */ |
| readonly integerDimension?: boolean; |
| } |
| |
| export class Histogram implements m.ClassComponent<HistogramAttrs> { |
| view({attrs}: m.Vnode<HistogramAttrs>) { |
| const {data, height, fillParent, className, onBrush} = attrs; |
| |
| const nullCount = data?.nullCount ?? 0; |
| const hasData = |
| data !== undefined && (data.buckets.length > 0 || nullCount > 0); |
| const option = hasData ? buildOption(attrs, data) : undefined; |
| |
| return m(EChartView, { |
| option, |
| height, |
| fillParent, |
| className, |
| empty: data !== undefined && !hasData, |
| eventHandlers: buildEventHandlers(attrs, data), |
| activeBrushType: onBrush !== undefined ? 'lineX' : undefined, |
| resolveOption: resolveBucketColors, |
| }); |
| } |
| } |
| |
| 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; |
| const nullCount = data.nullCount ?? 0; |
| const totalWithNull = data.totalCount + nullCount; |
| |
| const categories = data.buckets.map((b) => formatXValue(b.start)); |
| const seriesData: number[] = data.buckets.map((b) => b.count); |
| const bucketCount = data.buckets.length; |
| |
| // Append NULL bar when there are null values |
| if (nullCount > 0) { |
| categories.push('NULL'); |
| seriesData.push(nullCount); |
| } |
| |
| const option = buildChartOption({ |
| 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 >= categories.length) { |
| return ''; |
| } |
| |
| // NULL bar |
| if (idx >= bucketCount) { |
| const pct = |
| totalWithNull > 0 |
| ? ((nullCount / totalWithNull) * 100).toFixed(1) |
| : '0'; |
| return [`NULL`, `Count: ${fmtY(nullCount)}`, `${pct}%`].join('<br>'); |
| } |
| |
| // Regular bucket |
| const bucket = data.buckets[idx]; |
| const pct = |
| totalWithNull > 0 |
| ? ((bucket.count / totalWithNull) * 100).toFixed(1) |
| : '0'; |
| return [ |
| `Range: ${formatXValue(bucket.start)} - ${formatXValue(bucket.end)}`, |
| `Count: ${fmtY(bucket.count)}`, |
| `${pct}%`, |
| ].join('<br>'); |
| }, |
| }, |
| brush: attrs.onBrush ? {xAxisIndex: 0, brushType: 'lineX'} : undefined, |
| }); |
| |
| // Build per-bar styles, highlighting buckets that overlap the selection. |
| const sel = attrs.selection; |
| const {bucketColor} = attrs; |
| const styledData = seriesData.map((count, idx) => { |
| const item: Record<string, unknown> = {value: count}; |
| const bucket = idx < data.buckets.length ? data.buckets[idx] : undefined; |
| const selected = |
| sel !== undefined && |
| bucket !== undefined && |
| bucket.end > sel.start && |
| bucket.start < sel.end; |
| if (selected) { |
| item.itemStyle = {color: SELECTION_COLOR}; |
| } else { |
| const paletteIdx = bucketColor?.(bucket, idx); |
| if (paletteIdx !== undefined) { |
| // Stashed here as a sentinel; resolveBucketColors swaps it for the |
| // real color once the theme palette is known at render time. |
| item.itemStyle = {[PALETTE_INDEX_KEY]: paletteIdx}; |
| } |
| } |
| return item; |
| }); |
| |
| // Add series on top of the base option |
| (option as Record<string, unknown>).series = [ |
| { |
| type: 'bar', |
| data: styledData, |
| barWidth: '100%', |
| barCategoryGap: '0%', |
| itemStyle: barColor !== undefined ? {color: barColor} : undefined, |
| emphasis: |
| barHoverColor !== undefined |
| ? {itemStyle: {color: barHoverColor}} |
| : undefined, |
| }, |
| ]; |
| |
| return option; |
| } |
| |
| function buildEventHandlers( |
| attrs: HistogramAttrs, |
| data: HistogramData | undefined, |
| ): ReadonlyArray<EChartEventHandler> { |
| 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, |
| }); |
| } |
| } |
| }, |
| }, |
| ]; |
| } |