Tom Butcher 3c53278e9f Add ECharts Integration for Enhanced Data Visualization
- Introduced new components, HistoryEChart and HistoryChartLegend, to replace Recharts with ECharts for improved charting capabilities in the dashboard.
- Updated HistoryDisplay and ModelHistoryDisplay components to utilize the new ECharts components, enhancing the overall data visualization experience.
- Implemented a custom tooltip for ECharts to provide detailed information on data points, improving user interaction and data insights.
- Added a hook, useHistoryLegendOverlay, to manage the visibility of the chart legend based on available space, optimizing the layout for different screen sizes.
- Removed unused Recharts imports and related code, streamlining the component structure and improving performance.
2026-08-30 13:54:18 +01:00

390 lines
11 KiB
JavaScript

import { useMemo, useState } from 'react'
import { Card, Segmented, Flex, Popover, DatePicker, Button, Space } from 'antd'
import PropTypes from 'prop-types'
import dayjs from 'dayjs'
import { useThemeContext } from '../context/ThemeContext'
import MissingPlaceholder from './MissingPlaceholder'
import HistoryEChart from './HistoryEChart'
import HistoryChartLegend from './HistoryChartLegend'
import { useHistoryLegendOverlay } from '../hooks/useHistoryLegendOverlay'
import CheckIcon from '../../Icons/CheckIcon'
import { LoadingOutlined } from '@ant-design/icons'
const HISTORY_SERIES_CONFIG = {
filamentStock: {
series: [
{
key: 'currentWeight.net',
label: 'Net Weight',
color: 'processing',
suffix: 'g'
},
{
key: 'currentWeight.gross',
label: 'Gross Weight',
color: 'warning',
suffix: 'g'
}
],
formatValue: (value, suffix = '') =>
`${Number(value ?? 0).toFixed(2)}${suffix}`
},
partStock: {
series: [
{
key: 'currentQuantity',
label: 'Current Quantity',
color: 'processing'
}
],
formatValue: (value) => Number(value ?? 0).toFixed(2)
},
productStock: {
series: [
{
key: 'currentQuantity',
label: 'Current Quantity',
color: 'processing'
}
],
formatValue: (value) => Number(value ?? 0).toFixed(2)
},
stockEvent: {
series: [
{
key: 'value',
label: 'Value',
color: 'processing',
variance: true
}
],
formatValue: (value, suffix = '') => {
const numericValue = Number(value ?? 0)
const isPositive = numericValue > 0
const isNegative = numericValue < 0
return `${isPositive ? '+' : isNegative ? '-' : ''}${Math.abs(numericValue).toFixed(2)}${suffix}`
}
}
}
const getNestedValue = (obj, path) => {
if (!obj || !path) return undefined
const keys = path.split('.')
let value = obj
for (const key of keys) {
if (value === null || value === undefined) return undefined
value = value[key]
}
return value
}
const legendMeasureStyle = {
position: 'absolute',
visibility: 'hidden',
pointerEvents: 'none',
whiteSpace: 'nowrap',
width: 'max-content',
height: 0,
overflow: 'hidden'
}
const toCssHeight = (value) =>
typeof value === 'number' ? `${value}px` : value
const HistoryDisplay = ({
loading = false,
history = [],
objectType,
chartType = 'bar',
unit,
startDate,
endDate,
styles,
height = 400
}) => {
const [timeRange, setTimeRange] = useState('4hrs')
const [startCustomDate, setStartCustomDate] = useState(null)
const [endCustomDate, setEndCustomDate] = useState(null)
const { isDarkMode, getColors } = useThemeContext()
const config = HISTORY_SERIES_CONFIG[objectType]
const { defaultStartDate, defaultEndDate } = useMemo(() => {
const now = new Date()
if (startDate || endDate) {
return {
defaultStartDate:
startDate || new Date(now.getTime() - 24 * 60 * 60 * 1000),
defaultEndDate: endDate || now
}
}
if (timeRange === 'custom' && startCustomDate && endCustomDate) {
return {
defaultStartDate: startCustomDate.toDate(),
defaultEndDate: endCustomDate.toDate()
}
}
const timeRangeMap = {
'24hrs': 24 * 60,
'12hrs': 12 * 60,
'8hrs': 8 * 60,
'4hrs': 4 * 60,
'1hrs': 60,
'30mins': 30,
'15mins': 15,
'5mins': 5
}
const minutes = timeRangeMap[timeRange] || 60
return {
defaultStartDate: new Date(now.getTime() - minutes * 60 * 1000),
defaultEndDate: now
}
}, [startDate, endDate, timeRange, startCustomDate, endCustomDate])
const chartRows = useMemo(() => {
if (!config || !Array.isArray(history)) {
return []
}
const start = new Date(defaultStartDate).getTime()
const end = new Date(defaultEndDate).getTime()
return history
.filter((point) => {
const timestamp = new Date(point.timestamp).getTime()
return timestamp >= start && timestamp <= end
})
.map((point) => {
const row = {
timestamp: point.timestamp,
dateFormatted: dayjs(point.timestamp).format('DD/MM HH:mm')
}
config.series.forEach((seriesDef) => {
row[seriesDef.label] = getNestedValue(point, seriesDef.key) ?? 0
})
return row
})
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
}, [history, config, defaultStartDate, defaultEndDate])
const themeColors = getColors()
const seriesLabels = useMemo(() => {
if (!config) {
return []
}
const colors = {
success: themeColors.colorSuccess,
processing: themeColors.colorInfo,
error: themeColors.colorError,
warning: themeColors.colorWarning,
default: '#8c8c8c',
cyan: themeColors.colorCyan,
pink: themeColors.colorPink,
purple: themeColors.colorPurple,
magenta: themeColors.colorMagenta,
volcano: themeColors.colorVolcano
}
return config.series.map((seriesDef) => {
const color = seriesDef.color
? colors[seriesDef.color] || seriesDef.color
: colors.default
return {
label: seriesDef.label,
color
}
})
}, [config, themeColors])
const { slotRef, measureRef, overlay: legendOverflow } =
useHistoryLegendOverlay(seriesLabels)
if (!objectType || !config) {
return null
}
const formatTooltipValue = (value, seriesLabel) => {
const seriesDef = config.series.find((item) => item.label === seriesLabel)
const suffix = seriesDef?.suffix || unit || ''
return config.formatValue(value, suffix)
}
const customTimeRangeContent = (
<Space.Compact>
<DatePicker.RangePicker
onChange={(dates) => {
if (dates) {
setStartCustomDate(dates[0])
setEndCustomDate(dates[1])
} else {
setStartCustomDate(null)
setEndCustomDate(null)
}
}}
value={[startCustomDate, endCustomDate]}
/>
<Button
type='primary'
onClick={() => {
if (startCustomDate && endCustomDate) {
setTimeRange('custom')
}
}}
disabled={!startCustomDate || !endCustomDate}
icon={<CheckIcon />}
></Button>
</Space.Compact>
)
return (
<Card
style={{ width: '100%' }}
styles={{ body: { padding: '12px', ...styles } }}
>
<Flex gap='small' vertical>
<Flex style={{ width: '100%' }} justify='space-between' align='center'>
<Flex align='center' gap='5px' justify='flex-start'>
{!startDate && !endDate && (
<Popover
content={customTimeRangeContent}
trigger='hover'
arrow={false}
placement='bottomLeft'
styles={{ body: { borderRadius: '22.5px' } }}
>
<Segmented
size='small'
options={[{ label: 'Custom', value: 'custom' }]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</Popover>
)}
</Flex>
<div
ref={slotRef}
style={{
flex: 1,
minWidth: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative'
}}
>
{!legendOverflow && (
<HistoryChartLegend
seriesLabels={seriesLabels}
isDarkMode={isDarkMode}
/>
)}
<div ref={measureRef} aria-hidden='true' style={legendMeasureStyle}>
<HistoryChartLegend
seriesLabels={seriesLabels}
isDarkMode={isDarkMode}
wrap={false}
/>
</div>
</div>
<Flex gap='middle' align='center' justify='flex-end'>
{!startDate && !endDate && (
<>
{loading == true && <LoadingOutlined />}
<Segmented
size='small'
options={[
{ label: '24hr', value: '24hrs' },
{ label: '12hr', value: '12hrs' },
{ label: '8hr', value: '8hrs' },
{ label: '4hr', value: '4hrs' },
{ label: '1hr', value: '1hrs' },
{ label: '30m', value: '30mins' },
{ label: '15m', value: '15mins' },
{ label: '5m', value: '5mins' }
]}
value={timeRange}
onChange={setTimeRange}
disabled={loading}
/>
</>
)}
</Flex>
</Flex>
<div
style={{
height: toCssHeight(height),
display: 'flex',
flexDirection: 'column',
minHeight: 0
}}
>
{legendOverflow && (
<div
style={{
flexShrink: 0,
display: 'flex',
justifyContent: 'center',
paddingBottom: 4
}}
>
<HistoryChartLegend
seriesLabels={seriesLabels}
isDarkMode={isDarkMode}
/>
</div>
)}
<div style={{ flex: 1, minHeight: 0 }}>
{chartRows.length > 0 && (
<HistoryEChart
chartRows={chartRows}
seriesLabels={seriesLabels}
chartType={chartType}
isDarkMode={isDarkMode}
formatTooltipValue={formatTooltipValue}
/>
)}
{loading == false && chartRows.length == 0 && (
<Flex justify='center' align='center' style={{ height: '100%' }}>
<MissingPlaceholder message='No data available.' />
</Flex>
)}
</div>
</div>
</Flex>
</Card>
)
}
HistoryDisplay.propTypes = {
history: PropTypes.arrayOf(PropTypes.object),
objectType: PropTypes.oneOf([
'filamentStock',
'partStock',
'productStock',
'stockEvent'
]).isRequired,
chartType: PropTypes.oneOf(['line', 'bar']),
unit: PropTypes.string,
startDate: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(Date)
]),
loading: PropTypes.bool,
endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]),
styles: PropTypes.object,
height: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
}
export default HistoryDisplay