/** * @tips: * 2024年12月10日 当能查到数据,但是数据都是null,则图表的分割线无法正常出来。 * * */ import React, {memo, useEffect, useMemo, useRef, useState} from 'react'; import _, {cloneDeep} from 'lodash'; import {BasicChart} from '@wisdom-components/basicchart'; import PandaEmpty from '@wisdom-components/empty'; import optionGenerator from './utils'; import {getPointAddress, getPointAddressEntry, getSensorsRealName, getSensorType, getStatisticsInfo} from "./apis"; import moment from "moment"; const ChartTitle = ({prefixCls, title, unit}) => { const cls = `${prefixCls}-grid-item-title`; return ( <div className={cls}> <span className={`${cls}_text`}>{title}</span> {unit && <span className={`${cls}_unit`}>(单位:{unit})</span>} </div> ); }; const ChartWidthRef = (props) => { const ref = useRef(null); const timerRef = useRef(null); const minMaxMarkPoint = (dataSource, chart, isInit) => { // 只有一个数据曲线时显示markline let isMultiple = props.isMultiple if (dataSource.length !== 1) return {}; // 1. 找出最大、最小的值 let pointArr = dataSource[0].dataModel; let valueArr = pointArr.map(item => item.pv); let maxValue = Math.max(...valueArr); let minValue = Math.min(...valueArr); // 2. 找出点的索引和实际的点 let maxValueIndex = valueArr.findIndex(val => val === maxValue); let minValueIndex = valueArr.findIndex(val => val === minValue); let maxPoint = pointArr[maxValueIndex]; let minPoint = pointArr[minValueIndex]; if (!maxPoint || !minPoint) return {} // 3. 通过最大值、最小值,数组的首值尾值以及图表宽度来确认markpoint的位置 let _opts = chart.getOption(); let zoom = _opts.dataZoom[0]; let startPoint = pointArr[0]; let endPoint = pointArr[pointArr.length - 1]; let timePeriod = isInit ? moment(endPoint.pt) - moment(startPoint.pt) : zoom.endValue - zoom.startValue; let chartWidth = chart.getWidth(); // 需要考虑是否为0的情况 // 4. 计算最大最小值的标签宽度 let maxLength = 70 + String(maxValue).length * 5; let minLength = 70 + String(minValue).length * 5; // 5. 确定是否超出边界,确定超出边界是哪一边; // 用首尾时间评分 let gapOfYAxisToEdge = 62; let startTime = isInit ? moment(startPoint.pt) : zoom.startValue; let maxPointPosition = ((chartWidth - gapOfYAxisToEdge) / timePeriod) * (moment(maxPoint.pt).valueOf() - startTime) + gapOfYAxisToEdge; let minPointPosition = ((chartWidth - gapOfYAxisToEdge) / timePeriod) * (moment(minPoint.pt).valueOf() - startTime) + gapOfYAxisToEdge; let maxTagLeft = maxPointPosition - maxLength / 2; let maxTagRight = maxPointPosition + maxLength / 2; let minTagLeft = minPointPosition - minLength / 2; let minTagRight = minPointPosition + minLength / 2; let maxOutEdge = false, minOutEdge = false; let maxOutSide = '', minOutSide = ''; // 在实际使用中,我们认为不存在一个tag同时超出边界的情况。 if (maxTagLeft < 0) { maxOutEdge = true; maxOutSide = 'left'; } if (maxTagRight > chartWidth) { maxOutEdge = true; maxOutSide = 'right'; } if (minTagLeft < 0) { minOutEdge = true; minOutSide = 'left'; } if (minTagRight > chartWidth) { minOutEdge = true; minOutSide = 'right'; } // 6. 确定使用的图形 // 默认图形,居中 let maxIconPath = `path://M1233.329493 195.75466633h-1112.064c-34.128213 0-61.781333 27.661653-61.781333 61.781334v473.658026c0 34.117973 27.65312 61.781333 61.781333 61.781334h472.80128l83.23072 144.155306 83.23072-144.155306h472.80128c34.128213 0 61.781333-27.66336 61.781334-61.781334V257.53600033c0-34.117973-27.65312-61.781333-61.781334-61.781334z`; let maxSymbolOffset = isMultiple ? [0, -14] : [0, -20]; let _maxOffset = isMultiple ? -14 : -20; if (maxOutEdge) { if (maxOutSide === 'left') { // 左边界超出,使用朝右的图表 maxSymbolOffset = ['50%', _maxOffset]; maxIconPath = 'path://M48.677,6.151v10c0,1.7-1.3,3-3,3h-35.5l-6.7,4c0,0,0.2-5.3,0.2-7v-10c0-1.7,1.3-3,3-3h39C47.277,3.151,48.677,4.551,48.677,6.151z'; } else if (maxOutSide === 'right') { // 右边界超出,使用朝左的图表 maxSymbolOffset = ['-50%', _maxOffset]; maxIconPath = 'path://M6.477,3.151h39c1.7,0,3,1.3,3,3v10c0,1.7,0.2,7,0.2,7l-6.7-4h-35.5c-1.7,0-3-1.3-3-3v-10C3.477,4.551,4.877,3.151,6.477,3.151z' } } // 默认图形 let minIconPath = 'path://M131.999 849.579h1112.064c34.128 0 61.781-27.662 61.781-61.781v-473.658c0-34.118-27.653-61.781-61.781-61.781h-472.801l-83.231-144.155-83.231 144.155h-472.801c-34.128 0-61.781 27.663-61.781 61.781v473.658c0 34.118 27.653 61.781 61.781 61.781z'; let minSymbolOffset = isMultiple ? [0, 14] : [0, 20]; let _minOffset = isMultiple ? 14 : 20; if (minOutEdge) { if (minOutSide === 'left') { // 左边界超出,使用朝右的图表 minSymbolOffset = ['50%', _minOffset]; minIconPath = 'path://M45.677,23.151h-39c-1.7,0-3-1.3-3-3v-10c0-1.7-0.2-7-0.2-7l6.7,4h35.5c1.7,0,3,1.3,3,3v10C48.677,21.751,47.277,23.151,45.677,23.151z'; } else if (minOutSide === 'right') { // 右边界超出,使用朝左的图表 minSymbolOffset = ['-50%', _minOffset]; minIconPath = 'path://M3.477,20.151v-10c0-1.7,1.3-3,3-3h35.5l6.7-4c0,0-0.2,5.3-0.2,7v10c0,1.7-1.3,3-3,3h-39C4.877,23.151,3.477,21.751,3.477,20.151z' } } const data = [ { type: 'min', color: 'rgba(255,255,255,1)', name: '最小: ', symbolOffset: minSymbolOffset, symbol: minIconPath, symbolSize: (e) => { let str = ![undefined, null].includes(e) ? String(e) : ''; let length = (isMultiple ? 40 : 60) + str.length * 6 return [length, isMultiple ? 20 : 32] }, label: { show: true, color: '#fff', formatter: '最小: {c}', fontSize: isMultiple ? 12 : 14, fontWeight: isMultiple ? 'normal' : 'bold', verticalAlign: 'top', offset: [0, -2] }, itemStyle: { // color: "#21c8c3", } }, { type: 'max', name: '最大: ', position: [20, 200], symbol: maxIconPath, symbolOffset: maxSymbolOffset, symbolSize: (e) => { let str = ![undefined, null].includes(e) ? String(e) : ''; let length = (isMultiple ? 40 : 60) + str.length * 6 return [length, isMultiple ? 20 : 32] }, itemStyle: { // color: "#1980ff", }, label: { show: true, color: '#fff', formatter: '最大: {c}', fontSize: isMultiple ? 12 : 14, fontWeight: isMultiple ? 'normal' : 'bold', offset: [0, -2] }, }, { name: '', type: 'max', symbol: 'emptyCircle', label: {show: false}, symbolSize: 6, }, { name: '', type: 'min', symbol: 'emptyCircle', label: {show: false}, symbolSize: 6, } ]; return { symbol: 'circle', symbolSize: 20, animation: false, silent: true, label: { show: false, }, data, }; }; const renderMarkPoint = (isInit) => { if (timerRef.current) clearTimeout(timerRef.current); const chart = ref.current?.getEchartsInstance?.(); timerRef.current = setTimeout(() => { chart.setOption({ series: {markPoint: minMaxMarkPoint(props.data.list, chart, isInit)} }) }, 200) }; // 将opt修改一下 const cur_opt = useMemo(() => { let _option = cloneDeep(props.option); const chart = ref.current?.getEchartsInstance?.(); if (_option?.series?.[0] && chart) { _option.series[0].markPoint = minMaxMarkPoint(props.data.list, chart, true); _option.series[0].markLine = { silent: false, symbol: 'none', data: [{ name: '平均线', type: 'average', lineStyle: { color: '#00b8b1', type: 'solid', }, label: { position: 'insideEndTop', color: '#00b8b1', formatter: function (param) { return `平均值:${param.value}`; }, }, }] } } return { ..._option, grid: { ...props.option.grid, top: 45, bottom: 65, } } }, [props.option, ref, props.data.list]); useEffect(() => { ref.current?.resize?.(); if (props.data.list?.length !== 1) return; const chart = ref.current?.getEchartsInstance?.(); chart.setOption({ series: { markLine: { silent: false, symbol: 'none', data: [{ name: '平均线', type: 'average', lineStyle: { color: '#00b8b1', type: 'solid', }, label: { position: 'insideEndTop', color: '#00b8b1', formatter: function (param) { return `平均值:${param.value}`; }, }, }] } } }) function dataZoomFn() { renderMarkPoint(false) } chart.on('legendselectchanged', renderMarkPoint); chart.on('datazoom', dataZoomFn); return () => { chart.off('legendselectchanged', renderMarkPoint); chart.off('datazoom', dataZoomFn); } }, [JSON.stringify(cur_opt)]); return <BasicChart ref={ref} {...props} option={cur_opt} /> } const GridChart = memo((props) => { const { dataSource, contrast = false, contrastOption = 'day', smooth = true, curveCenter, allPointAddress, allSensorType, dateRange } = props; const {prefixCls} = props; const [gridData, setGridData] = useState([]); const [pointAddressEntryData, setPointAddressEntryData] = useState(null); const [sensorType, setSensorType] = useState(null); const [isInit, setIsInit] = useState(true); // 新增逻辑:需要区分出哪些是统计值 /** * @param {array} dataSource */ const handleDataSource = async (dataSource) => { props.setLoading(true); // 1. 统计设备 try { let _deviceTypes = []; let _deviceCodes = dataSource.reduce((final, cur) => { if (!final.includes(cur.stationCode) && !_deviceTypes.includes(cur.deviceType)) { final.push(cur.stationCode); _deviceTypes.push(cur.deviceType); } return final; }, []); // 2. 获取对应的版本id let _ids = []; let _idRequest = await getPointAddress({code: _deviceCodes.join(',')}); _ids = _idRequest?.data ?? []; // 3. 获取对应的点表 let _map = {}; for await (let item of _ids) { let _index = _deviceCodes.findIndex(code => code === item.code); if (pointAddressEntryData && pointAddressEntryData[item.id]) { _map[_deviceTypes[_index]] = pointAddressEntryData[item.id]; } else { let _entry = await getPointAddressEntry({versionId: item.id}); _map[_deviceTypes[_index]] = _entry?.data ?? []; setPointAddressEntryData({...pointAddressEntryData, [item.id]: _entry?.data}) } } // 4. 获取点类型 let _sensorType = [] if (sensorType) { _sensorType = sensorType; } else { _sensorType = (await getSensorType())?.data ?? []; } //5. 找出统计值,合并 let _dataSource = cloneDeep(dataSource); let _nameListMap = {}; let _indexArr = []; let _tempValue = {}; let _finalData = {}; _dataSource.forEach((item, index) => { let _sensorTypeId = _map[item.deviceType].find(sensor => sensor.name === item.sensorName)?.sensorTypeID || 0; let _type = _sensorType.find(sensor => sensor.id === _sensorTypeId)?.type ?? ''; if (_type === '统计值') { // 移除掉,并存储 _tempValue[`needToReplace_${item.stationCode}_${item.sensorName}`] = _dataSource.splice(index, 1, `needToReplace_${item.stationCode}_${item.sensorName}`)?.[0]; if (!_nameListMap[item.stationCode]) { _nameListMap[item.stationCode] = { code: item.stationCode, deviceType: item.deviceType, sensors: [item.sensorName] } } else { _nameListMap[item.stationCode].sensors.push(item.sensorName) } } }) //6. 请求数据并替换数据。grid模式下,请求的时间是一致的。 let baseParam = { pageIndex: 1, pageSize: 999, dateFrom: dateRange[0].dateFrom, dateTo: dateRange[0].dateTo, } let _arr = Object.values(_nameListMap) for await (let item of _arr) { let _params = { ...baseParam, accountName: item.deviceType, deviceCode: item.code, nameTypeList: item.sensors.map(sensor => ({ name: sensor, type: 'Sub' })), /* nameTypeList: ['今日用电量', '今日供水量'].map(sensor => ({ name: sensor, type: 'Sub' })),*/ dateType: returnDateType(dateRange[0]) }; // 虚拟点需要查出实际点后,进行查找 let _realSensors = {}; let _realSensorsMap = {}; // 统计类的如果是虚拟点,那么需要查出实际数据来源的点,查出映射关系 (await getSensorsRealName(_params))?.data?.forEach(sensor => { // name 虚拟点 staticName实际的点 _realSensors[sensor.staticName] = sensor.name; _realSensorsMap[sensor.name] = sensor.staticName; }); // 请求统计数据时,需要使用实际点去获取 _params.nameTypeList.forEach(sensor => { if (_realSensors[sensor.name]) { sensor.name = _realSensors[sensor.name] } }); // 获取数据后,将原始数据中的dataModel这部分替换掉 ((await getStatisticsInfo(_params))?.data?.list?.[0].dNameDataList ?? [])?.forEach(obj => { if (_realSensorsMap[obj.dName]) { let _v = _tempValue[`needToReplace_${item.code}_${_realSensorsMap[obj.dName]}`]; _v.dataModel = obj.nameDate.map(d => { return { pt: moment(d.time), pv: d.value, maxPV: d.value, minPV: d.value, firstPV: d.value, lastPV: d.value, } }); _finalData[`needToReplace_${item.code}_${_realSensorsMap[obj.dName]}`] = _v; } }); // 替换数据 _dataSource.forEach((d, index) => { if (_.isString(d) && d.includes('needToReplace') && _finalData[d]) { _dataSource[index] = _finalData[d]; } }) // 有不存在数据的,将原始数据替换回来 _dataSource.forEach((d, index) => { if (_.isString(d) && d.includes('needToReplace')) { _dataSource[index] = dataSource[index]; } }) } props.setLoading(false); return _dataSource } catch (e) { props.setLoading(false); return [] } }; const returnDateType = (date) => { let {dateFrom, dateTo} = date; let _duration = moment.duration(moment(dateTo) - moment(dateFrom), 'ms').days(); if (_duration >= 7) return 'month'; if (_duration >= 30) return 'year'; return 'day'; }; useEffect(() => { async function handle() { let _data = isInit ? dataSource : (await handleDataSource(dataSource) ?? []); setIsInit(false); const grids = _data.reduce((pre, item, index) => { const {sensorName, deviceType} = item; const key = `${deviceType}_${sensorName}`; // 同设备类型同指标才在同一组 let grid = pre.find((g) => g.key === key); if (!grid) { const restProp = _.pick(item, ['equipmentName', 'sensorName', 'stationCode', 'unit']); grid = { key: key, list: [], ...restProp, }; pre.push(grid); } grid.list.push(item); return pre; }, []); setGridData(grids); } handle(); }, [dataSource]) const options = useMemo(() => { let _options = gridData.map((item) => { const {key, list, equipmentName, sensorName, stationCode, unit} = item; let max = 300; // 5:左侧竖条的宽度;10:标题部分的左侧margin; // sensorName长度*单个宽度16.7;5:单位部分的左侧margin; // 91:单位部分的宽度(格式固定,宽度相对固定) let maxTitleLength = 5 + 10 + sensorName.length * 16.7 + 5 + 91; let finalLength = maxTitleLength > max ? max : maxTitleLength const cusOption = { title: { show: true, // text: `{prefix|}{t|${sensorName}}${unit ? '{suffix|(单位:' + unit + ')}' : ''}`, text: ' ', textStyle: { width: finalLength, overflow: 'truncate', }, }, legend: { // orient: 'vertical', itemGap: 10, padding: [0, 0, 0, finalLength], textStyle: { width: 120, overflow: 'truncate', }, }, }; const option = optionGenerator(list, cusOption, null, contrastOption, smooth, { curveCenter, nameWithSensor: false, showGridLine: true, isMultiple: gridData.length > 1, chartType: 'lineChart' }); // 无数据时,图表需要显示默认图形 2024年3月14日 // 1. x轴 let dataEmpty = []; option.series.forEach(item => { if (item.data.length === 0) { dataEmpty.push(true) item.data = [[moment(dataSource?.[0]?.dateFrom).valueOf(), null], [moment(dataSource?.[0]?.dateTo).valueOf(), null]] } else { dataEmpty.push(false); } }) // 2. y轴 let allEmpty = dataEmpty.length ? dataEmpty.reduce((final, cur) => { if (!cur) final = false; return final }, true) : true; if (allEmpty) { option.yAxis.forEach(item => { item.max = 100; item.min = 0; }); option.tooltip = false; } delete option.xAxis.max delete option.xAxis.min return { key, option: option, }; }); return _options; }, [gridData, smooth, curveCenter]); // const chartRef = dataSource.map((item, index) => useRef(null)); // useEffect(() => { // chartRef.forEach(item => {item?.current?.resize?.()}) // }, [options]); return ( <div className={`${prefixCls}-grid`}> {options.map((item, index) => { const {sensorName, unit} = gridData[index]; const isEmpty = !item.option.series.length || !item.option.series.find((e) => e.data && e.data.length > 0); return ( <div key={item.key} className={`${prefixCls}-grid-item`} style={{ height: gridData.length === 1 ? '100%' : '', width: gridData.length === 1 ? '100%' : '', }} > <div className={`${prefixCls}-grid-item-wrap`}> <ChartTitle prefixCls={prefixCls} title={sensorName} unit={unit}/> {isEmpty ? ( isInit ? '' : <PandaEmpty/> ) : ( <ChartWidthRef isMultiple={gridData.length > 1} data={gridData[index]} style={{width: '100%', height: '100%'}} option={item.option} notMerge /> )} </div> </div> ); })} </div> ); }); export default GridChart;