1554 lines
61 KiB
C#
1554 lines
61 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Collections.Specialized;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.UI;
|
|
using Microsoft.UI.Xaml;
|
|
using Microsoft.UI.Xaml.Media;
|
|
using Tornado3_2026Election.Common;
|
|
using Tornado3_2026Election.Domain;
|
|
using Tornado3_2026Election.Services;
|
|
|
|
namespace Tornado3_2026Election.ViewModels;
|
|
|
|
public sealed class ChannelScheduleViewModel : ObservableObject
|
|
{
|
|
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\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 PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216));
|
|
private static readonly Brush DataIssueBackgroundBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 57, 38, 16));
|
|
private static readonly Brush DataIssueBorderBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 184, 28));
|
|
private readonly ChannelScheduleEngine _engine;
|
|
private readonly ITornado3Adapter _adapter;
|
|
private readonly CutDebugStateStore _cutDebugStateStore;
|
|
private readonly DataViewModel _data;
|
|
private readonly LogService _logService;
|
|
private readonly ObservableCollection<CutDebugItemState> _emptyCutDebugItems = [];
|
|
private IReadOnlyList<FormatTemplateDefinition> _allFormats;
|
|
private SelectionOption<CutCategory?>? _selectedFormatCategoryOption;
|
|
private SelectionOption<string>? _selectedTurnoutRegionModeOption;
|
|
private FormatTemplateDefinition? _selectedFormat;
|
|
private CutDebugTemplateState? _selectedCutDebugTemplate;
|
|
private ScheduleRegionOption? _selectedRegionOption;
|
|
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
|
|
private CancellationTokenSource? _directPlaybackCts;
|
|
private ChannelScheduleItem? _preparedDirectItem;
|
|
private string _preparedDirectFormatId = string.Empty;
|
|
private string _preparedDirectRegionKey = string.Empty;
|
|
private bool _loopEnabled;
|
|
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
|
private int _regionOptionsRevision;
|
|
private string _lastRegionOptionFormatId = string.Empty;
|
|
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
|
|
private double _selectedFormatThumbnailWidth = 320;
|
|
private double _selectedFormatThumbnailHeight = 180;
|
|
private double _selectedFormatDraftDurationSeconds;
|
|
|
|
public ChannelScheduleViewModel(
|
|
BroadcastChannel channel,
|
|
string title,
|
|
IReadOnlyList<FormatTemplateDefinition> formats,
|
|
DataViewModel data,
|
|
ITornado3Adapter adapter,
|
|
CutDebugSettings cutDebug,
|
|
CutDebugStateStore cutDebugStateStore,
|
|
ChannelScheduleEngine engine,
|
|
LogService logService)
|
|
{
|
|
Channel = channel;
|
|
Title = title;
|
|
_data = data;
|
|
_adapter = adapter;
|
|
CutDebug = cutDebug;
|
|
_cutDebugStateStore = cutDebugStateStore;
|
|
_engine = engine;
|
|
_logService = logService;
|
|
_allFormats = formats.ToArray();
|
|
FormatCategoryOptions = [];
|
|
TurnoutRegionModeOptions =
|
|
[
|
|
new SelectionOption<string>(DataViewModel.TurnoutPhotoSidoMode, "시도별 투표율"),
|
|
new SelectionOption<string>(DataViewModel.TurnoutPhotoDistrictMode, "선거구별 투표율")
|
|
];
|
|
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
|
|
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
|
|
RegionOptions = new ObservableCollection<ScheduleRegionOption>();
|
|
EmptyBehaviorOptions =
|
|
[
|
|
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
|
|
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.HoldLastFrame, "마지막 프레임 유지")
|
|
];
|
|
Queue = engine.Queue;
|
|
|
|
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
|
|
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
|
|
StopCommand = new AsyncRelayCommand(StopAsync);
|
|
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
|
|
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
|
|
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
|
|
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
|
|
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
|
|
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
|
|
ResetQueueCommand = new RelayCommand(ResetQueue);
|
|
RetryDataIssueCommand = new RelayCommand<ChannelScheduleItem>(RetryDataIssue, CanRetryDataIssue);
|
|
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
|
|
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
|
|
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
|
|
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
|
|
IncreaseSelectedFormatDurationCommand = new RelayCommand(IncreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
|
|
DecreaseSelectedFormatDurationCommand = new RelayCommand(DecreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
|
|
ApplySelectedFormatDurationCommand = new RelayCommand(ApplySelectedFormatDuration, CanApplySelectedFormatDuration);
|
|
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
|
|
|
|
_engine.QueueChanged += (_, _) => RefreshSummary();
|
|
_adapter.StateChanged += (_, _) => RefreshSummary();
|
|
_adapter.ConnectionChanged += (_, _) => RefreshSummary();
|
|
CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary), nameof(CutDebugPanelVisibility));
|
|
_data.PropertyChanged += Data_PropertyChanged;
|
|
Queue.CollectionChanged += Queue_CollectionChanged;
|
|
|
|
RebuildFormatCategoryOptions();
|
|
RebuildAvailableFormats();
|
|
_ = RebuildRegionOptionsAsync();
|
|
UpdateSelectedFormatThumbnailMetrics();
|
|
ApplyQueueThumbnailLayouts();
|
|
RefreshSummary();
|
|
}
|
|
|
|
public BroadcastChannel Channel { get; }
|
|
|
|
public string Title { get; }
|
|
|
|
public bool IsLiveCg => _adapter.IsLiveCg;
|
|
|
|
public bool IsCgConnected => _adapter.IsConnected;
|
|
|
|
public string CgBackendName => _adapter.BackendName;
|
|
|
|
public string CgConnectionTarget => _adapter.ConnectionTarget;
|
|
|
|
public string CgStatusSummary => IsLiveCg
|
|
? $"{(IsCgConnected ? "Connected" : "Disconnected")} / {CgBackendName} / {CgConnectionTarget}"
|
|
: $"Disconnected / {CgBackendName} / {CgConnectionTarget}";
|
|
|
|
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
|
|
|
|
public ObservableCollection<SelectionOption<CutCategory?>> FormatCategoryOptions { get; }
|
|
|
|
public IReadOnlyList<SelectionOption<string>> TurnoutRegionModeOptions { get; }
|
|
|
|
public ObservableCollection<ScheduleRegionOption> RegionOptions { get; }
|
|
|
|
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
|
|
|
|
public ObservableCollection<ChannelScheduleItem> Queue { get; }
|
|
|
|
public AsyncRelayCommand SchedulePrepareCommand { get; }
|
|
|
|
public AsyncRelayCommand StartCommand { get; }
|
|
|
|
public AsyncRelayCommand StopCommand { get; }
|
|
|
|
public AsyncRelayCommand DirectPrepareCommand { get; }
|
|
|
|
public AsyncRelayCommand DirectStartCommand { get; }
|
|
|
|
public AsyncRelayCommand DirectStopCommand { get; }
|
|
|
|
public AsyncRelayCommand ForceNextCommand { get; }
|
|
|
|
public AsyncRelayCommand ForceQueueNextCommand { get; }
|
|
|
|
public CutDebugSettings CutDebug { get; }
|
|
|
|
public RelayCommand AddFormatCommand { get; }
|
|
|
|
public RelayCommand ResetQueueCommand { get; }
|
|
|
|
public RelayCommand<ChannelScheduleItem> RetryDataIssueCommand { get; }
|
|
|
|
public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; }
|
|
|
|
public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; }
|
|
|
|
public RelayCommand<ChannelScheduleItem> MoveDownCommand { get; }
|
|
|
|
public RelayCommand<ChannelScheduleItem> PromoteToNextCommand { get; }
|
|
|
|
public RelayCommand IncreaseSelectedFormatDurationCommand { get; }
|
|
|
|
public RelayCommand DecreaseSelectedFormatDurationCommand { get; }
|
|
|
|
public RelayCommand ApplySelectedFormatDurationCommand { get; }
|
|
|
|
public event EventHandler<FormatTemplateDefinition>? FormatDurationChanged;
|
|
|
|
public SelectionOption<CutCategory?>? SelectedFormatCategoryOption
|
|
{
|
|
get => _selectedFormatCategoryOption;
|
|
set
|
|
{
|
|
if (value is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (SetProperty(ref _selectedFormatCategoryOption, value))
|
|
{
|
|
RebuildAvailableFormats();
|
|
_ = RebuildRegionOptionsAsync();
|
|
RefreshSummary();
|
|
}
|
|
}
|
|
}
|
|
|
|
public SelectionOption<string>? SelectedTurnoutRegionModeOption
|
|
{
|
|
get => _selectedTurnoutRegionModeOption;
|
|
set
|
|
{
|
|
if (value is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (SetProperty(ref _selectedTurnoutRegionModeOption, value))
|
|
{
|
|
_ = RebuildRegionOptionsAsync();
|
|
AddFormatCommand.NotifyCanExecuteChanged();
|
|
RefreshSummary();
|
|
}
|
|
}
|
|
}
|
|
|
|
public Visibility TurnoutRegionModeVisibility =>
|
|
UsesTurnoutRegionMode(SelectedFormat) ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public FormatTemplateDefinition? SelectedFormat
|
|
{
|
|
get => _selectedFormat;
|
|
set
|
|
{
|
|
if (SetProperty(ref _selectedFormat, value))
|
|
{
|
|
EnsureTurnoutRegionModeSelection();
|
|
ResetSelectedFormatDurationDraft();
|
|
OnPropertyChanged(nameof(TurnoutRegionModeVisibility));
|
|
NotifySelectedFormatPreviewChanged();
|
|
SyncSelectedCutDebugTemplate();
|
|
_ = RebuildRegionOptionsAsync();
|
|
AddFormatCommand.NotifyCanExecuteChanged();
|
|
DirectPrepareCommand.NotifyCanExecuteChanged();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
public ScheduleRegionOption? SelectedRegionOption
|
|
{
|
|
get => _selectedRegionOption;
|
|
set
|
|
{
|
|
if (SetProperty(ref _selectedRegionOption, value))
|
|
{
|
|
AddFormatCommand.NotifyCanExecuteChanged();
|
|
DirectPrepareCommand.NotifyCanExecuteChanged();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void EnsureTurnoutRegionModeSelection()
|
|
{
|
|
if (!UsesTurnoutRegionMode(SelectedFormat))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_selectedTurnoutRegionModeOption is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
|
|
OnPropertyChanged(nameof(SelectedTurnoutRegionModeOption));
|
|
}
|
|
|
|
private static bool UsesTurnoutRegionMode(FormatTemplateDefinition? format)
|
|
{
|
|
return format is not null &&
|
|
string.Equals(format.Name, "투표율_사진", StringComparison.Ordinal);
|
|
}
|
|
|
|
public bool LoopEnabled
|
|
{
|
|
get => _loopEnabled;
|
|
set
|
|
{
|
|
if (SetProperty(ref _loopEnabled, value))
|
|
{
|
|
_engine.LoopEnabled = value;
|
|
_engine.RefreshQueueMarkers();
|
|
RefreshSummary();
|
|
}
|
|
}
|
|
}
|
|
|
|
public EmptyScheduleBehavior EmptyScheduleBehavior
|
|
{
|
|
get => _emptyScheduleBehavior;
|
|
set
|
|
{
|
|
if (SetProperty(ref _emptyScheduleBehavior, value))
|
|
{
|
|
_engine.EmptyScheduleBehavior = value;
|
|
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
public SelectionOption<EmptyScheduleBehavior>? SelectedEmptyBehaviorOption
|
|
{
|
|
get => _selectedEmptyBehaviorOption;
|
|
set
|
|
{
|
|
if (value is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (SetProperty(ref _selectedEmptyBehaviorOption, value) && _emptyScheduleBehavior != value.Value)
|
|
{
|
|
EmptyScheduleBehavior = value.Value;
|
|
}
|
|
}
|
|
}
|
|
|
|
public TornadoConnectionState AdapterState => _adapter.State;
|
|
|
|
public string AdapterStateLabel => AdapterState switch
|
|
{
|
|
TornadoConnectionState.Idle => "대기",
|
|
TornadoConnectionState.Ready => "준비 완료",
|
|
TornadoConnectionState.Sending => "전송 중",
|
|
TornadoConnectionState.OnAir => "송출 중",
|
|
TornadoConnectionState.Error => "오류",
|
|
_ => "대기"
|
|
};
|
|
|
|
public string AdapterStateText => $"Tornado 상태: {AdapterStateLabel}";
|
|
|
|
public bool IsPlaying => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
|
|
|| _engine.ActivePlaybackItem is not null;
|
|
|
|
public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush;
|
|
|
|
public string TransmissionLabel => _engine.ActivePlaybackItem?.State == ScheduleQueueItemState.Sending
|
|
? "준비"
|
|
: IsPlaying
|
|
? "송출 중"
|
|
: "대기";
|
|
|
|
public string CurrentItemName => CurrentPlaybackItem?.DisplayName ?? "대기 화면";
|
|
|
|
public string NextItemName => InternalNextPlaybackItem?.InternalNextPreviewDisplayName
|
|
?? QueueNextPlaybackItem?.DisplayName
|
|
?? "다음 컷 없음";
|
|
|
|
public ImageSource? CurrentPreviewSource => CurrentPlaybackItem?.PreviewSource;
|
|
|
|
public ImageSource? NextPreviewSource => InternalNextPlaybackItem?.InternalNextPreviewSource
|
|
?? QueueNextPlaybackItem?.PreviewSource;
|
|
|
|
public string CurrentPreviewStatusLabel => CurrentPlaybackItem?.PreviewStatusLabel ?? "송출 중인 컷 없음";
|
|
|
|
public string NextPreviewStatusLabel => InternalNextPlaybackItem?.InternalNextPreviewStatusLabel
|
|
?? QueueNextPlaybackItem?.PreviewStatusLabel
|
|
?? "다음 컷 없음";
|
|
|
|
public Visibility PlaybackCountdownVisibility =>
|
|
CurrentPlaybackItem?.PlaybackCountdownVisibility ?? Visibility.Collapsed;
|
|
|
|
public string PlaybackCountdownText => CurrentPlaybackItem?.PlaybackCountdownText ?? "대기 중";
|
|
|
|
public string PlaybackCountdownDetail => CurrentPlaybackItem?.PlaybackCountdownDetail ?? string.Empty;
|
|
|
|
public double PlaybackCountdownProgress => CurrentPlaybackItem?.PlaybackCountdownProgress ?? 0d;
|
|
|
|
public double CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
|
|
|
|
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
|
|
|
|
public double NextPreviewWidth => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Width;
|
|
|
|
public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height;
|
|
|
|
public int QueuedItemCount => Queue.Count;
|
|
|
|
public string QueueFootnote => $"목록 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
|
|
|
|
public string QueueSummary
|
|
{
|
|
get
|
|
{
|
|
var current = CurrentPlaybackItem?.DisplayName ?? "-";
|
|
var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
|
|
?? QueueNextPlaybackItem?.DisplayName
|
|
?? "-";
|
|
return $"현재 {current} / 다음 {next} / 목록 {Queue.Count}건";
|
|
}
|
|
}
|
|
|
|
public string LoopSummary => LoopEnabled ? "반복 재생" : "1회 재생";
|
|
|
|
public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";
|
|
|
|
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
|
|
|
|
public Visibility ScheduleDataIssueVisibility =>
|
|
LatestScheduleDataIssueItem is null ? Visibility.Collapsed : Visibility.Visible;
|
|
|
|
public Brush ScheduleDataIssueBackgroundBrush => DataIssueBackgroundBrushValue;
|
|
|
|
public Brush ScheduleDataIssueBorderBrush => DataIssueBorderBrushValue;
|
|
|
|
public string ScheduleDataIssueTitle => LatestScheduleDataIssueItem is { } item
|
|
? $"{item.FormatName} 송출 보류"
|
|
: string.Empty;
|
|
|
|
public string ScheduleDataIssueMessage => LatestScheduleDataIssueItem?.IssueDetail ?? string.Empty;
|
|
|
|
public string ScheduleDataIssueDetail => LatestScheduleDataIssueItem is { } item
|
|
? $"{item.DisplayRegionLabel} / {item.LastIssueLabel}"
|
|
: string.Empty;
|
|
|
|
public string ScheduleDataIssueHint => LatestScheduleDataIssueItem is null
|
|
? string.Empty
|
|
: "시스템 오류가 아니라 현재 조건 데이터가 부족한 상태입니다. 데이터가 들어오면 항목의 다시 확인을 눌러 스케줄 대상에 복귀시킬 수 있습니다.";
|
|
|
|
public string SelectedFormatName => SelectedFormat?.Name ?? "컷을 선택하세요";
|
|
|
|
public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";
|
|
|
|
public string SelectedFormatPath => SelectedFormat?.Id.Replace('\\', '/') ?? string.Empty;
|
|
|
|
public string SelectedFormatThumbnailStatus => SelectedFormat is null
|
|
? string.Empty
|
|
: CutThumbnailAssetCatalog.HasThumbnail(SelectedFormat)
|
|
? "등록된 썸네일을 표시 중"
|
|
: "썸네일이 없어 기본 아이콘을 표시 중";
|
|
|
|
public double SelectedFormatMinimumDurationSeconds => SelectedFormat is null
|
|
? 1d
|
|
: ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(SelectedFormat);
|
|
|
|
public double SelectedFormatDraftDurationSeconds
|
|
{
|
|
get => _selectedFormatDraftDurationSeconds;
|
|
set
|
|
{
|
|
var normalized = SelectedFormat is null
|
|
? Math.Max(1d, Math.Round(value, 1, MidpointRounding.AwayFromZero))
|
|
: ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, SelectedFormat);
|
|
if (SetProperty(ref _selectedFormatDraftDurationSeconds, normalized))
|
|
{
|
|
NotifySelectedFormatDurationStateChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool HasSelectedFormatDurationChange => SelectedFormat is not null &&
|
|
Math.Abs(SelectedFormatDraftDurationSeconds - ResolveSelectedFormatDurationSeconds(SelectedFormat)) >= 0.001d;
|
|
|
|
public string SelectedFormatDurationStatusLabel => HasSelectedFormatDurationChange ? "미적용" : "적용됨";
|
|
|
|
public string CutDebugSummary => CutDebug.Summary;
|
|
|
|
public Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public ObservableCollection<CutDebugItemState> CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems;
|
|
|
|
public int CutDebugItemCount => CutDebugItems.Count;
|
|
|
|
public string CutDebugTextTargets => BuildCutDebugTextTargets(SelectedFormat);
|
|
|
|
public string CutDebugImageTargets => BuildCutDebugImageTargets(SelectedFormat);
|
|
|
|
public string CutDebugVisibilityTargets => BuildCutDebugVisibilityTargets(SelectedFormat);
|
|
|
|
public string CutDebugVoteRateTextTargets => BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat));
|
|
|
|
public string CutDebugVoteRateCounterTargets => $"{BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat))} key#1";
|
|
|
|
public string CutDebugPartyBarColorTargets => JoinTargets(
|
|
BuildIndexedTargetRange("기호", ResolveCutDebugSlotCount(SelectedFormat)),
|
|
BuildIndexedTargetRange("기호텍스트", ResolveCutDebugSlotCount(SelectedFormat)),
|
|
BuildIndexedTargetRange("득표수바", ResolveCutDebugSlotCount(SelectedFormat)),
|
|
BuildIndexedTargetRange("정당바", ResolveCutDebugSlotCount(SelectedFormat)));
|
|
|
|
public string CutDebugPartyPlateColorTargets => JoinTargets(
|
|
BuildIndexedTargetRange("정당판", ResolveCutDebugSlotCount(SelectedFormat)),
|
|
BuildIndexedTargetRange("정당원", ResolveCutDebugSlotCount(SelectedFormat)),
|
|
BuildIndexedTargetRange("정당색", ResolveCutDebugSlotCount(SelectedFormat)),
|
|
BuildIndexedTargetRange("정당명", ResolveCutDebugSlotCount(SelectedFormat)));
|
|
|
|
public string CutDebugVoteRateColorTargets => $"{BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat))} edge/face";
|
|
|
|
public ImageSource? SelectedFormatThumbnailSource => SelectedFormat is null
|
|
? null
|
|
: CutThumbnailAssetCatalog.CreateImageSource(SelectedFormat);
|
|
|
|
public double SelectedFormatThumbnailWidth => _selectedFormatThumbnailWidth;
|
|
|
|
public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight;
|
|
|
|
private ChannelScheduleItem? CurrentPlaybackItem =>
|
|
_engine.ActivePlaybackItem;
|
|
|
|
private ChannelScheduleItem? InternalNextPlaybackItem =>
|
|
CurrentPlaybackItem is { HasInternalNextPreview: true } item ? item : null;
|
|
|
|
private ChannelScheduleItem? QueueNextPlaybackItem =>
|
|
Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next);
|
|
|
|
private ChannelScheduleItem? NextPlaybackItem =>
|
|
InternalNextPlaybackItem ?? QueueNextPlaybackItem;
|
|
|
|
private ChannelScheduleItem? LatestScheduleDataIssueItem =>
|
|
Queue
|
|
.Where(item => item.State == ScheduleQueueItemState.DataUnavailable)
|
|
.OrderByDescending(item => item.LastIssueAt ?? DateTimeOffset.MinValue)
|
|
.FirstOrDefault();
|
|
|
|
public async Task RefreshRegionOptionsAsync()
|
|
{
|
|
await RebuildRegionOptionsAsync();
|
|
}
|
|
|
|
public void RefreshSelectedFormatPreview()
|
|
{
|
|
NotifySelectedFormatPreviewChanged();
|
|
}
|
|
|
|
public void RefreshQueueThumbnails()
|
|
{
|
|
foreach (var item in Queue)
|
|
{
|
|
item.RefreshThumbnail();
|
|
}
|
|
}
|
|
|
|
public void UpdateFormats(IReadOnlyList<FormatTemplateDefinition> formats)
|
|
{
|
|
_allFormats = formats.ToArray();
|
|
RebuildFormatCategoryOptions();
|
|
RebuildAvailableFormats();
|
|
_ = RebuildRegionOptionsAsync();
|
|
ApplyQueueThumbnailLayouts();
|
|
RefreshSummary();
|
|
}
|
|
|
|
public void UpdateVideoWallLayoutPreset(VideoWallLayoutPreset videoWallLayoutPreset)
|
|
{
|
|
if (_videoWallLayoutPreset == videoWallLayoutPreset)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_videoWallLayoutPreset = videoWallLayoutPreset;
|
|
UpdateSelectedFormatThumbnailMetrics();
|
|
ApplyQueueThumbnailLayouts();
|
|
NotifyPlaybackPreviewChanged();
|
|
}
|
|
|
|
private async Task StartAsync()
|
|
{
|
|
await _engine.StartAsync().ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 스케줄 시작");
|
|
}
|
|
|
|
private async Task PrepareScheduleAsync()
|
|
{
|
|
try
|
|
{
|
|
await _engine.PrepareNextAsync(CancellationToken.None).ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 스케줄 다음 컷 준비");
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
RefreshSummary();
|
|
_logService.Error($"[{Title}] Schedule prepare failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private async Task StopAsync()
|
|
{
|
|
await _engine.StopAsync().ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 스케줄 정지");
|
|
}
|
|
|
|
private async Task DirectPrepareAsync()
|
|
{
|
|
var selectedFormat = SelectedFormat;
|
|
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
|
if (selectedFormat is null || regionOption is null)
|
|
{
|
|
_logService.Warning($"[{Title}] 바로 송출 준비할 컷과 지역을 먼저 선택해 주세요.");
|
|
return;
|
|
}
|
|
|
|
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
|
_directPlaybackCts?.Cancel();
|
|
_directPlaybackCts?.Dispose();
|
|
|
|
var prepareCts = new CancellationTokenSource();
|
|
_directPlaybackCts = prepareCts;
|
|
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
|
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
|
selectedFormat,
|
|
_videoWallLayoutPreset,
|
|
ThumbnailDisplayContext.Queue));
|
|
_preparedDirectItem = item;
|
|
_preparedDirectFormatId = selectedFormat.Id;
|
|
_preparedDirectRegionKey = BuildRegionOptionKey(regionOption);
|
|
|
|
try
|
|
{
|
|
_logService.Info($"[{Title}] 선택 컷 준비: {selectedFormat.Name} / {regionOption.Label}");
|
|
await _engine.PrepareDirectAsync(item, selectedFormat, prepareCts.Token).ConfigureAwait(false);
|
|
if (!prepareCts.IsCancellationRequested)
|
|
{
|
|
_logService.Info($"[{Title}] 선택 컷 준비 완료: {selectedFormat.Name}");
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
ClearPreparedDirectState(item);
|
|
_logService.Error($"[{Title}] 선택 컷 준비 실패: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
if (ReferenceEquals(_directPlaybackCts, prepareCts))
|
|
{
|
|
_directPlaybackCts = null;
|
|
}
|
|
|
|
prepareCts.Dispose();
|
|
RefreshSummary();
|
|
}
|
|
}
|
|
|
|
private async Task DirectStartAsync()
|
|
{
|
|
var selectedFormat = SelectedFormat;
|
|
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
|
if (selectedFormat is null || regionOption is null)
|
|
{
|
|
_logService.Warning($"[{Title}] 바로 송출할 컷과 지역을 먼저 선택해 주세요.");
|
|
return;
|
|
}
|
|
|
|
var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
|
|
if (preparedItem is null)
|
|
{
|
|
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
|
_directPlaybackCts?.Cancel();
|
|
_directPlaybackCts?.Dispose();
|
|
}
|
|
|
|
var playbackCts = new CancellationTokenSource();
|
|
_directPlaybackCts = playbackCts;
|
|
var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
|
if (preparedItem is null)
|
|
{
|
|
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
|
selectedFormat,
|
|
_videoWallLayoutPreset,
|
|
ThumbnailDisplayContext.Queue));
|
|
}
|
|
|
|
try
|
|
{
|
|
_logService.Info($"[{Title}] 선택 컷 바로 송출: {selectedFormat.Name} / {regionOption.Label}");
|
|
await _engine.PlayDirectAsync(item, selectedFormat, playbackCts.Token).ConfigureAwait(false);
|
|
if (!playbackCts.IsCancellationRequested)
|
|
{
|
|
_logService.Info($"[{Title}] 선택 컷 바로 송출 완료: {selectedFormat.Name}");
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logService.Error($"[{Title}] 선택 컷 바로 송출 실패: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
ClearPreparedDirectState(item);
|
|
if (ReferenceEquals(_directPlaybackCts, playbackCts))
|
|
{
|
|
_directPlaybackCts = null;
|
|
}
|
|
|
|
playbackCts.Dispose();
|
|
RefreshSummary();
|
|
}
|
|
}
|
|
|
|
private async Task DirectStopAsync()
|
|
{
|
|
_directPlaybackCts?.Cancel();
|
|
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
|
ClearPreparedDirectState(_preparedDirectItem);
|
|
_engine.ClearDirectPlayback();
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 선택 컷 송출 정지");
|
|
}
|
|
|
|
private ChannelScheduleItem? ResolvePreparedDirectItem(
|
|
FormatTemplateDefinition selectedFormat,
|
|
ScheduleRegionOption regionOption)
|
|
{
|
|
if (_preparedDirectItem is null ||
|
|
!_engine.IsPreparedItem(_preparedDirectItem) ||
|
|
!string.Equals(_preparedDirectFormatId, selectedFormat.Id, StringComparison.Ordinal) ||
|
|
!string.Equals(_preparedDirectRegionKey, BuildRegionOptionKey(regionOption), StringComparison.Ordinal))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return _preparedDirectItem;
|
|
}
|
|
|
|
private void ClearPreparedDirectState(ChannelScheduleItem? item)
|
|
{
|
|
if (item is not null && !ReferenceEquals(_preparedDirectItem, item))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_preparedDirectItem = null;
|
|
_preparedDirectFormatId = string.Empty;
|
|
_preparedDirectRegionKey = string.Empty;
|
|
}
|
|
|
|
private static string BuildRegionOptionKey(ScheduleRegionOption regionOption)
|
|
{
|
|
return string.Join(
|
|
"\u001F",
|
|
regionOption.Scope,
|
|
regionOption.ElectionType ?? string.Empty,
|
|
regionOption.Label ?? string.Empty,
|
|
regionOption.DistrictCode ?? string.Empty);
|
|
}
|
|
|
|
private async Task ForceNextAsync()
|
|
{
|
|
await _engine.ForceNextAsync().ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 현재 컷을 강제로 전환");
|
|
}
|
|
|
|
private async Task ForceQueueNextAsync()
|
|
{
|
|
await _engine.ForceQueueNextAsync().ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 다음 순서를 즉시 송출");
|
|
}
|
|
|
|
private void AddFormat()
|
|
{
|
|
if (SelectedFormat is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
|
if (regionOption is null)
|
|
{
|
|
_logService.Warning($"[{Title}] 선택 가능한 지역 정보가 아직 준비되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
var item = ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption);
|
|
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue));
|
|
_engine.Enqueue(item);
|
|
RefreshSummary();
|
|
}
|
|
|
|
private void ResetQueue()
|
|
{
|
|
_engine.Reset();
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
|
|
}
|
|
|
|
private void RetryDataIssue(ChannelScheduleItem? item)
|
|
{
|
|
if (!_engine.RetryDataUnavailable(item))
|
|
{
|
|
return;
|
|
}
|
|
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 데이터 없음 항목을 다시 스케줄 대상으로 전환: {item?.DisplayName}");
|
|
}
|
|
|
|
private static bool CanRetryDataIssue(ChannelScheduleItem? item)
|
|
{
|
|
return item?.State == ScheduleQueueItemState.DataUnavailable;
|
|
}
|
|
|
|
private void RemoveItem(ChannelScheduleItem? item)
|
|
{
|
|
if (!_engine.Remove(item))
|
|
{
|
|
_logService.Warning($"[{Title}] 송출 중 항목은 제거할 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
RefreshSummary();
|
|
}
|
|
|
|
private void MoveUp(ChannelScheduleItem? item)
|
|
{
|
|
_engine.MoveUp(item);
|
|
RefreshSummary();
|
|
}
|
|
|
|
private void MoveDown(ChannelScheduleItem? item)
|
|
{
|
|
_engine.MoveDown(item);
|
|
RefreshSummary();
|
|
}
|
|
|
|
private void PromoteToNext(ChannelScheduleItem? item)
|
|
{
|
|
_engine.PromoteToNext(item);
|
|
RefreshSummary();
|
|
}
|
|
|
|
private void IncreaseSelectedFormatDuration()
|
|
{
|
|
SelectedFormatDraftDurationSeconds += 1d;
|
|
}
|
|
|
|
private void DecreaseSelectedFormatDuration()
|
|
{
|
|
SelectedFormatDraftDurationSeconds -= 1d;
|
|
}
|
|
|
|
private void ApplySelectedFormatDuration()
|
|
{
|
|
var selectedFormat = SelectedFormat;
|
|
if (selectedFormat is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(SelectedFormatDraftDurationSeconds, selectedFormat);
|
|
var changed = false;
|
|
foreach (var cut in selectedFormat.Cuts)
|
|
{
|
|
if (Math.Abs(cut.DurationSeconds - normalized) < 0.001d)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
cut.DurationSeconds = normalized;
|
|
changed = true;
|
|
}
|
|
|
|
SelectedFormatDraftDurationSeconds = normalized;
|
|
NotifySelectedFormatDurationStateChanged();
|
|
if (changed)
|
|
{
|
|
FormatDurationChanged?.Invoke(this, selectedFormat);
|
|
_logService.Info($"[{Title}] 컷 송출 시간 적용: {selectedFormat.Name} {normalized:0.#}초");
|
|
}
|
|
}
|
|
|
|
private bool CanAdjustSelectedFormatDuration()
|
|
{
|
|
return SelectedFormat is not null;
|
|
}
|
|
|
|
private bool CanApplySelectedFormatDuration()
|
|
{
|
|
return HasSelectedFormatDurationChange;
|
|
}
|
|
|
|
public void RefreshSummary()
|
|
{
|
|
_engine.RefreshQueueMarkers();
|
|
OnPropertyChanged(
|
|
nameof(AdapterState),
|
|
nameof(AdapterStateText),
|
|
nameof(AdapterStateLabel),
|
|
nameof(IsPlaying),
|
|
nameof(PlaybackIconBrush),
|
|
nameof(TransmissionLabel),
|
|
nameof(CurrentItemName),
|
|
nameof(NextItemName),
|
|
nameof(CurrentPreviewSource),
|
|
nameof(NextPreviewSource),
|
|
nameof(CurrentPreviewStatusLabel),
|
|
nameof(NextPreviewStatusLabel),
|
|
nameof(PlaybackCountdownVisibility),
|
|
nameof(PlaybackCountdownText),
|
|
nameof(PlaybackCountdownDetail),
|
|
nameof(PlaybackCountdownProgress),
|
|
nameof(CurrentPreviewWidth),
|
|
nameof(CurrentPreviewHeight),
|
|
nameof(NextPreviewWidth),
|
|
nameof(NextPreviewHeight),
|
|
nameof(QueuedItemCount),
|
|
nameof(QueueFootnote),
|
|
nameof(QueueSummary),
|
|
nameof(ScheduleDataIssueVisibility),
|
|
nameof(ScheduleDataIssueBackgroundBrush),
|
|
nameof(ScheduleDataIssueBorderBrush),
|
|
nameof(ScheduleDataIssueTitle),
|
|
nameof(ScheduleDataIssueMessage),
|
|
nameof(ScheduleDataIssueDetail),
|
|
nameof(ScheduleDataIssueHint),
|
|
nameof(IsCgConnected),
|
|
nameof(CgStatusSummary),
|
|
nameof(LoopSummary),
|
|
nameof(EmptyBehaviorLabel),
|
|
nameof(OperatorQuickSummary));
|
|
}
|
|
|
|
private bool CanAddFormat()
|
|
{
|
|
return SelectedFormat is not null &&
|
|
SelectedRegionOption is not null;
|
|
}
|
|
|
|
private bool CanDirectStart()
|
|
{
|
|
return CanAddFormat();
|
|
}
|
|
|
|
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
|
{
|
|
if (args.PropertyName is nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.ElectionType))
|
|
{
|
|
RebuildFormatCategoryOptions();
|
|
RebuildAvailableFormats();
|
|
_ = RebuildRegionOptionsAsync();
|
|
RefreshSummary();
|
|
}
|
|
}
|
|
|
|
private void RebuildAvailableFormats()
|
|
{
|
|
var selectedFormatId = SelectedFormat?.Id;
|
|
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
|
var filteredFormats = _allFormats
|
|
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
|
|
.ToArray();
|
|
|
|
AvailableFormats.Clear();
|
|
foreach (var format in filteredFormats)
|
|
{
|
|
AvailableFormats.Add(format);
|
|
}
|
|
|
|
var nextSelectedFormat = !string.IsNullOrWhiteSpace(selectedFormatId)
|
|
? AvailableFormats.FirstOrDefault(format => string.Equals(format.Id, selectedFormatId, StringComparison.Ordinal))
|
|
: null;
|
|
nextSelectedFormat ??= AvailableFormats.FirstOrDefault();
|
|
if (!ReferenceEquals(SelectedFormat, nextSelectedFormat))
|
|
{
|
|
SelectedFormat = nextSelectedFormat;
|
|
}
|
|
|
|
UpdateSelectedFormatThumbnailMetrics();
|
|
SyncSelectedCutDebugTemplate();
|
|
AddFormatCommand.NotifyCanExecuteChanged();
|
|
DirectPrepareCommand.NotifyCanExecuteChanged();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
OnPropertyChanged(nameof(QueueFootnote));
|
|
}
|
|
|
|
private void RebuildFormatCategoryOptions()
|
|
{
|
|
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
|
var options = CreateFormatCategoryOptions(_allFormats);
|
|
|
|
FormatCategoryOptions.Clear();
|
|
foreach (var option in options)
|
|
{
|
|
FormatCategoryOptions.Add(option);
|
|
}
|
|
|
|
var nextSelectedOption = selectedCategory.HasValue
|
|
? FormatCategoryOptions.FirstOrDefault(option => option.Value == selectedCategory.Value)
|
|
: null;
|
|
nextSelectedOption ??= FormatCategoryOptions.FirstOrDefault();
|
|
|
|
if (!ReferenceEquals(_selectedFormatCategoryOption, nextSelectedOption))
|
|
{
|
|
_selectedFormatCategoryOption = nextSelectedOption;
|
|
OnPropertyChanged(nameof(SelectedFormatCategoryOption));
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<SelectionOption<CutCategory?>> CreateFormatCategoryOptions(
|
|
IReadOnlyList<FormatTemplateDefinition> formats)
|
|
{
|
|
List<SelectionOption<CutCategory?>> options = [new(null, "전체보기")];
|
|
var seenResultKeys = new HashSet<string>(StringComparer.Ordinal)
|
|
{
|
|
BuildCategoryResultKey(formats.Select(format => format.Id))
|
|
};
|
|
|
|
foreach (var category in CutCategoryResolver.GetOrderedCategories())
|
|
{
|
|
var matchingFormats = formats
|
|
.Where(format => CutCategoryResolver.IsMatch(format, category))
|
|
.ToArray();
|
|
if (matchingFormats.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!seenResultKeys.Add(BuildCategoryResultKey(matchingFormats.Select(format => format.Id))))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
options.Add(new SelectionOption<CutCategory?>(category, CutCategoryResolver.GetLabel(category)));
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
private static string BuildCategoryResultKey(IEnumerable<string> formatIds)
|
|
{
|
|
return string.Join(
|
|
"\u001F",
|
|
formatIds.OrderBy(formatId => formatId, StringComparer.Ordinal));
|
|
}
|
|
|
|
private async Task RebuildRegionOptionsAsync()
|
|
{
|
|
var revision = Interlocked.Increment(ref _regionOptionsRevision);
|
|
var selectedFormat = SelectedFormat;
|
|
var previousSelection = SelectedRegionOption;
|
|
|
|
if (selectedFormat is null)
|
|
{
|
|
RegionOptions.Clear();
|
|
SelectedRegionOption = null;
|
|
_lastRegionOptionFormatId = string.Empty;
|
|
AddFormatCommand.NotifyCanExecuteChanged();
|
|
DirectPrepareCommand.NotifyCanExecuteChanged();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
return;
|
|
}
|
|
|
|
var previousRegionOptionFormatId = _lastRegionOptionFormatId;
|
|
var turnoutPhotoMode = UsesTurnoutRegionMode(selectedFormat)
|
|
? SelectedTurnoutRegionModeOption?.Value
|
|
: null;
|
|
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat, turnoutPhotoMode);
|
|
if (revision != _regionOptionsRevision)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RegionOptions.Clear();
|
|
foreach (var option in options)
|
|
{
|
|
RegionOptions.Add(option);
|
|
}
|
|
|
|
var shouldUseDefaultSelection = !string.Equals(
|
|
previousRegionOptionFormatId,
|
|
selectedFormat.Id,
|
|
StringComparison.Ordinal);
|
|
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
|
|
_lastRegionOptionFormatId = selectedFormat.Id;
|
|
AddFormatCommand.NotifyCanExecuteChanged();
|
|
DirectPrepareCommand.NotifyCanExecuteChanged();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
private void NotifySelectedFormatPreviewChanged()
|
|
{
|
|
UpdateSelectedFormatThumbnailMetrics();
|
|
OnPropertyChanged(
|
|
nameof(CutDebugTextTargets),
|
|
nameof(CutDebugImageTargets),
|
|
nameof(CutDebugVisibilityTargets),
|
|
nameof(CutDebugVoteRateTextTargets),
|
|
nameof(CutDebugVoteRateCounterTargets),
|
|
nameof(CutDebugPartyBarColorTargets),
|
|
nameof(CutDebugPartyPlateColorTargets),
|
|
nameof(CutDebugVoteRateColorTargets),
|
|
nameof(SelectedFormatName),
|
|
nameof(SelectedFormatDescription),
|
|
nameof(SelectedFormatPath),
|
|
nameof(SelectedFormatThumbnailStatus),
|
|
nameof(SelectedFormatThumbnailSource),
|
|
nameof(SelectedFormatThumbnailWidth),
|
|
nameof(SelectedFormatThumbnailHeight));
|
|
}
|
|
|
|
private void ResetSelectedFormatDurationDraft()
|
|
{
|
|
var durationSeconds = SelectedFormat is null
|
|
? 0d
|
|
: ResolveSelectedFormatDurationSeconds(SelectedFormat);
|
|
SetProperty(ref _selectedFormatDraftDurationSeconds, durationSeconds, nameof(SelectedFormatDraftDurationSeconds));
|
|
NotifySelectedFormatDurationStateChanged();
|
|
}
|
|
|
|
public void RefreshSelectedFormatDuration(FormatTemplateDefinition template)
|
|
{
|
|
if (SelectedFormat is null ||
|
|
!string.Equals(SelectedFormat.Id, template.Id, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
ResetSelectedFormatDurationDraft();
|
|
}
|
|
|
|
private void NotifySelectedFormatDurationStateChanged()
|
|
{
|
|
OnPropertyChanged(
|
|
nameof(SelectedFormatMinimumDurationSeconds),
|
|
nameof(HasSelectedFormatDurationChange),
|
|
nameof(SelectedFormatDurationStatusLabel));
|
|
IncreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
|
DecreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
|
ApplySelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
private static double ResolveSelectedFormatDurationSeconds(FormatTemplateDefinition format)
|
|
{
|
|
return ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
|
|
format.Cuts.FirstOrDefault()?.DurationSeconds ?? ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(format),
|
|
format);
|
|
}
|
|
|
|
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
{
|
|
if (e.OldItems is not null)
|
|
{
|
|
foreach (var item in e.OldItems.OfType<ChannelScheduleItem>())
|
|
{
|
|
item.PropertyChanged -= QueueItem_PropertyChanged;
|
|
}
|
|
}
|
|
|
|
if (e.NewItems is not null)
|
|
{
|
|
foreach (var item in e.NewItems.OfType<ChannelScheduleItem>())
|
|
{
|
|
item.PropertyChanged -= QueueItem_PropertyChanged;
|
|
item.PropertyChanged += QueueItem_PropertyChanged;
|
|
}
|
|
}
|
|
|
|
ApplyQueueThumbnailLayouts();
|
|
NotifyPlaybackStateChanged();
|
|
NotifyPlaybackPreviewChanged();
|
|
}
|
|
|
|
private void QueueItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName is nameof(ChannelScheduleItem.State)
|
|
or nameof(ChannelScheduleItem.DisplayName)
|
|
or nameof(ChannelScheduleItem.CurrentRegionLabel)
|
|
or nameof(ChannelScheduleItem.PreviewSource)
|
|
or nameof(ChannelScheduleItem.PreviewStatusLabel)
|
|
or nameof(ChannelScheduleItem.InternalNextPreviewSource)
|
|
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
|
|
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
|
|
or nameof(ChannelScheduleItem.HasInternalNextPreview)
|
|
or nameof(ChannelScheduleItem.LastError)
|
|
or nameof(ChannelScheduleItem.LastIssueAt)
|
|
or nameof(ChannelScheduleItem.PlaybackCountdownVisibility)
|
|
or nameof(ChannelScheduleItem.PlaybackCountdownText)
|
|
or nameof(ChannelScheduleItem.PlaybackCountdownDetail)
|
|
or nameof(ChannelScheduleItem.PlaybackCountdownProgress)
|
|
or nameof(ChannelScheduleItem.ThumbnailSource))
|
|
{
|
|
if (e.PropertyName is nameof(ChannelScheduleItem.State)
|
|
or nameof(ChannelScheduleItem.DisplayName)
|
|
or nameof(ChannelScheduleItem.CurrentRegionLabel)
|
|
or nameof(ChannelScheduleItem.LastError)
|
|
or nameof(ChannelScheduleItem.LastIssueAt))
|
|
{
|
|
NotifyPlaybackStateChanged();
|
|
}
|
|
|
|
RetryDataIssueCommand.NotifyCanExecuteChanged();
|
|
NotifyPlaybackPreviewChanged();
|
|
}
|
|
}
|
|
|
|
private void NotifyPlaybackStateChanged()
|
|
{
|
|
OnPropertyChanged(
|
|
nameof(IsPlaying),
|
|
nameof(PlaybackIconBrush),
|
|
nameof(TransmissionLabel),
|
|
nameof(CurrentItemName),
|
|
nameof(NextItemName),
|
|
nameof(QueuedItemCount),
|
|
nameof(QueueFootnote),
|
|
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()
|
|
{
|
|
OnPropertyChanged(
|
|
nameof(CurrentPreviewSource),
|
|
nameof(NextPreviewSource),
|
|
nameof(CurrentPreviewStatusLabel),
|
|
nameof(NextPreviewStatusLabel),
|
|
nameof(PlaybackCountdownVisibility),
|
|
nameof(PlaybackCountdownText),
|
|
nameof(PlaybackCountdownDetail),
|
|
nameof(PlaybackCountdownProgress),
|
|
nameof(CurrentPreviewWidth),
|
|
nameof(CurrentPreviewHeight),
|
|
nameof(NextPreviewWidth),
|
|
nameof(NextPreviewHeight));
|
|
}
|
|
|
|
private ThumbnailDisplayMetrics ResolvePlaybackPreviewMetrics(ChannelScheduleItem? item)
|
|
{
|
|
var template = item is null
|
|
? null
|
|
: _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
|
|
|
|
return template is null
|
|
? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview)
|
|
: ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview);
|
|
}
|
|
|
|
private static ScheduleRegionOption? ResolvePreferredRegionOption(
|
|
IReadOnlyList<ScheduleRegionOption> options,
|
|
ScheduleRegionOption? previousSelection,
|
|
FormatTemplateDefinition selectedFormat,
|
|
bool shouldUseDefaultSelection)
|
|
{
|
|
if (options.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (previousSelection is null || shouldUseDefaultSelection)
|
|
{
|
|
return ResolveDefaultRegionOption(options, selectedFormat);
|
|
}
|
|
|
|
if (previousSelection.Scope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup)
|
|
{
|
|
var matchedSingle = options.FirstOrDefault(option =>
|
|
option.Scope == previousSelection.Scope &&
|
|
string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal) &&
|
|
string.Equals(option.DistrictCode, previousSelection.DistrictCode, System.StringComparison.OrdinalIgnoreCase));
|
|
if (matchedSingle is not null)
|
|
{
|
|
return matchedSingle;
|
|
}
|
|
}
|
|
|
|
return options.FirstOrDefault(option =>
|
|
option.Scope == previousSelection.Scope &&
|
|
string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal)) ??
|
|
options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ??
|
|
ResolveDefaultRegionOption(options, selectedFormat);
|
|
}
|
|
|
|
private static ScheduleRegionOption ResolveDefaultRegionOption(
|
|
IReadOnlyList<ScheduleRegionOption> options,
|
|
FormatTemplateDefinition selectedFormat)
|
|
{
|
|
var defaultScope = UsesAllDefaultRegionScope(selectedFormat)
|
|
? ScheduleRegionScope.All
|
|
: ScheduleRegionScope.StationRegions;
|
|
|
|
return options.FirstOrDefault(option => option.Scope == defaultScope) ?? options[0];
|
|
}
|
|
|
|
private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
|
|
{
|
|
if (IsBottomTurnoutSidoFormat(format))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (IsBottomTurnoutDistrictFormat(format))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var source = $"{format.Name} {format.Id}";
|
|
return source.Contains("광역단체장", StringComparison.Ordinal) ||
|
|
source.Contains("교육감", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsBottomTurnoutSidoFormat(FormatTemplateDefinition format)
|
|
{
|
|
return format.RecommendedChannel == BroadcastChannel.Bottom &&
|
|
(string.Equals(format.Name, "사전투표율_시도", StringComparison.Ordinal) ||
|
|
string.Equals(format.Name, "투표율_시도", StringComparison.Ordinal));
|
|
}
|
|
|
|
private static bool IsBottomTurnoutDistrictFormat(FormatTemplateDefinition format)
|
|
{
|
|
return format.RecommendedChannel == BroadcastChannel.Bottom &&
|
|
(string.Equals(format.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
|
|
string.Equals(format.Name, "투표율_시군구", StringComparison.Ordinal));
|
|
}
|
|
|
|
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
|
|
{
|
|
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
|
|
}
|
|
|
|
private void UpdateSelectedFormatThumbnailMetrics()
|
|
{
|
|
var metrics = SelectedFormat is null
|
|
? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview)
|
|
: ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview);
|
|
|
|
SetProperty(ref _selectedFormatThumbnailWidth, metrics.Width, nameof(SelectedFormatThumbnailWidth));
|
|
SetProperty(ref _selectedFormatThumbnailHeight, metrics.Height, nameof(SelectedFormatThumbnailHeight));
|
|
}
|
|
|
|
private void ApplyQueueThumbnailLayouts()
|
|
{
|
|
foreach (var item in Queue)
|
|
{
|
|
var template = _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
|
|
var metrics = template is null
|
|
? ThumbnailLayoutResolver.ResolveDisplayMetrics(item.Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue)
|
|
: ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue);
|
|
item.UpdateThumbnailLayout(metrics);
|
|
}
|
|
}
|
|
|
|
private void SyncSelectedCutDebugTemplate()
|
|
{
|
|
if (SelectedFormat is null)
|
|
{
|
|
_selectedCutDebugTemplate = null;
|
|
OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
|
|
return;
|
|
}
|
|
|
|
var templateState = _cutDebugStateStore.GetTemplate(Channel, SelectedFormat.Id, SelectedFormat.Name);
|
|
templateState.SyncItems(BuildCutDebugItemDescriptors(SelectedFormat));
|
|
_selectedCutDebugTemplate = templateState;
|
|
OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
|
|
}
|
|
|
|
private static string BuildCutDebugTextTargets(FormatTemplateDefinition? format)
|
|
{
|
|
var slotCount = ResolveCutDebugSlotCount(format);
|
|
return JoinTargets(
|
|
"시도명01",
|
|
"개표율01",
|
|
BuildIndexedTargetRange("순위", slotCount),
|
|
BuildIndexedTargetRange("후보명", slotCount),
|
|
BuildIndexedTargetRange("정당명", slotCount),
|
|
BuildIndexedTargetRange("득표수", slotCount),
|
|
BuildIndexedTargetRange("득표율", slotCount));
|
|
}
|
|
|
|
private static string BuildCutDebugImageTargets(FormatTemplateDefinition? format)
|
|
{
|
|
var slotCount = ResolveCutDebugSlotCount(format);
|
|
var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
|
|
return JoinTargets(
|
|
chartTarget,
|
|
BuildIndexedTargetRange("유확당", slotCount),
|
|
BuildIndexedTargetRange("점선", slotCount),
|
|
BuildIndexedTargetRange("후보사진", slotCount),
|
|
BuildIndexedTargetRange("득표수바", slotCount),
|
|
BuildIndexedTargetRange("정당바", slotCount),
|
|
BuildIndexedTargetRange("정당판", slotCount),
|
|
BuildIndexedTargetRange("정당원", slotCount),
|
|
BuildIndexedTargetRange("정당색", slotCount),
|
|
BuildIndexedTargetRange("정당심볼", slotCount));
|
|
}
|
|
|
|
private static string BuildCutDebugVisibilityTargets(FormatTemplateDefinition? format)
|
|
{
|
|
var slotCount = ResolveCutDebugSlotCount(format);
|
|
var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
|
|
return JoinTargets(
|
|
chartTarget,
|
|
BuildIndexedTargetRange("점선", slotCount),
|
|
BuildIndexedTargetRange("유확당", slotCount),
|
|
BuildIndexedTargetRange("그룹", slotCount),
|
|
BuildIndexedTargetRange("공약그룹", 3));
|
|
}
|
|
|
|
private static int ResolveCutDebugSlotCount(FormatTemplateDefinition? format)
|
|
{
|
|
if (format is null)
|
|
{
|
|
return 2;
|
|
}
|
|
|
|
var source = $"{format.Name} {format.Id}";
|
|
var topRankMatch = TopRankSlotCountPattern.Match(source);
|
|
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlotCount))
|
|
{
|
|
return Math.Max(1, topRankSlotCount);
|
|
}
|
|
|
|
var peopleMatch = PeopleSlotCountPattern.Match(source);
|
|
if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlotCount))
|
|
{
|
|
return Math.Max(1, peopleSlotCount);
|
|
}
|
|
|
|
return 2;
|
|
}
|
|
|
|
private static string BuildIndexedTargetRange(string prefix, int slotCount)
|
|
{
|
|
slotCount = Math.Max(1, slotCount);
|
|
return slotCount == 1
|
|
? $"{prefix}01"
|
|
: $"{prefix}01~{slotCount:00}";
|
|
}
|
|
|
|
private static string JoinTargets(params string[] targets)
|
|
{
|
|
return string.Join(", ", targets.Where(target => !string.IsNullOrWhiteSpace(target)));
|
|
}
|
|
|
|
private static IReadOnlyList<CutDebugItemDescriptor> BuildCutDebugItemDescriptors(FormatTemplateDefinition? format)
|
|
{
|
|
if (format is null)
|
|
{
|
|
return Array.Empty<CutDebugItemDescriptor>();
|
|
}
|
|
|
|
var slotCount = ResolveCutDebugSlotCount(format);
|
|
var descriptors = new List<CutDebugItemDescriptor>();
|
|
|
|
AddDescriptor(descriptors, "선거구명01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "시도명01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "개표율01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "기준시01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "기준시02", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "유권자수01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "투표자수01", CutDebugItemKind.TextValue, "공통 텍스트");
|
|
AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.Counter, "카운터");
|
|
if (!ShouldExcludeHistoricalTurnoutGraph(format))
|
|
{
|
|
AddDescriptor(descriptors, "차트01", CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddDescriptor(descriptors, "차트01", CutDebugItemKind.Visibility, "표시/숨김");
|
|
}
|
|
|
|
AddIndexedDescriptors(descriptors, "순위", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "후보명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "득표수", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "득표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "선거구명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "시도명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "개표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
|
|
AddIndexedDescriptors(descriptors, "공약", 3, CutDebugItemKind.TextValue, "공약 텍스트");
|
|
|
|
AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "후보사진", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "정당심볼", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
|
|
|
|
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.Counter, "카운터");
|
|
AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.Counter, "카운터");
|
|
|
|
AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.StyleColor, "색상");
|
|
|
|
AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
|
|
AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
|
|
AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.Visibility, "표시/숨김");
|
|
AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
|
|
|
|
return descriptors;
|
|
}
|
|
|
|
private static void AddIndexedDescriptors(
|
|
ICollection<CutDebugItemDescriptor> descriptors,
|
|
string prefix,
|
|
int slotCount,
|
|
CutDebugItemKind kind,
|
|
string groupLabel)
|
|
{
|
|
for (var slot = 1; slot <= Math.Max(1, slotCount); slot++)
|
|
{
|
|
AddDescriptor(descriptors, $"{prefix}{slot:00}", kind, groupLabel);
|
|
}
|
|
}
|
|
|
|
private static void AddDescriptor(
|
|
ICollection<CutDebugItemDescriptor> descriptors,
|
|
string key,
|
|
CutDebugItemKind kind,
|
|
string groupLabel)
|
|
{
|
|
descriptors.Add(new CutDebugItemDescriptor(key, kind, groupLabel));
|
|
}
|
|
|
|
private static bool ShouldExcludeHistoricalTurnoutGraph(FormatTemplateDefinition? format)
|
|
{
|
|
return format is not null &&
|
|
format.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal);
|
|
}
|
|
}
|