1179 lines
47 KiB
C#
1179 lines
47 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.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 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 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;
|
|
|
|
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
|
|
StopCommand = new AsyncRelayCommand(StopAsync);
|
|
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
|
|
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
|
|
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
|
|
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
|
|
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
|
|
ResetQueueCommand = new RelayCommand(ResetQueue);
|
|
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 StartCommand { get; }
|
|
|
|
public AsyncRelayCommand StopCommand { 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> 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();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
public ScheduleRegionOption? SelectedRegionOption
|
|
{
|
|
get => _selectedRegionOption;
|
|
set
|
|
{
|
|
if (SetProperty(ref _selectedRegionOption, value))
|
|
{
|
|
AddFormatCommand.NotifyCanExecuteChanged();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void EnsureTurnoutRegionModeSelection()
|
|
{
|
|
if (!UsesTurnoutRegionMode(SelectedFormat))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_selectedTurnoutRegionModeOption is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
|
|
OnPropertyChanged(nameof(SelectedTurnoutRegionModeOption));
|
|
}
|
|
|
|
private static bool UsesTurnoutRegionMode(FormatTemplateDefinition? format)
|
|
{
|
|
return format is not null &&
|
|
string.Equals(format.Name, "투표율_사진", StringComparison.Ordinal);
|
|
}
|
|
|
|
public bool LoopEnabled
|
|
{
|
|
get => _loopEnabled;
|
|
set
|
|
{
|
|
if (SetProperty(ref _loopEnabled, value))
|
|
{
|
|
_engine.LoopEnabled = value;
|
|
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 string TransmissionLabel => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
|
|
? "송출 중"
|
|
: "대기";
|
|
|
|
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "대기 화면";
|
|
|
|
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "다음 컷 없음";
|
|
|
|
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
|
|
|
|
public string QueueFootnote => $"대기 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
|
|
|
|
public string QueueSummary
|
|
{
|
|
get
|
|
{
|
|
var current = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "-";
|
|
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "-";
|
|
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
|
|
}
|
|
}
|
|
|
|
public string LoopSummary => LoopEnabled ? "반복 재생" : "1회 재생";
|
|
|
|
public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";
|
|
|
|
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
|
|
|
|
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;
|
|
|
|
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();
|
|
}
|
|
|
|
private async Task StartAsync()
|
|
{
|
|
await _engine.StartAsync().ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 큐를 시작");
|
|
}
|
|
|
|
private async Task StopAsync()
|
|
{
|
|
await _engine.StopAsync().ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 큐를 종료");
|
|
}
|
|
|
|
private async Task DirectStartAsync()
|
|
{
|
|
var selectedFormat = SelectedFormat;
|
|
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
|
if (selectedFormat is null || regionOption is null)
|
|
{
|
|
_logService.Warning($"[{Title}] 바로 송출할 컷과 지역을 먼저 선택해 주세요.");
|
|
return;
|
|
}
|
|
|
|
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
|
{
|
|
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
|
_directPlaybackCts?.Cancel();
|
|
|
|
var playbackCts = new CancellationTokenSource();
|
|
_directPlaybackCts = playbackCts;
|
|
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
|
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
|
selectedFormat,
|
|
_videoWallLayoutPreset,
|
|
ThumbnailDisplayContext.Queue));
|
|
|
|
try
|
|
{
|
|
_logService.Info($"[{Title}] 선택 컷 바로 송출: {selectedFormat.Name} / {regionOption.Label}");
|
|
await _engine.PlayDirectAsync(item, selectedFormat, playbackCts.Token).ConfigureAwait(false);
|
|
if (!playbackCts.IsCancellationRequested)
|
|
{
|
|
_logService.Info($"[{Title}] 선택 컷 바로 송출 완료: {selectedFormat.Name}");
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logService.Error($"[{Title}] 선택 컷 바로 송출 실패: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
if (ReferenceEquals(_directPlaybackCts, playbackCts))
|
|
{
|
|
_directPlaybackCts = null;
|
|
}
|
|
|
|
playbackCts.Dispose();
|
|
RefreshSummary();
|
|
}
|
|
}
|
|
|
|
private async Task DirectStopAsync()
|
|
{
|
|
_directPlaybackCts?.Cancel();
|
|
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
|
RefreshSummary();
|
|
_logService.Info($"[{Title}] 선택 컷 송출 정지");
|
|
}
|
|
|
|
private async Task ForceNextAsync()
|
|
{
|
|
await _engine.ForceNextAsync().ConfigureAwait(false);
|
|
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;
|
|
}
|
|
|
|
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
|
{
|
|
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
|
|
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 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(TransmissionLabel),
|
|
nameof(CurrentItemName),
|
|
nameof(NextItemName),
|
|
nameof(QueuedItemCount),
|
|
nameof(QueueFootnote),
|
|
nameof(QueueSummary),
|
|
nameof(IsCgConnected),
|
|
nameof(CgStatusSummary),
|
|
nameof(LoopSummary),
|
|
nameof(EmptyBehaviorLabel),
|
|
nameof(OperatorQuickSummary));
|
|
}
|
|
|
|
private bool CanAddFormat()
|
|
{
|
|
return SelectedFormat is not null &&
|
|
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
|
|
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 => format.IsAvailableInPhase(_data.BroadcastPhase))
|
|
.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();
|
|
DirectStartCommand.NotifyCanExecuteChanged();
|
|
OnPropertyChanged(nameof(QueueFootnote));
|
|
}
|
|
|
|
private void RebuildFormatCategoryOptions()
|
|
{
|
|
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
|
var formatsInCurrentPhase = _allFormats
|
|
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
|
.ToArray();
|
|
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
|
|
|
|
FormatCategoryOptions.Clear();
|
|
foreach (var option in options)
|
|
{
|
|
FormatCategoryOptions.Add(option);
|
|
}
|
|
|
|
var nextSelectedOption = selectedCategory.HasValue
|
|
? FormatCategoryOptions.FirstOrDefault(option => option.Value == selectedCategory.Value)
|
|
: null;
|
|
nextSelectedOption ??= FormatCategoryOptions.FirstOrDefault();
|
|
|
|
if (!ReferenceEquals(_selectedFormatCategoryOption, nextSelectedOption))
|
|
{
|
|
_selectedFormatCategoryOption = nextSelectedOption;
|
|
OnPropertyChanged(nameof(SelectedFormatCategoryOption));
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<SelectionOption<CutCategory?>> CreateFormatCategoryOptions(
|
|
IReadOnlyList<FormatTemplateDefinition> formats)
|
|
{
|
|
List<SelectionOption<CutCategory?>> options = [new(null, "전체보기")];
|
|
var seenResultKeys = new HashSet<string>(StringComparer.Ordinal)
|
|
{
|
|
BuildCategoryResultKey(formats.Select(format => format.Id))
|
|
};
|
|
|
|
foreach (var category in CutCategoryResolver.GetOrderedCategories())
|
|
{
|
|
var matchingFormats = formats
|
|
.Where(format => CutCategoryResolver.IsMatch(format, category))
|
|
.ToArray();
|
|
if (matchingFormats.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!seenResultKeys.Add(BuildCategoryResultKey(matchingFormats.Select(format => format.Id))))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
options.Add(new SelectionOption<CutCategory?>(category, CutCategoryResolver.GetLabel(category)));
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
private static string BuildCategoryResultKey(IEnumerable<string> formatIds)
|
|
{
|
|
return string.Join(
|
|
"\u001F",
|
|
formatIds.OrderBy(formatId => formatId, StringComparer.Ordinal));
|
|
}
|
|
|
|
private async Task RebuildRegionOptionsAsync()
|
|
{
|
|
var revision = Interlocked.Increment(ref _regionOptionsRevision);
|
|
var selectedFormat = SelectedFormat;
|
|
var previousSelection = SelectedRegionOption;
|
|
|
|
if (selectedFormat is null)
|
|
{
|
|
RegionOptions.Clear();
|
|
SelectedRegionOption = null;
|
|
_lastRegionOptionFormatId = string.Empty;
|
|
AddFormatCommand.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();
|
|
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();
|
|
}
|
|
|
|
private void NotifySelectedFormatDurationStateChanged()
|
|
{
|
|
OnPropertyChanged(
|
|
nameof(SelectedFormatMinimumDurationSeconds),
|
|
nameof(HasSelectedFormatDurationChange),
|
|
nameof(SelectedFormatDurationStatusLabel));
|
|
IncreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
|
DecreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
|
ApplySelectedFormatDurationCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
private static double ResolveSelectedFormatDurationSeconds(FormatTemplateDefinition format)
|
|
{
|
|
return ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
|
|
format.Cuts.FirstOrDefault()?.DurationSeconds ?? ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(format),
|
|
format);
|
|
}
|
|
|
|
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
{
|
|
ApplyQueueThumbnailLayouts();
|
|
}
|
|
|
|
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)
|
|
{
|
|
var source = $"{format.Name} {format.Id}";
|
|
return source.Contains("광역단체장", StringComparison.Ordinal) ||
|
|
source.Contains("교육감", 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);
|
|
}
|
|
}
|