mirror of
https://github.com/apache/superset.git
synced 2026-06-01 13:49:21 +00:00
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
|
* 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 { useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
DTTM_ALIAS,
|
|
BinaryQueryObjectFilterClause,
|
|
AxisType,
|
|
getTimeFormatter,
|
|
getColumnLabel,
|
|
getNumberFormatter,
|
|
LegendState,
|
|
ensureIsArray,
|
|
} from '@superset-ui/core';
|
|
import type { ViewRootGroup } from 'echarts/types/src/util/types';
|
|
import type GlobalModel from 'echarts/types/src/model/Global';
|
|
import type ComponentModel from 'echarts/types/src/model/Component';
|
|
import { EchartsHandler, EventHandlers } from '../types';
|
|
import Echart from '../components/Echart';
|
|
import { TimeseriesChartTransformedProps } from './types';
|
|
import { formatSeriesName } from '../utils/series';
|
|
import { ExtraControls } from '../components/ExtraControls';
|
|
|
|
const TIMER_DURATION = 300;
|
|
|
|
export default function EchartsTimeseries({
|
|
formData,
|
|
height,
|
|
width,
|
|
echartOptions,
|
|
groupby,
|
|
labelMap,
|
|
selectedValues,
|
|
setDataMask,
|
|
setControlValue,
|
|
legendData = [],
|
|
onContextMenu,
|
|
onLegendStateChanged,
|
|
onFocusedSeries,
|
|
xValueFormatter,
|
|
xAxis,
|
|
refs,
|
|
emitCrossFilters,
|
|
coltypeMapping,
|
|
onLegendScroll,
|
|
}: TimeseriesChartTransformedProps) {
|
|
const { stack } = formData;
|
|
const echartRef = useRef<EchartsHandler | null>(null);
|
|
// eslint-disable-next-line no-param-reassign
|
|
refs.echartRef = echartRef;
|
|
const clickTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
const extraControlRef = useRef<HTMLDivElement>(null);
|
|
const [extraControlHeight, setExtraControlHeight] = useState(0);
|
|
useEffect(() => {
|
|
const element = extraControlRef.current;
|
|
if (!element) {
|
|
setExtraControlHeight(0);
|
|
return;
|
|
}
|
|
|
|
const updateHeight = () => {
|
|
setExtraControlHeight(element.offsetHeight || 0);
|
|
};
|
|
|
|
updateHeight();
|
|
|
|
if (typeof ResizeObserver === 'function') {
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
updateHeight();
|
|
});
|
|
resizeObserver.observe(element);
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}
|
|
|
|
window.addEventListener('resize', updateHeight);
|
|
return () => {
|
|
window.removeEventListener('resize', updateHeight);
|
|
};
|
|
}, [formData.showExtraControls]);
|
|
|
|
const hasDimensions = ensureIsArray(groupby).length > 0;
|
|
|
|
const getModelInfo = (target: ViewRootGroup, globalModel: GlobalModel) => {
|
|
let el = target;
|
|
let model: ComponentModel | null = null;
|
|
while (el) {
|
|
// eslint-disable-next-line no-underscore-dangle
|
|
const modelInfo = el.__ecComponentInfo;
|
|
if (modelInfo != null) {
|
|
model = globalModel.getComponent(modelInfo.mainType, modelInfo.index);
|
|
break;
|
|
}
|
|
el = el.parent;
|
|
}
|
|
return model;
|
|
};
|
|
|
|
const getCrossFilterDataMask = useCallback(
|
|
(value: string) => {
|
|
const selected: string[] = Object.values(selectedValues);
|
|
let values: string[];
|
|
if (selected.includes(value)) {
|
|
values = selected.filter(v => v !== value);
|
|
} else {
|
|
values = [value];
|
|
}
|
|
const groupbyValues = values.map(value => labelMap[value]);
|
|
return {
|
|
dataMask: {
|
|
extraFormData: {
|
|
filters:
|
|
values.length === 0
|
|
? []
|
|
: groupby.map((col, idx) => {
|
|
const val = groupbyValues.map(v => v[idx]);
|
|
if (val === null || val === undefined)
|
|
return {
|
|
col,
|
|
op: 'IS NULL' as const,
|
|
};
|
|
return {
|
|
col,
|
|
op: 'IN' as const,
|
|
val: val as (string | number | boolean)[],
|
|
};
|
|
}),
|
|
},
|
|
filterState: {
|
|
label: groupbyValues.length ? groupbyValues : undefined,
|
|
value: groupbyValues.length ? groupbyValues : null,
|
|
selectedValues: values.length ? values : null,
|
|
},
|
|
},
|
|
isCurrentValueSelected: selected.includes(value),
|
|
};
|
|
},
|
|
[groupby, labelMap, selectedValues],
|
|
);
|
|
|
|
// Cross-filter using X-axis value when no dimensions are set (issue #25334)
|
|
const getXAxisCrossFilterDataMask = useCallback(
|
|
(xAxisValue: string | number) => {
|
|
const stringValue = String(xAxisValue);
|
|
const selected: string[] = Object.values(selectedValues);
|
|
let values: string[];
|
|
if (selected.includes(stringValue)) {
|
|
values = selected.filter(v => v !== stringValue);
|
|
} else {
|
|
values = [stringValue];
|
|
}
|
|
return {
|
|
dataMask: {
|
|
extraFormData: {
|
|
filters:
|
|
values.length === 0
|
|
? []
|
|
: [
|
|
{
|
|
col: xAxis.label,
|
|
op: 'IN' as const,
|
|
val: values,
|
|
},
|
|
],
|
|
},
|
|
filterState: {
|
|
label: values.length ? values : undefined,
|
|
value: values.length ? values : null,
|
|
selectedValues: values.length ? values : null,
|
|
},
|
|
},
|
|
isCurrentValueSelected: selected.includes(stringValue),
|
|
};
|
|
},
|
|
[selectedValues, xAxis.label],
|
|
);
|
|
|
|
const handleChange = useCallback(
|
|
(value: string) => {
|
|
if (!emitCrossFilters) {
|
|
return;
|
|
}
|
|
setDataMask(getCrossFilterDataMask(value).dataMask);
|
|
},
|
|
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
|
|
);
|
|
|
|
// Handle cross-filter using X-axis value when no dimensions (issue #25334)
|
|
const handleXAxisChange = useCallback(
|
|
(xAxisValue: string | number) => {
|
|
if (!emitCrossFilters) {
|
|
return;
|
|
}
|
|
setDataMask(getXAxisCrossFilterDataMask(xAxisValue).dataMask);
|
|
},
|
|
[emitCrossFilters, setDataMask, getXAxisCrossFilterDataMask],
|
|
);
|
|
|
|
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
|
|
const canCrossFilterByXAxis =
|
|
!hasDimensions && xAxis.type === AxisType.Category;
|
|
|
|
const eventHandlers: EventHandlers = {
|
|
click: props => {
|
|
// Allow cross-filter by dimensions OR by categorical X-axis (issue #25334)
|
|
if (!hasDimensions && !canCrossFilterByXAxis) {
|
|
return;
|
|
}
|
|
if (clickTimer.current) {
|
|
clearTimeout(clickTimer.current);
|
|
}
|
|
// Ensure that double-click events do not trigger single click event. So we put it in the timer.
|
|
clickTimer.current = setTimeout(() => {
|
|
if (hasDimensions) {
|
|
// Cross-filter by dimension (original behavior)
|
|
const { seriesName: name } = props;
|
|
handleChange(name);
|
|
} else if (canCrossFilterByXAxis && props.data?.[0] != null) {
|
|
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
|
handleXAxisChange(props.data[0]);
|
|
}
|
|
}, TIMER_DURATION);
|
|
},
|
|
mouseout: () => {
|
|
onFocusedSeries(null);
|
|
},
|
|
mouseover: params => {
|
|
onFocusedSeries(params.seriesName);
|
|
},
|
|
legendscroll: payload => {
|
|
onLegendScroll?.(payload.scrollDataIndex);
|
|
},
|
|
legendselectchanged: payload => {
|
|
onLegendStateChanged?.(payload.selected);
|
|
},
|
|
legendselectall: payload => {
|
|
onLegendStateChanged?.(payload.selected);
|
|
},
|
|
legendinverseselect: payload => {
|
|
onLegendStateChanged?.(payload.selected);
|
|
},
|
|
contextmenu: async eventParams => {
|
|
if (onContextMenu) {
|
|
eventParams.event.stop();
|
|
const { data, seriesName } = eventParams;
|
|
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
|
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
|
|
const pointerEvent = eventParams.event.event;
|
|
const values = [
|
|
...(eventParams.name ? [eventParams.name] : []),
|
|
...(labelMap[seriesName] ?? []),
|
|
];
|
|
const groupBy = ensureIsArray(formData.groupby);
|
|
if (data && xAxis.type === AxisType.Time) {
|
|
drillToDetailFilters.push({
|
|
col:
|
|
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
|
|
xAxis.label === DTTM_ALIAS
|
|
? formData.granularitySqla
|
|
: xAxis.label,
|
|
grain: formData.timeGrainSqla,
|
|
op: '==',
|
|
val: data[0],
|
|
formattedVal: xValueFormatter(data[0]),
|
|
});
|
|
}
|
|
[
|
|
...(xAxis.type === AxisType.Category && data ? [xAxis.label] : []),
|
|
...groupBy,
|
|
].forEach((dimension, i) =>
|
|
drillToDetailFilters.push({
|
|
col: dimension,
|
|
op: '==',
|
|
val: values[i],
|
|
formattedVal: String(values[i]),
|
|
}),
|
|
);
|
|
groupBy.forEach((dimension, i) => {
|
|
const dimensionValues = labelMap[seriesName] ?? [];
|
|
|
|
// Skip the metric values at the beginning and get the actual dimension value
|
|
// If we have multiple metrics, they come first, then the dimension values
|
|
const metricsCount = dimensionValues.length - groupBy.length;
|
|
const val = dimensionValues[metricsCount + i];
|
|
|
|
drillByFilters.push({
|
|
col: dimension,
|
|
op: '==',
|
|
val,
|
|
formattedVal: formatSeriesName(val, {
|
|
timeFormatter: getTimeFormatter(formData.dateFormat),
|
|
numberFormatter: getNumberFormatter(formData.numberFormat),
|
|
coltype: coltypeMapping?.[getColumnLabel(dimension)],
|
|
}),
|
|
});
|
|
});
|
|
|
|
// Provide cross-filter for dimensions OR categorical X-axis (issue #25334)
|
|
let crossFilter;
|
|
if (hasDimensions) {
|
|
crossFilter = getCrossFilterDataMask(seriesName);
|
|
} else if (canCrossFilterByXAxis && data?.[0] != null) {
|
|
crossFilter = getXAxisCrossFilterDataMask(data[0]);
|
|
}
|
|
|
|
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
|
drillToDetail: drillToDetailFilters,
|
|
drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' },
|
|
crossFilter,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
const zrEventHandlers: EventHandlers = {
|
|
dblclick: params => {
|
|
// clear single click timer
|
|
if (clickTimer.current) {
|
|
clearTimeout(clickTimer.current);
|
|
}
|
|
const pointInPixel = [params.offsetX, params.offsetY];
|
|
const echartInstance = echartRef.current?.getEchartInstance();
|
|
if (echartInstance?.containPixel('grid', pointInPixel)) {
|
|
// do not trigger if click unstacked chart's blank area
|
|
if (!stack && params.target?.type === 'ec-polygon') return;
|
|
// @ts-expect-error
|
|
const globalModel = echartInstance.getModel();
|
|
const model = getModelInfo(params.target, globalModel);
|
|
if (model) {
|
|
const { name } = model;
|
|
const legendState: LegendState = legendData.reduce(
|
|
(previous, datum) => ({
|
|
...previous,
|
|
[datum]: datum === name,
|
|
}),
|
|
{},
|
|
);
|
|
onLegendStateChanged?.(legendState);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div ref={extraControlRef}>
|
|
<ExtraControls formData={formData} setControlValue={setControlValue} />
|
|
</div>
|
|
<Echart
|
|
ref={echartRef}
|
|
refs={refs}
|
|
height={height - extraControlHeight}
|
|
width={width}
|
|
echartOptions={echartOptions}
|
|
eventHandlers={eventHandlers}
|
|
zrEventHandlers={zrEventHandlers}
|
|
selectedValues={selectedValues}
|
|
vizType={formData.vizType}
|
|
/>
|
|
</>
|
|
);
|
|
}
|