blob: 7072d4c280319123e8fd92760b876c669b9d4e12 [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import type {EChartsCoreOption} from 'echarts/core';
import {formatNumber} from './chart_utils';
import {
type ThemeColors,
EChartView,
type EChartEventHandler,
type EChartClickParams,
} from './echart_view';
import {buildTooltipOption} from './chart_option_builder';
export interface SankeyNode {
readonly name: string;
readonly color?: string;
readonly depth?: number;
}
export interface SankeyLink {
readonly source: string;
readonly target: string;
readonly value: number;
}
export interface SankeyData {
readonly nodes: readonly SankeyNode[];
readonly links: readonly SankeyLink[];
}
export interface SankeyChartAttrs {
readonly data: SankeyData | undefined;
readonly height?: number;
readonly fillParent?: boolean;
readonly className?: string;
readonly formatValue?: (value: number) => string;
readonly onNodeClick?: (node: SankeyNode) => void;
}
export class Sankey implements m.ClassComponent<SankeyChartAttrs> {
view({attrs}: m.Vnode<SankeyChartAttrs>) {
const {data, height, fillParent, className} = attrs;
const isEmpty =
data !== undefined &&
(data.nodes.length === 0 || data.links.length === 0);
const option =
data !== undefined && !isEmpty
? buildSankeyOption(attrs, data)
: undefined;
return m(EChartView, {
option,
height,
fillParent,
className,
empty: isEmpty,
eventHandlers: buildSankeyEventHandlers(attrs, data),
resolveOption: applySankeyTheme,
});
}
}
function buildSankeyOption(
attrs: SankeyChartAttrs,
data: SankeyData,
): EChartsCoreOption {
const {formatValue = (v: number) => formatNumber(v)} = attrs;
// Compute total outflow per source node for percentage in tooltips.
const outflow = new Map<string, number>();
for (const link of data.links) {
outflow.set(link.source, (outflow.get(link.source) ?? 0) + link.value);
}
return {
animation: false,
tooltip: buildTooltipOption({
trigger: 'item' as const,
formatter: (params: {
dataType?: string;
name?: string;
value?: number;
data?: {source?: string; target?: string; value?: number};
}) => {
if (params.dataType === 'edge') {
const d = params.data;
if (d === undefined) return '';
const total = outflow.get(d.source ?? '') ?? 0;
const pct =
total > 0 ? (((d.value ?? 0) / total) * 100).toFixed(1) : '0';
return [
`${d.source} → ${d.target}`,
`${formatValue(d.value ?? 0)}`,
`${pct}% of ${d.source}`,
].join('<br>');
}
// Node tooltip.
const name = params.name ?? '';
const value = params.value ?? 0;
return `${name}<br>${formatValue(value)}`;
},
}),
series: [
{
type: 'sankey',
orient: 'horizontal',
data: data.nodes.map((n) => ({
name: n.name,
...(n.depth !== undefined ? {depth: n.depth} : {}),
})),
links: data.links.map((l) => ({
source: l.source,
target: l.target,
value: l.value,
})),
label: {
show: true,
fontSize: 11,
},
lineStyle: {
color: 'gradient',
opacity: 0.4,
},
emphasis: {
focus: 'adjacency',
},
draggable: false,
nodeWidth: 24,
nodeGap: 16,
layoutIterations: 0,
left: 20,
right: 160,
top: 10,
bottom: 10,
},
],
};
}
interface SankeySeries {
type?: string;
label?: {color?: unknown};
}
function applySankeyTheme(
option: EChartsCoreOption,
colors: ThemeColors,
): EChartsCoreOption {
const series = (option as {series?: unknown[]}).series;
if (!Array.isArray(series) || series.length === 0) return option;
const sankey = series[0] as SankeySeries;
if (sankey.type !== 'sankey') return option;
if (sankey.label) {
sankey.label.color = colors.textColor;
}
return option;
}
function buildSankeyEventHandlers(
attrs: SankeyChartAttrs,
data: SankeyData | undefined,
): ReadonlyArray<EChartEventHandler> {
if (!attrs.onNodeClick || data === undefined) return [];
const nodeMap = new Map<string, SankeyNode>();
for (const node of data.nodes) {
nodeMap.set(node.name, node);
}
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);
}
}
},
},
];
}