blob: 01e974423e60e5b0f5583d213a7e0be4e4d5fb51 [file] [edit]
// 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 {extractBrushRect, formatNumber} from './chart_utils';
import {EChartView, type EChartEventHandler} from './echart_view';
import type {LegendPosition} from './common';
import {
buildChartOption,
buildLegendOption,
buildSelectionMarkArea,
} 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/Y rectangle.
*/
readonly onBrush?: (range: {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
}) => void;
/**
* Selection rectangle to highlight on the chart. When provided, a shaded
* region is drawn. The consumer controls this state — typically by feeding
* the `onBrush` output back in.
*/
readonly selection?: {
readonly xMin: number;
readonly xMax: number;
readonly yMin: number;
readonly yMax: 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;
/**
* 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;
/**
* Where the legend sits relative to the chart. Defaults to 'top'.
*/
readonly legendPosition?: LegendPosition;
/**
* 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;
/**
* Show grid lines. 'horizontal' draws lines parallel to the X axis,
* 'vertical' draws lines parallel to the Y axis, 'both' shows both.
* Defaults to no grid lines.
*/
readonly gridLines?: 'horizontal' | 'vertical' | 'both';
}
export class Scatterplot implements m.ClassComponent<ScatterChartAttrs> {
view({attrs}: m.Vnode<ScatterChartAttrs>) {
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 ? 'rect' : 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;
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, i) => {
const base: Record<string, unknown> = {
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<number | null>) => {
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},
},
};
// Render selection highlight on the first series only.
if (i === 0 && attrs.selection !== undefined) {
const sel = attrs.selection;
base.markArea = buildSelectionMarkArea([
[
{xAxis: sel.xMin, yAxis: sel.yMin},
{xAxis: sel.xMax, yAxis: sel.yMax},
],
]);
}
return base;
});
const option = buildChartOption({
xAxis: {
type: logScaleX ? 'log' : 'value',
name: xAxisLabel,
formatter:
formatXValue !== undefined
? (v) => formatXValue(v as number)
: undefined,
// When brush is enabled, always scale axes to data range so that after
// filtering the axes fit the selected region rather than anchoring at 0.
scale: attrs.onBrush !== undefined || attrs.scaleAxes,
showSplitLine:
attrs.gridLines === 'vertical' || attrs.gridLines === 'both',
},
yAxis: {
type: logScaleY ? 'log' : 'value',
name: yAxisLabel,
formatter:
formatYValue !== undefined
? (v) => formatYValue(v as number)
: undefined,
scale: attrs.onBrush !== undefined || attrs.scaleAxes,
showSplitLine:
attrs.gridLines === 'horizontal' || attrs.gridLines === 'both',
},
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('<br>');
},
},
brush: attrs.onBrush
? {xAxisIndex: 0, yAxisIndex: 0, brushType: 'rect' as const}
: undefined,
legend: displayLegend
? buildLegendOption(attrs.legendPosition)
: {show: false},
});
(option as Record<string, unknown>).series = series;
return option;
}
function buildScatterEventHandlers(
attrs: ScatterChartAttrs,
): ReadonlyArray<EChartEventHandler> {
if (!attrs.onBrush) return [];
const onBrush = attrs.onBrush;
return [
{
eventName: 'brushEnd',
handler: (params) => {
const range = extractBrushRect(params);
if (range !== undefined) {
onBrush(range);
}
},
},
];
}