import React from 'react'; import ReactDOMServer from 'react-dom/server'; import { Slider, Button, Dropdown, Menu, Tooltip, Table, Modal } from 'antd'; import { debounce } from 'lodash'; import { ScalarDB, TagSchema } from './libs/dataset'; import './Scalar.module.less'; import { ECharts } from 'echarts'; import ReactEcharts from '../ReactEcharts'; import 'echarts/lib/chart/line'; import 'echarts/lib/component/dataZoom'; import 'echarts/lib/component/toolbox'; import 'echarts/lib/component/tooltip'; import 'echarts/lib/component/legend'; import 'echarts/lib/component/legendScroll'; import 'echarts/lib/component/markPoint'; import clsx from 'clsx'; import { EnableSmoothPath } from './assets/SvgIcon'; import { xAxisFormatter } from './utils/formatterHelper'; import { DEFAULT_THEME } from '../ReactEcharts/constants'; import isEqual from 'fast-deep-equal'; import Fullscreen from 'react-full-screen'; import { FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, } from '@ant-design/icons'; import { round } from 'lodash'; import { ColumnsType } from 'antd/es/table'; import './Scalar.global.less'; import scalarStore from './stores/ScalarStore'; import { observer } from 'mobx-react'; import Trigger from '../Trigger'; import { AxisForm, AxisType } from '../AxisForm'; export interface ScalarAxisConfigSchema { axisType: AxisType; startValue: number; endValue: number; time: number; } interface DataSourceSchema { key: string; color: string; name: string; iter: number; time: string; relative: string; value: number; min: string; max: string; } const TooltipTable: React.FC<{ dataSource: DataSourceSchema[]; isInModal?: boolean; isMetric?: boolean; }> = ({ dataSource, isInModal, isMetric }) => { const column: ColumnsType = [ { title: 'Name', dataIndex: 'name', render: (_, record) => { return ( {record.name} ); }, }, { title: isMetric ? 'Epoch' : 'Iteration', dataIndex: 'iter', }, { title: 'Value', dataIndex: 'value', sorter: (a, b) => a.value - b.value, render: value => { return {isMetric ? `${value}%` : value}; }, }, { title: 'Time', dataIndex: 'time', sorter: (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), }, { title: 'Relative', dataIndex: 'relative', }, ]; return ( {dataSource?.map(data => ( ))}
Name {isMetric ? 'Epoch' : 'Iteration'} Value Time Relative
{data.name} {data.iter} {isMetric ? `${data.value}%` : data.value} {data.time} {data.relative}
); }; const ModalTable: React.FC<{ dataSource: DataSourceSchema[]; isInModal?: boolean; isMetric?: boolean; }> = ({ dataSource, isInModal, isMetric }) => { const column: ColumnsType = [ { title: 'Name', dataIndex: 'name', render: (_, record) => { return ( {record.name} ); }, }, { title: isMetric ? 'Epoch' : 'Iteration', dataIndex: 'iter', }, { title: 'Value', dataIndex: 'value', sorter: (a, b) => a.value - b.value, render: value => { return {isMetric ? `${value}%` : value}; }, }, { title: 'Time', dataIndex: 'time', sorter: (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(), }, { title: 'Relative', dataIndex: 'relative', }, ]; return ( ); }; interface ScalarValueSchema { iteration: number; value: number; created: number; } interface ScalarDataSourceSchema { [key: string]: ScalarValueSchema[]; } interface ScalarProps { tasks: { id: string; name: string }[]; // 其实只会有一个,为了兼容,先这样实现 tags: TagSchema[]; dataSource: ScalarDataSourceSchema; imageName: string; isMetric?: boolean; } interface ScalarStates { canZoom: boolean; smoothWeight: number; updateHandler: boolean; fullScreen: boolean; dataSource: ScalarDataSourceSchema; tags: string[]; } @observer class Scalar extends React.Component< ScalarProps & { style?: React.CSSProperties; className?: string; }, ScalarStates > { private _mounted = false; private _echartsInstance: ECharts | null = null; private _container: HTMLDivElement | null = null; private scalardb: ScalarDB; constructor(props: any) { super(props); this.state = { canZoom: true, smoothWeight: 0.5, updateHandler: false, fullScreen: false, dataSource: ([] as unknown) as ScalarDataSourceSchema, tags: [], }; this.scalardb = new ScalarDB(); if (props.tags && props.dataSource) { this.updateData(props.tags, props.dataSource); } this.componentDidMount = () => { this._mounted = true; }; this.componentWillUnmount = () => { this._mounted = false; this._echartsInstance = null; this._container = null; }; this.shouldComponentUpdate = (nextProps, nextState) => { // 判断是否需要重新渲染本组件(外部容器的一些样式之类的) let shouldRerender = false; // 判断是否需要重新渲染echarts表格 let shouldEchartsRenrender = false; if (!isEqual(this.props, nextProps)) { shouldRerender = true; this.updateData(nextProps.tags, nextProps.dataSource); shouldEchartsRenrender = true; // if ( // !isEqual( // omit(this.props, ['style', 'className', 'searchText']), // omit(nextProps, ['style', 'className', 'searchText']), // ) // ) { // shouldEchartsRenrender = true; // } } if (!isEqual(this.state, nextState)) { shouldRerender = true; this.updateData(nextProps.tags, nextProps.dataSource); shouldEchartsRenrender = true; } this._noState.shouldEchartsRenrender = shouldEchartsRenrender; return shouldRerender; }; } updateData = (tags: TagSchema[], dataSource: ScalarDataSourceSchema) => { tags.forEach(tag => { const tagData = dataSource[tag.dataKey] ?.map(item => ({ value: item.value * (tag.multiplier || 1), iter: item.iteration, time: item.created, })) .map(item => ({ ...item, time: item.time, iter: item.iter ?? 0, value: item.value ?? 0, })); tagData?.forEach(item => { this.scalardb.appendData(this.props.tasks[0].id, tag.dataKey, item); }); // this.scalardb.updateData(this.props.tasks[0].id, tag, tagData); }); }; /** * 一些不是state的记录值 */ private _noState = { xAxisType: AxisType.Iter, shouldEchartsRenrender: true, zoomX: { iter: { startValue: undefined as number | undefined, // 这里都用undefined来确保一开始进入的时候zoom是整个图 endValue: undefined as number | undefined, }, time: { startValue: undefined as number | undefined, // 这里都用undefined来确保一开始进入的时候zoom是整个图 endValue: undefined as number | undefined, }, relative: { startValue: undefined as number | undefined, // 这里都用undefined来确保一开始进入的时候zoom是整个图 endValue: undefined as number | undefined, }, }, zoomY: { startValue: undefined as number | undefined, endValue: undefined as number | undefined, }, }; public changeAxis = ( xAxisType: AxisType, startValue: number, endValue: number, ) => { this._noState.xAxisType = xAxisType; const dataRange = this.scalardb.getDataRange( this.props.tasks, this.props.tags.map(tag => tag.dataKey), xAxisType, ); this._noState.zoomX = { iter: { startValue: xAxisType === 'iter' ? startValue : undefined, endValue: xAxisType === 'iter' ? endValue : undefined, }, time: { startValue: xAxisType === 'time' ? startValue : undefined, endValue: xAxisType === 'time' ? endValue : undefined, }, relative: { startValue: xAxisType === 'relative' ? startValue : undefined, endValue: xAxisType === 'relative' ? endValue : undefined, }, }; // 计算startValue & endValue const minValue = dataRange.x.startValue; const maxValue = dataRange.x.endValue; if (minValue !== undefined && maxValue !== undefined) { if (minValue <= startValue && maxValue >= endValue) { this._noState.zoomX[xAxisType] = { startValue: startValue, endValue: endValue, }; } else if (minValue >= endValue || maxValue <= startValue) { this._noState.zoomX[xAxisType] = { startValue: undefined, endValue: undefined, }; } else if (minValue <= startValue && maxValue <= endValue) { this._noState.zoomX[xAxisType] = { startValue: startValue, endValue: maxValue, }; } else if (minValue >= startValue && maxValue >= endValue) { this._noState.zoomX[xAxisType] = { startValue: minValue, endValue: endValue, }; } else { this._noState.zoomX[xAxisType] = { startValue: undefined, endValue: undefined, }; } } this._noState.shouldEchartsRenrender = true; this._echartsInstance?.setOption(this.getOptions()); }; public restore = () => { this._noState = { xAxisType: AxisType.Iter, shouldEchartsRenrender: true, zoomX: { iter: { startValue: undefined, endValue: undefined, }, time: { startValue: undefined, endValue: undefined, }, relative: { startValue: undefined, endValue: undefined, }, }, zoomY: { startValue: undefined, endValue: undefined, }, }; this._echartsInstance?.setOption(this.getOptions()); }; /** * 用于判断是否需要重新渲染echarts */ public get series() { const { startValue, endValue } = this._noState.zoomX[ this._noState.xAxisType ]; const series = this.scalardb.getSeries( this.props.tasks, this.props.tags, this._noState.xAxisType, this.state.smoothWeight, { startValue: startValue === undefined ? this.dataRange.x.startValue : startValue, endValue: endValue === undefined ? this.dataRange.x.endValue : endValue, }, scalarStore, this.props.isMetric || false, ); return series; } public get dataRange() { return this.scalardb.getDataRange( this.props.tasks, this.props.tags.map(tag => tag.dataKey), this._noState.xAxisType, ); } public getOptions = (): echarts.EChartOption => { // 计算dataZoom,解决数据尚未加载的问题, 思考:changeAxis方法中是不是不需要判断了 const xAxisType = this._noState.xAxisType; let startValue = this._noState.zoomX[this._noState.xAxisType].startValue; let endValue = this._noState.zoomX[this._noState.xAxisType].endValue; if (startValue !== undefined && endValue !== undefined) { const dataRange = this.scalardb.getDataRange( this.props.tasks, this.props.tags.map(tag => tag.dataKey), xAxisType, ); const minValue = dataRange.x.startValue; const maxValue = dataRange.x.endValue; if (minValue !== undefined && maxValue !== undefined) { if (minValue <= startValue && maxValue >= endValue) { startValue = startValue; endValue = endValue; } else if (minValue >= endValue || maxValue <= startValue) { startValue = undefined; endValue = undefined; } else if (minValue <= startValue && maxValue <= endValue) { startValue = startValue; endValue = maxValue; } else if (minValue >= startValue && maxValue >= endValue) { startValue = minValue; endValue = endValue; } else { startValue = undefined; endValue = undefined; } } } let oneIter = true; Object.entries(this.props.dataSource).forEach(([key, item]) => { const tagKeys = this.props.tags?.map(tag => tag.dataKey); if (tagKeys?.includes(key) && item && item.length > 1) { oneIter = false; } }); return { grid: { top: '100', left: '100', right: '100', bottom: '100', }, // backgroundColor: antdTheme['body-background'], toolbox: { right: '30', show: true, feature: { dataZoom: { title: { zoom: 'block select zoom', back: 'block select zoom undo', }, }, saveAsImage: { title: 'save as image', name: this.props.imageName, }, }, }, legend: { type: 'scroll', orient: 'horizontal', top: 20, bottom: 20, itemWidth: 30, itemHeight: 16.8, show: false, }, tooltip: { trigger: 'axis', // confine: true, // 将tooltip框限制在图表的区域内 axisPointer: { type: 'cross', snap: true, // 坐标轴指示器自动吸附到点上 label: { show: false, }, }, renderMode: 'html', // 渲染模式是html position: function(pos, params, el, elRect, size) { var obj = { top: 10 }; // @ts-ignore obj[['left', 'right'][+(pos[0] < (size as any).viewSize[0] / 2)]] = 0; return obj; }, // 网上粘的 https://echarts.apache.org/examples/zh/editor.html?c=doc-example/candlestick-axisPointer // triggerOn: "click", // 只有点击的时候才可能触发浮层出现 enterable: false, extraCssText: 'opacity: 0.85', padding: 0, formatter: params => { const dataSource = ScalarDB.Summary( this.scalardb, this.series, (Array.isArray(params) ? params : [params])[0].axisValue as number, scalarStore, ); const tableComp = ( ); return ReactDOMServer.renderToString(tableComp); }, }, dataZoom: [ { id: 'dataZoomX', type: 'slider', show: this.state.canZoom, xAxisIndex: [0], startValue: startValue, endValue: endValue, rangeMode: ['value', 'value'], filterMode: 'none', // 这个选项可以让yAxis的范围一直是全局的最大最小 labelFormatter: (value: any) => { return xAxisFormatter(this._noState.xAxisType, false)(value); }, handleIcon: 'M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4v1.3h1.3v-1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z', handleStyle: { // color: '#4b4b4b', shadowBlur: 3, shadowColor: 'rgba(0, 0, 0, 0.6)', shadowOffsetX: 2, shadowOffsetY: 2, }, }, // 先注释掉,这里加了以后就不能在图中心滚动了 // { // id: "dataZoomXIndier", // type: "inside", // xAxisIndex: [0], // startValue: this._noState.zoomX[this.state.xAxisType].startValue, // endValue: this._noState.zoomX[this.state.xAxisType].endValue, // rangeMode: ["value", "value"], // zoomOnMouseWheel: false, // moveOnMouseMove: true, // moveOnMouseWheel: false // }, { id: 'dataZoomY', type: 'slider', show: this.state.canZoom, yAxisIndex: [0], startValue: this._noState.zoomY.startValue, endValue: this._noState.zoomY.endValue, rangeMode: ['value', 'value'], filterMode: 'none', // 这个选项可以让xAxis的范围一直是全局的最大最小 handleIcon: 'M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4v1.3h1.3v-1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7V23h6.6V24.4z M13.3,19.6H6.7v-1.4h6.6V19.6z', handleStyle: { // color: '#4b4b4b', shadowBlur: 3, shadowColor: 'rgba(0, 0, 0, 0.6)', shadowOffsetX: 2, shadowOffsetY: 2, }, }, ], yAxis: this.scalardb.getYAxis(this.props.isMetric), xAxis: this.scalardb.getXAxis( this._noState.xAxisType, oneIter, this.props.isMetric, ), series: this.series, }; }; public get smoothWeightPercent(): number { return Math.ceil(this.state.smoothWeight * 100); } /** * methods声明 */ public async init() {} public truncateName(text: string, length: number, keepOverflow: boolean) { if (text.length < length) { return text; } if (!keepOverflow) { return text.slice(0, length) + '...'; } let res = ''; while (text.length) { res += text.slice(0, length) + '
'; text = text.slice(length); } return res; } handleWeightChange = debounce((val: number) => { this.setState({ smoothWeight: val / 100 }); }, 200); private _onEvents = { datazoom: (e: any) => { const { x, y } = this.dataRange; if ( x.startValue === undefined || x.endValue === undefined || y.startValue === undefined || y.endValue === undefined ) return; if (e.batch && e.batch.length === 2) { // 这种情况下是框选缩放,或是我们自己的dispatch if (e.batch[0]) { this._noState.zoomX[this._noState.xAxisType].startValue = e.batch[0].startValue === x.startValue ? undefined : e.batch[0].startValue; this._noState.zoomX[this._noState.xAxisType].endValue = e.batch[0].end === x.endValue ? undefined : e.batch[0].endValue; } if (e.batch[1]) { this._noState.zoomY.startValue = e.batch[1].start === y.startValue ? undefined : e.batch[1].startValue; this._noState.zoomY.endValue = e.batch[1].end === y.endValue ? undefined : e.batch[1].endValue; } } else { // 这种情况是x y轴缩放 const action = e.batch ? e.batch[0] : e; const relateValue = { x: x.endValue - x.startValue, y: y.endValue - y.startValue, }; if (['dataZoomX', 'dataZoomXIndier'].includes(action.dataZoomId)) { this._noState.zoomX[this._noState.xAxisType].startValue = action.start === 0 ? undefined : (action.start * relateValue.x) / 100 + x.startValue; this._noState.zoomX[this._noState.xAxisType].endValue = action.end === 100 ? undefined : (action.end * relateValue.x) / 100 + x.startValue; } if (action.dataZoomId === 'dataZoomY') { this._noState.zoomY.startValue = action.start === 0 ? undefined : (action.start * relateValue.y) / 100 + y.startValue; this._noState.zoomY.endValue = action.end === 100 ? undefined : (action.end * relateValue.y) / 100 + y.startValue; } } this._echartsInstance?.setOption(this.getOptions()); }, }; render() { return (
e.stopPropagation()} onDragStart={e => e.stopPropagation()} > this.setState({ fullScreen: enabled })} >
node.parentElement || document.body} trigger={['click']} overlay={ Smooth weight:{' '} {this.smoothWeightPercent} % } > node.parentElement} > node.parentElement} overlay={ this.state.fullScreen ? 'fullscreen exit' : 'fullscreen' } > node.parentElement} > (
{ this.changeAxis(axisType, startValue, endValue); }} defaultValue={{ axisType: this._noState.xAxisType as AxisType, startValue: this._noState.zoomX[this._noState.xAxisType] .startValue as number, endValue: this._noState.zoomX[this._noState.xAxisType] .endValue as number, }} getDataRange={xAxisType => { return this.scalardb.getDataRange( this.props.tasks, this.props.tags.map(tag => tag.dataKey), xAxisType, ); }} isMetric={this.props.isMetric} />
)} closable={false} > {({ open }) => ( node.parentElement as HTMLElement } > )}
(this._container = node)} > {/* @ts-ignore */} { this._echartsInstance = inst; // 添加点击事件 inst .getZr() .on('click', (params: { offsetX: any; offsetY: any }) => { const pointInPixel = [params.offsetX, params.offsetY]; if (inst.containPixel('grid', pointInPixel)) { try { let xIndex = round( inst.convertFromPixel( { seriesIndex: 0 }, pointInPixel, )[0], ); const dataSource = ScalarDB.Summary( this.scalardb, this.series, xIndex, scalarStore, ); Modal.info({ autoFocusButton: 'ok', getContainer: this.state.fullScreen ? this._container : null, icon: null, style: { maxWidth: 'calc(100% - 40px)', }, width: '800px', maskClosable: true, okText: '确定', content: ( ), }); } catch (e) { console.log('e', e); } } }); }} onEvents={this._onEvents} shouldSetOption={() => { return this._noState.shouldEchartsRenrender; }} />
); } } export default Scalar;