未验证 提交 b676286f 编写于 作者: P Peter Pan 提交者: GitHub

feat: add chart tooltip in histogram overlay (#691)

* test: add test case

* feat: add chart tooltip in histogram overlay

* feat: add tooltip in histogram chart
上级 c6d4cdd1
...@@ -24,5 +24,8 @@ module.exports = { ...@@ -24,5 +24,8 @@ module.exports = {
.map(p => `tsc -p ${p} --noEmit`), .map(p => `tsc -p ${p} --noEmit`),
// lint changed files // lint changed files
'**/*.(j|t)s?(x)': filenames => `eslint ${filenames.join(' ')}` '**/*.(j|t)s?(x)': filenames => [
`eslint ${filenames.join(' ')}`,
`yarn test --silent --bail --findRelatedTests ${filenames.join(' ')}`
]
}; };
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
"dev": "cross-env NODE_ENV=development ts-node index.ts", "dev": "cross-env NODE_ENV=development ts-node index.ts",
"build": "tsc", "build": "tsc",
"start": "node index.js", "start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 0" "test": "echo \"Error: no test specified\" && exit 0; #"
}, },
"bin": "dist/index.js", "bin": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
......
import Page404 from '../pages/404';
import React from 'react';
import {shallow} from 'enzyme';
describe('Page 404', () => {
test('page with content `404 - errors:page-not-found`', () => {
const page = shallow(<Page404 namespacesRequired={['errors']} />);
expect(page.text()).toEqual('404 - errors:page-not-found');
});
});
import Icon from '../components/Icon';
import React from 'react';
import {shallow} from 'enzyme';
describe('Icon component', () => {
const click = jest.fn();
const icon = shallow(<Icon type="close" onClick={click} />);
test('icon has one empty `i` element', () => {
expect(icon.is('i')).toBe(true);
expect(icon.children().length).toBe(0);
});
test('icon has class name `vdl-icon`', () => {
expect(icon.hasClass('vdl-icon')).toBe(true);
});
test('icon with type has class name `icon-${type}`', () => {
expect(icon.hasClass('icon-close')).toBe(true);
});
test('icon click', () => {
icon.simulate('click');
expect(click.mock.calls.length).toBe(1);
});
});
{
"extends": "../tsconfig.test.json"
}
import {HistogramData, Modes, OffsetData, OverlayData, options as chartOptions, transform} from '~/resource/histogram'; import {EChartOption, ECharts, EChartsConvertFinder} from 'echarts';
import {
HistogramData,
Modes,
OffsetData,
OverlayData,
OverlayDataItem,
options as chartOptions,
transform
} from '~/resource/histogram';
import LineChart, {LineChartRef} from '~/components/LineChart'; import LineChart, {LineChartRef} from '~/components/LineChart';
import React, {FunctionComponent, useCallback, useMemo, useRef, useState} from 'react'; import React, {FunctionComponent, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import StackChart, {StackChartRef} from '~/components/StackChart'; import StackChart, {StackChartProps, StackChartRef} from '~/components/StackChart';
import {rem, size} from '~/utils/style'; import {rem, size} from '~/utils/style';
import ChartToolbox from '~/components/ChartToolbox'; import ChartToolbox from '~/components/ChartToolbox';
import {EChartOption} from 'echarts';
import {Run} from '~/types'; import {Run} from '~/types';
import {distance} from '~/utils';
import ee from '~/utils/event'; import ee from '~/utils/event';
import {fetcher} from '~/utils/fetch';
import {format} from 'd3-format';
import minBy from 'lodash/minBy';
import queryString from 'query-string'; import queryString from 'query-string';
import styled from 'styled-components'; import styled from 'styled-components';
import useHeavyWork from '~/hooks/useHeavyWork'; import useHeavyWork from '~/hooks/useHeavyWork';
import {useRunningRequest} from '~/hooks/useRequest'; import {useRunningRequest} from '~/hooks/useRequest';
import useThrottleFn from '~/hooks/useThrottleFn';
import {useTranslation} from '~/utils/i18n'; import {useTranslation} from '~/utils/i18n';
const formatTooltipXValue = format('.4f');
const formatTooltipYValue = format('.4');
const transformWasm = () => const transformWasm = () =>
import('@visualdl/wasm').then(({histogram_transform}): typeof transform => params => import('@visualdl/wasm').then(({histogram_transform}): typeof transform => params =>
histogram_transform(params.data, params.mode) histogram_transform(params.data, params.mode)
...@@ -64,7 +80,11 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag, ...@@ -64,7 +80,11 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag,
const {data: dataset, error, loading} = useRunningRequest<HistogramData>( const {data: dataset, error, loading} = useRunningRequest<HistogramData>(
`/histogram/list?${queryString.stringify({run: run.label, tag})}`, `/histogram/list?${queryString.stringify({run: run.label, tag})}`,
!!running !!running,
fetcher,
{
refreshInterval: 60 * 1000
}
); );
const [maximized, setMaximized] = useState<boolean>(false); const [maximized, setMaximized] = useState<boolean>(false);
...@@ -84,12 +104,19 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag, ...@@ -84,12 +104,19 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag,
); );
const data = useHeavyWork(transformWasm, transformWorker, transform, params); const data = useHeavyWork(transformWasm, transformWorker, transform, params);
const [highlight, setHighlight] = useState<number | null>(null);
useEffect(() => setHighlight(null), [mode]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
type Optional<T> = T | undefined; type Optional<T> = T | undefined;
if (mode === Modes.Overlay) { if (mode === Modes.Overlay) {
return (data as Optional<OverlayData>)?.data.map(items => ({ return (data as Optional<OverlayData>)?.data.map((items, index) => ({
name: `step${items[0][1]}`, name: `${t('common:time-mode.step')}${items[0][1]}`,
data: items, data: items,
lineStyle: {
color: run.colors[index === highlight || highlight == null ? 0 : 1]
},
z: index === highlight ? data?.data.length : index,
encode: { encode: {
x: [2], x: [2],
y: [3] y: [3]
...@@ -97,18 +124,99 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag, ...@@ -97,18 +124,99 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag,
})); }));
} }
if (mode === Modes.Offset) { if (mode === Modes.Offset) {
const offset = data as Optional<OffsetData>;
return { return {
...((data as Optional<OffsetData>) ?? {}) minX: offset?.minX,
maxX: offset?.maxX,
minY: offset?.minStep,
maxY: offset?.maxStep,
minZ: offset?.minZ,
maxZ: offset?.maxZ,
data: offset?.data ?? []
}; };
} }
}, [data, mode]); return null as never;
}, [data, mode, run, highlight, t]);
const formatter = {
[Modes.Overlay]: useCallback(
(params: EChartOption.Tooltip.Format | EChartOption.Tooltip.Format[]) => {
if (!data || highlight == null) {
return '';
}
const series = (params as EChartOption.Tooltip.Format[]).filter(
s => s.data[1] === (data as OverlayData).data[highlight][0][1]
);
return [
series[0].seriesName,
...series.map(s => `${formatTooltipXValue(s.data[2])}, ${formatTooltipYValue(s.data[3])}`)
].join('<br />');
},
[highlight, data]
),
[Modes.Offset]: useCallback(
(step: number, dots: [number, number, number][]) =>
[
`${t('common:time-mode.step')}${step}`,
...dots
.filter(d => d[1] === step)
.map(d => `${formatTooltipXValue(d[0])}, ${formatTooltipYValue(d[2])}`)
].join('<br />'),
[t]
)
} as const;
const options = useMemo( const options = useMemo(
() => ({ () => ({
...chartOptions[mode], ...chartOptions[mode],
color: [run.colors[0]] color: [run.colors[0]],
tooltip: {
formatter: formatter[mode]
}
}), }),
[mode, run] [mode, run, formatter]
);
const mousemove = useCallback((echarts: ECharts, {offsetX, offsetY}: {offsetX: number; offsetY: number}) => {
const series = echarts.getOption().series;
const pt: [number, number] = [offsetX, offsetY];
if (series) {
type Distance = number;
type Index = number;
const npts: [number, number, Distance, Index][] = series.map((s, i) =>
(s.data as OverlayDataItem[])?.reduce(
(m, [, , x, y]) => {
const px = echarts.convertToPixel('grid' as EChartsConvertFinder, [x, y]) as [number, number];
const d = distance(px, pt);
if (d < m[2]) {
return [x, y, d, i];
}
return m;
},
[0, 0, Number.POSITIVE_INFINITY, i]
)
);
const npt = minBy(npts, p => p[2]);
setHighlight(npt?.[3] ?? null);
return;
}
}, []);
const throttled = useThrottleFn(mousemove, {wait: 200});
const onInit = useCallback(
(echarts: ECharts) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zr = (echarts as any).getZr();
if (zr) {
zr.on('mousemove', (e: {offsetX: number; offsetY: number}) => throttled.run(echarts, e));
zr.on('mouseout', () => {
throttled.cancel();
setHighlight(null);
});
}
},
[throttled]
); );
const chart = useMemo(() => { const chart = useMemo(() => {
...@@ -118,8 +226,9 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag, ...@@ -118,8 +226,9 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag,
ref={echart as React.RefObject<LineChartRef>} ref={echart as React.RefObject<LineChartRef>}
title={title} title={title}
data={chartData as EChartOption<EChartOption.SeriesLine>['series']} data={chartData as EChartOption<EChartOption.SeriesLine>['series']}
options={options} options={options as EChartOption}
loading={loading} loading={loading}
onInit={onInit}
/> />
); );
} }
...@@ -128,14 +237,14 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag, ...@@ -128,14 +237,14 @@ const HistogramChart: FunctionComponent<HistogramChartProps> = ({cid, run, tag,
<StyledStackChart <StyledStackChart
ref={echart as React.RefObject<StackChartRef>} ref={echart as React.RefObject<StackChartRef>}
title={title} title={title}
data={chartData as EChartOption<EChartOption.SeriesCustom>['series'] & OffsetData} data={chartData as StackChartProps['data']}
options={options} options={options as EChartOption}
loading={loading} loading={loading}
/> />
); );
} }
return null; return null;
}, [chartData, loading, mode, options, title]); }, [chartData, loading, mode, options, title, onInit]);
// display error only on first fetch // display error only on first fetch
if (!data && error) { if (!data && error) {
......
...@@ -8,7 +8,7 @@ type IconProps = { ...@@ -8,7 +8,7 @@ type IconProps = {
}; };
const Icon: FunctionComponent<IconProps & WithStyled> = ({type, onClick, className}) => { const Icon: FunctionComponent<IconProps & WithStyled> = ({type, onClick, className}) => {
return <i className={`vdl-icon icon-${type} ${className ?? ''}`} onClick={() => onClick?.()} />; return <i className={`vdl-icon icon-${type} ${className ?? ''}`} onClick={() => onClick?.()}></i>;
}; };
export default Icon; export default Icon;
...@@ -2,7 +2,7 @@ import * as chart from '~/utils/chart'; ...@@ -2,7 +2,7 @@ import * as chart from '~/utils/chart';
import React, {useEffect, useImperativeHandle} from 'react'; import React, {useEffect, useImperativeHandle} from 'react';
import {WithStyled, primaryColor} from '~/utils/style'; import {WithStyled, primaryColor} from '~/utils/style';
import useECharts, {Wrapper} from '~/hooks/useECharts'; import useECharts, {Options, Wrapper} from '~/hooks/useECharts';
import {EChartOption} from 'echarts'; import {EChartOption} from 'echarts';
import GridLoader from 'react-spinners/GridLoader'; import GridLoader from 'react-spinners/GridLoader';
...@@ -16,6 +16,7 @@ type LineChartProps = { ...@@ -16,6 +16,7 @@ type LineChartProps = {
data?: Partial<NonNullable<EChartOption<EChartOption.SeriesLine>['series']>>; data?: Partial<NonNullable<EChartOption<EChartOption.SeriesLine>['series']>>;
loading?: boolean; loading?: boolean;
zoom?: boolean; zoom?: boolean;
onInit?: Options['onInit'];
}; };
export enum XAxisType { export enum XAxisType {
...@@ -35,13 +36,14 @@ export type LineChartRef = { ...@@ -35,13 +36,14 @@ export type LineChartRef = {
}; };
const LineChart = React.forwardRef<LineChartRef, LineChartProps & WithStyled>( const LineChart = React.forwardRef<LineChartRef, LineChartProps & WithStyled>(
({options, data, title, loading, zoom, className}, ref) => { ({options, data, title, loading, zoom, className, onInit}, ref) => {
const {i18n} = useTranslation(); const {i18n} = useTranslation();
const {ref: echartRef, echart, wrapper, saveAsImage} = useECharts<HTMLDivElement>({ const {ref: echartRef, echart, wrapper, saveAsImage} = useECharts<HTMLDivElement>({
loading: !!loading, loading: !!loading,
zoom, zoom,
autoFit: true autoFit: true,
onInit
}); });
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
......
import * as chart from '~/utils/chart'; import * as chart from '~/utils/chart';
import React, {useCallback, useEffect, useImperativeHandle} from 'react'; import {EChartOption, ECharts, EChartsConvertFinder} from 'echarts';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {WithStyled, primaryColor} from '~/utils/style'; import {WithStyled, primaryColor} from '~/utils/style';
import useECharts, {Wrapper} from '~/hooks/useECharts'; import useECharts, {Options, Wrapper} from '~/hooks/useECharts';
import {EChartOption} from 'echarts';
import GridLoader from 'react-spinners/GridLoader'; import GridLoader from 'react-spinners/GridLoader';
import defaultsDeep from 'lodash/defaultsDeep'; import defaultsDeep from 'lodash/defaultsDeep';
import {useTranslation} from '~/utils/i18n'; import styled from 'styled-components';
import useThrottleFn from '~/hooks/useThrottleFn';
const Tooltip = styled.div`
position: absolute;
z-index: 1;
background-color: rgba(0, 0, 0, 0.75);
color: #fff;
border-radius: 4px;
padding: 5px;
display: none;
`;
type renderItem = NonNullable<EChartOption.SeriesCustom['renderItem']>; type renderItem = NonNullable<EChartOption.SeriesCustom['renderItem']>;
type renderItemArguments = NonNullable<renderItem['arguments']>; type renderItemArguments = NonNullable<renderItem['arguments']>;
...@@ -19,19 +30,21 @@ type RenderItem = ( ...@@ -19,19 +30,21 @@ type RenderItem = (
type GetValue = (i: number) => number; type GetValue = (i: number) => number;
type GetCoord = (p: [number, number]) => [number, number]; type GetCoord = (p: [number, number]) => [number, number];
type StackChartProps = { export type StackChartProps = {
options?: EChartOption; options?: EChartOption;
title?: string; title?: string;
data?: Partial<NonNullable<EChartOption<EChartOption.SeriesCustom>['series']>[number]> & { data?: Partial<Omit<NonNullable<EChartOption<EChartOption.SeriesCustom>['series']>[number], 'data'>> & {
minZ: number; minZ: number;
maxZ: number; maxZ: number;
minX: number; minX: number;
maxX: number; maxX: number;
minStep: number; minY: number;
maxStep: number; maxY: number;
data: number[][];
}; };
loading?: boolean; loading?: boolean;
zoom?: boolean; zoom?: boolean;
onInit?: Options['onInit'];
}; };
export type StackChartRef = { export type StackChartRef = {
...@@ -39,117 +52,357 @@ export type StackChartRef = { ...@@ -39,117 +52,357 @@ export type StackChartRef = {
}; };
const StackChart = React.forwardRef<StackChartRef, StackChartProps & WithStyled>( const StackChart = React.forwardRef<StackChartRef, StackChartProps & WithStyled>(
({options, data, title, loading, zoom, className}, ref) => { ({options, data, title, loading, zoom, className, onInit}, ref) => {
const {i18n} = useTranslation(); const {minZ, maxZ, minY, maxY, minX, maxX, ...seriesData} = data ?? {
const {ref: echartRef, echart, wrapper, saveAsImage} = useECharts<HTMLDivElement>({
loading: !!loading,
zoom,
autoFit: true
});
useImperativeHandle(ref, () => ({
saveAsImage: () => {
saveAsImage(title);
}
}));
const {minZ, maxZ, minStep, maxStep, minX, maxX, ...seriesData} = data ?? {
minZ: 0, minZ: 0,
maxZ: 0, maxZ: 0,
minStep: 0, minY: 0,
maxStep: 0, maxY: 0,
minX: 0, minX: 0,
maxX: 0, maxX: 0,
data: null data: null
}; };
const rawData = (seriesData.data as number[][]) ?? []; const rawData = seriesData.data ?? [];
const negativeY = useMemo(() => minY - (maxY - minY) * 0.4, [minY, maxY]);
const getPoint = useCallback( const getPoint = useCallback(
(x: number, y: number, z: number, getCoord: GetCoord, yValueMapHeight: number) => { (x: number, y: number, z: number, getCoord: GetCoord) => {
const pt = getCoord([x, y]); const pt = getCoord([x, y]);
// bug of echarts
if (!pt) {
return [0, 0];
}
// linear map in z axis // linear map in z axis
pt[1] -= ((z - minZ) / (maxZ - minZ)) * yValueMapHeight; pt[1] -= ((z - minZ) / (maxZ - minZ)) * (getCoord([0, minY])[1] - getCoord([0, negativeY])[1]);
return pt; return pt;
}, },
[minZ, maxZ] [minZ, maxZ, minY, negativeY]
); );
const makePolyPoints = useCallback( const makePolyPoints = useCallback(
(dataIndex: number, getValue: GetValue, getCoord: GetCoord, yValueMapHeight: number) => { (dataIndex: number, getValue: GetValue, getCoord: GetCoord) => {
const points = []; const points = [];
let i = 0; let i = 0;
while (rawData[dataIndex] && i < rawData[dataIndex].length) { while (rawData[dataIndex] && i < rawData[dataIndex].length) {
const x = getValue(i++); const x = getValue(i++);
const y = getValue(i++); const y = getValue(i++);
const z = getValue(i++); const z = getValue(i++);
points.push(getPoint(x, y, z, getCoord, yValueMapHeight)); if (z !== 1 && i === 3) {
points.push(getPoint(x, y, 1, getCoord));
}
points.push(getPoint(x, y, z, getCoord));
if (z !== 1 && i === rawData[dataIndex].length) {
points.push(getPoint(x, y, 1, getCoord));
}
} }
return points; return points;
}, },
[getPoint, rawData] [getPoint, rawData]
); );
const renderItem = useCallback<RenderItem>(
(params, api) => {
const points = makePolyPoints(params.dataIndex as number, api.value as GetValue, api.coord as GetCoord);
return {
type: 'polygon',
silent: true,
z: api.value(1),
shape: {
points
},
style: api.style({
stroke: chart.xAxis.axisLine.lineStyle.color,
lineWidth: 1
})
};
},
[makePolyPoints]
);
const chartOptions = useMemo<EChartOption>(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {color, colorAlt, toolbox, series, ...defaults} = chart;
return defaultsDeep(
{
title: {
text: title ?? ''
},
visualMap: {
min: minY,
max: maxY
},
xAxis: {
min: minX,
max: maxX
},
yAxis: {
inverse: true,
position: 'right',
min: negativeY,
max: maxY,
axisLine: {
onZero: false
},
axisLabel: {
formatter: (value: number) => (value < minY ? '' : value + '')
}
},
grid: {
left: defaults.grid.right,
right: defaults.grid.left
},
tooltip: {
trigger: 'none',
showContent: false,
axisPointer: {
axis: 'y',
snap: false
}
},
series: [
{
...series,
type: 'custom',
silent: true,
data: rawData,
renderItem
}
]
},
options,
defaults
);
}, [options, title, rawData, minX, maxX, minY, maxY, negativeY, renderItem]);
const [highlight, setHighlight] = useState<number | null>(null);
const [dots, setDots] = useState<[number, number, number][]>([]);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [tooltip, setTooltip] = useState('');
const mouseout = useCallback(() => {
setHighlight(null);
setDots([]);
if (chartOptions.tooltip?.formatter) {
setTooltip('');
if (tooltipRef.current) {
tooltipRef.current.style.display = 'none';
}
}
}, [chartOptions.tooltip]);
const mousemove = useCallback(
(echarts: ECharts, e: {offsetX: number; offsetY: number}) => {
try {
if (!echarts || !e) {
return;
}
const {offsetX, offsetY} = e;
if (offsetY < negativeY + ((chartOptions.grid as EChartOption.Grid).top as number) ?? 0) {
mouseout();
return;
}
const [x, y] = echarts.convertFromPixel('grid' as EChartsConvertFinder, [offsetX, offsetY]) as [
number,
number
];
const data = (echarts.getOption().series?.[0].data as number[][]) ?? [];
// find right on top step
const steps = data.map(row => row[1]).sort((a, b) => a - b);
let i = 0;
let step: number | null = null;
while (i < steps.length) {
if (y <= steps[i++]) {
step = steps[i - 1];
break;
}
}
setHighlight(step == null ? null : data.findIndex(row => row[1] === step));
// find nearest x axis point
let dots: [number, number, number][] = [];
if (step == null) {
setDots(dots);
} else {
dots = data.map(row => {
const pt: [number, number, number] = [row[0], row[1], row[2]];
let d = Number.POSITIVE_INFINITY;
for (let j = 0; j < row.length; j += 3) {
const d1 = Math.abs(row[j] - x);
if (d1 < d) {
d = d1;
pt[0] = row[j];
pt[2] = row[j + 2];
}
}
return pt;
});
setDots(dots);
}
// set tooltip
if (chartOptions.tooltip?.formatter) {
setTooltip(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
step == null ? '' : (chartOptions.tooltip?.formatter as any)?.(step, dots)
);
if (tooltipRef.current) {
if (step == null) {
tooltipRef.current.style.display = 'none';
} else {
tooltipRef.current.style.left = `${offsetX + 10}px`;
tooltipRef.current.style.top = `${offsetY + 10}px`;
tooltipRef.current.style.display = 'block';
}
}
}
} catch {
mouseout();
}
},
[mouseout, negativeY, chartOptions.grid, chartOptions.tooltip]
);
const throttled = useThrottleFn(mousemove, {wait: 200});
const init = useCallback<NonNullable<Options['onInit']>>(
echarts => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const zr = (echarts as any).getZr();
if (zr) {
zr.on('mousemove', (e: {offsetX: number; offsetY: number}) => throttled.run(echarts, e));
zr.on('mouseout', () => {
throttled.cancel();
mouseout();
});
}
} catch {
throttled.cancel();
}
onInit?.(echarts);
},
[onInit, throttled, mouseout]
);
const {ref: echartRef, echart, wrapper, saveAsImage} = useECharts<HTMLDivElement>({
loading: !!loading,
zoom,
autoFit: true,
onInit: init
});
useImperativeHandle(ref, () => ({
saveAsImage: () => {
saveAsImage(title);
}
}));
useEffect(() => { useEffect(() => {
if (process.browser) { if (process.browser) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars echart?.setOption(chartOptions, {notMerge: true});
const {color, colorAlt, ...defaults} = chart; }
}, [echart, chartOptions]);
const chartOptions: EChartOption = defaultsDeep( useEffect(() => {
{ if (echart) {
title: { try {
text: title ?? '' if (highlight == null) {
}, echart.setOption({
visualMap: { graphic: {
min: minStep, elements: [
max: maxStep {
}, id: 'highlight',
xAxis: { type: 'polyline',
min: minX, $action: 'remove'
max: maxX }
}, ]
grid: {
top: '40%'
},
tooltip: {
axisPointer: {
axis: 'y',
snap: false
} }
}, });
series: [ } else {
{ const data = (echart.getOption().series?.[0].data as number[][]) ?? [];
type: 'custom', const getCoord: GetCoord = pt =>
dimensions: ['x', 'y'], echart.convertToPixel('grid' as EChartsConvertFinder, pt) as [number, number];
data: rawData as number[][], const getValue: GetValue = i => data[highlight][i];
renderItem: ((params, api) => { echart.setOption({
const points = makePolyPoints( graphic: {
params.dataIndex as number, elements: [
api.value as GetValue, {
api.coord as GetCoord, id: 'highlight',
(params.coordSys.y as number) - 10 type: 'polyline',
); $action: 'replace',
return {
type: 'polygon',
silent: true, silent: true,
cursor: 'default',
zlevel: 1,
z: 1,
shape: { shape: {
points points: makePolyPoints(highlight, getValue, getCoord)
}
}
]
}
});
}
} catch {
// ignore
}
}
}, [highlight, echart, makePolyPoints]);
useEffect(() => {
if (echart) {
try {
if (!dots.length) {
echart.setOption({
graphic: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
elements: ((echart.getOption()?.graphic as any[])?.[0]?.elements as any[])
?.filter(element => (element.id as string).startsWith('dot'))
.map(element => ({
id: element.id,
type: 'circle',
$action: 'remove'
}))
}
});
} else {
const getCoord: GetCoord = pt =>
echart.convertToPixel('grid' as EChartsConvertFinder, pt) as [number, number];
echart.setOption({
graphic: {
elements: dots.map((dot, i) => {
const pt = getPoint(dot[0], dot[1], dot[2], getCoord);
return {
type: 'circle',
id: `dot${i}`,
$action: 'replace',
cursor: 'default',
zlevel: 1,
z: 2,
shape: {
cx: pt[0],
cy: pt[1],
r: 3
}, },
style: api.style({ style: {
stroke: chart.xAxis.axisLine.lineStyle.color, fill: '#fff',
lineWidth: 1 stroke: chartOptions.color?.[0],
}) lineWidth: 2
}
}; };
}) as RenderItem })
} }
] });
}, }
options, } catch {
defaults // ignore
); }
echart?.setOption(chartOptions, {notMerge: true});
} }
}, [options, data, title, i18n.language, echart, rawData, minX, maxX, minStep, maxStep, makePolyPoints]); }, [dots, echart, chartOptions.color, getPoint]);
return ( return (
<Wrapper ref={wrapper} className={className}> <Wrapper ref={wrapper} className={className}>
...@@ -159,6 +412,7 @@ const StackChart = React.forwardRef<StackChartRef, StackChartProps & WithStyled> ...@@ -159,6 +412,7 @@ const StackChart = React.forwardRef<StackChartRef, StackChartProps & WithStyled>
</div> </div>
)} )}
<div className="echarts" ref={echartRef}></div> <div className="echarts" ref={echartRef}></div>
<Tooltip className="tooltip" ref={tooltipRef} dangerouslySetInnerHTML={{__html: tooltip}} />
</Wrapper> </Wrapper>
); );
} }
......
import {useRef} from 'react';
export default function useCreation<T>(factory: () => T, deps: unknown[]) {
const {current} = useRef({
deps,
obj: undefined as undefined | T,
initialized: false
});
if (current.initialized === false || !depsAreSame(current.deps, deps)) {
current.deps = deps;
current.obj = factory();
current.initialized = true;
}
return current.obj as T;
}
function depsAreSame(oldDeps: unknown[], deps: unknown[]): boolean {
if (oldDeps === deps) return true;
for (const i in oldDeps) {
if (oldDeps[i] !== deps[i]) return false;
}
return true;
}
...@@ -6,12 +6,18 @@ import {dataURL2Blob} from '~/utils/image'; ...@@ -6,12 +6,18 @@ import {dataURL2Blob} from '~/utils/image';
import {saveAs} from 'file-saver'; import {saveAs} from 'file-saver';
import styled from 'styled-components'; import styled from 'styled-components';
const useECharts = <T extends HTMLElement, W extends HTMLElement = HTMLDivElement>(options: { export type Options = {
loading?: boolean; loading?: boolean;
gl?: boolean; gl?: boolean;
zoom?: boolean; zoom?: boolean;
autoFit?: boolean; autoFit?: boolean;
}): { onInit?: (echarts: ECharts) => unknown;
onDispose?: (echarts: ECharts) => unknown;
};
const useECharts = <T extends HTMLElement, W extends HTMLElement = HTMLDivElement>(
options: Options
): {
ref: MutableRefObject<T | null>; ref: MutableRefObject<T | null>;
wrapper: MutableRefObject<W | null>; wrapper: MutableRefObject<W | null>;
echart: ECharts | null; echart: ECharts | null;
...@@ -21,6 +27,9 @@ const useECharts = <T extends HTMLElement, W extends HTMLElement = HTMLDivElemen ...@@ -21,6 +27,9 @@ const useECharts = <T extends HTMLElement, W extends HTMLElement = HTMLDivElemen
const echartInstance = useRef<ECharts | null>(null); const echartInstance = useRef<ECharts | null>(null);
const [echart, setEchart] = useState<ECharts | null>(null); const [echart, setEchart] = useState<ECharts | null>(null);
const onInit = useRef(options.onInit);
const onDispose = useRef(options.onDispose);
const createChart = useCallback(() => { const createChart = useCallback(() => {
(async () => { (async () => {
const echarts = await import('echarts'); const echarts = await import('echarts');
...@@ -31,20 +40,29 @@ const useECharts = <T extends HTMLElement, W extends HTMLElement = HTMLDivElemen ...@@ -31,20 +40,29 @@ const useECharts = <T extends HTMLElement, W extends HTMLElement = HTMLDivElemen
return; return;
} }
echartInstance.current = echarts.init((ref.current as unknown) as HTMLDivElement); echartInstance.current = echarts.init((ref.current as unknown) as HTMLDivElement);
if (options.zoom) {
setTimeout(() => { setTimeout(() => {
if (options.zoom) {
echartInstance.current?.dispatchAction({ echartInstance.current?.dispatchAction({
type: 'takeGlobalCursor', type: 'takeGlobalCursor',
key: 'dataZoomSelect', key: 'dataZoomSelect',
dataZoomSelectActive: true dataZoomSelectActive: true
}); });
}, 0); }
}
if (echartInstance.current) {
onInit.current?.(echartInstance.current);
}
}, 0);
setEchart(echartInstance.current); setEchart(echartInstance.current);
})(); })();
}, [options.gl, options.zoom]); }, [options.gl, options.zoom]);
const destroyChart = useCallback(() => { const destroyChart = useCallback(() => {
if (echartInstance.current) {
onDispose.current?.(echartInstance.current);
}
echartInstance.current?.dispose(); echartInstance.current?.dispose();
setEchart(null); setEchart(null);
}, []); }, []);
......
...@@ -35,20 +35,20 @@ function useRunningRequest<D = unknown, E = unknown>( ...@@ -35,20 +35,20 @@ function useRunningRequest<D = unknown, E = unknown>(
key: keyInterface, key: keyInterface,
running: boolean, running: boolean,
fetcher?: fetcherFn<D>, fetcher?: fetcherFn<D>,
config?: Omit<ConfigInterface<D, E, fetcherFn<D>>, 'refreshInterval' | 'dedupingInterval' | 'errorRetryInterval'> config?: Omit<ConfigInterface<D, E, fetcherFn<D>>, 'dedupingInterval' | 'errorRetryInterval'>
): Response<D, E>; ): Response<D, E>;
function useRunningRequest<D = unknown, E = unknown>( function useRunningRequest<D = unknown, E = unknown>(
key: keyInterface, key: keyInterface,
running: boolean, running: boolean,
fetcher?: fetcherFn<D>, fetcher?: fetcherFn<D>,
config?: Omit<ConfigInterface<D, E, fetcherFn<D>>, 'refreshInterval' | 'dedupingInterval' | 'errorRetryInterval'> config?: Omit<ConfigInterface<D, E, fetcherFn<D>>, 'dedupingInterval' | 'errorRetryInterval'>
) { ) {
const c = useMemo<ConfigInterface<D, E, fetcherFn<D>>>( const c = useMemo<ConfigInterface<D, E, fetcherFn<D>>>(
() => ({ () => ({
...config, ...config,
refreshInterval: running ? 15 * 1000 : 0, refreshInterval: running ? config?.refreshInterval ?? 15 * 1000 : 0,
dedupingInterval: 15 * 1000, dedupingInterval: config?.refreshInterval ?? 15 * 1000,
errorRetryInterval: 15 * 1000 errorRetryInterval: config?.refreshInterval ?? 15 * 1000
}), }),
[running, config] [running, config]
); );
......
/* eslint-disable @typescript-eslint/no-explicit-any */
import throttle from 'lodash/throttle';
import useCreation from '~/hooks/useCreation';
import {useRef} from 'react';
export interface ThrottleOptions {
wait?: number;
leading?: boolean;
trailing?: boolean;
}
type Fn = (...args: any[]) => any;
interface ReturnValue<T extends Fn> {
run: T;
cancel: () => void;
}
function useThrottleFn<T extends Fn>(fn: T, options?: ThrottleOptions): ReturnValue<T> {
const fnRef = useRef<Fn>(fn);
fnRef.current = fn;
const wait = options?.wait ?? 1000;
const throttled = useCreation(
() =>
throttle(
(...args: any[]) => {
fnRef.current(...args);
},
wait,
options
),
[]
);
return {
run: (throttled as any) as T,
cancel: throttled.cancel
};
}
export default useThrottleFn;
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'tsx', 'js'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest'
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
coveragePathIgnorePatterns: ['<rootDir>/next.config.js', '<rootDir>/node_modules/'],
testPathIgnorePatterns: ['<rootDir>/dist/', '<rootDir>/out/', '<rootDir>/node_modules/'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'\\.css$': '<rootDir>/__mocks__/styleMock.js',
'~/(.*)': '<rootDir>/$1'
},
snapshotSerializers: ['enzyme-to-json/serializer'],
globals: {
'ts-jest': {
tsConfig: '<rootDir>/tsconfig.test.json'
}
}
};
import ReactSixteenAdapter from 'enzyme-adapter-react-16';
import {configure} from 'enzyme';
configure({adapter: new ReactSixteenAdapter()});
...@@ -29,7 +29,8 @@ ...@@ -29,7 +29,8 @@
"build:next": "next build", "build:next": "next build",
"export": "next export", "export": "next export",
"start": "next start", "start": "next start",
"test": "echo \"Error: no test specified\" && exit 0" "test": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest",
"test:coverage": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage"
}, },
"dependencies": { "dependencies": {
"@tippyjs/react": "4.0.5", "@tippyjs/react": "4.0.5",
...@@ -66,7 +67,10 @@ ...@@ -66,7 +67,10 @@
"@babel/core": "7.10.3", "@babel/core": "7.10.3",
"@types/d3-format": "1.3.1", "@types/d3-format": "1.3.1",
"@types/echarts": "4.6.3", "@types/echarts": "4.6.3",
"@types/enzyme": "3.10.5",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/file-saver": "2.0.1", "@types/file-saver": "2.0.1",
"@types/jest": "26.0.3",
"@types/lodash": "4.14.157", "@types/lodash": "4.14.157",
"@types/mime-types": "2.1.0", "@types/mime-types": "2.1.0",
"@types/node": "14.0.14", "@types/node": "14.0.14",
...@@ -83,7 +87,12 @@ ...@@ -83,7 +87,12 @@
"cross-env": "7.0.2", "cross-env": "7.0.2",
"css-loader": "3.6.0", "css-loader": "3.6.0",
"enhanced-resolve": "4.2.0", "enhanced-resolve": "4.2.0",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.2",
"enzyme-to-json": "3.5.0",
"jest": "26.1.0",
"ora": "4.0.4", "ora": "4.0.4",
"ts-jest": "26.1.1",
"typescript": "3.9.5", "typescript": "3.9.5",
"worker-plugin": "4.0.3" "worker-plugin": "4.0.3"
}, },
......
import {NextI18NextPage, useTranslation} from '~/utils/i18n'; import {NextI18NextPage, useTranslation} from '~/utils/i18n';
import {headerHeight, rem} from '~/utils/style'; import {headerHeight, rem} from '~/utils/style';
import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
const Error = styled.div` const Error = styled.div`
......
...@@ -5,9 +5,6 @@ import {Modes} from './types'; ...@@ -5,9 +5,6 @@ import {Modes} from './types';
const baseOptions: EChartOption = { const baseOptions: EChartOption = {
legend: { legend: {
data: [] data: []
},
tooltip: {
showContent: false
} }
}; };
......
...@@ -30,8 +30,9 @@ ...@@ -30,8 +30,9 @@
"isolatedModules": true "isolatedModules": true
}, },
"exclude": [ "exclude": [
"server", "node_modules",
"node_modules" "__tests__",
"__mocks__"
], ],
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
......
{
"compilerOptions": {
"jsx": "react",
"target": "es5",
"module": "esnext",
"moduleResolution": "node",
"lib": [
"esnext",
"esnext.asynciterable",
"dom",
"webworker"
],
"esModuleInterop": true,
"allowJs": true,
"resolveJsonModule": true,
"sourceMap": true,
"strict": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"~/*": [
"./*"
]
},
"types": [
"@types/node",
"@types/jest"
],
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"noImplicitAny": true
}
}
...@@ -3,7 +3,7 @@ export interface Run { ...@@ -3,7 +3,7 @@ export interface Run {
colors: [string, string]; colors: [string, string];
} }
export interface Tag<R = Run> { export interface Tag<R extends Run = Run> {
runs: R[]; runs: R[];
label: string; label: string;
} }
......
...@@ -21,3 +21,6 @@ export const quantile = (values: number[], p: number) => { ...@@ -21,3 +21,6 @@ export const quantile = (values: number[], p: number) => {
const value1 = new BigNumber(values[i0 + 1]); const value1 = new BigNumber(values[i0 + 1]);
return value0.plus(value1.minus(value0).multipliedBy(i.minus(i0))).toNumber(); return value0.plus(value1.minus(value0).multipliedBy(i.minus(i0))).toNumber();
}; };
export const distance = (p1: [number, number], p2: [number, number]): number =>
Math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2);
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "echo \"Error: no test specified\" && exit 0" "test": "echo \"Error: no test specified\" && exit 0; #"
}, },
"files": [ "files": [
"dist", "dist",
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
], ],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "echo \"Error: no test specified\" && exit 0" "test": "echo \"Error: no test specified\" && exit 0; #"
}, },
"dependencies": { "dependencies": {
"faker": "4.1.0", "faker": "4.1.0",
......
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
}, },
"scripts": { "scripts": {
"build": "rimraf dist && webpack", "build": "rimraf dist && webpack",
"test": "echo \"Error: no test specified\" && exit 0" "test": "echo \"Error: no test specified\" && exit 0; #"
}, },
"files": [ "files": [
"dist" "dist"
......
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
"build": "cross-env API_URL=/api ts-node --script-mode build.ts", "build": "cross-env API_URL=/api ts-node --script-mode build.ts",
"build:webpack": "webpack", "build:webpack": "webpack",
"start": "pm2-runtime ecosystem.config.js", "start": "pm2-runtime ecosystem.config.js",
"test": "echo \"Error: no test specified\" && exit 0" "test": "echo \"Error: no test specified\" && exit 0; #"
}, },
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
], ],
"scripts": { "scripts": {
"build": "cross-env ts-node --script-mode build.ts", "build": "cross-env ts-node --script-mode build.ts",
"test": "echo \"Error: no test specified\" && exit 0" "test": "echo \"Error: no test specified\" && exit 0; #"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "14.0.14", "@types/node": "14.0.14",
......
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"scripts": { "scripts": {
"build": "wasm-pack build --release --out-dir dist --out-name index .", "build": "wasm-pack build --release --out-dir dist --out-name index .",
"test": "echo \"Error: no test specified\" && exit 0" "test": "echo \"Error: no test specified\" && exit 0; #"
}, },
"devDependencies": { "devDependencies": {
"wasm-pack": "0.9.1" "wasm-pack": "0.9.1"
......
...@@ -75,7 +75,7 @@ fn compute_histogram( ...@@ -75,7 +75,7 @@ fn compute_histogram(
let mut range: Vec<f64> = vec![]; let mut range: Vec<f64> = vec![];
let mut optional = Some(min); let mut optional = Some(min);
while let Some(i) = optional { while let Some(i) = optional {
if i > max { if i >= max {
optional = None; optional = None;
} else { } else {
range.push(i); range.push(i);
......
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册