import React, { useContext, useState, useReducer, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Highcharts from 'highcharts/highstock'; import HighchartsReact from 'highcharts-react-official'; import HighchartsBoost from 'highcharts/modules/boost'; import { Button, Tabs, Select, Radio, Checkbox, ConfigProvider, DatePicker, Spin } from 'antd'; import { PlusCircleOutlined, CloseCircleFilled, DownloadOutlined } from '@ant-design/icons'; import TimeRangePicker from '@wisdom-components/timerangepicker'; import BasicTable from '@wisdom-components/basictable'; import Empty from '@wisdom-components/empty'; import { ExportExcel } from '@wisdom-components/exportexcel'; import moment from 'moment'; import './index.less'; HighchartsBoost(Highcharts); const { TabPane } = Tabs; const { RangePicker } = DatePicker; const { Option } = Select; const UPDATE_TIME = { UPDATE_TIME: 'updateTime', UPDATE_BATCH_TIME: 'updateBatchTime', UPDATE_DATA_THIN: 'updateDataThin', }; const reducer = (state, action) => { switch (action.type) { case UPDATE_TIME.UPDATE_TIME: return { ...state, dateRange: updateTime(action.payload), }; case UPDATE_TIME.UPDATE_BATCH_TIME: return { ...state, dateRange: action.payload, }; case 'updateIgnoreOutliers': return { ...state, ignoreOutliers: action.payload, }; case UPDATE_TIME.UPDATE_DATA_THIN: const { zoom, unit } = action.payload; return { ...state, zoom, unit, }; default: throw new Error(); } }; const updateTime = (key) => { let start = '', end = ''; if (Array.isArray(key)) { start = moment(key[0]).format(timeFormat); end = moment(key[1]).format(timeFormat); } else { switch (key) { case 'oneHour': start = moment().subtract(1, 'hour').format(timeFormat); end = moment().format(timeFormat); break; case 'fourHour': start = moment().subtract(4, 'hour').format(timeFormat); end = moment().format(timeFormat); break; case 'twelveHours': start = moment().subtract(12, 'hour').format(timeFormat); end = moment().format(timeFormat); break; case 'roundClock': start = moment().subtract(24, 'hour').format(timeFormat); end = moment().format(timeFormat); break; case 'yesterday': start = moment().subtract(1, 'days').format(startFormat); end = moment().subtract(1, 'days').format(endFormat); break; } } return [ { dateFrom: start, dateTo: end, }, ]; }; const unique = (arr) => { let unique = {}; arr.forEach((item) => { unique[JSON.stringify(item)] = item; }); arr = Object.keys(unique).map((v) => { return JSON.parse(v); }); return arr; }; const DefaultDatePicker = (value) => [ { key: 1, value: moment(), }, { key: 2, value: moment().subtract(1, value), }, ]; const DefaultOptions = (color, contrastOption) => ({ chart: { zoomType: 'x', backgroundColor: 'rgba(255, 255, 255, 0.5)', }, boost: { useGPUTranslations: true, }, colors: color, title: null, credits: false, rangeSelector: { enabled: false, }, xAxis: [ { lineWidth: 0, crosshair: true, type: 'datetime', gridLineDashStyle: 'dash', gridLineWidth: 1, dateTimeLabelFormats: { second: '%H:%M:%S', minute: '%H:%M', hour: '%H:%M', day: '%d', week: '%d', month: '%d', year: '%Y', }, }, ], yAxis: [], tooltip: { shared: true, split: false, valueDecimals: 3, formatter: function () { let html = `<b>${Highcharts.dateFormat( contrastOption === 'day' ? '%H:%M' : '%d %H:%M', this.x, )}</b><br/>`; this.points.forEach((item) => { html += `<span style={{color: ${item.series.color}}}>${item.series.name}</span>: <b>${ item.point.y.toFixed( item.series.userOptions.decimalPoint ? item.series.userOptions.decimalPoint : 2, ) * 1 }${item.series.userOptions.tooltip.valueSuffix}</b> <br/>`; }); return html; }, }, plotOptions: { series: { showInNavigator: true, connectNulls: false, zoneAxis: 'x', }, }, legend: { enabled: true, verticalAlign: 'top', }, series: [], responsive: { rules: [ { condition: { maxWidth: 800, minHeight: 500, }, }, ], }, }); let chartWidth = 0; // chart width let chartHeight = 0; // chart height const HistoryInfo = (props) => { const { getPrefixCls } = useContext(ConfigProvider.ConfigContext); const prefixCls = getPrefixCls('history-info'); const { title, tableProps, historyInfoService, historyInfoParams, dictionaryService, dictionaryParams, defaultChecked, } = props; const [loading, setLoading] = useState(false); const [activeTabKey, setActiveTabKey] = useState('curve'); const [timeValue, setTimeValue] = useState('customer'); const [contrastOption, setContrastOption] = useState('day'); const [customerChecked, setCustomerChecked] = useState(defaultChecked || 'oneHour'); const [customerTime, setCustomerTime] = useState(null); const [datePickerArr, setDatePickerArr] = useState(DefaultDatePicker('day')); const [checkboxData, setCheckboxData] = useState(CheckboxData); const [dataThinKey, setDataThinKey] = useState(timeIntervalList[0].key); const [options, setOptions] = useState({}); const [colors, setColors] = useState(defaultColors); const [tableData, setTableData] = useState([]); const [pageSize, setPageSize] = useState(20); const [series, setSeries] = useState([]); const [columns, setColumns] = useState([ { title: '采集时间', dataIndex: 'time', key: 'time', width: 160, fixed: 'left', ellipsis: true, align: 'center', }, ]); const [state, dispatch] = useReducer(reducer, initialState); const container = useRef(null); // 处理图表 series const handleSeries = (v) => { const resData = v; const seriesData = []; const ignoreOutliers = checkboxData.find((item) => item.key === 'ignoreOutliers').checked; resData.forEach((item) => { const data = []; const dataModel = ignoreOutliers ? item.dataModelAbnormal : item.dataModel; if (dataModel.length) { if (timeValue === 'contrast') { // 同期对比 dataModel.forEach((child) => { const formatTime = moment(child.pt).format( contrastOption === 'day' ? '2020-01-01 HH:mm:00' : '2020-01-DD HH:mm:00', ); data.push([moment(formatTime).valueOf(), child.pv]); }); } else { dataModel.forEach((child) => { data.push([moment(child.pt).valueOf(), child.pv]); }); } } const obj = { name: item.equipmentName + '-' + item.sensorName, sensorName: item.sensorName, decimalPoint: item.decimalPoint, unit: item.unit, data: data, }; if (timeValue === 'contrast' && dataModel[0]) { const time = dataModel[0].pt.slice(0, contrastOption === 'day' ? 10 : 7).replace(/-/g, ''); obj.name = obj.name + '-' + time; } seriesData.push(obj); }); setSeries(seriesData); }; // 处理表格的数据 const handleTableData = (resData) => { let timeData = []; const ignoreOutliers = checkboxData.find((item) => item.key === 'ignoreOutliers').checked; const timeSort = (dataArray) => { // 处理时间排序 return dataArray.sort((a, b) => { let aa = a, bb = b; if (timeValue === 'contrast') { aa = a.slice(contrastOption === 'day' ? 11 : 8, 16); bb = b.slice(contrastOption === 'day' ? 11 : 8, 16); } return aa.localeCompare(bb); }); }; let format = timeFormat; if (timeValue === 'contrast') { format = contrastOption === 'day' ? '2020-01-01 HH:mm:00' : '2020-01-DD HH:mm:00'; } // 处理采集时间 resData.forEach((item) => { const dataModel = ignoreOutliers ? item.dataModelAbnormal : item.dataModel; dataModel.forEach((data) => { const formatTime = moment(data.pt).format(format); if (!timeData.includes(formatTime)) { timeData.push(formatTime); } }); }); timeData = timeSort(timeData); // 处理表头数据 const columnsData = resData.map((item, index) => { let obj = { title: `${item.equipmentName}-${item.sensorName}${item.unit ? `(${item.unit})` : ''}`, dataIndex: `value${index + 1}`, key: `value${index + 1}`, ellipsis: true, align: 'center', }; if (timeValue === 'contrast' && item.dataModel[0]) { const time = item.dataModel[0].pt .slice(0, contrastOption === 'day' ? 10 : 7) .replace(/-/g, ''); obj.title = `${item.equipmentName}-${item.sensorName}-${time}`; } return obj; }); // 处理表格数据 const tableData = timeData.map((item, index) => { let time = item; if (timeValue === 'contrast') { time = time.slice(contrastOption === 'day' ? 11 : 8, 16); } return { key: index, time: time }; }); // 处理表格数据 tableData.forEach((item, i) => { resData.forEach((child, index) => { item[`value${index + 1}`] = '--'; const dataModel = ignoreOutliers ? child.dataModelAbnormal : child.dataModel; dataModel.forEach((value, j) => { const formatTime = moment(value.pt).format(format); if (timeData[i] === formatTime) { item[`value${index + 1}`] = value.pv === null ? '--' : value.pv; } }); }); }); setTableData(tableData); setColumns([columns[0], ...columnsData]); }; // 处理接口服务参数的变化 const onChangeParams = (value = []) => { const { dateRange, ignoreOutliers, zoom, unit } = value; const requestArr = []; dateRange.forEach((item) => { const param = { ...historyInfoParams, stream: historyInfoParams.stream.map((child) => ({ ...child, dateFrom: item.dateFrom, dateTo: item.dateTo, })), ignoreOutliers, zoom, unit, }; requestArr.push(historyInfoService(param)); }); setLoading(true); Promise.all(requestArr).then((values) => { if (values.length) { let data = []; values.forEach((res) => { if (res.code === 0 && res.data.length) { data = data.concat(res.data); } }); setLoading(false); handleTableData(data); handleSeries(data); } }); }; // 获取数据字典配置的曲线颜色 const getDictionaryList = () => { dictionaryService(dictionaryParams).then((res) => { if (res.code === 0 && res.data.length) { const colorsData = res.data; colorsData.sort((a, b) => a.nodeName.localeCompare(b.nodeName)); const color = colorsData.map((item) => item.nodeValue); setColors(color); } }); }; useEffect(() => { dictionaryService && getDictionaryList(); }, []); useEffect(() => { onChangeParams(state); }, [state, historyInfoParams]); useEffect(() => { customerChecked && dispatch({ type: UPDATE_TIME.UPDATE_TIME, payload: customerChecked }); }, [customerChecked]); useEffect(() => { setOptions({ ...handleChartOptions() }); }, [series]); // 时间设置切换(自定义/同期对比) const onTimeSetChange = (e) => { setTimeValue(e.target.value); if (e.target.value === 'contrast') { // 同期对比 onContrastChange(contrastOption); } else { // 自定义 dispatch({ type: UPDATE_TIME.UPDATE_TIME, payload: customerChecked }); } }; const handleBatchTime = (arr, cOption) => { let newArr = []; arr.forEach((child) => { if (child.value) { newArr.push({ dateFrom: moment(child.value).startOf(cOption).format(startFormat), dateTo: moment(child.value).endOf(cOption).format(endFormat), }); } }); newArr = unique(newArr); setDatePickerArr(arr); dispatch({ type: UPDATE_TIME.UPDATE_BATCH_TIME, payload: newArr }); }; // 选择(日/月) const onContrastChange = (value) => { setContrastOption(value); handleBatchTime([...DefaultDatePicker(value)], value); }; const onCustomerRangeChange = (value) => { setCustomerChecked(null); setCustomerTime(value); dispatch({ type: UPDATE_TIME.UPDATE_TIME, payload: value }); }; const onCustomerTimeChange = (key) => { setCustomerChecked(key); !!customerTime && setCustomerTime(null); }; const onContrastPickerChange = (date, dateString, item) => { let arr = [...datePickerArr]; arr.forEach((child) => { if (child.key === item.key) { child.value = date; } }); handleBatchTime(arr, contrastOption); }; // 新增日期选择组件 const handleAddDatePicker = () => { setDatePickerArr([ ...datePickerArr, { key: datePickerArr[datePickerArr.length - 1].key + 1, value: '', }, ]); }; // 删除日期选择组件 const handleDeleteDatePicker = (index) => { const arr = [...datePickerArr]; arr.splice(index, 1); setDatePickerArr(arr); handleBatchTime(arr, contrastOption); }; // 曲线设置 checkbox const onCheckboxChange = (e, key) => { let data = [...checkboxData]; data.forEach((item) => { if (item.key === key) { item.checked = e.target.checked; if (key === 'dataThin') { if (e.target.checked) { timeIntervalList.forEach((child) => { if (child.key === dataThinKey) { dispatch({ type: item.type, payload: { zoom: dataThinKey, unit: child.unit } }); } }); } else { dispatch({ type: item.type, payload: { zoom: '', unit: '' } }); } } if (key === 'ignoreOutliers') { dispatch({ type: item.type, payload: e.target.checked }); } if (key === 'curveCenter') { setOptions({ ...handleChartOptions() }); } } }); setCheckboxData(data); }; // 数据抽稀时间间隔 const onTimeIntervalChange = (value, { unit }) => { let data = checkboxData.filter((item) => item.key === 'dataThin'); if (data[0].checked) { dispatch({ type: UPDATE_TIME.UPDATE_DATA_THIN, payload: { zoom: value, unit: unit } }); } setDataThinKey(value); }; const renderCheckbox = (child) => ( <Checkbox value={child.key} checked={child.checked} onChange={(e) => onCheckboxChange(e, child.key)} > {child.label} </Checkbox> ); const renderOptions = (item) => { return ( <> <div className={classNames(`${prefixCls}-time`)}> <div className={classNames(`${prefixCls}-label`)}>时间</div> <Radio.Group value={timeValue} onChange={onTimeSetChange}> <Radio.Button value="customer">自定义</Radio.Button> <Radio.Button value="contrast">同期对比</Radio.Button> </Radio.Group> {timeValue === 'customer' && ( // 自定义 <> <TimeRangePicker onChange={onCustomerTimeChange} value={customerChecked} dataSource={timeList} /> <RangePicker className={classNames(`${prefixCls}-customer`)} onChange={onCustomerRangeChange} value={customerTime} showTime /> </> )} {timeValue === 'contrast' && ( // 同期对比 <> <Select value={contrastOption} style={{ width: 60 }} onChange={onContrastChange}> <Option value="day">日</Option> <Option value="month">月</Option> </Select> {datePickerArr.map((child, index) => ( <div key={child.key} className={classNames(`${prefixCls}-contrast-list`)}> <div className={classNames(`${prefixCls}-date-wrap`)}> <DatePicker picker={contrastOption} value={child.value} onChange={(date, dateString) => onContrastPickerChange(date, dateString, child) } /> {datePickerArr.length > 2 && ( <div className={classNames(`${prefixCls}-date-delete`)} onClick={() => handleDeleteDatePicker(index)} > <CloseCircleFilled /> </div> )} </div> {index < datePickerArr.length - 1 && ( <div className={classNames(`${prefixCls}-connect`)}>与</div> )} </div> ))} {datePickerArr.length < 5 && <PlusCircleOutlined onClick={handleAddDatePicker} />} </> )} </div> <div className={classNames(`${prefixCls}-cover`)}> <div className={classNames(`${prefixCls}-label`)}>曲线设置</div> {checkboxData.map((child) => ( <div key={child.key}> {item.key === 'curve' && renderCheckbox(child)} {item.key === 'table' && child.key !== 'curveCenter' && renderCheckbox(child)} </div> ))} <Select value={dataThinKey} style={{ width: 90 }} onChange={onTimeIntervalChange}> {timeIntervalList.map((child) => ( <Option key={child.key} unit={child.unit} value={child.key}> {child.name} </Option> ))} </Select> </div> </> ); }; const getSeriesType = (sensorName) => { return sensorName ? (sensorName.indexOf('流量') > -1 ? 'area' : 'spline') : 'spline'; }; // 处理图表options const handleChartOptions = () => { const _series = []; let _yAxis = []; const uniqueUnit = []; series.forEach((item, index) => { // 处理series let _s = { name: item.name, type: getSeriesType(item.sensorName), data: item.data, zIndex: 1, tooltip: { valueSuffix: item.unit ? item.unit : '' }, color: colors[index], decimalPoint: item.decimalPoint, navigatorOptions: { enabled: true, }, }; if (_s.type === 'area' || _s.type === 'areaspline') { _s.fillColor = { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1, }, stops: [ [0, Highcharts.Color(_s.color).setOpacity(0.1).get('rgba')], [1, '#fff'], ], }; _s.threshold = 0; } // 处理yAxis if (!uniqueUnit.includes(item.unit)) { uniqueUnit.push(item.unit); let _length = uniqueUnit.length - 1; let _y = { title: { text: item.unit, align: 'high', offset: 0, rotation: 0, y: -25, x: 0, }, gridLineWidth: 1, gridLineDashStyle: 'dash', lineWidth: 1, tickAmount: 10, crosshair: true, floor: 0, num: _length, opposite: _length % 2 === 0, offset: Math.floor(_length / 2) * 40, style: { color: '', }, labels: { style: { color: '', }, x: -2, }, }; _yAxis.push(_y); } // 处理series _s.yAxis = uniqueUnit.findIndex((child) => child === item.unit); _series.push(_s); }); Highcharts.setOptions({ global: { timezoneOffset: -8 * 60 }, }); let options = { ...DefaultOptions(colors, contrastOption) }; if (CheckboxData[0].checked) { _yAxis = setYaxisMin(_yAxis, _series); } else { _yAxis = _yAxis.map((item) => ({ ...item, max: null, min: null })); } if (_yAxis.length > 0) { options = { ...DefaultOptions(colors, contrastOption), yAxis: _yAxis, series: _series, }; } if (container.current) { if (container.current.offsetWidth !== 0) { chartWidth = container.current.offsetWidth; chartHeight = container.current.offsetHeight; } Highcharts.setOptions({ // 处理 chart 高度坍塌 chart: { width: container.current.offsetWidth === 0 ? chartWidth : container.current.offsetWidth, height: container.current.offsetHeight === 0 ? chartHeight : container.current.offsetHeight, }, }); } return options; }; const setYaxisMin = (y, data) => { let result = y.concat(); data.forEach((val) => { let min = 999999999; let showMin = 999999999; let max = -999999999; let showMax = -999999999; val.data.forEach((item) => { if (item[1]) { min = Math.min(min, item[1]); showMin = Math.min(min, item[1]); max = Math.max(max, item[1]); showMax = Math.max(max, item[1]); } }); let k = 0; let same = false; for (let i = 0; i < val.data.length; i++) { // 判断是否全为0 if (val.data[i][1] !== 0) { k = 1; } // 判断是否全相等 if (i >= 1 && val.data[i][1] !== val.data[i - 1][1]) { same = true; } } if (k === 0) { result[val.yAxis].min = result[val.yAxis].min ? Math.min(result[val.yAxis].min, -0.2) : -0.2; result[val.yAxis].max = result[val.yAxis].max ? Math.max(result[val.yAxis].max, 0.2) : 0.2; } else if (!same) { min = val.data[0][1] > 0 ? val.data[0][1] * 0.5 : val.data[0][1] * 1.5; max = val.data[0][1] > 0 ? val.data[0][1] * 1.5 : val.data[0][1] * 0.5; result[val.yAxis].min = result[val.yAxis].min ? Math.min(result[val.yAxis].min, showMin) : min; result[val.yAxis].max = result[val.yAxis].max ? Math.max(result[val.yAxis].max, max) : max; } else { result[val.yAxis].min = result[val.yAxis].min ? Math.min(result[val.yAxis].min, min) : showMin; result[val.yAxis].max = result[val.yAxis].max ? Math.max(result[val.yAxis].max, max) : showMax; } }); return result; }; const exportExcelBtn = () => { let data = JSON.parse(JSON.stringify(tableData)); const keys = Object.keys(data[0]).filter((item) => item !== 'key'); const sheetData = data.map((item) => { delete item.key; return item; }); ExportExcel({ name: '历史曲线表格', content: [ { sheetData: sheetData, sheetName: '历史曲线表格', sheetFilter: keys, sheetHeader: columns.map((item) => item.title), columnWidths: columns.map((item) => 20), }, ], }); }; return ( <div className={classNames(prefixCls)}> <Tabs activeKey={activeTabKey} centered tabBarExtraContent={{ left: <h3 className="tabs-extra-demo-button">{title}</h3>, right: activeTabKey === 'table' ? ( <Button onClick={exportExcelBtn}> <DownloadOutlined /> 下载 </Button> ) : null, }} onChange={(key) => setActiveTabKey(key)} > {TabPaneData.map((item) => ( <TabPane tab={item.tab} key={item.key}> <div className={classNames(`${prefixCls}-content`)}> {renderOptions(item)} <Spin spinning={loading} wrapperClassName={classNames(`${prefixCls}-spin`)}> {!tableData.length && ( <div className={classNames(`${prefixCls}-empty`)}> <Empty /> </div> )} {!!tableData.length && ( <div className={classNames(`${prefixCls}-wrap`)}> <div className={classNames(`${prefixCls}-main`)}> {item.key === 'curve' && ( <div className={classNames(`${prefixCls}-chart`)} ref={container}> <HighchartsReact immutable={true} highcharts={Highcharts} constructorType={'stockChart'} options={options} allowChartUpdate={true} /> </div> )} {item.key === 'table' && ( <BasicTable dataSource={tableData} columns={columns} {...tableProps} pagination={{ pageSize, showQuickJumper: true, showSizeChanger: true }} onChange={(value) => { setPageSize(value.pageSize); }} /> )} </div> </div> )} </Spin> </div> </TabPane> ))} </Tabs> </div> ); }; HistoryInfo.defaultProps = { title: '指标曲线', defaultChecked: 'oneHour', tableProps: {}, historyInfoParams: {}, historyInfoService: () => {}, }; HistoryInfo.propTypes = { title: PropTypes.string, defaultChecked: PropTypes.string, tableProps: PropTypes.object, historyInfoParams: PropTypes.object, historyInfoService: PropTypes.func, }; export default HistoryInfo; const startFormat = 'YYYY-MM-DD 00:00:00'; const endFormat = 'YYYY-MM-DD 23:59:59'; const timeFormat = 'YYYY-MM-DD HH:mm:ss'; const defaultColors = [ '#1884EC', '#90CE53', '#86E0C7', '#68cbd1', '#bb98d1', '#588c66', '#b0859e', '#647fac', '#7c6894', '#9c8273', '#838b61', '#437db0', '#9b97c4', '#bda589', '#89bd8e', '#cbcc75', ]; const initialState = { dateRange: [], ignoreOutliers: false, isVertical: false, zoom: '', unit: '', }; const TabPaneData = [ { key: 'curve', tab: '曲线', }, { key: 'table', tab: '表格', }, ]; const CheckboxData = [ { key: 'curveCenter', label: '曲线居中', checked: false, }, { key: 'ignoreOutliers', label: '过滤异常值', type: 'updateIgnoreOutliers', checked: false, }, { key: 'dataThin', label: '数据抽稀', type: 'updateDataThin', checked: false, }, ]; const timeList = [ { key: 'oneHour', name: '近1小时', }, { key: 'fourHour', name: '近4小时', }, { key: 'twelveHours', name: '近12小时', }, { key: 'roundClock', name: '近24小时', }, { key: 'yesterday', name: '昨天', }, ]; const timeIntervalList = [ { key: '5', unit: 'min', name: '5分钟', }, { key: '10', unit: 'min', name: '10分钟', }, { key: '30', unit: 'min', name: '30分钟', }, { key: '1', unit: 'h', name: '1小时', }, { key: '2', unit: 'h', name: '2小时', }, { key: '6', unit: 'h', name: '6小时', }, { key: '12', unit: 'h', name: '12小时', }, ];