feat: 산점도 그래프 생성 기능 추가

hhsung_work
HyungJune Kim 10 months ago
parent e4f72f965f
commit fecca36925

@ -315,25 +315,27 @@
</StackPanel> </StackPanel>
<!-- SCATTER: 단일 Y + 옵션 --> <!-- SCATTER: 단일 Y + 옵션 -->
<Grid Style="{StaticResource VisibleWhenScatter}"> <StackPanel Style="{StaticResource VisibleWhenScatter}">
<Grid.RowDefinitions> <Grid>
<RowDefinition Height="70"/>
<RowDefinition Height="70"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/>
<ColumnDefinition/> <ColumnDefinition/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Text="Y축" VerticalAlignment="Center" <TextBlock Text="Y축" VerticalAlignment="Center" Width="80"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/> FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<ComboBox ItemsSource="{Binding YFieldCandidates}" <ComboBox ItemsSource="{Binding YFieldCandidates}"
SelectedItem="{Binding SelectedYField}" SelectedItem="{Binding SelectedYField}"
DisplayMemberPath="Display" Height="50" DisplayMemberPath="Display" Height="40"
Grid.Column="1" Grid.ColumnSpan="3" Margin="15 0 0 0" Grid.Column="1" Grid.ColumnSpan="3" Margin="15 0 0 0"
Style="{StaticResource ComboBoxStyle}"/> Style="{StaticResource ComboBoxStyle}"/>
</Grid>
<Grid Margin="0 10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="마커 크기" Grid.Row="1" VerticalAlignment="Center" <TextBlock Text="마커 크기" Grid.Row="1" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/> FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<Slider Grid.Row="1" Grid.Column="1" VerticalAlignment="Center" Margin="15 0 0 0" <Slider Grid.Row="1" Grid.Column="1" VerticalAlignment="Center" Margin="15 0 0 0"
@ -341,30 +343,18 @@
<TextBlock Text="{Binding ScatterMarkerSize}" Margin="15 0" <TextBlock Text="{Binding ScatterMarkerSize}" Margin="15 0"
Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" Grid.Row="1" Grid.Column="2" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/> FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
</Grid>
<CheckBox Content="회귀선" IsChecked="{Binding ShowRegression}" <CheckBox Content="회귀선" IsChecked="{Binding ShowRegression}"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White" FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"
Margin="0" Grid.Row="1" Grid.Column="3" Margin="0" Grid.Row="1" Grid.Column="3"
VerticalContentAlignment="Center" VerticalContentAlignment="Center"
Style="{StaticResource MaterialDesignUserForegroundCheckBox}"/> Style="{StaticResource MaterialDesignUserForegroundCheckBox}"/>
</Grid> </StackPanel>
<!-- BOX: 값 필드 + 그룹 필드 + 옵션 --> <!-- BOX: 값 필드 + 그룹 필드 + 옵션 -->
<StackPanel Style="{StaticResource VisibleWhenBox}"> <StackPanel Style="{StaticResource VisibleWhenBox}">
<Grid> <Grid Style="{StaticResource VisibleWhenTime}" Margin="0 10 0 15">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock Text="값 필드" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<ComboBox ItemsSource="{Binding YFieldCandidates}"
SelectedItem="{Binding SelectedYField}"
DisplayMemberPath="Display" Height="40"
Grid.Column="1" Margin="15 0 0 0"
Style="{StaticResource ComboBoxStyle}"/>
</Grid>
<Grid Grid.Row="1" Style="{StaticResource VisibleWhenTime}" Margin="0 15">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/>
<ColumnDefinition/> <ColumnDefinition/>
@ -382,7 +372,21 @@
FontSize="18" FontFamily="{StaticResource SCDream4}" Foreground="White"/> FontSize="18" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
</Grid> </Grid>
<Grid Grid.Row="2"> <Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock Text="값 필드" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<ComboBox ItemsSource="{Binding YFieldCandidates}"
SelectedItem="{Binding SelectedYField}"
DisplayMemberPath="Display" Height="40"
Grid.Column="1" Margin="15 0 0 0"
Style="{StaticResource ComboBoxStyle}"/>
</Grid>
<Grid Margin="0 15 0 0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/> <ColumnDefinition Width="80"/>
<ColumnDefinition/> <ColumnDefinition/>

@ -70,7 +70,7 @@ namespace SmartAquaViewer.ViewModel
foreach (var r in data.OrderBy(r => r.RecordedTime)) foreach (var r in data.OrderBy(r => r.RecordedTime))
{ {
var y = ResolveTankY(r, yField.Name!); var y = ResolveTank(r, yField.Name!);
if (!y.HasValue) continue; if (!y.HasValue) continue;
series.Points.Add(new DataPoint( series.Points.Add(new DataPoint(
@ -128,9 +128,9 @@ namespace SmartAquaViewer.ViewModel
{ {
double? y = null; double? y = null;
if (selectedTab.Equals(MonitorTab.Filter)) if (selectedTab.Equals(MonitorTab.Filter))
y = ResolveFilterY(r, yField.Name!); y = ResolveFilter(r, yField.Name!);
else if (selectedTab.Equals(MonitorTab.Sterilizer)) else if (selectedTab.Equals(MonitorTab.Sterilizer))
y = ResolveSterilizerY(r, yField.Name!); y = ResolveSterilizer(r, yField.Name!);
if (!y.HasValue) continue; if (!y.HasValue) continue;
@ -191,14 +191,14 @@ namespace SmartAquaViewer.ViewModel
var col = rows var col = rows
.OrderBy(r => r.RecordedTime) .OrderBy(r => r.RecordedTime)
.GroupBy(g => ResolveTankX(g, xAxisKind.Name, bucket)) .GroupBy(g => ResolveTankOrTime(g, xAxisKind.Name, bucket))
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
foreach(var (key, data) in col.OrderBy(x => x.Key)) foreach(var (key, data) in col.OrderBy(x => x.Key))
{ {
// 1) 수조/시간별 값 리스트 // 1) 수조/시간별 값 리스트
var values = data var values = data
.Select(d => ResolveTankY(d, valueField.Name!)) .Select(d => ResolveTank(d, valueField.Name!))
.OfType<double>() // object→double .OfType<double>() // object→double
.Where(v => !double.IsNaN(v) && !double.IsInfinity(v)) .Where(v => !double.IsNaN(v) && !double.IsInfinity(v))
.OrderBy(v => v) .OrderBy(v => v)
@ -234,71 +234,99 @@ namespace SmartAquaViewer.ViewModel
series.Items.Add(item); series.Items.Add(item);
} }
//// [X] = 값 리스트 (그룹 없음) Model.Series.Add(series);
//var byX = new Dictionary<string, List<double>>(); Model.IsLegendVisible = false;
Model.InvalidatePlot(true);
//// byGroup[gKey][xKey] = 값 리스트 }
//foreach (var r in rows) public void SetScatterPlot(
//{ List<WaterQualityVO> rows,
// var val = (double)ResolveTankY(r, valueField.Name); FieldItem xAxisField,
// if (double.IsNaN(val) || double.IsInfinity(val)) continue; FieldItem yAxisField,
double markerSize = 3,
bool showRegression = false)
{
Model.Series.Clear();
Model.Axes.Clear();
// string xKey = ResolveTankX(r, xAxisKind.Name, bucket); var xAxis = new LinearAxis
// if (!byX.TryGetValue(xKey, out var list)) {
// byX[xKey] = list = new List<double>(); Position = AxisPosition.Bottom,
Title = xAxisField.Display,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot
};
var yAxis = new LinearAxis
{
Position = AxisPosition.Left,
Title = yAxisField.Display,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot
};
// list.Add(val); Model.Axes.Add(xAxis);
//} Model.Axes.Add(yAxis);
//// X축 라벨(정렬) + 너무 많으면 최근 N개만 var scatterSeries = new ScatterSeries
//var xKeys = byX.Keys.OrderBy(k => k, new KeyComparer()).ToList(); {
//if (xKeys.Count == 0) { Model.InvalidatePlot(true); return; } MarkerType = MarkerType.Circle,
//if (xKeys.Count > maxCategories) MarkerSize = markerSize,
// xKeys = xKeys.Skip(xKeys.Count - maxCategories).ToList(); MarkerStroke = OxyColors.Black,
MarkerFill = OxyColors.DeepSkyBlue
};
//xAxis.Labels.Clear(); foreach (var row in rows)
//xAxis.Labels.AddRange(xKeys); {
double x = ResolveTank(row, xAxisField.Name) ?? double.NaN;
double y = ResolveTank(row, yAxisField.Name) ?? double.NaN;
scatterSeries.Points.Add(new ScatterPoint(x, y));
}
//var series = new BoxPlotSeries { BoxWidth = boxWidth }; Model.Series.Add(scatterSeries);
//for (int xi = 0; xi < xKeys.Count; xi++) if (showRegression && rows.Count > 1)
//{ {
// var list = byX[xKeys[xi]]; var points = rows.Select(r
// if (list == null || list.Count == 0) continue; => new DataPoint(
ResolveTank(r, xAxisField.Name) ?? double.NaN,
ResolveTank(r, yAxisField.Name) ?? double.NaN))
.ToList();
var regression = LinearRegression(points);
// list.Sort(); if (regression != null)
{
var lineSeries = new LineSeries
{
Title = "Regression",
Color = OxyColors.Red,
StrokeThickness = 2
};
// // 표본 1개도 표시(퇴화 박스) // 최소/최대 구간으로 선 그리기
// if (list.Count == 1) double minX = points.Min(p => p.X);
// { double maxX = points.Max(p => p.X);
// double v = list[0]; lineSeries.Points.Add(new DataPoint(minX, regression.Value.Intercept + regression.Value.Slope * minX));
// series.Items.Add(new BoxPlotItem(xi, v, v, v, v, v)); lineSeries.Points.Add(new DataPoint(maxX, regression.Value.Intercept + regression.Value.Slope * maxX));
// continue;
// }
// var (lw, q1, med, q3, uw, outs) = Summarize(list); Model.Series.Add(lineSeries);
// var item = new BoxPlotItem(xi, lw, q1, med, q3, uw); }
// foreach (var o in outs) item.Outliers.Add(o); }
// series.Items.Add(item);
//}
Model.Series.Add(series);
Model.IsLegendVisible = false;
Model.InvalidatePlot(true); Model.InvalidatePlot(true);
} }
private static DateTime FloorToBucket(DateTime dt, TimeSpan bucket) private DateTime FloorToBucket(DateTime dt, TimeSpan bucket)
{ {
long ticks = bucket.Ticks; long ticks = bucket.Ticks;
long floored = dt.Ticks - (dt.Ticks % ticks); long floored = dt.Ticks - (dt.Ticks % ticks);
return new DateTime(floored, dt.Kind); return new DateTime(floored, dt.Kind);
} }
private double? ResolveTankY(WaterQualityVO vo, string fieldName) private double? ResolveTank(WaterQualityVO vo, string fieldName)
{ {
return fieldName switch return fieldName switch
{ {
"RecordedTime" => DateTimeAxis.ToDouble(vo.RecordedTime),
"Tank.DOValue" => vo.Tank.DOValue, "Tank.DOValue" => vo.Tank.DOValue,
"Tank.PH" => vo.Tank.PH, "Tank.PH" => vo.Tank.PH,
"Tank.ORP" => vo.Tank.ORP, "Tank.ORP" => vo.Tank.ORP,
@ -308,7 +336,7 @@ namespace SmartAquaViewer.ViewModel
}; };
} }
private double? ResolveFilterY(WaterQualityVO vo, string fieldName) private double? ResolveFilter(WaterQualityVO vo, string fieldName)
{ {
return fieldName switch return fieldName switch
{ {
@ -323,7 +351,7 @@ namespace SmartAquaViewer.ViewModel
}; };
} }
private double? ResolveSterilizerY(WaterQualityVO vo, string fieldName) private double? ResolveSterilizer(WaterQualityVO vo, string fieldName)
{ {
return fieldName switch return fieldName switch
{ {
@ -332,7 +360,7 @@ namespace SmartAquaViewer.ViewModel
}; };
} }
private string ResolveTankX(WaterQualityVO vo, string fieldName, TimeSpan? bucket = null) private string ResolveTankOrTime(WaterQualityVO vo, string fieldName, TimeSpan? bucket = null)
{ {
return fieldName switch return fieldName switch
{ {
@ -390,6 +418,26 @@ namespace SmartAquaViewer.ViewModel
return (lw, q1, med, q3, uw, outs); return (lw, q1, med, q3, uw, outs);
} }
// 단순 선형회귀 계산
private (double Slope, double Intercept)? LinearRegression(List<DataPoint> points)
{
int n = points.Count;
if (n < 2) return null;
double avgX = points.Average(p => p.X);
double avgY = points.Average(p => p.Y);
double numerator = points.Sum(p => (p.X - avgX) * (p.Y - avgY));
double denominator = points.Sum(p => Math.Pow(p.X - avgX, 2));
if (denominator == 0) return null;
double slope = numerator / denominator;
double intercept = avgY - slope * avgX;
return (slope, intercept);
}
private class KeyComparer : IComparer<string> private class KeyComparer : IComparer<string>
{ {
public int Compare(string? a, string? b) public int Compare(string? a, string? b)

@ -255,7 +255,7 @@ namespace SmartAquaViewer.ViewModel
var xField = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; var xField = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null;
var yField = SelectedYField; var yField = SelectedYField;
var isMarker = ShowMarkers; var isMarker = ShowMarkers;
if (SelectedTab.Equals(MonitorTab.Tank)) SetGraphData_Line_Tank(SelectedWaterTanks, xField, yField, isMarker); if (SelectedTab.Equals(MonitorTab.Tank)) SetGraphData_Line_Tank(xField, yField, isMarker);
else GraphControlVM.SetDefaultLineGraph(WaterQualityList, SelectedTab, xField, yField, isMarker); else GraphControlVM.SetDefaultLineGraph(WaterQualityList, SelectedTab, xField, yField, isMarker);
break; break;
case GraphType.BOX: case GraphType.BOX:
@ -266,25 +266,24 @@ namespace SmartAquaViewer.ViewModel
GraphControlVM.SetBoxPlot(WaterQualityList, xFieldBox, dataFieldBox, boxWidth, boxTimeSpan); GraphControlVM.SetBoxPlot(WaterQualityList, xFieldBox, dataFieldBox, boxWidth, boxTimeSpan);
break; break;
case GraphType.SCATTER: case GraphType.SCATTER:
var xFieldScatter = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null; var xFieldScatter = SelectedXField;
var yFiledScatter = SelectedYField; var yFiledScatter = SelectedYField;
var markerSIze = ScatterMarkerSize; var markerSIze = ScatterMarkerSize;
var showRegression = ShowRegression; var showRegression = ShowRegression;
GraphControlVM.SetScatterPlot(WaterQualityList, xFieldScatter, yFiledScatter, markerSIze, showRegression);
break; break;
default: default:
break; break;
} }
} }
private void SetGraphData_Line_Tank(object data, FieldItem? xField, FieldItem? yField, bool showMarkers) private void SetGraphData_Line_Tank(FieldItem? xField, FieldItem? yField, bool showMarkers)
{ {
if (SelectedTab != MonitorTab.Tank) return; if (SelectedTab != MonitorTab.Tank) return;
if (xField?.Name != "RecordedTime" || yField == null) return; if (xField?.Name != "RecordedTime" || yField == null) return;
if (SelectedWaterTanks.Count == 0) return; if (SelectedWaterTanks.Count == 0) return;
var collection = data as Dictionary<int, ObservableCollection<WaterQualityVO>>; GraphControlVM.SetTankLineGraph(SelectedWaterTanks, xField, yField, showMarkers);
GraphControlVM.SetTankLineGraph(collection, xField, yField, showMarkers);
} }
private void SetGraphType() private void SetGraphType()
@ -357,6 +356,7 @@ namespace SmartAquaViewer.ViewModel
// X축: 시간 우선 // X축: 시간 우선
foreach (var f in AvailableFields) foreach (var f in AvailableFields)
{ {
if (SelectedGraphType == GraphType.SCATTER && f.Name.Equals("Tank.Number")) continue;
XFieldCandidates.Add(f); XFieldCandidates.Add(f);
if (SelectedGraphType == GraphType.LINE || SelectedGraphType == GraphType.STEP) break; if (SelectedGraphType == GraphType.LINE || SelectedGraphType == GraphType.STEP) break;
if (SelectedGraphType == GraphType.BOX && f.Name.Equals("Tank.Number")) break; if (SelectedGraphType == GraphType.BOX && f.Name.Equals("Tank.Number")) break;

Loading…
Cancel
Save