어린이날 기념 커밋

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<bool>? _canExecute;
private readonly bool _allowConcurrentExecutions;
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;
_canExecute = canExecute;
_allowConcurrentExecutions = allowConcurrentExecutions;
}
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)
{

View File

@@ -163,10 +163,15 @@
<StackPanel Spacing="14">
<Grid ColumnSpacing="12" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150" />
<ColumnDefinition Width="240" />
<ColumnDefinition Width="220" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="220" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
@@ -174,36 +179,110 @@
<ComboBox
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"
ItemsSource="{x:Bind ViewModel.AvailableFormats, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedFormat, Mode=TwoWay}" />
<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"
ItemsSource="{x:Bind ViewModel.RegionOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedRegionOption, Mode=TwoWay}" />
<Button
Grid.Column="2"
Grid.Column="5"
Command="{x:Bind ViewModel.AddFormatCommand}"
Content="컷 추가"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
Style="{StaticResource ConsolePrimaryButtonStyle}"
VerticalAlignment="Bottom" />
<StackPanel
Grid.Column="4"
VerticalAlignment="Bottom"
Orientation="Horizontal"
Spacing="6">
<NumberBox
Width="92"
Header="송출 시간"
Minimum="{x:Bind ViewModel.SelectedFormatMinimumDurationSeconds, Mode=OneWay}"
SmallChange="1"
SpinButtonPlacementMode="Hidden"
Value="{x:Bind ViewModel.SelectedFormatDraftDurationSeconds, Mode=TwoWay}" />
<StackPanel
VerticalAlignment="Bottom"
Spacing="2">
<Button
Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Command="{x:Bind ViewModel.IncreaseSelectedFormatDurationCommand}"
Content="▲"
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button
Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Command="{x:Bind ViewModel.ApplySelectedFormatDurationCommand}"
Content="확인"
FontSize="12"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button
Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Command="{x:Bind ViewModel.DecreaseSelectedFormatDurationCommand}"
Content="▼"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
<TextBlock
VerticalAlignment="Bottom"
Margin="0,0,0,8"
Style="{StaticResource ConsoleLabelTextStyle}"
Text="초" />
</StackPanel>
<ToggleSwitch
Grid.Column="3"
Grid.Row="1"
Grid.Column="0"
Header="반복"
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
<ComboBox
Grid.Column="4"
Grid.Row="1"
Grid.Column="1"
Width="150"
Header="빈 스케줄"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.EmptyBehaviorOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedEmptyBehaviorOption, Mode=TwoWay}" />
<Button
Grid.Column="5"
Grid.Row="1"
Grid.Column="2"
Width="22"
Height="22"
MinWidth="22"
@@ -228,35 +307,13 @@
Orientation="Horizontal"
Spacing="10">
<Button
Command="{x:Bind ViewModel.StartCommand}"
Command="{x:Bind ViewModel.DirectStartCommand}"
Content="시작"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button
Command="{x:Bind ViewModel.StopCommand}"
Command="{x:Bind ViewModel.DirectStopCommand}"
Content="정지"
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>
<Border
@@ -647,10 +704,42 @@
</Button.Flyout>
</Button>
</StackPanel>
<TextBlock
<StackPanel
Grid.Column="1"
Style="{StaticResource ConsoleLabelTextStyle}"
Text="실행 순서" />
HorizontalAlignment="Right"
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>
<ListView
@@ -733,13 +822,60 @@
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}">
<Run Text="컷 " />
<Run Text="{x:Bind TotalCuts}" />
<Run Text=" | 기본 " />
<Run Text="{x:Bind DefaultCutDurationSeconds}" />
<Run Text="초" />
</TextBlock>
<StackPanel
Orientation="Horizontal"
Spacing="8">
<TextBlock
VerticalAlignment="Center"
Style="{StaticResource ConsoleLabelTextStyle}">
<Run Text="컷 " />
<Run Text="{x:Bind TotalCuts}" />
<Run Text=" | 기본" />
</TextBlock>
<NumberBox
Width="96"
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
SmallChange="1"
SpinButtonPlacementMode="Hidden"
Value="{x:Bind DraftCutDurationSeconds, Mode=TwoWay}" />
<StackPanel
VerticalAlignment="Center"
Spacing="2">
<Button
Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Click="IncreaseDurationButton_Click"
Content="▲"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Click="ApplyDurationButton_Click"
Content="확인"
FontSize="12"
IsEnabled="{x:Bind HasPendingDurationChange, Mode=OneWay}"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Click="DecreaseDurationButton_Click"
Content="▼"
Style="{StaticResource PanelCommandButtonStyle}" />
</StackPanel>
<TextBlock
VerticalAlignment="Center"
Style="{StaticResource ConsoleLabelTextStyle}"
Text="초" />
</StackPanel>
</StackPanel>
<StackPanel

View File

@@ -64,6 +64,28 @@ public sealed partial class ChannelSchedulePanel : UserControl
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)
{
return (sender as FrameworkElement)?.DataContext as ChannelScheduleItem;

View File

@@ -54,6 +54,21 @@ public sealed class CandidateEntry : ObservableObject
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
{
get => _voteCount;
@@ -197,6 +212,11 @@ public sealed class CandidateEntry : ObservableObject
Name = Name,
Party = Party,
ColorParty = ColorParty,
BroadcastDistrictName = BroadcastDistrictName,
BroadcastRegionName = BroadcastRegionName,
BroadcastElectionDistrictName = BroadcastElectionDistrictName,
BroadcastDistrictCode = BroadcastDistrictCode,
BroadcastCountedRate = BroadcastCountedRate,
VoteCount = VoteCount,
VoteRate = VoteRate,
HasImage = HasImage,

View File

@@ -15,6 +15,7 @@ public sealed class ChannelScheduleItem : ObservableObject
private DateTimeOffset? _lastPlayedAt;
private string _currentRegionLabel = string.Empty;
private double _defaultCutDurationSeconds;
private double _draftCutDurationSeconds;
private int _totalCuts;
private double _thumbnailWidth = 160;
private double _thumbnailHeight = 90;
@@ -35,7 +36,33 @@ public sealed class ChannelScheduleItem : ObservableObject
public required double 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
@@ -117,6 +144,15 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore]
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]
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "아직 송출 전";
@@ -125,6 +161,7 @@ public sealed class ChannelScheduleItem : ObservableObject
{
ScheduleRegionScope.All => "전체",
ScheduleRegionScope.StationRegions => "선택권역",
ScheduleRegionScope.RegionGroup => string.IsNullOrWhiteSpace(RegionLabel) ? "시도" : RegionLabel,
_ => string.IsNullOrWhiteSpace(RegionLabel) ? "개별 지역" : RegionLabel
};
@@ -173,6 +210,23 @@ public sealed class ChannelScheduleItem : ObservableObject
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)
{
var selectedRegion = regionOption ?? new ScheduleRegionOption
@@ -192,7 +246,9 @@ public sealed class ChannelScheduleItem : ObservableObject
TotalCuts = template.Cuts.Count,
RegionScope = selectedRegion.Scope,
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
};
}

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,
string Label,
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 bool UseEndScene { get; init; }
public string? SceneIdOverride { get; init; }
}

View File

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

View File

@@ -470,7 +470,10 @@
SelectedValuePath="Value" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
<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}" />
</StackPanel>
</Grid>
@@ -483,6 +486,120 @@
</StackPanel>
</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"
Background="{StaticResource ControlRoomPanelGradientBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
@@ -520,6 +637,25 @@
FontSize="24"
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
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}"
Text="{x:Bind DetailText}"
TextWrapping="Wrap" />
@@ -783,7 +919,7 @@
ItemsSource="{x:Bind ViewModel.CutListFilterOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedCutListFilterOption, Mode=TwoWay}" />
<ComboBox Grid.Column="1"
Header="선거 분류"
Header=" 분류"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.CutListCategoryOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedCutListCategoryOption, Mode=TwoWay}" />
@@ -816,7 +952,7 @@
</StackPanel>
</Grid>
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="기본 송출 시간을 바꾸면 이후 송출과 대기열 표시값에 바로 반영됩니다."
Text="기본 송출 시간을 조정한 뒤 확인을 누르면 이후 송출과 대기열 표시값에 반영됩니다."
TextWrapping="Wrap" />
</StackPanel>
</Border>
@@ -832,7 +968,7 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="110" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="140" />
<ColumnDefinition Width="220" />
</Grid.ColumnDefinitions>
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="썸네일" />
<TextBlock Grid.Column="1" Style="{StaticResource ConsoleLabelTextStyle}" Text="권장 채널" />
@@ -856,7 +992,7 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="110" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="140" />
<ColumnDefinition Width="220" />
</Grid.ColumnDefinitions>
<Border Width="{x:Bind ThumbnailWidth, Mode=OneWay}"
@@ -893,11 +1029,52 @@
TextWrapping="Wrap" />
</StackPanel>
<NumberBox Grid.Column="3"
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
SmallChange="1"
SpinButtonPlacementMode="Compact"
Value="{x:Bind DurationSeconds, Mode=TwoWay}" />
<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}"
SmallChange="1"
SpinButtonPlacementMode="Hidden"
Value="{x:Bind DraftDurationSeconds, Mode=TwoWay}" />
<StackPanel VerticalAlignment="Center"
Spacing="2">
<Button Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Command="{x:Bind IncreaseDurationCommand}"
Content="▲"
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Command="{x:Bind ApplyDurationCommand}"
Content="확인"
FontSize="12"
IsEnabled="{x:Bind HasPendingDurationChange, Mode=OneWay}"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button Width="48"
Height="24"
MinWidth="48"
MinHeight="24"
Padding="0"
Command="{x:Bind DecreaseDurationCommand}"
Content="▼"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
<TextBlock VerticalAlignment="Center"
Style="{StaticResource ConsoleLabelTextStyle}"
Text="초" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
@@ -1006,10 +1183,9 @@
OffContent="OFF"
OnContent="ON" />
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
<NumberBox Header="API 갱신 주기(초)"
Minimum="3"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="API 갱신 주기: 60초 고정"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>

View File

@@ -43,6 +43,10 @@ public sealed class AppState
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 TurnoutVotes { get; set; } = 528_400;

View File

@@ -29,19 +29,23 @@ public sealed class CareerPromiseService
public string FilePath { get; }
public IReadOnlyDictionary<string, CareerPromiseEntry> GetEntryLookup(
public IReadOnlyList<CareerPromiseEntry> GetEntries(
string stationId,
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
.Where(entry =>
string.Equals(entry.StationId, stationId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(entry.ElectionType, electionType, StringComparison.Ordinal) &&
string.Equals(entry.DistrictCode, districtCode, StringComparison.OrdinalIgnoreCase))
.Where(entry => !string.IsNullOrWhiteSpace(entry.CandidateCode))
.GroupBy(entry => entry.CandidateCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) &&
MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
.ToArray();
}
public void SaveEntries(
@@ -62,7 +66,7 @@ public sealed class CareerPromiseService
.Where(entry =>
!string.Equals(entry.StationId, normalizedStationId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(entry.ElectionType, normalizedElectionType, StringComparison.Ordinal) ||
!string.Equals(entry.DistrictCode, normalizedDistrictCode, StringComparison.OrdinalIgnoreCase))
!MatchesDistrict(entry, normalizedDistrictCode, normalizedDistrictName))
.ToList();
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)
{
if (!File.Exists(filePath))

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -20,6 +21,7 @@ public sealed class ChannelScheduleEngine
private CancellationTokenSource? _playbackCts;
private TaskCompletionSource<bool>? _advanceSignal;
private Guid? _preferredNextItemId;
private Guid? _skipCurrentItemId;
public ChannelScheduleEngine(
BroadcastChannel channel,
@@ -57,6 +59,7 @@ public sealed class ChannelScheduleEngine
{
if (IsRunning)
{
await AdvanceToNextAsync().ConfigureAwait(false);
return;
}
@@ -67,7 +70,7 @@ public sealed class ChannelScheduleEngine
await Task.CompletedTask;
}
public async Task StopAsync()
public async Task StopAsync(bool takeOutputOff = true)
{
if (!IsRunning)
{
@@ -76,7 +79,10 @@ public sealed class ChannelScheduleEngine
_playbackCts?.Cancel();
_advanceSignal?.TrySetResult(true);
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
if (takeOutputOff)
{
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
}
foreach (var item in Queue.Where(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending))
{
@@ -85,11 +91,35 @@ public sealed class ChannelScheduleEngine
}
_preferredNextItemId = null;
_skipCurrentItemId = null;
IsRunning = false;
RefreshQueueMarkers();
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()
{
_preferredNextItemId = null;
@@ -105,23 +135,7 @@ public sealed class ChannelScheduleEngine
public async Task ForceNextAsync()
{
if (!IsRunning)
{
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);
await AdvanceToNextAsync().ConfigureAwait(false);
}
public async Task ForceQueueNextAsync()
@@ -141,6 +155,28 @@ public sealed class ChannelScheduleEngine
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)
{
if (item is null || !item.CanDelete)
@@ -287,65 +323,81 @@ public sealed class ChannelScheduleEngine
if (ShouldUseAggregateScheduleSnapshot(template))
{
ElectionDataSnapshot aggregateSnapshot;
try
var aggregateRegionGroups = ResolveAggregateScheduleRegionGroups(template, regionTargets);
for (var groupIndex = 0; groupIndex < aggregateRegionGroups.Count; groupIndex++)
{
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
aggregateSnapshot = await _dataRefreshGate
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, regionTargets, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = ex.Message;
queueItem.CurrentRegionLabel = string.Empty;
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 집계형 송출 데이터 수신 실패: {ex.Message}");
return;
}
var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
ElectionDataSnapshot aggregateSnapshot;
try
{
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
aggregateSnapshot = await _dataRefreshGate
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {ex.Message}";
_logService.Warning($"[{Channel}] 집계형 송출 데이터 수신 실패: {lastFailure}");
continue;
}
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = aggregateValidationError;
queueItem.CurrentRegionLabel = string.Empty;
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {aggregateValidationError}");
return;
}
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
{
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}";
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}");
continue;
}
queueItem.CurrentRegionLabel = queueItem.SelectionRegionLabel;
queueItem.CurrentRegionLabel = ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup);
var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
for (var cutIndex = 0; cutIndex < resolvedCuts.Count; cutIndex++)
{
var cut = ResolveScheduledCut(resolvedCuts[cutIndex], hasEndScene, cutIndex == resolvedCuts.Count - 1);
queueItem.State = ScheduleQueueItemState.Sending;
RefreshQueueMarkers();
for (var cutIndex = 0; cutIndex < resolvedCuts.Count; cutIndex++)
{
var cut = ResolveScheduledCut(resolvedCuts[cutIndex], hasEndScene && isLastGroup, cutIndex == resolvedCuts.Count - 1);
queueItem.State = ScheduleQueueItemState.Sending;
RefreshQueueMarkers();
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter.ApplyCutAsync(Channel, template, cut, aggregateSnapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter.ApplyCutAsync(Channel, template, cut, aggregateSnapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
queueItem.State = ScheduleQueueItemState.OnAir;
queueItem.LastPlayedAt = DateTimeOffset.Now;
RefreshQueueMarkers();
queueItem.State = ScheduleQueueItemState.OnAir;
queueItem.LastPlayedAt = DateTimeOffset.Now;
RefreshQueueMarkers();
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_advanceSignal = signal;
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_advanceSignal = signal;
if (ShouldSkipCurrentItem(queueItem))
{
signal.TrySetResult(true);
}
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
playedAny = true;
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
queueItem.CurrentRegionLabel = string.Empty;
queueItem.State = ScheduleQueueItemState.Completed;
queueItem.LastError = string.Empty;
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers();
return;
}
@@ -400,22 +452,54 @@ public sealed class ChannelScheduleEngine
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_advanceSignal = signal;
if (ShouldSkipCurrentItem(queueItem))
{
signal.TrySetResult(true);
}
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
playedAny = true;
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
queueItem.CurrentRegionLabel = string.Empty;
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem);
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)
{
if (IsCurrentLeaderTemplate(template))
{
return true;
}
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return true;
@@ -431,39 +515,170 @@ public sealed class ChannelScheduleEngine
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(
FormatTemplateDefinition template,
IReadOnlyList<FormatCutDefinition> baseCuts,
ElectionDataSnapshot snapshot,
bool useEndSceneOnLastCut)
{
if (!IsCareerTemplate(template) || baseCuts.Count == 0)
{
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
}
var candidateCount = snapshot.Candidates.Count;
if (candidateCount <= 1)
if (!IsCandidatePagedTemplate(template) || baseCuts.Count == 0)
{
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);
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
{
Name = $"{baseCut.Name} #{candidateIndex + 1}",
Name = cutName,
DurationSeconds = baseCut.DurationSeconds,
CandidateStartIndex = candidateIndex,
UseEndScene = baseCut.UseEndScene
CandidateStartIndex = candidateStartIndex,
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(
@@ -495,7 +710,8 @@ public sealed class ChannelScheduleEngine
Name = cut.Name,
DurationSeconds = cut.DurationSeconds,
CandidateStartIndex = cut.CandidateStartIndex,
UseEndScene = true
UseEndScene = true,
SceneIdOverride = cut.SceneIdOverride
};
}
@@ -504,6 +720,58 @@ public sealed class ChannelScheduleEngine
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)
{
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위_광역단체장_HD",
"이시각1위_광역단체장_L",
"이시각1위_기초단체장",
"이시각1위_기초단체장_HD",
"이시각1위_기초단체장_L",
"접전_광역단체장",
"접전_기초단체장",
"초접전_광역단체장",
@@ -178,7 +180,8 @@ public sealed class FormatCatalogService
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
RequiresCandidateData = !isPreElectionOnlyFormat &&
!IsHistoricalPreElectionWinnerFormat(baseName) &&
!ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(baseName),
!ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(baseName) &&
!ScheduleTemplatePolicy.IsTitleFormat(baseName),
LoopMode = LoopMode.None,
SceneWidth = sceneResolution?.Width,
SceneHeight = sceneResolution?.Height,
@@ -206,12 +209,38 @@ public sealed class FormatCatalogService
{
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위_광역단체장_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_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_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_1")] = 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_민방", "모든후보_광역단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장"),
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_5760"),
[Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_광역단체장"),
[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_민방", "모든후보_교육감_loop")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감"),
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_5760"),
[Path.Combine("Elect2026_Normal_민방", "모든후보_교육감_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_교육감"),
[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_민방", "모든후보_기초단체장_loop")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장"),
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장"),
[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_민방", "사전_역대투표율_L")] = 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_민방", "이시각1위_기초단체장_L")] = Path.Combine("Elect2026_Normal_민방", "이시각1위_기초단체장_HD"),
[Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = 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 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 endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn");
@@ -21,7 +35,7 @@ internal static class KarismaSceneResolver
{
selectedPath = endScenePath;
}
else if (useLoop && File.Exists(loopScenePath))
else if (!hasSceneOverride && useLoop && File.Exists(loopScenePath))
{
selectedPath = loopScenePath;
}
@@ -29,13 +43,13 @@ internal static class KarismaSceneResolver
{
selectedPath = baseScenePath;
}
else if (File.Exists(loopScenePath))
else if (!hasSceneOverride && File.Exists(loopScenePath))
{
selectedPath = loopScenePath;
}
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(

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 DefaultPlateWidth = 552;
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 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 ConcurrentDictionary<string, CachedCatalog> CatalogCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly IReadOnlyDictionary<string, string> ExplicitRgbSpecMap = BuildExplicitRgbSpecMap();
private static readonly string[] OtherPartyFallbackKeys =
[
"무기타",
"무소속기타",
"기타",
"무소속"
];
public static string ResolveFallbackAssetPath(
string templateFolderPath,
@@ -65,7 +74,7 @@ internal static class PartyColorCatalog
return false;
}
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName))
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName).Concat(OtherPartyFallbackKeys))
{
if (!section.PartyColors.TryGetValue(candidatePartyName, out var color))
{
@@ -453,7 +462,7 @@ internal static class PartyColorCatalog
continue;
}
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName))
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName).Concat(OtherPartyFallbackKeys))
{
if (section.PartyColors.TryGetValue(candidatePartyName, out color))
{
@@ -477,6 +486,7 @@ internal static class PartyColorCatalog
PartyColorAssetUsage.Outline => ["정당원", "정당색", "정당판", "정당바"],
PartyColorAssetUsage.Color => ["정당색", "정당바", "정당판"],
PartyColorAssetUsage.Group => ["그룹", "정당바", "정당판"],
PartyColorAssetUsage.Symbol => ["정당심볼", "정당바", "정당판"],
_ => Array.Empty<string>()
};
}
@@ -561,16 +571,19 @@ internal static class PartyColorCatalog
var safeTemplateName = SanitizeFileName(templateName);
var safePartyName = SanitizeFileName(partyName);
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);
if (File.Exists(filePath))
{
return filePath;
}
var (width, height) = usage == PartyColorAssetUsage.Bar
? (DefaultBarWidth, DefaultBarHeight)
: (DefaultPlateWidth, DefaultPlateHeight);
WriteSolidColorPng(filePath, width, height, color);
return filePath;
}
@@ -910,7 +923,8 @@ internal enum PartyColorAssetUsage
Plate,
Outline,
Color,
Group
Group,
Symbol
}
internal readonly record struct PartyStyleColorSpec(

View File

@@ -83,7 +83,7 @@ public sealed class PreElectionHistoryService
{
_logService = logService;
_assetPath = ResolveAssetPath();
_catalog = LoadCatalog(_assetPath);
_catalog = NormalizeCatalog(LoadCatalog(_assetPath));
_recordsByElectionType = new Dictionary<string, IReadOnlyList<PreElectionHistoryRecord>>(StringComparer.Ordinal);
_recordsByLookupKey = new Dictionary<string, IReadOnlyDictionary<string, PreElectionHistoryRecord>>(StringComparer.Ordinal);
RebuildIndexes();
@@ -96,22 +96,8 @@ public sealed class PreElectionHistoryService
throw new ArgumentNullException(nameof(record));
}
var canonicalElectionType = NormalizeElectionType(record.ElectionType);
var normalizedRecord = new PreElectionHistoryRecord
{
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 normalizedRecord = NormalizeRecord(record);
var canonicalElectionType = normalizedRecord.ElectionType;
var records = _catalog.Records.ToList();
var existingIndex = records.FindIndex(existingRecord =>
@@ -141,11 +127,123 @@ public sealed class PreElectionHistoryService
.ThenBy(existingRecord => existingRecord.DisplayName, StringComparer.Ordinal)
.ToArray()
};
_catalog = NormalizeCatalog(_catalog);
PersistCatalog();
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()
{
_recordsByElectionType = SupportedElectionTypes.ToDictionary(
@@ -382,7 +480,7 @@ public sealed class PreElectionHistoryService
return string.Empty;
}
var normalized = value.Trim();
var normalized = StripBasicDistrictDisambiguation(value.Trim());
foreach (var regionLabel in RegionLabels)
{
normalized = normalized.Replace(regionLabel, string.Empty, StringComparison.OrdinalIgnoreCase);
@@ -393,12 +491,44 @@ public sealed class PreElectionHistoryService
.Replace("군수", "군", StringComparison.Ordinal)
.Replace("시장", "시", StringComparison.Ordinal)
.Replace("교육감", string.Empty, StringComparison.Ordinal)
.Replace("()", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal)
.Trim();
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()
{
if (string.IsNullOrWhiteSpace(_assetPath))

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text;
@@ -15,7 +16,8 @@ public sealed class SbsElectionApiClient : IDisposable
{
private const string BasicApiBaseUrlEnvironmentVariable = "SBS_BASIC_API_BASE_URL";
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 BasicApiBaseUri = ResolveBasicApiBaseUri();
private static readonly JsonSerializerOptions SerializerOptions = new()
@@ -29,9 +31,9 @@ public sealed class SbsElectionApiClient : IDisposable
{
["국회의원"] = new SbsElectionConfiguration(2, false, 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(4, false, LegacyBaseUri, "gaepyo"),
["기초단체장"] = new SbsElectionConfiguration(4, true, LegacyBaseUri, "gaepyo"),
["기초의원"] = new SbsElectionConfiguration(6, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment())
};
@@ -105,6 +107,8 @@ public sealed class SbsElectionApiClient : IDisposable
private readonly bool _disposeHttpClient;
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
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 SemaphoreSlim _countingCacheLock = new(1, 1);
@@ -166,6 +170,13 @@ public sealed class SbsElectionApiClient : IDisposable
})
.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)
{
return options
@@ -229,13 +240,22 @@ public sealed class SbsElectionApiClient : IDisposable
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
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(
DisplayName: districtOption.DisplayName,
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
CountedVotes: countedVotes,
TotalVotes: totalVotes,
UncountedVotes: Math.Max(0, uncountedVotes))));
UncountedVotes: Math.Max(0, uncountedVotes),
JudgementBadgeText: BuildOverviewJudgementBadgeText(judgementCandidates),
JudgementDetailText: BuildOverviewJudgementDetailText(judgementCandidates))));
}
return overviewItems
@@ -315,11 +335,22 @@ public sealed class SbsElectionApiClient : IDisposable
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
.Select(district => new
{
District = district,
RegionCode = ResolveTurnoutRegionCode(district)
RegionCode = ResolveTurnoutRegionCode(configuration, district)
})
.Where(item => !string.IsNullOrWhiteSpace(item.RegionCode))
.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 items = await GetArrayAsync<SbsTurnoutItem>(
configuration.BaseUri,
$"tupyo/{configuration.SungerType}/sidos?ids={ids}",
$"tupyo/{turnoutQueryValue.SungerType}/{turnoutQueryValue.RegionSegment}?ids={ids}",
cancellationToken).ConfigureAwait(false);
foreach (var item in items)
@@ -391,8 +422,20 @@ public sealed class SbsElectionApiClient : IDisposable
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)
? district.ParentRegionCode
: district.DistrictCode;
@@ -400,6 +443,7 @@ public sealed class SbsElectionApiClient : IDisposable
public void Dispose()
{
_jsonCacheLock.Dispose();
_countingCacheLock.Dispose();
if (_disposeHttpClient)
{
@@ -419,22 +463,28 @@ public sealed class SbsElectionApiClient : IDisposable
"선택한 선거 종류는 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>(
configuration.BaseUri,
$"tupyo/{configuration.SungerType}/sidos?ids={Uri.EscapeDataString(sido.Id)}",
$"tupyo/{turnoutQuery.SungerType}/{turnoutQuery.RegionSegment}?ids={Uri.EscapeDataString(turnoutTarget.TurnoutRegionCode)}",
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가 해당 지역의 투표 데이터를 반환하지 않았습니다.");
var regionName = ExpandRegionName(item.Region?.Name1 ?? item.Region?.Name ?? districtName);
var outputRegionName = BuildOutputRegionName(regionName);
return new SbsElectionRefreshResult(
DistrictName: regionName,
DistrictCode: sido.Id,
RegionName: outputRegionName,
ElectionDistrictName: regionName,
DistrictName: turnoutTarget.DisplayName,
DistrictCode: turnoutTarget.DistrictCode,
RegionName: turnoutTarget.RegionName,
ElectionDistrictName: turnoutTarget.ElectionDistrictName,
TotalExpectedVotes: item.Sungerinsu,
TurnoutVotes: item.Total?.Tupyosu ?? 0,
CountedRate: null,
@@ -442,7 +492,58 @@ public sealed class SbsElectionApiClient : IDisposable
RemainingVotes: null,
Candidates: null,
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(
@@ -685,18 +786,100 @@ public sealed class SbsElectionApiClient : IDisposable
private static CandidateJudgement MapJudgement(string? degree)
{
return degree switch
return NormalizeJudgementCode(degree) switch
{
"40" => CandidateJudgement.Leading,
"50" => CandidateJudgement.Confirmed,
"60" => CandidateJudgement.ElectedInProgress,
"70" => CandidateJudgement.Elected,
"80" => CandidateJudgement.UnopposedElected,
"90" => CandidateJudgement.ElectedAfterCountComplete,
"40" or "유력" => CandidateJudgement.Leading,
"50" or "확정" or "확실" => CandidateJudgement.Confirmed,
"60" or "개표중당선" => CandidateJudgement.ElectedInProgress,
"70" or "당선" => CandidateJudgement.Elected,
"80" or "무투표당선" => CandidateJudgement.UnopposedElected,
"90" or "개표마감당선" => CandidateJudgement.ElectedAfterCountComplete,
_ => 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(
SbsElectionConfiguration configuration,
string districtName,
@@ -814,6 +997,14 @@ public sealed class SbsElectionApiClient : IDisposable
.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;
}
@@ -856,6 +1047,22 @@ public sealed class SbsElectionApiClient : IDisposable
private async Task<string> GetJsonAsync(Uri baseUri, string relativePath, CancellationToken cancellationToken)
{
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);
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
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}");
}
await _jsonCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_jsonCache[cacheKey] = new JsonCacheEntry(DateTimeOffset.Now, body);
}
finally
{
_jsonCacheLock.Release();
}
return body;
}
@@ -1014,6 +1231,7 @@ public sealed class SbsElectionApiClient : IDisposable
return sungerType switch
{
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
11 => BuildEducationOfficeLabel(regionName, region?.Name4 ?? fallback.Name4),
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region, fallback),
_ => regionName
};
@@ -1050,34 +1268,94 @@ public sealed class SbsElectionApiClient : IDisposable
private static string BuildMayorGovernorLabel(string regionName, string? officeName)
{
var normalizedRegionName = ExpandRegionName(regionName);
if (!string.IsNullOrWhiteSpace(officeName))
{
var trimmedOfficeName = officeName.Trim();
if (trimmedOfficeName.EndsWith("시장", StringComparison.Ordinal))
{
return $"{NormalizeRegionName(normalizedRegionName)}시장";
}
if (trimmedOfficeName.EndsWith("지사", StringComparison.Ordinal))
{
return $"{normalizedRegionName}지사";
}
return trimmedOfficeName;
return NormalizeMayorGovernorOfficeLabel(officeName);
}
if (normalizedRegionName.EndsWith("시", StringComparison.Ordinal))
var expandedRegionName = ExpandRegionName(regionName);
if (string.IsNullOrWhiteSpace(expandedRegionName))
{
return $"{NormalizeRegionName(normalizedRegionName)}시장";
return regionName?.Trim() ?? string.Empty;
}
if (normalizedRegionName.EndsWith("도", StringComparison.Ordinal))
var shortRegionName = NormalizeRegionName(expandedRegionName);
if (expandedRegionName.EndsWith("시", StringComparison.Ordinal))
{
return $"{normalizedRegionName}지사";
return $"{shortRegionName}시장";
}
return normalizedRegionName;
if (expandedRegionName.EndsWith("도", StringComparison.Ordinal))
{
return $"{shortRegionName}지사";
}
return expandedRegionName;
}
private static string NormalizeMayorGovernorOfficeLabel(string officeName)
{
var trimmed = officeName.Trim();
var suffix = trimmed.EndsWith("시장", StringComparison.Ordinal)
? "시장"
: trimmed.EndsWith("지사", StringComparison.Ordinal)
? "지사"
: string.Empty;
if (string.IsNullOrWhiteSpace(suffix))
{
return trimmed;
}
foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
{
if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
{
return $"{pair.Key}{suffix}";
}
}
var regionPart = trimmed[..^suffix.Length];
var shortRegionName = NormalizeRegionName(regionPart);
return string.IsNullOrWhiteSpace(shortRegionName)
? trimmed
: $"{shortRegionName}{suffix}";
}
private static string BuildEducationOfficeLabel(string regionName, string? officeName)
{
if (!string.IsNullOrWhiteSpace(officeName))
{
return NormalizeEducationOfficeLabel(officeName);
}
var shortRegionName = NormalizeRegionName(regionName);
return string.IsNullOrWhiteSpace(shortRegionName)
? regionName?.Trim() ?? string.Empty
: $"{shortRegionName}교육감";
}
private static string NormalizeEducationOfficeLabel(string officeName)
{
var trimmed = officeName.Trim();
const string suffix = "교육감";
if (!trimmed.EndsWith(suffix, StringComparison.Ordinal))
{
return trimmed;
}
foreach (var pair in FullRegionNames.OrderByDescending(pair => pair.Value.Length))
{
if (trimmed.StartsWith(pair.Value, StringComparison.Ordinal))
{
return $"{pair.Key}{suffix}";
}
}
var regionPart = trimmed[..^suffix.Length];
var shortRegionName = NormalizeRegionName(regionPart);
return string.IsNullOrWhiteSpace(shortRegionName)
? trimmed
: $"{shortRegionName}{suffix}";
}
private static string BuildFullDistrictDisplayName(SbsRegionInfo region)
@@ -1109,19 +1387,27 @@ public sealed class SbsElectionApiClient : IDisposable
var districtName = sungerType switch
{
3 => BuildMayorGovernorLabel(regionName, region.Name4),
11 => BuildEducationOfficeLabel(regionName, region.Name4),
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
_ => regionName
};
var displayName = sungerType is 2 or 4 or 5 or 6
? BuildFullDistrictDisplayName(regionName, districtName)
: regionName;
var turnoutRegionCode = sungerType switch
{
3 or 11 => region.Name1Id ?? region.Id,
4 => region.Name2Id ?? string.Empty,
_ => string.Empty
};
return new DistrictSelectionOption(
DisplayName: displayName,
DistrictCode: region.Id,
RegionName: outputRegionName,
DistrictName: districtName,
ParentRegionCode: region.Name1Id ?? string.Empty);
ParentRegionCode: region.Name1Id ?? string.Empty,
TurnoutRegionCode: turnoutRegionCode);
}
private static SbsRegionInfo CreateRegionInfo(DistrictSelectionOption option)
@@ -1223,12 +1509,24 @@ public sealed class SbsElectionApiClient : IDisposable
Uri BaseUri,
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(
string DisplayName,
string DistrictCode,
string RegionName,
string DistrictName,
string ParentRegionCode);
string ParentRegionCode,
string TurnoutRegionCode = "");
public sealed record SbsElectionRefreshResult(
string DistrictName,
@@ -1249,7 +1547,9 @@ public sealed class SbsElectionApiClient : IDisposable
double CountedRate,
int CountedVotes,
int TotalVotes,
int UncountedVotes);
int UncountedVotes,
string JudgementBadgeText = "",
string JudgementDetailText = "");
public sealed record TurnoutOverviewItem(
string DisplayName,
@@ -1377,6 +1677,10 @@ public sealed class SbsElectionApiClient : IDisposable
SbsCountingItem Item,
string SourcePath);
private readonly record struct JsonCacheEntry(
DateTimeOffset ReceivedAt,
string Body);
private readonly record struct SbsCountingCacheEntry(
DateTimeOffset ReceivedAt,
IReadOnlyList<SbsCountingItem> Items,

View File

@@ -56,7 +56,8 @@ internal static class ScheduleTemplatePolicy
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)
@@ -65,6 +66,12 @@ internal static class ScheduleTemplatePolicy
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)
{
return !string.IsNullOrWhiteSpace(templateName) &&

View File

@@ -26,10 +26,13 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private readonly LogService _logService;
private readonly ObservableCollection<CutDebugItemState> _emptyCutDebugItems = [];
private IReadOnlyList<FormatTemplateDefinition> _allFormats;
private SelectionOption<CutCategory?>? _selectedFormatCategoryOption;
private SelectionOption<string>? _selectedTurnoutRegionModeOption;
private FormatTemplateDefinition? _selectedFormat;
private CutDebugTemplateState? _selectedCutDebugTemplate;
private ScheduleRegionOption? _selectedRegionOption;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private CancellationTokenSource? _directPlaybackCts;
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision;
@@ -37,6 +40,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
private double _selectedFormatThumbnailWidth = 320;
private double _selectedFormatThumbnailHeight = 180;
private double _selectedFormatDraftDurationSeconds;
public ChannelScheduleViewModel(
BroadcastChannel channel,
@@ -58,6 +62,13 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_engine = engine;
_logService = logService;
_allFormats = formats.ToArray();
FormatCategoryOptions = [];
TurnoutRegionModeOptions =
[
new SelectionOption<string>(DataViewModel.TurnoutPhotoSidoMode, "시도별 투표율"),
new SelectionOption<string>(DataViewModel.TurnoutPhotoDistrictMode, "선거구별 투표율")
];
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
RegionOptions = new ObservableCollection<ScheduleRegionOption>();
EmptyBehaviorOptions =
@@ -67,8 +78,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
];
Queue = engine.Queue;
StartCommand = new AsyncRelayCommand(StartAsync);
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
StopCommand = new AsyncRelayCommand(StopAsync);
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
@@ -77,6 +90,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
IncreaseSelectedFormatDurationCommand = new RelayCommand(IncreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
DecreaseSelectedFormatDurationCommand = new RelayCommand(DecreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
ApplySelectedFormatDurationCommand = new RelayCommand(ApplySelectedFormatDuration, CanApplySelectedFormatDuration);
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
_engine.QueueChanged += (_, _) => RefreshSummary();
@@ -86,6 +102,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_data.PropertyChanged += Data_PropertyChanged;
Queue.CollectionChanged += Queue_CollectionChanged;
RebuildFormatCategoryOptions();
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
UpdateSelectedFormatThumbnailMetrics();
@@ -111,6 +128,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
public ObservableCollection<SelectionOption<CutCategory?>> FormatCategoryOptions { get; }
public IReadOnlyList<SelectionOption<string>> TurnoutRegionModeOptions { get; }
public ObservableCollection<ScheduleRegionOption> RegionOptions { get; }
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
@@ -121,6 +142,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand DirectStartCommand { get; }
public AsyncRelayCommand DirectStopCommand { get; }
public AsyncRelayCommand ForceNextCommand { get; }
public AsyncRelayCommand ForceQueueNextCommand { get; }
@@ -139,6 +164,55 @@ public sealed class ChannelScheduleViewModel : ObservableObject
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
{
get => _selectedFormat;
@@ -146,10 +220,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
if (SetProperty(ref _selectedFormat, value))
{
EnsureTurnoutRegionModeSelection();
ResetSelectedFormatDurationDraft();
OnPropertyChanged(nameof(TurnoutRegionModeVisibility));
NotifySelectedFormatPreviewChanged();
SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
}
@@ -162,10 +240,33 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _selectedRegionOption, value))
{
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
{
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 Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed;
@@ -324,6 +449,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public void UpdateFormats(IReadOnlyList<FormatTemplateDefinition> formats)
{
_allFormats = formats.ToArray();
RebuildFormatCategoryOptions();
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
ApplyQueueThumbnailLayouts();
@@ -356,6 +482,66 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_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()
{
await _engine.ForceNextAsync().ConfigureAwait(false);
@@ -432,6 +618,56 @@ public sealed class ChannelScheduleViewModel : ObservableObject
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()
{
_engine.RefreshQueueMarkers();
@@ -459,10 +695,16 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption is not null;
}
private bool CanDirectStart()
{
return CanAddFormat();
}
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName is nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.ElectionType))
{
RebuildFormatCategoryOptions();
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
RefreshSummary();
@@ -472,8 +714,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void RebuildAvailableFormats()
{
var selectedFormatId = SelectedFormat?.Id;
var selectedCategory = SelectedFormatCategoryOption?.Value;
var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
.ToArray();
AvailableFormats.Clear();
@@ -494,9 +738,73 @@ public sealed class ChannelScheduleViewModel : ObservableObject
UpdateSelectedFormatThumbnailMetrics();
SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
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()
{
var revision = Interlocked.Increment(ref _regionOptionsRevision);
@@ -509,11 +817,15 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = null;
_lastRegionOptionFormatId = string.Empty;
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
return;
}
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)
{
return;
@@ -532,6 +844,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
_lastRegionOptionFormatId = selectedFormat.Id;
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
private void NotifySelectedFormatPreviewChanged()
@@ -555,6 +868,33 @@ public sealed class ChannelScheduleViewModel : ObservableObject
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)
{
ApplyQueueThumbnailLayouts();
@@ -576,10 +916,11 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return ResolveDefaultRegionOption(options, selectedFormat);
}
if (previousSelection.Scope == ScheduleRegionScope.Single)
if (previousSelection.Scope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup)
{
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));
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);
}

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 VideoWallLayoutPreset _videoWallLayoutPreset;
private double _durationSeconds;
private double _draftDurationSeconds;
private double _thumbnailWidth;
private double _thumbnailHeight;
private ImageSource? _thumbnailSource;
@@ -29,8 +30,12 @@ public sealed class CutListEntryViewModel : ObservableObject
_durationChanged = durationChanged;
_videoWallLayoutPreset = videoWallLayoutPreset;
_durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
_draftDurationSeconds = _durationSeconds;
_cut.DurationSeconds = _durationSeconds;
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
IncreaseDurationCommand = new RelayCommand(IncreaseDraftDuration);
DecreaseDurationCommand = new RelayCommand(DecreaseDraftDuration);
ApplyDurationCommand = new RelayCommand(ApplyDraftDuration, CanApplyDraftDuration);
ApplyThumbnailLayout();
}
@@ -57,8 +62,19 @@ public sealed class CutListEntryViewModel : ObservableObject
public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
public bool IsInCategory(CutCategory category)
{
return CutCategoryResolver.IsMatch(_template, category);
}
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(_template);
public RelayCommand IncreaseDurationCommand { get; }
public RelayCommand DecreaseDurationCommand { get; }
public RelayCommand ApplyDurationCommand { get; }
public ImageSource? ThumbnailSource => _thumbnailSource;
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
@@ -72,6 +88,7 @@ public sealed class CutListEntryViewModel : ObservableObject
get => _durationSeconds;
set
{
var hadPendingDurationChange = HasPendingDurationChange;
if (double.IsNaN(value) || double.IsInfinity(value))
{
return;
@@ -89,10 +106,38 @@ public sealed class CutListEntryViewModel : ObservableObject
}
_cut.DurationSeconds = normalized;
if (!hadPendingDurationChange || _draftDurationSeconds <= 0)
{
SetProperty(ref _draftDurationSeconds, normalized, nameof(DraftDurationSeconds));
}
NotifyDurationStateChanged();
_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()
{
var sourceValue = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(_cut.DurationSeconds, _template);
@@ -101,6 +146,13 @@ public sealed class CutListEntryViewModel : ObservableObject
{
SetProperty(ref _durationSeconds, sourceValue);
}
if (Math.Abs(_draftDurationSeconds - sourceValue) >= 0.001d)
{
SetProperty(ref _draftDurationSeconds, sourceValue, nameof(DraftDurationSeconds));
}
NotifyDurationStateChanged();
}
public void RefreshThumbnail()
@@ -126,4 +178,31 @@ public sealed class CutListEntryViewModel : ObservableObject
SetProperty(ref _thumbnailWidth, metrics.Width, nameof(ThumbnailWidth));
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;
public sealed class DistrictOverviewCardViewModel
@@ -9,4 +11,13 @@ public sealed class DistrictOverviewCardViewModel
public required string CountedRateDisplay { 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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Linq;
@@ -33,6 +34,7 @@ public sealed class MainViewModel : ObservableObject
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
private bool _isSituationRoomExpanded;
private bool _suppressAutomaticSave;
private bool _isSyncingQueuedCutDurations;
private CancellationTokenSource? _automaticSaveCts;
private int? _windowX;
private int? _windowY;
@@ -42,7 +44,7 @@ public sealed class MainViewModel : ObservableObject
private SelectionOption<LogLevel?>? _selectedLogFilterOption;
private readonly List<(BroadcastChannel Channel, CutListEntryViewModel Entry)> _allCutListEntries = [];
private SelectionOption<BroadcastChannel?>? _selectedCutListFilterOption;
private SelectionOption<CutListElectionCategory?>? _selectedCutListCategoryOption;
private SelectionOption<CutCategory?>? _selectedCutListCategoryOption;
private string _thumbnailGenerationStatus = string.Empty;
public MainViewModel()
@@ -73,19 +75,11 @@ public sealed class MainViewModel : ObservableObject
new SelectionOption<BroadcastChannel?>(BroadcastChannel.Bottom, "하단"),
new SelectionOption<BroadcastChannel?>(BroadcastChannel.VideoWall, "비디오월")
];
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")
];
CutListCategoryOptions = [];
FilteredLogs = [];
CutListItems = [];
_selectedCutListFilterOption = CutListFilterOptions[0];
_selectedCutListCategoryOption = CutListCategoryOptions[0];
RebuildCutListCategoryOptions();
_cutDebugStateStore = new CutDebugStateStore();
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
@@ -104,17 +98,8 @@ public sealed class MainViewModel : ObservableObject
foreach (var channel in Channels)
{
channel.PropertyChanged += Channel_PropertyChanged;
channel.Queue.CollectionChanged += (_, args) =>
{
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();
}
};
channel.FormatDurationChanged += Channel_FormatDurationChanged;
channel.Queue.CollectionChanged += ChannelQueue_CollectionChanged;
}
SaveStateCommand = new AsyncRelayCommand(SaveStateAsync);
@@ -172,7 +157,7 @@ public sealed class MainViewModel : ObservableObject
public IReadOnlyList<SelectionOption<BroadcastChannel?>> CutListFilterOptions { get; }
public IReadOnlyList<SelectionOption<CutListElectionCategory?>> CutListCategoryOptions { get; }
public ObservableCollection<SelectionOption<CutCategory?>> CutListCategoryOptions { get; }
public ChannelOperationMode OperationMode
{
@@ -384,12 +369,13 @@ public sealed class MainViewModel : ObservableObject
if (SetProperty(ref _selectedCutListFilterOption, value))
{
RebuildCutListCategoryOptions();
ApplyCutListFilter();
}
}
}
public SelectionOption<CutListElectionCategory?>? SelectedCutListCategoryOption
public SelectionOption<CutCategory?>? SelectedCutListCategoryOption
{
get => _selectedCutListCategoryOption;
set
@@ -782,12 +768,13 @@ public sealed class MainViewModel : ObservableObject
}
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.PollingIntervalSeconds)
or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.ElectionType)
or nameof(DataViewModel.DistrictName)
or nameof(DataViewModel.DistrictCode)
or nameof(DataViewModel.ShowOnlyConfiguredRegions)
or nameof(DataViewModel.CloseRaceThresholdPercent)
or nameof(DataViewModel.SuperCloseRaceThresholdPercent)
or nameof(DataViewModel.TotalExpectedVotes)
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)
{
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.DistrictCode = string.IsNullOrWhiteSpace(state.DistrictCode) ? Data.DistrictCode : state.DistrictCode;
Data.ShowOnlyConfiguredRegions = state.ShowOnlyConfiguredRegions;
Data.SetCloseRaceThresholds(state.CloseRaceThresholdPercent, state.SuperCloseRaceThresholdPercent);
Data.TotalExpectedVotes = state.TotalExpectedVotes > 0 ? state.TotalExpectedVotes : Data.TotalExpectedVotes;
Data.TurnoutVotes = state.TurnoutVotes;
Data.IsPollingEnabled = state.IsPollingEnabled;
Data.PollingIntervalSeconds = state.PollingIntervalSeconds;
Data.PollingIntervalSeconds = DataViewModel.FixedPollingIntervalSeconds;
Data.ReplaceCandidates(state.Candidates.Select(candidate => new CandidateEntry
{
CandidateCode = candidate.CandidateCode,
@@ -993,11 +1027,13 @@ public sealed class MainViewModel : ObservableObject
OperationMode = OperationMode.ToString(),
BroadcastPhase = Data.BroadcastPhase.ToString(),
IsPollingEnabled = Data.IsPollingEnabled,
PollingIntervalSeconds = Data.PollingIntervalSeconds,
PollingIntervalSeconds = DataViewModel.FixedPollingIntervalSeconds,
ElectionType = Data.ElectionType,
DistrictName = Data.DistrictName,
DistrictCode = Data.DistrictCode,
ShowOnlyConfiguredRegions = Data.ShowOnlyConfiguredRegions,
CloseRaceThresholdPercent = Data.CloseRaceThresholdPercent,
SuperCloseRaceThresholdPercent = Data.SuperCloseRaceThresholdPercent,
TotalExpectedVotes = Data.TotalExpectedVotes,
TurnoutVotes = Data.TurnoutVotes,
Candidates = Data.Candidates.Select(candidate => new CandidateState
@@ -1209,6 +1245,7 @@ public sealed class MainViewModel : ObservableObject
_allCutListEntries.Clear();
_allCutListEntries.AddRange(entries);
RebuildCutListCategoryOptions();
ApplyCutListFilter();
OnPropertyChanged(nameof(CutThumbnailSummary));
}
@@ -1219,6 +1256,58 @@ public sealed class MainViewModel : ObservableObject
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)
{
if (durations is null || durations.Count == 0)
@@ -1259,7 +1348,7 @@ public sealed class MainViewModel : ObservableObject
var filteredEntries = _allCutListEntries
.Where(item =>
(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)
.ToArray();
@@ -1272,6 +1361,71 @@ public sealed class MainViewModel : ObservableObject
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()
{
foreach (var item in _allCutListEntries)
@@ -1318,13 +1472,21 @@ public sealed class MainViewModel : ObservableObject
private void SyncQueuedCutDurations(FormatTemplateDefinition template)
{
var defaultDuration = template.Cuts.FirstOrDefault()?.DurationSeconds ?? 0;
foreach (var channel in Channels)
_isSyncingQueuedCutDurations = true;
try
{
foreach (var item in channel.Queue.Where(item => string.Equals(item.FormatId, template.Id, StringComparison.Ordinal)))
foreach (var channel in Channels)
{
item.DefaultCutDurationSeconds = defaultDuration;
foreach (var item in channel.Queue.Where(item => string.Equals(item.FormatId, template.Id, StringComparison.Ordinal)))
{
item.DefaultCutDurationSeconds = defaultDuration;
}
}
}
finally
{
_isSyncingQueuedCutDurations = false;
}
}
private static string BuildCutDurationKey(string formatId, string cutName)

View File

@@ -259,10 +259,48 @@ function Normalize-CompactText {
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 {
param([string]$Value)
$normalized = Normalize-CompactText -Value $Value
$normalized = Normalize-CompactText -Value (Strip-BasicDistrictDisambiguation -Value $Value)
if ([string]::IsNullOrWhiteSpace($normalized))
{
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 "%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("()", [string]::Empty)
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 {
param(
[pscustomobject]$Cycle,
@@ -977,6 +1038,8 @@ foreach ($region in $regions)
continue
}
$rawDistrictName = $districtName
$districtName = Normalize-BasicDistrictDisplayName -DistrictName $districtName -DisplayName ([string]$winnerItem.wiwName) -RegionName $regionDisplayName
$districtKey = Normalize-BasicDistrictToken -Value $districtName
if ([string]::IsNullOrWhiteSpace($districtKey))
{
@@ -998,7 +1061,7 @@ foreach ($region in $regions)
$record = $basicRecordsByKey[$recordKey]
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)
{
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.RegularExpressions;
using System.Security.Cryptography;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Services;
@@ -59,6 +60,7 @@ internal static class CurrentApiCutDiagnostics
Console.WriteLine($"- Region Scope: {options.RegionScope}");
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
Console.WriteLine($"- Scene Capture: {(options.CaptureSceneImages ? "on" : "off")}");
Console.WriteLine($"- Output: {options.OutputPath}");
var stationCatalog = new StationCatalogService().GetAll();
@@ -142,6 +144,66 @@ internal static class CurrentApiCutDiagnostics
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)
{
var result = new CurrentApiCutDiagnosticResult
@@ -186,7 +248,7 @@ internal static class CurrentApiCutDiagnostics
}
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++;
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
result.Detail = options.LiveSend
@@ -259,6 +321,194 @@ internal static class CurrentApiCutDiagnostics
: $"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(
SbsElectionApiClient apiClient,
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
@@ -367,7 +617,7 @@ internal static class CurrentApiCutDiagnostics
DistrictName = districtName,
DistrictCode = target.DistrictCode,
RegionName = regionName,
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
ElectionDistrictName = ResolveHistoricalElectionDistrictName(electionType, regionName, districtName),
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 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(
ITornado3Adapter adapter,
BroadcastStationProfile station,
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
string imageRootPath)
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
foreach (var cut in template.Cuts)
{
@@ -396,7 +660,7 @@ internal static class CurrentApiCutDiagnostics
{
try
{
await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).ConfigureAwait(false);
await SendSingleCutAsync(adapter, station, template, cut, snapshot, options, result).ConfigureAwait(false);
lastException = null;
break;
}
@@ -425,18 +689,20 @@ internal static class CurrentApiCutDiagnostics
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
string imageRootPath)
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "connect");
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");
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "prepare");
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "take");
await CaptureSceneImageIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false);
}
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)
{
if (adapter.State == TornadoConnectionState.Error)
@@ -688,7 +1031,21 @@ internal static class CurrentApiCutDiagnostics
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)
@@ -816,6 +1173,8 @@ internal static class CurrentApiCutDiagnostics
public bool LiveSend { get; init; }
public bool CaptureSceneImages { get; init; }
public int SendLimit { get; init; } = 24;
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
@@ -840,6 +1199,7 @@ internal static class CurrentApiCutDiagnostics
var excludeFilter = string.Empty;
var simulateSend = true;
var liveSend = false;
var captureSceneImages = false;
var sendLimit = 24;
var outputPath = Path.Combine(
"artifacts",
@@ -892,6 +1252,9 @@ internal static class CurrentApiCutDiagnostics
simulateSend = true;
liveSend = true;
break;
case "--capture-scene-images":
captureSceneImages = true;
break;
case "--send-limit":
if (int.TryParse(NextValue(), out var parsedSendLimit))
{
@@ -923,9 +1286,10 @@ internal static class CurrentApiCutDiagnostics
ExcludeFilter = excludeFilter,
SimulateSend = simulateSend,
LiveSend = liveSend,
CaptureSceneImages = captureSceneImages,
SendLimit = sendLimit,
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
OutputPath = outputPath,
OutputPath = Path.GetFullPath(outputPath),
DefaultElectionType = defaultElectionType
};
}
@@ -966,6 +1330,12 @@ internal static class CurrentApiCutDiagnostics
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 PositiveCandidateVoteCount { get; set; }