어린이날 기념 커밋
This commit is contained in:
@@ -8,12 +8,17 @@ public sealed class AsyncRelayCommand : ObservableObject, ICommand
|
|||||||
{
|
{
|
||||||
private readonly Func<Task> _execute;
|
private readonly Func<Task> _execute;
|
||||||
private readonly Func<bool>? _canExecute;
|
private readonly Func<bool>? _canExecute;
|
||||||
|
private readonly bool _allowConcurrentExecutions;
|
||||||
private bool _isRunning;
|
private bool _isRunning;
|
||||||
|
|
||||||
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
|
public AsyncRelayCommand(
|
||||||
|
Func<Task> execute,
|
||||||
|
Func<bool>? canExecute = null,
|
||||||
|
bool allowConcurrentExecutions = false)
|
||||||
{
|
{
|
||||||
_execute = execute;
|
_execute = execute;
|
||||||
_canExecute = canExecute;
|
_canExecute = canExecute;
|
||||||
|
_allowConcurrentExecutions = allowConcurrentExecutions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler? CanExecuteChanged;
|
public event EventHandler? CanExecuteChanged;
|
||||||
@@ -30,7 +35,9 @@ public sealed class AsyncRelayCommand : ObservableObject, ICommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanExecute(object? parameter) => !IsRunning && (_canExecute?.Invoke() ?? true);
|
public bool CanExecute(object? parameter) =>
|
||||||
|
(_allowConcurrentExecutions || !IsRunning) &&
|
||||||
|
(_canExecute?.Invoke() ?? true);
|
||||||
|
|
||||||
public async void Execute(object? parameter)
|
public async void Execute(object? parameter)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -163,10 +163,15 @@
|
|||||||
|
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<Grid ColumnSpacing="12" RowSpacing="12">
|
<Grid ColumnSpacing="12" RowSpacing="12">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="150" />
|
||||||
<ColumnDefinition Width="240" />
|
<ColumnDefinition Width="240" />
|
||||||
<ColumnDefinition Width="220" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="220" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
@@ -174,36 +179,110 @@
|
|||||||
|
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
|
Header="컷 분류"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
ItemsSource="{x:Bind ViewModel.FormatCategoryOptions, Mode=OneWay}"
|
||||||
|
SelectedItem="{x:Bind ViewModel.SelectedFormatCategoryOption, Mode=TwoWay}" />
|
||||||
|
|
||||||
|
<ComboBox
|
||||||
|
Grid.Column="1"
|
||||||
|
Header="컷"
|
||||||
DisplayMemberPath="Name"
|
DisplayMemberPath="Name"
|
||||||
ItemsSource="{x:Bind ViewModel.AvailableFormats, Mode=OneWay}"
|
ItemsSource="{x:Bind ViewModel.AvailableFormats, Mode=OneWay}"
|
||||||
SelectedItem="{x:Bind ViewModel.SelectedFormat, Mode=TwoWay}" />
|
SelectedItem="{x:Bind ViewModel.SelectedFormat, Mode=TwoWay}" />
|
||||||
|
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Grid.Column="1"
|
Grid.Column="2"
|
||||||
|
Width="180"
|
||||||
|
Header="투표율 단위"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
ItemsSource="{x:Bind ViewModel.TurnoutRegionModeOptions, Mode=OneWay}"
|
||||||
|
SelectedItem="{x:Bind ViewModel.SelectedTurnoutRegionModeOption, Mode=TwoWay}"
|
||||||
|
Visibility="{x:Bind ViewModel.TurnoutRegionModeVisibility, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<ComboBox
|
||||||
|
Grid.Column="3"
|
||||||
|
Header="지역"
|
||||||
DisplayMemberPath="Label"
|
DisplayMemberPath="Label"
|
||||||
ItemsSource="{x:Bind ViewModel.RegionOptions, Mode=OneWay}"
|
ItemsSource="{x:Bind ViewModel.RegionOptions, Mode=OneWay}"
|
||||||
SelectedItem="{x:Bind ViewModel.SelectedRegionOption, Mode=TwoWay}" />
|
SelectedItem="{x:Bind ViewModel.SelectedRegionOption, Mode=TwoWay}" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
Grid.Column="2"
|
Grid.Column="5"
|
||||||
Command="{x:Bind ViewModel.AddFormatCommand}"
|
Command="{x:Bind ViewModel.AddFormatCommand}"
|
||||||
Content="컷 추가"
|
Content="컷 추가"
|
||||||
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
Style="{StaticResource ConsolePrimaryButtonStyle}"
|
||||||
|
VerticalAlignment="Bottom" />
|
||||||
|
|
||||||
|
<StackPanel
|
||||||
|
Grid.Column="4"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="6">
|
||||||
|
<NumberBox
|
||||||
|
Width="92"
|
||||||
|
Header="송출 시간"
|
||||||
|
Minimum="{x:Bind ViewModel.SelectedFormatMinimumDurationSeconds, Mode=OneWay}"
|
||||||
|
SmallChange="1"
|
||||||
|
SpinButtonPlacementMode="Hidden"
|
||||||
|
Value="{x:Bind ViewModel.SelectedFormatDraftDurationSeconds, Mode=TwoWay}" />
|
||||||
|
<StackPanel
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Spacing="2">
|
||||||
|
<Button
|
||||||
|
Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Command="{x:Bind ViewModel.IncreaseSelectedFormatDurationCommand}"
|
||||||
|
Content="▲"
|
||||||
|
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||||
|
<Button
|
||||||
|
Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Command="{x:Bind ViewModel.ApplySelectedFormatDurationCommand}"
|
||||||
|
Content="확인"
|
||||||
|
FontSize="12"
|
||||||
|
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||||
|
<Button
|
||||||
|
Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Command="{x:Bind ViewModel.DecreaseSelectedFormatDurationCommand}"
|
||||||
|
Content="▼"
|
||||||
|
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,0,8"
|
||||||
|
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
|
Text="초" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
Grid.Column="3"
|
Grid.Row="1"
|
||||||
|
Grid.Column="0"
|
||||||
Header="반복"
|
Header="반복"
|
||||||
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
|
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
|
||||||
|
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Grid.Column="4"
|
Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
Width="150"
|
Width="150"
|
||||||
|
Header="빈 스케줄"
|
||||||
DisplayMemberPath="Label"
|
DisplayMemberPath="Label"
|
||||||
ItemsSource="{x:Bind ViewModel.EmptyBehaviorOptions, Mode=OneWay}"
|
ItemsSource="{x:Bind ViewModel.EmptyBehaviorOptions, Mode=OneWay}"
|
||||||
SelectedItem="{x:Bind ViewModel.SelectedEmptyBehaviorOption, Mode=TwoWay}" />
|
SelectedItem="{x:Bind ViewModel.SelectedEmptyBehaviorOption, Mode=TwoWay}" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
Grid.Column="5"
|
Grid.Row="1"
|
||||||
|
Grid.Column="2"
|
||||||
Width="22"
|
Width="22"
|
||||||
Height="22"
|
Height="22"
|
||||||
MinWidth="22"
|
MinWidth="22"
|
||||||
@@ -228,35 +307,13 @@
|
|||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
Spacing="10">
|
Spacing="10">
|
||||||
<Button
|
<Button
|
||||||
Command="{x:Bind ViewModel.StartCommand}"
|
Command="{x:Bind ViewModel.DirectStartCommand}"
|
||||||
Content="시작"
|
Content="시작"
|
||||||
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||||
<Button
|
<Button
|
||||||
Command="{x:Bind ViewModel.StopCommand}"
|
Command="{x:Bind ViewModel.DirectStopCommand}"
|
||||||
Content="정지"
|
Content="정지"
|
||||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||||
<Button
|
|
||||||
Command="{x:Bind ViewModel.ForceNextCommand}"
|
|
||||||
Style="{StaticResource ConsoleGhostButtonStyle}">
|
|
||||||
<TextBlock TextAlignment="Center">
|
|
||||||
<Run Text="다음 컷" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="즉시 송출" />
|
|
||||||
</TextBlock>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
Command="{x:Bind ViewModel.ForceQueueNextCommand}"
|
|
||||||
Style="{StaticResource ConsoleGhostButtonStyle}">
|
|
||||||
<TextBlock TextAlignment="Center">
|
|
||||||
<Run Text="다음 목록" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="즉시 송출" />
|
|
||||||
</TextBlock>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
Command="{x:Bind ViewModel.ResetQueueCommand}"
|
|
||||||
Content="큐 초기화"
|
|
||||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Border
|
<Border
|
||||||
@@ -647,10 +704,42 @@
|
|||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock
|
<StackPanel
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
HorizontalAlignment="Right"
|
||||||
Text="실행 순서" />
|
Orientation="Horizontal"
|
||||||
|
Spacing="8">
|
||||||
|
<Button
|
||||||
|
Command="{x:Bind ViewModel.StartCommand}"
|
||||||
|
Content="스케줄 시작"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||||
|
<Button
|
||||||
|
Command="{x:Bind ViewModel.StopCommand}"
|
||||||
|
Content="스케줄 정지"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||||
|
<Button
|
||||||
|
Command="{x:Bind ViewModel.ForceNextCommand}"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}">
|
||||||
|
<TextBlock TextAlignment="Center">
|
||||||
|
<Run Text="다음 컷" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="즉시 송출" />
|
||||||
|
</TextBlock>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
Command="{x:Bind ViewModel.ForceQueueNextCommand}"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}">
|
||||||
|
<TextBlock TextAlignment="Center">
|
||||||
|
<Run Text="다음 목록" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="즉시 송출" />
|
||||||
|
</TextBlock>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
Command="{x:Bind ViewModel.ResetQueueCommand}"
|
||||||
|
Content="큐 초기화"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListView
|
<ListView
|
||||||
@@ -733,13 +822,60 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" />
|
Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" />
|
||||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}">
|
<StackPanel
|
||||||
<Run Text="컷 " />
|
Orientation="Horizontal"
|
||||||
<Run Text="{x:Bind TotalCuts}" />
|
Spacing="8">
|
||||||
<Run Text=" | 기본 " />
|
<TextBlock
|
||||||
<Run Text="{x:Bind DefaultCutDurationSeconds}" />
|
VerticalAlignment="Center"
|
||||||
<Run Text="초" />
|
Style="{StaticResource ConsoleLabelTextStyle}">
|
||||||
</TextBlock>
|
<Run Text="컷 " />
|
||||||
|
<Run Text="{x:Bind TotalCuts}" />
|
||||||
|
<Run Text=" | 기본" />
|
||||||
|
</TextBlock>
|
||||||
|
<NumberBox
|
||||||
|
Width="96"
|
||||||
|
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
|
||||||
|
SmallChange="1"
|
||||||
|
SpinButtonPlacementMode="Hidden"
|
||||||
|
Value="{x:Bind DraftCutDurationSeconds, Mode=TwoWay}" />
|
||||||
|
<StackPanel
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="2">
|
||||||
|
<Button
|
||||||
|
Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Click="IncreaseDurationButton_Click"
|
||||||
|
Content="▲"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||||
|
<Button
|
||||||
|
Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Click="ApplyDurationButton_Click"
|
||||||
|
Content="확인"
|
||||||
|
FontSize="12"
|
||||||
|
IsEnabled="{x:Bind HasPendingDurationChange, Mode=OneWay}"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||||
|
<Button
|
||||||
|
Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Click="DecreaseDurationButton_Click"
|
||||||
|
Content="▼"
|
||||||
|
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
|
Text="초" />
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel
|
<StackPanel
|
||||||
|
|||||||
@@ -64,6 +64,28 @@ public sealed partial class ChannelSchedulePanel : UserControl
|
|||||||
command.Execute(item);
|
command.Execute(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void IncreaseDurationButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
GetItem(sender)?.StepDraftDuration(1d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DecreaseDurationButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
GetItem(sender)?.StepDraftDuration(-1d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDurationButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var item = GetItem(sender);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ApplyDraftDuration();
|
||||||
|
ViewModel?.RefreshSummary();
|
||||||
|
}
|
||||||
|
|
||||||
private static ChannelScheduleItem? GetItem(object sender)
|
private static ChannelScheduleItem? GetItem(object sender)
|
||||||
{
|
{
|
||||||
return (sender as FrameworkElement)?.DataContext as ChannelScheduleItem;
|
return (sender as FrameworkElement)?.DataContext as ChannelScheduleItem;
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ public sealed class CandidateEntry : ObservableObject
|
|||||||
set => SetProperty(ref _colorParty, value);
|
set => SetProperty(ref _colorParty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string BroadcastDistrictName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string BroadcastRegionName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string BroadcastElectionDistrictName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string BroadcastDistrictCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double? BroadcastCountedRate { get; set; }
|
||||||
|
|
||||||
public int VoteCount
|
public int VoteCount
|
||||||
{
|
{
|
||||||
get => _voteCount;
|
get => _voteCount;
|
||||||
@@ -197,6 +212,11 @@ public sealed class CandidateEntry : ObservableObject
|
|||||||
Name = Name,
|
Name = Name,
|
||||||
Party = Party,
|
Party = Party,
|
||||||
ColorParty = ColorParty,
|
ColorParty = ColorParty,
|
||||||
|
BroadcastDistrictName = BroadcastDistrictName,
|
||||||
|
BroadcastRegionName = BroadcastRegionName,
|
||||||
|
BroadcastElectionDistrictName = BroadcastElectionDistrictName,
|
||||||
|
BroadcastDistrictCode = BroadcastDistrictCode,
|
||||||
|
BroadcastCountedRate = BroadcastCountedRate,
|
||||||
VoteCount = VoteCount,
|
VoteCount = VoteCount,
|
||||||
VoteRate = VoteRate,
|
VoteRate = VoteRate,
|
||||||
HasImage = HasImage,
|
HasImage = HasImage,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
private DateTimeOffset? _lastPlayedAt;
|
private DateTimeOffset? _lastPlayedAt;
|
||||||
private string _currentRegionLabel = string.Empty;
|
private string _currentRegionLabel = string.Empty;
|
||||||
private double _defaultCutDurationSeconds;
|
private double _defaultCutDurationSeconds;
|
||||||
|
private double _draftCutDurationSeconds;
|
||||||
private int _totalCuts;
|
private int _totalCuts;
|
||||||
private double _thumbnailWidth = 160;
|
private double _thumbnailWidth = 160;
|
||||||
private double _thumbnailHeight = 90;
|
private double _thumbnailHeight = 90;
|
||||||
@@ -35,7 +36,33 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
public required double DefaultCutDurationSeconds
|
public required double DefaultCutDurationSeconds
|
||||||
{
|
{
|
||||||
get => _defaultCutDurationSeconds;
|
get => _defaultCutDurationSeconds;
|
||||||
set => SetProperty(ref _defaultCutDurationSeconds, value);
|
set
|
||||||
|
{
|
||||||
|
var hadPendingDurationChange = HasPendingDurationChange;
|
||||||
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, Channel, FormatName);
|
||||||
|
if (SetProperty(ref _defaultCutDurationSeconds, normalized))
|
||||||
|
{
|
||||||
|
if (!hadPendingDurationChange || _draftCutDurationSeconds <= 0)
|
||||||
|
{
|
||||||
|
SetProperty(ref _draftCutDurationSeconds, normalized, nameof(DraftCutDurationSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
OnDurationStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double DraftCutDurationSeconds
|
||||||
|
{
|
||||||
|
get => _draftCutDurationSeconds <= 0 ? DefaultCutDurationSeconds : _draftCutDurationSeconds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, Channel, FormatName);
|
||||||
|
if (SetProperty(ref _draftCutDurationSeconds, normalized))
|
||||||
|
{
|
||||||
|
OnDurationStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public required int TotalCuts
|
public required int TotalCuts
|
||||||
@@ -117,6 +144,15 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
|
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(Channel, FormatName);
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool HasPendingDurationChange => Math.Abs(DraftCutDurationSeconds - DefaultCutDurationSeconds) >= 0.001d;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string DurationApplyStatusLabel => HasPendingDurationChange ? "미적용" : "적용됨";
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "아직 송출 전";
|
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "아직 송출 전";
|
||||||
|
|
||||||
@@ -125,6 +161,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
{
|
{
|
||||||
ScheduleRegionScope.All => "전체",
|
ScheduleRegionScope.All => "전체",
|
||||||
ScheduleRegionScope.StationRegions => "선택권역",
|
ScheduleRegionScope.StationRegions => "선택권역",
|
||||||
|
ScheduleRegionScope.RegionGroup => string.IsNullOrWhiteSpace(RegionLabel) ? "시도" : RegionLabel,
|
||||||
_ => string.IsNullOrWhiteSpace(RegionLabel) ? "개별 지역" : RegionLabel
|
_ => string.IsNullOrWhiteSpace(RegionLabel) ? "개별 지역" : RegionLabel
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +210,23 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
ThumbnailHeight = metrics.Height;
|
ThumbnailHeight = metrics.Height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void StepDraftDuration(double deltaSeconds)
|
||||||
|
{
|
||||||
|
DraftCutDurationSeconds = DraftCutDurationSeconds + deltaSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyDraftDuration()
|
||||||
|
{
|
||||||
|
DefaultCutDurationSeconds = DraftCutDurationSeconds;
|
||||||
|
OnDurationStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDurationStateChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasPendingDurationChange));
|
||||||
|
OnPropertyChanged(nameof(DurationApplyStatusLabel));
|
||||||
|
}
|
||||||
|
|
||||||
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
|
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
|
||||||
{
|
{
|
||||||
var selectedRegion = regionOption ?? new ScheduleRegionOption
|
var selectedRegion = regionOption ?? new ScheduleRegionOption
|
||||||
@@ -192,7 +246,9 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
TotalCuts = template.Cuts.Count,
|
TotalCuts = template.Cuts.Count,
|
||||||
RegionScope = selectedRegion.Scope,
|
RegionScope = selectedRegion.Scope,
|
||||||
ScheduleElectionType = selectedRegion.ElectionType,
|
ScheduleElectionType = selectedRegion.ElectionType,
|
||||||
RegionLabel = selectedRegion.Scope == ScheduleRegionScope.Single ? selectedRegion.Label : string.Empty,
|
RegionLabel = selectedRegion.Scope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup
|
||||||
|
? selectedRegion.Label
|
||||||
|
: string.Empty,
|
||||||
RegionCode = selectedRegion.DistrictCode
|
RegionCode = selectedRegion.DistrictCode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
15
Tornado3_2026Election/Domain/CutCategory.cs
Normal file
15
Tornado3_2026Election/Domain/CutCategory.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Tornado3_2026Election.Domain;
|
||||||
|
|
||||||
|
public enum CutCategory
|
||||||
|
{
|
||||||
|
MetropolitanHead,
|
||||||
|
LocalHead,
|
||||||
|
Superintendent,
|
||||||
|
MetropolitanCouncil,
|
||||||
|
LocalCouncil,
|
||||||
|
NationalAssembly,
|
||||||
|
PreElection,
|
||||||
|
Historical,
|
||||||
|
Turnout,
|
||||||
|
Title
|
||||||
|
}
|
||||||
@@ -71,4 +71,5 @@ public sealed record TurnoutBoardSlotEntry(
|
|||||||
int Slot,
|
int Slot,
|
||||||
string Label,
|
string Label,
|
||||||
double TurnoutRate,
|
double TurnoutRate,
|
||||||
bool IsNational = false);
|
bool IsNational = false,
|
||||||
|
string RegionLabel = "");
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ public sealed class FormatCutDefinition
|
|||||||
public int CandidateStartIndex { get; init; }
|
public int CandidateStartIndex { get; init; }
|
||||||
|
|
||||||
public bool UseEndScene { get; init; }
|
public bool UseEndScene { get; init; }
|
||||||
|
|
||||||
|
public string? SceneIdOverride { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ public enum ScheduleRegionScope
|
|||||||
{
|
{
|
||||||
All,
|
All,
|
||||||
StationRegions,
|
StationRegions,
|
||||||
|
RegionGroup,
|
||||||
Single
|
Single
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,7 +470,10 @@
|
|||||||
SelectedValuePath="Value" />
|
SelectedValuePath="Value" />
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
|
||||||
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||||
<NumberBox Width="140" Header="주기(초)" Minimum="3" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
|
<StackPanel VerticalAlignment="Bottom" Spacing="2">
|
||||||
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="갱신 주기" />
|
||||||
|
<TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="60초 고정" />
|
||||||
|
</StackPanel>
|
||||||
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 갱신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 갱신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -483,6 +486,120 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="20"
|
||||||
|
Background="{StaticResource ControlRoomPanelGradientBrush}"
|
||||||
|
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="24">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<Grid ColumnSpacing="12" RowSpacing="12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="150" />
|
||||||
|
<ColumnDefinition Width="150" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="접전 조건" />
|
||||||
|
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||||
|
Text="{x:Bind ViewModel.Data.CloseRaceThresholdSummaryText, Mode=OneWay}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<NumberBox Grid.Column="1"
|
||||||
|
Header="접전 기준(%)"
|
||||||
|
Maximum="100"
|
||||||
|
Minimum="0"
|
||||||
|
SmallChange="0.1"
|
||||||
|
SpinButtonPlacementMode="Compact"
|
||||||
|
Value="{x:Bind ViewModel.Data.DraftCloseRaceThresholdPercent, Mode=TwoWay}" />
|
||||||
|
<NumberBox Grid.Column="2"
|
||||||
|
Header="초접전 기준(%)"
|
||||||
|
Maximum="100"
|
||||||
|
Minimum="0"
|
||||||
|
SmallChange="0.1"
|
||||||
|
SpinButtonPlacementMode="Compact"
|
||||||
|
Value="{x:Bind ViewModel.Data.DraftSuperCloseRaceThresholdPercent, Mode=TwoWay}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Command="{x:Bind ViewModel.Data.ApplyCloseRaceThresholdsCommand}"
|
||||||
|
Content="적용"
|
||||||
|
IsEnabled="{x:Bind ViewModel.Data.HasPendingCloseRaceThresholdChange, Mode=OneWay}"
|
||||||
|
Style="{StaticResource ConsolePrimaryButtonStyle}"
|
||||||
|
VerticalAlignment="Bottom" />
|
||||||
|
<Button Grid.Column="4"
|
||||||
|
Command="{x:Bind ViewModel.Data.ResetCloseRaceThresholdsCommand}"
|
||||||
|
Content="기본값"
|
||||||
|
Style="{StaticResource ConsoleGhostButtonStyle}"
|
||||||
|
VerticalAlignment="Bottom" />
|
||||||
|
<Button Grid.Column="5"
|
||||||
|
Command="{x:Bind ViewModel.Data.RefreshCloseRaceTargetsCommand}"
|
||||||
|
Content="대상 확인"
|
||||||
|
Style="{StaticResource ConsoleGhostButtonStyle}"
|
||||||
|
VerticalAlignment="Bottom" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
|
Text="{x:Bind ViewModel.Data.CloseRaceThresholdStatusText, Mode=OneWay}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||||
|
Text="{x:Bind ViewModel.Data.CloseRaceTargetSummaryText, Mode=OneWay}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<ItemsControl ItemsSource="{x:Bind ViewModel.Data.CloseRaceTargets, Mode=OneWay}"
|
||||||
|
Visibility="{x:Bind ViewModel.Data.CloseRaceTargetsVisibility, Mode=OneWay}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:CloseRaceTargetViewModel">
|
||||||
|
<Border Margin="0,0,0,8"
|
||||||
|
Padding="12"
|
||||||
|
Background="#132338"
|
||||||
|
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8">
|
||||||
|
<Grid ColumnSpacing="12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="180" />
|
||||||
|
<ColumnDefinition Width="90" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="90" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind RegionName}" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind DistrictName}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
|
||||||
|
Text="{x:Bind Level}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||||
|
Text="{x:Bind FirstCandidateText}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="3"
|
||||||
|
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||||
|
Text="{x:Bind SecondCandidateText}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<StackPanel Grid.Column="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock FontFamily="Consolas"
|
||||||
|
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||||
|
Text="{x:Bind FirstVoteRateDisplay}" />
|
||||||
|
<TextBlock FontFamily="Consolas"
|
||||||
|
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||||
|
Text="{x:Bind SecondVoteRateDisplay}" />
|
||||||
|
<TextBlock FontFamily="Consolas"
|
||||||
|
Foreground="{StaticResource ControlRoomSignalGreenBrush}"
|
||||||
|
Text="{x:Bind GapDisplay}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Border Padding="20"
|
<Border Padding="20"
|
||||||
Background="{StaticResource ControlRoomPanelGradientBrush}"
|
Background="{StaticResource ControlRoomPanelGradientBrush}"
|
||||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||||
@@ -520,6 +637,25 @@
|
|||||||
FontSize="24"
|
FontSize="24"
|
||||||
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
|
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
|
||||||
Text="{x:Bind CountedRateDisplay}" />
|
Text="{x:Bind CountedRateDisplay}" />
|
||||||
|
<Border Padding="8,3"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Background="#7C2D12"
|
||||||
|
BorderBrush="#FDBA74"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="4"
|
||||||
|
Visibility="{x:Bind JudgementVisibility}">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock FontSize="12"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="#FED7AA"
|
||||||
|
Text="{x:Bind JudgementBadgeText}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock FontSize="11"
|
||||||
|
Foreground="#FFEDD5"
|
||||||
|
Text="{x:Bind JudgementDetailText}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
Text="{x:Bind DetailText}"
|
Text="{x:Bind DetailText}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
@@ -783,7 +919,7 @@
|
|||||||
ItemsSource="{x:Bind ViewModel.CutListFilterOptions, Mode=OneWay}"
|
ItemsSource="{x:Bind ViewModel.CutListFilterOptions, Mode=OneWay}"
|
||||||
SelectedItem="{x:Bind ViewModel.SelectedCutListFilterOption, Mode=TwoWay}" />
|
SelectedItem="{x:Bind ViewModel.SelectedCutListFilterOption, Mode=TwoWay}" />
|
||||||
<ComboBox Grid.Column="1"
|
<ComboBox Grid.Column="1"
|
||||||
Header="선거 분류"
|
Header="컷 분류"
|
||||||
DisplayMemberPath="Label"
|
DisplayMemberPath="Label"
|
||||||
ItemsSource="{x:Bind ViewModel.CutListCategoryOptions, Mode=OneWay}"
|
ItemsSource="{x:Bind ViewModel.CutListCategoryOptions, Mode=OneWay}"
|
||||||
SelectedItem="{x:Bind ViewModel.SelectedCutListCategoryOption, Mode=TwoWay}" />
|
SelectedItem="{x:Bind ViewModel.SelectedCutListCategoryOption, Mode=TwoWay}" />
|
||||||
@@ -816,7 +952,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
Text="기본 송출 시간을 바꾸면 이후 송출과 대기열 표시값에 바로 반영됩니다."
|
Text="기본 송출 시간을 조정한 뒤 확인을 누르면 이후 송출과 대기열 표시값에 반영됩니다."
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -832,7 +968,7 @@
|
|||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="110" />
|
<ColumnDefinition Width="110" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="140" />
|
<ColumnDefinition Width="220" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="썸네일" />
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="썸네일" />
|
||||||
<TextBlock Grid.Column="1" Style="{StaticResource ConsoleLabelTextStyle}" Text="권장 채널" />
|
<TextBlock Grid.Column="1" Style="{StaticResource ConsoleLabelTextStyle}" Text="권장 채널" />
|
||||||
@@ -856,7 +992,7 @@
|
|||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="110" />
|
<ColumnDefinition Width="110" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="140" />
|
<ColumnDefinition Width="220" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Border Width="{x:Bind ThumbnailWidth, Mode=OneWay}"
|
<Border Width="{x:Bind ThumbnailWidth, Mode=OneWay}"
|
||||||
@@ -893,11 +1029,52 @@
|
|||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<NumberBox Grid.Column="3"
|
<StackPanel Grid.Column="3"
|
||||||
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
|
VerticalAlignment="Center"
|
||||||
SmallChange="1"
|
Orientation="Horizontal"
|
||||||
SpinButtonPlacementMode="Compact"
|
Spacing="6">
|
||||||
Value="{x:Bind DurationSeconds, Mode=TwoWay}" />
|
<NumberBox Width="92"
|
||||||
|
Height="34"
|
||||||
|
MinHeight="34"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="14"
|
||||||
|
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
|
||||||
|
SmallChange="1"
|
||||||
|
SpinButtonPlacementMode="Hidden"
|
||||||
|
Value="{x:Bind DraftDurationSeconds, Mode=TwoWay}" />
|
||||||
|
<StackPanel VerticalAlignment="Center"
|
||||||
|
Spacing="2">
|
||||||
|
<Button Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Command="{x:Bind IncreaseDurationCommand}"
|
||||||
|
Content="▲"
|
||||||
|
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||||
|
<Button Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Command="{x:Bind ApplyDurationCommand}"
|
||||||
|
Content="확인"
|
||||||
|
FontSize="12"
|
||||||
|
IsEnabled="{x:Bind HasPendingDurationChange, Mode=OneWay}"
|
||||||
|
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||||
|
<Button Width="48"
|
||||||
|
Height="24"
|
||||||
|
MinWidth="48"
|
||||||
|
MinHeight="24"
|
||||||
|
Padding="0"
|
||||||
|
Command="{x:Bind DecreaseDurationCommand}"
|
||||||
|
Content="▼"
|
||||||
|
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock VerticalAlignment="Center"
|
||||||
|
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
|
Text="초" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -1006,10 +1183,9 @@
|
|||||||
OffContent="OFF"
|
OffContent="OFF"
|
||||||
OnContent="ON" />
|
OnContent="ON" />
|
||||||
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||||
<NumberBox Header="API 갱신 주기(초)"
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
Minimum="3"
|
Text="API 갱신 주기: 60초 고정"
|
||||||
SpinButtonPlacementMode="Compact"
|
TextWrapping="Wrap" />
|
||||||
Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public sealed class AppState
|
|||||||
|
|
||||||
public bool ShowOnlyConfiguredRegions { get; set; }
|
public bool ShowOnlyConfiguredRegions { get; set; }
|
||||||
|
|
||||||
|
public double CloseRaceThresholdPercent { get; set; } = 5.0;
|
||||||
|
|
||||||
|
public double SuperCloseRaceThresholdPercent { get; set; } = 3.0;
|
||||||
|
|
||||||
public int TotalExpectedVotes { get; set; } = 1_240_000;
|
public int TotalExpectedVotes { get; set; } = 1_240_000;
|
||||||
|
|
||||||
public int TurnoutVotes { get; set; } = 528_400;
|
public int TurnoutVotes { get; set; } = 528_400;
|
||||||
|
|||||||
@@ -29,19 +29,23 @@ public sealed class CareerPromiseService
|
|||||||
|
|
||||||
public string FilePath { get; }
|
public string FilePath { get; }
|
||||||
|
|
||||||
public IReadOnlyDictionary<string, CareerPromiseEntry> GetEntryLookup(
|
public IReadOnlyList<CareerPromiseEntry> GetEntries(
|
||||||
string stationId,
|
string stationId,
|
||||||
string electionType,
|
string electionType,
|
||||||
string districtCode)
|
string districtCode,
|
||||||
|
string districtName)
|
||||||
{
|
{
|
||||||
|
var normalizedStationId = stationId?.Trim() ?? string.Empty;
|
||||||
|
var normalizedElectionType = electionType?.Trim() ?? string.Empty;
|
||||||
|
var normalizedDistrictCode = districtCode?.Trim() ?? string.Empty;
|
||||||
|
var normalizedDistrictName = districtName?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
return _catalog.Entries
|
return _catalog.Entries
|
||||||
.Where(entry =>
|
.Where(entry =>
|
||||||
string.Equals(entry.StationId, stationId, StringComparison.OrdinalIgnoreCase) &&
|
string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
string.Equals(entry.ElectionType, electionType, StringComparison.Ordinal) &&
|
string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) &&
|
||||||
string.Equals(entry.DistrictCode, districtCode, StringComparison.OrdinalIgnoreCase))
|
MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
|
||||||
.Where(entry => !string.IsNullOrWhiteSpace(entry.CandidateCode))
|
.ToArray();
|
||||||
.GroupBy(entry => entry.CandidateCode, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SaveEntries(
|
public void SaveEntries(
|
||||||
@@ -62,7 +66,7 @@ public sealed class CareerPromiseService
|
|||||||
.Where(entry =>
|
.Where(entry =>
|
||||||
!string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) ||
|
!string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) ||
|
||||||
!string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) ||
|
!string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) ||
|
||||||
!string.Equals(entry.DistrictCode, normalizedDistrictCode, StringComparison.OrdinalIgnoreCase))
|
!MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
retainedEntries.AddRange(
|
retainedEntries.AddRange(
|
||||||
@@ -127,6 +131,35 @@ public sealed class CareerPromiseService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool MatchesDistrict(
|
||||||
|
CareerPromiseEntry entry,
|
||||||
|
string districtCode,
|
||||||
|
string districtName)
|
||||||
|
{
|
||||||
|
var entryDistrictCode = entry.DistrictCode?.Trim() ?? string.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(districtCode) &&
|
||||||
|
string.Equals(entryDistrictCode, districtCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedEntryDistrictName = NormalizeLookupKey(entry.DistrictName);
|
||||||
|
var normalizedDistrictName = NormalizeLookupKey(districtName);
|
||||||
|
return !string.IsNullOrWhiteSpace(normalizedDistrictName) &&
|
||||||
|
string.Equals(normalizedEntryDistrictName, normalizedDistrictName, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLookupKey(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Concat(value.Where(character => !char.IsWhiteSpace(character)))
|
||||||
|
.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
private CareerPromiseCatalog LoadCatalog(string filePath)
|
private CareerPromiseCatalog LoadCatalog(string filePath)
|
||||||
{
|
{
|
||||||
if (!File.Exists(filePath))
|
if (!File.Exists(filePath))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -20,6 +21,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
private CancellationTokenSource? _playbackCts;
|
private CancellationTokenSource? _playbackCts;
|
||||||
private TaskCompletionSource<bool>? _advanceSignal;
|
private TaskCompletionSource<bool>? _advanceSignal;
|
||||||
private Guid? _preferredNextItemId;
|
private Guid? _preferredNextItemId;
|
||||||
|
private Guid? _skipCurrentItemId;
|
||||||
|
|
||||||
public ChannelScheduleEngine(
|
public ChannelScheduleEngine(
|
||||||
BroadcastChannel channel,
|
BroadcastChannel channel,
|
||||||
@@ -57,6 +59,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
{
|
{
|
||||||
if (IsRunning)
|
if (IsRunning)
|
||||||
{
|
{
|
||||||
|
await AdvanceToNextAsync().ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +70,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopAsync()
|
public async Task StopAsync(bool takeOutputOff = true)
|
||||||
{
|
{
|
||||||
if (!IsRunning)
|
if (!IsRunning)
|
||||||
{
|
{
|
||||||
@@ -76,7 +79,10 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
_playbackCts?.Cancel();
|
_playbackCts?.Cancel();
|
||||||
_advanceSignal?.TrySetResult(true);
|
_advanceSignal?.TrySetResult(true);
|
||||||
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
if (takeOutputOff)
|
||||||
|
{
|
||||||
|
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var item in Queue.Where(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending))
|
foreach (var item in Queue.Where(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending))
|
||||||
{
|
{
|
||||||
@@ -85,11 +91,35 @@ public sealed class ChannelScheduleEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
_preferredNextItemId = null;
|
_preferredNextItemId = null;
|
||||||
|
_skipCurrentItemId = null;
|
||||||
IsRunning = false;
|
IsRunning = false;
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task PlayDirectAsync(
|
||||||
|
ChannelScheduleItem item,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await PlayItemAsync(item, template, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
item.State = ScheduleQueueItemState.Queued;
|
||||||
|
item.CurrentRegionLabel = string.Empty;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_executionLock.Release();
|
||||||
|
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
_preferredNextItemId = null;
|
_preferredNextItemId = null;
|
||||||
@@ -105,23 +135,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
public async Task ForceNextAsync()
|
public async Task ForceNextAsync()
|
||||||
{
|
{
|
||||||
if (!IsRunning)
|
await AdvanceToNextAsync().ConfigureAwait(false);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
|
||||||
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
|
|
||||||
if (activeItem is not null)
|
|
||||||
{
|
|
||||||
activeItem.State = ScheduleQueueItemState.Completed;
|
|
||||||
activeItem.LastError = string.Empty;
|
|
||||||
activeItem.CurrentRegionLabel = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
RefreshQueueMarkers();
|
|
||||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
|
||||||
_advanceSignal?.TrySetResult(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ForceQueueNextAsync()
|
public async Task ForceQueueNextAsync()
|
||||||
@@ -141,6 +155,28 @@ public sealed class ChannelScheduleEngine
|
|||||||
await ForceNextAsync().ConfigureAwait(false);
|
await ForceNextAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task AdvanceToNextAsync()
|
||||||
|
{
|
||||||
|
if (!IsRunning)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
|
||||||
|
if (activeItem is not null)
|
||||||
|
{
|
||||||
|
_skipCurrentItemId = activeItem.Id;
|
||||||
|
activeItem.State = ScheduleQueueItemState.Completed;
|
||||||
|
activeItem.LastError = string.Empty;
|
||||||
|
activeItem.CurrentRegionLabel = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshQueueMarkers();
|
||||||
|
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
_advanceSignal?.TrySetResult(true);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public bool Remove(ChannelScheduleItem? item)
|
public bool Remove(ChannelScheduleItem? item)
|
||||||
{
|
{
|
||||||
if (item is null || !item.CanDelete)
|
if (item is null || !item.CanDelete)
|
||||||
@@ -287,65 +323,81 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
if (ShouldUseAggregateScheduleSnapshot(template))
|
if (ShouldUseAggregateScheduleSnapshot(template))
|
||||||
{
|
{
|
||||||
ElectionDataSnapshot aggregateSnapshot;
|
var aggregateRegionGroups = ResolveAggregateScheduleRegionGroups(template, regionTargets);
|
||||||
try
|
for (var groupIndex = 0; groupIndex < aggregateRegionGroups.Count; groupIndex++)
|
||||||
{
|
{
|
||||||
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
|
var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
|
||||||
aggregateSnapshot = await _dataRefreshGate
|
ElectionDataSnapshot aggregateSnapshot;
|
||||||
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, regionTargets, cancellationToken)
|
try
|
||||||
.ConfigureAwait(false);
|
{
|
||||||
}
|
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||||
catch (OperationCanceledException)
|
aggregateSnapshot = await _dataRefreshGate
|
||||||
{
|
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
|
||||||
throw;
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
queueItem.State = ScheduleQueueItemState.Error;
|
throw;
|
||||||
queueItem.LastError = ex.Message;
|
}
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
catch (Exception ex)
|
||||||
RefreshQueueMarkers();
|
{
|
||||||
_logService.Warning($"[{Channel}] 집계형 송출 데이터 수신 실패: {ex.Message}");
|
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {ex.Message}";
|
||||||
return;
|
_logService.Warning($"[{Channel}] 집계형 송출 데이터 수신 실패: {lastFailure}");
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
|
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
|
||||||
{
|
{
|
||||||
queueItem.State = ScheduleQueueItemState.Error;
|
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}";
|
||||||
queueItem.LastError = aggregateValidationError;
|
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}");
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
continue;
|
||||||
RefreshQueueMarkers();
|
}
|
||||||
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {aggregateValidationError}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
queueItem.CurrentRegionLabel = queueItem.SelectionRegionLabel;
|
queueItem.CurrentRegionLabel = ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup);
|
||||||
|
var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
|
||||||
|
|
||||||
for (var cutIndex = 0; cutIndex < resolvedCuts.Count; cutIndex++)
|
for (var cutIndex = 0; cutIndex < resolvedCuts.Count; cutIndex++)
|
||||||
{
|
{
|
||||||
var cut = ResolveScheduledCut(resolvedCuts[cutIndex], hasEndScene, cutIndex == resolvedCuts.Count - 1);
|
var cut = ResolveScheduledCut(resolvedCuts[cutIndex], hasEndScene && isLastGroup, cutIndex == resolvedCuts.Count - 1);
|
||||||
queueItem.State = ScheduleQueueItemState.Sending;
|
queueItem.State = ScheduleQueueItemState.Sending;
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
|
|
||||||
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await _adapter.ApplyCutAsync(Channel, template, cut, aggregateSnapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
await _adapter.ApplyCutAsync(Channel, template, cut, aggregateSnapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
||||||
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
|
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||||
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
|
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
queueItem.State = ScheduleQueueItemState.OnAir;
|
queueItem.State = ScheduleQueueItemState.OnAir;
|
||||||
queueItem.LastPlayedAt = DateTimeOffset.Now;
|
queueItem.LastPlayedAt = DateTimeOffset.Now;
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
|
|
||||||
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_advanceSignal = signal;
|
_advanceSignal = signal;
|
||||||
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
if (ShouldSkipCurrentItem(queueItem))
|
||||||
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
|
{
|
||||||
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
signal.TrySetResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||||
|
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
|
||||||
|
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
||||||
|
if (ShouldSkipCurrentItem(queueItem))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playedAny = true;
|
||||||
|
if (ShouldSkipCurrentItem(queueItem))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
queueItem.CurrentRegionLabel = string.Empty;
|
||||||
queueItem.State = ScheduleQueueItemState.Completed;
|
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
|
||||||
queueItem.LastError = string.Empty;
|
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
||||||
|
ClearSkipCurrentItem(queueItem);
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -400,22 +452,54 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_advanceSignal = signal;
|
_advanceSignal = signal;
|
||||||
|
if (ShouldSkipCurrentItem(queueItem))
|
||||||
|
{
|
||||||
|
signal.TrySetResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||||
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
|
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
|
||||||
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
||||||
|
if (ShouldSkipCurrentItem(queueItem))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playedAny = true;
|
playedAny = true;
|
||||||
|
if (ShouldSkipCurrentItem(queueItem))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
queueItem.CurrentRegionLabel = string.Empty;
|
||||||
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
|
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
|
||||||
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
||||||
|
ClearSkipCurrentItem(queueItem);
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool ShouldSkipCurrentItem(ChannelScheduleItem queueItem)
|
||||||
|
{
|
||||||
|
return _skipCurrentItemId == queueItem.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
|
||||||
|
{
|
||||||
|
if (_skipCurrentItemId == queueItem.Id)
|
||||||
|
{
|
||||||
|
_skipCurrentItemId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ShouldUseAggregateScheduleSnapshot(FormatTemplateDefinition template)
|
private static bool ShouldUseAggregateScheduleSnapshot(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
|
if (IsCurrentLeaderTemplate(template))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -431,39 +515,170 @@ public sealed class ChannelScheduleEngine
|
|||||||
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
|
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<IReadOnlyList<ScheduleRegionTarget>> ResolveAggregateScheduleRegionGroups(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
IReadOnlyList<ScheduleRegionTarget> regionTargets)
|
||||||
|
{
|
||||||
|
if (IsCurrentLeaderTemplate(template))
|
||||||
|
{
|
||||||
|
return ChunkRegionTargets(regionTargets, ResolveCurrentLeaderPageSize(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||||
|
{
|
||||||
|
return [regionTargets];
|
||||||
|
}
|
||||||
|
|
||||||
|
return regionTargets
|
||||||
|
.GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => (IReadOnlyList<ScheduleRegionTarget>)group.ToArray())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<IReadOnlyList<ScheduleRegionTarget>> ChunkRegionTargets(
|
||||||
|
IReadOnlyList<ScheduleRegionTarget> regionTargets,
|
||||||
|
int pageSize)
|
||||||
|
{
|
||||||
|
pageSize = Math.Max(1, pageSize);
|
||||||
|
var groups = new List<IReadOnlyList<ScheduleRegionTarget>>();
|
||||||
|
for (var index = 0; index < regionTargets.Count; index += pageSize)
|
||||||
|
{
|
||||||
|
groups.Add(regionTargets.Skip(index).Take(pageSize).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCouncilSeatTableRegionKey(ScheduleRegionTarget target)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(target.RegionName))
|
||||||
|
{
|
||||||
|
return NormalizeRegionKey(target.RegionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(target.DisplayName))
|
||||||
|
{
|
||||||
|
return NormalizeRegionKey(target.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.DistrictCode ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveAggregateRegionGroupLabel(
|
||||||
|
ChannelScheduleItem queueItem,
|
||||||
|
IReadOnlyList<ScheduleRegionTarget> regionTargets)
|
||||||
|
{
|
||||||
|
var regionNames = regionTargets
|
||||||
|
.Select(target => target.RegionName)
|
||||||
|
.Where(regionName => !string.IsNullOrWhiteSpace(regionName))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (regionNames.Length == 1)
|
||||||
|
{
|
||||||
|
return regionNames[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(queueItem.SelectionRegionLabel)
|
||||||
|
? "선택권역"
|
||||||
|
: queueItem.SelectionRegionLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRegionKey(string value)
|
||||||
|
{
|
||||||
|
return string.Concat((value ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<FormatCutDefinition> ResolvePlaybackCuts(
|
private static IReadOnlyList<FormatCutDefinition> ResolvePlaybackCuts(
|
||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
IReadOnlyList<FormatCutDefinition> baseCuts,
|
IReadOnlyList<FormatCutDefinition> baseCuts,
|
||||||
ElectionDataSnapshot snapshot,
|
ElectionDataSnapshot snapshot,
|
||||||
bool useEndSceneOnLastCut)
|
bool useEndSceneOnLastCut)
|
||||||
{
|
{
|
||||||
if (!IsCareerTemplate(template) || baseCuts.Count == 0)
|
if (!IsCandidatePagedTemplate(template) || baseCuts.Count == 0)
|
||||||
{
|
|
||||||
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidateCount = snapshot.Candidates.Count;
|
|
||||||
if (candidateCount <= 1)
|
|
||||||
{
|
{
|
||||||
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
|
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var candidateCount = Math.Max(snapshot.Candidates.Count, 1);
|
||||||
|
var pageSize = ResolveCandidatePageSize(template);
|
||||||
|
var pageStarts = Enumerable
|
||||||
|
.Range(0, (int)Math.Ceiling(candidateCount / (double)pageSize))
|
||||||
|
.Select(pageIndex => pageIndex * pageSize)
|
||||||
|
.ToArray();
|
||||||
var playbackCuts = new List<FormatCutDefinition>(baseCuts.Count * candidateCount);
|
var playbackCuts = new List<FormatCutDefinition>(baseCuts.Count * candidateCount);
|
||||||
foreach (var baseCut in baseCuts)
|
foreach (var baseCut in baseCuts)
|
||||||
{
|
{
|
||||||
for (var candidateIndex = 0; candidateIndex < candidateCount; candidateIndex++)
|
foreach (var candidateStartIndex in pageStarts)
|
||||||
{
|
{
|
||||||
|
var isLastPage = candidateStartIndex == pageStarts[^1];
|
||||||
|
var cutName = ResolveCandidatePagedCutName(template, baseCut.Name, candidateStartIndex, isLastPage);
|
||||||
playbackCuts.Add(new FormatCutDefinition
|
playbackCuts.Add(new FormatCutDefinition
|
||||||
{
|
{
|
||||||
Name = $"{baseCut.Name} #{candidateIndex + 1}",
|
Name = cutName,
|
||||||
DurationSeconds = baseCut.DurationSeconds,
|
DurationSeconds = baseCut.DurationSeconds,
|
||||||
CandidateStartIndex = candidateIndex,
|
CandidateStartIndex = candidateStartIndex,
|
||||||
UseEndScene = baseCut.UseEndScene
|
UseEndScene = baseCut.UseEndScene,
|
||||||
|
SceneIdOverride = ResolveCareerSceneId(template, cutName)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApplyEndSceneToLastCut(playbackCuts, useEndSceneOnLastCut);
|
return IsAllCandidateTemplate(template)
|
||||||
|
? playbackCuts
|
||||||
|
: ApplyEndSceneToLastCut(playbackCuts, useEndSceneOnLastCut);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCandidatePagedCutName(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
string cutName,
|
||||||
|
int candidateStartIndex,
|
||||||
|
bool isLastPage)
|
||||||
|
{
|
||||||
|
if (IsAllCandidateTemplate(template))
|
||||||
|
{
|
||||||
|
if (candidateStartIndex == 0)
|
||||||
|
{
|
||||||
|
return cutName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLastPage)
|
||||||
|
{
|
||||||
|
return ResolveSuffixedCutName(cutName, "_END");
|
||||||
|
}
|
||||||
|
|
||||||
|
return UsesAllCandidateLoopScene(template)
|
||||||
|
? ResolveSuffixedCutName(cutName, "_loop")
|
||||||
|
: cutName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateStartIndex == 0
|
||||||
|
? cutName
|
||||||
|
: ResolveSuffixedCutName(cutName, "_loop");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSuffixedCutName(string cutName, string suffix)
|
||||||
|
{
|
||||||
|
if (cutName.EndsWith(suffix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return cutName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string inSuffix = "_in";
|
||||||
|
if (string.Equals(suffix, "_loop", StringComparison.Ordinal) &&
|
||||||
|
cutName.EndsWith(inSuffix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return cutName[..^inSuffix.Length] + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cutName + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCareerSceneId(FormatTemplateDefinition template, string cutName)
|
||||||
|
{
|
||||||
|
var folderName = Path.GetDirectoryName(template.Id);
|
||||||
|
return string.IsNullOrWhiteSpace(folderName)
|
||||||
|
? cutName
|
||||||
|
: Path.Combine(folderName, cutName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<FormatCutDefinition> ApplyEndSceneToLastCut(
|
private static IReadOnlyList<FormatCutDefinition> ApplyEndSceneToLastCut(
|
||||||
@@ -495,7 +710,8 @@ public sealed class ChannelScheduleEngine
|
|||||||
Name = cut.Name,
|
Name = cut.Name,
|
||||||
DurationSeconds = cut.DurationSeconds,
|
DurationSeconds = cut.DurationSeconds,
|
||||||
CandidateStartIndex = cut.CandidateStartIndex,
|
CandidateStartIndex = cut.CandidateStartIndex,
|
||||||
UseEndScene = true
|
UseEndScene = true,
|
||||||
|
SceneIdOverride = cut.SceneIdOverride
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,6 +720,58 @@ public sealed class ChannelScheduleEngine
|
|||||||
return template.Name.StartsWith("경력_", StringComparison.Ordinal);
|
return template.Name.StartsWith("경력_", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsAllCandidateTemplate(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return template.Name.StartsWith("모든후보_", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCurrentLeaderTemplate(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return template.Name.StartsWith("이시각1위_", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCandidatePagedTemplate(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return IsCareerTemplate(template) || IsAllCandidateTemplate(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveCandidatePageSize(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
if (!IsAllCandidateTemplate(template))
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.SceneWidth >= 5000 ||
|
||||||
|
template.Name.Contains("5760", StringComparison.Ordinal) ||
|
||||||
|
template.Id.Contains("_L", StringComparison.Ordinal)
|
||||||
|
? 3
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveCurrentLeaderPageSize(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
if (template.Name.Contains("_L", StringComparison.Ordinal) ||
|
||||||
|
template.Id.Contains("_L", StringComparison.Ordinal) ||
|
||||||
|
template.SceneWidth >= 5000)
|
||||||
|
{
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.Name.Contains("_HD", StringComparison.Ordinal) ||
|
||||||
|
template.Id.Contains("_HD", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesAllCandidateLoopScene(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return ResolveCandidatePageSize(template) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
private IReadOnlyList<FormatCutDefinition> ResolveCuts(FormatTemplateDefinition template, BroadcastStationProfile station)
|
private IReadOnlyList<FormatCutDefinition> ResolveCuts(FormatTemplateDefinition template, BroadcastStationProfile station)
|
||||||
{
|
{
|
||||||
if (template.LoopMode != LoopMode.StationRegions)
|
if (template.LoopMode != LoopMode.StationRegions)
|
||||||
|
|||||||
67
Tornado3_2026Election/Services/CutCategoryResolver.cs
Normal file
67
Tornado3_2026Election/Services/CutCategoryResolver.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Tornado3_2026Election.Domain;
|
||||||
|
|
||||||
|
namespace Tornado3_2026Election.Services;
|
||||||
|
|
||||||
|
public static class CutCategoryResolver
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyList<CutCategory> OrderedCategories =
|
||||||
|
[
|
||||||
|
CutCategory.MetropolitanHead,
|
||||||
|
CutCategory.LocalHead,
|
||||||
|
CutCategory.Superintendent,
|
||||||
|
CutCategory.MetropolitanCouncil,
|
||||||
|
CutCategory.LocalCouncil,
|
||||||
|
CutCategory.NationalAssembly,
|
||||||
|
CutCategory.PreElection,
|
||||||
|
CutCategory.Historical,
|
||||||
|
CutCategory.Turnout,
|
||||||
|
CutCategory.Title
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories;
|
||||||
|
|
||||||
|
public static bool IsMatch(FormatTemplateDefinition template, CutCategory category)
|
||||||
|
{
|
||||||
|
var formatName = template.Name ?? string.Empty;
|
||||||
|
|
||||||
|
return category switch
|
||||||
|
{
|
||||||
|
CutCategory.MetropolitanHead => Contains(formatName, "광역단체장"),
|
||||||
|
CutCategory.LocalHead => Contains(formatName, "기초단체장"),
|
||||||
|
CutCategory.Superintendent => Contains(formatName, "교육감"),
|
||||||
|
CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"),
|
||||||
|
CutCategory.LocalCouncil => Contains(formatName, "기초의원"),
|
||||||
|
CutCategory.NationalAssembly => Contains(formatName, "보궐선거") ||
|
||||||
|
Contains(formatName, "국회의원"),
|
||||||
|
CutCategory.PreElection => Contains(formatName, "사전"),
|
||||||
|
CutCategory.Historical => Contains(formatName, "역대"),
|
||||||
|
CutCategory.Turnout => Contains(formatName, "투표율"),
|
||||||
|
CutCategory.Title => Contains(formatName, "타이틀"),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetLabel(CutCategory category)
|
||||||
|
{
|
||||||
|
return category switch
|
||||||
|
{
|
||||||
|
CutCategory.LocalHead => "기초단체장",
|
||||||
|
CutCategory.Superintendent => "교육감",
|
||||||
|
CutCategory.MetropolitanCouncil => "광역의원",
|
||||||
|
CutCategory.LocalCouncil => "기초의원",
|
||||||
|
CutCategory.NationalAssembly => "국회의원",
|
||||||
|
CutCategory.PreElection => "사전",
|
||||||
|
CutCategory.Historical => "역대",
|
||||||
|
CutCategory.Turnout => "투표율",
|
||||||
|
CutCategory.Title => "타이틀",
|
||||||
|
_ => "광역단체장"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Contains(string value, string token)
|
||||||
|
{
|
||||||
|
return value.Contains(token, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,8 +119,10 @@ public sealed class FormatCatalogService
|
|||||||
"역대시도판세_기초단체장",
|
"역대시도판세_기초단체장",
|
||||||
"이시각1위_광역단체장",
|
"이시각1위_광역단체장",
|
||||||
"이시각1위_광역단체장_HD",
|
"이시각1위_광역단체장_HD",
|
||||||
|
"이시각1위_광역단체장_L",
|
||||||
"이시각1위_기초단체장",
|
"이시각1위_기초단체장",
|
||||||
"이시각1위_기초단체장_HD",
|
"이시각1위_기초단체장_HD",
|
||||||
|
"이시각1위_기초단체장_L",
|
||||||
"접전_광역단체장",
|
"접전_광역단체장",
|
||||||
"접전_기초단체장",
|
"접전_기초단체장",
|
||||||
"초접전_광역단체장",
|
"초접전_광역단체장",
|
||||||
@@ -178,7 +180,8 @@ public sealed class FormatCatalogService
|
|||||||
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
|
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
|
||||||
RequiresCandidateData = !isPreElectionOnlyFormat &&
|
RequiresCandidateData = !isPreElectionOnlyFormat &&
|
||||||
!IsHistoricalPreElectionWinnerFormat(baseName) &&
|
!IsHistoricalPreElectionWinnerFormat(baseName) &&
|
||||||
!ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(baseName),
|
!ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(baseName) &&
|
||||||
|
!ScheduleTemplatePolicy.IsTitleFormat(baseName),
|
||||||
LoopMode = LoopMode.None,
|
LoopMode = LoopMode.None,
|
||||||
SceneWidth = sceneResolution?.Width,
|
SceneWidth = sceneResolution?.Width,
|
||||||
SceneHeight = sceneResolution?.Height,
|
SceneHeight = sceneResolution?.Height,
|
||||||
@@ -206,12 +209,38 @@ public sealed class FormatCatalogService
|
|||||||
{
|
{
|
||||||
return new Dictionary<string, string>(StringComparer.Ordinal)
|
return new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "1-2위_광역단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "1-2위_광역단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "1-2위_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "1-2위_기초단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "1-3위_광역단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "1-3위_광역단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "1-3위_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "1-3위_기초단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "1위_광역단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "1위_광역단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "1위_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "1위_기초단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "당선_광역단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_광역단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "당선_광역의원_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_광역의원"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "당선_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_기초단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "당선_기초의원_loop")] = Path.Combine("Elect2026_Bottom_민방", "당선_기초의원"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "사전투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "사전투표율"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "전후보_광역단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_광역단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "전후보_교육감_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_교육감"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "전후보_기초단체장_loop")] = Path.Combine("Elect2026_Bottom_민방", "전후보_기초단체장"),
|
||||||
|
[Path.Combine("Elect2026_Bottom_민방", "투표율_loop")] = Path.Combine("Elect2026_Bottom_민방", "투표율"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "1-2위_ani_광역단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_광역단체장"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_ani_기초단체장_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-2위_광역단체장_5760"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "1-3위_ani_광역단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "1-3위_ani_광역단체장"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "1-3위_ani_기초단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "1-3위_ani_기초단체장"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_5760_loop")] = Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_L_1")] = Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_L_1")] = Path.Combine("Elect2026_Normal_민방", "1-3위_기초단체장_5760"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "1-3위_보궐선거_loop")] = Path.Combine("Elect2026_Normal_민방", "1-3위_보궐선거"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "경력_광역단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "경력_광역단체장_in"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "경력_기초단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "경력_기초단체장_in"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "광역의원표_loop")] = Path.Combine("Elect2026_Normal_민방", "광역의원표"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "광역의원표_HD_loop")] = Path.Combine("Elect2026_Normal_민방", "광역의원표_HD"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "광역의원표_L")] = Path.Combine("Elect2026_Normal_민방", "광역의원표_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "광역의원표_L")] = Path.Combine("Elect2026_Normal_민방", "광역의원표_HD"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "광역의원표_L_1")] = Path.Combine("Elect2026_Normal_민방", "광역의원표_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "광역의원표_L_1")] = Path.Combine("Elect2026_Normal_민방", "광역의원표_HD"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "기초의원표_loop")] = Path.Combine("Elect2026_Normal_민방", "기초의원표"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "기초의원표_HD_loop")] = Path.Combine("Elect2026_Normal_민방", "기초의원표_HD"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "기초의원표_L")] = Path.Combine("Elect2026_Normal_민방", "기초의원표_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "기초의원표_L")] = Path.Combine("Elect2026_Normal_민방", "기초의원표_HD"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "기초의원표_L_1")] = Path.Combine("Elect2026_Normal_민방", "기초의원표_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "기초의원표_L_1")] = Path.Combine("Elect2026_Normal_민방", "기초의원표_HD"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "당선_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "당선_광역단체장_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "당선_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "당선_광역단체장_HD"),
|
||||||
@@ -219,14 +248,17 @@ public sealed class FormatCatalogService
|
|||||||
[Path.Combine("Elect2026_Normal_민방", "당선_교육감_L")] = Path.Combine("Elect2026_Normal_민방", "당선_교육감_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "당선_교육감_L")] = Path.Combine("Elect2026_Normal_민방", "당선_교육감_HD"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "당선_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "당선_기초단체장_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "당선_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "당선_기초단체장_HD"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "당선_기초의원_L")] = Path.Combine("Elect2026_Normal_민방", "당선_기초의원_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "당선_기초의원_L")] = Path.Combine("Elect2026_Normal_민방", "당선_기초의원_HD"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_L_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_L_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_loop")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_L")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_L")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_L_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_L_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
|
||||||
@@ -237,10 +269,13 @@ public sealed class FormatCatalogService
|
|||||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760_loop")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760_loop")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "이시각1위_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "이시각1위_기초단체장_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_7680")] = Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_5760")
|
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_7680")] = Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_5760"),
|
||||||
|
[Path.Combine("Elect2026_Top_민방", "투표율_loop")] = Path.Combine("Elect2026_Top_민방", "투표율"),
|
||||||
|
[Path.Combine("Elect2026_Top_민방", "투표율_선거구별_loop")] = Path.Combine("Elect2026_Top_민방", "투표율_선거구별")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,21 @@ internal static class KarismaSceneResolver
|
|||||||
bool useLoop,
|
bool useLoop,
|
||||||
bool useEnd = false)
|
bool useEnd = false)
|
||||||
{
|
{
|
||||||
var baseScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
|
return ResolveScene(template, null, t3CutPath, useLoop, useEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KarismaResolvedScene ResolveScene(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
FormatCutDefinition? cut,
|
||||||
|
string t3CutPath,
|
||||||
|
bool useLoop,
|
||||||
|
bool useEnd = false)
|
||||||
|
{
|
||||||
|
var sceneId = string.IsNullOrWhiteSpace(cut?.SceneIdOverride)
|
||||||
|
? template.Id
|
||||||
|
: cut.SceneIdOverride!;
|
||||||
|
var hasSceneOverride = !string.IsNullOrWhiteSpace(cut?.SceneIdOverride);
|
||||||
|
var baseScenePath = Path.Combine(t3CutPath, sceneId + ".tscn");
|
||||||
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
|
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
|
||||||
var endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn");
|
var endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn");
|
||||||
|
|
||||||
@@ -21,7 +35,7 @@ internal static class KarismaSceneResolver
|
|||||||
{
|
{
|
||||||
selectedPath = endScenePath;
|
selectedPath = endScenePath;
|
||||||
}
|
}
|
||||||
else if (useLoop && File.Exists(loopScenePath))
|
else if (!hasSceneOverride && useLoop && File.Exists(loopScenePath))
|
||||||
{
|
{
|
||||||
selectedPath = loopScenePath;
|
selectedPath = loopScenePath;
|
||||||
}
|
}
|
||||||
@@ -29,13 +43,13 @@ internal static class KarismaSceneResolver
|
|||||||
{
|
{
|
||||||
selectedPath = baseScenePath;
|
selectedPath = baseScenePath;
|
||||||
}
|
}
|
||||||
else if (File.Exists(loopScenePath))
|
else if (!hasSceneOverride && File.Exists(loopScenePath))
|
||||||
{
|
{
|
||||||
selectedPath = loopScenePath;
|
selectedPath = loopScenePath;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
throw new FileNotFoundException($"Karisma cut file was not found for '{template.Id}'.", baseScenePath);
|
throw new FileNotFoundException($"Karisma cut file was not found for '{sceneId}'.", baseScenePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new KarismaResolvedScene(
|
return new KarismaResolvedScene(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ internal static class PartyColorCatalog
|
|||||||
private const int DefaultBarHeight = 174;
|
private const int DefaultBarHeight = 174;
|
||||||
private const int DefaultPlateWidth = 552;
|
private const int DefaultPlateWidth = 552;
|
||||||
private const int DefaultPlateHeight = 736;
|
private const int DefaultPlateHeight = 736;
|
||||||
|
private const int DefaultSymbolWidth = 31;
|
||||||
|
private const int DefaultSymbolHeight = 30;
|
||||||
|
|
||||||
private static readonly Regex StyleTargetPattern = new(@"(?<target>face|edge|shadow|underline|frame)(?:\s*(?<order>\d+)번째)?", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
private static readonly Regex StyleTargetPattern = new(@"(?<target>face|edge|shadow|underline|frame)(?:\s*(?<order>\d+)번째)?", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
private static readonly Regex RgbRowPattern = new(@"^(?<party>.+?)\s+(?<r>\d{1,3})\s+(?<g>\d{1,3})\s+(?<b>\d{1,3})\s*$", RegexOptions.Compiled);
|
private static readonly Regex RgbRowPattern = new(@"^(?<party>.+?)\s+(?<r>\d{1,3})\s+(?<g>\d{1,3})\s+(?<b>\d{1,3})\s*$", RegexOptions.Compiled);
|
||||||
@@ -24,6 +26,13 @@ internal static class PartyColorCatalog
|
|||||||
private static readonly Regex InvalidFileNamePattern = new(@"[^\p{L}\p{Nd}_-]+", RegexOptions.Compiled);
|
private static readonly Regex InvalidFileNamePattern = new(@"[^\p{L}\p{Nd}_-]+", RegexOptions.Compiled);
|
||||||
private static readonly ConcurrentDictionary<string, CachedCatalog> CatalogCache = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly ConcurrentDictionary<string, CachedCatalog> CatalogCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private static readonly IReadOnlyDictionary<string, string> ExplicitRgbSpecMap = BuildExplicitRgbSpecMap();
|
private static readonly IReadOnlyDictionary<string, string> ExplicitRgbSpecMap = BuildExplicitRgbSpecMap();
|
||||||
|
private static readonly string[] OtherPartyFallbackKeys =
|
||||||
|
[
|
||||||
|
"무기타",
|
||||||
|
"무소속기타",
|
||||||
|
"기타",
|
||||||
|
"무소속"
|
||||||
|
];
|
||||||
|
|
||||||
public static string ResolveFallbackAssetPath(
|
public static string ResolveFallbackAssetPath(
|
||||||
string templateFolderPath,
|
string templateFolderPath,
|
||||||
@@ -65,7 +74,7 @@ internal static class PartyColorCatalog
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName))
|
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName).Concat(OtherPartyFallbackKeys))
|
||||||
{
|
{
|
||||||
if (!section.PartyColors.TryGetValue(candidatePartyName, out var color))
|
if (!section.PartyColors.TryGetValue(candidatePartyName, out var color))
|
||||||
{
|
{
|
||||||
@@ -453,7 +462,7 @@ internal static class PartyColorCatalog
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName))
|
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName).Concat(OtherPartyFallbackKeys))
|
||||||
{
|
{
|
||||||
if (section.PartyColors.TryGetValue(candidatePartyName, out color))
|
if (section.PartyColors.TryGetValue(candidatePartyName, out color))
|
||||||
{
|
{
|
||||||
@@ -477,6 +486,7 @@ internal static class PartyColorCatalog
|
|||||||
PartyColorAssetUsage.Outline => ["정당원", "정당색", "정당판", "정당바"],
|
PartyColorAssetUsage.Outline => ["정당원", "정당색", "정당판", "정당바"],
|
||||||
PartyColorAssetUsage.Color => ["정당색", "정당바", "정당판"],
|
PartyColorAssetUsage.Color => ["정당색", "정당바", "정당판"],
|
||||||
PartyColorAssetUsage.Group => ["그룹", "정당바", "정당판"],
|
PartyColorAssetUsage.Group => ["그룹", "정당바", "정당판"],
|
||||||
|
PartyColorAssetUsage.Symbol => ["정당심볼", "정당바", "정당판"],
|
||||||
_ => Array.Empty<string>()
|
_ => Array.Empty<string>()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -561,16 +571,19 @@ internal static class PartyColorCatalog
|
|||||||
var safeTemplateName = SanitizeFileName(templateName);
|
var safeTemplateName = SanitizeFileName(templateName);
|
||||||
var safePartyName = SanitizeFileName(partyName);
|
var safePartyName = SanitizeFileName(partyName);
|
||||||
var safeSectionName = SanitizeFileName(sectionName);
|
var safeSectionName = SanitizeFileName(sectionName);
|
||||||
var fileName = $"{safeTemplateName}_{usage}_{safeSectionName}_{safePartyName}_{color.R}_{color.G}_{color.B}.png";
|
var (width, height) = usage switch
|
||||||
|
{
|
||||||
|
PartyColorAssetUsage.Symbol => (DefaultSymbolWidth, DefaultSymbolHeight),
|
||||||
|
PartyColorAssetUsage.Bar or PartyColorAssetUsage.Group => (DefaultBarWidth, DefaultBarHeight),
|
||||||
|
_ => (DefaultPlateWidth, DefaultPlateHeight)
|
||||||
|
};
|
||||||
|
var fileName = $"{safeTemplateName}_{usage}_{safeSectionName}_{safePartyName}_{color.R}_{color.G}_{color.B}_{width}x{height}.png";
|
||||||
var filePath = Path.Combine(cacheDirectory, fileName);
|
var filePath = Path.Combine(cacheDirectory, fileName);
|
||||||
if (File.Exists(filePath))
|
if (File.Exists(filePath))
|
||||||
{
|
{
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (width, height) = usage == PartyColorAssetUsage.Bar
|
|
||||||
? (DefaultBarWidth, DefaultBarHeight)
|
|
||||||
: (DefaultPlateWidth, DefaultPlateHeight);
|
|
||||||
WriteSolidColorPng(filePath, width, height, color);
|
WriteSolidColorPng(filePath, width, height, color);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
@@ -910,7 +923,8 @@ internal enum PartyColorAssetUsage
|
|||||||
Plate,
|
Plate,
|
||||||
Outline,
|
Outline,
|
||||||
Color,
|
Color,
|
||||||
Group
|
Group,
|
||||||
|
Symbol
|
||||||
}
|
}
|
||||||
|
|
||||||
internal readonly record struct PartyStyleColorSpec(
|
internal readonly record struct PartyStyleColorSpec(
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ public sealed class PreElectionHistoryService
|
|||||||
{
|
{
|
||||||
_logService = logService;
|
_logService = logService;
|
||||||
_assetPath = ResolveAssetPath();
|
_assetPath = ResolveAssetPath();
|
||||||
_catalog = LoadCatalog(_assetPath);
|
_catalog = NormalizeCatalog(LoadCatalog(_assetPath));
|
||||||
_recordsByElectionType = new Dictionary<string, IReadOnlyList<PreElectionHistoryRecord>>(StringComparer.Ordinal);
|
_recordsByElectionType = new Dictionary<string, IReadOnlyList<PreElectionHistoryRecord>>(StringComparer.Ordinal);
|
||||||
_recordsByLookupKey = new Dictionary<string, IReadOnlyDictionary<string, PreElectionHistoryRecord>>(StringComparer.Ordinal);
|
_recordsByLookupKey = new Dictionary<string, IReadOnlyDictionary<string, PreElectionHistoryRecord>>(StringComparer.Ordinal);
|
||||||
RebuildIndexes();
|
RebuildIndexes();
|
||||||
@@ -96,22 +96,8 @@ public sealed class PreElectionHistoryService
|
|||||||
throw new ArgumentNullException(nameof(record));
|
throw new ArgumentNullException(nameof(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
var canonicalElectionType = NormalizeElectionType(record.ElectionType);
|
var normalizedRecord = NormalizeRecord(record);
|
||||||
var normalizedRecord = new PreElectionHistoryRecord
|
var canonicalElectionType = normalizedRecord.ElectionType;
|
||||||
{
|
|
||||||
ElectionType = canonicalElectionType,
|
|
||||||
Key = record.Key ?? string.Empty,
|
|
||||||
RegionKey = record.RegionKey ?? string.Empty,
|
|
||||||
RegionName = record.RegionName ?? string.Empty,
|
|
||||||
DistrictName = record.DistrictName ?? string.Empty,
|
|
||||||
DisplayName = record.DisplayName ?? string.Empty,
|
|
||||||
TurnoutHistory = (record.TurnoutHistory ?? Array.Empty<PreElectionHistoricalTurnoutEntry>())
|
|
||||||
.OrderBy(entry => entry.ElectionOrder)
|
|
||||||
.ToArray(),
|
|
||||||
WinnerHistory = (record.WinnerHistory ?? Array.Empty<PreElectionHistoricalWinnerEntry>())
|
|
||||||
.OrderBy(entry => entry.ElectionOrder)
|
|
||||||
.ToArray()
|
|
||||||
};
|
|
||||||
|
|
||||||
var records = _catalog.Records.ToList();
|
var records = _catalog.Records.ToList();
|
||||||
var existingIndex = records.FindIndex(existingRecord =>
|
var existingIndex = records.FindIndex(existingRecord =>
|
||||||
@@ -141,11 +127,123 @@ public sealed class PreElectionHistoryService
|
|||||||
.ThenBy(existingRecord => existingRecord.DisplayName, StringComparer.Ordinal)
|
.ThenBy(existingRecord => existingRecord.DisplayName, StringComparer.Ordinal)
|
||||||
.ToArray()
|
.ToArray()
|
||||||
};
|
};
|
||||||
|
_catalog = NormalizeCatalog(_catalog);
|
||||||
|
|
||||||
PersistCatalog();
|
PersistCatalog();
|
||||||
RebuildIndexes();
|
RebuildIndexes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PreElectionHistoryCatalog NormalizeCatalog(PreElectionHistoryCatalog catalog)
|
||||||
|
{
|
||||||
|
var records = catalog.Records
|
||||||
|
.Select(NormalizeRecord)
|
||||||
|
.GroupBy(record => $"{record.ElectionType}|{record.Key}", StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(MergeRecordGroup)
|
||||||
|
.OrderBy(record => Array.IndexOf(SupportedElectionTypes, record.ElectionType))
|
||||||
|
.ThenBy(record => record.DisplayName, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new PreElectionHistoryCatalog
|
||||||
|
{
|
||||||
|
Metadata = catalog.Metadata,
|
||||||
|
Records = records
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PreElectionHistoryRecord NormalizeRecord(PreElectionHistoryRecord record)
|
||||||
|
{
|
||||||
|
var canonicalElectionType = NormalizeElectionType(record.ElectionType);
|
||||||
|
var regionName = record.RegionName ?? string.Empty;
|
||||||
|
var regionKey = string.IsNullOrWhiteSpace(record.RegionKey)
|
||||||
|
? NormalizeRegionKey(regionName)
|
||||||
|
: record.RegionKey.Trim();
|
||||||
|
var districtName = record.DistrictName ?? string.Empty;
|
||||||
|
var displayName = record.DisplayName ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.Equals(canonicalElectionType, "기초단체장", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
regionKey = string.IsNullOrWhiteSpace(regionKey)
|
||||||
|
? ResolveRegionKey(regionName, districtName, [displayName, record.Key])
|
||||||
|
: regionKey;
|
||||||
|
districtName = NormalizeBasicDistrictDisplayName(districtName, displayName, regionName);
|
||||||
|
var districtKey = NormalizeBasicDistrictToken(districtName);
|
||||||
|
var recordKey = BuildBasicLookupKey(regionKey, districtKey);
|
||||||
|
displayName = string.IsNullOrWhiteSpace(regionName) || string.IsNullOrWhiteSpace(districtName)
|
||||||
|
? displayName.Trim()
|
||||||
|
: $"{regionName.Trim()} {districtName}";
|
||||||
|
|
||||||
|
return new PreElectionHistoryRecord
|
||||||
|
{
|
||||||
|
ElectionType = canonicalElectionType,
|
||||||
|
Key = string.IsNullOrWhiteSpace(recordKey) ? record.Key ?? string.Empty : recordKey,
|
||||||
|
RegionKey = regionKey,
|
||||||
|
RegionName = regionName,
|
||||||
|
DistrictName = districtName,
|
||||||
|
DisplayName = displayName,
|
||||||
|
TurnoutHistory = SortTurnoutHistory(record.TurnoutHistory),
|
||||||
|
WinnerHistory = SortWinnerHistory(record.WinnerHistory)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PreElectionHistoryRecord
|
||||||
|
{
|
||||||
|
ElectionType = canonicalElectionType,
|
||||||
|
Key = record.Key ?? string.Empty,
|
||||||
|
RegionKey = regionKey,
|
||||||
|
RegionName = regionName,
|
||||||
|
DistrictName = districtName,
|
||||||
|
DisplayName = displayName,
|
||||||
|
TurnoutHistory = SortTurnoutHistory(record.TurnoutHistory),
|
||||||
|
WinnerHistory = SortWinnerHistory(record.WinnerHistory)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PreElectionHistoryRecord MergeRecordGroup(IGrouping<string, PreElectionHistoryRecord> records)
|
||||||
|
{
|
||||||
|
var primary = records
|
||||||
|
.OrderBy(record => record.DisplayName.Contains('(') ? 1 : 0)
|
||||||
|
.ThenByDescending(record => record.WinnerHistory.Count + record.TurnoutHistory.Count)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
return new PreElectionHistoryRecord
|
||||||
|
{
|
||||||
|
ElectionType = primary.ElectionType,
|
||||||
|
Key = primary.Key,
|
||||||
|
RegionKey = primary.RegionKey,
|
||||||
|
RegionName = primary.RegionName,
|
||||||
|
DistrictName = primary.DistrictName,
|
||||||
|
DisplayName = primary.DisplayName,
|
||||||
|
TurnoutHistory = records
|
||||||
|
.SelectMany(record => record.TurnoutHistory)
|
||||||
|
.GroupBy(entry => entry.ElectionOrder)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderBy(entry => entry.ElectionOrder)
|
||||||
|
.ToArray(),
|
||||||
|
WinnerHistory = records
|
||||||
|
.SelectMany(record => record.WinnerHistory)
|
||||||
|
.GroupBy(entry => entry.ElectionOrder)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderBy(entry => entry.ElectionOrder)
|
||||||
|
.ToArray()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> SortTurnoutHistory(
|
||||||
|
IReadOnlyList<PreElectionHistoricalTurnoutEntry>? entries)
|
||||||
|
{
|
||||||
|
return (entries ?? Array.Empty<PreElectionHistoricalTurnoutEntry>())
|
||||||
|
.OrderBy(entry => entry.ElectionOrder)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PreElectionHistoricalWinnerEntry> SortWinnerHistory(
|
||||||
|
IReadOnlyList<PreElectionHistoricalWinnerEntry>? entries)
|
||||||
|
{
|
||||||
|
return (entries ?? Array.Empty<PreElectionHistoricalWinnerEntry>())
|
||||||
|
.OrderBy(entry => entry.ElectionOrder)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
private void RebuildIndexes()
|
private void RebuildIndexes()
|
||||||
{
|
{
|
||||||
_recordsByElectionType = SupportedElectionTypes.ToDictionary(
|
_recordsByElectionType = SupportedElectionTypes.ToDictionary(
|
||||||
@@ -382,7 +480,7 @@ public sealed class PreElectionHistoryService
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalized = value.Trim();
|
var normalized = StripBasicDistrictDisambiguation(value.Trim());
|
||||||
foreach (var regionLabel in RegionLabels)
|
foreach (var regionLabel in RegionLabels)
|
||||||
{
|
{
|
||||||
normalized = normalized.Replace(regionLabel, string.Empty, StringComparison.OrdinalIgnoreCase);
|
normalized = normalized.Replace(regionLabel, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -393,12 +491,44 @@ public sealed class PreElectionHistoryService
|
|||||||
.Replace("군수", "군", StringComparison.Ordinal)
|
.Replace("군수", "군", StringComparison.Ordinal)
|
||||||
.Replace("시장", "시", StringComparison.Ordinal)
|
.Replace("시장", "시", StringComparison.Ordinal)
|
||||||
.Replace("교육감", string.Empty, StringComparison.Ordinal)
|
.Replace("교육감", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("()", string.Empty, StringComparison.Ordinal)
|
||||||
.Replace(" ", string.Empty, StringComparison.Ordinal)
|
.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||||
.Trim();
|
.Trim();
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeBasicDistrictDisplayName(
|
||||||
|
string? districtName,
|
||||||
|
string? displayName,
|
||||||
|
string? regionName)
|
||||||
|
{
|
||||||
|
var normalized = string.IsNullOrWhiteSpace(districtName)
|
||||||
|
? displayName?.Trim() ?? string.Empty
|
||||||
|
: districtName.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(regionName) &&
|
||||||
|
normalized.StartsWith(regionName.Trim(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
normalized = normalized[regionName.Trim().Length..].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return StripBasicDistrictDisambiguation(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripBasicDistrictDisambiguation(string value)
|
||||||
|
{
|
||||||
|
var normalized = value.Trim();
|
||||||
|
foreach (var regionLabel in RegionLabels)
|
||||||
|
{
|
||||||
|
normalized = normalized.Replace($"({regionLabel})", string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
.Replace("()", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
private void PersistCatalog()
|
private void PersistCatalog()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_assetPath))
|
if (string.IsNullOrWhiteSpace(_assetPath))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -15,7 +16,8 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
private const string BasicApiBaseUrlEnvironmentVariable = "SBS_BASIC_API_BASE_URL";
|
private const string BasicApiBaseUrlEnvironmentVariable = "SBS_BASIC_API_BASE_URL";
|
||||||
private const string BasicApiModeEnvironmentVariable = "SBS_BASIC_API_MODE";
|
private const string BasicApiModeEnvironmentVariable = "SBS_BASIC_API_MODE";
|
||||||
private static readonly TimeSpan BasicCouncilCountingCacheDuration = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan ApiResponseCacheDuration = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly TimeSpan BasicCouncilCountingCacheDuration = ApiResponseCacheDuration;
|
||||||
private static readonly Uri LegacyBaseUri = new("http://202.31.153.141:8421/");
|
private static readonly Uri LegacyBaseUri = new("http://202.31.153.141:8421/");
|
||||||
private static readonly Uri BasicApiBaseUri = ResolveBasicApiBaseUri();
|
private static readonly Uri BasicApiBaseUri = ResolveBasicApiBaseUri();
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
@@ -29,9 +31,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
["국회의원"] = new SbsElectionConfiguration(2, false, LegacyBaseUri, "gaepyo"),
|
["국회의원"] = new SbsElectionConfiguration(2, false, LegacyBaseUri, "gaepyo"),
|
||||||
["광역단체장"] = new SbsElectionConfiguration(3, true, LegacyBaseUri, "gaepyo"),
|
["광역단체장"] = new SbsElectionConfiguration(3, true, LegacyBaseUri, "gaepyo"),
|
||||||
["교육감"] = new SbsElectionConfiguration(11, false, LegacyBaseUri, "gaepyo"),
|
["교육감"] = new SbsElectionConfiguration(11, true, LegacyBaseUri, "gaepyo"),
|
||||||
["광역의원"] = new SbsElectionConfiguration(5, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment()),
|
["광역의원"] = new SbsElectionConfiguration(5, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment()),
|
||||||
["기초단체장"] = new SbsElectionConfiguration(4, false, LegacyBaseUri, "gaepyo"),
|
["기초단체장"] = new SbsElectionConfiguration(4, true, LegacyBaseUri, "gaepyo"),
|
||||||
["기초의원"] = new SbsElectionConfiguration(6, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment())
|
["기초의원"] = new SbsElectionConfiguration(6, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment())
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,6 +107,8 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
private readonly bool _disposeHttpClient;
|
private readonly bool _disposeHttpClient;
|
||||||
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
|
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
|
||||||
private readonly Dictionary<string, IReadOnlyList<SbsRegionInfo>> _districtRegions = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, IReadOnlyList<SbsRegionInfo>> _districtRegions = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, JsonCacheEntry> _jsonCache = new(StringComparer.Ordinal);
|
||||||
|
private readonly SemaphoreSlim _jsonCacheLock = new(1, 1);
|
||||||
private readonly Dictionary<string, SbsCountingCacheEntry> _countingCache = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, SbsCountingCacheEntry> _countingCache = new(StringComparer.Ordinal);
|
||||||
private readonly SemaphoreSlim _countingCacheLock = new(1, 1);
|
private readonly SemaphoreSlim _countingCacheLock = new(1, 1);
|
||||||
|
|
||||||
@@ -166,6 +170,13 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
})
|
})
|
||||||
.Where(item => !string.IsNullOrWhiteSpace(item.Option.DisplayName));
|
.Where(item => !string.IsNullOrWhiteSpace(item.Option.DisplayName));
|
||||||
|
|
||||||
|
if (configuration.SungerType == 11)
|
||||||
|
{
|
||||||
|
return options
|
||||||
|
.Select(item => item.Option)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
if (configuration.SungerType is 2 or 3 or 4 or 5 or 6)
|
if (configuration.SungerType is 2 or 3 or 4 or 5 or 6)
|
||||||
{
|
{
|
||||||
return options
|
return options
|
||||||
@@ -229,13 +240,22 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
|
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
|
||||||
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
|
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
|
||||||
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
|
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
|
||||||
|
var judgementCandidates = (item.Hubojas ?? [])
|
||||||
|
.Select(MapCandidate)
|
||||||
|
.Where(candidate => candidate.EffectiveJudgement != CandidateJudgement.None)
|
||||||
|
.OrderBy(candidate => ResolveJudgementDisplayPriority(candidate.EffectiveJudgement))
|
||||||
|
.ThenByDescending(candidate => candidate.VoteCount)
|
||||||
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
overviewItems.Add((order, new CountingOverviewItem(
|
overviewItems.Add((order, new CountingOverviewItem(
|
||||||
DisplayName: districtOption.DisplayName,
|
DisplayName: districtOption.DisplayName,
|
||||||
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
|
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
|
||||||
CountedVotes: countedVotes,
|
CountedVotes: countedVotes,
|
||||||
TotalVotes: totalVotes,
|
TotalVotes: totalVotes,
|
||||||
UncountedVotes: Math.Max(0, uncountedVotes))));
|
UncountedVotes: Math.Max(0, uncountedVotes),
|
||||||
|
JudgementBadgeText: BuildOverviewJudgementBadgeText(judgementCandidates),
|
||||||
|
JudgementDetailText: BuildOverviewJudgementDetailText(judgementCandidates))));
|
||||||
}
|
}
|
||||||
|
|
||||||
return overviewItems
|
return overviewItems
|
||||||
@@ -315,11 +335,22 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
DateTimeOffset.Now);
|
DateTimeOffset.Now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var turnoutQuery = ResolveTurnoutQuery(configuration);
|
||||||
|
if (!turnoutQuery.HasValue)
|
||||||
|
{
|
||||||
|
return new TurnoutOverviewResult(
|
||||||
|
Array.Empty<TurnoutOverviewItem>(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
DateTimeOffset.Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
var turnoutQueryValue = turnoutQuery.Value;
|
||||||
var requestedDistricts = districts
|
var requestedDistricts = districts
|
||||||
.Select(district => new
|
.Select(district => new
|
||||||
{
|
{
|
||||||
District = district,
|
District = district,
|
||||||
RegionCode = ResolveTurnoutRegionCode(district)
|
RegionCode = ResolveTurnoutRegionCode(configuration, district)
|
||||||
})
|
})
|
||||||
.Where(item => !string.IsNullOrWhiteSpace(item.RegionCode))
|
.Where(item => !string.IsNullOrWhiteSpace(item.RegionCode))
|
||||||
.GroupBy(item => item.RegionCode, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(item => item.RegionCode, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -348,7 +379,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
var ids = string.Join(",", districtChunk.Select(item => item.RegionCode));
|
var ids = string.Join(",", districtChunk.Select(item => item.RegionCode));
|
||||||
var items = await GetArrayAsync<SbsTurnoutItem>(
|
var items = await GetArrayAsync<SbsTurnoutItem>(
|
||||||
configuration.BaseUri,
|
configuration.BaseUri,
|
||||||
$"tupyo/{configuration.SungerType}/sidos?ids={ids}",
|
$"tupyo/{turnoutQueryValue.SungerType}/{turnoutQueryValue.RegionSegment}?ids={ids}",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
@@ -391,8 +422,20 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
DateTimeOffset.Now);
|
DateTimeOffset.Now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveTurnoutRegionCode(DistrictSelectionOption district)
|
private static string ResolveTurnoutRegionCode(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
DistrictSelectionOption district)
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(district.TurnoutRegionCode))
|
||||||
|
{
|
||||||
|
return district.TurnoutRegionCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration.SungerType == 4)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
return !string.IsNullOrWhiteSpace(district.ParentRegionCode)
|
return !string.IsNullOrWhiteSpace(district.ParentRegionCode)
|
||||||
? district.ParentRegionCode
|
? district.ParentRegionCode
|
||||||
: district.DistrictCode;
|
: district.DistrictCode;
|
||||||
@@ -400,6 +443,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_jsonCacheLock.Dispose();
|
||||||
_countingCacheLock.Dispose();
|
_countingCacheLock.Dispose();
|
||||||
if (_disposeHttpClient)
|
if (_disposeHttpClient)
|
||||||
{
|
{
|
||||||
@@ -419,22 +463,28 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
"선택한 선거 종류는 SBS API 문서 기준으로 투표율 연동 대상이 아닙니다.");
|
"선택한 선거 종류는 SBS API 문서 기준으로 투표율 연동 대상이 아닙니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var sido = await ResolveSidoRegionAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false);
|
var turnoutQuery = ResolveTurnoutQuery(configuration)
|
||||||
|
?? throw new InvalidOperationException("선택한 선거 종류는 투표율 연동 경로가 없습니다.");
|
||||||
|
var turnoutTarget = await ResolveTurnoutTargetAsync(
|
||||||
|
configuration,
|
||||||
|
districtName,
|
||||||
|
districtCode,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
var items = await GetArrayAsync<SbsTurnoutItem>(
|
var items = await GetArrayAsync<SbsTurnoutItem>(
|
||||||
configuration.BaseUri,
|
configuration.BaseUri,
|
||||||
$"tupyo/{configuration.SungerType}/sidos?ids={Uri.EscapeDataString(sido.Id)}",
|
$"tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={Uri.EscapeDataString(turnoutTarget.TurnoutRegionCode)}",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var item = items.FirstOrDefault()
|
var item = items.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.Region?.Id, turnoutTarget.TurnoutRegionCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? items.FirstOrDefault()
|
||||||
?? throw new InvalidOperationException("SBS API가 해당 지역의 투표 데이터를 반환하지 않았습니다.");
|
?? throw new InvalidOperationException("SBS API가 해당 지역의 투표 데이터를 반환하지 않았습니다.");
|
||||||
|
|
||||||
var regionName = ExpandRegionName(item.Region?.Name1 ?? item.Region?.Name ?? districtName);
|
|
||||||
var outputRegionName = BuildOutputRegionName(regionName);
|
|
||||||
return new SbsElectionRefreshResult(
|
return new SbsElectionRefreshResult(
|
||||||
DistrictName: regionName,
|
DistrictName: turnoutTarget.DisplayName,
|
||||||
DistrictCode: sido.Id,
|
DistrictCode: turnoutTarget.DistrictCode,
|
||||||
RegionName: outputRegionName,
|
RegionName: turnoutTarget.RegionName,
|
||||||
ElectionDistrictName: regionName,
|
ElectionDistrictName: turnoutTarget.ElectionDistrictName,
|
||||||
TotalExpectedVotes: item.Sungerinsu,
|
TotalExpectedVotes: item.Sungerinsu,
|
||||||
TurnoutVotes: item.Total?.Tupyosu ?? 0,
|
TurnoutVotes: item.Total?.Tupyosu ?? 0,
|
||||||
CountedRate: null,
|
CountedRate: null,
|
||||||
@@ -442,7 +492,58 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
RemainingVotes: null,
|
RemainingVotes: null,
|
||||||
Candidates: null,
|
Candidates: null,
|
||||||
ReceivedAt: DateTimeOffset.Now,
|
ReceivedAt: DateTimeOffset.Now,
|
||||||
SourcePath: $"GET /tupyo/{configuration.SungerType}/sidos?ids={sido.Id}");
|
SourcePath: $"GET /tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={turnoutTarget.TurnoutRegionCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TurnoutQueryDefinition? ResolveTurnoutQuery(SbsElectionConfiguration configuration)
|
||||||
|
{
|
||||||
|
return configuration.SungerType switch
|
||||||
|
{
|
||||||
|
3 => new TurnoutQueryDefinition(3, "sidos"),
|
||||||
|
4 => new TurnoutQueryDefinition(3, "sigungus"),
|
||||||
|
11 => new TurnoutQueryDefinition(3, "sidos"),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TurnoutTarget> ResolveTurnoutTargetAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
string districtName,
|
||||||
|
string districtCode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (configuration.SungerType == 3)
|
||||||
|
{
|
||||||
|
var sido = await ResolveSidoRegionAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false);
|
||||||
|
var regionName = ExpandRegionName(sido.Name1 ?? sido.Name);
|
||||||
|
return new TurnoutTarget(
|
||||||
|
DisplayName: regionName,
|
||||||
|
DistrictCode: sido.Id,
|
||||||
|
RegionName: BuildOutputRegionName(regionName),
|
||||||
|
ElectionDistrictName: regionName,
|
||||||
|
TurnoutRegionCode: !string.IsNullOrWhiteSpace(sido.Name1Id) ? sido.Name1Id : sido.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var district = await ResolveElectionDistrictAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false);
|
||||||
|
var districtOption = CreateDistrictSelectionOption(configuration.SungerType, district);
|
||||||
|
var turnoutRegionCode = configuration.SungerType switch
|
||||||
|
{
|
||||||
|
4 => district.Name2Id,
|
||||||
|
11 => district.Name1Id,
|
||||||
|
_ => districtOption.TurnoutRegionCode
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(turnoutRegionCode))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"투표율 조회용 지역 코드를 찾지 못했습니다: '{districtName}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TurnoutTarget(
|
||||||
|
DisplayName: districtOption.DisplayName,
|
||||||
|
DistrictCode: districtOption.DistrictCode,
|
||||||
|
RegionName: districtOption.RegionName,
|
||||||
|
ElectionDistrictName: districtOption.DistrictName,
|
||||||
|
TurnoutRegionCode: turnoutRegionCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SbsElectionRefreshResult> RefreshCountingAsync(
|
private async Task<SbsElectionRefreshResult> RefreshCountingAsync(
|
||||||
@@ -685,18 +786,100 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
|
|
||||||
private static CandidateJudgement MapJudgement(string? degree)
|
private static CandidateJudgement MapJudgement(string? degree)
|
||||||
{
|
{
|
||||||
return degree switch
|
return NormalizeJudgementCode(degree) switch
|
||||||
{
|
{
|
||||||
"40" => CandidateJudgement.Leading,
|
"40" or "유력" => CandidateJudgement.Leading,
|
||||||
"50" => CandidateJudgement.Confirmed,
|
"50" or "확정" or "확실" => CandidateJudgement.Confirmed,
|
||||||
"60" => CandidateJudgement.ElectedInProgress,
|
"60" or "개표중당선" => CandidateJudgement.ElectedInProgress,
|
||||||
"70" => CandidateJudgement.Elected,
|
"70" or "당선" => CandidateJudgement.Elected,
|
||||||
"80" => CandidateJudgement.UnopposedElected,
|
"80" or "무투표당선" => CandidateJudgement.UnopposedElected,
|
||||||
"90" => CandidateJudgement.ElectedAfterCountComplete,
|
"90" or "개표마감당선" => CandidateJudgement.ElectedAfterCountComplete,
|
||||||
_ => CandidateJudgement.None
|
_ => CandidateJudgement.None
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeJudgementCode(string? degree)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(degree))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = degree.Trim();
|
||||||
|
if (int.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numericCode))
|
||||||
|
{
|
||||||
|
return numericCode.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Concat(normalized.Where(character => !char.IsWhiteSpace(character)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveJudgementDisplayPriority(CandidateJudgement judgement)
|
||||||
|
{
|
||||||
|
return judgement switch
|
||||||
|
{
|
||||||
|
CandidateJudgement.Elected or
|
||||||
|
CandidateJudgement.ElectedInProgress or
|
||||||
|
CandidateJudgement.UnopposedElected or
|
||||||
|
CandidateJudgement.ElectedAfterCountComplete => 0,
|
||||||
|
CandidateJudgement.Confirmed => 1,
|
||||||
|
CandidateJudgement.Leading => 2,
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildOverviewJudgementBadgeText(IReadOnlyList<CandidateEntry> candidates)
|
||||||
|
{
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(
|
||||||
|
" · ",
|
||||||
|
candidates
|
||||||
|
.GroupBy(candidate => candidate.EffectiveJudgement)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var label = ResolveOverviewJudgementLabel(group.Key);
|
||||||
|
return group.Count() == 1 ? label : $"{label} {group.Count()}";
|
||||||
|
})
|
||||||
|
.Where(label => !string.IsNullOrWhiteSpace(label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildOverviewJudgementDetailText(IReadOnlyList<CandidateEntry> candidates)
|
||||||
|
{
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var names = candidates
|
||||||
|
.Take(2)
|
||||||
|
.Select(candidate => string.IsNullOrWhiteSpace(candidate.Party)
|
||||||
|
? candidate.Name
|
||||||
|
: $"{candidate.Name}({candidate.Party})")
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return candidates.Count <= names.Length
|
||||||
|
? string.Join(", ", names)
|
||||||
|
: $"{string.Join(", ", names)} 외 {candidates.Count - names.Length}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveOverviewJudgementLabel(CandidateJudgement judgement)
|
||||||
|
{
|
||||||
|
return judgement switch
|
||||||
|
{
|
||||||
|
CandidateJudgement.Leading => "유력",
|
||||||
|
CandidateJudgement.Confirmed => "확실",
|
||||||
|
CandidateJudgement.Elected => "당선",
|
||||||
|
CandidateJudgement.ElectedInProgress => "개표중 당선",
|
||||||
|
CandidateJudgement.UnopposedElected => "무투표 당선",
|
||||||
|
CandidateJudgement.ElectedAfterCountComplete => "개표마감 당선",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
|
private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
|
||||||
SbsElectionConfiguration configuration,
|
SbsElectionConfiguration configuration,
|
||||||
string districtName,
|
string districtName,
|
||||||
@@ -814,6 +997,14 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scopedSidoCodes.Count > 0)
|
||||||
|
{
|
||||||
|
var scopedSidoCodeSet = scopedSidoCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
regions = regions
|
||||||
|
.Where(region => string.IsNullOrWhiteSpace(region.Name1Id) || scopedSidoCodeSet.Contains(region.Name1Id))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
_districtRegions[cacheKey] = regions;
|
_districtRegions[cacheKey] = regions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,6 +1047,22 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
private async Task<string> GetJsonAsync(Uri baseUri, string relativePath, CancellationToken cancellationToken)
|
private async Task<string> GetJsonAsync(Uri baseUri, string relativePath, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var requestUri = new Uri(baseUri, relativePath);
|
var requestUri = new Uri(baseUri, relativePath);
|
||||||
|
var cacheKey = requestUri.AbsoluteUri;
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
|
await _jsonCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_jsonCache.TryGetValue(cacheKey, out var cached) &&
|
||||||
|
now - cached.ReceivedAt < ApiResponseCacheDuration)
|
||||||
|
{
|
||||||
|
return cached.Body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_jsonCacheLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var body = Encoding.UTF8.GetString(bytes);
|
var body = Encoding.UTF8.GetString(bytes);
|
||||||
@@ -867,6 +1074,16 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
$"SBS API 요청 실패: GET {requestUri.PathAndQuery} -> {(int)response.StatusCode} {response.ReasonPhrase}{detail}");
|
$"SBS API 요청 실패: GET {requestUri.PathAndQuery} -> {(int)response.StatusCode} {response.ReasonPhrase}{detail}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _jsonCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_jsonCache[cacheKey] = new JsonCacheEntry(DateTimeOffset.Now, body);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_jsonCacheLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1014,6 +1231,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
return sungerType switch
|
return sungerType switch
|
||||||
{
|
{
|
||||||
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
|
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
|
||||||
|
11 => BuildEducationOfficeLabel(regionName, region?.Name4 ?? fallback.Name4),
|
||||||
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region, fallback),
|
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region, fallback),
|
||||||
_ => regionName
|
_ => regionName
|
||||||
};
|
};
|
||||||
@@ -1050,34 +1268,94 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
|
|
||||||
private static string BuildMayorGovernorLabel(string regionName, string? officeName)
|
private static string BuildMayorGovernorLabel(string regionName, string? officeName)
|
||||||
{
|
{
|
||||||
var normalizedRegionName = ExpandRegionName(regionName);
|
|
||||||
if (!string.IsNullOrWhiteSpace(officeName))
|
if (!string.IsNullOrWhiteSpace(officeName))
|
||||||
{
|
{
|
||||||
var trimmedOfficeName = officeName.Trim();
|
return NormalizeMayorGovernorOfficeLabel(officeName);
|
||||||
if (trimmedOfficeName.EndsWith("시장", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return $"{NormalizeRegionName(normalizedRegionName)}시장";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmedOfficeName.EndsWith("지사", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return $"{normalizedRegionName}지사";
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmedOfficeName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedRegionName.EndsWith("시", StringComparison.Ordinal))
|
var expandedRegionName = ExpandRegionName(regionName);
|
||||||
|
if (string.IsNullOrWhiteSpace(expandedRegionName))
|
||||||
{
|
{
|
||||||
return $"{NormalizeRegionName(normalizedRegionName)}시장";
|
return regionName?.Trim() ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedRegionName.EndsWith("도", StringComparison.Ordinal))
|
var shortRegionName = NormalizeRegionName(expandedRegionName);
|
||||||
|
if (expandedRegionName.EndsWith("시", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return $"{normalizedRegionName}지사";
|
return $"{shortRegionName}시장";
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizedRegionName;
|
if (expandedRegionName.EndsWith("도", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return $"{shortRegionName}지사";
|
||||||
|
}
|
||||||
|
|
||||||
|
return expandedRegionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeMayorGovernorOfficeLabel(string officeName)
|
||||||
|
{
|
||||||
|
var trimmed = officeName.Trim();
|
||||||
|
var suffix = trimmed.EndsWith("시장", StringComparison.Ordinal)
|
||||||
|
? "시장"
|
||||||
|
: trimmed.EndsWith("지사", StringComparison.Ordinal)
|
||||||
|
? "지사"
|
||||||
|
: string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(suffix))
|
||||||
|
{
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
|
||||||
|
{
|
||||||
|
if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return $"{pair.Key}{suffix}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionPart = trimmed[..^suffix.Length];
|
||||||
|
var shortRegionName = NormalizeRegionName(regionPart);
|
||||||
|
return string.IsNullOrWhiteSpace(shortRegionName)
|
||||||
|
? trimmed
|
||||||
|
: $"{shortRegionName}{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildEducationOfficeLabel(string regionName, string? officeName)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(officeName))
|
||||||
|
{
|
||||||
|
return NormalizeEducationOfficeLabel(officeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortRegionName = NormalizeRegionName(regionName);
|
||||||
|
return string.IsNullOrWhiteSpace(shortRegionName)
|
||||||
|
? regionName?.Trim() ?? string.Empty
|
||||||
|
: $"{shortRegionName}교육감";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeEducationOfficeLabel(string officeName)
|
||||||
|
{
|
||||||
|
var trimmed = officeName.Trim();
|
||||||
|
const string suffix = "교육감";
|
||||||
|
if (!trimmed.EndsWith(suffix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
|
||||||
|
{
|
||||||
|
if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return $"{pair.Key}{suffix}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionPart = trimmed[..^suffix.Length];
|
||||||
|
var shortRegionName = NormalizeRegionName(regionPart);
|
||||||
|
return string.IsNullOrWhiteSpace(shortRegionName)
|
||||||
|
? trimmed
|
||||||
|
: $"{shortRegionName}{suffix}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildFullDistrictDisplayName(SbsRegionInfo region)
|
private static string BuildFullDistrictDisplayName(SbsRegionInfo region)
|
||||||
@@ -1109,19 +1387,27 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
var districtName = sungerType switch
|
var districtName = sungerType switch
|
||||||
{
|
{
|
||||||
3 => BuildMayorGovernorLabel(regionName, region.Name4),
|
3 => BuildMayorGovernorLabel(regionName, region.Name4),
|
||||||
|
11 => BuildEducationOfficeLabel(regionName, region.Name4),
|
||||||
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
|
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
|
||||||
_ => regionName
|
_ => regionName
|
||||||
};
|
};
|
||||||
var displayName = sungerType is 2 or 4 or 5 or 6
|
var displayName = sungerType is 2 or 4 or 5 or 6
|
||||||
? BuildFullDistrictDisplayName(regionName, districtName)
|
? BuildFullDistrictDisplayName(regionName, districtName)
|
||||||
: regionName;
|
: regionName;
|
||||||
|
var turnoutRegionCode = sungerType switch
|
||||||
|
{
|
||||||
|
3 or 11 => region.Name1Id ?? region.Id,
|
||||||
|
4 => region.Name2Id ?? string.Empty,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
return new DistrictSelectionOption(
|
return new DistrictSelectionOption(
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
DistrictCode: region.Id,
|
DistrictCode: region.Id,
|
||||||
RegionName: outputRegionName,
|
RegionName: outputRegionName,
|
||||||
DistrictName: districtName,
|
DistrictName: districtName,
|
||||||
ParentRegionCode: region.Name1Id ?? string.Empty);
|
ParentRegionCode: region.Name1Id ?? string.Empty,
|
||||||
|
TurnoutRegionCode: turnoutRegionCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SbsRegionInfo CreateRegionInfo(DistrictSelectionOption option)
|
private static SbsRegionInfo CreateRegionInfo(DistrictSelectionOption option)
|
||||||
@@ -1223,12 +1509,24 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
Uri BaseUri,
|
Uri BaseUri,
|
||||||
string CountingEndpointSegment);
|
string CountingEndpointSegment);
|
||||||
|
|
||||||
|
private readonly record struct TurnoutQueryDefinition(
|
||||||
|
int SungerType,
|
||||||
|
string RegionSegment);
|
||||||
|
|
||||||
|
private readonly record struct TurnoutTarget(
|
||||||
|
string DisplayName,
|
||||||
|
string DistrictCode,
|
||||||
|
string RegionName,
|
||||||
|
string ElectionDistrictName,
|
||||||
|
string TurnoutRegionCode);
|
||||||
|
|
||||||
public sealed record DistrictSelectionOption(
|
public sealed record DistrictSelectionOption(
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string DistrictCode,
|
string DistrictCode,
|
||||||
string RegionName,
|
string RegionName,
|
||||||
string DistrictName,
|
string DistrictName,
|
||||||
string ParentRegionCode);
|
string ParentRegionCode,
|
||||||
|
string TurnoutRegionCode = "");
|
||||||
|
|
||||||
public sealed record SbsElectionRefreshResult(
|
public sealed record SbsElectionRefreshResult(
|
||||||
string DistrictName,
|
string DistrictName,
|
||||||
@@ -1249,7 +1547,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
double CountedRate,
|
double CountedRate,
|
||||||
int CountedVotes,
|
int CountedVotes,
|
||||||
int TotalVotes,
|
int TotalVotes,
|
||||||
int UncountedVotes);
|
int UncountedVotes,
|
||||||
|
string JudgementBadgeText = "",
|
||||||
|
string JudgementDetailText = "");
|
||||||
|
|
||||||
public sealed record TurnoutOverviewItem(
|
public sealed record TurnoutOverviewItem(
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
@@ -1377,6 +1677,10 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
SbsCountingItem Item,
|
SbsCountingItem Item,
|
||||||
string SourcePath);
|
string SourcePath);
|
||||||
|
|
||||||
|
private readonly record struct JsonCacheEntry(
|
||||||
|
DateTimeOffset ReceivedAt,
|
||||||
|
string Body);
|
||||||
|
|
||||||
private readonly record struct SbsCountingCacheEntry(
|
private readonly record struct SbsCountingCacheEntry(
|
||||||
DateTimeOffset ReceivedAt,
|
DateTimeOffset ReceivedAt,
|
||||||
IReadOnlyList<SbsCountingItem> Items,
|
IReadOnlyList<SbsCountingItem> Items,
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ internal static class ScheduleTemplatePolicy
|
|||||||
|
|
||||||
public static bool UsesSingleRegionOption(FormatTemplateDefinition? template)
|
public static bool UsesSingleRegionOption(FormatTemplateDefinition? template)
|
||||||
{
|
{
|
||||||
return template is not null && IsStaticHistoricalTrendFormat(template.Name);
|
return template is not null &&
|
||||||
|
(IsStaticHistoricalTrendFormat(template.Name) || IsTitleFormat(template.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsStaticHistoricalTrendFormat(string? templateName)
|
public static bool IsStaticHistoricalTrendFormat(string? templateName)
|
||||||
@@ -65,6 +66,12 @@ internal static class ScheduleTemplatePolicy
|
|||||||
string.Equals(templateName, "역대시도판세_기초단체장", StringComparison.Ordinal);
|
string.Equals(templateName, "역대시도판세_기초단체장", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool IsTitleFormat(string? templateName)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(templateName) &&
|
||||||
|
templateName.Contains("타이틀", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsHistoricalTurnoutFormat(string? templateName)
|
public static bool IsHistoricalTurnoutFormat(string? templateName)
|
||||||
{
|
{
|
||||||
return !string.IsNullOrWhiteSpace(templateName) &&
|
return !string.IsNullOrWhiteSpace(templateName) &&
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
private readonly LogService _logService;
|
private readonly LogService _logService;
|
||||||
private readonly ObservableCollection<CutDebugItemState> _emptyCutDebugItems = [];
|
private readonly ObservableCollection<CutDebugItemState> _emptyCutDebugItems = [];
|
||||||
private IReadOnlyList<FormatTemplateDefinition> _allFormats;
|
private IReadOnlyList<FormatTemplateDefinition> _allFormats;
|
||||||
|
private SelectionOption<CutCategory?>? _selectedFormatCategoryOption;
|
||||||
|
private SelectionOption<string>? _selectedTurnoutRegionModeOption;
|
||||||
private FormatTemplateDefinition? _selectedFormat;
|
private FormatTemplateDefinition? _selectedFormat;
|
||||||
private CutDebugTemplateState? _selectedCutDebugTemplate;
|
private CutDebugTemplateState? _selectedCutDebugTemplate;
|
||||||
private ScheduleRegionOption? _selectedRegionOption;
|
private ScheduleRegionOption? _selectedRegionOption;
|
||||||
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
|
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
|
||||||
|
private CancellationTokenSource? _directPlaybackCts;
|
||||||
private bool _loopEnabled;
|
private bool _loopEnabled;
|
||||||
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
||||||
private int _regionOptionsRevision;
|
private int _regionOptionsRevision;
|
||||||
@@ -37,6 +40,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
|
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
|
||||||
private double _selectedFormatThumbnailWidth = 320;
|
private double _selectedFormatThumbnailWidth = 320;
|
||||||
private double _selectedFormatThumbnailHeight = 180;
|
private double _selectedFormatThumbnailHeight = 180;
|
||||||
|
private double _selectedFormatDraftDurationSeconds;
|
||||||
|
|
||||||
public ChannelScheduleViewModel(
|
public ChannelScheduleViewModel(
|
||||||
BroadcastChannel channel,
|
BroadcastChannel channel,
|
||||||
@@ -58,6 +62,13 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
_engine = engine;
|
_engine = engine;
|
||||||
_logService = logService;
|
_logService = logService;
|
||||||
_allFormats = formats.ToArray();
|
_allFormats = formats.ToArray();
|
||||||
|
FormatCategoryOptions = [];
|
||||||
|
TurnoutRegionModeOptions =
|
||||||
|
[
|
||||||
|
new SelectionOption<string>(DataViewModel.TurnoutPhotoSidoMode, "시도별 투표율"),
|
||||||
|
new SelectionOption<string>(DataViewModel.TurnoutPhotoDistrictMode, "선거구별 투표율")
|
||||||
|
];
|
||||||
|
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
|
||||||
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
|
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
|
||||||
RegionOptions = new ObservableCollection<ScheduleRegionOption>();
|
RegionOptions = new ObservableCollection<ScheduleRegionOption>();
|
||||||
EmptyBehaviorOptions =
|
EmptyBehaviorOptions =
|
||||||
@@ -67,8 +78,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
];
|
];
|
||||||
Queue = engine.Queue;
|
Queue = engine.Queue;
|
||||||
|
|
||||||
StartCommand = new AsyncRelayCommand(StartAsync);
|
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
|
||||||
StopCommand = new AsyncRelayCommand(StopAsync);
|
StopCommand = new AsyncRelayCommand(StopAsync);
|
||||||
|
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
|
||||||
|
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
|
||||||
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
|
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
|
||||||
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
|
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
|
||||||
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
|
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
|
||||||
@@ -77,6 +90,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
|
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
|
||||||
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
|
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
|
||||||
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
|
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
|
||||||
|
IncreaseSelectedFormatDurationCommand = new RelayCommand(IncreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
|
||||||
|
DecreaseSelectedFormatDurationCommand = new RelayCommand(DecreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
|
||||||
|
ApplySelectedFormatDurationCommand = new RelayCommand(ApplySelectedFormatDuration, CanApplySelectedFormatDuration);
|
||||||
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
|
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
|
||||||
|
|
||||||
_engine.QueueChanged += (_, _) => RefreshSummary();
|
_engine.QueueChanged += (_, _) => RefreshSummary();
|
||||||
@@ -86,6 +102,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
_data.PropertyChanged += Data_PropertyChanged;
|
_data.PropertyChanged += Data_PropertyChanged;
|
||||||
Queue.CollectionChanged += Queue_CollectionChanged;
|
Queue.CollectionChanged += Queue_CollectionChanged;
|
||||||
|
|
||||||
|
RebuildFormatCategoryOptions();
|
||||||
RebuildAvailableFormats();
|
RebuildAvailableFormats();
|
||||||
_ = RebuildRegionOptionsAsync();
|
_ = RebuildRegionOptionsAsync();
|
||||||
UpdateSelectedFormatThumbnailMetrics();
|
UpdateSelectedFormatThumbnailMetrics();
|
||||||
@@ -111,6 +128,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
|
|
||||||
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
|
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<SelectionOption<CutCategory?>> FormatCategoryOptions { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SelectionOption<string>> TurnoutRegionModeOptions { get; }
|
||||||
|
|
||||||
public ObservableCollection<ScheduleRegionOption> RegionOptions { get; }
|
public ObservableCollection<ScheduleRegionOption> RegionOptions { get; }
|
||||||
|
|
||||||
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
|
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
|
||||||
@@ -121,6 +142,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
|
|
||||||
public AsyncRelayCommand StopCommand { get; }
|
public AsyncRelayCommand StopCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand DirectStartCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand DirectStopCommand { get; }
|
||||||
|
|
||||||
public AsyncRelayCommand ForceNextCommand { get; }
|
public AsyncRelayCommand ForceNextCommand { get; }
|
||||||
|
|
||||||
public AsyncRelayCommand ForceQueueNextCommand { get; }
|
public AsyncRelayCommand ForceQueueNextCommand { get; }
|
||||||
@@ -139,6 +164,55 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
|
|
||||||
public RelayCommand<ChannelScheduleItem> PromoteToNextCommand { get; }
|
public RelayCommand<ChannelScheduleItem> PromoteToNextCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand IncreaseSelectedFormatDurationCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand DecreaseSelectedFormatDurationCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand ApplySelectedFormatDurationCommand { get; }
|
||||||
|
|
||||||
|
public event EventHandler<FormatTemplateDefinition>? FormatDurationChanged;
|
||||||
|
|
||||||
|
public SelectionOption<CutCategory?>? SelectedFormatCategoryOption
|
||||||
|
{
|
||||||
|
get => _selectedFormatCategoryOption;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SetProperty(ref _selectedFormatCategoryOption, value))
|
||||||
|
{
|
||||||
|
RebuildAvailableFormats();
|
||||||
|
_ = RebuildRegionOptionsAsync();
|
||||||
|
RefreshSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SelectionOption<string>? SelectedTurnoutRegionModeOption
|
||||||
|
{
|
||||||
|
get => _selectedTurnoutRegionModeOption;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SetProperty(ref _selectedTurnoutRegionModeOption, value))
|
||||||
|
{
|
||||||
|
_ = RebuildRegionOptionsAsync();
|
||||||
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
|
RefreshSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Visibility TurnoutRegionModeVisibility =>
|
||||||
|
UsesTurnoutRegionMode(SelectedFormat) ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
public FormatTemplateDefinition? SelectedFormat
|
public FormatTemplateDefinition? SelectedFormat
|
||||||
{
|
{
|
||||||
get => _selectedFormat;
|
get => _selectedFormat;
|
||||||
@@ -146,10 +220,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (SetProperty(ref _selectedFormat, value))
|
if (SetProperty(ref _selectedFormat, value))
|
||||||
{
|
{
|
||||||
|
EnsureTurnoutRegionModeSelection();
|
||||||
|
ResetSelectedFormatDurationDraft();
|
||||||
|
OnPropertyChanged(nameof(TurnoutRegionModeVisibility));
|
||||||
NotifySelectedFormatPreviewChanged();
|
NotifySelectedFormatPreviewChanged();
|
||||||
SyncSelectedCutDebugTemplate();
|
SyncSelectedCutDebugTemplate();
|
||||||
_ = RebuildRegionOptionsAsync();
|
_ = RebuildRegionOptionsAsync();
|
||||||
AddFormatCommand.NotifyCanExecuteChanged();
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
|
DirectStartCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,10 +240,33 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
if (SetProperty(ref _selectedRegionOption, value))
|
if (SetProperty(ref _selectedRegionOption, value))
|
||||||
{
|
{
|
||||||
AddFormatCommand.NotifyCanExecuteChanged();
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
|
DirectStartCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureTurnoutRegionModeSelection()
|
||||||
|
{
|
||||||
|
if (!UsesTurnoutRegionMode(SelectedFormat))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedTurnoutRegionModeOption is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
|
||||||
|
OnPropertyChanged(nameof(SelectedTurnoutRegionModeOption));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesTurnoutRegionMode(FormatTemplateDefinition? format)
|
||||||
|
{
|
||||||
|
return format is not null &&
|
||||||
|
string.Equals(format.Name, "투표율_사진", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
public bool LoopEnabled
|
public bool LoopEnabled
|
||||||
{
|
{
|
||||||
get => _loopEnabled;
|
get => _loopEnabled;
|
||||||
@@ -263,6 +364,30 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
? "등록된 썸네일을 표시 중"
|
? "등록된 썸네일을 표시 중"
|
||||||
: "썸네일이 없어 기본 아이콘을 표시 중";
|
: "썸네일이 없어 기본 아이콘을 표시 중";
|
||||||
|
|
||||||
|
public double SelectedFormatMinimumDurationSeconds => SelectedFormat is null
|
||||||
|
? 1d
|
||||||
|
: ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(SelectedFormat);
|
||||||
|
|
||||||
|
public double SelectedFormatDraftDurationSeconds
|
||||||
|
{
|
||||||
|
get => _selectedFormatDraftDurationSeconds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var normalized = SelectedFormat is null
|
||||||
|
? Math.Max(1d, Math.Round(value, 1, MidpointRounding.AwayFromZero))
|
||||||
|
: ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, SelectedFormat);
|
||||||
|
if (SetProperty(ref _selectedFormatDraftDurationSeconds, normalized))
|
||||||
|
{
|
||||||
|
NotifySelectedFormatDurationStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasSelectedFormatDurationChange => SelectedFormat is not null &&
|
||||||
|
Math.Abs(SelectedFormatDraftDurationSeconds - ResolveSelectedFormatDurationSeconds(SelectedFormat)) >= 0.001d;
|
||||||
|
|
||||||
|
public string SelectedFormatDurationStatusLabel => HasSelectedFormatDurationChange ? "미적용" : "적용됨";
|
||||||
|
|
||||||
public string CutDebugSummary => CutDebug.Summary;
|
public string CutDebugSummary => CutDebug.Summary;
|
||||||
|
|
||||||
public Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed;
|
public Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed;
|
||||||
@@ -324,6 +449,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
public void UpdateFormats(IReadOnlyList<FormatTemplateDefinition> formats)
|
public void UpdateFormats(IReadOnlyList<FormatTemplateDefinition> formats)
|
||||||
{
|
{
|
||||||
_allFormats = formats.ToArray();
|
_allFormats = formats.ToArray();
|
||||||
|
RebuildFormatCategoryOptions();
|
||||||
RebuildAvailableFormats();
|
RebuildAvailableFormats();
|
||||||
_ = RebuildRegionOptionsAsync();
|
_ = RebuildRegionOptionsAsync();
|
||||||
ApplyQueueThumbnailLayouts();
|
ApplyQueueThumbnailLayouts();
|
||||||
@@ -356,6 +482,66 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
_logService.Info($"[{Title}] 큐를 종료");
|
_logService.Info($"[{Title}] 큐를 종료");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task DirectStartAsync()
|
||||||
|
{
|
||||||
|
var selectedFormat = SelectedFormat;
|
||||||
|
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
||||||
|
if (selectedFormat is null || regionOption is null)
|
||||||
|
{
|
||||||
|
_logService.Warning($"[{Title}] 바로 송출할 컷과 지역을 먼저 선택해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
||||||
|
{
|
||||||
|
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
||||||
|
_directPlaybackCts?.Cancel();
|
||||||
|
|
||||||
|
var playbackCts = new CancellationTokenSource();
|
||||||
|
_directPlaybackCts = playbackCts;
|
||||||
|
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
||||||
|
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
||||||
|
selectedFormat,
|
||||||
|
_videoWallLayoutPreset,
|
||||||
|
ThumbnailDisplayContext.Queue));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logService.Info($"[{Title}] 선택 컷 바로 송출: {selectedFormat.Name} / {regionOption.Label}");
|
||||||
|
await _engine.PlayDirectAsync(item, selectedFormat, playbackCts.Token).ConfigureAwait(false);
|
||||||
|
if (!playbackCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logService.Info($"[{Title}] 선택 컷 바로 송출 완료: {selectedFormat.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logService.Error($"[{Title}] 선택 컷 바로 송출 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_directPlaybackCts, playbackCts))
|
||||||
|
{
|
||||||
|
_directPlaybackCts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackCts.Dispose();
|
||||||
|
RefreshSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DirectStopAsync()
|
||||||
|
{
|
||||||
|
_directPlaybackCts?.Cancel();
|
||||||
|
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
RefreshSummary();
|
||||||
|
_logService.Info($"[{Title}] 선택 컷 송출 정지");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ForceNextAsync()
|
private async Task ForceNextAsync()
|
||||||
{
|
{
|
||||||
await _engine.ForceNextAsync().ConfigureAwait(false);
|
await _engine.ForceNextAsync().ConfigureAwait(false);
|
||||||
@@ -432,6 +618,56 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
RefreshSummary();
|
RefreshSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void IncreaseSelectedFormatDuration()
|
||||||
|
{
|
||||||
|
SelectedFormatDraftDurationSeconds += 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DecreaseSelectedFormatDuration()
|
||||||
|
{
|
||||||
|
SelectedFormatDraftDurationSeconds -= 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySelectedFormatDuration()
|
||||||
|
{
|
||||||
|
var selectedFormat = SelectedFormat;
|
||||||
|
if (selectedFormat is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(SelectedFormatDraftDurationSeconds, selectedFormat);
|
||||||
|
var changed = false;
|
||||||
|
foreach (var cut in selectedFormat.Cuts)
|
||||||
|
{
|
||||||
|
if (Math.Abs(cut.DurationSeconds - normalized) < 0.001d)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cut.DurationSeconds = normalized;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedFormatDraftDurationSeconds = normalized;
|
||||||
|
NotifySelectedFormatDurationStateChanged();
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
FormatDurationChanged?.Invoke(this, selectedFormat);
|
||||||
|
_logService.Info($"[{Title}] 컷 송출 시간 적용: {selectedFormat.Name} {normalized:0.#}초");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanAdjustSelectedFormatDuration()
|
||||||
|
{
|
||||||
|
return SelectedFormat is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanApplySelectedFormatDuration()
|
||||||
|
{
|
||||||
|
return HasSelectedFormatDurationChange;
|
||||||
|
}
|
||||||
|
|
||||||
public void RefreshSummary()
|
public void RefreshSummary()
|
||||||
{
|
{
|
||||||
_engine.RefreshQueueMarkers();
|
_engine.RefreshQueueMarkers();
|
||||||
@@ -459,10 +695,16 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
SelectedRegionOption is not null;
|
SelectedRegionOption is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanDirectStart()
|
||||||
|
{
|
||||||
|
return CanAddFormat();
|
||||||
|
}
|
||||||
|
|
||||||
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
{
|
{
|
||||||
if (args.PropertyName is nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.ElectionType))
|
if (args.PropertyName is nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.ElectionType))
|
||||||
{
|
{
|
||||||
|
RebuildFormatCategoryOptions();
|
||||||
RebuildAvailableFormats();
|
RebuildAvailableFormats();
|
||||||
_ = RebuildRegionOptionsAsync();
|
_ = RebuildRegionOptionsAsync();
|
||||||
RefreshSummary();
|
RefreshSummary();
|
||||||
@@ -472,8 +714,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
private void RebuildAvailableFormats()
|
private void RebuildAvailableFormats()
|
||||||
{
|
{
|
||||||
var selectedFormatId = SelectedFormat?.Id;
|
var selectedFormatId = SelectedFormat?.Id;
|
||||||
|
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
||||||
var filteredFormats = _allFormats
|
var filteredFormats = _allFormats
|
||||||
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
||||||
|
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
AvailableFormats.Clear();
|
AvailableFormats.Clear();
|
||||||
@@ -494,9 +738,73 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
UpdateSelectedFormatThumbnailMetrics();
|
UpdateSelectedFormatThumbnailMetrics();
|
||||||
SyncSelectedCutDebugTemplate();
|
SyncSelectedCutDebugTemplate();
|
||||||
AddFormatCommand.NotifyCanExecuteChanged();
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
|
DirectStartCommand.NotifyCanExecuteChanged();
|
||||||
OnPropertyChanged(nameof(QueueFootnote));
|
OnPropertyChanged(nameof(QueueFootnote));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RebuildFormatCategoryOptions()
|
||||||
|
{
|
||||||
|
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
||||||
|
var formatsInCurrentPhase = _allFormats
|
||||||
|
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
||||||
|
.ToArray();
|
||||||
|
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
|
||||||
|
|
||||||
|
FormatCategoryOptions.Clear();
|
||||||
|
foreach (var option in options)
|
||||||
|
{
|
||||||
|
FormatCategoryOptions.Add(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextSelectedOption = selectedCategory.HasValue
|
||||||
|
? FormatCategoryOptions.FirstOrDefault(option => option.Value == selectedCategory.Value)
|
||||||
|
: null;
|
||||||
|
nextSelectedOption ??= FormatCategoryOptions.FirstOrDefault();
|
||||||
|
|
||||||
|
if (!ReferenceEquals(_selectedFormatCategoryOption, nextSelectedOption))
|
||||||
|
{
|
||||||
|
_selectedFormatCategoryOption = nextSelectedOption;
|
||||||
|
OnPropertyChanged(nameof(SelectedFormatCategoryOption));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SelectionOption<CutCategory?>> CreateFormatCategoryOptions(
|
||||||
|
IReadOnlyList<FormatTemplateDefinition> formats)
|
||||||
|
{
|
||||||
|
List<SelectionOption<CutCategory?>> options = [new(null, "전체보기")];
|
||||||
|
var seenResultKeys = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
BuildCategoryResultKey(formats.Select(format => format.Id))
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var category in CutCategoryResolver.GetOrderedCategories())
|
||||||
|
{
|
||||||
|
var matchingFormats = formats
|
||||||
|
.Where(format => CutCategoryResolver.IsMatch(format, category))
|
||||||
|
.ToArray();
|
||||||
|
if (matchingFormats.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenResultKeys.Add(BuildCategoryResultKey(matchingFormats.Select(format => format.Id))))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.Add(new SelectionOption<CutCategory?>(category, CutCategoryResolver.GetLabel(category)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCategoryResultKey(IEnumerable<string> formatIds)
|
||||||
|
{
|
||||||
|
return string.Join(
|
||||||
|
"\u001F",
|
||||||
|
formatIds.OrderBy(formatId => formatId, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RebuildRegionOptionsAsync()
|
private async Task RebuildRegionOptionsAsync()
|
||||||
{
|
{
|
||||||
var revision = Interlocked.Increment(ref _regionOptionsRevision);
|
var revision = Interlocked.Increment(ref _regionOptionsRevision);
|
||||||
@@ -509,11 +817,15 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
SelectedRegionOption = null;
|
SelectedRegionOption = null;
|
||||||
_lastRegionOptionFormatId = string.Empty;
|
_lastRegionOptionFormatId = string.Empty;
|
||||||
AddFormatCommand.NotifyCanExecuteChanged();
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
|
DirectStartCommand.NotifyCanExecuteChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var previousRegionOptionFormatId = _lastRegionOptionFormatId;
|
var previousRegionOptionFormatId = _lastRegionOptionFormatId;
|
||||||
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat);
|
var turnoutPhotoMode = UsesTurnoutRegionMode(selectedFormat)
|
||||||
|
? SelectedTurnoutRegionModeOption?.Value
|
||||||
|
: null;
|
||||||
|
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat, turnoutPhotoMode);
|
||||||
if (revision != _regionOptionsRevision)
|
if (revision != _regionOptionsRevision)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -532,6 +844,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
|
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
|
||||||
_lastRegionOptionFormatId = selectedFormat.Id;
|
_lastRegionOptionFormatId = selectedFormat.Id;
|
||||||
AddFormatCommand.NotifyCanExecuteChanged();
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
|
DirectStartCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void NotifySelectedFormatPreviewChanged()
|
private void NotifySelectedFormatPreviewChanged()
|
||||||
@@ -555,6 +868,33 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
nameof(SelectedFormatThumbnailHeight));
|
nameof(SelectedFormatThumbnailHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ResetSelectedFormatDurationDraft()
|
||||||
|
{
|
||||||
|
var durationSeconds = SelectedFormat is null
|
||||||
|
? 0d
|
||||||
|
: ResolveSelectedFormatDurationSeconds(SelectedFormat);
|
||||||
|
SetProperty(ref _selectedFormatDraftDurationSeconds, durationSeconds, nameof(SelectedFormatDraftDurationSeconds));
|
||||||
|
NotifySelectedFormatDurationStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifySelectedFormatDurationStateChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(
|
||||||
|
nameof(SelectedFormatMinimumDurationSeconds),
|
||||||
|
nameof(HasSelectedFormatDurationChange),
|
||||||
|
nameof(SelectedFormatDurationStatusLabel));
|
||||||
|
IncreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
||||||
|
DecreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
||||||
|
ApplySelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ResolveSelectedFormatDurationSeconds(FormatTemplateDefinition format)
|
||||||
|
{
|
||||||
|
return ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
|
||||||
|
format.Cuts.FirstOrDefault()?.DurationSeconds ?? ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(format),
|
||||||
|
format);
|
||||||
|
}
|
||||||
|
|
||||||
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
ApplyQueueThumbnailLayouts();
|
ApplyQueueThumbnailLayouts();
|
||||||
@@ -576,10 +916,11 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
return ResolveDefaultRegionOption(options, selectedFormat);
|
return ResolveDefaultRegionOption(options, selectedFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousSelection.Scope == ScheduleRegionScope.Single)
|
if (previousSelection.Scope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup)
|
||||||
{
|
{
|
||||||
var matchedSingle = options.FirstOrDefault(option =>
|
var matchedSingle = options.FirstOrDefault(option =>
|
||||||
option.Scope == ScheduleRegionScope.Single &&
|
option.Scope == previousSelection.Scope &&
|
||||||
|
string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal) &&
|
||||||
string.Equals(option.DistrictCode, previousSelection.DistrictCode, System.StringComparison.OrdinalIgnoreCase));
|
string.Equals(option.DistrictCode, previousSelection.DistrictCode, System.StringComparison.OrdinalIgnoreCase));
|
||||||
if (matchedSingle is not null)
|
if (matchedSingle is not null)
|
||||||
{
|
{
|
||||||
@@ -587,7 +928,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ??
|
return options.FirstOrDefault(option =>
|
||||||
|
option.Scope == previousSelection.Scope &&
|
||||||
|
string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal)) ??
|
||||||
|
options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ??
|
||||||
ResolveDefaultRegionOption(options, selectedFormat);
|
ResolveDefaultRegionOption(options, selectedFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
Tornado3_2026Election/ViewModels/CloseRaceTargetViewModel.cs
Normal file
24
Tornado3_2026Election/ViewModels/CloseRaceTargetViewModel.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Tornado3_2026Election.ViewModels;
|
||||||
|
|
||||||
|
public sealed class CloseRaceTargetViewModel
|
||||||
|
{
|
||||||
|
public required string ElectionType { get; init; }
|
||||||
|
|
||||||
|
public required string DistrictName { get; init; }
|
||||||
|
|
||||||
|
public required string RegionName { get; init; }
|
||||||
|
|
||||||
|
public required string Level { get; init; }
|
||||||
|
|
||||||
|
public required string FirstCandidateText { get; init; }
|
||||||
|
|
||||||
|
public required string SecondCandidateText { get; init; }
|
||||||
|
|
||||||
|
public required string FirstVoteRateDisplay { get; init; }
|
||||||
|
|
||||||
|
public required string SecondVoteRateDisplay { get; init; }
|
||||||
|
|
||||||
|
public required string GapDisplay { get; init; }
|
||||||
|
|
||||||
|
public double Gap { get; init; }
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
private readonly Action<FormatTemplateDefinition> _durationChanged;
|
private readonly Action<FormatTemplateDefinition> _durationChanged;
|
||||||
private VideoWallLayoutPreset _videoWallLayoutPreset;
|
private VideoWallLayoutPreset _videoWallLayoutPreset;
|
||||||
private double _durationSeconds;
|
private double _durationSeconds;
|
||||||
|
private double _draftDurationSeconds;
|
||||||
private double _thumbnailWidth;
|
private double _thumbnailWidth;
|
||||||
private double _thumbnailHeight;
|
private double _thumbnailHeight;
|
||||||
private ImageSource? _thumbnailSource;
|
private ImageSource? _thumbnailSource;
|
||||||
@@ -29,8 +30,12 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
_durationChanged = durationChanged;
|
_durationChanged = durationChanged;
|
||||||
_videoWallLayoutPreset = videoWallLayoutPreset;
|
_videoWallLayoutPreset = videoWallLayoutPreset;
|
||||||
_durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
_durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||||
|
_draftDurationSeconds = _durationSeconds;
|
||||||
_cut.DurationSeconds = _durationSeconds;
|
_cut.DurationSeconds = _durationSeconds;
|
||||||
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
|
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
|
||||||
|
IncreaseDurationCommand = new RelayCommand(IncreaseDraftDuration);
|
||||||
|
DecreaseDurationCommand = new RelayCommand(DecreaseDraftDuration);
|
||||||
|
ApplyDurationCommand = new RelayCommand(ApplyDraftDuration, CanApplyDraftDuration);
|
||||||
ApplyThumbnailLayout();
|
ApplyThumbnailLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +62,19 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
|
|
||||||
public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
|
public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
|
||||||
|
|
||||||
|
public bool IsInCategory(CutCategory category)
|
||||||
|
{
|
||||||
|
return CutCategoryResolver.IsMatch(_template, category);
|
||||||
|
}
|
||||||
|
|
||||||
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(_template);
|
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(_template);
|
||||||
|
|
||||||
|
public RelayCommand IncreaseDurationCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand DecreaseDurationCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand ApplyDurationCommand { get; }
|
||||||
|
|
||||||
public ImageSource? ThumbnailSource => _thumbnailSource;
|
public ImageSource? ThumbnailSource => _thumbnailSource;
|
||||||
|
|
||||||
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
|
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
|
||||||
@@ -72,6 +88,7 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
get => _durationSeconds;
|
get => _durationSeconds;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
|
var hadPendingDurationChange = HasPendingDurationChange;
|
||||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -89,10 +106,38 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
_cut.DurationSeconds = normalized;
|
_cut.DurationSeconds = normalized;
|
||||||
|
if (!hadPendingDurationChange || _draftDurationSeconds <= 0)
|
||||||
|
{
|
||||||
|
SetProperty(ref _draftDurationSeconds, normalized, nameof(DraftDurationSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyDurationStateChanged();
|
||||||
_durationChanged(_template);
|
_durationChanged(_template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public double DraftDurationSeconds
|
||||||
|
{
|
||||||
|
get => _draftDurationSeconds <= 0 ? DurationSeconds : _draftDurationSeconds;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, _template);
|
||||||
|
if (SetProperty(ref _draftDurationSeconds, normalized))
|
||||||
|
{
|
||||||
|
NotifyDurationStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasPendingDurationChange => Math.Abs(DraftDurationSeconds - DurationSeconds) >= 0.001d;
|
||||||
|
|
||||||
|
public string DurationApplyStatusLabel => HasPendingDurationChange ? "미적용" : "적용됨";
|
||||||
|
|
||||||
public void RefreshFromSource()
|
public void RefreshFromSource()
|
||||||
{
|
{
|
||||||
var sourceValue = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(_cut.DurationSeconds, _template);
|
var sourceValue = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(_cut.DurationSeconds, _template);
|
||||||
@@ -101,6 +146,13 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
SetProperty(ref _durationSeconds, sourceValue);
|
SetProperty(ref _durationSeconds, sourceValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Math.Abs(_draftDurationSeconds - sourceValue) >= 0.001d)
|
||||||
|
{
|
||||||
|
SetProperty(ref _draftDurationSeconds, sourceValue, nameof(DraftDurationSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyDurationStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshThumbnail()
|
public void RefreshThumbnail()
|
||||||
@@ -126,4 +178,31 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
SetProperty(ref _thumbnailWidth, metrics.Width, nameof(ThumbnailWidth));
|
SetProperty(ref _thumbnailWidth, metrics.Width, nameof(ThumbnailWidth));
|
||||||
SetProperty(ref _thumbnailHeight, metrics.Height, nameof(ThumbnailHeight));
|
SetProperty(ref _thumbnailHeight, metrics.Height, nameof(ThumbnailHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void IncreaseDraftDuration()
|
||||||
|
{
|
||||||
|
DraftDurationSeconds += 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DecreaseDraftDuration()
|
||||||
|
{
|
||||||
|
DraftDurationSeconds -= 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDraftDuration()
|
||||||
|
{
|
||||||
|
DurationSeconds = DraftDurationSeconds;
|
||||||
|
NotifyDurationStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanApplyDraftDuration()
|
||||||
|
{
|
||||||
|
return HasPendingDurationChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyDurationStateChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasPendingDurationChange), nameof(DurationApplyStatusLabel));
|
||||||
|
ApplyDurationCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
namespace Tornado3_2026Election.ViewModels;
|
namespace Tornado3_2026Election.ViewModels;
|
||||||
|
|
||||||
public sealed class DistrictOverviewCardViewModel
|
public sealed class DistrictOverviewCardViewModel
|
||||||
@@ -9,4 +11,13 @@ public sealed class DistrictOverviewCardViewModel
|
|||||||
public required string CountedRateDisplay { get; init; }
|
public required string CountedRateDisplay { get; init; }
|
||||||
|
|
||||||
public required string DetailText { get; init; }
|
public required string DetailText { get; init; }
|
||||||
|
|
||||||
|
public string JudgementBadgeText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string JudgementDetailText { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public Visibility JudgementVisibility =>
|
||||||
|
string.IsNullOrWhiteSpace(JudgementBadgeText)
|
||||||
|
? Visibility.Collapsed
|
||||||
|
: Visibility.Visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -33,6 +34,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
|
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
|
||||||
private bool _isSituationRoomExpanded;
|
private bool _isSituationRoomExpanded;
|
||||||
private bool _suppressAutomaticSave;
|
private bool _suppressAutomaticSave;
|
||||||
|
private bool _isSyncingQueuedCutDurations;
|
||||||
private CancellationTokenSource? _automaticSaveCts;
|
private CancellationTokenSource? _automaticSaveCts;
|
||||||
private int? _windowX;
|
private int? _windowX;
|
||||||
private int? _windowY;
|
private int? _windowY;
|
||||||
@@ -42,7 +44,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
private SelectionOption<LogLevel?>? _selectedLogFilterOption;
|
private SelectionOption<LogLevel?>? _selectedLogFilterOption;
|
||||||
private readonly List<(BroadcastChannel Channel, CutListEntryViewModel Entry)> _allCutListEntries = [];
|
private readonly List<(BroadcastChannel Channel, CutListEntryViewModel Entry)> _allCutListEntries = [];
|
||||||
private SelectionOption<BroadcastChannel?>? _selectedCutListFilterOption;
|
private SelectionOption<BroadcastChannel?>? _selectedCutListFilterOption;
|
||||||
private SelectionOption<CutListElectionCategory?>? _selectedCutListCategoryOption;
|
private SelectionOption<CutCategory?>? _selectedCutListCategoryOption;
|
||||||
private string _thumbnailGenerationStatus = string.Empty;
|
private string _thumbnailGenerationStatus = string.Empty;
|
||||||
|
|
||||||
public MainViewModel()
|
public MainViewModel()
|
||||||
@@ -73,19 +75,11 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
new SelectionOption<BroadcastChannel?>(BroadcastChannel.Bottom, "하단"),
|
new SelectionOption<BroadcastChannel?>(BroadcastChannel.Bottom, "하단"),
|
||||||
new SelectionOption<BroadcastChannel?>(BroadcastChannel.VideoWall, "비디오월")
|
new SelectionOption<BroadcastChannel?>(BroadcastChannel.VideoWall, "비디오월")
|
||||||
];
|
];
|
||||||
CutListCategoryOptions =
|
CutListCategoryOptions = [];
|
||||||
[
|
|
||||||
new SelectionOption<CutListElectionCategory?>(null, "\uC804\uCCB4"),
|
|
||||||
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.MetropolitanHead, "\uAD11\uC5ED\uB2E8\uCCB4\uC7A5"),
|
|
||||||
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.MetropolitanCouncil, "\uAD11\uC5ED\uC758\uC6D0"),
|
|
||||||
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.Superintendent, "\uAD50\uC721\uAC10"),
|
|
||||||
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.LocalHead, "\uAE30\uCD08\uB2E8\uCCB4\uC7A5"),
|
|
||||||
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.LocalCouncil, "\uAE30\uCD08\uC758\uC6D0")
|
|
||||||
];
|
|
||||||
FilteredLogs = [];
|
FilteredLogs = [];
|
||||||
CutListItems = [];
|
CutListItems = [];
|
||||||
_selectedCutListFilterOption = CutListFilterOptions[0];
|
_selectedCutListFilterOption = CutListFilterOptions[0];
|
||||||
_selectedCutListCategoryOption = CutListCategoryOptions[0];
|
RebuildCutListCategoryOptions();
|
||||||
|
|
||||||
_cutDebugStateStore = new CutDebugStateStore();
|
_cutDebugStateStore = new CutDebugStateStore();
|
||||||
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
|
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
|
||||||
@@ -104,17 +98,8 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
foreach (var channel in Channels)
|
foreach (var channel in Channels)
|
||||||
{
|
{
|
||||||
channel.PropertyChanged += Channel_PropertyChanged;
|
channel.PropertyChanged += Channel_PropertyChanged;
|
||||||
channel.Queue.CollectionChanged += (_, args) =>
|
channel.FormatDurationChanged += Channel_FormatDurationChanged;
|
||||||
{
|
channel.Queue.CollectionChanged += ChannelQueue_CollectionChanged;
|
||||||
if (args.Action is System.Collections.Specialized.NotifyCollectionChangedAction.Add
|
|
||||||
or System.Collections.Specialized.NotifyCollectionChangedAction.Remove
|
|
||||||
or System.Collections.Specialized.NotifyCollectionChangedAction.Move
|
|
||||||
or System.Collections.Specialized.NotifyCollectionChangedAction.Replace
|
|
||||||
or System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
|
|
||||||
{
|
|
||||||
QueueAutomaticSave();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SaveStateCommand = new AsyncRelayCommand(SaveStateAsync);
|
SaveStateCommand = new AsyncRelayCommand(SaveStateAsync);
|
||||||
@@ -172,7 +157,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
public IReadOnlyList<SelectionOption<BroadcastChannel?>> CutListFilterOptions { get; }
|
public IReadOnlyList<SelectionOption<BroadcastChannel?>> CutListFilterOptions { get; }
|
||||||
|
|
||||||
public IReadOnlyList<SelectionOption<CutListElectionCategory?>> CutListCategoryOptions { get; }
|
public ObservableCollection<SelectionOption<CutCategory?>> CutListCategoryOptions { get; }
|
||||||
|
|
||||||
public ChannelOperationMode OperationMode
|
public ChannelOperationMode OperationMode
|
||||||
{
|
{
|
||||||
@@ -384,12 +369,13 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
if (SetProperty(ref _selectedCutListFilterOption, value))
|
if (SetProperty(ref _selectedCutListFilterOption, value))
|
||||||
{
|
{
|
||||||
|
RebuildCutListCategoryOptions();
|
||||||
ApplyCutListFilter();
|
ApplyCutListFilter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SelectionOption<CutListElectionCategory?>? SelectedCutListCategoryOption
|
public SelectionOption<CutCategory?>? SelectedCutListCategoryOption
|
||||||
{
|
{
|
||||||
get => _selectedCutListCategoryOption;
|
get => _selectedCutListCategoryOption;
|
||||||
set
|
set
|
||||||
@@ -782,12 +768,13 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
|
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
|
||||||
or nameof(DataViewModel.PollingIntervalSeconds)
|
|
||||||
or nameof(DataViewModel.BroadcastPhase)
|
or nameof(DataViewModel.BroadcastPhase)
|
||||||
or nameof(DataViewModel.ElectionType)
|
or nameof(DataViewModel.ElectionType)
|
||||||
or nameof(DataViewModel.DistrictName)
|
or nameof(DataViewModel.DistrictName)
|
||||||
or nameof(DataViewModel.DistrictCode)
|
or nameof(DataViewModel.DistrictCode)
|
||||||
or nameof(DataViewModel.ShowOnlyConfiguredRegions)
|
or nameof(DataViewModel.ShowOnlyConfiguredRegions)
|
||||||
|
or nameof(DataViewModel.CloseRaceThresholdPercent)
|
||||||
|
or nameof(DataViewModel.SuperCloseRaceThresholdPercent)
|
||||||
or nameof(DataViewModel.TotalExpectedVotes)
|
or nameof(DataViewModel.TotalExpectedVotes)
|
||||||
or nameof(DataViewModel.TurnoutVotes))
|
or nameof(DataViewModel.TurnoutVotes))
|
||||||
{
|
{
|
||||||
@@ -822,6 +809,52 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ChannelQueue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.OldItems is not null)
|
||||||
|
{
|
||||||
|
foreach (var item in args.OldItems.OfType<ChannelScheduleItem>())
|
||||||
|
{
|
||||||
|
item.PropertyChanged -= QueueItem_PropertyChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.NewItems is not null)
|
||||||
|
{
|
||||||
|
foreach (var item in args.NewItems.OfType<ChannelScheduleItem>())
|
||||||
|
{
|
||||||
|
item.PropertyChanged -= QueueItem_PropertyChanged;
|
||||||
|
item.PropertyChanged += QueueItem_PropertyChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.Action is NotifyCollectionChangedAction.Add
|
||||||
|
or NotifyCollectionChangedAction.Remove
|
||||||
|
or NotifyCollectionChangedAction.Move
|
||||||
|
or NotifyCollectionChangedAction.Replace
|
||||||
|
or NotifyCollectionChangedAction.Reset)
|
||||||
|
{
|
||||||
|
QueueAutomaticSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isSyncingQueuedCutDurations ||
|
||||||
|
e.PropertyName != nameof(ChannelScheduleItem.DefaultCutDurationSeconds) ||
|
||||||
|
sender is not ChannelScheduleItem item)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyScheduleDurationToCutList(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Channel_FormatDurationChanged(object? sender, FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
OnCutDurationChanged(template);
|
||||||
|
}
|
||||||
|
|
||||||
private void Station_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void Station_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.PropertyName is nameof(StationFilterItemViewModel.RegionFiltersText)
|
if (e.PropertyName is nameof(StationFilterItemViewModel.RegionFiltersText)
|
||||||
@@ -884,10 +917,11 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
Data.DistrictName = string.IsNullOrWhiteSpace(state.DistrictName) ? Data.DistrictName : state.DistrictName;
|
Data.DistrictName = string.IsNullOrWhiteSpace(state.DistrictName) ? Data.DistrictName : state.DistrictName;
|
||||||
Data.DistrictCode = string.IsNullOrWhiteSpace(state.DistrictCode) ? Data.DistrictCode : state.DistrictCode;
|
Data.DistrictCode = string.IsNullOrWhiteSpace(state.DistrictCode) ? Data.DistrictCode : state.DistrictCode;
|
||||||
Data.ShowOnlyConfiguredRegions = state.ShowOnlyConfiguredRegions;
|
Data.ShowOnlyConfiguredRegions = state.ShowOnlyConfiguredRegions;
|
||||||
|
Data.SetCloseRaceThresholds(state.CloseRaceThresholdPercent, state.SuperCloseRaceThresholdPercent);
|
||||||
Data.TotalExpectedVotes = state.TotalExpectedVotes > 0 ? state.TotalExpectedVotes : Data.TotalExpectedVotes;
|
Data.TotalExpectedVotes = state.TotalExpectedVotes > 0 ? state.TotalExpectedVotes : Data.TotalExpectedVotes;
|
||||||
Data.TurnoutVotes = state.TurnoutVotes;
|
Data.TurnoutVotes = state.TurnoutVotes;
|
||||||
Data.IsPollingEnabled = state.IsPollingEnabled;
|
Data.IsPollingEnabled = state.IsPollingEnabled;
|
||||||
Data.PollingIntervalSeconds = state.PollingIntervalSeconds;
|
Data.PollingIntervalSeconds = DataViewModel.FixedPollingIntervalSeconds;
|
||||||
Data.ReplaceCandidates(state.Candidates.Select(candidate => new CandidateEntry
|
Data.ReplaceCandidates(state.Candidates.Select(candidate => new CandidateEntry
|
||||||
{
|
{
|
||||||
CandidateCode = candidate.CandidateCode,
|
CandidateCode = candidate.CandidateCode,
|
||||||
@@ -993,11 +1027,13 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
OperationMode = OperationMode.ToString(),
|
OperationMode = OperationMode.ToString(),
|
||||||
BroadcastPhase = Data.BroadcastPhase.ToString(),
|
BroadcastPhase = Data.BroadcastPhase.ToString(),
|
||||||
IsPollingEnabled = Data.IsPollingEnabled,
|
IsPollingEnabled = Data.IsPollingEnabled,
|
||||||
PollingIntervalSeconds = Data.PollingIntervalSeconds,
|
PollingIntervalSeconds = DataViewModel.FixedPollingIntervalSeconds,
|
||||||
ElectionType = Data.ElectionType,
|
ElectionType = Data.ElectionType,
|
||||||
DistrictName = Data.DistrictName,
|
DistrictName = Data.DistrictName,
|
||||||
DistrictCode = Data.DistrictCode,
|
DistrictCode = Data.DistrictCode,
|
||||||
ShowOnlyConfiguredRegions = Data.ShowOnlyConfiguredRegions,
|
ShowOnlyConfiguredRegions = Data.ShowOnlyConfiguredRegions,
|
||||||
|
CloseRaceThresholdPercent = Data.CloseRaceThresholdPercent,
|
||||||
|
SuperCloseRaceThresholdPercent = Data.SuperCloseRaceThresholdPercent,
|
||||||
TotalExpectedVotes = Data.TotalExpectedVotes,
|
TotalExpectedVotes = Data.TotalExpectedVotes,
|
||||||
TurnoutVotes = Data.TurnoutVotes,
|
TurnoutVotes = Data.TurnoutVotes,
|
||||||
Candidates = Data.Candidates.Select(candidate => new CandidateState
|
Candidates = Data.Candidates.Select(candidate => new CandidateState
|
||||||
@@ -1209,6 +1245,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
_allCutListEntries.Clear();
|
_allCutListEntries.Clear();
|
||||||
_allCutListEntries.AddRange(entries);
|
_allCutListEntries.AddRange(entries);
|
||||||
|
RebuildCutListCategoryOptions();
|
||||||
ApplyCutListFilter();
|
ApplyCutListFilter();
|
||||||
OnPropertyChanged(nameof(CutThumbnailSummary));
|
OnPropertyChanged(nameof(CutThumbnailSummary));
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1256,58 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
QueueAutomaticSave();
|
QueueAutomaticSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ApplyScheduleDurationToCutList(ChannelScheduleItem item)
|
||||||
|
{
|
||||||
|
var template = _formatCatalogService.FindById(item.FormatId);
|
||||||
|
if (template is null)
|
||||||
|
{
|
||||||
|
QueueAutomaticSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(item.DefaultCutDurationSeconds, template);
|
||||||
|
if (Math.Abs(item.DefaultCutDurationSeconds - normalized) >= 0.001d)
|
||||||
|
{
|
||||||
|
_isSyncingQueuedCutDurations = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
item.DefaultCutDurationSeconds = normalized;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isSyncingQueuedCutDurations = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
foreach (var cut in template.Cuts)
|
||||||
|
{
|
||||||
|
if (Math.Abs(cut.DurationSeconds - normalized) < 0.001d)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cut.DurationSeconds = normalized;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
RefreshCutListEntries(template);
|
||||||
|
SyncQueuedCutDurations(template);
|
||||||
|
QueueAutomaticSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshCutListEntries(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
foreach (var item in _allCutListEntries.Where(item =>
|
||||||
|
string.Equals(item.Entry.FormatId, template.Id, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
item.Entry.RefreshFromSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyCutDurations(IReadOnlyDictionary<string, double>? durations)
|
private void ApplyCutDurations(IReadOnlyDictionary<string, double>? durations)
|
||||||
{
|
{
|
||||||
if (durations is null || durations.Count == 0)
|
if (durations is null || durations.Count == 0)
|
||||||
@@ -1259,7 +1348,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
var filteredEntries = _allCutListEntries
|
var filteredEntries = _allCutListEntries
|
||||||
.Where(item =>
|
.Where(item =>
|
||||||
(selectedChannel is null || item.Channel == selectedChannel.Value) &&
|
(selectedChannel is null || item.Channel == selectedChannel.Value) &&
|
||||||
(selectedCategory is null || item.Entry.ElectionCategory == selectedCategory.Value))
|
(selectedCategory is null || item.Entry.IsInCategory(selectedCategory.Value)))
|
||||||
.Select(item => item.Entry)
|
.Select(item => item.Entry)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -1272,6 +1361,71 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(CutListSummary));
|
OnPropertyChanged(nameof(CutListSummary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RebuildCutListCategoryOptions()
|
||||||
|
{
|
||||||
|
var selectedChannel = SelectedCutListFilterOption?.Value;
|
||||||
|
var selectedCategory = SelectedCutListCategoryOption?.Value;
|
||||||
|
var entriesInSelectedChannel = _allCutListEntries
|
||||||
|
.Where(item => selectedChannel is null || item.Channel == selectedChannel.Value)
|
||||||
|
.Select(item => item.Entry)
|
||||||
|
.ToArray();
|
||||||
|
var options = CreateCutListCategoryOptions(entriesInSelectedChannel);
|
||||||
|
|
||||||
|
CutListCategoryOptions.Clear();
|
||||||
|
foreach (var option in options)
|
||||||
|
{
|
||||||
|
CutListCategoryOptions.Add(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextSelectedOption = selectedCategory.HasValue
|
||||||
|
? CutListCategoryOptions.FirstOrDefault(option => option.Value == selectedCategory.Value)
|
||||||
|
: null;
|
||||||
|
nextSelectedOption ??= CutListCategoryOptions.FirstOrDefault();
|
||||||
|
|
||||||
|
if (!ReferenceEquals(_selectedCutListCategoryOption, nextSelectedOption))
|
||||||
|
{
|
||||||
|
_selectedCutListCategoryOption = nextSelectedOption;
|
||||||
|
OnPropertyChanged(nameof(SelectedCutListCategoryOption));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SelectionOption<CutCategory?>> CreateCutListCategoryOptions(
|
||||||
|
IReadOnlyList<CutListEntryViewModel> entries)
|
||||||
|
{
|
||||||
|
List<SelectionOption<CutCategory?>> options = [new(null, "전체보기")];
|
||||||
|
var seenResultKeys = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
BuildCutListCategoryResultKey(entries.Select(entry => entry.FormatId))
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var category in CutCategoryResolver.GetOrderedCategories())
|
||||||
|
{
|
||||||
|
var matchingEntries = entries
|
||||||
|
.Where(entry => entry.IsInCategory(category))
|
||||||
|
.ToArray();
|
||||||
|
if (matchingEntries.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenResultKeys.Add(BuildCutListCategoryResultKey(matchingEntries.Select(entry => entry.FormatId))))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.Add(new SelectionOption<CutCategory?>(category, CutCategoryResolver.GetLabel(category)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCutListCategoryResultKey(IEnumerable<string> formatIds)
|
||||||
|
{
|
||||||
|
return string.Join(
|
||||||
|
"\u001F",
|
||||||
|
formatIds.OrderBy(formatId => formatId, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshCutListThumbnails()
|
private void RefreshCutListThumbnails()
|
||||||
{
|
{
|
||||||
foreach (var item in _allCutListEntries)
|
foreach (var item in _allCutListEntries)
|
||||||
@@ -1318,13 +1472,21 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
private void SyncQueuedCutDurations(FormatTemplateDefinition template)
|
private void SyncQueuedCutDurations(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
var defaultDuration = template.Cuts.FirstOrDefault()?.DurationSeconds ?? 0;
|
var defaultDuration = template.Cuts.FirstOrDefault()?.DurationSeconds ?? 0;
|
||||||
foreach (var channel in Channels)
|
_isSyncingQueuedCutDurations = true;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
foreach (var item in channel.Queue.Where(item => string.Equals(item.FormatId, template.Id, StringComparison.Ordinal)))
|
foreach (var channel in Channels)
|
||||||
{
|
{
|
||||||
item.DefaultCutDurationSeconds = defaultDuration;
|
foreach (var item in channel.Queue.Where(item => string.Equals(item.FormatId, template.Id, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
item.DefaultCutDurationSeconds = defaultDuration;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isSyncingQueuedCutDurations = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildCutDurationKey(string formatId, string cutName)
|
private static string BuildCutDurationKey(string formatId, string cutName)
|
||||||
|
|||||||
@@ -259,10 +259,48 @@ function Normalize-CompactText {
|
|||||||
return ($Value -replace '\s+', [string]::Empty).Trim()
|
return ($Value -replace '\s+', [string]::Empty).Trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Strip-BasicDistrictDisambiguation {
|
||||||
|
param([string]$Value)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Value))
|
||||||
|
{
|
||||||
|
return [string]::Empty
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $Value.Trim()
|
||||||
|
$regionLabels = @(
|
||||||
|
"%EC%84%9C%EC%9A%B8",
|
||||||
|
"%EB%B6%80%EC%82%B0",
|
||||||
|
"%EB%8C%80%EA%B5%AC",
|
||||||
|
"%EC%9D%B8%EC%B2%9C",
|
||||||
|
"%EA%B4%91%EC%A3%BC",
|
||||||
|
"%EB%8C%80%EC%A0%84",
|
||||||
|
"%EC%9A%B8%EC%82%B0",
|
||||||
|
"%EC%84%B8%EC%A2%85",
|
||||||
|
"%EA%B2%BD%EA%B8%B0",
|
||||||
|
"%EA%B0%95%EC%9B%90",
|
||||||
|
"%EC%B6%A9%EB%B6%81",
|
||||||
|
"%EC%B6%A9%EB%82%A8",
|
||||||
|
"%EC%A0%84%EB%B6%81",
|
||||||
|
"%EC%A0%84%EB%82%A8",
|
||||||
|
"%EA%B2%BD%EB%B6%81",
|
||||||
|
"%EA%B2%BD%EB%82%A8",
|
||||||
|
"%EC%A0%9C%EC%A3%BC"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($encodedLabel in $regionLabels)
|
||||||
|
{
|
||||||
|
$label = Decode-Text $encodedLabel
|
||||||
|
$normalized = $normalized.Replace("($label)", [string]::Empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized.Replace("()", [string]::Empty).Trim()
|
||||||
|
}
|
||||||
|
|
||||||
function Normalize-BasicDistrictToken {
|
function Normalize-BasicDistrictToken {
|
||||||
param([string]$Value)
|
param([string]$Value)
|
||||||
|
|
||||||
$normalized = Normalize-CompactText -Value $Value
|
$normalized = Normalize-CompactText -Value (Strip-BasicDistrictDisambiguation -Value $Value)
|
||||||
if ([string]::IsNullOrWhiteSpace($normalized))
|
if ([string]::IsNullOrWhiteSpace($normalized))
|
||||||
{
|
{
|
||||||
return [string]::Empty
|
return [string]::Empty
|
||||||
@@ -272,9 +310,32 @@ function Normalize-BasicDistrictToken {
|
|||||||
$normalized = $normalized.Replace($(Decode-Text "%EA%B5%B0%EC%88%98"), $(Decode-Text "%EA%B5%B0"))
|
$normalized = $normalized.Replace($(Decode-Text "%EA%B5%B0%EC%88%98"), $(Decode-Text "%EA%B5%B0"))
|
||||||
$normalized = $normalized.Replace($(Decode-Text "%EC%8B%9C%EC%9E%A5"), $(Decode-Text "%EC%8B%9C"))
|
$normalized = $normalized.Replace($(Decode-Text "%EC%8B%9C%EC%9E%A5"), $(Decode-Text "%EC%8B%9C"))
|
||||||
$normalized = $normalized.Replace($(Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90"), [string]::Empty)
|
$normalized = $normalized.Replace($(Decode-Text "%EA%B5%90%EC%9C%A1%EA%B0%90"), [string]::Empty)
|
||||||
|
$normalized = $normalized.Replace("()", [string]::Empty)
|
||||||
return $normalized.Trim()
|
return $normalized.Trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Normalize-BasicDistrictDisplayName {
|
||||||
|
param(
|
||||||
|
[string]$DistrictName,
|
||||||
|
[string]$DisplayName,
|
||||||
|
[string]$RegionName
|
||||||
|
)
|
||||||
|
|
||||||
|
$normalized = if ([string]::IsNullOrWhiteSpace($DistrictName)) { $DisplayName } else { $DistrictName }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($normalized))
|
||||||
|
{
|
||||||
|
return [string]::Empty
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $normalized.Trim()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($RegionName) -and $normalized.StartsWith($RegionName.Trim(), [System.StringComparison]::Ordinal))
|
||||||
|
{
|
||||||
|
$normalized = $normalized.Substring($RegionName.Trim().Length).Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Strip-BasicDistrictDisambiguation -Value $normalized
|
||||||
|
}
|
||||||
|
|
||||||
function New-OfficialWinnerEntry {
|
function New-OfficialWinnerEntry {
|
||||||
param(
|
param(
|
||||||
[pscustomobject]$Cycle,
|
[pscustomobject]$Cycle,
|
||||||
@@ -977,6 +1038,8 @@ foreach ($region in $regions)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$rawDistrictName = $districtName
|
||||||
|
$districtName = Normalize-BasicDistrictDisplayName -DistrictName $districtName -DisplayName ([string]$winnerItem.wiwName) -RegionName $regionDisplayName
|
||||||
$districtKey = Normalize-BasicDistrictToken -Value $districtName
|
$districtKey = Normalize-BasicDistrictToken -Value $districtName
|
||||||
if ([string]::IsNullOrWhiteSpace($districtKey))
|
if ([string]::IsNullOrWhiteSpace($districtKey))
|
||||||
{
|
{
|
||||||
@@ -998,7 +1061,7 @@ foreach ($region in $regions)
|
|||||||
$record = $basicRecordsByKey[$recordKey]
|
$record = $basicRecordsByKey[$recordKey]
|
||||||
Add-HistoryEntry -Target $record.WinnerHistory -Entry (New-OfficialWinnerEntry -Cycle $cycle -Item $winnerItem -SourceUrl $winnerSourceUrl)
|
Add-HistoryEntry -Target $record.WinnerHistory -Entry (New-OfficialWinnerEntry -Cycle $cycle -Item $winnerItem -SourceUrl $winnerSourceUrl)
|
||||||
|
|
||||||
$turnoutSnapshot = Resolve-BasicTurnoutSnapshot -DistrictName $districtName -TurnoutItems $turnoutDetails
|
$turnoutSnapshot = Resolve-BasicTurnoutSnapshot -DistrictName $rawDistrictName -TurnoutItems $turnoutDetails
|
||||||
if ($null -ne $turnoutSnapshot)
|
if ($null -ne $turnoutSnapshot)
|
||||||
{
|
{
|
||||||
Add-HistoryEntry -Target $record.TurnoutHistory -Entry (New-OfficialTurnoutEntry -Cycle $cycle -Electors $turnoutSnapshot.Electors -Votes $turnoutSnapshot.Votes -SourceUrl $turnoutSourceUrl)
|
Add-HistoryEntry -Target $record.TurnoutHistory -Entry (New-OfficialTurnoutEntry -Cycle $cycle -Electors $turnoutSnapshot.Electors -Votes $turnoutSnapshot.Votes -SourceUrl $turnoutSourceUrl)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using Tornado3_2026Election.Domain;
|
using Tornado3_2026Election.Domain;
|
||||||
using Tornado3_2026Election.Services;
|
using Tornado3_2026Election.Services;
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
Console.WriteLine($"- Region Scope: {options.RegionScope}");
|
Console.WriteLine($"- Region Scope: {options.RegionScope}");
|
||||||
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
|
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
|
||||||
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
|
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
|
||||||
|
Console.WriteLine($"- Scene Capture: {(options.CaptureSceneImages ? "on" : "off")}");
|
||||||
Console.WriteLine($"- Output: {options.OutputPath}");
|
Console.WriteLine($"- Output: {options.OutputPath}");
|
||||||
|
|
||||||
var stationCatalog = new StationCatalogService().GetAll();
|
var stationCatalog = new StationCatalogService().GetAll();
|
||||||
@@ -142,6 +144,66 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ShouldUseAggregateTurnoutSnapshot(template, phase, electionType))
|
||||||
|
{
|
||||||
|
var result = new CurrentApiCutDiagnosticResult
|
||||||
|
{
|
||||||
|
Station = station.Id,
|
||||||
|
Channel = template.RecommendedChannel.ToString(),
|
||||||
|
TemplateId = template.Id,
|
||||||
|
TemplateName = template.Name,
|
||||||
|
Phase = phase.ToString(),
|
||||||
|
ElectionType = electionType,
|
||||||
|
Region = string.Join(", ", targets.Select(target => target.DisplayName)),
|
||||||
|
DistrictCode = string.Join(",", targets.Select(target => target.DistrictCode)),
|
||||||
|
Status = "unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await CreateAggregateTurnoutSnapshotAsync(
|
||||||
|
apiClient,
|
||||||
|
electionType,
|
||||||
|
districts,
|
||||||
|
targets,
|
||||||
|
template,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
PopulateDataFields(result, snapshot, "GET /tupyo aggregate overview");
|
||||||
|
|
||||||
|
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
|
||||||
|
{
|
||||||
|
result.Status = "validation-failed";
|
||||||
|
result.Detail = validationError;
|
||||||
|
result.Warning = warning;
|
||||||
|
}
|
||||||
|
else if (adapter is not null && simulatedSendCount < options.SendLimit)
|
||||||
|
{
|
||||||
|
await SimulateSendAsync(adapter, station, template, snapshot, options, result).ConfigureAwait(false);
|
||||||
|
simulatedSendCount++;
|
||||||
|
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
|
||||||
|
result.Detail = options.LiveSend
|
||||||
|
? "validated and live send completed"
|
||||||
|
: "validated and mock send completed";
|
||||||
|
result.Warning = warning;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Status = "valid";
|
||||||
|
result.Detail = adapter is null ? "validated" : "validated; send limit reached";
|
||||||
|
result.Warning = warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Status = "api-or-send-failed";
|
||||||
|
result.Detail = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var target in targets)
|
foreach (var target in targets)
|
||||||
{
|
{
|
||||||
var result = new CurrentApiCutDiagnosticResult
|
var result = new CurrentApiCutDiagnosticResult
|
||||||
@@ -186,7 +248,7 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
}
|
}
|
||||||
else if (adapter is not null && simulatedSendCount < options.SendLimit)
|
else if (adapter is not null && simulatedSendCount < options.SendLimit)
|
||||||
{
|
{
|
||||||
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
|
await SimulateSendAsync(adapter, station, template, snapshot, options, result).ConfigureAwait(false);
|
||||||
simulatedSendCount++;
|
simulatedSendCount++;
|
||||||
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
|
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
|
||||||
result.Detail = options.LiveSend
|
result.Detail = options.LiveSend
|
||||||
@@ -259,6 +321,194 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
: $"mock ({options.SendLimit})";
|
: $"mock ({options.SendLimit})";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<ElectionDataSnapshot> CreateAggregateTurnoutSnapshotAsync(
|
||||||
|
SbsElectionApiClient apiClient,
|
||||||
|
string electionType,
|
||||||
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> allDistricts,
|
||||||
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> selectedDistricts,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var overview = await apiClient
|
||||||
|
.GetTurnoutOverviewAsync(electionType, allDistricts, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var primaryDistrict = selectedDistricts.FirstOrDefault();
|
||||||
|
var primaryItem = FindTurnoutOverviewItem(overview.Items, primaryDistrict);
|
||||||
|
var includeNationalSlot = IsBottomTurnoutBoardTemplate(template);
|
||||||
|
var maxRegionalSlots = includeNationalSlot ? 4 : 7;
|
||||||
|
var turnoutBoardSlots = new List<TurnoutBoardSlotEntry>();
|
||||||
|
|
||||||
|
if (includeNationalSlot)
|
||||||
|
{
|
||||||
|
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(1, "전국", overview.NationalTurnoutRate, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextSlot = turnoutBoardSlots.Count + 1;
|
||||||
|
foreach (var district in selectedDistricts.Take(maxRegionalSlots))
|
||||||
|
{
|
||||||
|
var item = FindTurnoutOverviewItem(overview.Items, district);
|
||||||
|
if (item is null || item.TurnoutVotes <= 0 || item.TurnoutRate <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(
|
||||||
|
nextSlot++,
|
||||||
|
ResolveTurnoutBoardDistrictLabel(electionType, item, district),
|
||||||
|
item.TurnoutRate,
|
||||||
|
RegionLabel: ResolveTurnoutBoardRegionLabel(item, district)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turnoutBoardSlots.Count == (includeNationalSlot ? 1 : 0))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No positive turnout board slots were available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionName = primaryItem?.RegionName ?? primaryDistrict?.RegionName ?? string.Empty;
|
||||||
|
var districtName = primaryItem?.DisplayName ?? primaryDistrict?.DisplayName ?? regionName;
|
||||||
|
var electionDistrictName = ResolveTurnoutElectionDistrictName(
|
||||||
|
electionType,
|
||||||
|
primaryItem,
|
||||||
|
primaryDistrict,
|
||||||
|
regionName,
|
||||||
|
districtName);
|
||||||
|
var totalExpectedVotes = includeNationalSlot
|
||||||
|
? overview.TotalExpectedVotes
|
||||||
|
: primaryItem?.TotalExpectedVotes ?? 0;
|
||||||
|
var turnoutVotes = includeNationalSlot
|
||||||
|
? overview.TurnoutVotes
|
||||||
|
: primaryItem?.TurnoutVotes ?? 0;
|
||||||
|
|
||||||
|
return new ElectionDataSnapshot
|
||||||
|
{
|
||||||
|
BroadcastPhase = BroadcastPhase.PreElection,
|
||||||
|
ElectionType = electionType,
|
||||||
|
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
|
||||||
|
DistrictCode = primaryItem?.DistrictCode ?? primaryDistrict?.DistrictCode ?? string.Empty,
|
||||||
|
RegionName = regionName,
|
||||||
|
ElectionDistrictName = electionDistrictName,
|
||||||
|
Candidates = Array.Empty<CandidateEntry>(),
|
||||||
|
TotalExpectedVotes = Math.Max(0, totalExpectedVotes),
|
||||||
|
TurnoutVotes = Math.Max(0, turnoutVotes),
|
||||||
|
CountedVotesFromApi = null,
|
||||||
|
RemainingVotesFromApi = null,
|
||||||
|
CountedRateFromApi = null,
|
||||||
|
ReceivedAt = DateTimeOffset.Now,
|
||||||
|
TurnoutBoardSlots = turnoutBoardSlots,
|
||||||
|
NationalTurnoutRateOverride = overview.NationalTurnoutRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldUseAggregateTurnoutSnapshot(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
BroadcastPhase phase,
|
||||||
|
string electionType)
|
||||||
|
{
|
||||||
|
return phase == BroadcastPhase.PreElection &&
|
||||||
|
SupportsPreElectionTurnout(electionType) &&
|
||||||
|
(IsBottomTurnoutBoardTemplate(template) || IsRegionalTurnoutBoardTemplate(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||||
|
(string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(template.Name, "투표율", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRegionalTurnoutBoardTemplate(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return template.RecommendedChannel == BroadcastChannel.Normal &&
|
||||||
|
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SbsElectionApiClient.TurnoutOverviewItem? FindTurnoutOverviewItem(
|
||||||
|
IReadOnlyList<SbsElectionApiClient.TurnoutOverviewItem> items,
|
||||||
|
SbsElectionApiClient.DistrictSelectionOption? district)
|
||||||
|
{
|
||||||
|
if (district is null || items.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(district.DistrictCode))
|
||||||
|
{
|
||||||
|
var matchedByCode = items.FirstOrDefault(item =>
|
||||||
|
string.Equals(item.DistrictCode, district.DistrictCode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (matchedByCode is not null)
|
||||||
|
{
|
||||||
|
return matchedByCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.FirstOrDefault(item =>
|
||||||
|
string.Equals(item.RegionName, district.RegionName, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(item.DisplayName, district.DisplayName, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(item.DistrictName, district.DistrictName, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTurnoutElectionDistrictName(
|
||||||
|
string electionType,
|
||||||
|
SbsElectionApiClient.TurnoutOverviewItem? item,
|
||||||
|
SbsElectionApiClient.DistrictSelectionOption? district,
|
||||||
|
string regionName,
|
||||||
|
string districtName)
|
||||||
|
{
|
||||||
|
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return FirstNonWhiteSpace(
|
||||||
|
item?.DistrictName,
|
||||||
|
district?.DistrictName,
|
||||||
|
districtName,
|
||||||
|
regionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTurnoutBoardDistrictLabel(
|
||||||
|
string electionType,
|
||||||
|
SbsElectionApiClient.TurnoutOverviewItem item,
|
||||||
|
SbsElectionApiClient.DistrictSelectionOption district)
|
||||||
|
{
|
||||||
|
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return FirstNonWhiteSpace(
|
||||||
|
item.DistrictName,
|
||||||
|
district.DistrictName,
|
||||||
|
item.DisplayName,
|
||||||
|
district.DisplayName,
|
||||||
|
item.RegionName,
|
||||||
|
district.RegionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveTurnoutBoardRegionLabel(item, district);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveTurnoutBoardRegionLabel(
|
||||||
|
SbsElectionApiClient.TurnoutOverviewItem item,
|
||||||
|
SbsElectionApiClient.DistrictSelectionOption district)
|
||||||
|
{
|
||||||
|
return FirstNonWhiteSpace(
|
||||||
|
item.RegionName,
|
||||||
|
district.RegionName,
|
||||||
|
item.DisplayName,
|
||||||
|
district.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstNonWhiteSpace(params string?[] values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
|
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
|
||||||
SbsElectionApiClient apiClient,
|
SbsElectionApiClient apiClient,
|
||||||
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
|
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
|
||||||
@@ -367,7 +617,7 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
DistrictName = districtName,
|
DistrictName = districtName,
|
||||||
DistrictCode = target.DistrictCode,
|
DistrictCode = target.DistrictCode,
|
||||||
RegionName = regionName,
|
RegionName = regionName,
|
||||||
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
|
ElectionDistrictName = ResolveHistoricalElectionDistrictName(electionType, regionName, districtName),
|
||||||
Candidates = Array.Empty<CandidateEntry>(),
|
Candidates = Array.Empty<CandidateEntry>(),
|
||||||
TotalExpectedVotes = 0,
|
TotalExpectedVotes = 0,
|
||||||
TurnoutVotes = 0,
|
TurnoutVotes = 0,
|
||||||
@@ -382,12 +632,26 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveHistoricalElectionDistrictName(
|
||||||
|
string electionType,
|
||||||
|
string regionName,
|
||||||
|
string districtName)
|
||||||
|
{
|
||||||
|
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(districtName) ? regionName : districtName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task SimulateSendAsync(
|
private static async Task SimulateSendAsync(
|
||||||
ITornado3Adapter adapter,
|
ITornado3Adapter adapter,
|
||||||
BroadcastStationProfile station,
|
BroadcastStationProfile station,
|
||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
ElectionDataSnapshot snapshot,
|
ElectionDataSnapshot snapshot,
|
||||||
string imageRootPath)
|
CurrentApiCutDiagnosticsOptions options,
|
||||||
|
CurrentApiCutDiagnosticResult result)
|
||||||
{
|
{
|
||||||
foreach (var cut in template.Cuts)
|
foreach (var cut in template.Cuts)
|
||||||
{
|
{
|
||||||
@@ -396,7 +660,7 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).ConfigureAwait(false);
|
await SendSingleCutAsync(adapter, station, template, cut, snapshot, options, result).ConfigureAwait(false);
|
||||||
lastException = null;
|
lastException = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -425,18 +689,20 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
FormatCutDefinition cut,
|
FormatCutDefinition cut,
|
||||||
ElectionDataSnapshot snapshot,
|
ElectionDataSnapshot snapshot,
|
||||||
string imageRootPath)
|
CurrentApiCutDiagnosticsOptions options,
|
||||||
|
CurrentApiCutDiagnosticResult result)
|
||||||
{
|
{
|
||||||
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
ThrowIfAdapterErrored(adapter, "connect");
|
ThrowIfAdapterErrored(adapter, "connect");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, imageRootPath, CancellationToken.None).ConfigureAwait(false);
|
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
||||||
ThrowIfAdapterErrored(adapter, "apply");
|
ThrowIfAdapterErrored(adapter, "apply");
|
||||||
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
ThrowIfAdapterErrored(adapter, "prepare");
|
ThrowIfAdapterErrored(adapter, "prepare");
|
||||||
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
ThrowIfAdapterErrored(adapter, "take");
|
ThrowIfAdapterErrored(adapter, "take");
|
||||||
|
await CaptureSceneImageIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -464,6 +730,83 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task CaptureSceneImageIfRequestedAsync(
|
||||||
|
ITornado3Adapter adapter,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
FormatCutDefinition cut,
|
||||||
|
CurrentApiCutDiagnosticsOptions options,
|
||||||
|
CurrentApiCutDiagnosticResult result)
|
||||||
|
{
|
||||||
|
if (!options.CaptureSceneImages || adapter is not KarismaTornado3Adapter karismaAdapter)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var captureDirectory = Path.Combine(options.OutputPath, "captures");
|
||||||
|
Directory.CreateDirectory(captureDirectory);
|
||||||
|
var districtToken = result.DistrictCode.Replace(",", "-", StringComparison.Ordinal);
|
||||||
|
if (districtToken.Length > 40)
|
||||||
|
{
|
||||||
|
districtToken = districtToken[..40];
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileStem = SanitizeFileName(
|
||||||
|
$"{result.Station}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}");
|
||||||
|
var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}.png"));
|
||||||
|
var (width, height) = ResolveSceneCaptureSize(template);
|
||||||
|
|
||||||
|
await karismaAdapter.SavePendingSceneImageAsync(
|
||||||
|
template.RecommendedChannel,
|
||||||
|
outputPath,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
frame: -1,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ThrowIfAdapterErrored(adapter, "capture");
|
||||||
|
|
||||||
|
result.CapturePath = outputPath;
|
||||||
|
result.CaptureHash = ComputeSha256(outputPath);
|
||||||
|
result.CaptureBytes = new FileInfo(outputPath).Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
var sourceWidth = template.SceneWidth.GetValueOrDefault(1920);
|
||||||
|
var sourceHeight = template.SceneHeight.GetValueOrDefault(1080);
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0)
|
||||||
|
{
|
||||||
|
return (1280, 720);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int maxWidth = 1280;
|
||||||
|
if (sourceWidth <= maxWidth)
|
||||||
|
{
|
||||||
|
return (sourceWidth, sourceHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
var scale = maxWidth / (double)sourceWidth;
|
||||||
|
return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSha256(string path)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
return Convert.ToHexString(SHA256.HashData(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string value)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var sanitized = new string(value.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray()).Trim();
|
||||||
|
if (sanitized.Length > 80)
|
||||||
|
{
|
||||||
|
sanitized = sanitized[..80];
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(sanitized) ? "capture.png" : sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
|
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
|
||||||
{
|
{
|
||||||
if (adapter.State == TornadoConnectionState.Error)
|
if (adapter.State == TornadoConnectionState.Error)
|
||||||
@@ -688,7 +1031,21 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
return "광역단체장";
|
return "광역단체장";
|
||||||
}
|
}
|
||||||
|
|
||||||
return phase == BroadcastPhase.PreElection ? "광역단체장" : defaultElectionType;
|
if (phase == BroadcastPhase.PreElection)
|
||||||
|
{
|
||||||
|
return SupportsPreElectionTurnout(defaultElectionType)
|
||||||
|
? defaultElectionType
|
||||||
|
: "광역단체장";
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultElectionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SupportsPreElectionTurnout(string? electionType)
|
||||||
|
{
|
||||||
|
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(electionType, "교육감", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(electionType, "기초단체장", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizeRegion(string? regionName)
|
private static string NormalizeRegion(string? regionName)
|
||||||
@@ -816,6 +1173,8 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
|
|
||||||
public bool LiveSend { get; init; }
|
public bool LiveSend { get; init; }
|
||||||
|
|
||||||
|
public bool CaptureSceneImages { get; init; }
|
||||||
|
|
||||||
public int SendLimit { get; init; } = 24;
|
public int SendLimit { get; init; } = 24;
|
||||||
|
|
||||||
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
|
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
@@ -840,6 +1199,7 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
var excludeFilter = string.Empty;
|
var excludeFilter = string.Empty;
|
||||||
var simulateSend = true;
|
var simulateSend = true;
|
||||||
var liveSend = false;
|
var liveSend = false;
|
||||||
|
var captureSceneImages = false;
|
||||||
var sendLimit = 24;
|
var sendLimit = 24;
|
||||||
var outputPath = Path.Combine(
|
var outputPath = Path.Combine(
|
||||||
"artifacts",
|
"artifacts",
|
||||||
@@ -892,6 +1252,9 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
simulateSend = true;
|
simulateSend = true;
|
||||||
liveSend = true;
|
liveSend = true;
|
||||||
break;
|
break;
|
||||||
|
case "--capture-scene-images":
|
||||||
|
captureSceneImages = true;
|
||||||
|
break;
|
||||||
case "--send-limit":
|
case "--send-limit":
|
||||||
if (int.TryParse(NextValue(), out var parsedSendLimit))
|
if (int.TryParse(NextValue(), out var parsedSendLimit))
|
||||||
{
|
{
|
||||||
@@ -923,9 +1286,10 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
ExcludeFilter = excludeFilter,
|
ExcludeFilter = excludeFilter,
|
||||||
SimulateSend = simulateSend,
|
SimulateSend = simulateSend,
|
||||||
LiveSend = liveSend,
|
LiveSend = liveSend,
|
||||||
|
CaptureSceneImages = captureSceneImages,
|
||||||
SendLimit = sendLimit,
|
SendLimit = sendLimit,
|
||||||
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
|
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
|
||||||
OutputPath = outputPath,
|
OutputPath = Path.GetFullPath(outputPath),
|
||||||
DefaultElectionType = defaultElectionType
|
DefaultElectionType = defaultElectionType
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -966,6 +1330,12 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
|
|
||||||
public string SourcePath { get; set; } = string.Empty;
|
public string SourcePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CapturePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CaptureHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public long CaptureBytes { get; set; }
|
||||||
|
|
||||||
public int CandidateCount { get; set; }
|
public int CandidateCount { get; set; }
|
||||||
|
|
||||||
public int PositiveCandidateVoteCount { get; set; }
|
public int PositiveCandidateVoteCount { get; set; }
|
||||||
|
|||||||
Reference in New Issue
Block a user