Compare commits
9 Commits
f9596a2033
...
dc6c670c8e
| Author | SHA1 | Date | |
|---|---|---|---|
| dc6c670c8e | |||
| 7e3f496ae4 | |||
| df01f07c44 | |||
| 24915c1dca | |||
| 72afee11fc | |||
| 258b3ddaeb | |||
| a743a5f709 | |||
| 8beee8e419 | |||
| aa2336358b |
@@ -30,11 +30,6 @@
|
|||||||
CornerRadius="26">
|
CornerRadius="26">
|
||||||
<StackPanel Spacing="18">
|
<StackPanel Spacing="18">
|
||||||
<Grid ColumnSpacing="16">
|
<Grid ColumnSpacing="16">
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontFamily="Bahnschrift SemiBold"
|
FontFamily="Bahnschrift SemiBold"
|
||||||
@@ -49,33 +44,62 @@
|
|||||||
Text="{x:Bind ViewModel.CgStatusSummary, Mode=OneWay}"
|
Text="{x:Bind ViewModel.CgStatusSummary, Mode=OneWay}"
|
||||||
TextWrapping="WrapWholeWords" />
|
TextWrapping="WrapWholeWords" />
|
||||||
</StackPanel>
|
</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"
|
Grid.Column="1"
|
||||||
HorizontalAlignment="Right"
|
VerticalAlignment="Center"
|
||||||
Spacing="10">
|
Style="{StaticResource ConsoleBodyTextStyle}"
|
||||||
<Border
|
Text="{x:Bind ViewModel.PlaybackCountdownDetail, Mode=OneWay}"
|
||||||
Padding="12,8"
|
TextTrimming="CharacterEllipsis" />
|
||||||
Background="#33FF5A54"
|
</Grid>
|
||||||
BorderBrush="{StaticResource ControlRoomSignalRedBrush}"
|
<ProgressBar
|
||||||
BorderThickness="1"
|
Height="8"
|
||||||
CornerRadius="14">
|
Maximum="100"
|
||||||
<TextBlock
|
Minimum="0"
|
||||||
Style="{StaticResource MiniSignalTextStyle}"
|
Value="{x:Bind ViewModel.PlaybackCountdownProgress, Mode=OneWay}" />
|
||||||
Text="{x:Bind ViewModel.TransmissionLabel, Mode=OneWay}" />
|
|
||||||
</Border>
|
|
||||||
<Border
|
|
||||||
Padding="12,8"
|
|
||||||
Background="#22293B52"
|
|
||||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
|
||||||
BorderThickness="1"
|
|
||||||
CornerRadius="14">
|
|
||||||
<TextBlock
|
|
||||||
Style="{StaticResource MiniSignalTextStyle}"
|
|
||||||
Text="{x:Bind ViewModel.AdapterStateLabel, Mode=OneWay}" />
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Grid ColumnSpacing="12">
|
<Grid ColumnSpacing="12">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
@@ -205,6 +229,61 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</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
|
<Border
|
||||||
Padding="16"
|
Padding="16"
|
||||||
Background="#0C1421"
|
Background="#0C1421"
|
||||||
@@ -887,6 +966,49 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" />
|
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
|
<StackPanel
|
||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
Spacing="8">
|
Spacing="8">
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ public sealed partial class ChannelSchedulePanel : UserControl
|
|||||||
command.Execute(item);
|
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)
|
private void IncreaseDurationButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
GetItem(sender)?.StepDraftDuration(1d);
|
GetItem(sender)?.StepDraftDuration(1d);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.UI;
|
using Microsoft.UI;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Media;
|
using Microsoft.UI.Xaml.Media;
|
||||||
using Tornado3_2026Election.Common;
|
using Tornado3_2026Election.Common;
|
||||||
using Tornado3_2026Election.Services;
|
using Tornado3_2026Election.Services;
|
||||||
@@ -12,6 +13,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
{
|
{
|
||||||
private ScheduleQueueItemState _state = ScheduleQueueItemState.Queued;
|
private ScheduleQueueItemState _state = ScheduleQueueItemState.Queued;
|
||||||
private string _lastError = string.Empty;
|
private string _lastError = string.Empty;
|
||||||
|
private DateTimeOffset? _lastIssueAt;
|
||||||
private DateTimeOffset? _lastPlayedAt;
|
private DateTimeOffset? _lastPlayedAt;
|
||||||
private string _currentRegionLabel = string.Empty;
|
private string _currentRegionLabel = string.Empty;
|
||||||
private double _defaultCutDurationSeconds;
|
private double _defaultCutDurationSeconds;
|
||||||
@@ -27,6 +29,8 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
private ImageSource? _internalNextPreviewSource;
|
private ImageSource? _internalNextPreviewSource;
|
||||||
private string _internalNextPreviewStatusLabel = string.Empty;
|
private string _internalNextPreviewStatusLabel = string.Empty;
|
||||||
private string _internalNextPreviewDisplayName = string.Empty;
|
private string _internalNextPreviewDisplayName = string.Empty;
|
||||||
|
private double _playbackRemainingSeconds;
|
||||||
|
private double _playbackTotalSeconds;
|
||||||
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
@@ -103,6 +107,8 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
|
OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
|
||||||
OnPropertyChanged(nameof(CardOpacity));
|
OnPropertyChanged(nameof(CardOpacity));
|
||||||
OnPropertyChanged(nameof(CanDelete));
|
OnPropertyChanged(nameof(CanDelete));
|
||||||
|
OnPropertyChanged(nameof(PlaybackCountdownVisibility));
|
||||||
|
OnIssueStateChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +116,26 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
public string LastError
|
public string LastError
|
||||||
{
|
{
|
||||||
get => _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
|
public DateTimeOffset? LastPlayedAt
|
||||||
@@ -139,6 +164,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
ScheduleQueueItemState.Sending => "준비",
|
ScheduleQueueItemState.Sending => "준비",
|
||||||
ScheduleQueueItemState.OnAir => "송출 중",
|
ScheduleQueueItemState.OnAir => "송출 중",
|
||||||
ScheduleQueueItemState.Completed => "대기",
|
ScheduleQueueItemState.Completed => "대기",
|
||||||
|
ScheduleQueueItemState.DataUnavailable => "데이터 없음",
|
||||||
ScheduleQueueItemState.Error => "오류",
|
ScheduleQueueItemState.Error => "오류",
|
||||||
_ => "대기"
|
_ => "대기"
|
||||||
};
|
};
|
||||||
@@ -149,6 +175,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
|
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
|
||||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
|
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
|
||||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
|
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
|
||||||
|
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 255, 184, 28),
|
||||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
|
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
|
||||||
_ => ColorHelper.FromArgb(255, 100, 116, 139)
|
_ => ColorHelper.FromArgb(255, 100, 116, 139)
|
||||||
});
|
});
|
||||||
@@ -159,6 +186,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 72, 38, 10),
|
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 72, 38, 10),
|
||||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 64, 42, 16),
|
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 64, 42, 16),
|
||||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 58, 22, 24),
|
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 58, 22, 24),
|
||||||
|
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 63, 40, 16),
|
||||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 54, 18, 31),
|
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 54, 18, 31),
|
||||||
_ => ColorHelper.FromArgb(255, 18, 32, 51)
|
_ => ColorHelper.FromArgb(255, 18, 32, 51)
|
||||||
});
|
});
|
||||||
@@ -169,6 +197,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
|
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 184, 28),
|
||||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
|
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 132, 38),
|
||||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 255, 90, 84),
|
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 255, 90, 84),
|
||||||
|
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 255, 184, 28),
|
||||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
|
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 251, 113, 133),
|
||||||
_ => ColorHelper.FromArgb(255, 39, 64, 95)
|
_ => ColorHelper.FromArgb(255, 39, 64, 95)
|
||||||
});
|
});
|
||||||
@@ -179,6 +208,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 194, 65, 12),
|
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 194, 65, 12),
|
||||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 180, 83, 9),
|
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 180, 83, 9),
|
||||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 220, 38, 38),
|
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 220, 38, 38),
|
||||||
|
ScheduleQueueItemState.DataUnavailable => ColorHelper.FromArgb(255, 180, 83, 9),
|
||||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 190, 18, 60),
|
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 190, 18, 60),
|
||||||
_ => ColorHelper.FromArgb(255, 26, 46, 71)
|
_ => ColorHelper.FromArgb(255, 26, 46, 71)
|
||||||
});
|
});
|
||||||
@@ -189,6 +219,49 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
|
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public 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]
|
[JsonIgnore]
|
||||||
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(Channel, FormatName);
|
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(Channel, FormatName);
|
||||||
|
|
||||||
@@ -237,6 +310,27 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
? "실데이터 프리뷰 준비 중"
|
? "실데이터 프리뷰 준비 중"
|
||||||
: _renderedPreviewStatusLabel;
|
: _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]
|
[JsonIgnore]
|
||||||
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
|
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
|
||||||
|
|
||||||
@@ -333,6 +427,33 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
OnInternalNextPreviewChanged();
|
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)
|
public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
|
||||||
{
|
{
|
||||||
ThumbnailWidth = metrics.Width;
|
ThumbnailWidth = metrics.Width;
|
||||||
@@ -356,6 +477,18 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
OnPropertyChanged(nameof(DurationApplyStatusLabel));
|
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()
|
private void OnPreviewChanged()
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(PreviewSource));
|
OnPropertyChanged(nameof(PreviewSource));
|
||||||
@@ -371,6 +504,15 @@ public sealed class ChannelScheduleItem : ObservableObject
|
|||||||
OnPropertyChanged(nameof(InternalNextPreviewDisplayName));
|
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)
|
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
|
||||||
{
|
{
|
||||||
var selectedRegion = regionOption ?? new ScheduleRegionOption
|
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,
|
Sending,
|
||||||
OnAir,
|
OnAir,
|
||||||
Completed,
|
Completed,
|
||||||
Error
|
Error,
|
||||||
|
DataUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,8 +127,10 @@
|
|||||||
|
|
||||||
<Grid ColumnSpacing="10">
|
<Grid ColumnSpacing="10">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="1.05*" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="1.25*" />
|
||||||
|
<ColumnDefinition Width="1.15*" />
|
||||||
|
<ColumnDefinition Width="1.15*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Border Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
<Border Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||||
@@ -145,7 +147,12 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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 Spacing="2">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="CG 연결 상태" />
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="CG 연결 상태" />
|
||||||
@@ -167,21 +174,73 @@
|
|||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<Ellipse Width="10"
|
|
||||||
Height="10"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Fill="{x:Bind ViewModel.CgIntegrationBrush, Mode=OneWay}" />
|
|
||||||
<TextBlock FontFamily="Bahnschrift SemiBold"
|
<TextBlock FontFamily="Bahnschrift SemiBold"
|
||||||
FontSize="15"
|
FontSize="22"
|
||||||
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||||
Text="{x:Bind ViewModel.CgIntegrationSummary, Mode=OneWay}" />
|
Text="{x:Bind ViewModel.CgIntegrationSignalText, Mode=OneWay}"
|
||||||
</StackPanel>
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||||
|
Text="{x:Bind ViewModel.CgIntegrationOperatorMessage, Mode=OneWay}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||||
Text="{x:Bind ViewModel.CgIntegrationDetail, Mode=OneWay}"
|
Text="{x:Bind ViewModel.CgIntegrationDetail, Mode=OneWay}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
</Grid>
|
||||||
|
|
||||||
<Border Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"
|
<Border Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
private TaskCompletionSource<bool>? _advanceSignal;
|
private TaskCompletionSource<bool>? _advanceSignal;
|
||||||
private Guid? _lastPlaybackItemId;
|
private Guid? _lastPlaybackItemId;
|
||||||
private Guid? _skipCurrentItemId;
|
private Guid? _skipCurrentItemId;
|
||||||
|
private Guid? _restartFromTopAfterItemId;
|
||||||
private ChannelScheduleItem? _directPlaybackItem;
|
private ChannelScheduleItem? _directPlaybackItem;
|
||||||
private PreparedCutFrame? _preparedCutFrame;
|
private PreparedCutFrame? _preparedCutFrame;
|
||||||
|
|
||||||
@@ -77,6 +78,12 @@ public sealed class ChannelScheduleEngine
|
|||||||
{
|
{
|
||||||
if (IsRunning)
|
if (IsRunning)
|
||||||
{
|
{
|
||||||
|
if (GetNextPlayableItem() is null)
|
||||||
|
{
|
||||||
|
await RestartFromTopAsync().ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await AdvanceToNextAsync().ConfigureAwait(false);
|
await AdvanceToNextAsync().ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,6 +94,10 @@ public sealed class ChannelScheduleEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
_lastPlaybackItemId = null;
|
_lastPlaybackItemId = null;
|
||||||
|
_skipCurrentItemId = null;
|
||||||
|
_restartFromTopAfterItemId = null;
|
||||||
|
ResetDataUnavailableItems();
|
||||||
|
ClearStalePlaybackStatesBeforeStart();
|
||||||
_playbackCts = new CancellationTokenSource();
|
_playbackCts = new CancellationTokenSource();
|
||||||
IsRunning = true;
|
IsRunning = true;
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
@@ -111,6 +122,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
ClearPreparedFrame(resetState: true);
|
ClearPreparedFrame(resetState: true);
|
||||||
_lastPlaybackItemId = null;
|
_lastPlaybackItemId = null;
|
||||||
_skipCurrentItemId = null;
|
_skipCurrentItemId = null;
|
||||||
|
_restartFromTopAfterItemId = null;
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||||
return;
|
return;
|
||||||
@@ -129,10 +141,12 @@ public sealed class ChannelScheduleEngine
|
|||||||
item.CurrentRegionLabel = string.Empty;
|
item.CurrentRegionLabel = string.Empty;
|
||||||
item.ClearRenderedPreview();
|
item.ClearRenderedPreview();
|
||||||
item.ClearInternalNextPreview();
|
item.ClearInternalNextPreview();
|
||||||
|
item.ClearPlaybackCountdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastPlaybackItemId = null;
|
_lastPlaybackItemId = null;
|
||||||
_skipCurrentItemId = null;
|
_skipCurrentItemId = null;
|
||||||
|
_restartFromTopAfterItemId = null;
|
||||||
ClearPreparedFrame(resetState: false);
|
ClearPreparedFrame(resetState: false);
|
||||||
IsRunning = false;
|
IsRunning = false;
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
@@ -150,6 +164,9 @@ public sealed class ChannelScheduleEngine
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_lastPlaybackItemId = null;
|
_lastPlaybackItemId = null;
|
||||||
|
_skipCurrentItemId = null;
|
||||||
|
_restartFromTopAfterItemId = null;
|
||||||
|
ResetDataUnavailableItems();
|
||||||
ClearPreparedFrame(resetState: true);
|
ClearPreparedFrame(resetState: true);
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
|
|
||||||
@@ -201,6 +218,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
item.State = ScheduleQueueItemState.Queued;
|
item.State = ScheduleQueueItemState.Queued;
|
||||||
item.CurrentRegionLabel = string.Empty;
|
item.CurrentRegionLabel = string.Empty;
|
||||||
|
item.ClearPlaybackCountdown();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -219,6 +237,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
_directPlaybackItem.CurrentRegionLabel = string.Empty;
|
_directPlaybackItem.CurrentRegionLabel = string.Empty;
|
||||||
_directPlaybackItem.ClearRenderedPreview();
|
_directPlaybackItem.ClearRenderedPreview();
|
||||||
_directPlaybackItem.ClearInternalNextPreview();
|
_directPlaybackItem.ClearInternalNextPreview();
|
||||||
|
_directPlaybackItem.ClearPlaybackCountdown();
|
||||||
_directPlaybackItem = null;
|
_directPlaybackItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +261,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
{
|
{
|
||||||
item.State = ScheduleQueueItemState.Queued;
|
item.State = ScheduleQueueItemState.Queued;
|
||||||
item.CurrentRegionLabel = string.Empty;
|
item.CurrentRegionLabel = string.Empty;
|
||||||
|
item.ClearPlaybackCountdown();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -254,6 +274,8 @@ public sealed class ChannelScheduleEngine
|
|||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
_lastPlaybackItemId = null;
|
_lastPlaybackItemId = null;
|
||||||
|
_skipCurrentItemId = null;
|
||||||
|
_restartFromTopAfterItemId = null;
|
||||||
ClearPreparedFrame(resetState: false);
|
ClearPreparedFrame(resetState: false);
|
||||||
foreach (var item in Queue)
|
foreach (var item in Queue)
|
||||||
{
|
{
|
||||||
@@ -262,11 +284,30 @@ public sealed class ChannelScheduleEngine
|
|||||||
item.CurrentRegionLabel = string.Empty;
|
item.CurrentRegionLabel = string.Empty;
|
||||||
item.ClearRenderedPreview();
|
item.ClearRenderedPreview();
|
||||||
item.ClearInternalNextPreview();
|
item.ClearInternalNextPreview();
|
||||||
|
item.ClearPlaybackCountdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshQueueMarkers();
|
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()
|
public async Task ForceNextAsync()
|
||||||
{
|
{
|
||||||
await AdvanceToNextAsync().ConfigureAwait(false);
|
await AdvanceToNextAsync().ConfigureAwait(false);
|
||||||
@@ -293,6 +334,38 @@ public sealed class ChannelScheduleEngine
|
|||||||
activeItem.LastError = string.Empty;
|
activeItem.LastError = string.Empty;
|
||||||
activeItem.CurrentRegionLabel = string.Empty;
|
activeItem.CurrentRegionLabel = string.Empty;
|
||||||
activeItem.ClearInternalNextPreview();
|
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();
|
RefreshQueueMarkers();
|
||||||
@@ -430,6 +503,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
sendingItem.LastError = ex.Message;
|
sendingItem.LastError = ex.Message;
|
||||||
sendingItem.CurrentRegionLabel = string.Empty;
|
sendingItem.CurrentRegionLabel = string.Empty;
|
||||||
sendingItem.ClearInternalNextPreview();
|
sendingItem.ClearInternalNextPreview();
|
||||||
|
sendingItem.ClearPlaybackCountdown();
|
||||||
ClearSkipCurrentItem(sendingItem);
|
ClearSkipCurrentItem(sendingItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,9 +552,9 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
if (previewFrame is null)
|
if (previewFrame is null)
|
||||||
{
|
{
|
||||||
queueItem.State = ScheduleQueueItemState.Error;
|
MarkDataUnavailable(
|
||||||
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
|
queueItem,
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
"현재 선택한 컷과 지역 조건에 맞는 데이터가 없어 준비할 수 없습니다.");
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
_logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}");
|
_logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}");
|
||||||
return;
|
return;
|
||||||
@@ -520,16 +594,15 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
if (regionTargets.Count == 0)
|
if (regionTargets.Count == 0)
|
||||||
{
|
{
|
||||||
queueItem.State = ScheduleQueueItemState.Error;
|
MarkLastPlaybackItem(queueItem);
|
||||||
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
|
MarkDataUnavailable(queueItem, "선택한 지역 조건에 송출 가능한 데이터가 없습니다.");
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
|
||||||
_lastPlaybackItemId = queueItem.Id;
|
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var playedAny = false;
|
var playedAny = false;
|
||||||
var lastFailure = string.Empty;
|
var lastFailure = string.Empty;
|
||||||
|
var dataUnavailableFailure = false;
|
||||||
|
|
||||||
if (ShouldUseAggregateScheduleSnapshot(template))
|
if (ShouldUseAggregateScheduleSnapshot(template))
|
||||||
{
|
{
|
||||||
@@ -559,6 +632,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
|
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
|
||||||
{
|
{
|
||||||
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}";
|
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}";
|
||||||
|
dataUnavailableFailure = true;
|
||||||
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}");
|
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -609,9 +683,23 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
queueItem.CurrentRegionLabel = string.Empty;
|
||||||
queueItem.ClearInternalNextPreview();
|
queueItem.ClearInternalNextPreview();
|
||||||
_lastPlaybackItemId = queueItem.Id;
|
queueItem.ClearPlaybackCountdown();
|
||||||
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error;
|
MarkLastPlaybackItem(queueItem);
|
||||||
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
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);
|
ClearSkipCurrentItem(queueItem);
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
return;
|
return;
|
||||||
@@ -642,6 +730,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out var validationError))
|
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out var validationError))
|
||||||
{
|
{
|
||||||
lastFailure = $"{regionTarget.DisplayName}: {validationError}";
|
lastFailure = $"{regionTarget.DisplayName}: {validationError}";
|
||||||
|
dataUnavailableFailure = true;
|
||||||
_logService.Warning($"[{Channel}] 스케줄 지역 검증 실패: {lastFailure}");
|
_logService.Warning($"[{Channel}] 스케줄 지역 검증 실패: {lastFailure}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -694,9 +783,23 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
queueItem.CurrentRegionLabel = string.Empty;
|
queueItem.CurrentRegionLabel = string.Empty;
|
||||||
queueItem.ClearInternalNextPreview();
|
queueItem.ClearInternalNextPreview();
|
||||||
_lastPlaybackItemId = queueItem.Id;
|
queueItem.ClearPlaybackCountdown();
|
||||||
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error;
|
MarkLastPlaybackItem(queueItem);
|
||||||
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
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);
|
ClearSkipCurrentItem(queueItem);
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
}
|
}
|
||||||
@@ -712,6 +815,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
queueItem.State = ScheduleQueueItemState.Sending;
|
queueItem.State = ScheduleQueueItemState.Sending;
|
||||||
|
queueItem.ClearPlaybackCountdown();
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
@@ -734,8 +838,11 @@ public sealed class ChannelScheduleEngine
|
|||||||
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
|
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var onAirAt = DateTimeOffset.Now;
|
var onAirAt = DateTimeOffset.Now;
|
||||||
|
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||||
|
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
|
||||||
queueItem.State = ScheduleQueueItemState.OnAir;
|
queueItem.State = ScheduleQueueItemState.OnAir;
|
||||||
queueItem.LastPlayedAt = onAirAt;
|
queueItem.LastPlayedAt = onAirAt;
|
||||||
|
queueItem.UpdatePlaybackCountdown(playbackDuration, playbackDuration);
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
@@ -746,8 +853,6 @@ public sealed class ChannelScheduleEngine
|
|||||||
signal.TrySetResult(true);
|
signal.TrySetResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
|
||||||
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
|
|
||||||
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
|
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
|
||||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||||
if (!ShouldSkipCurrentItem(queueItem))
|
if (!ShouldSkipCurrentItem(queueItem))
|
||||||
@@ -783,17 +888,51 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
if (ShouldSkipCurrentItem(queueItem))
|
if (ShouldSkipCurrentItem(queueItem))
|
||||||
{
|
{
|
||||||
|
queueItem.ClearPlaybackCountdown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
|
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
|
||||||
if (remainingDuration <= TimeSpan.Zero)
|
if (remainingDuration <= TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
|
queueItem.ClearPlaybackCountdown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var delayTask = Task.Delay(remainingDuration, cancellationToken);
|
await WaitForPlaybackCountdownAsync(queueItem, onAirAt, playbackDuration, signal, cancellationToken)
|
||||||
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
.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(
|
private async Task CaptureCurrentPreviewAsync(
|
||||||
@@ -1169,6 +1308,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
preparedFrame.Item.CurrentRegionLabel = string.Empty;
|
preparedFrame.Item.CurrentRegionLabel = string.Empty;
|
||||||
preparedFrame.Item.ClearRenderedPreview();
|
preparedFrame.Item.ClearRenderedPreview();
|
||||||
preparedFrame.Item.ClearInternalNextPreview();
|
preparedFrame.Item.ClearInternalNextPreview();
|
||||||
|
preparedFrame.Item.ClearPlaybackCountdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
|
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
|
||||||
@@ -1348,7 +1488,8 @@ public sealed class ChannelScheduleEngine
|
|||||||
private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
|
private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
return template.RecommendedChannel == BroadcastChannel.Normal &&
|
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)
|
private static string NormalizeRegionKey(string value)
|
||||||
@@ -1624,7 +1765,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
foreach (var item in Queue)
|
foreach (var item in Queue)
|
||||||
{
|
{
|
||||||
if (item == activeItem || item.State == ScheduleQueueItemState.Error)
|
if (item == activeItem || item.State is ScheduleQueueItemState.Error or ScheduleQueueItemState.DataUnavailable)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1677,6 +1818,66 @@ public sealed class ChannelScheduleEngine
|
|||||||
return item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next or ScheduleQueueItemState.Completed;
|
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(
|
private sealed record PreparedCutFrame(
|
||||||
ChannelScheduleItem Item,
|
ChannelScheduleItem Item,
|
||||||
FormatTemplateDefinition Template,
|
FormatTemplateDefinition Template,
|
||||||
|
|||||||
@@ -1425,7 +1425,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
};
|
};
|
||||||
|
|
||||||
var candidateSlotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables);
|
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);
|
ClearCandidateSlotValues(values, candidateSlotCount, template, sceneVariables);
|
||||||
}
|
}
|
||||||
@@ -1478,6 +1478,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return FilterValuesForScene(values, sceneVariables, template);
|
return FilterValuesForScene(values, sceneVariables, template);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsNormalBasicMayorPanseTemplate(template))
|
||||||
|
{
|
||||||
|
ApplyNormalBasicMayorPanseValues(values, template, snapshot, templateFolderPath, sceneVariables);
|
||||||
|
return FilterValuesForScene(values, sceneVariables, template);
|
||||||
|
}
|
||||||
|
|
||||||
if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name))
|
if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name))
|
||||||
{
|
{
|
||||||
return FilterValuesForScene(values, sceneVariables, template);
|
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)
|
private static PanseSummary[] BuildNormalPanseMapSummaries(ElectionDataSnapshot snapshot)
|
||||||
{
|
{
|
||||||
var counts = snapshot.Candidates
|
var counts = snapshot.Candidates
|
||||||
@@ -1679,6 +1714,33 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
.ToArray();
|
.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(
|
private static PanseSummary[] BuildTopPanseSummaries(
|
||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
ElectionDataSnapshot snapshot)
|
ElectionDataSnapshot snapshot)
|
||||||
@@ -2175,6 +2237,23 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount;
|
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)
|
private static bool TryParseSimpleIndexedSlot(string variableName, string prefix, out int slot)
|
||||||
{
|
{
|
||||||
slot = 0;
|
slot = 0;
|
||||||
@@ -2303,6 +2382,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables);
|
return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsNormalBasicMayorPanseTemplate(template))
|
||||||
|
{
|
||||||
|
return Array.Empty<KarismaStyleColorUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
|
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
|
||||||
if (orderedCandidates.Length == 0)
|
if (orderedCandidates.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -2402,7 +2486,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
string t3CutPath,
|
string t3CutPath,
|
||||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
{
|
{
|
||||||
if (IsNormalPanseMapTemplate(template))
|
if (IsNormalPanseTemplate(template))
|
||||||
{
|
{
|
||||||
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
|
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
|
||||||
}
|
}
|
||||||
@@ -2649,7 +2733,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
string t3CutPath,
|
string t3CutPath,
|
||||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
{
|
{
|
||||||
if (IsNormalPanseMapTemplate(template))
|
if (IsNormalPanseTemplate(template))
|
||||||
{
|
{
|
||||||
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
|
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
|
||||||
}
|
}
|
||||||
@@ -2798,6 +2882,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables);
|
return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsNormalBasicMayorPanseTemplate(template))
|
||||||
|
{
|
||||||
|
return BuildNormalBasicMayorPanseCounterNumberKeyUpdates(snapshot, sceneVariables);
|
||||||
|
}
|
||||||
|
|
||||||
if (IsNormalPanseMapTemplate(template))
|
if (IsNormalPanseMapTemplate(template))
|
||||||
{
|
{
|
||||||
return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables);
|
return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables);
|
||||||
@@ -3011,6 +3100,43 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return updates;
|
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(
|
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
|
||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
ElectionDataSnapshot snapshot,
|
ElectionDataSnapshot snapshot,
|
||||||
@@ -5304,7 +5430,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return false;
|
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 "유확당" ||
|
"기준시" or "기준시01" or "기준시02" or "유권자수" or "유권자수01" or "투표자수" or "투표자수01" or "유확당" ||
|
||||||
NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) ||
|
NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) ||
|
||||||
IsBottomWinnerBallotNumberVariableName(variableName) ||
|
IsBottomWinnerBallotNumberVariableName(variableName) ||
|
||||||
@@ -5314,6 +5440,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
MatchesIndexedVariable(variableName, "개표율") ||
|
MatchesIndexedVariable(variableName, "개표율") ||
|
||||||
MatchesIndexedVariable(variableName, "투표율") ||
|
MatchesIndexedVariable(variableName, "투표율") ||
|
||||||
MatchesIndexedVariable(variableName, "전국투표율") ||
|
MatchesIndexedVariable(variableName, "전국투표율") ||
|
||||||
|
MatchesIndexedVariable(variableName, "총") ||
|
||||||
MatchesIndexedVariable(variableName, "사진") ||
|
MatchesIndexedVariable(variableName, "사진") ||
|
||||||
MatchesIndexedVariable(variableName, "순위") ||
|
MatchesIndexedVariable(variableName, "순위") ||
|
||||||
IsSpecialRankVariableName(variableName) ||
|
IsSpecialRankVariableName(variableName) ||
|
||||||
@@ -6157,6 +6284,17 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
|
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)
|
private static bool IsPanseEducationTemplate(string templateName)
|
||||||
{
|
{
|
||||||
return string.Equals(templateName, "판세_교육감", StringComparison.Ordinal);
|
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 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 readonly record struct NormalPansePartySlot(string Label, string ColorParty);
|
||||||
|
|
||||||
private sealed record SceneUpdatePayload(
|
private sealed record SceneUpdatePayload(
|
||||||
|
|||||||
@@ -301,10 +301,11 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
foreach (var responseItem in countingItems)
|
foreach (var responseItem in countingItems)
|
||||||
{
|
{
|
||||||
var item = responseItem.Item;
|
var item = responseItem.Item;
|
||||||
var regionId = item.Region?.Id;
|
var matchedRegionCode = GetCountingRegionMatchKeys(item.Region)
|
||||||
if (string.IsNullOrWhiteSpace(regionId) ||
|
.FirstOrDefault(key => districtMap.ContainsKey(key));
|
||||||
!districtMap.TryGetValue(regionId, out var districtOption) ||
|
if (string.IsNullOrWhiteSpace(matchedRegionCode) ||
|
||||||
!orderMap.TryGetValue(regionId, out var order))
|
!districtMap.TryGetValue(matchedRegionCode, out var districtOption) ||
|
||||||
|
!orderMap.TryGetValue(matchedRegionCode, out var order))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -322,6 +323,179 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
.ToArray();
|
.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(
|
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
|
||||||
string electionType,
|
string electionType,
|
||||||
IReadOnlyList<DistrictSelectionOption> districts,
|
IReadOnlyList<DistrictSelectionOption> districts,
|
||||||
@@ -1224,6 +1398,186 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
|
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
|
||||||
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}";
|
: $"{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)
|
private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration)
|
||||||
=> Uri.Compare(
|
=> Uri.Compare(
|
||||||
configuration.BaseUri,
|
configuration.BaseUri,
|
||||||
@@ -1711,6 +2065,20 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
string JudgementBadgeText = "",
|
string JudgementBadgeText = "",
|
||||||
string JudgementDetailText = "");
|
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(
|
public sealed record TurnoutOverviewItem(
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string RegionName,
|
string RegionName,
|
||||||
@@ -1832,6 +2200,81 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
public int Tupyosu { get; set; }
|
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
|
private sealed class SbsCountingItem
|
||||||
{
|
{
|
||||||
[JsonPropertyName("region")]
|
[JsonPropertyName("region")]
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
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 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 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 ChannelScheduleEngine _engine;
|
||||||
private readonly ITornado3Adapter _adapter;
|
private readonly ITornado3Adapter _adapter;
|
||||||
private readonly CutDebugStateStore _cutDebugStateStore;
|
private readonly CutDebugStateStore _cutDebugStateStore;
|
||||||
@@ -94,6 +96,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
|
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
|
||||||
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
|
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
|
||||||
ResetQueueCommand = new RelayCommand(ResetQueue);
|
ResetQueueCommand = new RelayCommand(ResetQueue);
|
||||||
|
RetryDataIssueCommand = new RelayCommand<ChannelScheduleItem>(RetryDataIssue, CanRetryDataIssue);
|
||||||
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
|
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
|
||||||
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
|
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
|
||||||
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
|
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
|
||||||
@@ -168,6 +171,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
|
|
||||||
public RelayCommand ResetQueueCommand { get; }
|
public RelayCommand ResetQueueCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand<ChannelScheduleItem> RetryDataIssueCommand { get; }
|
||||||
|
|
||||||
public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; }
|
public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; }
|
||||||
|
|
||||||
public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; }
|
public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; }
|
||||||
@@ -367,6 +372,15 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
?? QueueNextPlaybackItem?.PreviewStatusLabel
|
?? 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 CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
|
||||||
|
|
||||||
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
|
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
|
||||||
@@ -397,6 +411,27 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
|
|
||||||
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
|
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 SelectedFormatName => SelectedFormat?.Name ?? "컷을 선택하세요";
|
||||||
|
|
||||||
public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";
|
public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";
|
||||||
@@ -485,6 +520,12 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
private ChannelScheduleItem? NextPlaybackItem =>
|
private ChannelScheduleItem? NextPlaybackItem =>
|
||||||
InternalNextPlaybackItem ?? QueueNextPlaybackItem;
|
InternalNextPlaybackItem ?? QueueNextPlaybackItem;
|
||||||
|
|
||||||
|
private ChannelScheduleItem? LatestScheduleDataIssueItem =>
|
||||||
|
Queue
|
||||||
|
.Where(item => item.State == ScheduleQueueItemState.DataUnavailable)
|
||||||
|
.OrderByDescending(item => item.LastIssueAt ?? DateTimeOffset.MinValue)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
public async Task RefreshRegionOptionsAsync()
|
public async Task RefreshRegionOptionsAsync()
|
||||||
{
|
{
|
||||||
await RebuildRegionOptionsAsync();
|
await RebuildRegionOptionsAsync();
|
||||||
@@ -749,6 +790,22 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
_logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
|
_logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RetryDataIssue(ChannelScheduleItem? item)
|
||||||
|
{
|
||||||
|
if (!_engine.RetryDataUnavailable(item))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshSummary();
|
||||||
|
_logService.Info($"[{Title}] 데이터 없음 항목을 다시 스케줄 대상으로 전환: {item?.DisplayName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanRetryDataIssue(ChannelScheduleItem? item)
|
||||||
|
{
|
||||||
|
return item?.State == ScheduleQueueItemState.DataUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
private void RemoveItem(ChannelScheduleItem? item)
|
private void RemoveItem(ChannelScheduleItem? item)
|
||||||
{
|
{
|
||||||
if (!_engine.Remove(item))
|
if (!_engine.Remove(item))
|
||||||
@@ -844,6 +901,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
nameof(NextPreviewSource),
|
nameof(NextPreviewSource),
|
||||||
nameof(CurrentPreviewStatusLabel),
|
nameof(CurrentPreviewStatusLabel),
|
||||||
nameof(NextPreviewStatusLabel),
|
nameof(NextPreviewStatusLabel),
|
||||||
|
nameof(PlaybackCountdownVisibility),
|
||||||
|
nameof(PlaybackCountdownText),
|
||||||
|
nameof(PlaybackCountdownDetail),
|
||||||
|
nameof(PlaybackCountdownProgress),
|
||||||
nameof(CurrentPreviewWidth),
|
nameof(CurrentPreviewWidth),
|
||||||
nameof(CurrentPreviewHeight),
|
nameof(CurrentPreviewHeight),
|
||||||
nameof(NextPreviewWidth),
|
nameof(NextPreviewWidth),
|
||||||
@@ -851,6 +912,13 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
nameof(QueuedItemCount),
|
nameof(QueuedItemCount),
|
||||||
nameof(QueueFootnote),
|
nameof(QueueFootnote),
|
||||||
nameof(QueueSummary),
|
nameof(QueueSummary),
|
||||||
|
nameof(ScheduleDataIssueVisibility),
|
||||||
|
nameof(ScheduleDataIssueBackgroundBrush),
|
||||||
|
nameof(ScheduleDataIssueBorderBrush),
|
||||||
|
nameof(ScheduleDataIssueTitle),
|
||||||
|
nameof(ScheduleDataIssueMessage),
|
||||||
|
nameof(ScheduleDataIssueDetail),
|
||||||
|
nameof(ScheduleDataIssueHint),
|
||||||
nameof(IsCgConnected),
|
nameof(IsCgConnected),
|
||||||
nameof(CgStatusSummary),
|
nameof(CgStatusSummary),
|
||||||
nameof(LoopSummary),
|
nameof(LoopSummary),
|
||||||
@@ -1045,6 +1113,17 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
NotifySelectedFormatDurationStateChanged();
|
NotifySelectedFormatDurationStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RefreshSelectedFormatDuration(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
if (SelectedFormat is null ||
|
||||||
|
!string.Equals(SelectedFormat.Id, template.Id, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetSelectedFormatDurationDraft();
|
||||||
|
}
|
||||||
|
|
||||||
private void NotifySelectedFormatDurationStateChanged()
|
private void NotifySelectedFormatDurationStateChanged()
|
||||||
{
|
{
|
||||||
OnPropertyChanged(
|
OnPropertyChanged(
|
||||||
@@ -1098,15 +1177,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
|
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
|
||||||
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
|
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
|
||||||
or nameof(ChannelScheduleItem.HasInternalNextPreview)
|
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))
|
or nameof(ChannelScheduleItem.ThumbnailSource))
|
||||||
{
|
{
|
||||||
if (e.PropertyName is nameof(ChannelScheduleItem.State)
|
if (e.PropertyName is nameof(ChannelScheduleItem.State)
|
||||||
or nameof(ChannelScheduleItem.DisplayName)
|
or nameof(ChannelScheduleItem.DisplayName)
|
||||||
or nameof(ChannelScheduleItem.CurrentRegionLabel))
|
or nameof(ChannelScheduleItem.CurrentRegionLabel)
|
||||||
|
or nameof(ChannelScheduleItem.LastError)
|
||||||
|
or nameof(ChannelScheduleItem.LastIssueAt))
|
||||||
{
|
{
|
||||||
NotifyPlaybackStateChanged();
|
NotifyPlaybackStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RetryDataIssueCommand.NotifyCanExecuteChanged();
|
||||||
NotifyPlaybackPreviewChanged();
|
NotifyPlaybackPreviewChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1121,7 +1209,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
nameof(NextItemName),
|
nameof(NextItemName),
|
||||||
nameof(QueuedItemCount),
|
nameof(QueuedItemCount),
|
||||||
nameof(QueueFootnote),
|
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()
|
private void NotifyPlaybackPreviewChanged()
|
||||||
@@ -1131,6 +1230,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
nameof(NextPreviewSource),
|
nameof(NextPreviewSource),
|
||||||
nameof(CurrentPreviewStatusLabel),
|
nameof(CurrentPreviewStatusLabel),
|
||||||
nameof(NextPreviewStatusLabel),
|
nameof(NextPreviewStatusLabel),
|
||||||
|
nameof(PlaybackCountdownVisibility),
|
||||||
|
nameof(PlaybackCountdownText),
|
||||||
|
nameof(PlaybackCountdownDetail),
|
||||||
|
nameof(PlaybackCountdownProgress),
|
||||||
nameof(CurrentPreviewWidth),
|
nameof(CurrentPreviewWidth),
|
||||||
nameof(CurrentPreviewHeight),
|
nameof(CurrentPreviewHeight),
|
||||||
nameof(NextPreviewWidth),
|
nameof(NextPreviewWidth),
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
private const string PanseDemocraticPartyLabel = "더불어민주당";
|
private const string PanseDemocraticPartyLabel = "더불어민주당";
|
||||||
private const string PansePeoplePowerPartyLabel = "국민의힘";
|
private const string PansePeoplePowerPartyLabel = "국민의힘";
|
||||||
private const string PanseOtherPartyLabel = "무·기타";
|
private const string PanseOtherPartyLabel = "무·기타";
|
||||||
|
private const string SbsProbeElectionType = "광역단체장";
|
||||||
|
private const string MbcCniProbeElectionType = "광역의원";
|
||||||
public const int FixedPollingIntervalSeconds = 60;
|
public const int FixedPollingIntervalSeconds = 60;
|
||||||
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
||||||
private static readonly Regex PeopleSlotCountPattern = new(@"(\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 SbsElectionApiClient _apiClient;
|
||||||
private readonly PreElectionHistoryService _preElectionHistoryService;
|
private readonly PreElectionHistoryService _preElectionHistoryService;
|
||||||
private readonly CareerPromiseService _careerPromiseService;
|
private readonly CareerPromiseService _careerPromiseService;
|
||||||
|
private readonly SemaphoreSlim _dataSourceRefreshLock = new(1, 1);
|
||||||
private readonly Dictionary<string, string> _districtCodeMap = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, string> _districtCodeMap = new(StringComparer.Ordinal);
|
||||||
private readonly Dictionary<string, SbsElectionApiClient.DistrictSelectionOption> _districtOptionMap = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, SbsElectionApiClient.DistrictSelectionOption> _districtOptionMap = new(StringComparer.Ordinal);
|
||||||
private CancellationTokenSource? _pollingCts;
|
private CancellationTokenSource? _pollingCts;
|
||||||
@@ -150,6 +153,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
private bool _isApplyingRefreshResult;
|
private bool _isApplyingRefreshResult;
|
||||||
private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue;
|
private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue;
|
||||||
private DateTimeOffset _lastManualRefreshAt = 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 _electionType = "광역단체장";
|
||||||
private string _districtName = "부산광역시";
|
private string _districtName = "부산광역시";
|
||||||
private string _districtCode = "26";
|
private string _districtCode = "26";
|
||||||
@@ -332,6 +341,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
if (SetProperty(ref _broadcastPhase, value))
|
if (SetProperty(ref _broadcastPhase, value))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(StatusText), nameof(HasLiveDataSignal));
|
OnPropertyChanged(nameof(StatusText), nameof(HasLiveDataSignal));
|
||||||
|
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
|
||||||
NotifyModePresentationChanged();
|
NotifyModePresentationChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +655,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
|
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
|
||||||
OnPropertyChanged(nameof(HasLiveDataSignal));
|
OnPropertyChanged(nameof(HasLiveDataSignal));
|
||||||
|
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -678,10 +689,27 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(StatusText), nameof(LastRefreshDisplay));
|
OnPropertyChanged(nameof(StatusText), nameof(LastRefreshDisplay));
|
||||||
OnPropertyChanged(nameof(HasLiveDataSignal));
|
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
|
public string ElectionType
|
||||||
{
|
{
|
||||||
get => _electionType;
|
get => _electionType;
|
||||||
@@ -692,6 +720,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
if (SetProperty(ref _electionType, normalizedValue))
|
if (SetProperty(ref _electionType, normalizedValue))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(StatusText), nameof(CareerPromiseContextText), nameof(HasLiveDataSignal));
|
OnPropertyChanged(nameof(StatusText), nameof(CareerPromiseContextText), nameof(HasLiveDataSignal));
|
||||||
|
OnPropertyChanged(nameof(HasAnyLiveDataSignal));
|
||||||
NotifyModePresentationChanged();
|
NotifyModePresentationChanged();
|
||||||
RefreshPreElectionHistoryPresentation();
|
RefreshPreElectionHistoryPresentation();
|
||||||
_ = RefreshDistrictOptionsForElectionTypeAsync();
|
_ = RefreshDistrictOptionsForElectionTypeAsync();
|
||||||
@@ -911,6 +940,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
LastRefreshAt != DateTimeOffset.MinValue &&
|
LastRefreshAt != DateTimeOffset.MinValue &&
|
||||||
string.IsNullOrWhiteSpace(_lastRefreshWarningMessage));
|
string.IsNullOrWhiteSpace(_lastRefreshWarningMessage));
|
||||||
|
|
||||||
|
public bool HasAnyLiveDataSignal =>
|
||||||
|
HasLiveDataSignal ||
|
||||||
|
SbsDataSourceState == DataSourceConnectionState.Connected ||
|
||||||
|
MbcCniDataSourceState == DataSourceConnectionState.Connected;
|
||||||
|
|
||||||
public string PollingModeLabel
|
public string PollingModeLabel
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -984,6 +1018,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
}
|
}
|
||||||
|
|
||||||
_pollingCts = new CancellationTokenSource();
|
_pollingCts = new CancellationTokenSource();
|
||||||
|
_ = RefreshAllDataSourcesAsync(_pollingCts.Token);
|
||||||
_ = RunPollingLoopAsync(_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))
|
if (ShouldUseTurnoutPhotoRegionLevelOptions(template))
|
||||||
{
|
{
|
||||||
return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false);
|
return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -1159,9 +1200,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (IsByElectionTemplate(template) ||
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
|
||||||
IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
|
{
|
||||||
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));
|
regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
|
||||||
}
|
}
|
||||||
@@ -1769,10 +1822,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsNormalPanseMapTemplate(template))
|
if (IsNormalPanseMapTemplate(template) || IsNormalBasicMayorPanseTemplate(template))
|
||||||
{
|
{
|
||||||
return CreateNormalPanseMapScheduleSnapshotAsync(
|
return CreateNormalPanseMapScheduleSnapshotAsync(
|
||||||
electionType,
|
electionType,
|
||||||
|
template,
|
||||||
|
station,
|
||||||
regionTargets,
|
regionTargets,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -2481,6 +2536,218 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
_lastRefreshWarningMessage = normalized;
|
_lastRefreshWarningMessage = normalized;
|
||||||
OnPropertyChanged(nameof(StatusText), nameof(HasLiveDataSignal));
|
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()
|
public void Dispose()
|
||||||
@@ -2506,6 +2773,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
PollingCountdownSeconds = remainingSeconds;
|
PollingCountdownSeconds = remainingSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await RefreshAllDataSourcesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await RefreshAsync(isManualRequest: false);
|
await RefreshAsync(isManualRequest: false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -3676,6 +3944,25 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
FormatTemplateDefinition? template,
|
FormatTemplateDefinition? template,
|
||||||
CancellationToken cancellationToken)
|
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))
|
if (UsesHistoricalScheduleOptions(template))
|
||||||
{
|
{
|
||||||
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
|
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
|
||||||
@@ -4158,31 +4445,73 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다.");
|
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(
|
var refreshResults = await GetCountingSnapshotsForScheduleTargetsAsync(
|
||||||
electionType,
|
electionType,
|
||||||
selectedTargets,
|
selectedTargets,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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 totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
|
||||||
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
|
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
|
||||||
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
|
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
|
||||||
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 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 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
|
? selectedTargets[0].DisplayName
|
||||||
: regionName;
|
: 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);
|
var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName);
|
||||||
|
|
||||||
return new ElectionDataSnapshot
|
return new ElectionDataSnapshot
|
||||||
@@ -4192,14 +4521,14 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
DistrictName = districtName,
|
DistrictName = districtName,
|
||||||
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
|
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
|
||||||
RegionName = regionName,
|
RegionName = regionName,
|
||||||
ElectionDistrictName = selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
|
ElectionDistrictName = electionDistrictName,
|
||||||
Candidates = seatCandidates,
|
Candidates = seatCandidates,
|
||||||
TotalExpectedVotes = totalVotes,
|
TotalExpectedVotes = totalExpectedVotesForSnapshot,
|
||||||
TurnoutVotes = turnoutVotes,
|
TurnoutVotes = turnoutVotesForSnapshot,
|
||||||
CountedVotesFromApi = countedVotes,
|
CountedVotesFromApi = countedVotesForSnapshot,
|
||||||
RemainingVotesFromApi = remainingVotes,
|
RemainingVotesFromApi = remainingVotesForSnapshot,
|
||||||
CountedRateFromApi = countedRate,
|
CountedRateFromApi = countedRate,
|
||||||
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
|
ReceivedAt = receivedAt,
|
||||||
HistoricalTurnoutHistory = history.TurnoutHistory,
|
HistoricalTurnoutHistory = history.TurnoutHistory,
|
||||||
HistoricalWinnerHistory = history.WinnerHistory
|
HistoricalWinnerHistory = history.WinnerHistory
|
||||||
};
|
};
|
||||||
@@ -4379,12 +4708,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
private async Task<ElectionDataSnapshot> CreateNormalPanseMapScheduleSnapshotAsync(
|
private async Task<ElectionDataSnapshot> CreateNormalPanseMapScheduleSnapshotAsync(
|
||||||
string electionType,
|
string electionType,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
BroadcastStationProfile station,
|
||||||
IReadOnlyList<ScheduleRegionTarget> regionTargets,
|
IReadOnlyList<ScheduleRegionTarget> regionTargets,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var isBasicMayorPanse = IsNormalBasicMayorPanseTemplate(template);
|
||||||
|
var panseLabel = isBasicMayorPanse ? "기초단체장 판세" : "전국 판세 지도";
|
||||||
if (!SupportsApiDistrictOptions(electionType))
|
if (!SupportsApiDistrictOptions(electionType))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"{electionType} 전국 판세 지도는 현재 SBS API 연동 대상이 아닙니다.");
|
throw new InvalidOperationException($"{electionType} {panseLabel}는 현재 SBS API 연동 대상이 아닙니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedTargets = regionTargets
|
var selectedTargets = regionTargets
|
||||||
@@ -4394,7 +4727,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
if (selectedTargets.Length == 0)
|
if (selectedTargets.Length == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("전국 판세 지도 대상 선거구가 없습니다.");
|
throw new InvalidOperationException($"{panseLabel} 대상 선거구가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var districtOptions = selectedTargets
|
var districtOptions = selectedTargets
|
||||||
@@ -4410,7 +4743,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
if (refreshResults.Count == 0)
|
if (refreshResults.Count == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("전국 판세 지도용 개표 데이터가 없습니다.");
|
throw new InvalidOperationException($"{panseLabel}용 개표 데이터가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetMap = selectedTargets
|
var targetMap = selectedTargets
|
||||||
@@ -4427,7 +4760,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
if (leaders.Length == 0)
|
if (leaders.Length == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("전국 판세 지도에 반영할 1위 후보 데이터가 없습니다.");
|
throw new InvalidOperationException($"{panseLabel}에 반영할 1위 후보 데이터가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
|
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
|
var countedRate = totalVotes <= 0
|
||||||
? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max()
|
? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max()
|
||||||
: Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero);
|
: 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
|
return new ElectionDataSnapshot
|
||||||
{
|
{
|
||||||
BroadcastPhase = BroadcastPhase.Counting,
|
BroadcastPhase = BroadcastPhase.Counting,
|
||||||
ElectionType = electionType,
|
ElectionType = electionType,
|
||||||
DistrictName = "전국",
|
DistrictName = districtName,
|
||||||
DistrictCode = string.Empty,
|
DistrictCode = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
|
||||||
RegionName = "전국",
|
RegionName = regionName,
|
||||||
ElectionDistrictName = "전국",
|
ElectionDistrictName = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
|
||||||
Candidates = leaders,
|
Candidates = leaders,
|
||||||
TotalExpectedVotes = totalVotes,
|
TotalExpectedVotes = isBasicMayorPanse ? totalPlaces : totalVotes,
|
||||||
TurnoutVotes = turnoutVotes,
|
TurnoutVotes = isBasicMayorPanse ? totalPlaces : turnoutVotes,
|
||||||
CountedVotesFromApi = countedVotes,
|
CountedVotesFromApi = isBasicMayorPanse ? leaders.Length : countedVotes,
|
||||||
RemainingVotesFromApi = remainingVotes,
|
RemainingVotesFromApi = isBasicMayorPanse ? Math.Max(0, totalPlaces - leaders.Length) : remainingVotes,
|
||||||
CountedRateFromApi = countedRate,
|
CountedRateFromApi = countedRate,
|
||||||
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
|
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
|
||||||
HistoricalTurnoutHistory = history.TurnoutHistory,
|
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)));
|
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(
|
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(
|
||||||
string electionType,
|
string electionType,
|
||||||
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
||||||
@@ -5035,6 +5464,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
|
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)
|
private static bool IsPanseEducationTemplate(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal);
|
return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal);
|
||||||
@@ -5059,6 +5494,49 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName);
|
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)
|
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
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 ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
|
||||||
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
|
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 DataReceivingNavigationBrush = new SolidColorBrush(Colors.LimeGreen);
|
||||||
private static readonly Brush DataWaitingNavigationBrush = new SolidColorBrush(Colors.White);
|
private static readonly Brush DataWaitingNavigationBrush = new SolidColorBrush(Colors.White);
|
||||||
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
|
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
|
||||||
@@ -190,7 +198,13 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
nameof(BottomVisibility),
|
nameof(BottomVisibility),
|
||||||
nameof(VideoWallVisibility),
|
nameof(VideoWallVisibility),
|
||||||
nameof(HeaderStatus),
|
nameof(HeaderStatus),
|
||||||
|
nameof(IsCgConnected),
|
||||||
nameof(CgIntegrationSummary),
|
nameof(CgIntegrationSummary),
|
||||||
|
nameof(CgIntegrationBrush),
|
||||||
|
nameof(CgIntegrationCardBackgroundBrush),
|
||||||
|
nameof(CgIntegrationCardBorderBrush),
|
||||||
|
nameof(CgIntegrationSignalText),
|
||||||
|
nameof(CgIntegrationOperatorMessage),
|
||||||
nameof(CgIntegrationDetail),
|
nameof(CgIntegrationDetail),
|
||||||
nameof(TornadoConnectionSummary),
|
nameof(TornadoConnectionSummary),
|
||||||
nameof(TornadoConnectionDetail));
|
nameof(TornadoConnectionDetail));
|
||||||
@@ -408,10 +422,38 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
|
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
|
? DataReceivingNavigationBrush
|
||||||
: DataWaitingNavigationBrush;
|
: 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
|
public string CgIntegrationDetail
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -504,6 +546,51 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
public string HeaderStatus => $"{Settings.SelectedStation.Name} / {CurrentPageTitle} / {Data.BroadcastPhaseBadgeText} / {OperationModeLabel}";
|
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)
|
public void Navigate(string tag)
|
||||||
{
|
{
|
||||||
var targetPage = tag switch
|
var targetPage = tag switch
|
||||||
@@ -792,11 +879,29 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(HeaderStatus));
|
OnPropertyChanged(nameof(HeaderStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.PropertyName is nameof(DataViewModel.HasLiveDataSignal))
|
if (args.PropertyName is nameof(DataViewModel.HasAnyLiveDataSignal))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(DataNavigationIconBrush));
|
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)
|
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
|
||||||
or nameof(DataViewModel.BroadcastPhase)
|
or nameof(DataViewModel.BroadcastPhase)
|
||||||
or nameof(DataViewModel.ElectionType)
|
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)
|
private void RestoreSelection_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
QueueAutomaticSave();
|
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)
|
private void Channel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.PropertyName is nameof(ChannelScheduleViewModel.LoopEnabled)
|
if (e.PropertyName is nameof(ChannelScheduleViewModel.LoopEnabled)
|
||||||
@@ -829,13 +965,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
or nameof(ChannelScheduleViewModel.AdapterStateLabel)
|
or nameof(ChannelScheduleViewModel.AdapterStateLabel)
|
||||||
or nameof(ChannelScheduleViewModel.IsCgConnected))
|
or nameof(ChannelScheduleViewModel.IsCgConnected))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(
|
NotifyCgConnectionStatusChanged();
|
||||||
nameof(IsCgConnected),
|
|
||||||
nameof(CgIntegrationSummary),
|
|
||||||
nameof(CgIntegrationBrush),
|
|
||||||
nameof(CgIntegrationDetail),
|
|
||||||
nameof(TornadoConnectionSummary),
|
|
||||||
nameof(TornadoConnectionDetail));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,6 +1399,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
private void OnCutDurationChanged(FormatTemplateDefinition template)
|
private void OnCutDurationChanged(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
SyncQueuedCutDurations(template);
|
SyncQueuedCutDurations(template);
|
||||||
|
RefreshSelectedFormatDurations(template);
|
||||||
QueueAutomaticSave();
|
QueueAutomaticSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1311,6 +1442,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
RefreshCutListEntries(template);
|
RefreshCutListEntries(template);
|
||||||
SyncQueuedCutDurations(template);
|
SyncQueuedCutDurations(template);
|
||||||
|
RefreshSelectedFormatDurations(template);
|
||||||
QueueAutomaticSave();
|
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)
|
private void ApplyCutDurations(IReadOnlyDictionary<string, double>? durations)
|
||||||
{
|
{
|
||||||
if (durations is null || durations.Count == 0)
|
if (durations is null || durations.Count == 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user