731 lines
24 KiB
C#
731 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.UI.Xaml;
|
|
using Tornado3_2026Election.Common;
|
|
using Tornado3_2026Election.Domain;
|
|
using Tornado3_2026Election.Services;
|
|
|
|
namespace Tornado3_2026Election.ViewModels;
|
|
|
|
public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposable
|
|
{
|
|
private static readonly IReadOnlyDictionary<string, string> DistrictCodeMap = new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["서울특별시"] = "1100",
|
|
["부산광역시"] = "2600",
|
|
["대구광역시"] = "2700",
|
|
["인천광역시"] = "2800",
|
|
["광주광역시"] = "2900",
|
|
["대전광역시"] = "3000",
|
|
["울산광역시"] = "3100",
|
|
["세종특별자치시"] = "3600",
|
|
["경기도"] = "4100",
|
|
["강원특별자치도"] = "4200",
|
|
["충청북도"] = "4300",
|
|
["충청남도"] = "4400",
|
|
["전북특별자치도"] = "4500",
|
|
["전라남도"] = "4600",
|
|
["경상북도"] = "4700",
|
|
["경상남도"] = "4800",
|
|
["제주특별자치도"] = "5000"
|
|
};
|
|
|
|
private readonly LogService _logService;
|
|
private readonly Random _random = new();
|
|
private CancellationTokenSource? _pollingCts;
|
|
private TaskCompletionSource<bool>? _refreshSignal;
|
|
private BroadcastPhase _broadcastPhase = BroadcastPhase.Counting;
|
|
private bool _isRefreshing;
|
|
private bool _isPollingEnabled = true;
|
|
private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue;
|
|
private DateTimeOffset _lastManualRefreshAt = DateTimeOffset.MinValue;
|
|
private string _electionType = "광역단체장";
|
|
private string _districtName = "부산광역시";
|
|
private string _districtCode = "2600";
|
|
private int _totalExpectedVotes = 1_240_000;
|
|
private int _turnoutVotes = 528_400;
|
|
private int _pollingIntervalSeconds = 12;
|
|
private int _pollingCountdownSeconds;
|
|
|
|
public DataViewModel(LogService logService)
|
|
{
|
|
_logService = logService;
|
|
|
|
ElectionTypeOptions =
|
|
[
|
|
new SelectionOption<string>("광역단체장", "광역단체장"),
|
|
new SelectionOption<string>("교육감", "교육감"),
|
|
new SelectionOption<string>("광역의원", "광역의원"),
|
|
new SelectionOption<string>("기초단체장", "기초단체장"),
|
|
new SelectionOption<string>("기초의원", "기초의원"),
|
|
new SelectionOption<string>("비례대표광역의원", "비례대표광역의원"),
|
|
new SelectionOption<string>("비례대표기초의원", "비례대표기초의원")
|
|
];
|
|
|
|
DistrictOptions =
|
|
[
|
|
new SelectionOption<string>("서울특별시", "서울특별시"),
|
|
new SelectionOption<string>("부산광역시", "부산광역시"),
|
|
new SelectionOption<string>("대구광역시", "대구광역시"),
|
|
new SelectionOption<string>("인천광역시", "인천광역시"),
|
|
new SelectionOption<string>("광주광역시", "광주광역시"),
|
|
new SelectionOption<string>("대전광역시", "대전광역시"),
|
|
new SelectionOption<string>("울산광역시", "울산광역시"),
|
|
new SelectionOption<string>("세종특별자치시", "세종특별자치시"),
|
|
new SelectionOption<string>("경기도", "경기도"),
|
|
new SelectionOption<string>("강원특별자치도", "강원특별자치도"),
|
|
new SelectionOption<string>("충청북도", "충청북도"),
|
|
new SelectionOption<string>("충청남도", "충청남도"),
|
|
new SelectionOption<string>("전북특별자치도", "전북특별자치도"),
|
|
new SelectionOption<string>("전라남도", "전라남도"),
|
|
new SelectionOption<string>("경상북도", "경상북도"),
|
|
new SelectionOption<string>("경상남도", "경상남도"),
|
|
new SelectionOption<string>("제주특별자치도", "제주특별자치도")
|
|
];
|
|
|
|
EnsureOptionExists(ElectionTypeOptions, _electionType);
|
|
EnsureOptionExists(DistrictOptions, _districtName);
|
|
|
|
Candidates =
|
|
[
|
|
new CandidateEntry { CandidateCode = "A01", Name = "김하늘", Party = "미래연합", VoteCount = 312000, VoteRate = 34.8, HasImage = true },
|
|
new CandidateEntry { CandidateCode = "A02", Name = "박준호", Party = "국민실행", VoteCount = 287000, VoteRate = 32.0, HasImage = true },
|
|
new CandidateEntry { CandidateCode = "A03", Name = "이서윤", Party = "정의미래", VoteCount = 168000, VoteRate = 18.7, HasImage = false },
|
|
new CandidateEntry { CandidateCode = "A04", Name = "정민석", Party = "무소속", VoteCount = 129000, VoteRate = 14.5, HasImage = true }
|
|
];
|
|
|
|
JudgementOptions =
|
|
[
|
|
new SelectionOption<CandidateJudgement>(CandidateJudgement.None, "자동 판정 사용"),
|
|
new SelectionOption<CandidateJudgement>(CandidateJudgement.Leading, "유력"),
|
|
new SelectionOption<CandidateJudgement>(CandidateJudgement.Confirmed, "확실"),
|
|
new SelectionOption<CandidateJudgement>(CandidateJudgement.Elected, "당선")
|
|
];
|
|
|
|
ManualRefreshCommand = new AsyncRelayCommand(() => RefreshAsync(isManualRequest: true));
|
|
AddCandidateCommand = new RelayCommand(AddCandidate);
|
|
ResetManualJudgementsCommand = new RelayCommand(ResetManualJudgements);
|
|
RemoveCandidateCommand = new RelayCommand<CandidateEntry>(RemoveCandidate);
|
|
ToggleCandidateImageCommand = new RelayCommand<CandidateEntry>(ToggleCandidateImage);
|
|
|
|
RecalculateJudgements();
|
|
StartPolling();
|
|
}
|
|
|
|
public ObservableCollection<CandidateEntry> Candidates { get; }
|
|
|
|
public ObservableCollection<SelectionOption<string>> ElectionTypeOptions { get; }
|
|
|
|
public ObservableCollection<SelectionOption<string>> DistrictOptions { get; }
|
|
|
|
public IReadOnlyList<SelectionOption<CandidateJudgement>> JudgementOptions { get; }
|
|
|
|
public AsyncRelayCommand ManualRefreshCommand { get; }
|
|
|
|
public RelayCommand AddCandidateCommand { get; }
|
|
|
|
public RelayCommand ResetManualJudgementsCommand { get; }
|
|
|
|
public RelayCommand<CandidateEntry> RemoveCandidateCommand { get; }
|
|
|
|
public RelayCommand<CandidateEntry> ToggleCandidateImageCommand { get; }
|
|
|
|
public BroadcastPhase BroadcastPhase
|
|
{
|
|
get => _broadcastPhase;
|
|
set
|
|
{
|
|
if (SetProperty(ref _broadcastPhase, value))
|
|
{
|
|
OnPropertyChanged(nameof(StatusText));
|
|
NotifyModePresentationChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool IsPreElectionPhase => BroadcastPhase == BroadcastPhase.PreElection;
|
|
|
|
public bool IsCountingPhase => BroadcastPhase == BroadcastPhase.Counting;
|
|
|
|
public string BroadcastPhaseLabel => IsPreElectionPhase ? "사전" : "개표";
|
|
|
|
public string BroadcastPhaseBadgeText => IsPreElectionPhase ? "사전 투표율 수신" : "개표 득표수 수신";
|
|
|
|
public string BroadcastPhaseDetailText => IsPreElectionPhase
|
|
? "사전 단계에서는 투표율과 투표자 수를 수신합니다."
|
|
: "개표 단계에서는 후보 득표수와 판정 데이터를 수신합니다.";
|
|
|
|
public string HeaderMetricSummary => IsPreElectionPhase
|
|
? $"사전 투표율 {TurnoutRateDisplay} / 투표자 {TurnoutVotes:N0}"
|
|
: $"개표 {CountedVotes:N0} / 남은표 {RemainingVotes:N0}";
|
|
|
|
public string SituationMetricPrimaryLabel => IsPreElectionPhase ? "투표율" : "개표수";
|
|
|
|
public string SituationMetricPrimaryValue => IsPreElectionPhase ? TurnoutRateDisplay : CountedVotes.ToString("N0");
|
|
|
|
public string SituationMetricSecondaryLabel => IsPreElectionPhase ? "투표자" : "남은표";
|
|
|
|
public string SituationMetricSecondaryValue => IsPreElectionPhase ? TurnoutVotes.ToString("N0") : RemainingVotes.ToString("N0");
|
|
|
|
public string TotalExpectedVotesLabel => IsPreElectionPhase ? "총 선거인수" : "총 예상표";
|
|
|
|
public Visibility TurnoutBoardVisibility => IsPreElectionPhase ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public Visibility CandidateBoardVisibility => IsCountingPhase ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public Visibility CountingActionsVisibility => IsCountingPhase ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public bool IsRefreshing
|
|
{
|
|
get => _isRefreshing;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _isRefreshing, value))
|
|
{
|
|
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool IsPollingEnabled
|
|
{
|
|
get => _isPollingEnabled;
|
|
set
|
|
{
|
|
if (SetProperty(ref _isPollingEnabled, value))
|
|
{
|
|
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
|
|
if (value)
|
|
{
|
|
StartPolling();
|
|
}
|
|
else
|
|
{
|
|
StopPolling();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public DateTimeOffset LastRefreshAt
|
|
{
|
|
get => _lastRefreshAt;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _lastRefreshAt, value))
|
|
{
|
|
OnPropertyChanged(nameof(StatusText), nameof(LastRefreshDisplay));
|
|
}
|
|
}
|
|
}
|
|
|
|
public string ElectionType
|
|
{
|
|
get => _electionType;
|
|
set
|
|
{
|
|
EnsureOptionExists(ElectionTypeOptions, value);
|
|
SetProperty(ref _electionType, value);
|
|
}
|
|
}
|
|
|
|
public string DistrictName
|
|
{
|
|
get => _districtName;
|
|
set
|
|
{
|
|
EnsureOptionExists(DistrictOptions, value);
|
|
if (SetProperty(ref _districtName, value) && DistrictCodeMap.TryGetValue(value, out var districtCode))
|
|
{
|
|
DistrictCode = districtCode;
|
|
}
|
|
}
|
|
}
|
|
|
|
public string DistrictCode
|
|
{
|
|
get => _districtCode;
|
|
set => SetProperty(ref _districtCode, value);
|
|
}
|
|
|
|
public int TotalExpectedVotes
|
|
{
|
|
get => _totalExpectedVotes;
|
|
set
|
|
{
|
|
var normalized = Math.Max(1, value);
|
|
if (SetProperty(ref _totalExpectedVotes, normalized))
|
|
{
|
|
if (_turnoutVotes > normalized)
|
|
{
|
|
_turnoutVotes = normalized;
|
|
OnPropertyChanged(nameof(TurnoutVotes));
|
|
}
|
|
|
|
RecalculateJudgements();
|
|
NotifyMetricPresentationChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
public int TurnoutVotes
|
|
{
|
|
get => _turnoutVotes;
|
|
set
|
|
{
|
|
var normalized = Math.Clamp(value, 0, TotalExpectedVotes);
|
|
if (SetProperty(ref _turnoutVotes, normalized))
|
|
{
|
|
OnPropertyChanged(nameof(TurnoutRemainingVotes), nameof(TurnoutRate), nameof(TurnoutRateDisplay));
|
|
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
|
}
|
|
}
|
|
}
|
|
|
|
public int PollingIntervalSeconds
|
|
{
|
|
get => _pollingIntervalSeconds;
|
|
set
|
|
{
|
|
if (SetProperty(ref _pollingIntervalSeconds, Math.Max(3, value)))
|
|
{
|
|
OnPropertyChanged(nameof(PollingCountdownText));
|
|
StartPolling();
|
|
}
|
|
}
|
|
}
|
|
|
|
public int PollingCountdownSeconds
|
|
{
|
|
get => _pollingCountdownSeconds;
|
|
private set
|
|
{
|
|
var normalized = Math.Max(0, value);
|
|
if (SetProperty(ref _pollingCountdownSeconds, normalized))
|
|
{
|
|
OnPropertyChanged(nameof(PollingCountdownText));
|
|
}
|
|
}
|
|
}
|
|
|
|
public int CountedVotes => Candidates.Sum(candidate => candidate.VoteCount);
|
|
|
|
public int RemainingVotes => Math.Max(0, TotalExpectedVotes - CountedVotes);
|
|
|
|
public int TurnoutRemainingVotes => Math.Max(0, TotalExpectedVotes - TurnoutVotes);
|
|
|
|
public double TurnoutRate => TotalExpectedVotes <= 0
|
|
? 0
|
|
: Math.Round(TurnoutVotes * 100d / TotalExpectedVotes, 1, MidpointRounding.AwayFromZero);
|
|
|
|
public string TurnoutRateDisplay => $"{TurnoutRate:0.0}%";
|
|
|
|
public string LastRefreshDisplay => LastRefreshAt == DateTimeOffset.MinValue
|
|
? "미수신"
|
|
: LastRefreshAt.ToString("HH:mm:ss");
|
|
|
|
public string PollingModeLabel
|
|
{
|
|
get
|
|
{
|
|
if (IsRefreshing)
|
|
{
|
|
return "수신 중";
|
|
}
|
|
|
|
return IsPollingEnabled ? "자동 수신" : "수동 수신";
|
|
}
|
|
}
|
|
|
|
public string PollingStateDetail => IsRefreshing
|
|
? "갱신 완료 후 송출"
|
|
: PollingCountdownText;
|
|
|
|
public string PollingCountdownText
|
|
{
|
|
get
|
|
{
|
|
if (!IsPollingEnabled)
|
|
{
|
|
return "API 자동 수신 OFF";
|
|
}
|
|
|
|
if (IsRefreshing)
|
|
{
|
|
return "API 수신 진행 중";
|
|
}
|
|
|
|
return $"다음 API 수신까지 {PollingCountdownSeconds}초";
|
|
}
|
|
}
|
|
|
|
public string StatusText
|
|
{
|
|
get
|
|
{
|
|
if (IsRefreshing)
|
|
{
|
|
return $"{BroadcastPhaseLabel} 데이터 갱신 중. 송출 요청은 갱신 완료 후 진행됩니다.";
|
|
}
|
|
|
|
if (LastRefreshAt == DateTimeOffset.MinValue)
|
|
{
|
|
return $"{BroadcastPhaseLabel} 단계 초기 데이터 유지 중";
|
|
}
|
|
|
|
return $"마지막 갱신 {LastRefreshAt:HH:mm:ss} / 단계 {BroadcastPhaseLabel} / 폴링 {(IsPollingEnabled ? "ON" : "OFF")}";
|
|
}
|
|
}
|
|
|
|
public void StartPolling()
|
|
{
|
|
_pollingCts?.Cancel();
|
|
if (!IsPollingEnabled)
|
|
{
|
|
PollingCountdownSeconds = 0;
|
|
return;
|
|
}
|
|
|
|
_pollingCts = new CancellationTokenSource();
|
|
_ = RunPollingLoopAsync(_pollingCts.Token);
|
|
}
|
|
|
|
public void ApplyBroadcastPhase(BroadcastPhase phase)
|
|
{
|
|
if (BroadcastPhase == phase)
|
|
{
|
|
return;
|
|
}
|
|
|
|
BroadcastPhase = phase;
|
|
_logService.Info($"방송 단계를 {BroadcastPhaseLabel} 모드로 전환했습니다.");
|
|
}
|
|
|
|
public async Task WaitForRefreshAsync(CancellationToken cancellationToken)
|
|
{
|
|
while (IsRefreshing)
|
|
{
|
|
var waiter = _refreshSignal;
|
|
if (waiter is null)
|
|
{
|
|
break;
|
|
}
|
|
|
|
await waiter.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
public ElectionDataSnapshot GetCurrentSnapshot()
|
|
{
|
|
return new ElectionDataSnapshot
|
|
{
|
|
BroadcastPhase = BroadcastPhase,
|
|
ElectionType = ElectionType,
|
|
DistrictName = DistrictName,
|
|
DistrictCode = DistrictCode,
|
|
Candidates = Candidates.Select(candidate => candidate.Clone()).ToArray(),
|
|
TotalExpectedVotes = TotalExpectedVotes,
|
|
TurnoutVotes = TurnoutVotes,
|
|
ReceivedAt = LastRefreshAt == DateTimeOffset.MinValue ? DateTimeOffset.Now : LastRefreshAt
|
|
};
|
|
}
|
|
|
|
public bool ValidateForFormat(FormatTemplateDefinition template, out string errorMessage)
|
|
{
|
|
if (Candidates.Count == 0)
|
|
{
|
|
errorMessage = "후보 데이터가 없습니다.";
|
|
return false;
|
|
}
|
|
|
|
foreach (var candidate in Candidates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(candidate.Name)
|
|
|| string.IsNullOrWhiteSpace(candidate.Party)
|
|
|| string.IsNullOrWhiteSpace(candidate.CandidateCode))
|
|
{
|
|
errorMessage = "필수 후보 필드가 비어 있어 송출할 수 없습니다.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (template.RequiresImage && Candidates.Any(candidate => !candidate.HasImage))
|
|
{
|
|
errorMessage = "이미지 필수 포맷인데 후보 이미지가 없는 항목이 있습니다.";
|
|
return false;
|
|
}
|
|
|
|
errorMessage = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
public async Task RefreshAsync(bool isManualRequest)
|
|
{
|
|
if (isManualRequest && DateTimeOffset.Now - _lastManualRefreshAt < TimeSpan.FromSeconds(3))
|
|
{
|
|
_logService.Warning("수동 수신은 3초 이내 재요청할 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
_refreshSignal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
IsRefreshing = true;
|
|
|
|
try
|
|
{
|
|
if (isManualRequest)
|
|
{
|
|
_lastManualRefreshAt = DateTimeOffset.Now;
|
|
_logService.Info($"수동 수신 요청을 처리합니다. 현재 단계는 {BroadcastPhaseLabel}입니다.");
|
|
}
|
|
else
|
|
{
|
|
_logService.Info($"Polling 기반 {BroadcastPhaseLabel} 데이터 수신을 처리합니다.");
|
|
}
|
|
|
|
await Task.Delay(650);
|
|
|
|
if (IsPreElectionPhase)
|
|
{
|
|
ApplySimulatedTurnoutDelta();
|
|
LastRefreshAt = DateTimeOffset.Now;
|
|
_logService.Info($"데이터 갱신 완료. 투표율={TurnoutRateDisplay}, 투표자={TurnoutVotes:N0}");
|
|
}
|
|
else
|
|
{
|
|
ApplySimulatedVoteDelta();
|
|
LastRefreshAt = DateTimeOffset.Now;
|
|
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
|
_logService.Info($"데이터 갱신 완료. 개표수={CountedVotes:N0}, 남은표={RemainingVotes:N0}");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
IsRefreshing = false;
|
|
_refreshSignal?.TrySetResult(true);
|
|
_refreshSignal = null;
|
|
|
|
if (isManualRequest && IsPollingEnabled)
|
|
{
|
|
StartPolling();
|
|
}
|
|
}
|
|
}
|
|
|
|
public void ReplaceCandidates(IEnumerable<CandidateEntry> candidates)
|
|
{
|
|
Candidates.Clear();
|
|
foreach (var candidate in candidates)
|
|
{
|
|
Candidates.Add(candidate);
|
|
}
|
|
|
|
RecalculateJudgements();
|
|
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
|
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
StopPolling();
|
|
}
|
|
|
|
private async Task RunPollingLoopAsync(CancellationToken cancellationToken)
|
|
{
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var remainingSeconds = PollingIntervalSeconds;
|
|
PollingCountdownSeconds = remainingSeconds;
|
|
|
|
while (remainingSeconds > 0)
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
|
remainingSeconds--;
|
|
PollingCountdownSeconds = remainingSeconds;
|
|
}
|
|
|
|
await RefreshAsync(isManualRequest: false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void StopPolling()
|
|
{
|
|
_pollingCts?.Cancel();
|
|
_pollingCts = null;
|
|
PollingCountdownSeconds = 0;
|
|
}
|
|
|
|
private void ApplySimulatedTurnoutDelta()
|
|
{
|
|
if (TurnoutVotes >= TotalExpectedVotes)
|
|
{
|
|
return;
|
|
}
|
|
|
|
TurnoutVotes = Math.Min(TotalExpectedVotes, TurnoutVotes + _random.Next(4_500, 18_000));
|
|
}
|
|
|
|
private void ApplySimulatedVoteDelta()
|
|
{
|
|
if (RemainingVotes <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var candidate in Candidates)
|
|
{
|
|
candidate.VoteCount += _random.Next(1200, 3800);
|
|
}
|
|
|
|
var total = Math.Max(1, Candidates.Sum(candidate => candidate.VoteCount));
|
|
foreach (var candidate in Candidates)
|
|
{
|
|
candidate.VoteRate = Math.Round(candidate.VoteCount * 100d / total, 1, MidpointRounding.AwayFromZero);
|
|
}
|
|
|
|
RecalculateJudgements();
|
|
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
|
}
|
|
|
|
private void RecalculateJudgements()
|
|
{
|
|
var orderedCandidates = Candidates.OrderByDescending(candidate => candidate.VoteCount).ToArray();
|
|
if (orderedCandidates.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var candidate in orderedCandidates)
|
|
{
|
|
candidate.AutomaticJudgement = CandidateJudgement.None;
|
|
}
|
|
|
|
if (orderedCandidates.Length == 1)
|
|
{
|
|
orderedCandidates[0].AutomaticJudgement = CandidateJudgement.Elected;
|
|
return;
|
|
}
|
|
|
|
var first = orderedCandidates[0];
|
|
var second = orderedCandidates[1];
|
|
var difference = first.VoteCount - second.VoteCount;
|
|
if (difference > RemainingVotes)
|
|
{
|
|
first.AutomaticJudgement = CandidateJudgement.Elected;
|
|
}
|
|
else if (difference > RemainingVotes / 2)
|
|
{
|
|
first.AutomaticJudgement = CandidateJudgement.Confirmed;
|
|
}
|
|
else
|
|
{
|
|
first.AutomaticJudgement = CandidateJudgement.Leading;
|
|
}
|
|
}
|
|
|
|
private void AddCandidate()
|
|
{
|
|
Candidates.Add(new CandidateEntry
|
|
{
|
|
CandidateCode = $"NEW{Candidates.Count + 1:00}",
|
|
Name = "신규 후보",
|
|
Party = "정당 입력",
|
|
VoteCount = 0,
|
|
VoteRate = 0,
|
|
HasImage = true
|
|
});
|
|
|
|
RecalculateJudgements();
|
|
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
|
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
|
_logService.Info("후보 행을 추가했습니다.");
|
|
}
|
|
|
|
private void RemoveCandidate(CandidateEntry? candidate)
|
|
{
|
|
if (candidate is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Candidates.Remove(candidate);
|
|
RecalculateJudgements();
|
|
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
|
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
|
_logService.Info($"후보 행 삭제: {candidate.Name}");
|
|
}
|
|
|
|
private void ToggleCandidateImage(CandidateEntry? candidate)
|
|
{
|
|
if (candidate is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
candidate.HasImage = !candidate.HasImage;
|
|
_logService.Info($"후보 이미지 상태 변경: {candidate.Name} => {(candidate.HasImage ? "있음" : "없음")}");
|
|
}
|
|
|
|
private void ResetManualJudgements()
|
|
{
|
|
foreach (var candidate in Candidates)
|
|
{
|
|
candidate.ManualJudgement = CandidateJudgement.None;
|
|
}
|
|
|
|
_logService.Info("수동 유력/확실/당선 지정값을 초기화했습니다.");
|
|
}
|
|
|
|
private void NotifyModePresentationChanged()
|
|
{
|
|
OnPropertyChanged(
|
|
nameof(IsPreElectionPhase),
|
|
nameof(IsCountingPhase),
|
|
nameof(BroadcastPhaseLabel),
|
|
nameof(BroadcastPhaseBadgeText),
|
|
nameof(BroadcastPhaseDetailText),
|
|
nameof(HeaderMetricSummary),
|
|
nameof(SituationMetricPrimaryLabel),
|
|
nameof(SituationMetricPrimaryValue),
|
|
nameof(SituationMetricSecondaryLabel),
|
|
nameof(SituationMetricSecondaryValue),
|
|
nameof(TotalExpectedVotesLabel),
|
|
nameof(TurnoutBoardVisibility),
|
|
nameof(CandidateBoardVisibility),
|
|
nameof(CountingActionsVisibility));
|
|
}
|
|
|
|
private void NotifyMetricPresentationChanged()
|
|
{
|
|
OnPropertyChanged(
|
|
nameof(CountedVotes),
|
|
nameof(RemainingVotes),
|
|
nameof(TurnoutRemainingVotes),
|
|
nameof(TurnoutRate),
|
|
nameof(TurnoutRateDisplay),
|
|
nameof(SituationMetricPrimaryValue),
|
|
nameof(SituationMetricSecondaryValue),
|
|
nameof(HeaderMetricSummary));
|
|
}
|
|
|
|
private static void EnsureOptionExists(ObservableCollection<SelectionOption<string>> options, string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value) || options.Any(option => string.Equals(option.Value, value, StringComparison.Ordinal)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
options.Add(new SelectionOption<string>(value, value));
|
|
}
|
|
}
|