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:
Miguel 2025-08-16 19:20:42 +02:00
parent be2df781cf
commit 0f928c50e7
8 changed files with 6729 additions and 3333 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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
{(() => {

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
);

View File

@ -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
View File

@ -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"""

View File

@ -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"
}