초기 커밋.
This commit is contained in:
730
Tornado3_2026Election/ViewModels/DataViewModel.cs
Normal file
730
Tornado3_2026Election/ViewModels/DataViewModel.cs
Normal file
@@ -0,0 +1,730 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user