Compare commits
1 Commits
main
...
JsonEditor
Author | SHA1 | Date |
---|---|---|
|
86b4add6ab |
|
@ -8170,8 +8170,15 @@
|
|||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T00:53:19.221218",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-08-15T23:05:23.049195",
|
||||
"total_entries": 678
|
||||
"last_updated": "2025-08-16T00:53:19.221218",
|
||||
"total_entries": 679
|
||||
}
|
|
@ -0,0 +1,430 @@
|
|||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Box, Text, Switch, FormLabel, HStack, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
// Historical Chart.js Plot Component with Zoom and Pan
|
||||
const ChartjsHistoricalPlot = ({ session, height = '400px' }) => {
|
||||
const canvasRef = useRef(null);
|
||||
const chartRef = useRef(null);
|
||||
const sessionDataRef = useRef({
|
||||
sessionId: null,
|
||||
currentTimeRange: { start: null, end: null },
|
||||
pendingDataRequest: false
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [dataPointsCount, setDataPointsCount] = useState(0);
|
||||
const [isZoomEnabled, setIsZoomEnabled] = useState(true);
|
||||
const [currentRange, setCurrentRange] = useState('');
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
// Check if Chart.js is available
|
||||
const checkChartDependencies = useCallback(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.Chart) {
|
||||
console.error('❌ Chart.js not loaded');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Chart.js dependency check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Destroy chart helper
|
||||
const destroyChart = useCallback(() => {
|
||||
if (chartRef.current) {
|
||||
try {
|
||||
chartRef.current.destroy();
|
||||
} catch (error) {
|
||||
console.warn('Chart destroy error (non-critical):', error);
|
||||
}
|
||||
chartRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create historical chart
|
||||
const createChart = useCallback(() => {
|
||||
if (!canvasRef.current || !checkChartDependencies()) {
|
||||
setError('Chart.js not available');
|
||||
return;
|
||||
}
|
||||
|
||||
destroyChart();
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (!ctx) {
|
||||
setError('Cannot get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize empty datasets
|
||||
const datasets = (session.config.variables || []).map((variable, index) => ({
|
||||
label: variable.name || variable,
|
||||
data: [],
|
||||
borderColor: `hsl(${(index * 137.5) % 360}, 70%, 50%)`,
|
||||
backgroundColor: `hsla(${(index * 137.5) % 360}, 70%, 50%, 0.1)`,
|
||||
borderWidth: 2,
|
||||
fill: session.config.stacked || false,
|
||||
tension: session.config.line_tension || 0.4,
|
||||
pointRadius: session.config.point_radius || 1,
|
||||
pointHoverRadius: session.config.point_hover_radius || 4,
|
||||
stepped: session.config.stepped || false
|
||||
}));
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false, // Disable animations for better performance
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 10
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
if (context[0] && context[0].parsed && context[0].parsed.x) {
|
||||
return new Date(context[0].parsed.x).toLocaleString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: isZoomEnabled && window.Chart?.registry?.plugins?.get?.('zoom') ? {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
onPanComplete: handlePanComplete
|
||||
},
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true,
|
||||
},
|
||||
pinch: {
|
||||
enabled: true,
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomComplete: handleZoomComplete
|
||||
}
|
||||
} : {}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
displayFormats: {
|
||||
millisecond: 'HH:mm:ss.SSS',
|
||||
second: 'HH:mm:ss',
|
||||
minute: 'HH:mm',
|
||||
hour: 'MM/DD HH:mm',
|
||||
day: 'MM/DD',
|
||||
week: 'MM/DD',
|
||||
month: 'MM/YY',
|
||||
quarter: 'MM/YY',
|
||||
year: 'YYYY'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Value'
|
||||
},
|
||||
min: session.config.y_min,
|
||||
max: session.config.y_max
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartRef.current = new window.Chart(ctx, config);
|
||||
|
||||
// Register chart controls
|
||||
if (session.onChartReady) {
|
||||
session.onChartReady({
|
||||
loadHistoricalRange: loadHistoricalRange,
|
||||
refreshData: refreshCurrentRange,
|
||||
getTimeRange: getCurrentTimeRange,
|
||||
resetZoom: resetZoom
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📈 Historical chart created for session ${session.session_id}`);
|
||||
setError(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating historical chart:', error);
|
||||
setError(`Chart creation failed: ${error.message}`);
|
||||
}
|
||||
}, [session, isZoomEnabled]);
|
||||
|
||||
// Handle zoom completion
|
||||
const handleZoomComplete = useCallback(({ chart }) => {
|
||||
const xScale = chart.scales.x;
|
||||
const startTime = xScale.min;
|
||||
const endTime = xScale.max;
|
||||
|
||||
updateCurrentRange(startTime, endTime);
|
||||
requestAdditionalData(startTime, endTime);
|
||||
}, []);
|
||||
|
||||
// Handle pan completion
|
||||
const handlePanComplete = useCallback(({ chart }) => {
|
||||
const xScale = chart.scales.x;
|
||||
const startTime = xScale.min;
|
||||
const endTime = xScale.max;
|
||||
|
||||
updateCurrentRange(startTime, endTime);
|
||||
requestAdditionalData(startTime, endTime);
|
||||
}, []);
|
||||
|
||||
// Update current range display
|
||||
const updateCurrentRange = (startTime, endTime) => {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
const range = `${start.toLocaleString()} - ${end.toLocaleString()}`;
|
||||
setCurrentRange(range);
|
||||
|
||||
// Update session time range if callback available
|
||||
if (session.onTimeRangeChange) {
|
||||
session.onTimeRangeChange(startTime, endTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Request additional data for the visible range
|
||||
const requestAdditionalData = useCallback(async (startTime, endTime) => {
|
||||
if (!session.onDataRequest || sessionDataRef.current.pendingDataRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sessionDataRef.current.pendingDataRequest = true;
|
||||
setIsLoading(true);
|
||||
|
||||
const data = await session.onDataRequest(startTime, endTime);
|
||||
|
||||
if (data && chartRef.current) {
|
||||
updateChartData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting historical data:', error);
|
||||
} finally {
|
||||
sessionDataRef.current.pendingDataRequest = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Load specific time range
|
||||
const loadHistoricalRange = useCallback(async (startTime, endTime) => {
|
||||
if (!session.onDataRequest || !chartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await session.onDataRequest(startTime, endTime);
|
||||
|
||||
if (data) {
|
||||
// Clear existing data
|
||||
chartRef.current.data.datasets.forEach(dataset => {
|
||||
dataset.data = [];
|
||||
});
|
||||
|
||||
updateChartData(data);
|
||||
|
||||
// Set chart view to the requested range
|
||||
if (chartRef.current.scales?.x) {
|
||||
chartRef.current.scales.x.options.min = startTime;
|
||||
chartRef.current.scales.x.options.max = endTime;
|
||||
}
|
||||
chartRef.current.update('none');
|
||||
|
||||
updateCurrentRange(startTime, endTime);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading historical range:', error);
|
||||
setError(`Failed to load data: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Update chart with new data from the optimized API response
|
||||
const updateChartData = useCallback((response) => {
|
||||
if (!chartRef.current || !response) return;
|
||||
|
||||
let totalPoints = 0;
|
||||
|
||||
try {
|
||||
// Use the optimized chart_data format from the API
|
||||
const chartData = response.chart_data || {};
|
||||
|
||||
chartRef.current.data.datasets.forEach((dataset, index) => {
|
||||
const variableName = session.config.variables[index]?.name || session.config.variables[index];
|
||||
const variableData = chartData[variableName];
|
||||
|
||||
if (variableData && Array.isArray(variableData)) {
|
||||
// Data is already in {x, y} format for Chart.js
|
||||
const newPoints = variableData.map(point => ({
|
||||
x: new Date(point.x).getTime(),
|
||||
y: point.y
|
||||
}));
|
||||
|
||||
// Merge with existing data and remove duplicates
|
||||
const allPoints = [...dataset.data, ...newPoints];
|
||||
const uniquePoints = allPoints.filter((point, index, arr) =>
|
||||
arr.findIndex(p => p.x === point.x) === index
|
||||
);
|
||||
|
||||
// Sort by timestamp
|
||||
dataset.data = uniquePoints.sort((a, b) => a.x - b.x);
|
||||
totalPoints += dataset.data.length;
|
||||
}
|
||||
});
|
||||
|
||||
chartRef.current.update('none');
|
||||
setDataPointsCount(totalPoints);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Refresh current visible range
|
||||
const refreshCurrentRange = useCallback(() => {
|
||||
if (!chartRef.current?.scales?.x) return;
|
||||
|
||||
const xScale = chartRef.current.scales.x;
|
||||
const startTime = xScale.min;
|
||||
const endTime = xScale.max;
|
||||
|
||||
if (startTime && endTime) {
|
||||
loadHistoricalRange(startTime, endTime);
|
||||
}
|
||||
}, [loadHistoricalRange]);
|
||||
|
||||
// Get current time range
|
||||
const getCurrentTimeRange = useCallback(() => {
|
||||
if (!chartRef.current?.scales?.x) return null;
|
||||
|
||||
const xScale = chartRef.current.scales.x;
|
||||
return {
|
||||
start: xScale.min,
|
||||
end: xScale.max
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset zoom to show all data
|
||||
const resetZoom = useCallback(() => {
|
||||
if (chartRef.current && chartRef.current.resetZoom) {
|
||||
chartRef.current.resetZoom();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize chart
|
||||
useEffect(() => {
|
||||
createChart();
|
||||
return destroyChart;
|
||||
}, [createChart, destroyChart]);
|
||||
|
||||
// Handle session changes
|
||||
useEffect(() => {
|
||||
sessionDataRef.current.sessionId = session.session_id;
|
||||
}, [session.session_id]);
|
||||
|
||||
// Load initial data when component mounts
|
||||
useEffect(() => {
|
||||
if (session.timeRange && session.timeRange.startDate && session.timeRange.endDate) {
|
||||
const startDateTime = new Date(`${session.timeRange.startDate}T${session.timeRange.startTime || '00:00'}:00`);
|
||||
const endDateTime = new Date(`${session.timeRange.endDate}T${session.timeRange.endTime || '23:59'}:00`);
|
||||
|
||||
loadHistoricalRange(startDateTime.getTime(), endDateTime.getTime());
|
||||
}
|
||||
}, [session.timeRange, loadHistoricalRange]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
height={height}
|
||||
bg={bgColor}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="red.200"
|
||||
>
|
||||
<Text color="red.500" textAlign="center">
|
||||
❌ {error}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box height={height} bg={bgColor} borderRadius="md" position="relative">
|
||||
{/* Controls */}
|
||||
<HStack spacing={4} mb={2} fontSize="sm">
|
||||
<FormLabel mb={0} fontSize="xs">
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={isZoomEnabled}
|
||||
onChange={(e) => setIsZoomEnabled(e.target.checked)}
|
||||
mr={2}
|
||||
/>
|
||||
Zoom/Pan
|
||||
</FormLabel>
|
||||
|
||||
{dataPointsCount > 0 && (
|
||||
<Text color={textColor} fontSize="xs">
|
||||
{dataPointsCount.toLocaleString()} data points
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Text color="blue.500" fontSize="xs">
|
||||
Loading...
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Current range display */}
|
||||
{currentRange && (
|
||||
<Text fontSize="xs" color={textColor} mb={2}>
|
||||
Viewing: {currentRange}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Chart canvas */}
|
||||
<Box height="calc(100% - 60px)">
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartjsHistoricalPlot;
|
|
@ -0,0 +1,325 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
Divider,
|
||||
Grid,
|
||||
GridItem,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center
|
||||
} from '@chakra-ui/react'
|
||||
import { TimeIcon, AddIcon } from '@chakra-ui/icons'
|
||||
import PlotHistoricalSession from './PlotHistoricalSession'
|
||||
import * as api from '../services/api'
|
||||
|
||||
/**
|
||||
* PlotHistoricalManager - Manages historical plot sessions
|
||||
* Allows users to select plot definitions and create historical analysis sessions
|
||||
*/
|
||||
export default function PlotHistoricalManager() {
|
||||
const [plotDefinitions, setPlotDefinitions] = useState([])
|
||||
const [plotVariables, setPlotVariables] = useState([])
|
||||
const [activeSessions, setActiveSessions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const toast = useToast()
|
||||
const cardBg = useColorModeValue('white', 'gray.800')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
// Load plot definitions and variables on mount
|
||||
useEffect(() => {
|
||||
loadPlotConfigurations()
|
||||
}, [])
|
||||
|
||||
const loadPlotConfigurations = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Load plot definitions
|
||||
const plotDefsResponse = await api.getConfig('plot-definitions')
|
||||
const plotDefs = plotDefsResponse.data?.plots || []
|
||||
|
||||
// Load plot variables
|
||||
const plotVarsResponse = await api.getConfig('plot-variables')
|
||||
const plotVars = plotVarsResponse.data?.plot_variables || []
|
||||
|
||||
setPlotDefinitions(plotDefs)
|
||||
setPlotVariables(plotVars)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading plot configurations:', err)
|
||||
setError('Failed to load plot configurations')
|
||||
toast({
|
||||
title: 'Error Loading Plots',
|
||||
description: 'Failed to load plot definitions and variables',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get variables for a specific plot
|
||||
const getVariablesForPlot = (plotId) => {
|
||||
const plotVarConfig = plotVariables.find(pv => pv.plot_id === plotId)
|
||||
return plotVarConfig?.variables || []
|
||||
}
|
||||
|
||||
// Create a new historical session
|
||||
const createSession = (plotDefinition) => {
|
||||
const sessionId = `historical_${plotDefinition.id}_${Date.now()}`
|
||||
const variables = getVariablesForPlot(plotDefinition.id)
|
||||
|
||||
if (variables.length === 0) {
|
||||
toast({
|
||||
title: 'No Variables Configured',
|
||||
description: `Plot "${plotDefinition.name}" has no variables configured`,
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newSession = {
|
||||
id: sessionId,
|
||||
plotDefinition,
|
||||
variables,
|
||||
createdAt: new Date()
|
||||
}
|
||||
|
||||
setActiveSessions(prev => [...prev, newSession])
|
||||
|
||||
toast({
|
||||
title: 'Historical Session Created',
|
||||
description: `Created session for "${plotDefinition.name}"`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
})
|
||||
}
|
||||
|
||||
// Remove a session
|
||||
const removeSession = (sessionId) => {
|
||||
setActiveSessions(prev => prev.filter(session => session.id !== sessionId))
|
||||
|
||||
toast({
|
||||
title: 'Session Removed',
|
||||
description: 'Historical plot session removed',
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text>Loading plot configurations...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="medium">Configuration Error</Text>
|
||||
<Text fontSize="sm">{error}</Text>
|
||||
<Button size="sm" onClick={loadPlotConfigurations}>
|
||||
Retry Loading
|
||||
</Button>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack spacing={3} align="start">
|
||||
<HStack>
|
||||
<TimeIcon boxSize={6} color="blue.500" />
|
||||
<Heading size="lg">Historical Plot Analysis</Heading>
|
||||
</HStack>
|
||||
<Text color="gray.600" fontSize="md">
|
||||
Create historical analysis sessions from CSV data using existing plot definitions
|
||||
</Text>
|
||||
<Divider />
|
||||
<HStack spacing={4}>
|
||||
<Badge colorScheme="blue" variant="subtle">
|
||||
{plotDefinitions.length} Plot{plotDefinitions.length !== 1 ? 's' : ''} Available
|
||||
</Badge>
|
||||
<Badge colorScheme="green" variant="subtle">
|
||||
{activeSessions.length} Active Session{activeSessions.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Plot Selection Grid */}
|
||||
{plotDefinitions.length === 0 ? (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="medium">No Plot Definitions Found</Text>
|
||||
<Text fontSize="sm">
|
||||
Please configure plot definitions in the Configuration tab first.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Alert>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">Available Plot Definitions</Heading>
|
||||
<Text fontSize="sm" color="gray.600" mt={1}>
|
||||
Select a plot definition to create a historical analysis session
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}>
|
||||
{plotDefinitions.map((plotDef) => {
|
||||
const variables = getVariablesForPlot(plotDef.id)
|
||||
const hasSession = activeSessions.some(s => s.plotDefinition.id === plotDef.id)
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plotDef.id}
|
||||
variant="outline"
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
borderColor: 'blue.300',
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'md'
|
||||
}}
|
||||
>
|
||||
<CardHeader pb={2}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
{plotDef.name}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={variables.length > 0 ? "green" : "gray"}
|
||||
variant="subtle"
|
||||
>
|
||||
{variables.length} vars
|
||||
</Badge>
|
||||
</HStack>
|
||||
{plotDef.description && (
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={2}>
|
||||
{plotDef.description}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{variables.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
Variables:
|
||||
</Text>
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
{variables.slice(0, 3).map((variable, idx) => (
|
||||
<Badge key={idx} size="sm" variant="outline">
|
||||
{variable.name}
|
||||
</Badge>
|
||||
))}
|
||||
{variables.length > 3 && (
|
||||
<Badge size="sm" variant="outline" colorScheme="gray">
|
||||
+{variables.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant={hasSession ? "outline" : "solid"}
|
||||
leftIcon={<AddIcon />}
|
||||
isDisabled={variables.length === 0}
|
||||
onClick={() => createSession(plotDef)}
|
||||
>
|
||||
{hasSession ? 'Create Another' : 'Create Session'}
|
||||
</Button>
|
||||
|
||||
{variables.length === 0 && (
|
||||
<Text fontSize="xs" color="red.500">
|
||||
No variables configured
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Grid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Sessions */}
|
||||
{activeSessions.length > 0 && (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Divider />
|
||||
<Heading size="md">Active Historical Sessions</Heading>
|
||||
|
||||
{activeSessions.map((session) => (
|
||||
<PlotHistoricalSession
|
||||
key={session.id}
|
||||
plotDefinition={session.plotDefinition}
|
||||
plotVariables={session.variables}
|
||||
onRemove={() => removeSession(session.id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Empty State for Sessions */}
|
||||
{activeSessions.length === 0 && plotDefinitions.length > 0 && (
|
||||
<Card variant="outline" borderStyle="dashed">
|
||||
<CardBody>
|
||||
<VStack spacing={4} py={8}>
|
||||
<TimeIcon boxSize={12} color="gray.400" />
|
||||
<Text color="gray.500" fontSize="lg" fontWeight="medium">
|
||||
No Active Historical Sessions
|
||||
</Text>
|
||||
<Text color="gray.400" fontSize="sm" textAlign="center">
|
||||
Select a plot definition above to create your first historical analysis session
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,370 @@
|
|||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
IconButton,
|
||||
Divider,
|
||||
Spacer,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Grid,
|
||||
GridItem,
|
||||
Flex,
|
||||
useToast,
|
||||
Checkbox,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
useDisclosure,
|
||||
Input,
|
||||
Select,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Spinner
|
||||
} from '@chakra-ui/react'
|
||||
import { SettingsIcon, ViewIcon, CloseIcon, TimeIcon } from '@chakra-ui/icons'
|
||||
import ChartjsHistoricalPlot from './ChartjsHistoricalPlot.jsx'
|
||||
import * as api from '../services/api'
|
||||
|
||||
/**
|
||||
* PlotHistoricalSession - Individual historical Chart.js plot component
|
||||
* Loads data from CSV files with time range selection and zoom/pan capabilities
|
||||
*/
|
||||
export default function PlotHistoricalSession({
|
||||
plotDefinition,
|
||||
plotVariables = [],
|
||||
onRemove
|
||||
}) {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const { isOpen: isFullscreen, onOpen: openFullscreen, onClose: closeFullscreen } = useDisclosure()
|
||||
|
||||
// Historical-specific state
|
||||
const [timeRange, setTimeRange] = useState({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
startTime: '00:00',
|
||||
endTime: '23:59'
|
||||
})
|
||||
|
||||
const [availableDateRange, setAvailableDateRange] = useState({
|
||||
minDate: '',
|
||||
maxDate: ''
|
||||
})
|
||||
|
||||
const [loadingData, setLoadingData] = useState(false)
|
||||
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
time_window: plotDefinition.time_window || 3600, // seconds for historical view
|
||||
y_min: plotDefinition.y_min,
|
||||
y_max: plotDefinition.y_max,
|
||||
// Visual style properties
|
||||
line_tension: plotDefinition.line_tension !== undefined ? plotDefinition.line_tension : 0.4,
|
||||
stepped: plotDefinition.stepped || false,
|
||||
stacked: plotDefinition.stacked || false,
|
||||
point_radius: plotDefinition.point_radius !== undefined ? plotDefinition.point_radius : 1,
|
||||
point_hover_radius: plotDefinition.point_hover_radius !== undefined ? plotDefinition.point_hover_radius : 4
|
||||
})
|
||||
|
||||
const chartControlsRef = useRef(null)
|
||||
const toast = useToast()
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const settingsBg = useColorModeValue('gray.50', 'gray.600')
|
||||
|
||||
// Enhanced session object for ChartjsHistoricalPlot
|
||||
const enhancedSession = useMemo(() => ({
|
||||
session_id: `historical_${plotDefinition.id}`,
|
||||
plot_id: plotDefinition.id,
|
||||
name: plotDefinition.name,
|
||||
variables_count: plotVariables.length,
|
||||
isFullscreen: isFullscreen,
|
||||
isHistorical: true,
|
||||
timeRange: timeRange,
|
||||
config: {
|
||||
...plotDefinition,
|
||||
...localConfig,
|
||||
variables: plotVariables
|
||||
},
|
||||
onChartReady: (controls) => {
|
||||
chartControlsRef.current = controls
|
||||
},
|
||||
onTimeRangeChange: handleChartTimeRangeChange,
|
||||
onDataRequest: handleDataRequest
|
||||
}), [
|
||||
plotDefinition,
|
||||
plotVariables,
|
||||
localConfig,
|
||||
isFullscreen,
|
||||
timeRange
|
||||
])
|
||||
|
||||
// Initialize with last 24 hours
|
||||
useEffect(() => {
|
||||
const now = new Date()
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
setTimeRange({
|
||||
startDate: yesterday.toISOString().split('T')[0],
|
||||
endDate: now.toISOString().split('T')[0],
|
||||
startTime: yesterday.toTimeString().substr(0, 5),
|
||||
endTime: now.toTimeString().substr(0, 5)
|
||||
})
|
||||
|
||||
// Set available range (last 30 days)
|
||||
setAvailableDateRange({
|
||||
minDate: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
maxDate: now.toISOString().split('T')[0]
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle time range changes from chart zoom/pan
|
||||
const handleChartTimeRangeChange = useCallback((newStartTime, newEndTime) => {
|
||||
const startDate = new Date(newStartTime)
|
||||
const endDate = new Date(newEndTime)
|
||||
|
||||
setTimeRange({
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: endDate.toISOString().split('T')[0],
|
||||
startTime: startDate.toTimeString().substr(0, 5),
|
||||
endTime: endDate.toTimeString().substr(0, 5)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle data requests from chart (zoom/pan events)
|
||||
const handleDataRequest = useCallback(async (startTime, endTime) => {
|
||||
try {
|
||||
const response = await api.getHistoricalDataOptimized({
|
||||
variables: plotVariables.map(v => v.name),
|
||||
start_time: new Date(startTime).toISOString(),
|
||||
end_time: new Date(endTime).toISOString()
|
||||
})
|
||||
|
||||
return response?.success ? response : null
|
||||
} catch (error) {
|
||||
console.error('Error loading historical data:', error)
|
||||
return null
|
||||
}
|
||||
}, [plotVariables])
|
||||
|
||||
const handleTimeRangeSubmit = () => {
|
||||
if (chartControlsRef.current?.loadHistoricalRange) {
|
||||
const startDateTime = new Date(`${timeRange.startDate}T${timeRange.startTime}:00`)
|
||||
const endDateTime = new Date(`${timeRange.endDate}T${timeRange.endTime}:00`)
|
||||
|
||||
chartControlsRef.current.loadHistoricalRange(
|
||||
startDateTime.getTime(),
|
||||
endDateTime.getTime()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateRange = () => {
|
||||
if (!timeRange.startDate || !timeRange.endDate) return 'No range selected'
|
||||
|
||||
const start = new Date(`${timeRange.startDate}T${timeRange.startTime}`)
|
||||
const end = new Date(`${timeRange.endDate}T${timeRange.endTime}`)
|
||||
|
||||
if (timeRange.startDate === timeRange.endDate) {
|
||||
return `${timeRange.startDate} ${timeRange.startTime} - ${timeRange.endTime}`
|
||||
}
|
||||
|
||||
return `${start.toLocaleDateString()} ${timeRange.startTime} - ${end.toLocaleDateString()} ${timeRange.endTime}`
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
if (chartControlsRef.current?.refreshData) {
|
||||
chartControlsRef.current.refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
<CardHeader pb={2}>
|
||||
<Flex align="center">
|
||||
<Box mr={3} fontSize="2xl">🕰️</Box>
|
||||
<VStack spacing={0} align="start" flex={1}>
|
||||
<Heading size="md">{plotDefinition.name}</Heading>
|
||||
<HStack spacing={4} fontSize="sm" color={muted}>
|
||||
<Text>{plotVariables.length} variables</Text>
|
||||
<Text>Historical Mode</Text>
|
||||
<Text>{formatDateRange()}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Settings"
|
||||
icon={<SettingsIcon />}
|
||||
size="sm"
|
||||
variant={showSettings ? "solid" : "ghost"}
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Fullscreen"
|
||||
icon={<ViewIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={openFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Remove"
|
||||
icon={<CloseIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={showSettings} animateOpacity>
|
||||
<Box px={6} pb={4} bg={settingsBg} borderRadius="md" mx={6} mb={4}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text fontWeight="medium" fontSize="sm">Historical Data Controls</Text>
|
||||
|
||||
{/* Time Range Controls */}
|
||||
<Grid templateColumns="1fr 1fr" gap={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Start Date</FormLabel>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={timeRange.startDate}
|
||||
min={availableDateRange.minDate}
|
||||
max={availableDateRange.maxDate}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, startDate: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">End Date</FormLabel>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={timeRange.endDate}
|
||||
min={availableDateRange.minDate}
|
||||
max={availableDateRange.maxDate}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, endDate: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid templateColumns="1fr 1fr" gap={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Start Time</FormLabel>
|
||||
<Input
|
||||
type="time"
|
||||
size="sm"
|
||||
value={timeRange.startTime}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, startTime: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">End Time</FormLabel>
|
||||
<Input
|
||||
type="time"
|
||||
size="sm"
|
||||
value={timeRange.endTime}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, endTime: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={handleTimeRangeSubmit}
|
||||
isLoading={loadingData}
|
||||
leftIcon={<TimeIcon />}
|
||||
>
|
||||
Load Time Range
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={refreshData}
|
||||
isLoading={isRefreshing}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{availableDateRange.minDate && (
|
||||
<Text fontSize="xs" color={muted}>
|
||||
Available data: {availableDateRange.minDate} to {availableDateRange.maxDate}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<CardBody pt={0}>
|
||||
{loadingData ? (
|
||||
<Flex align="center" justify="center" py={8}>
|
||||
<Spinner size="lg" mr={4} />
|
||||
<VStack spacing={2} align="start">
|
||||
<Text fontSize="lg" fontWeight="medium">Loading Historical Data...</Text>
|
||||
<Text fontSize="sm" color={muted}>Scanning CSV files for available data</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box height="400px">
|
||||
<ChartjsHistoricalPlot session={enhancedSession} height="400px" />
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
<Modal isOpen={isFullscreen} onClose={closeFullscreen} size="full">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex align="center">
|
||||
<Box mr={3} fontSize="2xl">🕰️</Box>
|
||||
<VStack spacing={0} align="start">
|
||||
<Heading size="lg">{plotDefinition.name}</Heading>
|
||||
<Text fontSize="sm" color={muted}>Historical Analysis - {formatDateRange()}</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<Box height="calc(100vh - 120px)">
|
||||
<ChartjsHistoricalPlot session={enhancedSession} height="100%" />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -56,6 +56,7 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
|
|||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import PlotManager from '../components/PlotManager'
|
||||
import PlotHistoricalManager from '../components/PlotHistoricalManager'
|
||||
import allWidgets from '../components/widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate'
|
||||
import CsvFileBrowser from '../components/CsvFileBrowser'
|
||||
|
@ -1453,8 +1454,9 @@ function DashboardContent() {
|
|||
<Tab>🔧 Configuration</Tab>
|
||||
<Tab>📊 Datasets</Tab>
|
||||
<Tab>📈 Plotting</Tab>
|
||||
<Tab><EFBFBD> CSV Files</Tab>
|
||||
<Tab><EFBFBD>📋 Events</Tab>
|
||||
<Tab>🕰️ Historical Plots</Tab>
|
||||
<Tab>📄 CSV Files</Tab>
|
||||
<Tab>📋 Events</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
|
@ -1480,6 +1482,11 @@ function DashboardContent() {
|
|||
<PlotManager />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* Historical Plot Management Section */}
|
||||
<PlotHistoricalManager />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* CSV File Browser Section */}
|
||||
<CsvFileBrowser />
|
||||
|
|
|
@ -287,4 +287,26 @@ export async function openCsvInExcel(filePath) {
|
|||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Configuration aliases for compatibility
|
||||
export async function getConfig(configId) {
|
||||
return readConfig(configId)
|
||||
}
|
||||
|
||||
// Enhanced historical data loading with time range support
|
||||
export async function getHistoricalDataOptimized(variables, options = {}) {
|
||||
const requestBody = {
|
||||
variables: variables,
|
||||
...options
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/plots/historical`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue