blob: 81873f0d87ab797db831d4b8064d444e09ad549b [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 { Button, Intent } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { C, F, L } from 'druid-query-toolkit';
import type { ECharts } from 'echarts';
import * as echarts from 'echarts';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Loader, PortalBubble, type PortalBubbleOpenOn } from '../../../../components';
import { useQueryManager } from '../../../../hooks';
import { ColorAssigner } from '../../../../singletons';
import { formatEmpty, formatNumber } from '../../../../utils';
import { Issue } from '../../components';
import type { ExpressionMeta } from '../../models';
import { ModuleRepository } from '../../module-repository/module-repository';
import { updateFilterClause } from '../../utils';
const OVERALL_LABEL = 'Overall';
/**
* Returns the cartesian coordinates of a pie slice external centroid
*/
function getCentroid(chart: echarts.ECharts, dataIndex: number) {
// see these underscores everywhere? that's because those are private properties
// I have no real choice but to use them, because there is no public API for this (on pie charts)
// #no_ragrets
const layout = (chart as any)._chartsViews?.[0]?._data?._itemLayouts?.[dataIndex];
if (!layout) return;
const { cx, cy, startAngle, endAngle, r } = layout;
const angle = (startAngle + endAngle) / 2;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
return { x, y };
}
interface PieChartHighlight extends PortalBubbleOpenOn {
name: string;
dataIndex: number;
}
interface PieChartParameterValues {
splitColumn: ExpressionMeta;
measure: ExpressionMeta;
limit: number;
showOthers: boolean;
}
ModuleRepository.registerModule<PieChartParameterValues>({
id: 'pie-chart',
title: 'Pie chart',
icon: IconNames.PIE_CHART,
parameters: {
splitColumn: {
type: 'expression',
label: 'Slice column',
transferGroup: 'show',
required: true,
important: true,
},
measure: {
type: 'measure',
transferGroup: 'show',
defaultValue: ({ querySource }) => querySource?.getFirstAggregateMeasure(),
required: true,
important: true,
},
limit: {
type: 'number',
label: 'Max slices to show',
defaultValue: 5,
required: true,
},
showOthers: {
type: 'boolean',
defaultValue: true,
label: 'Show others',
},
},
component: function PieChartModule(props) {
const { querySource, where, setWhere, moduleWhere, parameterValues, stage, runSqlQuery } =
props;
const containerRef = useRef<HTMLDivElement>();
const chartRef = useRef<ECharts>();
const [highlight, setHighlight] = useState<PieChartHighlight | undefined>();
const { splitColumn, measure, limit, showOthers } = parameterValues;
const dataQueries = useMemo(() => {
const splitExpression = splitColumn ? splitColumn.expression : L(OVERALL_LABEL);
const effectiveWhere = where.and(moduleWhere);
return {
mainQuery: querySource
.getInitQuery(effectiveWhere)
.addSelect(F.cast(splitExpression, 'VARCHAR').as('name'), { addToGroupBy: 'end' })
.addSelect(measure.expression.as('value'), {
addToOrderBy: 'end',
direction: 'DESC',
})
.changeLimitValue(limit + (showOthers ? 1 : 0)),
limit,
splitExpression: splitColumn?.expression,
othersPartialQuery: showOthers
? querySource.getInitQuery(effectiveWhere).addSelect(measure.expression.as('value'))
: undefined,
};
}, [querySource, where, moduleWhere, splitColumn, measure, limit, showOthers]);
const [sourceDataState, queryManager] = useQueryManager({
query: dataQueries,
processQuery: async ({ mainQuery, limit, splitExpression, othersPartialQuery }, signal) => {
const result = await runSqlQuery({ query: mainQuery }, signal);
const data = result.toObjectArray();
if (splitExpression && othersPartialQuery) {
const pieValues = result.getColumnByIndex(0)!;
if (pieValues.length > limit) {
const othersResult = await runSqlQuery({
query: othersPartialQuery.addWhere(splitExpression.notIn(pieValues.slice(0, limit))),
});
data.push({ name: 'Others', value: othersResult.rows[0][0], __isOthers: true });
}
}
return data;
},
});
function setupChart(container: HTMLDivElement) {
const myChart = echarts.init(container, 'dark');
myChart.setOption({
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
series: [
{
type: 'pie',
id: 'hello',
radius: '50%',
data: [],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
label: {
formatter: (params: any) => formatEmpty(params.name),
},
},
],
});
return myChart;
}
useEffect(() => {
return () => {
const myChart = chartRef.current;
if (!myChart) return;
myChart.dispose();
};
}, []);
useEffect(() => {
const myChart = chartRef.current;
const data = sourceDataState.data;
if (!myChart || !data) return;
myChart.off('click');
const dataWithColors = data.map((item: any) => ({
...item,
itemStyle: {
color: splitColumn
? ColorAssigner.getColorForDimensionValue(splitColumn.name, item.name)
: '#1890ff',
},
}));
myChart.setOption({
series: [
{
id: 'hello',
data: dataWithColors,
},
],
});
myChart.on('click', 'series', p => {
if (highlight?.name === p.name) {
setHighlight(undefined);
return;
}
const centroid = getCentroid(myChart, p.dataIndex);
if (!centroid) return;
const { name, value, __isOthers } = p.data as any;
setHighlight({
title: formatEmpty(name),
x: centroid.x,
y: centroid.y - 20,
name,
dataIndex: p.dataIndex,
text: (
<>
{formatNumber(value)}
<div className="button-bar">
{!__isOthers && (
<Button
text="Zoom in"
intent={Intent.PRIMARY}
small
onClick={() => {
setWhere(updateFilterClause(where, C(splitColumn.name).equal(name)));
setHighlight(undefined);
}}
/>
)}
<Button
text="Close"
small
onClick={() => {
setHighlight(undefined);
}}
/>
</div>
</>
),
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceDataState.data]);
useEffect(() => {
const myChart = chartRef.current;
if (!myChart) return;
myChart.resize();
// if there is a highlight, update its x position
// by calculating new pixel position from the highlight's data
if (highlight) {
const { dataIndex } = highlight;
const centroid = getCentroid(myChart, dataIndex);
if (!centroid) return;
setHighlight({
...highlight,
x: centroid.x,
y: centroid.y - 20,
});
}
}, [stage]);
const errorMessage = sourceDataState.getErrorMessage();
return (
<div className="pie-chart-module module">
<div
className="echart-container"
ref={container => {
if (chartRef.current || !container) return;
containerRef.current = container;
chartRef.current = setupChart(container);
}}
/>
{errorMessage && <Issue issue={errorMessage} />}
{sourceDataState.loading && (
<Loader cancelText="Cancel query" onCancel={() => queryManager.cancelCurrent()} />
)}
<PortalBubble
className="module-bubble"
openOn={highlight}
offsetElement={containerRef.current}
/>
</div>
);
},
});