초기 커밋.

This commit is contained in:
2026-03-25 17:26:16 +09:00
commit 7b0d900bdb
86 changed files with 20087 additions and 0 deletions

View 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));
}
}