어린이날 기념 커밋

This commit is contained in:
2026-05-05 00:50:11 +09:00
parent e40a2a568e
commit 960163dad8
29 changed files with 4399 additions and 463 deletions

View File

@@ -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)
{ {

View File

@@ -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}"
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}" /> 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
Orientation="Horizontal"
Spacing="8">
<TextBlock
VerticalAlignment="Center"
Style="{StaticResource ConsoleLabelTextStyle}">
<Run Text="컷 " /> <Run Text="컷 " />
<Run Text="{x:Bind TotalCuts}" /> <Run Text="{x:Bind TotalCuts}" />
<Run Text=" | 기본 " /> <Run Text=" | 기본" />
<Run Text="{x:Bind DefaultCutDurationSeconds}" />
<Run Text="초" />
</TextBlock> </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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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
}; };
} }

View File

@@ -0,0 +1,15 @@
namespace Tornado3_2026Election.Domain;
public enum CutCategory
{
MetropolitanHead,
LocalHead,
Superintendent,
MetropolitanCouncil,
LocalCouncil,
NationalAssembly,
PreElection,
Historical,
Turnout,
Title
}

View File

@@ -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 = "");

View File

@@ -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; }
} }

View File

@@ -4,5 +4,6 @@ public enum ScheduleRegionScope
{ {
All, All,
StationRegions, StationRegions,
RegionGroup,
Single Single
} }

View File

@@ -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"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="6">
<NumberBox Width="92"
Height="34"
MinHeight="34"
VerticalAlignment="Center"
FontSize="14"
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}" Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
SmallChange="1" SmallChange="1"
SpinButtonPlacementMode="Compact" SpinButtonPlacementMode="Hidden"
Value="{x:Bind DurationSeconds, Mode=TwoWay}" /> 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>

View File

@@ -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;

View File

@@ -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))

View File

@@ -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);
if (takeOutputOff)
{
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false); 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,12 +323,16 @@ public sealed class ChannelScheduleEngine
if (ShouldUseAggregateScheduleSnapshot(template)) if (ShouldUseAggregateScheduleSnapshot(template))
{ {
var aggregateRegionGroups = ResolveAggregateScheduleRegionGroups(template, regionTargets);
for (var groupIndex = 0; groupIndex < aggregateRegionGroups.Count; groupIndex++)
{
var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
ElectionDataSnapshot aggregateSnapshot; ElectionDataSnapshot aggregateSnapshot;
try try
{ {
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false); await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
aggregateSnapshot = await _dataRefreshGate aggregateSnapshot = await _dataRefreshGate
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, regionTargets, cancellationToken) .GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -301,29 +341,24 @@ public sealed class ChannelScheduleEngine
} }
catch (Exception ex) catch (Exception ex)
{ {
queueItem.State = ScheduleQueueItemState.Error; lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {ex.Message}";
queueItem.LastError = ex.Message; _logService.Warning($"[{Channel}] 집계형 송출 데이터 수신 실패: {lastFailure}");
queueItem.CurrentRegionLabel = string.Empty; continue;
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 집계형 송출 데이터 수신 실패: {ex.Message}");
return;
} }
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();
@@ -338,14 +373,31 @@ 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;
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)

View 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);
}
}

View File

@@ -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_민방", "투표율_선거구별")
}; };
} }

View File

@@ -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

View File

@@ -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(

View File

@@ -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))

View File

@@ -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)) }
var expandedRegionName = ExpandRegionName(regionName);
if (string.IsNullOrWhiteSpace(expandedRegionName))
{ {
return $"{NormalizeRegionName(normalizedRegionName)}시장"; return regionName?.Trim() ?? string.Empty;
} }
if (trimmedOfficeName.EndsWith("지사", StringComparison.Ordinal)) var shortRegionName = NormalizeRegionName(expandedRegionName);
if (expandedRegionName.EndsWith("시", StringComparison.Ordinal))
{ {
return $"{normalizedRegionName}지사"; return $"{shortRegionName}시장";
} }
return trimmedOfficeName; if (expandedRegionName.EndsWith("도", StringComparison.Ordinal))
}
if (normalizedRegionName.EndsWith("시", StringComparison.Ordinal))
{ {
return $"{NormalizeRegionName(normalizedRegionName)}시장"; return $"{shortRegionName}지사";
} }
if (normalizedRegionName.EndsWith("도", StringComparison.Ordinal)) return expandedRegionName;
}
private static string NormalizeMayorGovernorOfficeLabel(string officeName)
{ {
return $"{normalizedRegionName}지사"; var trimmed = officeName.Trim();
var suffix = trimmed.EndsWith("시장", StringComparison.Ordinal)
? "시장"
: trimmed.EndsWith("지사", StringComparison.Ordinal)
? "지사"
: string.Empty;
if (string.IsNullOrWhiteSpace(suffix))
{
return trimmed;
} }
return normalizedRegionName; 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,

View File

@@ -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) &&

View File

@@ -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);
} }

View 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; }
}

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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,6 +1472,9 @@ 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;
_isSyncingQueuedCutDurations = true;
try
{
foreach (var channel in Channels) foreach (var channel in Channels)
{ {
foreach (var item in channel.Queue.Where(item => string.Equals(item.FormatId, template.Id, StringComparison.Ordinal))) foreach (var item in channel.Queue.Where(item => string.Equals(item.FormatId, template.Id, StringComparison.Ordinal)))
@@ -1326,6 +1483,11 @@ public sealed class MainViewModel : ObservableObject
} }
} }
} }
finally
{
_isSyncingQueuedCutDurations = false;
}
}
private static string BuildCutDurationKey(string formatId, string cutName) private static string BuildCutDurationKey(string formatId, string cutName)
{ {

View File

@@ -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)

View File

@@ -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; }