feat: 수조 라인차트 생성 기능 추가

hhsung_work
HyungJune Kim 10 months ago
parent 630570e506
commit 4cbcc658b8

@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static SmartAquaViewer.Model.Enums;
namespace SmartAquaViewer.Classes
{
public interface IChartSettings
{
GraphType Kind { get; }
}
public sealed class AvailableFields
{
public IReadOnlyList<string> Numeric { get; init; } = new List<string>();
public IReadOnlyList<string> Categorical { get; init; } = new List<string>();
}
public class LineSettings : IChartSettings
{
public GraphType Kind => GraphType.LINE;
[Required] public string XField { get; set; } = ""; // 보통 시간
[MinLength(1)] public List<string> YFields { get; set; } = new();
public bool ShowLegend { get; set; } = true;
}
public class BoxPlotSettings : IChartSettings
{
public GraphType Kind => GraphType.BOX;
[Required] public string GroupField { get; set; } = ""; // 수조/장비 등 카테고리
[Required] public string ValueField { get; set; } = ""; // DO, pH, 압력 등 숫자
public bool ShowOutliers { get; set; } = true;
}
public class ScatterSettings : IChartSettings
{
public GraphType Kind => GraphType.SCATTER;
[Required] public string XField { get; set; } = ""; // 숫자
[Required] public string YField { get; set; } = ""; // 숫자
public string? ColorBy { get; set; } // 카테고리(수조/장비/상태)
public string? SizeBy { get; set; } // 숫자(버블 크기)
}
public class StepSettings : IChartSettings
{
public GraphType Kind => GraphType.STEP;
[Required] public string XField { get; set; } = ""; // 보통 시간
// 장비별 상태 필드 매핑
public ObservableCollection<StateSeriesMap> Series { get; } = new();
public double OnValue { get; set; } = 1;
public double OffValue { get; set; } = 0;
}
public class StateSeriesMap
{
public string SeriesName { get; set; } = ""; // 예: "오존용해장치"
public string Field { get; set; } = ""; // 예: "오존용해장치(상태)"
public string OnToken { get; set; } = "ON";
public string OffToken { get; set; } = "OFF";
}
}

@ -4,46 +4,13 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:SmartAquaViewer.Controls"
xmlns:oxy="http://oxyplot.org/wpf"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="1080">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="550"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Margin="20"
Background="#384659" BorderBrush="#404F63" BorderThickness="1" CornerRadius="10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Text="그래프" FontSize="24" FontFamily="{StaticResource SCDream4}" Foreground="White"
Margin="15 15 15 5"/>
<TextBlock Grid.Row="2" Text="X축" FontSize="24" FontFamily="{StaticResource SCDream4}" Foreground="White"
Margin="15 15 15 5"/>
<TextBlock Grid.Row="4" Text="Y축" FontSize="24" FontFamily="{StaticResource SCDream4}" Foreground="White"
Margin="15 15 15 5"/>
<ComboBox Grid.Row="1" Margin="15 0" Height="50"
Style="{StaticResource ComboBoxStyle}"
ItemsSource="{Binding GraphTypes}"
SelectedItem="{Binding SelectedGraphType}"/>
<ComboBox Grid.Row="3" Margin="15 0" Height="50"
Style="{StaticResource ComboBoxStyle}"></ComboBox>
<ListBox Grid.Row="5" Margin="15 0" Height="50" SelectionMode="Extended"/>
</Grid>
</Border>
<Border Grid.Column="1" Margin="0 20 20 20"
Background="#384659" BorderBrush="#404F63" BorderThickness="1" CornerRadius="10">
</Border>
</Grid>
<Border>
<Grid>
<oxy:PlotView Name="pvChart" Model="{Binding Model}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Padding="0"/>
</Grid>
</Border>
</UserControl>

@ -6,6 +6,7 @@ using System.Text;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows;
using System.ComponentModel;
namespace SmartAquaViewer.Helper
{
@ -38,11 +39,112 @@ namespace SmartAquaViewer.Helper
private static void Lb_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (sender is not ListBox lb) return;
var list = GetSelectedItems(lb);
if (list == null) return;
if (list != null)
{
foreach (var removed in e.RemovedItems) list.Remove(removed);
foreach (var added in e.AddedItems) list.Add(added);
}
// Dictionary 동기화도 같이 수행
var dict = GetSelectedDictionary(lb);
if (dict != null)
{
string? keyPath = GetKeyPath(lb);
string? valuePath = GetValuePath(lb);
foreach (var removed in e.RemovedItems)
{
var key = ResolvePath(removed, keyPath);
if (key != null && dict.Contains(key)) dict.Remove(key);
}
foreach (var added in e.AddedItems)
{
var key = ResolvePath(added, keyPath);
var value = ResolvePath(added, valuePath);
if (key != null) dict[key] = value;
}
}
}
// ===== IDictionary 버전 =====
public static readonly DependencyProperty SelectedDictionaryProperty =
DependencyProperty.RegisterAttached(
"SelectedDictionary", typeof(IDictionary), typeof(MultiSelectBehavior),
new PropertyMetadata(null, OnSelectedDictionaryChanged));
public static void SetSelectedDictionary(DependencyObject element, IDictionary? value)
=> element.SetValue(SelectedDictionaryProperty, value);
public static IDictionary? GetSelectedDictionary(DependencyObject element)
=> (IDictionary?)element.GetValue(SelectedDictionaryProperty);
// 선택된 항목에서 key/value를 뽑아낼 경로 (예: "Id", "Name", "User.Profile.Id")
public static readonly DependencyProperty KeyPathProperty =
DependencyProperty.RegisterAttached(
"KeyPath", typeof(string), typeof(MultiSelectBehavior), new PropertyMetadata(null));
public static void SetKeyPath(DependencyObject element, string? value)
=> element.SetValue(KeyPathProperty, value);
public static string? GetKeyPath(DependencyObject element)
=> (string?)element.GetValue(KeyPathProperty);
foreach (var removed in e.RemovedItems) list.Remove(removed);
foreach (var added in e.AddedItems) list.Add(added);
// 값 경로. 비우거나 "." 이면 아이템 자체를 값으로 사용
public static readonly DependencyProperty ValuePathProperty =
DependencyProperty.RegisterAttached(
"ValuePath", typeof(string), typeof(MultiSelectBehavior), new PropertyMetadata("."));
public static void SetValuePath(DependencyObject element, string? value)
=> element.SetValue(ValuePathProperty, value);
public static string? GetValuePath(DependencyObject element)
=> (string?)element.GetValue(ValuePathProperty);
private static void OnSelectedDictionaryChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ListBox lb) return;
lb.SelectionChanged -= Lb_SelectionChanged;
lb.SelectionChanged += Lb_SelectionChanged;
if (e.NewValue is IDictionary dict)
{
dict.Clear();
string? keyPath = GetKeyPath(lb);
string? valuePath = GetValuePath(lb);
foreach (var item in lb.SelectedItems)
{
var key = ResolvePath(item, keyPath);
var value = ResolvePath(item, valuePath);
if (key != null) dict[key] = value;
}
}
}
// ===== 유틸 =====
private static object? ResolvePath(object? instance, string? path)
{
if (instance == null) return null;
if (string.IsNullOrWhiteSpace(path) || path == ".") return instance;
object? current = instance;
foreach (var segment in path.Split('.'))
{
if (current == null) return null;
// IDictionary인 중간 단계도 지원 (예: dict["Key"])
if (current is IDictionary dict)
{
if (dict.Contains(segment)) { current = dict[segment]; continue; }
return null;
}
var pd = TypeDescriptor.GetProperties(current)[segment];
if (pd == null) return null;
current = pd.GetValue(current);
}
return current;
}
}
}

@ -80,7 +80,9 @@
<Resource Include="Resources\Images\tab_bg_off.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
<Resource Include="Resources\Images\top_bg.png" />
<Resource Include="Resources\Images\top_bg.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
</Project>

@ -164,8 +164,8 @@
<RowDefinition/>
<RowDefinition Height="50"/>
</Grid.RowDefinitions>
<StackPanel DockPanel.Dock="Top">
<Grid Margin="15">
<StackPanel>
<Grid Margin="15 15 15 10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition/>
@ -173,7 +173,7 @@
<TextBlock Text="그래프" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<ComboBox Margin="15 0 0 0" Height="50" Grid.Column="1"
<ComboBox Margin="15 0 0 0" Height="40" Grid.Column="1"
Style="{StaticResource ComboBoxStyle}"
ItemsSource="{Binding GraphTypes}"
SelectedIndex="{Binding SelectedGraphIndex, Mode=TwoWay}"
@ -187,14 +187,14 @@
</Grid.ColumnDefinitions>
<TextBlock Text="X축" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<ComboBox Margin="15 0 0 0" Height="50" Grid.Column="1"
<ComboBox Margin="15 0 0 0" Height="40" Grid.Column="1"
Style="{StaticResource ComboBoxStyle}"
ItemsSource="{Binding XFieldCandidates}"
SelectedItem="{Binding SelectedXField}"
DisplayMemberPath="Display"/>
</Grid>
<Grid Margin="15 5">
<Grid Margin="15 10 15 0">
<Grid.Resources>
<Style TargetType="FrameworkElement">
<Setter Property="Visibility" Value="Collapsed"/>
@ -210,6 +210,27 @@
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="VisibleWhenTankNLine" TargetType="FrameworkElement" BasedOn="{StaticResource {x:Type FrameworkElement}}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsTankAndLine}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="VisibleWhenLine" TargetType="FrameworkElement" BasedOn="{StaticResource {x:Type FrameworkElement}}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedGraphType}" Value="LINE">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="VisibleWhenStep" TargetType="FrameworkElement" BasedOn="{StaticResource {x:Type FrameworkElement}}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedGraphType}" Value="STEP">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="VisibleWhenScatter" TargetType="FrameworkElement" BasedOn="{StaticResource {x:Type FrameworkElement}}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedGraphType}" Value="SCATTER">
@ -227,36 +248,64 @@
</Grid.Resources>
<!-- LINE/STEP: 다중 선택 -->
<Grid Style="{StaticResource VisibleWhenTrue}">
<Grid.RowDefinitions>
<RowDefinition Height="40"/>
<RowDefinition/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="Y축 (복수 선택)" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<!-- SelectedItems 바인딩을 위한 간단 Behavior는 아래 3) 참고 -->
<Border Grid.Row="1" CornerRadius="10" Background="White">
<ListBox ItemsSource="{Binding YFieldCandidates}"
DisplayMemberPath="Display"
SelectionMode="Extended"
helper:MultiSelectBehavior.SelectedItems="{Binding SelectedYFields, Mode=OneWay}"
Height="Auto" Background="White"
FontSize="16" FontWeight="Bold"
Style="{StaticResource MaterialDesignFilterChipListBox}"/>
</Border>
<StackPanel Style="{StaticResource VisibleWhenTrue}">
<StackPanel Style="{StaticResource VisibleWhenTankNLine}">
<TextBlock Text="수조 (복수 선택)" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<Border CornerRadius="10" Background="White" Margin="0 5 0 10">
<ListBox ItemsSource="{Binding TankGroups}"
DisplayMemberPath="Key"
SelectionMode="Extended"
helper:MultiSelectBehavior.SelectedDictionary="{Binding SelectedWaterTanks, Mode=OneWay}"
helper:MultiSelectBehavior.KeyPath="Key"
helper:MultiSelectBehavior.ValuePath="Value"
Height="Auto" Background="White"
FontSize="16" FontWeight="Bold"
Style="{StaticResource MaterialDesignFilterChipListBox}"/>
</Border>
</StackPanel>
<Grid Style="{StaticResource VisibleWhenLine}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock Text="Y축" VerticalAlignment="Center" Style="{StaticResource VisibleWhenLine}"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<ComboBox ItemsSource="{Binding YFieldCandidates}"
SelectedItem="{Binding SelectedYField, Mode=TwoWay}"
Grid.Column="1"
DisplayMemberPath="Display" Margin="15 0 0 0"
Height="40" Style="{StaticResource ComboBoxStyle}"/>
</Grid>
<StackPanel Style="{StaticResource VisibleWhenStep}">
<TextBlock Text="Y축 (복수 선택)" VerticalAlignment="Center"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"/>
<!-- SelectedItems 바인딩을 위한 간단 Behavior는 아래 3) 참고 -->
<Border CornerRadius="10" Background="White" Margin="0 5">
<ListBox ItemsSource="{Binding YFieldCandidates}"
DisplayMemberPath="Display"
SelectionMode="Extended"
helper:MultiSelectBehavior.SelectedItems="{Binding SelectedYFields, Mode=OneWay}"
Height="Auto" Background="White"
FontSize="16" FontWeight="Bold"
Style="{StaticResource MaterialDesignFilterChipListBox}"/>
</Border>
</StackPanel>
<!-- 옵션 -->
<StackPanel Orientation="Horizontal" Margin="0 15 0 0" Grid.Row="2">
<StackPanel Orientation="Horizontal" Margin="0 15 0 0" Grid.Row="4">
<CheckBox Content="마커 표시" IsChecked="{Binding ShowMarkers}" Margin="0 0 15 0"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"
VerticalContentAlignment="Center"
Style="{StaticResource MaterialDesignUserForegroundCheckBox}"/>
<CheckBox Content="스무딩" IsChecked="{Binding UseSmoothing}"
<!--<CheckBox Content="스무딩" IsChecked="{Binding UseSmoothing}"
FontSize="20" FontFamily="{StaticResource SCDream4}" Foreground="White"
VerticalContentAlignment="Center"
Style="{StaticResource MaterialDesignUserForegroundCheckBox}"/>
Style="{StaticResource MaterialDesignUserForegroundCheckBox}"/>-->
</StackPanel>
</Grid>
</StackPanel>
<!-- SCATTER: 단일 Y + 옵션 -->
<Grid Style="{StaticResource VisibleWhenScatter}">
@ -347,7 +396,9 @@
<Border Grid.Column="1" Margin="0 20 20 20"
Background="#384659" BorderBrush="#404F63" BorderThickness="1" CornerRadius="10">
<control:GraphControl x:Name="graphControl"
Margin="10" DataContext="{Binding GraphControlVM}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
</Border>
</Grid>
</Border>

@ -1,12 +1,102 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using OxyPlot;
using OxyPlot.Axes;
using OxyPlot.Series;
using SmartAquaViewer.DataAnalysis;
namespace SmartAquaViewer.ViewModel
{
class GraphControlViewModel
public class GraphControlViewModel
{
public PlotModel Model { get; }
public GraphControlViewModel()
{
Model = new PlotModel();
InitializeGraph();
}
private void InitializeGraph()
{
Model.TextColor = OxyColors.Black;
}
public void SetTankLineGraph(Dictionary<int, ObservableCollection<WaterQualityVO>> collection,
FieldItem? xField, FieldItem? yField,
bool isMarker)
{
Model.Series.Clear();
Model.Axes.Clear();
var xAxis = new DateTimeAxis
{
Position = AxisPosition.Bottom,
Title = "시간",
StringFormat = "HH:mm:ss",
IntervalType = DateTimeIntervalType.Minutes,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot
};
var yAxis = new LinearAxis
{
Position = AxisPosition.Left,
Title = yField.Display,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot
};
Model.Axes.Add(xAxis);
Model.Axes.Add(yAxis);
foreach (var (tankNum, data) in collection.OrderBy(x => x.Key))
{
if (data == null || data.Count == 0) continue;
var series = new LineSeries()
{
Title = tankNum.ToString(),
MarkerType = isMarker ? MarkerType.Circle : MarkerType.None,
MarkerSize = isMarker ? 3 : 0
};
foreach (var r in data.OrderBy(r => r.RecordedTime))
{
var y = ResolveTankY(r, yField.Name!);
if (!y.HasValue) continue;
series.Points.Add(new DataPoint(
DateTimeAxis.ToDouble(r.RecordedTime),
y.Value));
}
if (series.Points.Count > 0)
{
// 트래커 포맷: 시간, 수조, 지표, 값
series.TrackerFormatString =
$"수조 {tankNum}\n시간: {{2:HH:mm}}\n{yField.Display}: {{4:0.###}}";
Model.Series.Add(series);
}
}
Model.InvalidatePlot(true);
}
private static double? ResolveTankY(WaterQualityVO vo, string fieldName)
{
return fieldName switch
{
"Tank.DOValue" => vo.Tank.DOValue,
"Tank.PH" => vo.Tank.PH,
"Tank.ORP" => vo.Tank.ORP,
"Tank.Temperature" => vo.Tank.Temperature,
"Tank.FlowRate" => vo.Tank.FlowRate,
_ => null
};
}
}
}

@ -27,6 +27,8 @@ namespace SmartAquaViewer.ViewModel
public class MonitoringViewModel : INotifyPropertyChanged
{
#region Properties
public GraphControlViewModel GraphControlVM { get; } = new GraphControlViewModel();
public ObservableCollection<GraphType> GraphTypes { get; }
public List<WaterQualityVO> WaterQualityList { get; }
@ -45,6 +47,7 @@ namespace SmartAquaViewer.ViewModel
RebuildAvailableFields(); // 탭에 맞춰 필드 목록 재구성
SetGraphType();
OnPropertyChanged(nameof(IsTankAndLine));
Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
@ -91,11 +94,19 @@ namespace SmartAquaViewer.ViewModel
{
_selectedGraphIndex = idx;
OnPropertyChanged(nameof(SelectedGraphIndex));
OnPropertyChanged(nameof(IsTankAndLine));
}
}
}
}
public bool IsTankAndLine
{
get => SelectedTab.Equals(MonitorTab.Tank) && SelectedGraphType.Equals(GraphType.LINE);
}
public Dictionary<int, ObservableCollection<WaterQualityVO>> SelectedWaterTanks { get; } = new();
private bool _isOpenMode;
public bool IsOpenMode
{
@ -233,13 +244,17 @@ namespace SmartAquaViewer.ViewModel
private void DrawGraph(object obj)
{
var data = SetGraphData();
switch (SelectedGraphType)
{
case GraphType.LINE:
var xFieldLine = SelectedXField?.Name == "RecordedTime" ? SelectedXField : null;
var yFields = SelectedYFields.Count > 0 ? SelectedYFields : new ObservableCollection<FieldItem> { SelectedYField };
var yField = SelectedYField;
var isMarker = ShowMarkers;
var isSmoothing = UseSmoothing;
GraphControlVM.SetTankLineGraph(
TankGroups, xFieldLine, yField, isMarker
);
break;
case GraphType.BOX:
var xFieldBox = SelectedXField;
@ -258,6 +273,43 @@ namespace SmartAquaViewer.ViewModel
}
}
private void SetGraphData_Line_Tank(FieldItem? xField, FieldItem? yField, bool showMarkers, bool useSmoothing)
{
if (SelectedTab != MonitorTab.Tank) return;
if (xField?.Name != "RecordedTime" || yField == null) return;
if (SelectedWaterTanks.Count == 0) return;
}
private object SetGraphData()
{
object data = null;
switch (SelectedTab)
{
case MonitorTab.Tank:
data = SelectedWaterTanks;
break;
case MonitorTab.Filter:
data = WaterQualityList.ToDictionary(
x => x.RecordedTime,
x => x.Filtering
);
break;
case MonitorTab.Sterilizer:
data = WaterQualityList.ToDictionary(
x => x.RecordedTime,
x => x.Sterilizing
);
break;
default:
break;
}
return data;
}
private void SetGraphType()
{
GraphTypes.Clear();
@ -351,6 +403,10 @@ namespace SmartAquaViewer.ViewModel
switch (SelectedGraphType)
{
case GraphType.LINE:
SelectedYField = YFieldCandidates.FirstOrDefault();
ShowMarkers = false;
UseSmoothing = false;
break;
case GraphType.STEP:
var def = YFieldCandidates.FirstOrDefault();
if (def != null) SelectedYFields.Add(def);

Loading…
Cancel
Save