feat: Enhance plot configuration with visual style options

- Added line tension, stepped lines, point radius, and point hover radius to plot definitions.
- Updated plot variables to include labels for better clarity.
- Modified plot definitions schema to accommodate new visual style properties.
- Enhanced UI schema to support new configuration options in the settings panel.
- Improved ChartjsPlot component to utilize new visual style properties for rendering.
- Implemented refresh functionality for plot configuration in PlotRealtimeSession.
- Updated VariableSelectorWidget to remove unnecessary required attribute.
- Adjusted system state to reflect changes in active datasets and last update timestamp.
This commit is contained in:
Miguel 2025-08-14 13:12:04 +02:00
parent d0d675d804
commit 087a9458ce
9 changed files with 801 additions and 10404 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2,20 +2,17 @@
"plots": [
{
"id": "plot_1",
"line_tension": 0,
"name": "UR29",
"time_window": 25,
"point_hover_radius": 4,
"point_radius": 1,
"stepped": true,
"time_window": 20,
"trigger_enabled": false,
"trigger_on_true": true,
"trigger_variable": null,
"y_max": null,
"y_min": null
},
{
"id": "Brix",
"name": "Brix",
"time_window": 60,
"trigger_enabled": false,
"trigger_on_true": true
}
]
}

View File

@ -4,14 +4,20 @@
"plot_id": "plot_1",
"variables": [
{
"variable_name": "UR29_Brix",
"label": "Brix",
"color": "#3498db",
"enabled": true,
"variable_name": "UR29_Brix"
"line_width": 2,
"y_axis": "left",
"enabled": true
},
{
"variable_name": "UR29_ma",
"label": "ma",
"color": "#e74c3c",
"enabled": true,
"variable_name": "UR29_ma"
"line_width": 2,
"y_axis": "left",
"enabled": true
}
]
}

View File

@ -54,6 +54,36 @@
"type": "boolean",
"title": "Trigger on True",
"default": true
},
"line_tension": {
"type": "number",
"title": "Line Tension",
"description": "Bezier curve tension (0 = straight lines, 0.4 = smooth curves)",
"minimum": 0,
"maximum": 1,
"default": 0.4
},
"stepped": {
"type": "boolean",
"title": "Stepped Lines",
"description": "Enable stepped line style",
"default": false
},
"point_radius": {
"type": "number",
"title": "Point Radius",
"description": "Size of data points",
"minimum": 0,
"maximum": 10,
"default": 1
},
"point_hover_radius": {
"type": "number",
"title": "Point Hover Radius",
"description": "Size of data points when hovering",
"minimum": 0,
"maximum": 15,
"default": 4
}
},
"required": ["id", "name", "time_window"]

View File

@ -15,7 +15,11 @@
"y_max",
"trigger_variable",
"trigger_enabled",
"trigger_on_true"
"trigger_on_true",
"line_tension",
"stepped",
"point_radius",
"point_hover_radius"
],
"ui:layout": [
[
@ -55,6 +59,24 @@
"name": "trigger_on_true",
"width": 12
}
],
[
{
"name": "line_tension",
"width": 3
},
{
"name": "stepped",
"width": 3
},
{
"name": "point_radius",
"width": 3
},
{
"name": "point_hover_radius",
"width": 3
}
]
],
"id": {
@ -95,6 +117,22 @@
"trigger_on_true": {
"ui:widget": "checkbox",
"ui:help": "🔄 Trigger when variable becomes true (vs false)"
},
"line_tension": {
"ui:widget": "updown",
"ui:help": "📈 Line smoothness: 0=straight lines, 0.4=smooth curves"
},
"stepped": {
"ui:widget": "checkbox",
"ui:help": "📊 Enable stepped line style instead of curves"
},
"point_radius": {
"ui:widget": "updown",
"ui:help": "🔴 Size of data points (0-10)"
},
"point_hover_radius": {
"ui:widget": "updown",
"ui:help": "🎯 Size of points when hovering (0-15)"
}
}
},

View File

@ -22,6 +22,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [dataPointsCount, setDataPointsCount] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const resolvedConfigRef = useRef(null);
const bgColor = useColorModeValue('white', 'gray.800');
@ -62,6 +63,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
.filter(varConfig => varConfig.enabled !== false && varConfig.variable_name)
.map((varConfig, index) => ({
name: varConfig.variable_name,
label: varConfig.label || varConfig.variable_name, // Use display label if available
color: varConfig.color || getColor(varConfig.variable_name, index),
enabled: varConfig.enabled !== false
}));
@ -69,6 +71,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Handle simple array of strings
return variables.map((variable, index) => ({
name: variable,
label: variable, // For simple strings, name and label are the same
color: getColor(variable, index),
enabled: true
}));
@ -79,6 +82,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
.filter(([id, config]) => config.enabled !== false && config.variable_name)
.map(([id, config]) => ({
name: config.variable_name,
label: config.label || config.variable_name, // Use display label if available
color: config.color || getColor(config.variable_name),
enabled: config.enabled !== false
}));
@ -137,10 +141,21 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const ctx = canvasRef.current.getContext('2d');
// Destroy existing chart
// Destroy existing chart more safely
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
try {
// Pause streaming before destroying to avoid dangling references
const rt = chartRef.current.options?.scales?.x?.realtime;
if (rt) {
rt.pause = true;
chartRef.current.update('none');
}
chartRef.current.destroy();
} catch (destroyError) {
console.warn('⚠️ Error destroying previous chart:', destroyError);
} finally {
chartRef.current = null;
}
}
const config = cfg;
@ -148,17 +163,25 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const datasets = enabledVariables.map((variableInfo, index) => {
const color = variableInfo.color || getColor(variableInfo.name, index);
// Get style configuration with defaults
const lineTension = (typeof config.line_tension === 'number') ? config.line_tension : 0.4;
const stepped = config.stepped === true;
const pointRadius = (typeof config.point_radius === 'number') ? config.point_radius : 1;
const pointHoverRadius = (typeof config.point_hover_radius === 'number') ? config.point_hover_radius : 4;
return {
label: variableInfo.name,
label: variableInfo.label, // Use display label for chart legend
data: [],
borderColor: color,
backgroundColor: color + '20',
borderWidth: 2,
fill: false,
spanGaps: true,
pointRadius: 1,
pointHoverRadius: 4,
tension: 0.4
pointRadius: pointRadius,
pointHoverRadius: pointHoverRadius,
tension: lineTension,
stepped: stepped
};
});
@ -500,6 +523,86 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
setDataPointsCount(0);
}, []);
// Update configuration directly (for real-time style changes)
const updateConfig = useCallback(async (newConfig) => {
try {
console.log(`🔄 Updating configuration for plot session ${session?.session_id}...`);
const oldConfig = resolvedConfigRef.current;
resolvedConfigRef.current = { ...oldConfig, ...newConfig };
// Check if chart recreation is needed
const needsRecreation = !oldConfig ||
oldConfig.line_tension !== newConfig.line_tension ||
oldConfig.stepped !== newConfig.stepped ||
oldConfig.point_radius !== newConfig.point_radius ||
oldConfig.point_hover_radius !== newConfig.point_hover_radius ||
oldConfig.time_window !== newConfig.time_window ||
oldConfig.y_min !== newConfig.y_min ||
oldConfig.y_max !== newConfig.y_max;
if (needsRecreation) {
console.log(`🔄 Chart needs recreation due to configuration changes`);
await createStreamingChart();
} else {
console.log(`✅ No chart recreation needed, configuration updated`);
}
} catch (error) {
console.error(`❌ Error updating configuration for plot session ${session?.session_id}:`, error);
setError(error.message);
}
}, [session?.session_id, createStreamingChart]);
// Refresh configuration from server and recreate chart
const refreshConfiguration = useCallback(async () => {
if (!session?.session_id) return;
setIsRefreshing(true);
try {
console.log(`🔄 Refreshing configuration for plot session ${session.session_id}...`);
// Fetch latest session configuration from server
const response = await fetch(`/api/plots/${session.session_id}/config`);
if (!response.ok) {
throw new Error(`Failed to fetch session configuration: ${response.statusText}`);
}
const updatedSession = await response.json();
// Update the resolved config with the latest configuration
if (updatedSession.success && updatedSession.config) {
const oldConfig = resolvedConfigRef.current;
resolvedConfigRef.current = updatedSession.config;
console.log(`✅ Configuration refreshed for plot session ${session.session_id}`);
// Only recreate the chart if there are significant changes
const needsRecreation = !oldConfig ||
JSON.stringify(oldConfig.variables) !== JSON.stringify(updatedSession.config.variables) ||
oldConfig.time_window !== updatedSession.config.time_window ||
oldConfig.y_min !== updatedSession.config.y_min ||
oldConfig.y_max !== updatedSession.config.y_max ||
oldConfig.line_tension !== updatedSession.config.line_tension ||
oldConfig.stepped !== updatedSession.config.stepped ||
oldConfig.point_radius !== updatedSession.config.point_radius ||
oldConfig.point_hover_radius !== updatedSession.config.point_hover_radius;
if (needsRecreation) {
console.log(`🔄 Chart needs recreation due to configuration changes`);
await createStreamingChart();
} else {
console.log(`✅ No chart recreation needed, configuration is the same`);
}
} else {
throw new Error('Invalid response format or configuration not found');
}
} catch (error) {
console.error(`❌ Error refreshing configuration for plot session ${session.session_id}:`, error);
setError(error.message);
} finally {
setIsRefreshing(false);
}
}, [session?.session_id, createStreamingChart]);
// Not using useImperativeHandle since we're exposing functions through props callback
// Also expose control functions through props for easier access
@ -511,10 +614,12 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
session.onChartReady({
pauseStreaming,
resumeStreaming,
clearChart
clearChart,
refreshConfiguration,
updateConfig
});
}
}, [pauseStreaming, resumeStreaming, clearChart, session?.session_id, session?.onChartReady]);
}, [pauseStreaming, resumeStreaming, clearChart, refreshConfiguration, updateConfig, session?.session_id, session?.onChartReady]);
// Update chart when session status changes
useEffect(() => {
@ -554,9 +659,13 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
chartRef.current.destroy();
chartRef.current = null;
}
} catch { /* ignore */ }
} catch (error) {
console.warn('⚠️ Chart cleanup error:', error);
}
if (sessionDataRef.current.manualInterval) {
clearInterval(sessionDataRef.current.manualInterval);
sessionDataRef.current.manualInterval = null;
}
};
}, [session?.session_id]);
@ -621,6 +730,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
userSelect: 'none'
}}
/>
{/* Points counter */}
<Box
position="absolute"
top={2}

View File

@ -25,9 +25,14 @@ import {
Grid,
GridItem,
Flex,
useToast
useToast,
Checkbox,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb
} from '@chakra-ui/react'
import { SettingsIcon } from '@chakra-ui/icons'
import { SettingsIcon, RepeatIcon } from '@chakra-ui/icons'
import ChartjsPlot from './ChartjsPlot.jsx'
import * as api from '../services/api'
@ -50,13 +55,19 @@ export default function PlotRealtimeSession({
})
const [showSettings, setShowSettings] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [localConfig, setLocalConfig] = useState({
time_window: plotDefinition.time_window || 60,
y_min: plotDefinition.y_min,
y_max: plotDefinition.y_max,
trigger_enabled: plotDefinition.trigger_enabled || false,
trigger_variable: plotDefinition.trigger_variable,
trigger_on_true: plotDefinition.trigger_on_true || true
trigger_on_true: plotDefinition.trigger_on_true || true,
// Visual style properties
line_tension: plotDefinition.line_tension !== undefined ? plotDefinition.line_tension : 0.4,
stepped: plotDefinition.stepped || 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)
@ -254,11 +265,51 @@ export default function PlotRealtimeSession({
y_max: plotDefinition.y_max,
trigger_enabled: plotDefinition.trigger_enabled || false,
trigger_variable: plotDefinition.trigger_variable,
trigger_on_true: plotDefinition.trigger_on_true || true
trigger_on_true: plotDefinition.trigger_on_true || true,
// Reset visual style properties to defaults
line_tension: plotDefinition.line_tension !== undefined ? plotDefinition.line_tension : 0.4,
stepped: plotDefinition.stepped || false,
point_radius: plotDefinition.point_radius !== undefined ? plotDefinition.point_radius : 1,
point_hover_radius: plotDefinition.point_hover_radius !== undefined ? plotDefinition.point_hover_radius : 4
})
setShowSettings(false)
}
// Refresh plot configuration and recreate chart
const refreshPlotConfiguration = useCallback(async () => {
setIsRefreshing(true)
try {
console.log(`🔄 Refreshing configuration for plot ${plotDefinition.id}...`)
// Trigger chart configuration refresh if available
if (chartControlsRef.current?.refreshConfiguration) {
await chartControlsRef.current.refreshConfiguration()
}
// Also refresh session status
await refreshSessionStatus()
toast({
title: '🔄 Configuration refreshed',
description: 'Plot configuration and variables have been updated',
status: 'success',
duration: 2000
})
console.log(`✅ Configuration refreshed for plot ${plotDefinition.id}`)
} catch (error) {
console.error(`❌ Error refreshing configuration for plot ${plotDefinition.id}:`, error)
toast({
title: '❌ Failed to refresh configuration',
description: error.message,
status: 'error',
duration: 3000
})
} finally {
setIsRefreshing(false)
}
}, [plotDefinition.id, refreshSessionStatus, toast])
// Auto-refresh session status
useEffect(() => {
// Try to get session status first, if it fails, create the session
@ -292,6 +343,15 @@ export default function PlotRealtimeSession({
</Box>
<Spacer />
<HStack>
<IconButton
icon={<RepeatIcon />}
size="sm"
variant="outline"
aria-label="Refresh Configuration"
onClick={refreshPlotConfiguration}
isLoading={isRefreshing}
title="Refresh plot configuration and variables"
/>
<IconButton
icon={<SettingsIcon />}
size="sm"
@ -315,83 +375,189 @@ export default function PlotRealtimeSession({
{/* Settings Panel */}
{showSettings && (
<Box mb={4} p={4} bg={useColorModeValue('gray.50', 'gray.600')} borderRadius="md">
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4}>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Time Window (seconds)</FormLabel>
<NumberInput
value={localConfig.time_window}
onChange={(valueString) => setLocalConfig(prev => ({
...prev,
time_window: parseInt(valueString) || 60
}))}
min={10}
max={3600}
size="sm"
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Y Min (auto if empty)</FormLabel>
<NumberInput
value={localConfig.y_min || ''}
onChange={(valueString) => setLocalConfig(prev => ({
...prev,
y_min: valueString === '' ? null : parseFloat(valueString)
}))}
size="sm"
>
<NumberInputField placeholder="Auto" />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Y Max (auto if empty)</FormLabel>
<NumberInput
value={localConfig.y_max || ''}
onChange={(valueString) => setLocalConfig(prev => ({
...prev,
y_max: valueString === '' ? null : parseFloat(valueString)
}))}
size="sm"
>
<NumberInputField placeholder="Auto" />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Enable Trigger</FormLabel>
<Switch
isChecked={localConfig.trigger_enabled}
onChange={(e) => setLocalConfig(prev => ({
...prev,
trigger_enabled: e.target.checked
}))}
size="sm"
/>
</FormControl>
</GridItem>
</Grid>
<VStack spacing={4} align="stretch">
{/* Basic Plot Settings */}
<Box>
<Heading size="sm" mb={3}>📊 Plot Configuration</Heading>
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4}>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Time Window (seconds)</FormLabel>
<NumberInput
value={localConfig.time_window}
onChange={(valueString) => setLocalConfig(prev => ({
...prev,
time_window: parseInt(valueString) || 60
}))}
min={10}
max={3600}
size="sm"
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Y Min (auto if empty)</FormLabel>
<NumberInput
value={localConfig.y_min || ''}
onChange={(valueString) => setLocalConfig(prev => ({
...prev,
y_min: valueString === '' ? null : parseFloat(valueString)
}))}
size="sm"
>
<NumberInputField placeholder="Auto" />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Y Max (auto if empty)</FormLabel>
<NumberInput
value={localConfig.y_max || ''}
onChange={(valueString) => setLocalConfig(prev => ({
...prev,
y_max: valueString === '' ? null : parseFloat(valueString)
}))}
size="sm"
>
<NumberInputField placeholder="Auto" />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Enable Trigger</FormLabel>
<Switch
isChecked={localConfig.trigger_enabled}
onChange={(e) => setLocalConfig(prev => ({
...prev,
trigger_enabled: e.target.checked
}))}
size="sm"
/>
</FormControl>
</GridItem>
</Grid>
</Box>
{/* Visual Style Settings */}
<Box>
<Heading size="sm" mb={3}>🎨 Visual Style</Heading>
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4}>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Line Tension: {localConfig.line_tension}</FormLabel>
<Text fontSize="xs" color="gray.500" mb={2}>
0 = straight lines, 1 = smooth curves
</Text>
<Slider
value={localConfig.line_tension}
onChange={(value) => setLocalConfig(prev => ({
...prev,
line_tension: value
}))}
min={0}
max={1}
step={0.1}
size="sm"
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Stepped Lines</FormLabel>
<Text fontSize="xs" color="gray.500" mb={2}>
Enable step-line style instead of curves
</Text>
<Checkbox
isChecked={localConfig.stepped}
onChange={(e) => setLocalConfig(prev => ({
...prev,
stepped: e.target.checked
}))}
size="sm"
>
Step Mode
</Checkbox>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Point Size: {localConfig.point_radius}px</FormLabel>
<Text fontSize="xs" color="gray.500" mb={2}>
Size of data points (0 = hidden)
</Text>
<Slider
value={localConfig.point_radius}
onChange={(value) => setLocalConfig(prev => ({
...prev,
point_radius: value
}))}
min={0}
max={10}
step={0.5}
size="sm"
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</GridItem>
<GridItem>
<FormControl>
<FormLabel fontSize="sm">Hover Point Size: {localConfig.point_hover_radius}px</FormLabel>
<Text fontSize="xs" color="gray.500" mb={2}>
Size when hovering over points
</Text>
<Slider
value={localConfig.point_hover_radius}
onChange={(value) => setLocalConfig(prev => ({
...prev,
point_hover_radius: value
}))}
min={0}
max={15}
step={0.5}
size="sm"
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</GridItem>
</Grid>
</Box>
</VStack>
<Flex mt={4} gap={2}>
<Button size="sm" colorScheme="blue" onClick={applyConfigChanges}>

View File

@ -243,7 +243,7 @@ export function VariableSelectorWidget(props) {
}
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
<FormControl isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<VStack spacing={3} align="stretch">
@ -309,6 +309,7 @@ export function VariableSelectorWidget(props) {
_focus={{ borderColor: focusBorderColor }}
bg={bgColor}
isDisabled={disabled || readonly}
isRequired={required}
>
<option value="">Select a variable...</option>
{filteredVariables.map((variable, index) => (

View File

@ -3,11 +3,11 @@
"should_connect": true,
"should_stream": false,
"active_datasets": [
"DAR",
"Test",
"Fast",
"DAR"
"Fast"
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-14T12:05:58.306284"
"last_update": "2025-08-14T13:09:58.767138"
}