Compare commits
9 Commits
f9596a2033
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dc6c670c8e | |||
| 7e3f496ae4 | |||
| df01f07c44 | |||
| 24915c1dca | |||
| 72afee11fc | |||
| 258b3ddaeb | |||
| a743a5f709 | |||
| 8beee8e419 | |||
| aa2336358b |
@@ -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,33 +44,62 @@
|
||||
Text="{x:Bind ViewModel.CgStatusSummary, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel
|
||||
<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"
|
||||
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>
|
||||
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>
|
||||
@@ -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"
|
||||
@@ -887,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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -103,6 +107,8 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
|
||||
OnPropertyChanged(nameof(CardOpacity));
|
||||
OnPropertyChanged(nameof(CanDelete));
|
||||
OnPropertyChanged(nameof(PlaybackCountdownVisibility));
|
||||
OnIssueStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,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
|
||||
@@ -139,6 +164,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
ScheduleQueueItemState.Sending => "준비",
|
||||
ScheduleQueueItemState.OnAir => "송출 중",
|
||||
ScheduleQueueItemState.Completed => "대기",
|
||||
ScheduleQueueItemState.DataUnavailable => "데이터 없음",
|
||||
ScheduleQueueItemState.Error => "오류",
|
||||
_ => "대기"
|
||||
};
|
||||
@@ -149,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)
|
||||
});
|
||||
@@ -159,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)
|
||||
});
|
||||
@@ -169,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)
|
||||
});
|
||||
@@ -179,6 +208,7 @@ 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)
|
||||
});
|
||||
@@ -189,6 +219,49 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
[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);
|
||||
|
||||
@@ -237,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;
|
||||
|
||||
@@ -333,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;
|
||||
@@ -356,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));
|
||||
@@ -371,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
|
||||
|
||||
10
Tornado3_2026Election/Domain/DataSourceConnectionState.cs
Normal file
10
Tornado3_2026Election/Domain/DataSourceConnectionState.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum DataSourceConnectionState
|
||||
{
|
||||
Waiting,
|
||||
Receiving,
|
||||
Connected,
|
||||
Warning,
|
||||
Disconnected
|
||||
}
|
||||
@@ -7,5 +7,6 @@ public enum ScheduleQueueItemState
|
||||
Sending,
|
||||
OnAir,
|
||||
Completed,
|
||||
Error
|
||||
Error,
|
||||
DataUnavailable
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
FontSize="22"
|
||||
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||
Text="{x:Bind ViewModel.CgIntegrationSummary, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
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}"
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class ChannelScheduleEngine
|
||||
private TaskCompletionSource<bool>? _advanceSignal;
|
||||
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;
|
||||
}
|
||||
@@ -87,6 +94,10 @@ public sealed class ChannelScheduleEngine
|
||||
}
|
||||
|
||||
_lastPlaybackItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
_restartFromTopAfterItemId = null;
|
||||
ResetDataUnavailableItems();
|
||||
ClearStalePlaybackStatesBeforeStart();
|
||||
_playbackCts = new CancellationTokenSource();
|
||||
IsRunning = true;
|
||||
RefreshQueueMarkers();
|
||||
@@ -111,6 +122,7 @@ public sealed class ChannelScheduleEngine
|
||||
ClearPreparedFrame(resetState: true);
|
||||
_lastPlaybackItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
_restartFromTopAfterItemId = null;
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
@@ -129,10 +141,12 @@ public sealed class ChannelScheduleEngine
|
||||
item.CurrentRegionLabel = string.Empty;
|
||||
item.ClearRenderedPreview();
|
||||
item.ClearInternalNextPreview();
|
||||
item.ClearPlaybackCountdown();
|
||||
}
|
||||
|
||||
_lastPlaybackItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
_restartFromTopAfterItemId = null;
|
||||
ClearPreparedFrame(resetState: false);
|
||||
IsRunning = false;
|
||||
RefreshQueueMarkers();
|
||||
@@ -150,6 +164,9 @@ public sealed class ChannelScheduleEngine
|
||||
try
|
||||
{
|
||||
_lastPlaybackItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
_restartFromTopAfterItemId = null;
|
||||
ResetDataUnavailableItems();
|
||||
ClearPreparedFrame(resetState: true);
|
||||
RefreshQueueMarkers();
|
||||
|
||||
@@ -201,6 +218,7 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
item.CurrentRegionLabel = string.Empty;
|
||||
item.ClearPlaybackCountdown();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
@@ -219,6 +237,7 @@ public sealed class ChannelScheduleEngine
|
||||
_directPlaybackItem.CurrentRegionLabel = string.Empty;
|
||||
_directPlaybackItem.ClearRenderedPreview();
|
||||
_directPlaybackItem.ClearInternalNextPreview();
|
||||
_directPlaybackItem.ClearPlaybackCountdown();
|
||||
_directPlaybackItem = null;
|
||||
}
|
||||
|
||||
@@ -242,6 +261,7 @@ public sealed class ChannelScheduleEngine
|
||||
{
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
item.CurrentRegionLabel = string.Empty;
|
||||
item.ClearPlaybackCountdown();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -254,6 +274,8 @@ public sealed class ChannelScheduleEngine
|
||||
public void Reset()
|
||||
{
|
||||
_lastPlaybackItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
_restartFromTopAfterItemId = null;
|
||||
ClearPreparedFrame(resetState: false);
|
||||
foreach (var item in Queue)
|
||||
{
|
||||
@@ -262,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);
|
||||
@@ -293,6 +334,38 @@ public sealed class ChannelScheduleEngine
|
||||
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();
|
||||
@@ -430,6 +503,7 @@ public sealed class ChannelScheduleEngine
|
||||
sendingItem.LastError = ex.Message;
|
||||
sendingItem.CurrentRegionLabel = string.Empty;
|
||||
sendingItem.ClearInternalNextPreview();
|
||||
sendingItem.ClearPlaybackCountdown();
|
||||
ClearSkipCurrentItem(sendingItem);
|
||||
}
|
||||
|
||||
@@ -478,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;
|
||||
@@ -520,16 +594,15 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
if (regionTargets.Count == 0)
|
||||
{
|
||||
queueItem.State = ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
_lastPlaybackItemId = queueItem.Id;
|
||||
MarkLastPlaybackItem(queueItem);
|
||||
MarkDataUnavailable(queueItem, "선택한 지역 조건에 송출 가능한 데이터가 없습니다.");
|
||||
RefreshQueueMarkers();
|
||||
return;
|
||||
}
|
||||
|
||||
var playedAny = false;
|
||||
var lastFailure = string.Empty;
|
||||
var dataUnavailableFailure = false;
|
||||
|
||||
if (ShouldUseAggregateScheduleSnapshot(template))
|
||||
{
|
||||
@@ -559,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;
|
||||
}
|
||||
@@ -609,9 +683,23 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
queueItem.ClearInternalNextPreview();
|
||||
_lastPlaybackItemId = queueItem.Id;
|
||||
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : 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;
|
||||
@@ -642,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;
|
||||
}
|
||||
@@ -694,9 +783,23 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
queueItem.ClearInternalNextPreview();
|
||||
_lastPlaybackItemId = queueItem.Id;
|
||||
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : 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();
|
||||
}
|
||||
@@ -712,6 +815,7 @@ public sealed class ChannelScheduleEngine
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
queueItem.State = ScheduleQueueItemState.Sending;
|
||||
queueItem.ClearPlaybackCountdown();
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -734,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);
|
||||
|
||||
@@ -746,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))
|
||||
@@ -783,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(
|
||||
@@ -1169,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)
|
||||
@@ -1348,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)
|
||||
@@ -1624,7 +1765,7 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
foreach (var item in Queue)
|
||||
{
|
||||
if (item == activeItem || item.State == ScheduleQueueItemState.Error)
|
||||
if (item == activeItem || item.State is ScheduleQueueItemState.Error or ScheduleQueueItemState.DataUnavailable)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1677,6 +1818,66 @@ public sealed class ChannelScheduleEngine
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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; }
|
||||
@@ -367,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;
|
||||
@@ -397,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 ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";
|
||||
@@ -485,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();
|
||||
@@ -749,6 +790,22 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_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)
|
||||
{
|
||||
if (!_engine.Remove(item))
|
||||
@@ -844,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),
|
||||
@@ -851,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),
|
||||
@@ -1045,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(
|
||||
@@ -1098,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();
|
||||
}
|
||||
}
|
||||
@@ -1121,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()
|
||||
@@ -1131,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),
|
||||
|
||||
@@ -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
|
||||
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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user