feat: Implement data availability visualization in historical plots
- Added DataAvailabilityBar component to visualize data segments on the timeline. - Integrated DataAvailabilityBar into TimePointSelector and ChartjsHistoricalPlot components. - Updated PlotHistoricalSession to fetch data segments from the backend. - Modified API to include endpoint for retrieving historical data segments. - Refactored date formatting to use 'en-US' locale for consistency. - Removed reset zoom functionality and associated UI elements from ChartjsHistoricalPlot.
This commit is contained in:
parent
be2df781cf
commit
0f928c50e7
File diff suppressed because it is too large
Load Diff
|
@ -461,12 +461,6 @@ const ChartjsHistoricalPlot = ({
|
|||
console.log('📊 Zoom/Pan events setup for historical chart');
|
||||
};
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
if (chartRef.current && window.Chart.registry.plugins.get('zoom')) {
|
||||
chartRef.current.resetZoom();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleZoom = useCallback(() => {
|
||||
setIsZoomEnabled(!isZoomEnabled);
|
||||
}, [isZoomEnabled]);
|
||||
|
@ -523,17 +517,6 @@ const ChartjsHistoricalPlot = ({
|
|||
/>
|
||||
</HStack>
|
||||
|
||||
{isZoomEnabled && (
|
||||
<button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={resetZoom}
|
||||
colorScheme="blue"
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
{dataPointsCount.toLocaleString()} data points
|
||||
{(() => {
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
import { useMemo } from 'react'
|
||||
import { Box, Tooltip, useColorModeValue } from '@chakra-ui/react'
|
||||
|
||||
/**
|
||||
* DataAvailabilityBar - Shows data availability segments on a timeline
|
||||
* Used under time selectors to visualize where data exists
|
||||
*/
|
||||
export default function DataAvailabilityBar({
|
||||
segments = [],
|
||||
minDate,
|
||||
maxDate,
|
||||
height = '8px',
|
||||
showTooltips = true
|
||||
}) {
|
||||
// Color mode
|
||||
const bgColor = useColorModeValue('gray.100', 'gray.600')
|
||||
const segmentColor = useColorModeValue('blue.400', 'blue.300')
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.500')
|
||||
|
||||
// Convert segments to visual blocks
|
||||
const visualSegments = useMemo(() => {
|
||||
if (!segments || segments.length === 0 || !minDate || !maxDate) {
|
||||
return []
|
||||
}
|
||||
|
||||
const totalRange = maxDate.getTime() - minDate.getTime()
|
||||
if (totalRange <= 0) return []
|
||||
|
||||
return segments.map((segment, index) => {
|
||||
try {
|
||||
const startTime = new Date(segment.start).getTime()
|
||||
const endTime = new Date(segment.end).getTime()
|
||||
|
||||
// Calculate positions as percentages
|
||||
const leftPercent = Math.max(0, ((startTime - minDate.getTime()) / totalRange) * 100)
|
||||
const rightPercent = Math.min(100, ((endTime - minDate.getTime()) / totalRange) * 100)
|
||||
const widthPercent = Math.max(0.1, rightPercent - leftPercent) // Minimum 0.1% width for visibility
|
||||
|
||||
return {
|
||||
id: `${segment.dataset}-${index}`,
|
||||
dataset: segment.dataset,
|
||||
left: leftPercent,
|
||||
width: widthPercent,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
filesCount: segment.files_count || 0,
|
||||
dateFolder: segment.date_folder || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error processing segment:', segment, error)
|
||||
return null
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}, [segments, minDate, maxDate])
|
||||
|
||||
if (!visualSegments.length) {
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height={height}
|
||||
bg={bgColor}
|
||||
borderRadius="2px"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
position="relative"
|
||||
opacity={0.5}
|
||||
>
|
||||
{/* Empty state - no data available */}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height={height}
|
||||
bg={bgColor}
|
||||
borderRadius="2px"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{visualSegments.map((segment) => (
|
||||
<Tooltip
|
||||
key={segment.id}
|
||||
label={showTooltips ? `${segment.dataset}: ${new Date(segment.start).toLocaleString('es-ES')} - ${new Date(segment.end).toLocaleString('es-ES')} (${segment.filesCount} files)` : ''}
|
||||
placement="top"
|
||||
hasArrow
|
||||
isDisabled={!showTooltips}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
left={`${segment.left}%`}
|
||||
width={`${segment.width}%`}
|
||||
height="100%"
|
||||
bg={segmentColor}
|
||||
borderRadius="1px"
|
||||
transition="opacity 0.2s"
|
||||
_hover={{ opacity: 0.8 }}
|
||||
cursor={showTooltips ? "pointer" : "default"}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -48,6 +48,7 @@ import {
|
|||
import { SettingsIcon, RepeatIcon, ViewIcon, DeleteIcon, TimeIcon, CalendarIcon } from '@chakra-ui/icons'
|
||||
import ChartjsHistoricalPlot from './ChartjsHistoricalPlot.jsx'
|
||||
import TimePointSelector from './TimePointSelector.jsx'
|
||||
import DataAvailabilityBar from './DataAvailabilityBar.jsx'
|
||||
import * as api from '../services/api'
|
||||
|
||||
/**
|
||||
|
@ -78,6 +79,7 @@ export default function PlotHistoricalSession({
|
|||
})
|
||||
const [timeRangeSeconds, setTimeRangeSeconds] = useState(1000) // 500 seg atrás + 500 seg adelante
|
||||
const [dateRange, setDateRange] = useState(null) // Min/max dates disponibles del backend
|
||||
const [dataSegments, setDataSegments] = useState([]) // Data availability segments
|
||||
|
||||
// Debounced state updates for pan/zoom with 1s cooldown
|
||||
const pendingUpdatesRef = useRef({ centralTime: null, rangeSeconds: null })
|
||||
|
@ -173,6 +175,7 @@ export default function PlotHistoricalSession({
|
|||
// Load date range from backend on mount
|
||||
useEffect(() => {
|
||||
loadDateRange()
|
||||
loadDataSegments()
|
||||
}, [])
|
||||
|
||||
// Load historical data when derived time range changes
|
||||
|
@ -216,6 +219,18 @@ export default function PlotHistoricalSession({
|
|||
}
|
||||
}
|
||||
|
||||
const loadDataSegments = async () => {
|
||||
try {
|
||||
const response = await api.getHistoricalDataSegments()
|
||||
if (response.success) {
|
||||
setDataSegments(response.segments || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data segments:', error)
|
||||
// Non-critical error, don't set error state
|
||||
}
|
||||
}
|
||||
|
||||
// Function to check if a range is contained within another range
|
||||
const isRangeContained = (newRange, existingRange) => {
|
||||
if (!existingRange || !newRange) return false
|
||||
|
@ -428,7 +443,7 @@ export default function PlotHistoricalSession({
|
|||
const formatCentralTimeInfo = () => {
|
||||
const halfRange = timeRangeSeconds / 2
|
||||
return {
|
||||
central: centralTime.toLocaleString('es-ES', {
|
||||
central: centralTime.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
|
@ -437,7 +452,7 @@ export default function PlotHistoricalSession({
|
|||
hour12: false
|
||||
}),
|
||||
range: `±${halfRange}s (${timeRangeSeconds}s total)`,
|
||||
start: derivedTimeRange.start.toLocaleString('es-ES', {
|
||||
start: derivedTimeRange.start.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
|
@ -445,7 +460,7 @@ export default function PlotHistoricalSession({
|
|||
minute: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
end: derivedTimeRange.end.toLocaleString('es-ES', {
|
||||
end: derivedTimeRange.end.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
|
@ -483,7 +498,7 @@ export default function PlotHistoricalSession({
|
|||
<HStack spacing={4} fontSize="xs" color={textColor}>
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>Centro: {formatCentralTimeInfo().central}</Text>
|
||||
<Text>Center: {formatCentralTimeInfo().central}</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<CalendarIcon />
|
||||
|
@ -572,13 +587,14 @@ export default function PlotHistoricalSession({
|
|||
maxDate={dateRange.maxDate}
|
||||
initial={centralTime}
|
||||
stepMinutes={1}
|
||||
dataSegments={dataSegments}
|
||||
onTimeChange={handleTimePointChange}
|
||||
/>
|
||||
<Box mt={2} p={2} bg={infoBgColor} borderRadius="md" border="1px solid" borderColor={borderColor}>
|
||||
<HStack justify="space-between" fontSize="sm" color={textColor}>
|
||||
<Text><strong>Rango:</strong> {timeRangeSeconds}s</Text>
|
||||
<Text><strong>Desde:</strong> {formatCentralTimeInfo().start}</Text>
|
||||
<Text><strong>Hasta:</strong> {formatCentralTimeInfo().end}</Text>
|
||||
<Text><strong>Range:</strong> {timeRangeSeconds}s</Text>
|
||||
<Text><strong>From:</strong> {formatCentralTimeInfo().start}</Text>
|
||||
<Text><strong>To:</strong> {formatCentralTimeInfo().end}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
@ -3,12 +3,14 @@ import { Box, Flex, Text, Slider, SliderTrack, SliderFilledTrack, SliderThumb, B
|
|||
import { CheckIcon } from "@chakra-ui/icons";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import DataAvailabilityBar from "./DataAvailabilityBar.jsx";
|
||||
|
||||
export default function TimePointSelector({
|
||||
minDate,
|
||||
maxDate,
|
||||
initial,
|
||||
stepMinutes = 5,
|
||||
dataSegments = [],
|
||||
onTimeChange,
|
||||
}) {
|
||||
// Color mode values
|
||||
|
@ -134,7 +136,7 @@ export default function TimePointSelector({
|
|||
<Box p={4} bg={bgColor} borderRadius="md" border="1px solid" borderColor={borderColor}>
|
||||
<Flex gap={4} align="center" mb={3} wrap="wrap">
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1} color={textColor}>Seleccionar fecha y hora</Text>
|
||||
<Text fontWeight="semibold" mb={1} color={textColor}>Select Date and Time</Text>
|
||||
<Box
|
||||
sx={{
|
||||
'& .react-datepicker-wrapper': {
|
||||
|
@ -183,11 +185,11 @@ export default function TimePointSelector({
|
|||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={stepMinutes}
|
||||
timeCaption="Hora"
|
||||
timeCaption="Time"
|
||||
dateFormat="dd-MM-yyyy HH:mm"
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
// Opcional: fuerzas el rango también por hora al navegar
|
||||
// Optional: force time range when navigating
|
||||
minTime={new Date(new Date(value).setHours(0, 0, 0, 0))}
|
||||
maxTime={new Date(new Date(value).setHours(23, 59, 59, 999))}
|
||||
/>
|
||||
|
@ -196,7 +198,7 @@ export default function TimePointSelector({
|
|||
|
||||
<Box flex="1" minW="260px">
|
||||
<Flex align="center" mb={1}>
|
||||
<Text color={textColor}>Navegar con slider</Text>
|
||||
<Text color={textColor}>Navigate with slider</Text>
|
||||
{hasPendingChanges && (
|
||||
<Button
|
||||
size="xs"
|
||||
|
@ -209,22 +211,45 @@ export default function TimePointSelector({
|
|||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
<Slider
|
||||
min={minMs}
|
||||
max={maxMs}
|
||||
step={stepMs}
|
||||
value={sliderValue}
|
||||
onChange={onSlide}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<SliderTrack bg={useColorModeValue('gray.200', 'gray.600')}>
|
||||
<SliderFilledTrack bg="blue.500" />
|
||||
</SliderTrack>
|
||||
<SliderThumb bg="blue.500" />
|
||||
</Slider>
|
||||
|
||||
{/* Slider with integrated data availability */}
|
||||
<Box position="relative" mb={2}>
|
||||
{/* Data availability bar positioned above slider track */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
left="0"
|
||||
right="0"
|
||||
px="1"
|
||||
zIndex={1}
|
||||
>
|
||||
<DataAvailabilityBar
|
||||
segments={dataSegments}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
height="4px"
|
||||
showTooltips={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
min={minMs}
|
||||
max={maxMs}
|
||||
step={stepMs}
|
||||
value={sliderValue}
|
||||
onChange={onSlide}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<SliderTrack bg={useColorModeValue('gray.200', 'gray.600')}>
|
||||
<SliderFilledTrack bg="blue.500" />
|
||||
</SliderTrack>
|
||||
<SliderThumb bg="blue.500" />
|
||||
</Slider>
|
||||
</Box>
|
||||
|
||||
<Flex mt={2} justify="space-between" align="center">
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
{new Date(sliderValue).toLocaleString('es-ES', {
|
||||
{new Date(sliderValue).toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
|
@ -235,7 +260,7 @@ export default function TimePointSelector({
|
|||
</Text>
|
||||
{hasPendingChanges && (
|
||||
<Text fontSize="xs" color="orange.500">
|
||||
Cambios pendientes
|
||||
Pending changes
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
@ -243,21 +268,21 @@ export default function TimePointSelector({
|
|||
</Flex>
|
||||
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.400')}>
|
||||
Rango: {minDate.toLocaleString('es-ES', {
|
||||
Range: {minDate.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})} → {maxDate.toLocaleString('es-ES', {
|
||||
})} → {maxDate.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})} | Paso: {stepMinutes} min
|
||||
})} | Step: {stepMinutes} min
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
@ -237,6 +237,14 @@ export async function getHistoricalDateRange() {
|
|||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Get data availability segments for visualization
|
||||
export async function getHistoricalDataSegments() {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/historical/data-segments`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Plot session status and control (aliases for existing functions)
|
||||
export async function getPlotSession(sessionId) {
|
||||
// Use existing getPlotConfig to get session info
|
||||
|
|
122
main.py
122
main.py
|
@ -2382,6 +2382,128 @@ def get_historical_date_range():
|
|||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/historical/data-segments", methods=["GET"])
|
||||
def get_historical_data_segments():
|
||||
"""Get data availability segments for visualization"""
|
||||
try:
|
||||
import glob
|
||||
|
||||
records_dir = os.path.join(os.path.dirname(__file__), "records")
|
||||
|
||||
if not os.path.exists(records_dir):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "No records directory found"
|
||||
}), 404
|
||||
|
||||
segments = []
|
||||
|
||||
# Find all date folders (format: DD-MM-YYYY)
|
||||
date_folders = []
|
||||
for item in os.listdir(records_dir):
|
||||
folder_path = os.path.join(records_dir, item)
|
||||
if os.path.isdir(folder_path):
|
||||
try:
|
||||
# Try to parse the folder name as a date
|
||||
date_obj = datetime.strptime(item, "%d-%m-%Y")
|
||||
date_folders.append((date_obj, folder_path))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Sort by date
|
||||
date_folders.sort(key=lambda x: x[0])
|
||||
|
||||
# For each date folder, find file segments
|
||||
for date_obj, folder_path in date_folders:
|
||||
csv_files = glob.glob(os.path.join(folder_path, "*.csv"))
|
||||
|
||||
# Group files by dataset name (prefix before _XX.csv)
|
||||
dataset_files = {}
|
||||
for csv_file in csv_files:
|
||||
filename = os.path.basename(csv_file)
|
||||
if '_' in filename:
|
||||
# Extract dataset name (e.g., "fast" from "fast_00.csv")
|
||||
dataset_name = filename.split('_')[0]
|
||||
if dataset_name not in dataset_files:
|
||||
dataset_files[dataset_name] = []
|
||||
dataset_files[dataset_name].append(csv_file)
|
||||
|
||||
# For each dataset, create segments based on file sequence
|
||||
for dataset_name, files in dataset_files.items():
|
||||
# Sort files by sequence number
|
||||
files.sort()
|
||||
|
||||
if not files:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get time range from first and last file quickly
|
||||
# Read only first few lines of first file and last few lines of last file
|
||||
first_file = files[0]
|
||||
last_file = files[-1]
|
||||
|
||||
# Quick read of timestamps from file headers/footers
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
# Read first file's start time
|
||||
with open(first_file, 'r', encoding='utf-8-sig') as f:
|
||||
lines = f.readlines()
|
||||
if len(lines) > 1: # Skip header
|
||||
first_data_line = lines[1].strip()
|
||||
if first_data_line and ',' in first_data_line:
|
||||
timestamp_str = first_data_line.split(',')[0]
|
||||
try:
|
||||
start_time = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
if start_time.tzinfo:
|
||||
start_time = start_time.replace(tzinfo=None)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Read last file's end time
|
||||
with open(last_file, 'r', encoding='utf-8-sig') as f:
|
||||
lines = f.readlines()
|
||||
# Find last non-empty line with data
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if line and ',' in line and not line.startswith('timestamp'):
|
||||
timestamp_str = line.split(',')[0]
|
||||
try:
|
||||
end_time = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
if end_time.tzinfo:
|
||||
end_time = end_time.replace(tzinfo=None)
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if start_time and end_time:
|
||||
segments.append({
|
||||
"dataset": dataset_name,
|
||||
"start": start_time.isoformat(),
|
||||
"end": end_time.isoformat(),
|
||||
"files_count": len(files),
|
||||
"date_folder": date_obj.strftime("%d-%m-%Y")
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Skip problematic files, don't break the entire response
|
||||
backend_logger.warning(f"Error processing dataset {dataset_name} in {folder_path}: {e}")
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"segments": segments,
|
||||
"total_segments": len(segments)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
backend_logger.error(f"Error getting data segments: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/sessions/<plot_id>", methods=["GET"])
|
||||
def get_plot_sessions(plot_id):
|
||||
"""Get all session IDs for a specific plot ID"""
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
"should_stream": false,
|
||||
"active_datasets": [
|
||||
"DAR",
|
||||
"Fast"
|
||||
"Fast",
|
||||
"Test"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-16T18:34:19.713675",
|
||||
"last_update": "2025-08-16T19:07:34.585967",
|
||||
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
|
||||
}
|
Loading…
Reference in New Issue