Compare commits

...

10 Commits

12 changed files with 2063 additions and 197 deletions

View File

@@ -30,11 +30,6 @@
CornerRadius="26">
<StackPanel Spacing="18">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="6">
<TextBlock
FontFamily="Bahnschrift SemiBold"
@@ -49,34 +44,63 @@
Text="{x:Bind ViewModel.CgStatusSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<StackPanel
Grid.Column="1"
HorizontalAlignment="Right"
Spacing="10">
<Border
Padding="12,8"
Background="#33FF5A54"
BorderBrush="{StaticResource ControlRoomSignalRedBrush}"
BorderThickness="1"
CornerRadius="14">
<TextBlock
Style="{StaticResource MiniSignalTextStyle}"
Text="{x:Bind ViewModel.TransmissionLabel, Mode=OneWay}" />
</Border>
<Border
Padding="12,8"
Background="#22293B52"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="14">
<TextBlock
Style="{StaticResource MiniSignalTextStyle}"
Text="{x:Bind ViewModel.AdapterStateLabel, Mode=OneWay}" />
</Border>
</StackPanel>
</Grid>
<Border
Padding="14,12"
Background="#251A0B"
BorderBrush="{StaticResource ControlRoomSignalAmberBrush}"
BorderThickness="2"
CornerRadius="18"
Visibility="{x:Bind ViewModel.PlaybackCountdownVisibility, Mode=OneWay}">
<Grid ColumnSpacing="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="54"
Height="54"
Background="#33FFB81C"
BorderBrush="{StaticResource ControlRoomSignalAmberBrush}"
BorderThickness="1"
CornerRadius="27">
<SymbolIcon
Foreground="{StaticResource ControlRoomSignalAmberBrush}"
Symbol="Clock"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="6">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
FontFamily="Consolas"
FontSize="30"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.PlaybackCountdownText, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.PlaybackCountdownDetail, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</Grid>
<ProgressBar
Height="8"
Maximum="100"
Minimum="0"
Value="{x:Bind ViewModel.PlaybackCountdownProgress, Mode=OneWay}" />
</StackPanel>
</Grid>
</Border>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.25*" />
@@ -192,7 +216,7 @@
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="대기열" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="스케줄 목록" />
<TextBlock
FontFamily="Consolas"
FontSize="30"
@@ -205,6 +229,61 @@
</Border>
</Grid>
<Border
Padding="14"
Background="{x:Bind ViewModel.ScheduleDataIssueBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind ViewModel.ScheduleDataIssueBorderBrush, Mode=OneWay}"
BorderThickness="2"
CornerRadius="18"
Visibility="{x:Bind ViewModel.ScheduleDataIssueVisibility, Mode=OneWay}">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="42"
Height="42"
Background="#52FFB81C"
BorderBrush="#FFFFB81C"
BorderThickness="1"
CornerRadius="21">
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="Bahnschrift SemiBold"
FontSize="22"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="!" />
</Border>
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="데이터 없음 알림" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="18"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.ScheduleDataIssueTitle, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.ScheduleDataIssueMessage, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.ScheduleDataIssueDetail, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.ScheduleDataIssueHint, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
</Grid>
</Border>
<Border
Padding="16"
Background="#0C1421"
@@ -754,7 +833,7 @@
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
Style="{StaticResource ConsoleSectionTitleTextStyle}"
Text="대기 중 목록" />
Text="스케줄 목록" />
<Button
Width="22"
Height="22"
@@ -769,7 +848,7 @@
<Flyout>
<TextBlock
MaxWidth="280"
Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 목록의 다음 버튼은 다음 예약만 바꾸고, '다음 컷 즉시 송출'은 노란 컷을 바로 송출합니다. '다음 목록 즉시 송출'은 대기열 순서상 다음 목록을 바로 송출합니다."
Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 스케줄은 항상 목록 위에서 아래로 진행하고, 마지막 컷 이후에는 반복이 켜져 있을 때만 맨 위로 이어집니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
@@ -799,24 +878,11 @@
Command="{x:Bind ViewModel.ForceNextCommand}"
Style="{StaticResource PanelCommandButtonStyle}">
<TextBlock TextAlignment="Center">
<Run Text="다음" />
<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>
@@ -900,6 +966,49 @@
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" />
<Border
Padding="10"
Background="{x:Bind IssueBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind IssueBorderBrush, Mode=OneWay}"
BorderThickness="1"
CornerRadius="8"
Visibility="{x:Bind IssueVisibility, Mode=OneWay}">
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind IssueTitle, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind LastIssueLabel, Mode=OneWay}" />
</StackPanel>
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind IssueDetail, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind IssueOperatorHint, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<Button
Grid.Column="1"
Click="RetryDataIssueButton_Click"
Content="다시 확인"
Style="{StaticResource PanelCommandButtonStyle}"
VerticalAlignment="Center"
Visibility="{x:Bind DataUnavailableActionVisibility, Mode=OneWay}" />
</Grid>
</Border>
<StackPanel
Orientation="Horizontal"
Spacing="8">
@@ -961,10 +1070,6 @@
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<Button
Click="PromoteToNextButton_Click"
Content="다음"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Click="MoveUpButton_Click"
Content="위"

View File

@@ -64,6 +64,18 @@ public sealed partial class ChannelSchedulePanel : UserControl
command.Execute(item);
}
private void RetryDataIssueButton_Click(object sender, RoutedEventArgs e)
{
var item = GetItem(sender);
var command = ViewModel?.RetryDataIssueCommand;
if (item is null || command is null || !command.CanExecute(item))
{
return;
}
command.Execute(item);
}
private void IncreaseDurationButton_Click(object sender, RoutedEventArgs e)
{
GetItem(sender)?.StepDraftDuration(1d);

View File

@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Services;
@@ -12,6 +13,7 @@ public sealed class ChannelScheduleItem : ObservableObject
{
private ScheduleQueueItemState _state = ScheduleQueueItemState.Queued;
private string _lastError = string.Empty;
private DateTimeOffset? _lastIssueAt;
private DateTimeOffset? _lastPlayedAt;
private string _currentRegionLabel = string.Empty;
private double _defaultCutDurationSeconds;
@@ -27,6 +29,8 @@ public sealed class ChannelScheduleItem : ObservableObject
private ImageSource? _internalNextPreviewSource;
private string _internalNextPreviewStatusLabel = string.Empty;
private string _internalNextPreviewDisplayName = string.Empty;
private double _playbackRemainingSeconds;
private double _playbackTotalSeconds;
public Guid Id { get; set; } = Guid.NewGuid();
@@ -91,7 +95,10 @@ public sealed class ChannelScheduleItem : ObservableObject
get => _state;
set
{
if (SetProperty(ref _state, value))
var normalized = value == ScheduleQueueItemState.Completed
? ScheduleQueueItemState.Queued
: value;
if (SetProperty(ref _state, normalized))
{
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StateBrush));
@@ -100,6 +107,8 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
OnPropertyChanged(nameof(CardOpacity));
OnPropertyChanged(nameof(CanDelete));
OnPropertyChanged(nameof(PlaybackCountdownVisibility));
OnIssueStateChanged();
}
}
}
@@ -107,7 +116,26 @@ public sealed class ChannelScheduleItem : ObservableObject
public string LastError
{
get => _lastError;
set => SetProperty(ref _lastError, value);
set
{
if (SetProperty(ref _lastError, value))
{
LastIssueAt = string.IsNullOrWhiteSpace(value) ? null : DateTimeOffset.Now;
OnIssueStateChanged();
}
}
}
public DateTimeOffset? LastIssueAt
{
get => _lastIssueAt;
private set
{
if (SetProperty(ref _lastIssueAt, value))
{
OnPropertyChanged(nameof(LastIssueLabel));
}
}
}
public DateTimeOffset? LastPlayedAt
@@ -135,7 +163,8 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => "다음",
ScheduleQueueItemState.Sending => "준비",
ScheduleQueueItemState.OnAir => "송출 중",
ScheduleQueueItemState.Completed => "완료",
ScheduleQueueItemState.Completed => "대기",
ScheduleQueueItemState.DataUnavailable => "데이터 없음",
ScheduleQueueItemState.Error => "오류",
_ => "대기"
};
@@ -146,6 +175,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
_ => ColorHelper.FromArgb(255, 100, 116, 139)
});
@@ -156,6 +186,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 72, 38, 10),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 64, 42, 16),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 58, 22, 24),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 63, 40, 16),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 54, 18, 31),
_ => ColorHelper.FromArgb(255, 18, 32, 51)
});
@@ -166,6 +197,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 255, 90, 84),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 255, 184, 28),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
_ => ColorHelper.FromArgb(255, 39, 64, 95)
});
@@ -176,16 +208,60 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 194, 65, 12),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 180, 83, 9),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 220, 38, 38),
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 180, 83, 9),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 190, 18, 60),
_ => ColorHelper.FromArgb(255, 26, 46, 71)
});
[JsonIgnore]
public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0;
public double CardOpacity => 1.0;
[JsonIgnore]
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
[JsonIgnore]
public bool HasIssue => State is ScheduleQueueItemState.DataUnavailable or ScheduleQueueItemState.Error;
[JsonIgnore]
public Visibility IssueVisibility => HasIssue ? Visibility.Visible : Visibility.Collapsed;
[JsonIgnore]
public Visibility DataUnavailableActionVisibility =>
State == ScheduleQueueItemState.DataUnavailable ? Visibility.Visible : Visibility.Collapsed;
[JsonIgnore]
public string IssueTitle => State switch
{
ScheduleQueueItemState.DataUnavailable => "데이터 없음 - 자동 건너뜀",
ScheduleQueueItemState.Error => "송출 오류",
_ => string.Empty
};
[JsonIgnore]
public string IssueDetail => string.IsNullOrWhiteSpace(LastError)
? State == ScheduleQueueItemState.DataUnavailable
? "이전 실행에서 조건에 맞는 데이터가 없어 보류되었습니다. 다시 시작하면 재확인합니다."
: string.Empty
: LastError;
[JsonIgnore]
public string IssueOperatorHint => State == ScheduleQueueItemState.DataUnavailable
? "데이터가 들어온 뒤 다시 확인을 누르면 스케줄 대상에 복귀합니다."
: "CG 연결과 컷 파일, 데이터 수신 상태를 확인해 주세요.";
[JsonIgnore]
public string LastIssueLabel => LastIssueAt?.ToString("HH:mm:ss") ?? string.Empty;
[JsonIgnore]
public SolidColorBrush IssueBackgroundBrush => new(State == ScheduleQueueItemState.DataUnavailable
? ColorHelper.FromArgb(255, 69, 43, 14)
: ColorHelper.FromArgb(255, 60, 18, 31));
[JsonIgnore]
public SolidColorBrush IssueBorderBrush => new(State == ScheduleQueueItemState.DataUnavailable
? ColorHelper.FromArgb(255, 255, 184, 28)
: ColorHelper.FromArgb(255, 251, 113, 133));
[JsonIgnore]
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(Channel, FormatName);
@@ -234,6 +310,27 @@ public sealed class ChannelScheduleItem : ObservableObject
? "실데이터 프리뷰 준비 중"
: _renderedPreviewStatusLabel;
[JsonIgnore]
public bool HasPlaybackCountdown => State == ScheduleQueueItemState.OnAir && _playbackTotalSeconds > 0;
[JsonIgnore]
public Visibility PlaybackCountdownVisibility => HasPlaybackCountdown ? Visibility.Visible : Visibility.Collapsed;
[JsonIgnore]
public string PlaybackCountdownText => HasPlaybackCountdown
? $"다음 컷까지 {Math.Max(0, (int)Math.Ceiling(_playbackRemainingSeconds))}초"
: "대기 중";
[JsonIgnore]
public string PlaybackCountdownDetail => HasPlaybackCountdown
? $"{FormatName} / {DisplayRegionLabel}"
: string.Empty;
[JsonIgnore]
public double PlaybackCountdownProgress => HasPlaybackCountdown
? Math.Clamp((_playbackRemainingSeconds / _playbackTotalSeconds) * 100d, 0d, 100d)
: 0d;
[JsonIgnore]
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
@@ -330,6 +427,33 @@ public sealed class ChannelScheduleItem : ObservableObject
OnInternalNextPreviewChanged();
}
public void UpdatePlaybackCountdown(TimeSpan remaining, TimeSpan total)
{
var normalizedTotal = Math.Max(0, total.TotalSeconds);
var normalizedRemaining = Math.Clamp(remaining.TotalSeconds, 0, normalizedTotal);
if (Math.Abs(_playbackRemainingSeconds - normalizedRemaining) < 0.001d &&
Math.Abs(_playbackTotalSeconds - normalizedTotal) < 0.001d)
{
return;
}
_playbackRemainingSeconds = normalizedRemaining;
_playbackTotalSeconds = normalizedTotal;
OnPlaybackCountdownChanged();
}
public void ClearPlaybackCountdown()
{
if (_playbackRemainingSeconds <= 0 && _playbackTotalSeconds <= 0)
{
return;
}
_playbackRemainingSeconds = 0;
_playbackTotalSeconds = 0;
OnPlaybackCountdownChanged();
}
public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
{
ThumbnailWidth = metrics.Width;
@@ -353,6 +477,18 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(DurationApplyStatusLabel));
}
private void OnIssueStateChanged()
{
OnPropertyChanged(nameof(HasIssue));
OnPropertyChanged(nameof(IssueVisibility));
OnPropertyChanged(nameof(DataUnavailableActionVisibility));
OnPropertyChanged(nameof(IssueTitle));
OnPropertyChanged(nameof(IssueDetail));
OnPropertyChanged(nameof(IssueOperatorHint));
OnPropertyChanged(nameof(IssueBackgroundBrush));
OnPropertyChanged(nameof(IssueBorderBrush));
}
private void OnPreviewChanged()
{
OnPropertyChanged(nameof(PreviewSource));
@@ -368,6 +504,15 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(InternalNextPreviewDisplayName));
}
private void OnPlaybackCountdownChanged()
{
OnPropertyChanged(nameof(HasPlaybackCountdown));
OnPropertyChanged(nameof(PlaybackCountdownVisibility));
OnPropertyChanged(nameof(PlaybackCountdownText));
OnPropertyChanged(nameof(PlaybackCountdownDetail));
OnPropertyChanged(nameof(PlaybackCountdownProgress));
}
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
{
var selectedRegion = regionOption ?? new ScheduleRegionOption

View File

@@ -0,0 +1,10 @@
namespace Tornado3_2026Election.Domain;
public enum DataSourceConnectionState
{
Waiting,
Receiving,
Connected,
Warning,
Disconnected
}

View File

@@ -7,5 +7,6 @@ public enum ScheduleQueueItemState
Sending,
OnAir,
Completed,
Error
Error,
DataUnavailable
}

View File

@@ -127,8 +127,10 @@
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="1.05*" />
<ColumnDefinition Width="1.25*" />
<ColumnDefinition Width="1.15*" />
<ColumnDefinition Width="1.15*" />
</Grid.ColumnDefinitions>
<Border Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
@@ -145,7 +147,12 @@
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<Border Grid.Column="1"
Padding="12,10"
Background="{x:Bind ViewModel.CgIntegrationCardBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind ViewModel.CgIntegrationCardBorderBrush, Mode=OneWay}"
BorderThickness="2"
CornerRadius="14">
<StackPanel Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="CG 연결 상태" />
@@ -167,21 +174,73 @@
</Button.Flyout>
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="10"
Height="10"
VerticalAlignment="Center"
Fill="{x:Bind ViewModel.CgIntegrationBrush, Mode=OneWay}" />
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="15"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.CgIntegrationSummary, Mode=OneWay}" />
</StackPanel>
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="22"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.CgIntegrationSignalText, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.CgIntegrationOperatorMessage, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CgIntegrationDetail, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
<Border Grid.Column="2"
Padding="12,10"
Background="{x:Bind ViewModel.SbsDataConnectionCardBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind ViewModel.SbsDataConnectionCardBorderBrush, Mode=OneWay}"
BorderThickness="2"
CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="SBS 데이터" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="12"
Height="12"
VerticalAlignment="Center"
Fill="{x:Bind ViewModel.SbsDataConnectionBrush, Mode=OneWay}" />
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="20"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.SbsDataConnectionSummary, Mode=OneWay}" />
</StackPanel>
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="광역단체장 포함"
TextTrimming="CharacterEllipsis" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.SbsDataConnectionDetail, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
<Border Grid.Column="3"
Padding="12,10"
Background="{x:Bind ViewModel.MbcCniDataConnectionCardBackgroundBrush, Mode=OneWay}"
BorderBrush="{x:Bind ViewModel.MbcCniDataConnectionCardBorderBrush, Mode=OneWay}"
BorderThickness="2"
CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="MBC CNI 데이터" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="12"
Height="12"
VerticalAlignment="Center"
Fill="{x:Bind ViewModel.MbcCniDataConnectionBrush, Mode=OneWay}" />
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="20"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.MbcCniDataConnectionSummary, Mode=OneWay}" />
</StackPanel>
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="광역의원 포함"
TextTrimming="CharacterEllipsis" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.MbcCniDataConnectionDetail, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
</Grid>
<Border Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"

View File

@@ -24,8 +24,9 @@ public sealed class ChannelScheduleEngine
private readonly SemaphoreSlim _executionLock = new(1, 1);
private CancellationTokenSource? _playbackCts;
private TaskCompletionSource<bool>? _advanceSignal;
private Guid? _preferredNextItemId;
private Guid? _lastPlaybackItemId;
private Guid? _skipCurrentItemId;
private Guid? _restartFromTopAfterItemId;
private ChannelScheduleItem? _directPlaybackItem;
private PreparedCutFrame? _preparedCutFrame;
@@ -77,6 +78,12 @@ public sealed class ChannelScheduleEngine
{
if (IsRunning)
{
if (GetNextPlayableItem() is null)
{
await RestartFromTopAsync().ConfigureAwait(false);
return;
}
await AdvanceToNextAsync().ConfigureAwait(false);
return;
}
@@ -86,6 +93,11 @@ public sealed class ChannelScheduleEngine
ClearPreparedFrame(resetState: true);
}
_lastPlaybackItemId = null;
_skipCurrentItemId = null;
_restartFromTopAfterItemId = null;
ResetDataUnavailableItems();
ClearStalePlaybackStatesBeforeStart();
_playbackCts = new CancellationTokenSource();
IsRunning = true;
RefreshQueueMarkers();
@@ -108,8 +120,9 @@ public sealed class ChannelScheduleEngine
}
ClearPreparedFrame(resetState: true);
_preferredNextItemId = null;
_lastPlaybackItemId = null;
_skipCurrentItemId = null;
_restartFromTopAfterItemId = null;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return;
@@ -128,10 +141,12 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
}
_preferredNextItemId = null;
_lastPlaybackItemId = null;
_skipCurrentItemId = null;
_restartFromTopAfterItemId = null;
ClearPreparedFrame(resetState: false);
IsRunning = false;
RefreshQueueMarkers();
@@ -148,6 +163,10 @@ public sealed class ChannelScheduleEngine
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_lastPlaybackItemId = null;
_skipCurrentItemId = null;
_restartFromTopAfterItemId = null;
ResetDataUnavailableItems();
ClearPreparedFrame(resetState: true);
RefreshQueueMarkers();
@@ -199,6 +218,7 @@ public sealed class ChannelScheduleEngine
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
item.ClearPlaybackCountdown();
throw;
}
finally
@@ -217,6 +237,7 @@ public sealed class ChannelScheduleEngine
_directPlaybackItem.CurrentRegionLabel = string.Empty;
_directPlaybackItem.ClearRenderedPreview();
_directPlaybackItem.ClearInternalNextPreview();
_directPlaybackItem.ClearPlaybackCountdown();
_directPlaybackItem = null;
}
@@ -240,6 +261,7 @@ public sealed class ChannelScheduleEngine
{
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
item.ClearPlaybackCountdown();
}
finally
{
@@ -251,7 +273,9 @@ public sealed class ChannelScheduleEngine
public void Reset()
{
_preferredNextItemId = null;
_lastPlaybackItemId = null;
_skipCurrentItemId = null;
_restartFromTopAfterItemId = null;
ClearPreparedFrame(resetState: false);
foreach (var item in Queue)
{
@@ -260,11 +284,30 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
}
RefreshQueueMarkers();
}
public bool RetryDataUnavailable(ChannelScheduleItem? item)
{
if (item is null || item.State != ScheduleQueueItemState.DataUnavailable)
{
return false;
}
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return true;
}
public async Task ForceNextAsync()
{
await AdvanceToNextAsync().ConfigureAwait(false);
@@ -272,18 +315,6 @@ public sealed class ChannelScheduleEngine
public async Task ForceQueueNextAsync()
{
if (!IsRunning)
{
return;
}
var nextListItem = GetNextPendingQueueItem();
if (nextListItem is null)
{
return;
}
_preferredNextItemId = nextListItem.Id;
await ForceNextAsync().ConfigureAwait(false);
}
@@ -298,9 +329,43 @@ public sealed class ChannelScheduleEngine
if (activeItem is not null)
{
_skipCurrentItemId = activeItem.Id;
activeItem.State = ScheduleQueueItemState.Completed;
_lastPlaybackItemId = activeItem.Id;
activeItem.State = ScheduleQueueItemState.Queued;
activeItem.LastError = string.Empty;
activeItem.CurrentRegionLabel = string.Empty;
activeItem.ClearInternalNextPreview();
activeItem.ClearPlaybackCountdown();
}
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
_advanceSignal?.TrySetResult(true);
return Task.CompletedTask;
}
private Task RestartFromTopAsync()
{
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;
_restartFromTopAfterItemId = activeItem.Id;
_lastPlaybackItemId = null;
activeItem.State = ScheduleQueueItemState.Queued;
activeItem.LastError = string.Empty;
activeItem.CurrentRegionLabel = string.Empty;
activeItem.ClearInternalNextPreview();
activeItem.ClearPlaybackCountdown();
}
else
{
_lastPlaybackItemId = null;
_restartFromTopAfterItemId = null;
}
RefreshQueueMarkers();
@@ -319,9 +384,9 @@ public sealed class ChannelScheduleEngine
var removed = Queue.Remove(item);
if (removed)
{
if (_preferredNextItemId == item.Id)
if (_lastPlaybackItemId == item.Id)
{
_preferredNextItemId = null;
_lastPlaybackItemId = null;
}
RefreshQueueMarkers();
@@ -343,17 +408,25 @@ public sealed class ChannelScheduleEngine
public bool PromoteToNext(ChannelScheduleItem? item)
{
if (item is null || item.State is not (ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next))
if (item is null)
{
return false;
}
if (_preferredNextItemId == item.Id && item.State == ScheduleQueueItemState.Next)
var anchorItem = ActivePlaybackItem ?? GetLastPlaybackItem();
var targetIndex = anchorItem is null ? 0 : Queue.IndexOf(anchorItem) + 1;
var currentIndex = Queue.IndexOf(item);
if (currentIndex < 0 || targetIndex < 0 || targetIndex > Queue.Count || currentIndex == targetIndex)
{
return false;
}
_preferredNextItemId = item.Id;
if (currentIndex < targetIndex)
{
targetIndex--;
}
Queue.Move(currentIndex, targetIndex);
RefreshQueueMarkers();
return true;
}
@@ -387,12 +460,6 @@ public sealed class ChannelScheduleEngine
var next = GetNextPlayableItem();
if (next is null)
{
if (LoopEnabled && Queue.Count > 0)
{
Reset();
continue;
}
if (EmptyScheduleBehavior == EmptyScheduleBehavior.ImmediateOut)
{
await _adapter.OutAsync(Channel, cancellationToken).ConfigureAwait(false);
@@ -411,6 +478,7 @@ public sealed class ChannelScheduleEngine
{
next.State = ScheduleQueueItemState.Error;
next.LastError = "포맷을 찾을 수 없습니다.";
_lastPlaybackItemId = next.Id;
_logService.Error($"[{Channel}] Missing template: {next.FormatId}");
RefreshQueueMarkers();
continue;
@@ -435,6 +503,7 @@ public sealed class ChannelScheduleEngine
sendingItem.LastError = ex.Message;
sendingItem.CurrentRegionLabel = string.Empty;
sendingItem.ClearInternalNextPreview();
sendingItem.ClearPlaybackCountdown();
ClearSkipCurrentItem(sendingItem);
}
@@ -483,9 +552,9 @@ public sealed class ChannelScheduleEngine
if (previewFrame is null)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
queueItem.CurrentRegionLabel = string.Empty;
MarkDataUnavailable(
queueItem,
"현재 선택한 컷과 지역 조건에 맞는 데이터가 없어 준비할 수 없습니다.");
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}");
return;
@@ -525,15 +594,15 @@ public sealed class ChannelScheduleEngine
if (regionTargets.Count == 0)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
queueItem.CurrentRegionLabel = string.Empty;
MarkLastPlaybackItem(queueItem);
MarkDataUnavailable(queueItem, "선택한 지역 조건에 송출 가능한 데이터가 없습니다.");
RefreshQueueMarkers();
return;
}
var playedAny = false;
var lastFailure = string.Empty;
var dataUnavailableFailure = false;
if (ShouldUseAggregateScheduleSnapshot(template))
{
@@ -563,6 +632,7 @@ public sealed class ChannelScheduleEngine
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
{
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}";
dataUnavailableFailure = true;
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}");
continue;
}
@@ -613,8 +683,23 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview();
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
queueItem.ClearPlaybackCountdown();
MarkLastPlaybackItem(queueItem);
if (playedAny)
{
queueItem.State = ScheduleQueueItemState.Queued;
queueItem.LastError = string.Empty;
}
else if (dataUnavailableFailure)
{
MarkDataUnavailable(queueItem, string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
}
else
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure;
}
ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers();
return;
@@ -645,6 +730,7 @@ public sealed class ChannelScheduleEngine
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out var validationError))
{
lastFailure = $"{regionTarget.DisplayName}: {validationError}";
dataUnavailableFailure = true;
_logService.Warning($"[{Channel}] 스케줄 지역 검증 실패: {lastFailure}");
continue;
}
@@ -697,8 +783,23 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview();
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
queueItem.ClearPlaybackCountdown();
MarkLastPlaybackItem(queueItem);
if (playedAny)
{
queueItem.State = ScheduleQueueItemState.Queued;
queueItem.LastError = string.Empty;
}
else if (dataUnavailableFailure)
{
MarkDataUnavailable(queueItem, string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
}
else
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure;
}
ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers();
}
@@ -714,6 +815,7 @@ public sealed class ChannelScheduleEngine
CancellationToken cancellationToken)
{
queueItem.State = ScheduleQueueItemState.Sending;
queueItem.ClearPlaybackCountdown();
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -736,8 +838,11 @@ public sealed class ChannelScheduleEngine
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
var onAirAt = DateTimeOffset.Now;
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
queueItem.State = ScheduleQueueItemState.OnAir;
queueItem.LastPlayedAt = onAirAt;
queueItem.UpdatePlaybackCountdown(playbackDuration, playbackDuration);
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -748,8 +853,6 @@ public sealed class ChannelScheduleEngine
signal.TrySetResult(true);
}
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
if (!ShouldSkipCurrentItem(queueItem))
@@ -785,17 +888,51 @@ public sealed class ChannelScheduleEngine
if (ShouldSkipCurrentItem(queueItem))
{
queueItem.ClearPlaybackCountdown();
return;
}
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remainingDuration <= TimeSpan.Zero)
{
queueItem.ClearPlaybackCountdown();
return;
}
var delayTask = Task.Delay(remainingDuration, cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
await WaitForPlaybackCountdownAsync(queueItem, onAirAt, playbackDuration, signal, cancellationToken)
.ConfigureAwait(false);
}
private static async Task WaitForPlaybackCountdownAsync(
ChannelScheduleItem queueItem,
DateTimeOffset onAirAt,
TimeSpan playbackDuration,
TaskCompletionSource<bool> advanceSignal,
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var remaining = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remaining <= TimeSpan.Zero)
{
queueItem.ClearPlaybackCountdown();
return;
}
queueItem.UpdatePlaybackCountdown(remaining, playbackDuration);
var updateInterval = remaining > TimeSpan.FromSeconds(1)
? TimeSpan.FromSeconds(1)
: remaining;
var delayTask = Task.Delay(updateInterval, cancellationToken);
var completedTask = await Task.WhenAny(delayTask, advanceSignal.Task).ConfigureAwait(false);
if (completedTask == advanceSignal.Task)
{
queueItem.ClearPlaybackCountdown();
return;
}
}
queueItem.ClearPlaybackCountdown();
}
private async Task CaptureCurrentPreviewAsync(
@@ -1112,8 +1249,7 @@ public sealed class ChannelScheduleEngine
private ChannelScheduleItem? GetPreviewNextItem(ChannelScheduleItem activeItem)
{
return Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Next)
?? Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Queued);
return GetSequentialNextItem(activeItem, allowWrap: LoopEnabled);
}
private bool ShouldSkipCurrentItem(ChannelScheduleItem queueItem)
@@ -1172,6 +1308,7 @@ public sealed class ChannelScheduleEngine
preparedFrame.Item.CurrentRegionLabel = string.Empty;
preparedFrame.Item.ClearRenderedPreview();
preparedFrame.Item.ClearInternalNextPreview();
preparedFrame.Item.ClearPlaybackCountdown();
}
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
@@ -1351,7 +1488,8 @@ public sealed class ChannelScheduleEngine
private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
(string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal) ||
template.Name.StartsWith("판세_기초단체장", StringComparison.Ordinal));
}
private static string NormalizeRegionKey(string value)
@@ -1615,32 +1753,19 @@ public sealed class ChannelScheduleEngine
return preparedItem;
}
return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)
?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
}
private ChannelScheduleItem? GetNextPendingQueueItem()
{
return Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.Next or ScheduleQueueItemState.Queued);
var anchorItem = ActivePlaybackItem ?? GetLastPlaybackItem();
return GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
}
public void RefreshQueueMarkers()
{
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
var pendingItems = Queue
.Where(item => item != activeItem && item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next)
.ToArray();
var nextItem = pendingItems.FirstOrDefault(item => _preferredNextItemId == item.Id);
if (nextItem is null)
{
_preferredNextItemId = null;
nextItem = pendingItems.FirstOrDefault();
}
var anchorItem = activeItem ?? GetLastPlaybackItem();
var nextItem = GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
foreach (var item in Queue)
{
if (item == activeItem || item.State == ScheduleQueueItemState.Completed || item.State == ScheduleQueueItemState.Error)
if (item == activeItem || item.State is ScheduleQueueItemState.Error or ScheduleQueueItemState.DataUnavailable)
{
continue;
}
@@ -1649,6 +1774,110 @@ public sealed class ChannelScheduleEngine
}
}
private ChannelScheduleItem? GetLastPlaybackItem()
{
if (_lastPlaybackItemId is not { } itemId)
{
return null;
}
return Queue.FirstOrDefault(item => item.Id == itemId);
}
private ChannelScheduleItem? GetSequentialNextItem(ChannelScheduleItem? anchorItem, bool allowWrap)
{
if (Queue.Count == 0)
{
return null;
}
if (anchorItem is null)
{
return Queue.FirstOrDefault(IsPlayableQueueItem);
}
var anchorIndex = Queue.IndexOf(anchorItem);
if (anchorIndex < 0)
{
return Queue.FirstOrDefault(IsPlayableQueueItem);
}
var nextItem = Queue.Skip(anchorIndex + 1).FirstOrDefault(IsPlayableQueueItem);
if (nextItem is not null)
{
return nextItem;
}
return allowWrap
? Queue.Take(anchorIndex + 1).FirstOrDefault(IsPlayableQueueItem)
: null;
}
private static bool IsPlayableQueueItem(ChannelScheduleItem item)
{
return item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next or ScheduleQueueItemState.Completed;
}
private void MarkLastPlaybackItem(ChannelScheduleItem queueItem)
{
if (_restartFromTopAfterItemId == queueItem.Id)
{
_lastPlaybackItemId = null;
_restartFromTopAfterItemId = null;
return;
}
_lastPlaybackItemId = queueItem.Id;
}
private void ClearStalePlaybackStatesBeforeStart()
{
foreach (var item in Queue.Where(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending))
{
if (_preparedCutFrame?.Item.Id == item.Id)
{
continue;
}
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
}
}
private void ResetDataUnavailableItems()
{
foreach (var item in Queue.Where(item => item.State == ScheduleQueueItemState.DataUnavailable))
{
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
}
}
private void MarkDataUnavailable(ChannelScheduleItem queueItem, string reason)
{
queueItem.LastError = NormalizeDataUnavailableReason(reason);
queueItem.State = ScheduleQueueItemState.DataUnavailable;
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearRenderedPreview();
queueItem.ClearInternalNextPreview();
queueItem.ClearPlaybackCountdown();
}
private static string NormalizeDataUnavailableReason(string reason)
{
return string.IsNullOrWhiteSpace(reason)
? "현재 조건에 맞는 데이터가 없어 송출을 건너뜁니다."
: reason;
}
private sealed record PreparedCutFrame(
ChannelScheduleItem Item,
FormatTemplateDefinition Template,

View File

@@ -1425,7 +1425,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
};
var candidateSlotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
if (candidateSlotCount > 0 && !IsTopPanseTemplate(template) && !IsNormalPanseMapTemplate(template))
if (candidateSlotCount > 0 && !IsTopPanseTemplate(template) && !IsNormalPanseTemplate(template))
{
ClearCandidateSlotValues(values, candidateSlotCount, template, sceneVariables);
}
@@ -1478,6 +1478,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return FilterValuesForScene(values, sceneVariables, template);
}
if (IsNormalBasicMayorPanseTemplate(template))
{
ApplyNormalBasicMayorPanseValues(values, template, snapshot, templateFolderPath, sceneVariables);
return FilterValuesForScene(values, sceneVariables, template);
}
if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name))
{
return FilterValuesForScene(values, sceneVariables, template);
@@ -1667,6 +1673,35 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}
}
private static void ApplyNormalBasicMayorPanseValues(
IDictionary<string, string> values,
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
string templateFolderPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var rows = BuildNormalBasicMayorPanseSummaries(snapshot);
var slotCount = ResolveNormalBasicMayorPanseSlotCount(sceneVariables);
var totalPlaces = ResolveNormalBasicMayorPanseTotalPlaces(snapshot);
var totalDisplay = FormattableString.Invariant($"총 {FormatCount(totalPlaces)}곳");
SetAliases(values, totalDisplay, "총", "총01", "총1");
SetAliases(values, snapshot.RegionName, "시도명", "시도명01", "시도명1");
for (var slot = 1; slot <= slotCount; slot++)
{
var row = slot <= rows.Length ? rows[slot - 1] : default;
var partyLabel = string.IsNullOrWhiteSpace(row.Party) ? string.Empty : row.Party;
var colorParty = string.IsNullOrWhiteSpace(row.ColorParty) ? partyLabel : row.ColorParty;
var rateDisplay = FormatRate(row.Rate);
var graphColorPath = ResolvePartyGraphColorAssetPath(templateFolderPath, template.Name, colorParty);
SetAliases(values, partyLabel, $"정당명{slot:00}", $"정당명{slot}");
SetAliases(values, rateDisplay, $"득표율{slot:00}", $"득표율{slot}");
SetOptionalAliases(values, graphColorPath, $"그래프{slot:00}", $"그래프{slot}");
}
}
private static PanseSummary[] BuildNormalPanseMapSummaries(ElectionDataSnapshot snapshot)
{
var counts = snapshot.Candidates
@@ -1679,6 +1714,33 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
.ToArray();
}
private static NormalBasicMayorPanseSummary[] BuildNormalBasicMayorPanseSummaries(ElectionDataSnapshot snapshot)
{
var totalPlaces = ResolveNormalBasicMayorPanseTotalPlaces(snapshot);
var counts = snapshot.Candidates
.Where(candidate => !IsPanseSummaryCandidate(candidate))
.GroupBy(ResolveNormalPanseMapPartyKey, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
return NormalPanseMapPartySlots
.Select(slot =>
{
var count = counts.TryGetValue(slot.ColorParty, out var value) ? value : 0;
var rate = totalPlaces <= 0
? 0d
: Math.Round(count * 100d / totalPlaces, 1, MidpointRounding.AwayFromZero);
return new NormalBasicMayorPanseSummary(slot.Label, slot.ColorParty, count, rate);
})
.ToArray();
}
private static int ResolveNormalBasicMayorPanseTotalPlaces(ElectionDataSnapshot snapshot)
{
return snapshot.TotalExpectedVotes > 0
? snapshot.TotalExpectedVotes
: snapshot.Candidates.Count(candidate => !IsPanseSummaryCandidate(candidate));
}
private static PanseSummary[] BuildTopPanseSummaries(
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot)
@@ -2175,6 +2237,23 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount;
}
private static int ResolveNormalBasicMayorPanseSlotCount(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var maxSlot = 0;
foreach (var variableName in sceneVariables.Keys)
{
if ((TryParseSimpleIndexedSlot(variableName, "득표율", out var slot) ||
TryParseSimpleIndexedSlot(variableName, "그래프", out slot) ||
TryParseSimpleIndexedSlot(variableName, "정당명", out slot)) &&
slot > maxSlot)
{
maxSlot = slot;
}
}
return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount;
}
private static bool TryParseSimpleIndexedSlot(string variableName, string prefix, out int slot)
{
slot = 0;
@@ -2303,6 +2382,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables);
}
if (IsNormalBasicMayorPanseTemplate(template))
{
return Array.Empty<KarismaStyleColorUpdate>();
}
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
if (orderedCandidates.Length == 0)
{
@@ -2402,7 +2486,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string t3CutPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
if (IsNormalPanseMapTemplate(template))
if (IsNormalPanseTemplate(template))
{
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
}
@@ -2649,7 +2733,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string t3CutPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
if (IsNormalPanseMapTemplate(template))
if (IsNormalPanseTemplate(template))
{
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
}
@@ -2798,6 +2882,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables);
}
if (IsNormalBasicMayorPanseTemplate(template))
{
return BuildNormalBasicMayorPanseCounterNumberKeyUpdates(snapshot, sceneVariables);
}
if (IsNormalPanseMapTemplate(template))
{
return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables);
@@ -3011,6 +3100,43 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates;
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildNormalBasicMayorPanseCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var rows = BuildNormalBasicMayorPanseSummaries(snapshot);
var slotCount = ResolveNormalBasicMayorPanseSlotCount(sceneVariables);
if (slotCount <= 0)
{
return Array.Empty<KarismaCounterNumberKeyUpdate>();
}
var updates = new List<KarismaCounterNumberKeyUpdate>(slotCount);
for (var slot = 1; slot <= slotCount; slot++)
{
var rate = slot <= rows.Length ? NormalizeRateForBroadcast(rows[slot - 1].Rate) : 0d;
var matched = false;
foreach (var variableName in sceneVariables.Keys)
{
if (!TryParseSimpleIndexedSlot(variableName, "득표율", out var parsedSlot) ||
parsedSlot != slot)
{
continue;
}
updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, rate));
matched = true;
}
if (!matched && sceneVariables.Count == 0)
{
updates.Add(new KarismaCounterNumberKeyUpdate($"득표율{slot:00}", 1, rate));
}
}
return updates;
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
@@ -5304,7 +5430,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return false;
}
return variableName is "선거구명" or "시도명" or "개표율" or "투표율" or "전국투표율" or
return variableName is "선거구명" or "시도명" or "개표율" or "투표율" or "전국투표율" or "총" or
"기준시" or "기준시01" or "기준시02" or "유권자수" or "유권자수01" or "투표자수" or "투표자수01" or "유확당" ||
NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) ||
IsBottomWinnerBallotNumberVariableName(variableName) ||
@@ -5314,6 +5440,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
MatchesIndexedVariable(variableName, "개표율") ||
MatchesIndexedVariable(variableName, "투표율") ||
MatchesIndexedVariable(variableName, "전국투표율") ||
MatchesIndexedVariable(variableName, "총") ||
MatchesIndexedVariable(variableName, "사진") ||
MatchesIndexedVariable(variableName, "순위") ||
IsSpecialRankVariableName(variableName) ||
@@ -6157,6 +6284,17 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
}
private static bool IsNormalBasicMayorPanseTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Normal &&
template.Name.StartsWith("판세_기초단체장", StringComparison.Ordinal);
}
private static bool IsNormalPanseTemplate(FormatTemplateDefinition template)
{
return IsNormalPanseMapTemplate(template) || IsNormalBasicMayorPanseTemplate(template);
}
private static bool IsPanseEducationTemplate(string templateName)
{
return string.Equals(templateName, "판세_교육감", StringComparison.Ordinal);
@@ -7199,6 +7337,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private readonly record struct PanseSummary(string Party, int Count);
private readonly record struct NormalBasicMayorPanseSummary(string Party, string ColorParty, int Count, double Rate);
private readonly record struct NormalPansePartySlot(string Label, string ColorParty);
private sealed record SceneUpdatePayload(

View File

@@ -301,10 +301,11 @@ public sealed class SbsElectionApiClient : IDisposable
foreach (var responseItem in countingItems)
{
var item = responseItem.Item;
var regionId = item.Region?.Id;
if (string.IsNullOrWhiteSpace(regionId) ||
!districtMap.TryGetValue(regionId, out var districtOption) ||
!orderMap.TryGetValue(regionId, out var order))
var matchedRegionCode = GetCountingRegionMatchKeys(item.Region)
.FirstOrDefault(key => districtMap.ContainsKey(key));
if (string.IsNullOrWhiteSpace(matchedRegionCode) ||
!districtMap.TryGetValue(matchedRegionCode, out var districtOption) ||
!orderMap.TryGetValue(matchedRegionCode, out var order))
{
continue;
}
@@ -322,6 +323,179 @@ public sealed class SbsElectionApiClient : IDisposable
.ToArray();
}
public async Task<IReadOnlyList<DistrictSelectionOption>> GetCouncilSeatRegionOptionsAsync(
string electionType,
IEnumerable<string> regionFilters,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return Array.Empty<DistrictSelectionOption>();
}
var scopedSidoCodes = ResolveCouncilSeatSidoCodes(configuration.SungerType, regionFilters);
var items = await GetCouncilSeatItemsAsync(
configuration,
scopedSidoCodes,
cancellationToken)
.ConfigureAwait(false);
return items
.Where(item => item.Sido is not null)
.GroupBy(item => FormatSidoCode(item.Sido!.Code), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var item = group.First();
var code = FormatSidoCode(item.Sido!.Code);
var regionName = BuildOutputRegionName(ExpandRegionName(item.Sido.Name));
return new
{
Order = item.Sido.Order,
Option = new DistrictSelectionOption(
DisplayName: regionName,
DistrictCode: code,
RegionName: regionName,
DistrictName: regionName,
ParentRegionCode: code)
};
})
.OrderBy(item => item.Order > 0 ? item.Order : int.MaxValue)
.ThenBy(item => item.Option.RegionName, StringComparer.Ordinal)
.Select(item => item.Option)
.ToArray();
}
public async Task<IReadOnlyList<DistrictSelectionOption>> GetCouncilSeatSigunguOptionsAsync(
string electionType,
IEnumerable<string> regionFilters,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return Array.Empty<DistrictSelectionOption>();
}
var scopedSidoCodes = ResolveCouncilSeatSidoCodes(configuration.SungerType, regionFilters);
var items = await GetCouncilSeatItemsAsync(
configuration,
scopedSidoCodes,
cancellationToken)
.ConfigureAwait(false);
return items
.Where(item => item.Sido is not null)
.SelectMany(item =>
{
var sidoCode = FormatSidoCode(item.Sido!.Code);
var regionName = BuildOutputRegionName(ExpandRegionName(item.Sido.Name));
return (item.Sigungus ?? [])
.Where(sigungu => sigungu.Sigungu is not null &&
!string.IsNullOrWhiteSpace(sigungu.Sigungu.Id))
.Select(sigungu =>
{
var districtName = sigungu.Sigungu!.Name.Trim();
return new
{
SidoOrder = item.Sido.Order,
SigunguOrder = sigungu.Sigungu.Order,
Option = new DistrictSelectionOption(
DisplayName: BuildFullDistrictDisplayName(regionName, districtName),
DistrictCode: sigungu.Sigungu.Id.Trim(),
RegionName: regionName,
DistrictName: districtName,
ParentRegionCode: sidoCode)
};
});
})
.GroupBy(item => item.Option.DistrictCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(item => item.SidoOrder > 0 ? item.SidoOrder : int.MaxValue)
.ThenBy(item => item.SigunguOrder > 0 ? item.SigunguOrder : int.MaxValue)
.ThenBy(item => item.Option.DisplayName, StringComparer.Ordinal)
.Select(item => item.Option)
.ToArray();
}
public async Task<IReadOnlyList<CouncilSeatPartySummary>> GetCouncilSeatSummariesAsync(
string electionType,
IReadOnlyList<string> sidoCodes,
CancellationToken cancellationToken)
{
return await GetCouncilSeatSummariesAsync(
electionType,
sidoCodes,
Array.Empty<string>(),
cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<CouncilSeatPartySummary>> GetCouncilSeatSummariesAsync(
string electionType,
IReadOnlyList<string> sidoCodes,
IReadOnlyList<string> sigunguCodes,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return Array.Empty<CouncilSeatPartySummary>();
}
var items = await GetCouncilSeatItemsAsync(
configuration,
sidoCodes,
cancellationToken)
.ConfigureAwait(false);
var sigunguCodeSet = NormalizeCouncilSeatSigunguCodes(sigunguCodes);
return items
.SelectMany(item => ResolveCouncilSeatPartyItems(item, sigunguCodeSet))
.GroupBy(item => string.IsNullOrWhiteSpace(item.Id) ? item.Name : item.Id, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return new CouncilSeatPartySummary(
PartyId: first.Id ?? string.Empty,
PartyName: string.IsNullOrWhiteSpace(first.Name) ? "무기타" : first.Name.Trim(),
RegionalSeats: group.Sum(item => Math.Max(0, item.Regional)),
ProportionalSeats: group.Sum(item => Math.Max(0, item.Proportional)),
TotalSeats: group.Sum(item => Math.Max(0, item.Total)));
})
.Where(row => row.TotalSeats > 0 || row.RegionalSeats > 0 || row.ProportionalSeats > 0)
.OrderByDescending(row => row.TotalSeats)
.ThenBy(row => row.PartyName, StringComparer.Ordinal)
.ToArray();
}
public async Task<CouncilPanseResult?> GetCouncilPanseAsync(
string electionType,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return null;
}
var path = BuildPansePath(configuration);
var json = await GetJsonAsync(
configuration.BaseUri,
path,
cancellationToken)
.ConfigureAwait(false);
var item = JsonSerializer.Deserialize<SbsCouncilPanseItem>(json, SerializerOptions);
return item is null
? null
: new CouncilPanseResult(
item.Minju,
item.Kukhim,
item.Etc,
DateTimeOffset.Now,
$"GET /{path}");
}
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
string electionType,
IReadOnlyList<DistrictSelectionOption> districts,
@@ -1224,6 +1398,186 @@ public sealed class SbsElectionApiClient : IDisposable
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}";
private static string BuildPansePath(SbsElectionConfiguration configuration)
=> $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/panse";
private static string BuildCouncilSeatPath(SbsElectionConfiguration configuration, string query)
=> string.IsNullOrWhiteSpace(query)
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/uiseok"
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/uiseok?{query}";
private async Task<IReadOnlyList<SbsCouncilSeatSidoItem>> GetCouncilSeatItemsAsync(
SbsElectionConfiguration configuration,
IReadOnlyList<string> sidoCodes,
CancellationToken cancellationToken)
{
var normalizedSidoCodes = NormalizeSidoCodes(sidoCodes);
if (configuration.SungerType == 5 && normalizedSidoCodes.Count == 0)
{
return await GetArrayAsync<SbsCouncilSeatSidoItem>(
configuration.BaseUri,
BuildCouncilSeatPath(configuration, string.Empty),
cancellationToken)
.ConfigureAwait(false);
}
var querySidoCodes = normalizedSidoCodes.Count > 0
? normalizedSidoCodes
: ResolveAllBasicApiSidoCodes();
var items = new List<SbsCouncilSeatSidoItem>();
var failures = 0;
foreach (var sidoCode in querySidoCodes)
{
try
{
var path = BuildCouncilSeatPath(
configuration,
$"sidos={Uri.EscapeDataString(sidoCode)}");
items.AddRange(await GetArrayAsync<SbsCouncilSeatSidoItem>(
configuration.BaseUri,
path,
cancellationToken)
.ConfigureAwait(false));
}
catch (HttpRequestException)
{
failures++;
}
}
if (items.Count == 0 && failures > 0)
{
throw new HttpRequestException("SBS API 의석표 데이터를 불러오지 못했습니다.");
}
return items
.GroupBy(item => FormatSidoCode(item.Sido?.Code ?? 0), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Where(item => item.Sido is not null)
.ToArray();
}
private static IReadOnlyList<string> NormalizeSidoCodes(IEnumerable<string> sidoCodes)
{
return (sidoCodes ?? Array.Empty<string>())
.Select(code => code?.Trim() ?? string.Empty)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.PadLeft(2, '0'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(code => code, StringComparer.Ordinal)
.ToArray();
}
private static IReadOnlyList<string> ResolveAllBasicApiSidoCodes()
{
return
[
"11",
"26",
"27",
"28",
"29",
"30",
"31",
"41",
"43",
"44",
"46",
"47",
"48",
"49",
"51",
"52",
"53"
];
}
private static IReadOnlyList<string> ResolveCouncilSeatSidoCodes(int sungerType, IEnumerable<string> regionNames)
{
return (regionNames ?? Array.Empty<string>())
.Select(regionName => ResolveCouncilSeatSidoCode(sungerType, regionName))
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(code => code, StringComparer.Ordinal)
.ToArray();
}
private static string ResolveCouncilSeatSidoCode(int sungerType, string? regionName)
{
if (string.IsNullOrWhiteSpace(regionName))
{
return string.Empty;
}
var normalized = NormalizeRegionName(regionName);
return normalized switch
{
"세종" => "51",
"강원" => "52",
"전북" => "53",
"광주" when sungerType == 5 => "29",
"전남" when sungerType == 5 => "29",
"전남광주" when sungerType == 5 => "29",
"광주전남" when sungerType == 5 => "29",
_ => ResolveBasicApiSidoCode(regionName)
};
}
private static IEnumerable<SbsCouncilSeatPartyItem> ResolveCouncilSeatPartyItems(SbsCouncilSeatSidoItem item)
{
if (item.Parties is { Count: > 0 })
{
return item.Parties;
}
return item.Sigungus?
.SelectMany(sigungu => sigungu.Parties ?? [])
.ToArray() ?? [];
}
private static IEnumerable<SbsCouncilSeatPartyItem> ResolveCouncilSeatPartyItems(
SbsCouncilSeatSidoItem item,
ISet<string> sigunguCodes)
{
if (sigunguCodes.Count == 0)
{
return ResolveCouncilSeatPartyItems(item);
}
return item.Sigungus?
.Where(sigungu => sigungu.Sigungu is not null &&
sigunguCodes.Contains(sigungu.Sigungu.Id.Trim()))
.SelectMany(sigungu => sigungu.Parties ?? [])
.ToArray() ?? [];
}
private static ISet<string> NormalizeCouncilSeatSigunguCodes(IEnumerable<string> sigunguCodes)
{
return (sigunguCodes ?? Array.Empty<string>())
.Select(code => code?.Trim() ?? string.Empty)
.Where(code => !string.IsNullOrWhiteSpace(code))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> GetCountingRegionMatchKeys(SbsTurnoutRegion? region)
{
if (region is null)
{
yield break;
}
foreach (var key in new[] { region.Id, region.Name4Id, region.Name3Id, region.Name2Id })
{
if (!string.IsNullOrWhiteSpace(key))
{
yield return key.Trim();
}
}
}
private static string FormatSidoCode(int code)
=> code <= 0 ? string.Empty : code.ToString("00", CultureInfo.InvariantCulture);
private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration)
=> Uri.Compare(
configuration.BaseUri,
@@ -1711,6 +2065,20 @@ public sealed class SbsElectionApiClient : IDisposable
string JudgementBadgeText = "",
string JudgementDetailText = "");
public sealed record CouncilSeatPartySummary(
string PartyId,
string PartyName,
int RegionalSeats,
int ProportionalSeats,
int TotalSeats);
public sealed record CouncilPanseResult(
int Minju,
int Kukhim,
int Etc,
DateTimeOffset ReceivedAt,
string SourcePath);
public sealed record TurnoutOverviewItem(
string DisplayName,
string RegionName,
@@ -1832,6 +2200,81 @@ public sealed class SbsElectionApiClient : IDisposable
public int Tupyosu { get; set; }
}
private sealed class SbsCouncilPanseItem
{
[JsonPropertyName("minju")]
public int Minju { get; set; }
[JsonPropertyName("kukhim")]
public int Kukhim { get; set; }
[JsonPropertyName("etc")]
public int Etc { get; set; }
}
private sealed class SbsCouncilSeatSidoItem
{
[JsonPropertyName("sido")]
public SbsCouncilSeatSido? Sido { get; set; }
[JsonPropertyName("sigungus")]
public List<SbsCouncilSeatSigunguItem>? Sigungus { get; set; }
[JsonPropertyName("parties")]
public List<SbsCouncilSeatPartyItem>? Parties { get; set; }
}
private sealed class SbsCouncilSeatSido
{
[JsonPropertyName("code")]
public int Code { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("order")]
public int Order { get; set; }
}
private sealed class SbsCouncilSeatSigunguItem
{
[JsonPropertyName("sigungu")]
public SbsCouncilSeatSigungu? Sigungu { get; set; }
[JsonPropertyName("parties")]
public List<SbsCouncilSeatPartyItem>? Parties { get; set; }
}
private sealed class SbsCouncilSeatSigungu
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("order")]
public int Order { get; set; }
}
private sealed class SbsCouncilSeatPartyItem
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("regional")]
public int Regional { get; set; }
[JsonPropertyName("proportional")]
public int Proportional { get; set; }
[JsonPropertyName("total")]
public int Total { get; set; }
}
private sealed class SbsCountingItem
{
[JsonPropertyName("region")]

View File

@@ -22,6 +22,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
private static readonly Brush PlaybackActiveIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 90, 84));
private static readonly Brush PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216));
private static readonly Brush DataIssueBackgroundBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 57, 38, 16));
private static readonly Brush DataIssueBorderBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 184, 28));
private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter;
private readonly CutDebugStateStore _cutDebugStateStore;
@@ -94,6 +96,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
ResetQueueCommand = new RelayCommand(ResetQueue);
RetryDataIssueCommand = new RelayCommand<ChannelScheduleItem>(RetryDataIssue, CanRetryDataIssue);
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
@@ -168,6 +171,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public RelayCommand ResetQueueCommand { get; }
public RelayCommand<ChannelScheduleItem> RetryDataIssueCommand { get; }
public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; }
public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; }
@@ -289,6 +294,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _loopEnabled, value))
{
_engine.LoopEnabled = value;
_engine.RefreshQueueMarkers();
RefreshSummary();
}
}
@@ -366,6 +372,15 @@ public sealed class ChannelScheduleViewModel : ObservableObject
?? QueueNextPlaybackItem?.PreviewStatusLabel
?? "다음 컷 없음";
public Visibility PlaybackCountdownVisibility =>
CurrentPlaybackItem?.PlaybackCountdownVisibility ?? Visibility.Collapsed;
public string PlaybackCountdownText => CurrentPlaybackItem?.PlaybackCountdownText ?? "대기 중";
public string PlaybackCountdownDetail => CurrentPlaybackItem?.PlaybackCountdownDetail ?? string.Empty;
public double PlaybackCountdownProgress => CurrentPlaybackItem?.PlaybackCountdownProgress ?? 0d;
public double CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
@@ -374,9 +389,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height;
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
public int QueuedItemCount => Queue.Count;
public string QueueFootnote => $"대기 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
public string QueueFootnote => $"목록 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
public string QueueSummary
{
@@ -386,7 +401,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
?? QueueNextPlaybackItem?.DisplayName
?? "-";
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
return $"현재 {current} / 다음 {next} / 목록 {Queue.Count}";
}
}
@@ -396,6 +411,27 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
public Visibility ScheduleDataIssueVisibility =>
LatestScheduleDataIssueItem is null ? Visibility.Collapsed : Visibility.Visible;
public Brush ScheduleDataIssueBackgroundBrush => DataIssueBackgroundBrushValue;
public Brush ScheduleDataIssueBorderBrush => DataIssueBorderBrushValue;
public string ScheduleDataIssueTitle => LatestScheduleDataIssueItem is { } item
? $"{item.FormatName} 송출 보류"
: string.Empty;
public string ScheduleDataIssueMessage => LatestScheduleDataIssueItem?.IssueDetail ?? string.Empty;
public string ScheduleDataIssueDetail => LatestScheduleDataIssueItem is { } item
? $"{item.DisplayRegionLabel} / {item.LastIssueLabel}"
: string.Empty;
public string ScheduleDataIssueHint => LatestScheduleDataIssueItem is null
? string.Empty
: "시스템 오류가 아니라 현재 조건 데이터가 부족한 상태입니다. 데이터가 들어오면 항목의 다시 확인을 눌러 스케줄 대상에 복귀시킬 수 있습니다.";
public string SelectedFormatName => SelectedFormat?.Name ?? "컷을 선택하세요";
public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";
@@ -484,6 +520,12 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private ChannelScheduleItem? NextPlaybackItem =>
InternalNextPlaybackItem ?? QueueNextPlaybackItem;
private ChannelScheduleItem? LatestScheduleDataIssueItem =>
Queue
.Where(item => item.State == ScheduleQueueItemState.DataUnavailable)
.OrderByDescending(item => item.LastIssueAt ?? DateTimeOffset.MinValue)
.FirstOrDefault();
public async Task RefreshRegionOptionsAsync()
{
await RebuildRegionOptionsAsync();
@@ -529,7 +571,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
await _engine.StartAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 큐를 시작");
_logService.Info($"[{Title}] 스케줄 시작");
}
private async Task PrepareScheduleAsync()
@@ -551,7 +593,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
await _engine.StopAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 큐를 종료");
_logService.Info($"[{Title}] 스케줄 정지");
}
private async Task DirectPrepareAsync()
@@ -718,7 +760,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
await _engine.ForceQueueNextAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 대기열의 다음 목록을 즉시 송출");
_logService.Info($"[{Title}] 다음 순서를 즉시 송출");
}
private void AddFormat()
@@ -745,7 +787,23 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
_engine.Reset();
RefreshSummary();
_logService.Info($"[{Title}] 큐를 첫 컷부터 다시 시작하도록 초기화했습니다.");
_logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
}
private void RetryDataIssue(ChannelScheduleItem? item)
{
if (!_engine.RetryDataUnavailable(item))
{
return;
}
RefreshSummary();
_logService.Info($"[{Title}] 데이터 없음 항목을 다시 스케줄 대상으로 전환: {item?.DisplayName}");
}
private static bool CanRetryDataIssue(ChannelScheduleItem? item)
{
return item?.State == ScheduleQueueItemState.DataUnavailable;
}
private void RemoveItem(ChannelScheduleItem? item)
@@ -843,6 +901,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),
@@ -850,6 +912,13 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary),
nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush),
nameof(ScheduleDataIssueTitle),
nameof(ScheduleDataIssueMessage),
nameof(ScheduleDataIssueDetail),
nameof(ScheduleDataIssueHint),
nameof(IsCgConnected),
nameof(CgStatusSummary),
nameof(LoopSummary),
@@ -1044,6 +1113,17 @@ public sealed class ChannelScheduleViewModel : ObservableObject
NotifySelectedFormatDurationStateChanged();
}
public void RefreshSelectedFormatDuration(FormatTemplateDefinition template)
{
if (SelectedFormat is null ||
!string.Equals(SelectedFormat.Id, template.Id, StringComparison.Ordinal))
{
return;
}
ResetSelectedFormatDurationDraft();
}
private void NotifySelectedFormatDurationStateChanged()
{
OnPropertyChanged(
@@ -1097,15 +1177,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
or nameof(ChannelScheduleItem.HasInternalNextPreview)
or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt)
or nameof(ChannelScheduleItem.PlaybackCountdownVisibility)
or nameof(ChannelScheduleItem.PlaybackCountdownText)
or nameof(ChannelScheduleItem.PlaybackCountdownDetail)
or nameof(ChannelScheduleItem.PlaybackCountdownProgress)
or nameof(ChannelScheduleItem.ThumbnailSource))
{
if (e.PropertyName is nameof(ChannelScheduleItem.State)
or nameof(ChannelScheduleItem.DisplayName)
or nameof(ChannelScheduleItem.CurrentRegionLabel))
or nameof(ChannelScheduleItem.CurrentRegionLabel)
or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt))
{
NotifyPlaybackStateChanged();
}
RetryDataIssueCommand.NotifyCanExecuteChanged();
NotifyPlaybackPreviewChanged();
}
}
@@ -1120,7 +1209,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextItemName),
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary));
nameof(QueueSummary),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush),
nameof(ScheduleDataIssueTitle),
nameof(ScheduleDataIssueMessage),
nameof(ScheduleDataIssueDetail),
nameof(ScheduleDataIssueHint));
}
private void NotifyPlaybackPreviewChanged()
@@ -1130,6 +1230,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),

View File

@@ -29,6 +29,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private const string PanseDemocraticPartyLabel = "더불어민주당";
private const string PansePeoplePowerPartyLabel = "국민의힘";
private const string PanseOtherPartyLabel = "무·기타";
private const string SbsProbeElectionType = "광역단체장";
private const string MbcCniProbeElectionType = "광역의원";
public const int FixedPollingIntervalSeconds = 60;
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
@@ -138,6 +140,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private readonly SbsElectionApiClient _apiClient;
private readonly PreElectionHistoryService _preElectionHistoryService;
private readonly CareerPromiseService _careerPromiseService;
private readonly SemaphoreSlim _dataSourceRefreshLock = new(1, 1);
private readonly Dictionary<string, string> _districtCodeMap = new(StringComparer.Ordinal);
private readonly Dictionary<string, SbsElectionApiClient.DistrictSelectionOption> _districtOptionMap = new(StringComparer.Ordinal);
private CancellationTokenSource? _pollingCts;
@@ -150,6 +153,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private bool _isApplyingRefreshResult;
private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue;
private DateTimeOffset _lastManualRefreshAt = DateTimeOffset.MinValue;
private DataSourceConnectionState _sbsDataSourceState = DataSourceConnectionState.Waiting;
private DataSourceConnectionState _mbcCniDataSourceState = DataSourceConnectionState.Waiting;
private DateTimeOffset _sbsDataSourceLastRefreshAt = DateTimeOffset.MinValue;
private DateTimeOffset _mbcCniDataSourceLastRefreshAt = DateTimeOffset.MinValue;
private string _sbsDataSourceDetail = "아직 자동 수신 전입니다.";
private string _mbcCniDataSourceDetail = "아직 자동 수신 전입니다.";
private string _electionType = "광역단체장";
private string _districtName = "부산광역시";
private string _districtCode = "26";
@@ -332,6 +341,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (SetProperty(ref _broadcastPhase, value))
{
OnPropertyChanged(nameof(StatusText), nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
NotifyModePresentationChanged();
}
}
@@ -645,6 +655,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
{
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
OnPropertyChanged(nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
}
}
}
@@ -678,10 +689,27 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
{
OnPropertyChanged(nameof(StatusText), nameof(LastRefreshDisplay));
OnPropertyChanged(nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
}
}
}
public DataSourceConnectionState SbsDataSourceState => _sbsDataSourceState;
public DataSourceConnectionState MbcCniDataSourceState => _mbcCniDataSourceState;
public DateTimeOffset SbsDataSourceLastRefreshAt => _sbsDataSourceLastRefreshAt;
public DateTimeOffset MbcCniDataSourceLastRefreshAt => _mbcCniDataSourceLastRefreshAt;
public string SbsDataSourceSummary => ResolveDataSourceSummary(SbsDataSourceState);
public string MbcCniDataSourceSummary => ResolveDataSourceSummary(MbcCniDataSourceState);
public string SbsDataSourceDetail => _sbsDataSourceDetail;
public string MbcCniDataSourceDetail => _mbcCniDataSourceDetail;
public string ElectionType
{
get => _electionType;
@@ -692,6 +720,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (SetProperty(ref _electionType, normalizedValue))
{
OnPropertyChanged(nameof(StatusText), nameof(CareerPromiseContextText), nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
NotifyModePresentationChanged();
RefreshPreElectionHistoryPresentation();
_ = RefreshDistrictOptionsForElectionTypeAsync();
@@ -911,6 +940,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
LastRefreshAt != DateTimeOffset.MinValue &&
string.IsNullOrWhiteSpace(_lastRefreshWarningMessage));
public bool HasAnyLiveDataSignal =>
HasLiveDataSignal ||
SbsDataSourceState == DataSourceConnectionState.Connected ||
MbcCniDataSourceState == DataSourceConnectionState.Connected;
public string PollingModeLabel
{
get
@@ -984,6 +1018,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
_pollingCts = new CancellationTokenSource();
_ = RefreshAllDataSourcesAsync(_pollingCts.Token);
_ = RunPollingLoopAsync(_pollingCts.Token);
}
@@ -1136,6 +1171,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
];
}
if (IsNormalBasicMayorPanseTemplate(template))
{
var panseOptions = await GetScheduleDistrictOptionsAsync(electionType, template, cancellationToken).ConfigureAwait(false);
return CreateScheduleRegionGroupOptions(panseOptions, electionType).ToArray();
}
if (ShouldUseTurnoutPhotoRegionLevelOptions(template))
{
return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false);
@@ -1159,9 +1200,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
};
if (IsByElectionTemplate(template) ||
IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
{
regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
regionOptions.AddRange(options.Select(option => new ScheduleRegionOption
{
Scope = ScheduleRegionScope.Single,
Label = option.DisplayName,
ElectionType = electionType,
RegionName = option.RegionName,
DistrictName = option.DistrictName,
DistrictCode = option.DistrictCode
}));
}
else if (IsByElectionTemplate(template) ||
IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
{
regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
}
@@ -1769,10 +1822,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
cancellationToken);
}
if (IsNormalPanseMapTemplate(template))
if (IsNormalPanseMapTemplate(template) || IsNormalBasicMayorPanseTemplate(template))
{
return CreateNormalPanseMapScheduleSnapshotAsync(
electionType,
template,
station,
regionTargets,
cancellationToken);
}
@@ -2481,6 +2536,218 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
_lastRefreshWarningMessage = normalized;
OnPropertyChanged(nameof(StatusText), nameof(HasLiveDataSignal));
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
}
private async Task RefreshAllDataSourcesAsync(CancellationToken cancellationToken)
{
if (!await _dataSourceRefreshLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try
{
await RefreshSbsDataSourceAsync(cancellationToken).ConfigureAwait(false);
await RefreshMbcCniDataSourceAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_dataSourceRefreshLock.Release();
}
}
private async Task RefreshSbsDataSourceAsync(CancellationToken cancellationToken)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Receiving,
"광역단체장 개표 데이터 수신 중");
try
{
var districts = await _apiClient
.GetDistrictOptionsAsync(SbsProbeElectionType, cancellationToken)
.ConfigureAwait(false);
if (districts.Count == 0)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Warning,
"광역단체장 선거구 목록이 비어 있습니다.");
_logService.Warning("SBS 데이터 상태 확인: 광역단체장 선거구 목록이 비어 있습니다.");
return;
}
var snapshots = await _apiClient
.GetCountingSnapshotsAsync(SbsProbeElectionType, districts, cancellationToken)
.ConfigureAwait(false);
if (snapshots.Count == 0)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Warning,
$"광역단체장 선거구 {districts.Count}개 확인, 개표 데이터 없음");
_logService.Warning($"SBS 데이터 상태 확인: 광역단체장 선거구 {districts.Count}개 확인, 개표 데이터가 없습니다.");
return;
}
var receivedAt = snapshots
.Select(snapshot => snapshot.ReceivedAt == default ? DateTimeOffset.Now : snapshot.ReceivedAt)
.DefaultIfEmpty(DateTimeOffset.Now)
.Max();
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Connected,
$"광역단체장 개표 {snapshots.Count}/{districts.Count}개 수신 / {receivedAt:HH:mm:ss}",
receivedAt);
_logService.Info($"SBS 데이터 상태 갱신 완료. 광역단체장 개표 {snapshots.Count}/{districts.Count}개 수신");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
SetDataSourceStatus(
isMbcCni: false,
DataSourceConnectionState.Disconnected,
CreateDataSourceFailureDetail(ex.Message, SbsDataSourceLastRefreshAt));
_logService.Warning($"SBS 데이터 상태 확인 실패: {ex.Message}");
}
}
private async Task RefreshMbcCniDataSourceAsync(CancellationToken cancellationToken)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Receiving,
"광역의원 개표·의석표 데이터 수신 중");
try
{
var scopedRegionFilters = ResolveMbcCniProbeRegionScope();
var districts = await _apiClient
.GetDistrictOptionsAsync(MbcCniProbeElectionType, scopedRegionFilters, cancellationToken)
.ConfigureAwait(false);
var seatRegions = await _apiClient
.GetCouncilSeatRegionOptionsAsync(MbcCniProbeElectionType, scopedRegionFilters, cancellationToken)
.ConfigureAwait(false);
if (districts.Count == 0 && seatRegions.Count == 0)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Warning,
"광역의원 개표·의석표 데이터가 비어 있습니다.");
_logService.Warning("MBC CNI 데이터 상태 확인: 광역의원 개표·의석표 데이터가 비어 있습니다.");
return;
}
var snapshots = districts.Count == 0
? Array.Empty<SbsElectionApiClient.SbsElectionRefreshResult>()
: await _apiClient
.GetCountingSnapshotsAsync(MbcCniProbeElectionType, districts, cancellationToken)
.ConfigureAwait(false);
var receivedAt = snapshots
.Select(snapshot => snapshot.ReceivedAt == default ? DateTimeOffset.Now : snapshot.ReceivedAt)
.DefaultIfEmpty(DateTimeOffset.Now)
.Max();
if (snapshots.Count == 0 || seatRegions.Count == 0)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Warning,
$"광역의원 개표 {snapshots.Count}/{districts.Count}개, 의석표 {seatRegions.Count}개 확인",
receivedAt);
_logService.Warning($"MBC CNI 데이터 상태 확인: 광역의원 개표 {snapshots.Count}/{districts.Count}개, 의석표 {seatRegions.Count}개 확인");
return;
}
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Connected,
$"광역의원 개표 {snapshots.Count}/{districts.Count}개 / 의석표 {seatRegions.Count}개 수신 / {receivedAt:HH:mm:ss}",
receivedAt);
_logService.Info($"MBC CNI 데이터 상태 갱신 완료. 광역의원 개표 {snapshots.Count}/{districts.Count}개, 의석표 {seatRegions.Count}개 수신");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
SetDataSourceStatus(
isMbcCni: true,
DataSourceConnectionState.Disconnected,
CreateDataSourceFailureDetail(ex.Message, MbcCniDataSourceLastRefreshAt));
_logService.Warning($"MBC CNI 데이터 상태 확인 실패: {ex.Message}");
}
}
private string[] ResolveMbcCniProbeRegionScope()
{
var scopedRegionFilters = ResolveApiDistrictRegionScope(MbcCniProbeElectionType)
.Where(region => !string.IsNullOrWhiteSpace(region))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return scopedRegionFilters.Length > 0
? scopedRegionFilters
: DefaultDistrictOptions.Select(option => option.RegionName).ToArray();
}
private void SetDataSourceStatus(
bool isMbcCni,
DataSourceConnectionState state,
string detail,
DateTimeOffset? receivedAt = null)
{
if (isMbcCni)
{
_mbcCniDataSourceState = state;
_mbcCniDataSourceDetail = string.IsNullOrWhiteSpace(detail) ? ResolveDataSourceSummary(state) : detail;
if (receivedAt.HasValue)
{
_mbcCniDataSourceLastRefreshAt = receivedAt.Value;
}
OnPropertyChanged(
nameof(MbcCniDataSourceState),
nameof(MbcCniDataSourceSummary),
nameof(MbcCniDataSourceDetail),
nameof(MbcCniDataSourceLastRefreshAt),
nameof(HasAnyLiveDataSignal));
return;
}
_sbsDataSourceState = state;
_sbsDataSourceDetail = string.IsNullOrWhiteSpace(detail) ? ResolveDataSourceSummary(state) : detail;
if (receivedAt.HasValue)
{
_sbsDataSourceLastRefreshAt = receivedAt.Value;
}
OnPropertyChanged(
nameof(SbsDataSourceState),
nameof(SbsDataSourceSummary),
nameof(SbsDataSourceDetail),
nameof(SbsDataSourceLastRefreshAt),
nameof(HasAnyLiveDataSignal));
}
private static string ResolveDataSourceSummary(DataSourceConnectionState state)
{
return state switch
{
DataSourceConnectionState.Receiving => "수신 중",
DataSourceConnectionState.Connected => "연결됨",
DataSourceConnectionState.Warning => "확인 필요",
DataSourceConnectionState.Disconnected => "끊김",
_ => "대기"
};
}
private static string CreateDataSourceFailureDetail(string message, DateTimeOffset lastRefreshAt)
{
var detail = string.IsNullOrWhiteSpace(message)
? "수신 실패"
: $"수신 실패: {message}";
return lastRefreshAt == DateTimeOffset.MinValue
? detail
: $"{detail} / 마지막 성공 {lastRefreshAt:HH:mm:ss}";
}
public void Dispose()
@@ -2506,6 +2773,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
PollingCountdownSeconds = remainingSeconds;
}
await RefreshAllDataSourcesAsync(cancellationToken).ConfigureAwait(false);
await RefreshAsync(isManualRequest: false);
}
catch (OperationCanceledException)
@@ -3676,6 +3944,25 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
FormatTemplateDefinition? template,
CancellationToken cancellationToken)
{
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name) &&
IsBasicCouncilElectionType(electionType))
{
try
{
var councilSeatRegions = await _apiClient
.GetCouncilSeatSigunguOptionsAsync(electionType, ResolveApiDistrictRegionScope(electionType), cancellationToken)
.ConfigureAwait(false);
if (councilSeatRegions.Count > 0)
{
return councilSeatRegions;
}
}
catch (Exception ex)
{
_logService.Warning($"{electionType} 의석표 지역 목록 수신 실패: {ex.Message}");
}
}
if (UsesHistoricalScheduleOptions(template))
{
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
@@ -4158,31 +4445,73 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다.");
}
var sidoCodes = selectedTargets
.Select(ResolveCouncilSeatTargetSidoCode)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (sidoCodes.Length == 0)
{
throw new InvalidOperationException("의석 집계 대상 시도코드가 없습니다.");
}
var sigunguCodes = selectedTargets
.Select(ResolveCouncilSeatTargetSigunguCode)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var councilSeats = await _apiClient
.GetCouncilSeatSummariesAsync(electionType, sidoCodes, sigunguCodes, cancellationToken)
.ConfigureAwait(false);
if (councilSeats.Count == 0)
{
throw new InvalidOperationException("의석 집계용 SBS API 의석표 데이터가 없습니다.");
}
var seatCandidates = BuildCouncilSeatSummaryCandidates(councilSeats);
if (seatCandidates.Length == 0)
{
throw new InvalidOperationException("의석표에 반영할 정당별 의석 데이터가 없습니다.");
}
var refreshResults = await GetCountingSnapshotsForScheduleTargetsAsync(
electionType,
selectedTargets,
cancellationToken)
.ConfigureAwait(false);
if (refreshResults.Count == 0)
{
throw new InvalidOperationException("의석 집계용 개표 데이터가 없습니다.");
}
var seatCandidates = BuildCouncilSeatSummaryCandidates(electionType, refreshResults);
if (seatCandidates.Length == 0)
{
throw new InvalidOperationException("의석으로 집계할 현재 득표 기준 후보가 없습니다.");
}
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0));
var countedRate = ResolveCouncilSeatAggregateCountedRate(refreshResults);
var countedRate = refreshResults.Count == 0
? 0d
: ResolveAggregateCountedRate(totalVotes, countedVotes, refreshResults);
var totalSeats = councilSeats.Sum(row => Math.Max(row.TotalSeats, row.RegionalSeats + row.ProportionalSeats));
var regionName = ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets);
var districtName = selectedTargets.Length == 1
? selectedTargets[0].DisplayName
: regionName;
var isSingleSigunguTarget = selectedTargets.Length == 1 && sigunguCodes.Length == 1;
var districtName = isSingleSigunguTarget
? FirstNonWhiteSpace(selectedTargets[0].DistrictName, selectedTargets[0].DisplayName)
: selectedTargets.Length == 1
? selectedTargets[0].DisplayName
: regionName;
var receivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max();
var countedVotesForSnapshot = refreshResults.Count == 0
? totalSeats
: countedVotes;
var remainingVotesForSnapshot = refreshResults.Count == 0
? 0
: remainingVotes;
var totalExpectedVotesForSnapshot = totalVotes > 0
? totalVotes
: totalSeats;
var turnoutVotesForSnapshot = turnoutVotes > 0
? turnoutVotes
: totalSeats;
var electionDistrictName = isSingleSigunguTarget
? districtName
: selectedTargets.Length == 1
? selectedTargets[0].DistrictName
: regionName;
var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName);
return new ElectionDataSnapshot
@@ -4192,14 +4521,14 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
DistrictName = districtName,
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
RegionName = regionName,
ElectionDistrictName = selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
ElectionDistrictName = electionDistrictName,
Candidates = seatCandidates,
TotalExpectedVotes = totalVotes,
TurnoutVotes = turnoutVotes,
CountedVotesFromApi = countedVotes,
RemainingVotesFromApi = remainingVotes,
TotalExpectedVotes = totalExpectedVotesForSnapshot,
TurnoutVotes = turnoutVotesForSnapshot,
CountedVotesFromApi = countedVotesForSnapshot,
RemainingVotesFromApi = remainingVotesForSnapshot,
CountedRateFromApi = countedRate,
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
ReceivedAt = receivedAt,
HistoricalTurnoutHistory = history.TurnoutHistory,
HistoricalWinnerHistory = history.WinnerHistory
};
@@ -4379,12 +4708,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private async Task<ElectionDataSnapshot> CreateNormalPanseMapScheduleSnapshotAsync(
string electionType,
FormatTemplateDefinition template,
BroadcastStationProfile station,
IReadOnlyList<ScheduleRegionTarget> regionTargets,
CancellationToken cancellationToken)
{
var isBasicMayorPanse = IsNormalBasicMayorPanseTemplate(template);
var panseLabel = isBasicMayorPanse ? "기초단체장 판세" : "전국 판세 지도";
if (!SupportsApiDistrictOptions(electionType))
{
throw new InvalidOperationException($"{electionType} 전국 판세 지도는 현재 SBS API 연동 대상이 아닙니다.");
throw new InvalidOperationException($"{electionType} {panseLabel}는 현재 SBS API 연동 대상이 아닙니다.");
}
var selectedTargets = regionTargets
@@ -4394,7 +4727,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray();
if (selectedTargets.Length == 0)
{
throw new InvalidOperationException("전국 판세 지도 대상 선거구가 없습니다.");
throw new InvalidOperationException($"{panseLabel} 대상 선거구가 없습니다.");
}
var districtOptions = selectedTargets
@@ -4410,7 +4743,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ConfigureAwait(false);
if (refreshResults.Count == 0)
{
throw new InvalidOperationException("전국 판세 지도용 개표 데이터가 없습니다.");
throw new InvalidOperationException($"{panseLabel}용 개표 데이터가 없습니다.");
}
var targetMap = selectedTargets
@@ -4427,7 +4760,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray();
if (leaders.Length == 0)
{
throw new InvalidOperationException("전국 판세 지도에 반영할 1위 후보 데이터가 없습니다.");
throw new InvalidOperationException($"{panseLabel}에 반영할 1위 후보 데이터가 없습니다.");
}
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
@@ -4437,21 +4770,28 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
var countedRate = totalVotes <= 0
? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max()
: Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero);
var history = ResolvePreElectionHistoryRecords(electionType, "전국", "전국");
var regionName = isBasicMayorPanse
? ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets)
: "전국";
var districtName = isBasicMayorPanse && selectedTargets.Length == 1
? selectedTargets[0].DisplayName
: regionName;
var totalPlaces = selectedTargets.Length;
var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName);
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase.Counting,
ElectionType = electionType,
DistrictName = "전국",
DistrictCode = string.Empty,
RegionName = "전국",
ElectionDistrictName = "전국",
DistrictName = districtName,
DistrictCode = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
RegionName = regionName,
ElectionDistrictName = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
Candidates = leaders,
TotalExpectedVotes = totalVotes,
TurnoutVotes = turnoutVotes,
CountedVotesFromApi = countedVotes,
RemainingVotesFromApi = remainingVotes,
TotalExpectedVotes = isBasicMayorPanse ? totalPlaces : totalVotes,
TurnoutVotes = isBasicMayorPanse ? totalPlaces : turnoutVotes,
CountedVotesFromApi = isBasicMayorPanse ? leaders.Length : countedVotes,
RemainingVotesFromApi = isBasicMayorPanse ? Math.Max(0, totalPlaces - leaders.Length) : remainingVotes,
CountedRateFromApi = countedRate,
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
HistoricalTurnoutHistory = history.TurnoutHistory,
@@ -4697,6 +5037,95 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
}
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(
IReadOnlyList<SbsElectionApiClient.CouncilSeatPartySummary> councilSeats)
{
var candidates = new List<CandidateEntry>(councilSeats.Count * 2);
for (var index = 0; index < councilSeats.Count; index++)
{
var row = councilSeats[index];
if (row.RegionalSeats > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate(
$"{CouncilSeatDistrictCandidateCodePrefix}{index + 1:00}",
row.PartyName,
row.RegionalSeats,
index + 1));
}
if (row.ProportionalSeats > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate(
$"{CouncilSeatProportionalCandidateCodePrefix}{index + 1:00}",
row.PartyName,
row.ProportionalSeats,
index + 1));
}
if (row.RegionalSeats <= 0 && row.ProportionalSeats <= 0 && row.TotalSeats > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate(
$"{CouncilSeatCandidateCodePrefix}{index + 1:00}",
row.PartyName,
row.TotalSeats,
index + 1));
}
}
return candidates.ToArray();
}
private static CandidateEntry[] BuildProportionalCouncilCandidates(
IReadOnlyList<SbsElectionApiClient.CouncilSeatPartySummary> councilSeats)
{
var totalProportionalSeats = councilSeats.Sum(row => Math.Max(0, row.ProportionalSeats));
return councilSeats
.OrderByDescending(row => row.ProportionalSeats)
.ThenBy(row => row.PartyName, StringComparer.Ordinal)
.Select((row, index) =>
{
var seatCount = Math.Max(0, row.ProportionalSeats);
return new CandidateEntry
{
CandidateCode = $"{CouncilSeatProportionalCandidateCodePrefix}{index + 1:00}",
BallotNumber = (index + 1).ToString(),
Name = row.PartyName,
Party = row.PartyName,
ColorParty = row.PartyName,
VoteCount = seatCount,
VoteRate = totalProportionalSeats <= 0
? 0
: Math.Round(seatCount * 100d / totalProportionalSeats, 1, MidpointRounding.AwayFromZero),
HasImage = false,
ManualJudgement = CandidateJudgement.None,
AutomaticJudgement = seatCount > 0 ? CandidateJudgement.Elected : CandidateJudgement.None
};
})
.ToArray();
}
private static CandidateEntry CreateCouncilSeatSummaryCandidate(
string candidateCode,
string partyName,
int seatCount,
int ballotNumber)
{
var normalizedParty = string.IsNullOrWhiteSpace(partyName) ? "무기타" : partyName.Trim();
return new CandidateEntry
{
CandidateCode = candidateCode,
BallotNumber = ballotNumber.ToString(),
Name = normalizedParty,
Party = normalizedParty,
ColorParty = normalizedParty,
VoteCount = seatCount,
VoteRate = seatCount,
HasImage = false,
ManualJudgement = CandidateJudgement.None,
AutomaticJudgement = CandidateJudgement.Elected
};
}
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(
string electionType,
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
@@ -5035,6 +5464,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
}
private static bool IsNormalBasicMayorPanseTemplate(FormatTemplateDefinition? template)
{
return template?.RecommendedChannel == BroadcastChannel.Normal &&
template.Name.StartsWith("판세_기초단체장", StringComparison.Ordinal);
}
private static bool IsPanseEducationTemplate(FormatTemplateDefinition template)
{
return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal);
@@ -5059,6 +5494,49 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName);
}
private string ResolveCouncilSeatTargetSidoCode(ScheduleRegionTarget target)
{
if (!string.IsNullOrWhiteSpace(target.DistrictCode) &&
target.DistrictCode.Length == 2 &&
target.DistrictCode.All(char.IsDigit))
{
return target.DistrictCode;
}
return ResolveScheduleTargetParentRegionCode(target);
}
private static string ResolveCouncilSeatTargetSigunguCode(ScheduleRegionTarget target)
{
if (string.IsNullOrWhiteSpace(target.DistrictCode) ||
target.DistrictCode.Length == 2 && target.DistrictCode.All(char.IsDigit))
{
return string.Empty;
}
return target.DistrictCode.Trim();
}
private string ResolveSelectedCouncilSeatSidoCode()
{
if (!string.IsNullOrWhiteSpace(DistrictCode) &&
DistrictCode.Length == 2 &&
DistrictCode.All(char.IsDigit))
{
return DistrictCode;
}
if (!string.IsNullOrWhiteSpace(DistrictName) &&
_districtOptionMap.TryGetValue(DistrictName, out var option))
{
return !string.IsNullOrWhiteSpace(option.ParentRegionCode)
? option.ParentRegionCode
: option.DistrictCode;
}
return SbsElectionApiClient.ResolveBasicApiSidoCode(FirstNonWhiteSpace(RegionName, DistrictName));
}
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&

View File

@@ -22,6 +22,14 @@ public sealed class MainViewModel : ObservableObject
{
private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
private static readonly Brush WaitingStatusBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 148, 163, 184));
private static readonly Brush ReceivingStatusBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 56, 189, 248));
private static readonly Brush WarningStatusBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 250, 204, 21));
private static readonly Brush ConnectedCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 8, 52, 34));
private static readonly Brush DisconnectedCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 64, 24, 28));
private static readonly Brush WaitingCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 19, 35, 56));
private static readonly Brush ReceivingCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 12, 42, 66));
private static readonly Brush WarningCardBackgroundBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 58, 48, 16));
private static readonly Brush DataReceivingNavigationBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DataWaitingNavigationBrush = new SolidColorBrush(Colors.White);
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
@@ -190,7 +198,13 @@ public sealed class MainViewModel : ObservableObject
nameof(BottomVisibility),
nameof(VideoWallVisibility),
nameof(HeaderStatus),
nameof(IsCgConnected),
nameof(CgIntegrationSummary),
nameof(CgIntegrationBrush),
nameof(CgIntegrationCardBackgroundBrush),
nameof(CgIntegrationCardBorderBrush),
nameof(CgIntegrationSignalText),
nameof(CgIntegrationOperatorMessage),
nameof(CgIntegrationDetail),
nameof(TornadoConnectionSummary),
nameof(TornadoConnectionDetail));
@@ -408,10 +422,38 @@ public sealed class MainViewModel : ObservableObject
public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
public Brush DataNavigationIconBrush => Data.HasLiveDataSignal
public Brush CgIntegrationCardBackgroundBrush => IsCgConnected ? ConnectedCardBackgroundBrush : DisconnectedCardBackgroundBrush;
public Brush CgIntegrationCardBorderBrush => CgIntegrationBrush;
public string CgIntegrationSignalText => IsCgConnected ? "CG 연결됨" : "CG 끊김";
public string CgIntegrationOperatorMessage => IsCgConnected ? "송출 가능" : "연결 확인 필요";
public Brush DataNavigationIconBrush => Data.HasAnyLiveDataSignal
? DataReceivingNavigationBrush
: DataWaitingNavigationBrush;
public Brush SbsDataConnectionBrush => ResolveDataConnectionBrush(isMbcCni: false);
public Brush SbsDataConnectionCardBackgroundBrush => ResolveDataConnectionCardBackgroundBrush(isMbcCni: false);
public Brush SbsDataConnectionCardBorderBrush => SbsDataConnectionBrush;
public string SbsDataConnectionSummary => ResolveDataConnectionSummary(isMbcCni: false);
public string SbsDataConnectionDetail => ResolveDataConnectionDetail(isMbcCni: false);
public Brush MbcCniDataConnectionBrush => ResolveDataConnectionBrush(isMbcCni: true);
public Brush MbcCniDataConnectionCardBackgroundBrush => ResolveDataConnectionCardBackgroundBrush(isMbcCni: true);
public Brush MbcCniDataConnectionCardBorderBrush => MbcCniDataConnectionBrush;
public string MbcCniDataConnectionSummary => ResolveDataConnectionSummary(isMbcCni: true);
public string MbcCniDataConnectionDetail => ResolveDataConnectionDetail(isMbcCni: true);
public string CgIntegrationDetail
{
get
@@ -504,6 +546,51 @@ public sealed class MainViewModel : ObservableObject
public string HeaderStatus => $"{Settings.SelectedStation.Name} / {CurrentPageTitle} / {Data.BroadcastPhaseBadgeText} / {OperationModeLabel}";
private Brush ResolveDataConnectionBrush(bool isMbcCni)
{
return ResolveDataConnectionState(isMbcCni) switch
{
DataSourceConnectionState.Receiving => ReceivingStatusBrush,
DataSourceConnectionState.Connected => ConnectedStatusBrush,
DataSourceConnectionState.Warning => WarningStatusBrush,
DataSourceConnectionState.Disconnected => DisconnectedStatusBrush,
_ => WaitingStatusBrush
};
}
private Brush ResolveDataConnectionCardBackgroundBrush(bool isMbcCni)
{
return ResolveDataConnectionState(isMbcCni) switch
{
DataSourceConnectionState.Receiving => ReceivingCardBackgroundBrush,
DataSourceConnectionState.Connected => ConnectedCardBackgroundBrush,
DataSourceConnectionState.Warning => WarningCardBackgroundBrush,
DataSourceConnectionState.Disconnected => DisconnectedCardBackgroundBrush,
_ => WaitingCardBackgroundBrush
};
}
private string ResolveDataConnectionSummary(bool isMbcCni)
{
return isMbcCni
? Data.MbcCniDataSourceSummary
: Data.SbsDataSourceSummary;
}
private string ResolveDataConnectionDetail(bool isMbcCni)
{
return isMbcCni
? Data.MbcCniDataSourceDetail
: Data.SbsDataSourceDetail;
}
private DataSourceConnectionState ResolveDataConnectionState(bool isMbcCni)
{
return isMbcCni
? Data.MbcCniDataSourceState
: Data.SbsDataSourceState;
}
public void Navigate(string tag)
{
var targetPage = tag switch
@@ -792,11 +879,29 @@ public sealed class MainViewModel : ObservableObject
OnPropertyChanged(nameof(HeaderStatus));
}
if (args.PropertyName is nameof(DataViewModel.HasLiveDataSignal))
if (args.PropertyName is nameof(DataViewModel.HasAnyLiveDataSignal))
{
OnPropertyChanged(nameof(DataNavigationIconBrush));
}
if (args.PropertyName is nameof(DataViewModel.HasAnyLiveDataSignal)
or nameof(DataViewModel.HasLiveDataSignal)
or nameof(DataViewModel.IsRefreshing)
or nameof(DataViewModel.LastRefreshAt)
or nameof(DataViewModel.StatusText)
or nameof(DataViewModel.ElectionType)
or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.SbsDataSourceState)
or nameof(DataViewModel.SbsDataSourceDetail)
or nameof(DataViewModel.SbsDataSourceSummary)
or nameof(DataViewModel.MbcCniDataSourceState)
or nameof(DataViewModel.MbcCniDataSourceDetail)
or nameof(DataViewModel.MbcCniDataSourceSummary))
{
NotifyDataConnectionCardsChanged();
}
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.ElectionType)
@@ -812,11 +917,42 @@ public sealed class MainViewModel : ObservableObject
}
}
private void NotifyDataConnectionCardsChanged()
{
OnPropertyChanged(
nameof(DataNavigationIconBrush),
nameof(SbsDataConnectionBrush),
nameof(SbsDataConnectionCardBackgroundBrush),
nameof(SbsDataConnectionCardBorderBrush),
nameof(SbsDataConnectionSummary),
nameof(SbsDataConnectionDetail),
nameof(MbcCniDataConnectionBrush),
nameof(MbcCniDataConnectionCardBackgroundBrush),
nameof(MbcCniDataConnectionCardBorderBrush),
nameof(MbcCniDataConnectionSummary),
nameof(MbcCniDataConnectionDetail));
}
private void RestoreSelection_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
QueueAutomaticSave();
}
private void NotifyCgConnectionStatusChanged()
{
OnPropertyChanged(
nameof(IsCgConnected),
nameof(CgIntegrationSummary),
nameof(CgIntegrationBrush),
nameof(CgIntegrationCardBackgroundBrush),
nameof(CgIntegrationCardBorderBrush),
nameof(CgIntegrationSignalText),
nameof(CgIntegrationOperatorMessage),
nameof(CgIntegrationDetail),
nameof(TornadoConnectionSummary),
nameof(TornadoConnectionDetail));
}
private void Channel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ChannelScheduleViewModel.LoopEnabled)
@@ -829,13 +965,7 @@ public sealed class MainViewModel : ObservableObject
or nameof(ChannelScheduleViewModel.AdapterStateLabel)
or nameof(ChannelScheduleViewModel.IsCgConnected))
{
OnPropertyChanged(
nameof(IsCgConnected),
nameof(CgIntegrationSummary),
nameof(CgIntegrationBrush),
nameof(CgIntegrationDetail),
nameof(TornadoConnectionSummary),
nameof(TornadoConnectionDetail));
NotifyCgConnectionStatusChanged();
}
}
@@ -1269,6 +1399,7 @@ public sealed class MainViewModel : ObservableObject
private void OnCutDurationChanged(FormatTemplateDefinition template)
{
SyncQueuedCutDurations(template);
RefreshSelectedFormatDurations(template);
QueueAutomaticSave();
}
@@ -1311,6 +1442,7 @@ public sealed class MainViewModel : ObservableObject
{
RefreshCutListEntries(template);
SyncQueuedCutDurations(template);
RefreshSelectedFormatDurations(template);
QueueAutomaticSave();
}
}
@@ -1324,6 +1456,14 @@ public sealed class MainViewModel : ObservableObject
}
}
private void RefreshSelectedFormatDurations(FormatTemplateDefinition template)
{
foreach (var channel in Channels)
{
channel.RefreshSelectedFormatDuration(template);
}
}
private void ApplyCutDurations(IReadOnlyDictionary<string, double>? durations)
{
if (durations is null || durations.Count == 0)