u
This commit is contained in:
167
uiserver/src/components/Chart.js
vendored
167
uiserver/src/components/Chart.js
vendored
@@ -1,9 +1,112 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Box, Paper, Typography, CircularProgress, IconButton } from '@mui/material';
|
||||
import { LineChart } from '@mui/x-charts/LineChart';
|
||||
import { useDrawingArea, useYScale, useXScale } from '@mui/x-charts/hooks';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
|
||||
// Custom component to render a horizontal band between two y-values
|
||||
function ReferenceArea({ yMin, yMax, color = 'rgba(76, 175, 80, 0.15)', axisId = 'left' }) {
|
||||
const { left, width } = useDrawingArea();
|
||||
const yScale = useYScale(axisId);
|
||||
|
||||
if (!yScale) return null;
|
||||
|
||||
const y1 = yScale(yMax);
|
||||
const y2 = yScale(yMin);
|
||||
|
||||
if (y1 === undefined || y2 === undefined) return null;
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={left}
|
||||
y={Math.min(y1, y2)}
|
||||
width={width}
|
||||
height={Math.abs(y2 - y1)}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom component to render vertical time bands every 6 hours aligned to midnight
|
||||
function TimeReferenceAreas({ axisStart, axisEnd, colors }) {
|
||||
const { top, height } = useDrawingArea();
|
||||
const xScale = useXScale();
|
||||
|
||||
if (!xScale) return null;
|
||||
|
||||
// Calculate 6-hour bands aligned to midnight
|
||||
const SIX_HOURS = 6 * 60 * 60 * 1000;
|
||||
const bands = [];
|
||||
|
||||
// Find the first midnight before axisStart
|
||||
const startDate = new Date(axisStart);
|
||||
const midnight = new Date(startDate);
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
|
||||
// Start from that midnight
|
||||
let bandStart = midnight.getTime();
|
||||
|
||||
while (bandStart < axisEnd) {
|
||||
const bandEnd = bandStart + SIX_HOURS;
|
||||
|
||||
// Only render if band overlaps with visible range
|
||||
if (bandEnd > axisStart && bandStart < axisEnd) {
|
||||
const visibleStart = Math.max(bandStart, axisStart);
|
||||
const visibleEnd = Math.min(bandEnd, axisEnd);
|
||||
|
||||
const x1 = xScale(new Date(visibleStart));
|
||||
const x2 = xScale(new Date(visibleEnd));
|
||||
|
||||
if (x1 !== undefined && x2 !== undefined) {
|
||||
// Determine which 6-hour block (0-3) based on hour of day
|
||||
const hour = new Date(bandStart).getHours();
|
||||
const blockIndex = Math.floor(hour / 6); // 0, 1, 2, or 3
|
||||
const color = colors[blockIndex % colors.length];
|
||||
|
||||
bands.push(
|
||||
<rect
|
||||
key={bandStart}
|
||||
x={Math.min(x1, x2)}
|
||||
y={top}
|
||||
width={Math.abs(x2 - x1)}
|
||||
height={height}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
bandStart = bandEnd;
|
||||
}
|
||||
|
||||
return <>{bands}</>;
|
||||
}
|
||||
|
||||
// Helper function to calculate Simple Moving Average
|
||||
function calculateSMA(data, channelKey, period) {
|
||||
if (period <= 1 || data.length === 0) return data;
|
||||
|
||||
return data.map((row, i) => {
|
||||
const newRow = { ...row };
|
||||
const values = [];
|
||||
|
||||
// Look back up to 'period' samples
|
||||
for (let j = Math.max(0, i - period + 1); j <= i; j++) {
|
||||
const val = data[j][channelKey];
|
||||
if (val !== null && val !== undefined && !isNaN(val)) {
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average if we have values
|
||||
if (values.length > 0) {
|
||||
newRow[channelKey] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
}
|
||||
|
||||
export default class Chart extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -115,9 +218,14 @@ export default class Chart extends Component {
|
||||
|
||||
// Calculate effective end
|
||||
let end = explicitEnd;
|
||||
// If 'until' is null, extend to next point or now
|
||||
// If 'until' is null, extend to next point or now (but never beyond current time)
|
||||
const nowTime = Date.now();
|
||||
if (!end) {
|
||||
end = nextStart || endTimeVal;
|
||||
end = nextStart || Math.min(endTimeVal, nowTime);
|
||||
}
|
||||
// Never extend data beyond the current time
|
||||
if (end > nowTime) {
|
||||
end = nowTime;
|
||||
}
|
||||
|
||||
// Strict Cutoff: Current interval cannot extend past the start of the next interval
|
||||
@@ -169,7 +277,19 @@ export default class Chart extends Component {
|
||||
return row;
|
||||
});
|
||||
|
||||
this.setState({ data: denseData, loading: false });
|
||||
// 4. Apply SMA for channels that have it configured
|
||||
const { channelConfig } = this.props;
|
||||
let processedData = denseData;
|
||||
|
||||
if (channelConfig) {
|
||||
channelConfig.forEach(cfg => {
|
||||
if (cfg.sma && cfg.sma > 1) {
|
||||
processedData = calculateSMA(processedData, cfg.id, cfg.sma);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({ data: processedData, loading: false });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
@@ -305,6 +425,32 @@ export default class Chart extends Component {
|
||||
const axisEnd = windowEnd ? windowEnd.getTime() : Date.now();
|
||||
const axisStart = axisEnd - rangeMs;
|
||||
|
||||
// Determine if all visible series are Temperature channels
|
||||
const isTemperatureOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
|
||||
const lcId = id.toLowerCase();
|
||||
return lcId.includes('temp') || lcId.includes('temperature');
|
||||
});
|
||||
|
||||
// Determine if all visible series are Humidity channels
|
||||
const isHumidityOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
|
||||
const lcId = id.toLowerCase();
|
||||
return lcId.includes('humid') || lcId.includes('humidity') || lcId.includes('rh');
|
||||
});
|
||||
|
||||
// Determine if all visible series are Light channels
|
||||
const isLightOnly = visibleChannels.length > 0 && visibleChannels.every(id => {
|
||||
const lcId = id.toLowerCase();
|
||||
return lcId.includes('light');
|
||||
});
|
||||
|
||||
// Colors for 6-hour time bands (midnight, 6am, noon, 6pm)
|
||||
const lightBandColors = [
|
||||
'rgba(0, 0, 0, 0.1)', // 00:00-06:00 - black (night)
|
||||
'rgba(135, 206, 250, 0.1)', // 06:00-12:00 - light blue (morning)
|
||||
'rgba(255, 255, 180, 0.1)', // 12:00-18:00 - light yellow (afternoon)
|
||||
'rgba(255, 200, 150, 0.1)', // 18:00-24:00 - light orange (evening)
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', p: 2, boxSizing: 'border-box' }}>
|
||||
<Paper sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
@@ -367,7 +513,20 @@ export default class Chart extends Component {
|
||||
fillOpacity: series.find(s => s.area)?.fillOpacity ?? 0.5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{/* Green reference band for temperature charts (20-25°C) */}
|
||||
{isTemperatureOnly && (
|
||||
<ReferenceArea yMin={20} yMax={25} color="rgba(76, 175, 80, 0.2)" />
|
||||
)}
|
||||
{/* Green reference band for humidity charts (50-70%) */}
|
||||
{isHumidityOnly && (
|
||||
<ReferenceArea yMin={50} yMax={70} color="rgba(76, 175, 80, 0.2)" />
|
||||
)}
|
||||
{/* Time-based vertical bands for light charts (6-hour intervals) */}
|
||||
{isLightOnly && (
|
||||
<TimeReferenceAreas axisStart={axisStart} axisEnd={axisEnd} colors={lightBandColors} />
|
||||
)}
|
||||
</LineChart>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -24,6 +24,14 @@ const RANGES = {
|
||||
'3m': 90 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const SMA_OPTIONS = [
|
||||
{ value: 0, label: 'Off' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 15, label: '15' },
|
||||
];
|
||||
|
||||
const GRUVBOX_COLORS = [
|
||||
'#cc241d', '#fb4934', // Red
|
||||
'#98971a', '#b8bb26', // Green
|
||||
@@ -74,6 +82,7 @@ class ViewManager extends Component {
|
||||
this.refreshViews();
|
||||
this.loadRules();
|
||||
this.loadOutputValues();
|
||||
this.loadRuleStatus(); // Load immediately on mount
|
||||
// Refresh rules and outputs every 30s
|
||||
this.rulesInterval = setInterval(() => {
|
||||
this.loadRules();
|
||||
@@ -174,48 +183,122 @@ class ViewManager extends Component {
|
||||
'CircFanLevel': '🌀',
|
||||
'CO2Valve': '🫧',
|
||||
'BigDehumid': '💧',
|
||||
'TentExhaust': '💨'
|
||||
'TentExhaust': '💨',
|
||||
'RoomExhaust': '🌬️'
|
||||
};
|
||||
return emojis[channel] || '⚡';
|
||||
};
|
||||
|
||||
// Format conditions for display
|
||||
formatRuleConditions = (condition) => {
|
||||
if (!condition) return '(always)';
|
||||
// Format conditions for display - returns React components with visual grouping
|
||||
formatRuleConditions = (condition, depth = 0) => {
|
||||
if (!condition) return <span style={{ color: '#888' }}>(always)</span>;
|
||||
|
||||
if (condition.operator === 'AND' || condition.operator === 'OR') {
|
||||
const parts = (condition.conditions || []).map(c => this.formatRuleConditions(c)).filter(Boolean);
|
||||
if (parts.length === 0) return '(always)';
|
||||
const sep = condition.operator === 'AND' ? ' & ' : ' | ';
|
||||
return parts.join(sep);
|
||||
const parts = (condition.conditions || []).map((c, i) => this.formatRuleConditions(c, depth + 1)).filter(Boolean);
|
||||
if (parts.length === 0) return <span style={{ color: '#888' }}>(always)</span>;
|
||||
|
||||
const isAnd = condition.operator === 'AND';
|
||||
const borderColor = isAnd ? 'rgba(100, 150, 255, 0.5)' : 'rgba(255, 150, 100, 0.5)';
|
||||
const bgColor = isAnd ? 'rgba(100, 150, 255, 0.08)' : 'rgba(255, 150, 100, 0.08)';
|
||||
const label = isAnd ? 'ALL' : 'ANY';
|
||||
const symbol = isAnd ? 'and' : 'or';
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: 1,
|
||||
bgcolor: bgColor,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
fontSize: depth > 0 ? '0.9em' : '1em',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: '0.7em',
|
||||
fontWeight: 'bold',
|
||||
color: isAnd ? '#6496ff' : '#ff9664',
|
||||
mr: 0.5,
|
||||
}}
|
||||
>
|
||||
{label}:
|
||||
</Typography>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{part}
|
||||
{i < parts.length - 1 && (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
mx: 0.5,
|
||||
fontWeight: 'bold',
|
||||
color: isAnd ? '#6496ff' : '#ff9664',
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</Typography>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { type, channel, operator, value } = condition;
|
||||
const opSymbols = { '=': '=', '==': '=', '!=': '≠', '<': '<', '>': '>', '<=': '≤', '>=': '≥', 'between': '↔' };
|
||||
const op = opSymbols[operator] || operator;
|
||||
|
||||
let text = '?';
|
||||
switch (type) {
|
||||
case 'time':
|
||||
if (operator === 'between' && Array.isArray(value)) {
|
||||
return `🕐 ${value[0]} - ${value[1]}`;
|
||||
text = `🕐 ${value[0]} - ${value[1]}`;
|
||||
} else {
|
||||
text = `🕐 ${op} ${value}`;
|
||||
}
|
||||
return `🕐 ${op} ${value}`;
|
||||
break;
|
||||
case 'date':
|
||||
if (operator === 'between' && Array.isArray(value)) {
|
||||
return `📅 ${value[0]} to ${value[1]}`;
|
||||
text = `📅 ${value[0]} to ${value[1]}`;
|
||||
} else {
|
||||
text = `📅 ${operator} ${value}`;
|
||||
}
|
||||
return `📅 ${operator} ${value}`;
|
||||
break;
|
||||
case 'sensor':
|
||||
// Show device:channel for clarity
|
||||
if (value && value.type === 'dynamic') {
|
||||
return `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
|
||||
text = `📡 ${channel} ${op} (${value.channel} * ${value.factor} + ${value.offset})`;
|
||||
} else {
|
||||
text = `📡 ${channel} ${op} ${value}`;
|
||||
}
|
||||
return `📡 ${channel} ${op} ${value}`;
|
||||
break;
|
||||
case 'output':
|
||||
return `⚙️ ${channel} ${op} ${value}`;
|
||||
text = `⚙️ ${channel} ${op} ${value}`;
|
||||
break;
|
||||
default:
|
||||
return '?';
|
||||
text = '?';
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
// Format action for display
|
||||
@@ -225,7 +308,8 @@ class ViewManager extends Component {
|
||||
'CircFanLevel': '🌀 Circ Fan',
|
||||
'CO2Valve': '🫧 CO2',
|
||||
'BigDehumid': '💧 Big Dehumid',
|
||||
'TentExhaust': '💨 Tent Exhaust Fan'
|
||||
'TentExhaust': '💨 Tent Exhaust Fan',
|
||||
'RoomExhaust': '🌬️ Room Exhaust'
|
||||
};
|
||||
const name = channelNames[action.channel] || action.channel;
|
||||
|
||||
@@ -414,6 +498,14 @@ class ViewManager extends Component {
|
||||
this.setState({ viewConfig: newConfig });
|
||||
};
|
||||
|
||||
updateChannel = (idx, updates) => {
|
||||
const newConfig = this.state.viewConfig.map((ch, i) => {
|
||||
if (i === idx) return { ...ch, ...updates };
|
||||
return ch;
|
||||
});
|
||||
this.setState({ viewConfig: newConfig });
|
||||
};
|
||||
|
||||
updateFillOpacity = (idx, value) => {
|
||||
const newConfig = this.state.viewConfig.map((ch, i) => {
|
||||
if (i === idx) {
|
||||
@@ -441,6 +533,45 @@ class ViewManager extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
handleAlignToPeriod = () => {
|
||||
const { rangeLabel } = this.state;
|
||||
const now = new Date();
|
||||
let periodEnd;
|
||||
|
||||
switch (rangeLabel) {
|
||||
case '1d':
|
||||
// Midnight of tomorrow (so range - 24h = midnight today)
|
||||
periodEnd = new Date(now);
|
||||
periodEnd.setDate(periodEnd.getDate() + 1);
|
||||
periodEnd.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case '1w':
|
||||
// Midnight of next Monday (start of next week)
|
||||
periodEnd = new Date(now);
|
||||
const dayOfWeek = periodEnd.getDay(); // 0 = Sunday
|
||||
const daysUntilNextMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
||||
periodEnd.setDate(periodEnd.getDate() + daysUntilNextMonday);
|
||||
periodEnd.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case '1m':
|
||||
// First day of next month (midnight)
|
||||
periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
|
||||
break;
|
||||
case '3m':
|
||||
// First day of next quarter (midnight)
|
||||
const nextQuarterMonth = (Math.floor(now.getMonth() / 3) + 1) * 3;
|
||||
periodEnd = new Date(now.getFullYear(), nextQuarterMonth, 1, 0, 0, 0, 0);
|
||||
break;
|
||||
default:
|
||||
// For 3h or unsupported, don't change
|
||||
return;
|
||||
}
|
||||
// Set window end to the end of the period
|
||||
// This makes the chart show [period_start, period_end]
|
||||
// e.g., for 1d, shows 0:00 to 23:59:59 of today
|
||||
this.setState({ windowEnd: periodEnd });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
views, open, editingId, viewName, availableDevices,
|
||||
@@ -464,14 +595,71 @@ class ViewManager extends Component {
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
||||
<Paper sx={{ position: 'sticky', top: 10, zIndex: 1000, p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between', border: '1px solid #504945' }}>
|
||||
<Paper sx={{
|
||||
position: 'sticky',
|
||||
top: 10,
|
||||
zIndex: 1000,
|
||||
p: 2,
|
||||
mb: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
bgcolor: 'rgba(20, 30, 50, 0.95)',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(25, 118, 210, 0.3)',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ToggleButtonGroup value={rangeLabel} exclusive onChange={this.handleRangeChange} size="small">
|
||||
<ToggleButtonGroup
|
||||
value={rangeLabel}
|
||||
exclusive
|
||||
onChange={this.handleRangeChange}
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiToggleButton-root': {
|
||||
transition: 'all 0.15s ease',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(100, 180, 255, 0.3)',
|
||||
border: '2px solid #64b5f6',
|
||||
boxShadow: '0 0 15px rgba(100, 180, 255, 0.6), inset 0 0 8px rgba(100, 180, 255, 0.2)',
|
||||
transform: 'scale(1.08)',
|
||||
zIndex: 1,
|
||||
color: '#fff',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
bgcolor: '#1976d2',
|
||||
color: 'white',
|
||||
border: '2px solid #42a5f5',
|
||||
'&:hover': {
|
||||
bgcolor: '#1e88e5',
|
||||
boxShadow: '0 0 20px rgba(100, 180, 255, 0.8)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Object.keys(RANGES).map(r => <ToggleButton key={r} value={r}>{r}</ToggleButton>)}
|
||||
</ToggleButtonGroup>
|
||||
<Box>
|
||||
<IconButton onClick={() => this.handleTimeNav(-1)}><ArrowBackIcon /></IconButton>
|
||||
<IconButton onClick={() => this.handleTimeNav(1)} disabled={!windowEnd}><ArrowForwardIcon /></IconButton>
|
||||
{['1d', '1w', '1m', '3m'].includes(rangeLabel) && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={this.handleAlignToPeriod}
|
||||
sx={{
|
||||
ml: 1,
|
||||
minWidth: 'auto',
|
||||
fontSize: '0.75rem',
|
||||
px: 1,
|
||||
}}
|
||||
title={`Align to ${rangeLabel === '1d' ? 'today' : rangeLabel === '1w' ? 'this week' : rangeLabel === '1m' ? 'this month' : 'this quarter'}`}
|
||||
>
|
||||
📅 Align
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="h6">{dateDisplay}</Typography>
|
||||
@@ -508,7 +696,8 @@ class ViewManager extends Component {
|
||||
yAxis: c.yAxis || 'left',
|
||||
color: c.color,
|
||||
fillColor: c.fillColor,
|
||||
fillOpacity: c.fillOpacity
|
||||
fillOpacity: c.fillOpacity,
|
||||
sma: c.sma || 0
|
||||
}))}
|
||||
axisConfig={axes}
|
||||
windowEnd={windowEnd}
|
||||
@@ -563,6 +752,9 @@ class ViewManager extends Component {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Scroll space at end of page */}
|
||||
<Box sx={{ height: 200 }} />
|
||||
|
||||
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{editingId ? 'Edit View' : 'Create New View'}</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -615,11 +807,36 @@ class ViewManager extends Component {
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<ListItemText
|
||||
primary={ch.alias}
|
||||
secondary={`${ch.device}:${ch.channel} (${ch.yAxis})`}
|
||||
sx={{ ml: 1 }}
|
||||
<Select
|
||||
size="small"
|
||||
value={ch.sma || 0}
|
||||
onChange={e => this.updateChannel(idx, { sma: e.target.value })}
|
||||
sx={{ width: 70, ml: 1 }}
|
||||
title="Simple Moving Average"
|
||||
>
|
||||
{SMA_OPTIONS.map(opt => (
|
||||
<MenuItem key={opt.value} value={opt.value}>SMA: {opt.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<TextField
|
||||
size="small"
|
||||
value={ch.alias}
|
||||
onChange={e => this.updateChannel(idx, { alias: e.target.value })}
|
||||
sx={{ ml: 1, flex: 1, minWidth: 100 }}
|
||||
placeholder="Alias"
|
||||
/>
|
||||
<Typography variant="caption" sx={{ ml: 1, color: 'text.secondary', whiteSpace: 'nowrap' }}>
|
||||
{ch.device}:{ch.channel}
|
||||
</Typography>
|
||||
<Select
|
||||
size="small"
|
||||
value={ch.yAxis || 'left'}
|
||||
onChange={e => this.updateChannel(idx, { yAxis: e.target.value })}
|
||||
sx={{ width: 75, ml: 1 }}
|
||||
>
|
||||
<MenuItem value="left">Left</MenuItem>
|
||||
<MenuItem value="right">Right</MenuItem>
|
||||
</Select>
|
||||
<IconButton size="small" onClick={() => this.moveChannel(idx, -1)} disabled={idx === 0}><ArrowUpwardIcon /></IconButton>
|
||||
<IconButton size="small" onClick={() => this.moveChannel(idx, 1)} disabled={idx === viewConfig.length - 1}><ArrowDownwardIcon /></IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => this.removeChannel(idx)}><DeleteIcon /></IconButton>
|
||||
|
||||
Reference in New Issue
Block a user