초기 커밋.

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

63
.gitattributes vendored Normal file
View File

@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

363
.gitignore vendored Normal file
View File

@@ -0,0 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

228
SYSTEM_SPEC.md Normal file
View File

@@ -0,0 +1,228 @@
# 선거방송 송출 프로그램 요구사항 정의 (v0.1)
---
## 1. 시스템 개요
### 1.1 목적
- Tornado3를 통해 선거 방송 자막 송출
- 포맷(디자인)에 데이터 매핑 후 송출
---
## 2. 데이터 처리
### 2.1 데이터 수신
- Polling 기반
- 수동 수신 가능
### 2.2 수동 수신 정책
- 수동 수신 시 polling 주기 초기화
- 3초 이내 재요청 금지
### 2.3 갱신 vs 송출
- 갱신 중 송출 요청 시 → 갱신 완료 후 송출
### 2.4 데이터 기준
- 득표율: 소수점 1자리 반올림
- 득표수: 3자리 콤마
### 2.5 데이터 유효성
- 필수 필드 누락 시 송출 금지
- 사진 필수 포맷에서 이미지 없으면 송출 금지
---
## 3. 포맷
### 3.1 정의
- 디자인 템플릿
- 데이터 매핑 구조
### 3.2 구조
- 포맷 → 컷 → (준비 → 송출)
### 3.3 루프
- 하위 범주 반복 (예: 시도별 17개)
### 3.4 데이터 반영
- 현재 컷 반영 금지
- 다음 컷부터 반영
### 3.5 송출 시간
- 포맷별 설정 가능
- 변경 시 다음 컷부터 적용
---
## 4. 스케줄
### 4.1 구조
- 큐 기반
### 4.2 상태
- 현재 송출: 빨간색
- 다음 송출: 주황색
### 4.3 제어
- 다음 포맷 변경 가능
- 현재 포맷 강제 중지 후 전환 가능
- 순서 변경 가능
### 4.4 삭제 정책
- 대기 포맷 삭제 가능
- 송출 중 포맷 삭제 불가
### 4.5 루프
- 전체 루프 가능
- 첫 포맷부터 재시작
### 4.6 빈 스케줄
- 설정에 따라:
- 즉시 Out
- 마지막 화면 유지
### 4.7 종료
- 수동 종료 시:
- 스케줄 종료
- 해당 Layer Out
---
## 5. 방송 영역
- 노멀
- 좌상단
- 하단
- VideoWall
특징:
- 독립 스케줄
- 동시 송출 가능
---
## 6. 방송사 설정
### 대상
- KNN, TBC, KBC, G1, TJB, JTV
### 특징
- 동일 구조
- 지역 필터만 다름
### 지역 필터
- 기본값 제공
- 사용자 수정 가능
---
## 7. 유력/확실/당선
### 기준
- 후보 단위
### 수동 입력
- 콤보박스 선택
- 자동 판정보다 우선
### 자동 판정
- 수동 지정 없는 경우만 적용
- 당선 조건:
- (1위 - 2위) > 남은 개표수
### 초기화
- 전체 초기화 가능
### 저장
- 방송사 + 선거종류 + 선거구 + 후보 기준
---
## 8. Tornado3 연동
### 방식
- TCP + DLL
### 기능
- 이미지 변경
- 텍스트 변경
- 준비
- 송출
### 응답 처리
- ACK 대기 없음
- 5초 내 응답 없으면 실패
### 상태
- IDLE
- READY (송출 가능 상태)
- SENDING
- ON_AIR
- ERROR
### 연결
- 끊김 시 재연결
- 재연결 후 사용자 확인 후 재개
---
## 9. 상태 흐름
IDLE → READY → SENDING → ON_AIR → NEXT
ERROR
---
## 10. 복원
### 대상
- 스케줄
- 방송사
- 상태값
### 방식
- 통합 대화상자
- 체크박스 선택
---
## 11. UI 구조
### 네비게이션
- 통합 스케줄
- 노멀
- 좌상단
- 하단
- VideoWall
- 데이터
- 설정
- 로그
### 표시
- 빨강: 현재 송출
- 주황: 다음 송출
---
## 12. 이미지
경로 규칙:
{선택경로}/{선거종류}/{지역코드}/{후보코드}.png
---
## 13. 예외 처리
- API 실패 → 사용자 알림
- Tornado 실패 → ERROR 상태
---
## 14. 핵심 개념
- 포맷 기반
- 컷 단위 송출
- 스케줄 큐 구조
- 상태 머신 기반 제어

View File

@@ -0,0 +1,13 @@
<Solution>
<Configurations>
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="Tornado3_2026Election/Tornado3_2026Election.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
<Deploy />
</Project>
</Solution>

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="Tornado3_2026Election.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Tornado3_2026Election">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
<SolidColorBrush x:Key="ControlRoomChromeBrush" Color="#08111F" />
<SolidColorBrush x:Key="ControlRoomPanelBrush" Color="#0F1B2D" />
<SolidColorBrush x:Key="ControlRoomPanelAltBrush" Color="#132338" />
<SolidColorBrush x:Key="ControlRoomPanelSoftBrush" Color="#192C45" />
<SolidColorBrush x:Key="ControlRoomPanelStrokeBrush" Color="#2D4566" />
<SolidColorBrush x:Key="ControlRoomTextPrimaryBrush" Color="#F8FAFC" />
<SolidColorBrush x:Key="ControlRoomTextSecondaryBrush" Color="#B7C5D8" />
<SolidColorBrush x:Key="ControlRoomTextMutedBrush" Color="#7F96B5" />
<SolidColorBrush x:Key="ControlRoomSignalRedBrush" Color="#FF5A54" />
<SolidColorBrush x:Key="ControlRoomSignalAmberBrush" Color="#FFB44C" />
<SolidColorBrush x:Key="ControlRoomSignalGreenBrush" Color="#3DD38A" />
<SolidColorBrush x:Key="ControlRoomSignalBlueBrush" Color="#5FA9FF" />
<LinearGradientBrush x:Key="ControlRoomHeroBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#111E34" Offset="0" />
<GradientStop Color="#0B1422" Offset="0.55" />
<GradientStop Color="#20192E" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="ControlRoomPanelGradientBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#12233A" Offset="0" />
<GradientStop Color="#0E192B" Offset="1" />
</LinearGradientBrush>
<Style x:Key="ConsoleHeroTitleTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Bahnschrift SemiBold" />
<Setter Property="FontSize" Value="34" />
<Setter Property="Foreground" Value="{StaticResource ControlRoomTextPrimaryBrush}" />
</Style>
<Style x:Key="ConsoleSectionTitleTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Bahnschrift SemiBold" />
<Setter Property="FontSize" Value="22" />
<Setter Property="Foreground" Value="{StaticResource ControlRoomTextPrimaryBrush}" />
</Style>
<Style x:Key="ConsoleLabelTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Bahnschrift" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource ControlRoomTextMutedBrush}" />
<Setter Property="TextWrapping" Value="WrapWholeWords" />
</Style>
<Style x:Key="ConsoleBodyTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Bahnschrift" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{StaticResource ControlRoomTextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="WrapWholeWords" />
</Style>
<Style x:Key="ConsoleMonoTextStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Cascadia Mono" />
<Setter Property="Foreground" Value="{StaticResource ControlRoomTextPrimaryBrush}" />
</Style>
<Style x:Key="ConsolePrimaryButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
<Setter Property="Background" Value="{StaticResource ControlRoomSignalBlueBrush}" />
<Setter Property="Foreground" Value="#03111F" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="16,10" />
<Setter Property="FontFamily" Value="Bahnschrift SemiBold" />
</Style>
<Style x:Key="ConsoleGhostButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
<Setter Property="Background" Value="#1F3048" />
<Setter Property="Foreground" Value="{StaticResource ControlRoomTextPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource ControlRoomPanelStrokeBrush}" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="FontFamily" Value="Bahnschrift SemiBold" />
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,52 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using Microsoft.UI.Xaml.Shapes;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Tornado3_2026Election.Common;
using Windows.ApplicationModel;
using Windows.ApplicationModel.Activation;
using Windows.Foundation;
using Windows.Foundation.Collections;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Tornado3_2026Election
{
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : Application
{
private Window? _window;
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
InitializeComponent();
}
/// <summary>
/// Invoked when the application is launched.
/// </summary>
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
UiDispatcher.Initialize(Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread());
_window = new MainWindow();
_window.Activate();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g fill="none" stroke="#E11D2E" stroke-linecap="round" stroke-linejoin="round">
<circle cx="512" cy="512" r="392" stroke-width="72" />
<line x1="512" y1="152" x2="512" y2="872" stroke-width="72" />
<line x1="512" y1="512" x2="766" y2="766" stroke-width="72" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,54 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
namespace Tornado3_2026Election.Common;
public sealed class AsyncRelayCommand : ObservableObject, ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isRunning;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool IsRunning
{
get => _isRunning;
private set
{
if (SetProperty(ref _isRunning, value))
{
NotifyCanExecuteChanged();
}
}
}
public bool CanExecute(object? parameter) => !IsRunning && (_canExecute?.Invoke() ?? true);
public async void Execute(object? parameter)
{
if (!CanExecute(parameter))
{
return;
}
try
{
IsRunning = true;
await _execute();
}
finally
{
IsRunning = false;
}
}
public void NotifyCanExecuteChanged() => UiDispatcher.Enqueue(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Tornado3_2026Election.Common;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
UiDispatcher.Enqueue(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
}
protected void OnPropertyChanged(params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
UiDispatcher.Enqueue(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
}
}
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Windows.Input;
namespace Tornado3_2026Election.Common;
public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public void NotifyCanExecuteChanged() => UiDispatcher.Enqueue(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Windows.Input;
namespace Tornado3_2026Election.Common;
public sealed class RelayCommand<T> : ICommand
{
private readonly Action<T?> _execute;
private readonly Func<T?, bool>? _canExecute;
public RelayCommand(Action<T?> execute, Func<T?, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
if (parameter is null)
{
return _canExecute?.Invoke(default) ?? true;
}
return parameter is T typedParameter && (_canExecute?.Invoke(typedParameter) ?? true);
}
public void Execute(object? parameter)
{
if (parameter is null)
{
_execute(default);
return;
}
if (parameter is T typedParameter)
{
_execute(typedParameter);
}
}
public void NotifyCanExecuteChanged() => UiDispatcher.Enqueue(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Threading.Tasks;
using Microsoft.UI.Dispatching;
namespace Tornado3_2026Election.Common;
public static class UiDispatcher
{
private static DispatcherQueue? _dispatcherQueue;
public static void Initialize(DispatcherQueue? dispatcherQueue)
{
_dispatcherQueue ??= dispatcherQueue;
}
public static void Enqueue(Action action)
{
var dispatcherQueue = _dispatcherQueue;
if (dispatcherQueue is null || dispatcherQueue.HasThreadAccess)
{
action();
return;
}
_ = dispatcherQueue.TryEnqueue(() => action());
}
public static Task EnqueueAsync(Action action)
{
var dispatcherQueue = _dispatcherQueue;
if (dispatcherQueue is null || dispatcherQueue.HasThreadAccess)
{
action();
return Task.CompletedTask;
}
var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
if (!dispatcherQueue.TryEnqueue(() =>
{
try
{
action();
completionSource.SetResult();
}
catch (Exception exception)
{
completionSource.SetException(exception);
}
}))
{
completionSource.SetResult();
}
return completionSource.Task;
}
}

View File

@@ -0,0 +1,331 @@
<UserControl
x:Class="Tornado3_2026Election.Controls.ChannelSchedulePanel"
x:Name="Root"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Tornado3_2026Election.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Tornado3_2026Election.ViewModels"
mc:Ignorable="d">
<UserControl.Resources>
<Style x:Key="PanelCommandButtonStyle" TargetType="Button" BasedOn="{StaticResource ConsoleGhostButtonStyle}">
<Setter Property="MinWidth" Value="78" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontFamily" Value="Bahnschrift SemiBold" />
</Style>
<Style x:Key="MiniSignalTextStyle" TargetType="TextBlock" BasedOn="{StaticResource ConsoleLabelTextStyle}">
<Setter Property="Foreground" Value="{StaticResource ControlRoomTextPrimaryBrush}" />
<Setter Property="FontFamily" Value="Consolas" />
</Style>
</UserControl.Resources>
<Border
Padding="22"
Background="{StaticResource ControlRoomPanelGradientBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="26">
<StackPanel Spacing="18">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="6">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="28"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.Title, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.OperatorQuickSummary, Mode=OneWay}" />
</StackPanel>
<StackPanel
Grid.Column="1"
HorizontalAlignment="Right"
Spacing="10">
<Border
Padding="12,8"
Background="#33FF5A54"
BorderBrush="{StaticResource ControlRoomSignalRedBrush}"
BorderThickness="1"
CornerRadius="14">
<TextBlock
Style="{StaticResource MiniSignalTextStyle}"
Text="{x:Bind ViewModel.TransmissionLabel, Mode=OneWay}" />
</Border>
<Border
Padding="12,8"
Background="#22293B52"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="14">
<TextBlock
Style="{StaticResource MiniSignalTextStyle}"
Text="{x:Bind ViewModel.AdapterStateLabel, Mode=OneWay}" />
</Border>
</StackPanel>
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.25*" />
<ColumnDefinition Width="1.25*" />
<ColumnDefinition Width="0.8*" />
</Grid.ColumnDefinitions>
<Border
Padding="16"
Background="#18263A"
BorderBrush="#25405D"
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="8">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="22"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.CurrentItemName, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.QueueSummary, Mode=OneWay}" />
</StackPanel>
</Border>
<Border
Grid.Column="1"
Padding="16"
Background="#1E2438"
BorderBrush="#5D4B35"
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="8">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="다음" />
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="22"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.NextItemName, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.LoopSummary, Mode=OneWay}" />
</StackPanel>
</Border>
<Border
Grid.Column="2"
Padding="16"
Background="#131D2B"
BorderBrush="#27405F"
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="대기열" />
<TextBlock
FontFamily="Consolas"
FontSize="30"
Foreground="{StaticResource ControlRoomSignalAmberBrush}"
Text="{x:Bind ViewModel.QueuedItemCount, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.QueueFootnote, Mode=OneWay}" />
</StackPanel>
</Border>
</Grid>
<Border
Padding="16"
Background="#0C1421"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="20">
<StackPanel Spacing="14">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="제어 패널" />
<Grid ColumnSpacing="12" RowSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="240" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
DisplayMemberPath="Name"
ItemsSource="{x:Bind ViewModel.AvailableFormats, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedFormat, Mode=TwoWay}" />
<Button
Grid.Column="1"
Command="{x:Bind ViewModel.AddFormatCommand}"
Content="포맷 추가"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<ToggleSwitch
Grid.Column="2"
Header="반복"
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
<ComboBox
Grid.Column="3"
Width="150"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.EmptyBehaviorOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedEmptyBehaviorOption, Mode=TwoWay}" />
<TextBlock
Grid.Column="4"
VerticalAlignment="Center"
Style="{StaticResource ConsoleBodyTextStyle}"
Text="빈 스케줄 처리, 강제 전환, 순서 변경까지 이 패널에서 즉시 수행합니다." />
</Grid>
<StackPanel
Orientation="Horizontal"
Spacing="10">
<Button
Command="{x:Bind ViewModel.StartCommand}"
Content="시작"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button
Command="{x:Bind ViewModel.StopCommand}"
Content="정지"
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button
Command="{x:Bind ViewModel.ForceNextCommand}"
Content="다음 강제"
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button
Command="{x:Bind ViewModel.ResetQueueCommand}"
Content="루프 초기화"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
</StackPanel>
</Border>
<Border
Padding="16"
Background="#0B1220"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="20">
<StackPanel Spacing="14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Style="{StaticResource ConsoleSectionTitleTextStyle}"
Text="스케줄 목록" />
<TextBlock
Grid.Column="1"
Style="{StaticResource ConsoleLabelTextStyle}"
Text="큐 엔진" />
</Grid>
<ListView
ItemsSource="{x:Bind ViewModel.Queue, Mode=OneWay}"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="domain:ChannelScheduleItem">
<Border
Margin="0,0,0,10"
Padding="14"
Background="#122033"
BorderBrush="#27405F"
BorderThickness="1"
CornerRadius="18">
<Grid ColumnSpacing="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="8" />
<ColumnDefinition Width="140" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border
Background="{x:Bind StateBrush}"
CornerRadius="4" />
<StackPanel Grid.Column="1" Spacing="6">
<Border
Padding="10,6"
Background="#1A2E47"
CornerRadius="12">
<TextBlock
Style="{StaticResource MiniSignalTextStyle}"
Text="{x:Bind StateLabel}" />
</Border>
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind LastPlayedLabel}" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="18"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind FormatName}" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind Description}" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}">
<Run Text="컷 " />
<Run Text="{x:Bind TotalCuts}" />
<Run Text=" | " />
<Run Text="기본 " />
<Run Text="{x:Bind DefaultCutDurationSeconds}" />
<Run Text="초" />
</TextBlock>
</StackPanel>
<StackPanel
Grid.Column="3"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<Button
Command="{Binding ViewModel.PromoteToNextCommand, ElementName=Root}"
CommandParameter="{Binding}"
Content="다음"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{Binding ViewModel.MoveUpCommand, ElementName=Root}"
CommandParameter="{Binding}"
Content="위"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{Binding ViewModel.MoveDownCommand, ElementName=Root}"
CommandParameter="{Binding}"
Content="아래"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{Binding ViewModel.RemoveItemCommand, ElementName=Root}"
CommandParameter="{Binding}"
Content="삭제"
Style="{StaticResource PanelCommandButtonStyle}" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</Border>
</StackPanel>
</Border>
</UserControl>

View File

@@ -0,0 +1,26 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Tornado3_2026Election.ViewModels;
namespace Tornado3_2026Election.Controls;
public sealed partial class ChannelSchedulePanel : UserControl
{
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(
nameof(ViewModel),
typeof(ChannelScheduleViewModel),
typeof(ChannelSchedulePanel),
new PropertyMetadata(null));
public ChannelSchedulePanel()
{
InitializeComponent();
}
public ChannelScheduleViewModel? ViewModel
{
get => (ChannelScheduleViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
{
"generatedAt": "2026-03-13T16:00:00+09:00",
"data": [
{
"id": "d6cb5e1a-1dcb-4e3b-b8a8-2b1b8f0e1a11",
"rank": 1,
"candidateNumber": "1",
"name": "예비후보자 홍길동",
"party": "더불어민주당",
"province": "서울특별시",
"region": "노원구",
"electionDistrict": "나선거구",
"voteCount": 0,
"voteRate": 0,
"electionStatus": "-없음"
},
{
"id": "6c7f4b8a-2f6e-4bde-a8d0-1f1f4f4f3b22",
"rank": 2,
"candidateNumber": "2",
"name": "예비후보자 김하나",
"party": "국민의힘",
"province": "서울특별시",
"region": "노원구",
"electionDistrict": "나선거구",
"voteCount": 0,
"voteRate": 0,
"electionStatus": "-없음"
},
{
"id": "95d1ab4d-9c3d-4c7f-90f9-a7dd7c4d5d33",
"rank": 1,
"candidateNumber": "3",
"name": "예비후보자 이무진",
"party": "열린민주당",
"province": "부산광역시",
"region": "해운대구",
"electionDistrict": "다선거구",
"voteCount": 0,
"voteRate": 0,
"electionStatus": "-없음"
}
]
}

View File

@@ -0,0 +1,13 @@
namespace Tornado3_2026Election.Domain;
public enum AppPage
{
IntegratedSchedule,
Normal,
TopLeft,
Bottom,
VideoWall,
Data,
Settings,
Log
}

View File

@@ -0,0 +1,9 @@
namespace Tornado3_2026Election.Domain;
public enum BroadcastChannel
{
Normal,
TopLeft,
Bottom,
VideoWall
}

View File

@@ -0,0 +1,7 @@
namespace Tornado3_2026Election.Domain;
public enum BroadcastPhase
{
PreElection,
Counting
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace Tornado3_2026Election.Domain;
public sealed class BroadcastStationProfile
{
public required string Id { get; init; }
public required string Name { get; init; }
public string LogoAssetPath { get; init; } = string.Empty;
public required IReadOnlyList<string> RegionFilters { get; init; }
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Text.Json.Serialization;
using Tornado3_2026Election.Common;
namespace Tornado3_2026Election.Domain;
public sealed class CandidateEntry : ObservableObject
{
private string _candidateCode = string.Empty;
private string _name = string.Empty;
private string _party = string.Empty;
private int _voteCount;
private double _voteRate;
private bool _hasImage = true;
private CandidateJudgement _manualJudgement;
private CandidateJudgement _automaticJudgement;
public Guid Id { get; set; } = Guid.NewGuid();
public string CandidateCode
{
get => _candidateCode;
set => SetProperty(ref _candidateCode, value);
}
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
public string Party
{
get => _party;
set => SetProperty(ref _party, value);
}
public int VoteCount
{
get => _voteCount;
set => SetProperty(ref _voteCount, value);
}
public double VoteRate
{
get => _voteRate;
set => SetProperty(ref _voteRate, value);
}
public bool HasImage
{
get => _hasImage;
set => SetProperty(ref _hasImage, value);
}
public CandidateJudgement ManualJudgement
{
get => _manualJudgement;
set
{
if (SetProperty(ref _manualJudgement, value))
{
OnPropertyChanged(nameof(EffectiveJudgement));
OnPropertyChanged(nameof(EffectiveJudgementLabel));
}
}
}
public CandidateJudgement AutomaticJudgement
{
get => _automaticJudgement;
set
{
if (SetProperty(ref _automaticJudgement, value))
{
OnPropertyChanged(nameof(EffectiveJudgement));
OnPropertyChanged(nameof(EffectiveJudgementLabel));
}
}
}
[JsonIgnore]
public CandidateJudgement EffectiveJudgement => ManualJudgement != CandidateJudgement.None ? ManualJudgement : AutomaticJudgement;
[JsonIgnore]
public string EffectiveJudgementLabel => EffectiveJudgement switch
{
CandidateJudgement.Leading => "유력",
CandidateJudgement.Confirmed => "확실",
CandidateJudgement.Elected => "당선",
_ => "-"
};
[JsonIgnore]
public string VoteCountDisplay => VoteCount.ToString("N0");
[JsonIgnore]
public string VoteRateDisplay => Math.Round(VoteRate, 1, MidpointRounding.AwayFromZero).ToString("0.0");
public CandidateEntry Clone()
{
return new CandidateEntry
{
Id = Id,
CandidateCode = CandidateCode,
Name = Name,
Party = Party,
VoteCount = VoteCount,
VoteRate = VoteRate,
HasImage = HasImage,
ManualJudgement = ManualJudgement,
AutomaticJudgement = AutomaticJudgement
};
}
}

View File

@@ -0,0 +1,9 @@
namespace Tornado3_2026Election.Domain;
public enum CandidateJudgement
{
None,
Leading,
Confirmed,
Elected
}

View File

@@ -0,0 +1,7 @@
namespace Tornado3_2026Election.Domain;
public enum ChannelOperationMode
{
General,
VideoWall
}

View File

@@ -0,0 +1,99 @@
using System;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI;
using Tornado3_2026Election.Common;
namespace Tornado3_2026Election.Domain;
public sealed class ChannelScheduleItem : ObservableObject
{
private ScheduleQueueItemState _state = ScheduleQueueItemState.Queued;
private string _lastError = string.Empty;
private DateTimeOffset? _lastPlayedAt;
public Guid Id { get; set; } = Guid.NewGuid();
public required string FormatId { get; init; }
public required string FormatName { get; init; }
public required string Description { get; init; }
public required BroadcastChannel Channel { get; init; }
public required bool RequiresImage { get; init; }
public required double DefaultCutDurationSeconds { get; init; }
public required int TotalCuts { get; init; }
public ScheduleQueueItemState State
{
get => _state;
set
{
if (SetProperty(ref _state, value))
{
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StateBrush));
OnPropertyChanged(nameof(CanDelete));
}
}
}
public string LastError
{
get => _lastError;
set => SetProperty(ref _lastError, value);
}
public DateTimeOffset? LastPlayedAt
{
get => _lastPlayedAt;
set => SetProperty(ref _lastPlayedAt, value);
}
[JsonIgnore]
public string StateLabel => State switch
{
ScheduleQueueItemState.Next => "다음",
ScheduleQueueItemState.Sending => "준비",
ScheduleQueueItemState.OnAir => "송출중",
ScheduleQueueItemState.Completed => "완료",
ScheduleQueueItemState.Error => "오류",
_ => "대기"
};
[JsonIgnore]
public SolidColorBrush StateBrush => new(State switch
{
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 145, 77),
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 191, 0),
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 202, 52, 52),
ScheduleQueueItemState.Completed => ColorHelper.FromArgb(255, 68, 104, 77),
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 110, 39, 39),
_ => ColorHelper.FromArgb(255, 80, 90, 110)
});
[JsonIgnore]
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
[JsonIgnore]
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "not played";
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template)
{
return new ChannelScheduleItem
{
FormatId = template.Id,
FormatName = template.Name,
Description = template.Description,
Channel = template.RecommendedChannel,
RequiresImage = template.RequiresImage,
DefaultCutDurationSeconds = template.Cuts.First().DurationSeconds,
TotalCuts = template.Cuts.Count
};
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Tornado3_2026Election.Domain;
public sealed class ElectionDataSnapshot
{
public required BroadcastPhase BroadcastPhase { get; init; }
public required string ElectionType { get; init; }
public required string DistrictName { get; init; }
public required string DistrictCode { get; init; }
public required IReadOnlyList<CandidateEntry> Candidates { get; init; }
public required int TotalExpectedVotes { get; init; }
public required int TurnoutVotes { get; init; }
public required DateTimeOffset ReceivedAt { get; init; }
public int CountedVotes => Candidates.Sum(candidate => candidate.VoteCount);
public int RemainingVotes => Math.Max(0, TotalExpectedVotes - CountedVotes);
public double TurnoutRate => TotalExpectedVotes <= 0
? 0
: Math.Round(TurnoutVotes * 100d / TotalExpectedVotes, 1, MidpointRounding.AwayFromZero);
}

View File

@@ -0,0 +1,7 @@
namespace Tornado3_2026Election.Domain;
public enum EmptyScheduleBehavior
{
ImmediateOut,
HoldLastFrame
}

View File

@@ -0,0 +1,8 @@
namespace Tornado3_2026Election.Domain;
public sealed class FormatCutDefinition
{
public required string Name { get; init; }
public required double DurationSeconds { get; init; }
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace Tornado3_2026Election.Domain;
public sealed class FormatTemplateDefinition
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required BroadcastChannel RecommendedChannel { get; init; }
public required bool RequiresImage { get; init; }
public required LoopMode LoopMode { get; init; }
public required IReadOnlyList<FormatCutDefinition> Cuts { get; init; }
}

View File

@@ -0,0 +1,22 @@
using System;
namespace Tornado3_2026Election.Domain;
public sealed class LogEntry
{
public required DateTimeOffset Timestamp { get; init; }
public required LogLevel Level { get; init; }
public required string Message { get; init; }
public string LevelLabel => Level switch
{
LogLevel.Info => "정보",
LogLevel.Warning => "경고",
LogLevel.Error => "오류",
_ => "정보"
};
public string Display => $"[{Timestamp:HH:mm:ss}] {LevelLabel} {Message}";
}

View File

@@ -0,0 +1,8 @@
namespace Tornado3_2026Election.Domain;
public enum LogLevel
{
Info,
Warning,
Error
}

View File

@@ -0,0 +1,7 @@
namespace Tornado3_2026Election.Domain;
public enum LoopMode
{
None,
StationRegions
}

View File

@@ -0,0 +1,11 @@
namespace Tornado3_2026Election.Domain;
public enum ScheduleQueueItemState
{
Queued,
Next,
Sending,
OnAir,
Completed,
Error
}

View File

@@ -0,0 +1,10 @@
namespace Tornado3_2026Election.Domain;
public enum TornadoConnectionState
{
Idle,
Ready,
Sending,
OnAir,
Error
}

View File

@@ -0,0 +1,520 @@
<Window x:Class="Tornado3_2026Election.MainWindow"
x:Name="RootWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Tornado3_2026Election.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Tornado3_2026Election.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Tornado3_2026Election.ViewModels"
mc:Ignorable="d"
Title="Tornado3 선거방송 상황실">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<NavigationView x:Name="MainNavigationView"
AlwaysShowHeader="False"
Background="{StaticResource ControlRoomChromeBrush}"
IsBackButtonVisible="Collapsed"
IsSettingsVisible="False"
PaneDisplayMode="Left"
RequestedTheme="Dark"
SelectionChanged="MainNavigationView_SelectionChanged">
<NavigationView.PaneHeader>
<Border Margin="12,14,12,6"
Padding="16"
Background="#101A2A"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="20">
<StackPanel Spacing="10">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="선택 방송사" />
<Image Height="38"
HorizontalAlignment="Left"
MaxWidth="170"
Source="{x:Bind ViewModel.SelectedStationLogo, Mode=OneWay}"
Stretch="Uniform"
Visibility="{x:Bind ViewModel.SelectedStationLogoVisibility, Mode=OneWay}" />
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="16"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" />
</StackPanel>
</Border>
</NavigationView.PaneHeader>
<NavigationView.MenuItems>
<NavigationViewItem Content="통합 스케줄" Tag="integrated"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="노멀" Tag="normal" Visibility="{x:Bind ViewModel.NormalMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Play" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="좌상단" Tag="top-left" Visibility="{x:Bind ViewModel.TopLeftMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="PreviewLink" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="데이터" Tag="data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="설정" Tag="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>
</NavigationView.MenuItems>
<Grid Padding="28">
<Grid.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#08111F" Offset="0" />
<GradientStop Color="#0D182A" Offset="0.5" />
<GradientStop Color="#12131E" Offset="1" />
</LinearGradientBrush>
</Grid.Background>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Padding="18"
Background="{StaticResource ControlRoomHeroBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="24">
<StackPanel Spacing="14">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="8">
<TextBlock Style="{StaticResource ConsoleHeroTitleTextStyle}"
FontSize="27"
Text="선거방송 송출 상황실" />
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="Consolas"
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
Text="{x:Bind ViewModel.Data.PollingCountdownText, Mode=OneWay}"
VerticalAlignment="Center" />
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}"
Content="수동 수신"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재 메뉴" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="16" Foreground="{StaticResource ControlRoomSignalBlueBrush}" Text="{x:Bind ViewModel.CurrentPageTitle, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="방송 단계" />
<ToggleSwitch x:Name="BroadcastPhaseToggleSwitch"
OffContent="사전"
OnContent="개표"
IsOn="{x:Bind ViewModel.Data.IsCountingPhase, Mode=OneWay}"
Toggled="BroadcastPhaseToggleSwitch_Toggled" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.BroadcastPhaseBadgeText, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="2" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="운영 모드" />
<ToggleSwitch x:Name="OperationModeToggleSwitch"
OffContent="일반"
OnContent="비디오월"
IsOn="{x:Bind ViewModel.IsVideoWallOperationMode, Mode=OneWay}"
Toggled="OperationModeToggleSwitch_Toggled" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.OperationModeBadgeText, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="3" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="Tornado 연결" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="16" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.TornadoConnectionSummary, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="4" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="마지막 수신" />
<TextBlock FontFamily="Consolas" FontSize="16" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.LastRefreshDisplay, Mode=OneWay}" />
</StackPanel>
</Border>
</Grid>
</StackPanel>
<Button Grid.Column="1"
Command="{x:Bind ViewModel.ToggleSituationRoomCommand}"
Content="{x:Bind ViewModel.SituationRoomToggleText, Mode=OneWay}"
VerticalAlignment="Top"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</Grid>
<Grid Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"
ColumnSpacing="20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.8*" />
<ColumnDefinition Width="1.2*" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="10">
<TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextSecondaryBrush}" Text="{x:Bind ViewModel.HeaderStatus, Mode=OneWay}" />
</StackPanel>
<Grid Grid.Column="1" ColumnSpacing="12" RowSpacing="12">
<Grid.RowDefinitions><RowDefinition Height="*" /><RowDefinition Height="*" /></Grid.RowDefinitions>
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<Border Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="방송사" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="22" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" /></StackPanel>
</Border>
<Border Grid.Column="1" Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="선거구" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="22" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.DistrictName, Mode=OneWay}" /></StackPanel>
</Border>
<Border Grid.Row="1" Padding="16" Background="#18263A" BorderBrush="#35506F" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.SituationMetricPrimaryLabel, Mode=OneWay}" /><TextBlock FontFamily="Consolas" FontSize="24" Foreground="{StaticResource ControlRoomSignalGreenBrush}" Text="{x:Bind ViewModel.Data.SituationMetricPrimaryValue, Mode=OneWay}" /></StackPanel>
</Border>
<Border Grid.Row="1" Grid.Column="1" Padding="16" Background="#201B2F" BorderBrush="#60475A" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.SituationMetricSecondaryLabel, Mode=OneWay}" /><TextBlock FontFamily="Consolas" FontSize="24" Foreground="{StaticResource ControlRoomSignalAmberBrush}" Text="{x:Bind ViewModel.Data.SituationMetricSecondaryValue, Mode=OneWay}" /></StackPanel>
</Border>
</Grid>
</Grid>
</StackPanel>
</Border>
<Grid Grid.Row="1">
<ScrollViewer Visibility="{x:Bind ViewModel.IntegratedScheduleVisibility, Mode=OneWay}">
<StackPanel Spacing="20">
<Grid ColumnSpacing="20">
<Grid.ColumnDefinitions><ColumnDefinition Width="2*" /><ColumnDefinition Width="1*" /></Grid.ColumnDefinitions>
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<Grid>
<StackPanel Spacing="10" Visibility="{x:Bind ViewModel.GeneralIntegratedVisibility, Mode=OneWay}">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 송출 매트릭스" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="일반 모드에서는 노멀, 좌상단, 하단 채널을 통합 상황실에서 함께 운영합니다." />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<Border Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="노멀" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.NormalChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.NormalChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
<Border Grid.Column="1" Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="좌상단" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.TopLeftChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.TopLeftChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
<Border Grid.Column="2" Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="하단" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.BottomChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.BottomChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
</Grid>
</StackPanel>
<StackPanel Spacing="10" Visibility="{x:Bind ViewModel.VideoWallIntegratedVisibility, Mode=OneWay}">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 송출 매트릭스" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="비디오월 모드에서는 대형 화면 연출 채널만 단독으로 운영합니다." />
<Border Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="비디오월" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.VideoWallChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.VideoWallChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
</StackPanel>
</Grid>
</Border>
<StackPanel Grid.Column="1" Spacing="20">
<Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="12">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="빠른 실행" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="운영 중 저장과 복원을 빠르게 수행합니다." />
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Command="{x:Bind ViewModel.SaveStateCommand}" Content="상태 저장" Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button Command="{x:Bind ViewModel.RestoreStateCommand}" Content="복원" Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="10">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="운영 정책" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 갱신 중 송출 요청 시 갱신 완료 후 송출" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 수동 수신은 3초 이내 재요청 금지" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 이미지 필수 포맷은 사진 누락 시 송출 차단" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Visibility="{x:Bind ViewModel.GeneralIntegratedVisibility, Mode=OneWay}" ColumnSpacing="20" RowSpacing="20">
<Grid.RowDefinitions><RowDefinition Height="Auto" /><RowDefinition Height="Auto" /></Grid.RowDefinitions>
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.NormalChannel, Mode=OneWay}" />
<controls:ChannelSchedulePanel Grid.Column="1" ViewModel="{x:Bind ViewModel.TopLeftChannel, Mode=OneWay}" />
<controls:ChannelSchedulePanel Grid.Row="1" Grid.ColumnSpan="2" ViewModel="{x:Bind ViewModel.BottomChannel, Mode=OneWay}" />
</Grid>
<controls:ChannelSchedulePanel Visibility="{x:Bind ViewModel.VideoWallIntegratedVisibility, Mode=OneWay}" ViewModel="{x:Bind ViewModel.VideoWallChannel, Mode=OneWay}" />
</StackPanel>
</ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.NormalVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="노멀 채널은 메인 자막과 시도 루프의 핵심 송출 구간입니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.NormalChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.TopLeftVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="좌상단 채널은 간결한 보조 정보와 속보형 상태 표시를 담당합니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.TopLeftChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.BottomVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="하단 채널은 라인형 득표 바와 속보 자막을 위한 전용 런다운입니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.BottomChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.VideoWallVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="비디오월 채널은 사진과 당선 카드 중심의 대형 시각 연출을 담당합니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.VideoWallChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.DataVisibility, Mode=OneWay}">
<StackPanel Spacing="20">
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="16">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="Data Ingest Deck" />
<Grid ColumnSpacing="12" RowSpacing="12">
<Grid.ColumnDefinitions><ColumnDefinition Width="220" /><ColumnDefinition Width="220" /><ColumnDefinition Width="180" /><ColumnDefinition Width="180" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<ComboBox Header="선거 종류"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.Data.ElectionTypeOptions, Mode=OneWay}"
SelectedValue="{x:Bind ViewModel.Data.ElectionType, Mode=TwoWay}"
SelectedValuePath="Value" />
<ComboBox Grid.Column="1"
Header="선거구명"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.Data.DistrictOptions, Mode=OneWay}"
SelectedValue="{x:Bind ViewModel.Data.DistrictName, Mode=TwoWay}"
SelectedValuePath="Value" />
<TextBox Grid.Column="2" Header="지역 코드" Text="{x:Bind ViewModel.Data.DistrictCode, Mode=TwoWay}" />
<NumberBox Grid.Column="3" Header="{x:Bind ViewModel.Data.TotalExpectedVotesLabel, Mode=OneWay}" Minimum="1" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.TotalExpectedVotes, Mode=TwoWay}" />
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
<ToggleSwitch Header="API 자동 수신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
<NumberBox Width="140" Header="주기(초)" Minimum="3" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 수신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button Command="{x:Bind ViewModel.Data.AddCandidateCommand}" Content="후보 추가" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
<Button Command="{x:Bind ViewModel.Data.ResetManualJudgementsCommand}" Content="수동 판정 초기화" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border Padding="20"
Background="{StaticResource ControlRoomPanelGradientBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="24"
Visibility="{x:Bind ViewModel.Data.TurnoutBoardVisibility, Mode=OneWay}">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="투표율 현황" />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220" />
<ColumnDefinition Width="220" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<NumberBox Header="투표자 수"
Minimum="0"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Data.TurnoutVotes, Mode=TwoWay}" />
<Border Grid.Column="1"
Padding="16"
Background="#132338"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="투표율" />
<TextBlock FontFamily="Consolas"
FontSize="24"
Foreground="{StaticResource ControlRoomSignalGreenBrush}"
Text="{x:Bind ViewModel.Data.TurnoutRateDisplay, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="2"
Padding="16"
Background="#101C2E"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="수신 정책" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="사전 단계에서는 투표율과 투표자 수 중심으로 수신하며 후보 득표수 편집은 잠시 숨깁니다." />
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
<Border Padding="20"
Background="{StaticResource ControlRoomPanelGradientBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="24"
Visibility="{x:Bind ViewModel.Data.CandidateBoardVisibility, Mode=OneWay}">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="후보 현황" />
<ListView ItemsSource="{x:Bind ViewModel.Data.Candidates, Mode=OneWay}" SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="domain:CandidateEntry">
<Border Margin="0,0,0,10" Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="20">
<Grid ColumnSpacing="14">
<Grid.ColumnDefinitions><ColumnDefinition Width="120" /><ColumnDefinition Width="140" /><ColumnDefinition Width="140" /><ColumnDefinition Width="120" /><ColumnDefinition Width="120" /><ColumnDefinition Width="140" /><ColumnDefinition Width="110" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Header="후보코드" Text="{Binding CandidateCode, Mode=TwoWay}" />
<TextBox Grid.Column="1" Header="이름" Text="{Binding Name, Mode=TwoWay}" />
<TextBox Grid.Column="2" Header="정당" Text="{Binding Party, Mode=TwoWay}" />
<NumberBox Grid.Column="3" Header="득표수" Minimum="0" SpinButtonPlacementMode="Compact" Value="{Binding VoteCount, Mode=TwoWay}" />
<NumberBox Grid.Column="4" Header="득표율" Minimum="0" Maximum="100" SmallChange="0.1" SpinButtonPlacementMode="Compact" Value="{Binding VoteRate, Mode=TwoWay}" />
<ComboBox Grid.Column="5"
Header="수동 판정"
DisplayMemberPath="Label"
ItemsSource="{Binding ViewModel.Data.JudgementOptions, ElementName=RootWindow}"
SelectedValue="{Binding ManualJudgement, Mode=TwoWay}"
SelectedValuePath="Value" />
<StackPanel Grid.Column="6" Spacing="6"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="판정" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="18" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind EffectiveJudgementLabel}" /><ToggleButton IsChecked="{Binding HasImage, Mode=TwoWay}" Content="사진" /></StackPanel>
<StackPanel Grid.Column="7" Orientation="Horizontal" Spacing="8" VerticalAlignment="Bottom"><Button Command="{Binding ViewModel.Data.RemoveCandidateCommand, ElementName=RootWindow}" CommandParameter="{Binding}" Content="삭제" Style="{StaticResource ConsoleGhostButtonStyle}" /></StackPanel>
</Grid>
</Border>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.SettingsVisibility, Mode=OneWay}">
<StackPanel Spacing="20">
<Grid ColumnSpacing="20">
<Grid.ColumnDefinitions><ColumnDefinition Width="1.2*" /><ColumnDefinition Width="0.8*" /></Grid.ColumnDefinitions>
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="방송사 프로필" />
<Grid ColumnSpacing="12"><Grid.ColumnDefinitions><ColumnDefinition Width="280" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<ComboBox DisplayMemberPath="Name" Header="방송사" ItemsSource="{x:Bind ViewModel.Settings.Stations, Mode=OneWay}" SelectedValue="{x:Bind ViewModel.Settings.SelectedStationId, Mode=TwoWay}" SelectedValuePath="Id" />
<Grid Grid.Column="1" ColumnSpacing="10">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBox Header="이미지 루트 경로"
IsReadOnly="True"
IsSpellCheckEnabled="False"
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}" />
<Button Grid.Column="1"
Click="PickImageRootFolderButton_Click"
Content="폴더 선택"
VerticalAlignment="Bottom"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</Grid>
</Grid>
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="여기서는 현재 선택한 방송사만 편집합니다. 권역 루프에 들어갈 시도는 아래 17개 체크 항목으로 관리됩니다." />
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="12">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시작 및 수신 옵션" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="첫 실행 때만 방송사를 묻고, 이후에는 이 옵션대로 자동 복원합니다. 변경 내용은 자동 저장됩니다." />
<CheckBox Content="스케줄 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreSchedules, Mode=TwoWay}" />
<CheckBox Content="방송사 설정 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStations, Mode=TwoWay}" />
<CheckBox Content="상태값 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStatusValues, Mode=TwoWay}" />
<ToggleSwitch Header="API 자동 수신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
<NumberBox Header="API 수신 주기(초)"
Minimum="3"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
</StackPanel>
</Border>
</Grid>
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="14">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="권역 설정" />
<Grid ColumnSpacing="16" RowSpacing="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Padding="18" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="편집 대상" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="26" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.Settings.SelectedStationRegionSummary, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="18" Background="#101C2E" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="8" VerticalAlignment="Center">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="권역 정책" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="선택된 방송사 한 곳만 수정됩니다. 체크된 시도만 채널별 권역 루프와 방송사 프로필에 반영됩니다." />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Settings.SelectedStation.RegionFiltersText, Mode=OneWay}" />
</StackPanel>
</Border>
<ListView Grid.Row="1"
Grid.ColumnSpan="2"
ItemsSource="{x:Bind ViewModel.Settings.SelectedStationRegions, Mode=OneWay}"
IsItemClickEnabled="False"
SelectionMode="None"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<ItemsWrapGrid MaximumRowsOrColumns="4" Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:RegionOptionViewModel">
<Border Margin="0,0,12,12"
Padding="14,12"
MinWidth="150"
Background="#132338"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="14">
<CheckBox IsChecked="{x:Bind IsSelected, Mode=TwoWay}">
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="16"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind Name}" />
</CheckBox>
</Border>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.LogVisibility, Mode=OneWay}">
<StackPanel Spacing="20">
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="14">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<StackPanel Spacing="6"><TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시스템 이벤트 로그" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="송출, 복원, 수신, 검증, 어댑터 동작 로그를 시간순으로 추적합니다." /></StackPanel>
<Button Grid.Column="1" Command="{x:Bind ViewModel.ClearLogsCommand}" Content="로그 비우기" Style="{StaticResource ConsoleGhostButtonStyle}" />
</Grid>
<Grid ColumnSpacing="12" RowSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ComboBox Header="표시 수준"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.LogFilterOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedLogFilterOption, Mode=TwoWay}" />
<Border Grid.Column="1" Padding="14" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="16">
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="로그 표시 현황" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="18" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.LogFilterSummary, Mode=OneWay}" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="정보·경고·오류 중 필요한 수준만 골라 운영 중 노이즈를 줄일 수 있습니다." />
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
<Border Padding="16" Background="#09111D" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<ListView ItemsSource="{x:Bind ViewModel.FilteredLogs, Mode=OneWay}" SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="domain:LogEntry">
<Border Margin="0,0,0,8" Padding="12" Background="#102033" BorderBrush="#203852" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind LevelLabel}" />
<TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextSecondaryBrush}" Text="{x:Bind Display}" TextWrapping="WrapWholeWords" />
</StackPanel>
</Border>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</NavigationView>
</Window>

View File

@@ -0,0 +1,444 @@
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using System.IO;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.ViewModels;
using Windows.Graphics;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace Tornado3_2026Election;
public sealed partial class MainWindow : Window
{
private bool _suppressBroadcastPhaseToggle;
private bool _suppressOperationModeToggle;
private bool _startupFlowShown;
public MainWindow()
{
ViewModel = new MainViewModel();
InitializeComponent();
ApplyWindowIcon();
CaptureCurrentWindowPlacement();
HookWindowPlacementTracking();
MainNavigationView.SelectedItem = MainNavigationView.MenuItems[0];
MainNavigationView.Header = ViewModel.CurrentPageTitle;
MainNavigationView.Loaded += MainNavigationView_Loaded;
Closed += MainWindow_Closed;
}
public MainViewModel ViewModel { get; }
private void ApplyWindowIcon()
{
try
{
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "AppIcon.ico");
if (!File.Exists(iconPath))
{
return;
}
var appWindow = GetAppWindow();
if (appWindow is null)
{
return;
}
appWindow.SetIcon(iconPath);
}
catch
{
// Ignore icon application failures and keep the window usable.
}
}
private async void MainNavigationView_Loaded(object sender, RoutedEventArgs e)
{
if (_startupFlowShown)
{
return;
}
_startupFlowShown = true;
if (MainNavigationView.XamlRoot is null)
{
return;
}
await ApplySavedWindowPlacementAsync();
if (await ViewModel.HasRestorableStateAsync())
{
await ViewModel.RestoreStartupStateAsync();
EnsureNavigationSelection();
return;
}
await ShowStationSelectionDialogAsync();
await ViewModel.SaveStateAsync();
}
private async Task ShowStationSelectionDialogAsync()
{
var stationComboBox = new ComboBox
{
DisplayMemberPath = nameof(StationFilterItemViewModel.Name),
SelectedValuePath = nameof(StationFilterItemViewModel.Id),
ItemsSource = ViewModel.Settings.Stations,
SelectedValue = ViewModel.Settings.SelectedStationId,
MinWidth = 240
};
var stationDialog = new ContentDialog
{
XamlRoot = MainNavigationView.XamlRoot,
Title = "방송사 선택",
PrimaryButtonText = "확인",
DefaultButton = ContentDialogButton.Primary,
Content = new StackPanel
{
Spacing = 10,
Children =
{
new TextBlock
{
Text = "앱 시작 시 사용할 방송사를 선택하세요."
},
stationComboBox
}
}
};
stationDialog.PrimaryButtonClick += (_, _) =>
{
if (stationComboBox.SelectedValue is string stationId && !string.IsNullOrWhiteSpace(stationId))
{
ViewModel.Settings.SelectedStationId = stationId;
MainNavigationView.Header = ViewModel.CurrentPageTitle;
}
};
await ShowDialogAsync(stationDialog);
}
private async void PickImageRootFolderButton_Click(object sender, RoutedEventArgs e)
{
try
{
var windowHandle = WindowNative.GetWindowHandle(this);
if (windowHandle == IntPtr.Zero)
{
return;
}
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
CommitButtonText = "선택"
};
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(picker, windowHandle);
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
{
ViewModel.Settings.ImageRootPath = folder.Path;
}
}
catch
{
// Ignore picker failures and keep the settings screen responsive.
}
}
private async void BroadcastPhaseToggleSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (_suppressBroadcastPhaseToggle || sender is not ToggleSwitch toggleSwitch)
{
return;
}
var targetPhase = toggleSwitch.IsOn ? BroadcastPhase.Counting : BroadcastPhase.PreElection;
if (ViewModel.Data.BroadcastPhase == targetPhase)
{
return;
}
var confirmed = await ConfirmBroadcastPhaseChangeAsync(targetPhase);
if (confirmed)
{
ViewModel.ApplyBroadcastPhase(targetPhase);
return;
}
_suppressBroadcastPhaseToggle = true;
toggleSwitch.IsOn = ViewModel.Data.IsCountingPhase;
_suppressBroadcastPhaseToggle = false;
}
private async void OperationModeToggleSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (_suppressOperationModeToggle || sender is not ToggleSwitch toggleSwitch)
{
return;
}
var targetMode = toggleSwitch.IsOn ? ChannelOperationMode.VideoWall : ChannelOperationMode.General;
if (ViewModel.OperationMode == targetMode)
{
return;
}
var confirmed = await ConfirmOperationModeChangeAsync(targetMode);
if (confirmed)
{
ViewModel.ApplyOperationMode(targetMode);
EnsureNavigationSelection();
return;
}
_suppressOperationModeToggle = true;
toggleSwitch.IsOn = ViewModel.IsVideoWallOperationMode;
_suppressOperationModeToggle = false;
}
private AppWindow? GetAppWindow()
{
try
{
var windowHandle = WindowNative.GetWindowHandle(this);
if (windowHandle == IntPtr.Zero)
{
return null;
}
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
return AppWindow.GetFromWindowId(windowId);
}
catch
{
return null;
}
}
private void HookWindowPlacementTracking()
{
var appWindow = GetAppWindow();
if (appWindow is null)
{
return;
}
appWindow.Changed += (_, args) =>
{
if (args.DidPositionChange || args.DidSizeChange || args.DidPresenterChange)
{
CaptureCurrentWindowPlacement();
}
};
}
private async Task ApplySavedWindowPlacementAsync()
{
var savedWindowPlacement = await ViewModel.GetSavedWindowPlacementAsync();
var appWindow = GetAppWindow();
if (savedWindowPlacement is null || appWindow is null)
{
return;
}
var savedRect = new RectInt32(
savedWindowPlacement.Value.X ?? appWindow.Position.X,
savedWindowPlacement.Value.Y ?? appWindow.Position.Y,
savedWindowPlacement.Value.Width,
savedWindowPlacement.Value.Height);
var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest);
var width = Math.Clamp(savedWindowPlacement.Value.Width, 640, displayArea.WorkArea.Width);
var height = Math.Clamp(savedWindowPlacement.Value.Height, 480, displayArea.WorkArea.Height);
var x = savedWindowPlacement.Value.X ?? appWindow.Position.X;
var y = savedWindowPlacement.Value.Y ?? appWindow.Position.Y;
var maxX = displayArea.WorkArea.X + Math.Max(0, displayArea.WorkArea.Width - width);
var maxY = displayArea.WorkArea.Y + Math.Max(0, displayArea.WorkArea.Height - height);
x = Math.Clamp(x, displayArea.WorkArea.X, maxX);
y = Math.Clamp(y, displayArea.WorkArea.Y, maxY);
appWindow.MoveAndResize(new RectInt32(x, y, width, height));
ViewModel.UpdateWindowPlacement(x, y, width, height, savedWindowPlacement.Value.IsMaximized);
if (savedWindowPlacement.Value.IsMaximized && appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.Maximize();
}
}
private void CaptureCurrentWindowPlacement()
{
var appWindow = GetAppWindow();
if (appWindow is null)
{
return;
}
if (appWindow.Presenter is not OverlappedPresenter presenter)
{
ViewModel.UpdateWindowPlacement(appWindow.Position.X, appWindow.Position.Y, appWindow.Size.Width, appWindow.Size.Height, false);
return;
}
if (presenter.State == OverlappedPresenterState.Minimized)
{
return;
}
var isMaximized = presenter.State == OverlappedPresenterState.Maximized;
ViewModel.UpdateWindowPlacement(appWindow.Position.X, appWindow.Position.Y, appWindow.Size.Width, appWindow.Size.Height, isMaximized);
}
private static Task ShowDialogAsync(ContentDialog dialog)
{
var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnClosed(ContentDialog sender, ContentDialogClosedEventArgs args)
{
sender.Closed -= OnClosed;
completionSource.TrySetResult();
}
dialog.Closed += OnClosed;
_ = dialog.ShowAsync();
return completionSource.Task;
}
private async Task<bool> ConfirmBroadcastPhaseChangeAsync(BroadcastPhase targetPhase)
{
if (MainNavigationView.XamlRoot is null)
{
return false;
}
var targetLabel = targetPhase == BroadcastPhase.PreElection ? "사전" : "개표";
var description = targetPhase == BroadcastPhase.PreElection
? "사전 단계에서는 투표율과 투표자 수 중심으로 수신합니다."
: "개표 단계에서는 후보 득표수와 당선 판정 중심으로 수신합니다.";
var dialog = new ContentDialog
{
XamlRoot = MainNavigationView.XamlRoot,
Title = "방송 단계 변경",
PrimaryButtonText = "변경",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Close,
Content = new StackPanel
{
Spacing = 10,
Children =
{
new TextBlock
{
Text = $"현재 방송 단계를 {targetLabel} 모드로 전환할까요?"
},
new TextBlock
{
Text = description,
TextWrapping = TextWrapping.WrapWholeWords
}
}
}
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
private async Task<bool> ConfirmOperationModeChangeAsync(ChannelOperationMode targetMode)
{
if (MainNavigationView.XamlRoot is null)
{
return false;
}
var targetLabel = targetMode == ChannelOperationMode.General ? "일반" : "VideoWall";
var description = targetMode == ChannelOperationMode.General
? "일반 모드에서는 노멀, 좌상단, 하단만 보이고 VideoWall 메뉴와 패널은 숨깁니다."
: "VideoWall 모드에서는 VideoWall만 보이고 노멀, 좌상단, 하단 메뉴와 패널은 숨깁니다.";
var dialog = new ContentDialog
{
XamlRoot = MainNavigationView.XamlRoot,
Title = "운영 모드 변경",
PrimaryButtonText = "변경",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Close,
Content = new StackPanel
{
Spacing = 10,
Children =
{
new TextBlock
{
Text = $"현재 운영 모드를 {targetLabel} 모드로 전환할까요?"
},
new TextBlock
{
Text = description,
TextWrapping = TextWrapping.WrapWholeWords
}
}
}
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
private void MainNavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
if (args.SelectedItemContainer?.Tag is string tag)
{
ViewModel.Navigate(tag);
MainNavigationView.Header = ViewModel.CurrentPageTitle;
}
}
private void EnsureNavigationSelection()
{
if (!ViewModel.IsPageAvailable(ViewModel.CurrentPage))
{
ViewModel.Navigate("integrated");
}
var targetTag = ViewModel.CurrentPage switch
{
AppPage.Normal => "normal",
AppPage.TopLeft => "top-left",
AppPage.Bottom => "bottom",
AppPage.VideoWall => "videowall",
AppPage.Data => "data",
AppPage.Settings => "settings",
AppPage.Log => "log",
_ => "integrated"
};
foreach (var item in MainNavigationView.MenuItems)
{
if (item is NavigationViewItem navigationItem && string.Equals(navigationItem.Tag as string, targetTag, StringComparison.Ordinal))
{
MainNavigationView.SelectedItem = navigationItem;
break;
}
}
MainNavigationView.Header = ViewModel.CurrentPageTitle;
}
private async void MainWindow_Closed(object sender, WindowEventArgs args)
{
CaptureCurrentWindowPlacement();
await ViewModel.ShutdownAsync();
}
}

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10"
IgnorableNamespaces="uap rescap systemai">
<Identity
Name="8472d715-ce0c-4ed2-8f7d-7e330428ce82"
Publisher="CN=Comtrophy"
Version="1.0.3.0" />
<mp:PhoneIdentity PhoneProductId="8472d715-ce0c-4ed2-8f7d-7e330428ce82" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>Tornado3_2026Election</DisplayName>
<PublisherDisplayName>김의연</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="Tornado3_2026Election"
Description="Tornado3_2026Election"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<systemai:Capability Name="systemAIModels"/>
</Capabilities>
</Package>

View File

@@ -0,0 +1,50 @@
using System.Collections.Generic;
namespace Tornado3_2026Election.Persistence;
public sealed class AppState
{
public string OperationMode { get; set; } = "General";
public string BroadcastPhase { get; set; } = "Counting";
public string SelectedStationId { get; set; } = "KNN";
public string ImageRootPath { get; set; } = @"C:\ElectionImages";
public bool AutoRestoreSchedules { get; set; } = true;
public bool AutoRestoreStations { get; set; } = true;
public bool AutoRestoreStatusValues { get; set; } = true;
public int WindowWidth { get; set; }
public int WindowHeight { get; set; }
public int? WindowX { get; set; }
public int? WindowY { get; set; }
public bool IsWindowMaximized { get; set; }
public bool IsPollingEnabled { get; set; } = true;
public int PollingIntervalSeconds { get; set; } = 12;
public string ElectionType { get; set; } = "광역단체장";
public string DistrictName { get; set; } = "부산광역시";
public string DistrictCode { get; set; } = "2600";
public int TotalExpectedVotes { get; set; } = 1_240_000;
public int TurnoutVotes { get; set; } = 528_400;
public List<CandidateState> Candidates { get; set; } = [];
public Dictionary<string, ChannelState> Channels { get; set; } = [];
public Dictionary<string, string> StationRegionFilters { get; set; } = [];
}

View File

@@ -0,0 +1,42 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
namespace Tornado3_2026Election.Persistence;
public sealed class AppStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
public string FilePath { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Tornado3_2026Election",
"app-state.json");
public async Task<AppState?> LoadAsync()
{
if (!File.Exists(FilePath))
{
return null;
}
await using var stream = File.OpenRead(FilePath);
return await JsonSerializer.DeserializeAsync<AppState>(stream, SerializerOptions).ConfigureAwait(false);
}
public async Task SaveAsync(AppState state)
{
var directory = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await using var stream = File.Create(FilePath);
await JsonSerializer.SerializeAsync(stream, state, SerializerOptions).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,20 @@
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Persistence;
public sealed class CandidateState
{
public string CandidateCode { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Party { get; set; } = string.Empty;
public int VoteCount { get; set; }
public double VoteRate { get; set; }
public bool HasImage { get; set; }
public CandidateJudgement ManualJudgement { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Persistence;
public sealed class ChannelState
{
public bool LoopEnabled { get; set; }
public EmptyScheduleBehavior EmptyScheduleBehavior { get; set; }
public List<ScheduleItemState> Items { get; set; } = [];
}

View File

@@ -0,0 +1,25 @@
using System;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Persistence;
public sealed class ScheduleItemState
{
public Guid Id { get; set; }
public string FormatId { get; set; } = string.Empty;
public string FormatName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public BroadcastChannel Channel { get; set; }
public bool RequiresImage { get; set; }
public double DefaultCutDurationSeconds { get; set; }
public int TotalCuts { get; set; }
public Domain.ScheduleQueueItemState State { get; set; }
}

View File

@@ -0,0 +1,10 @@
{
"profiles": {
"Tornado3_2026Election (Package)": {
"commandName": "MsixPackage"
},
"Tornado3_2026Election (Unpackaged)": {
"commandName": "Project"
}
}
}

View File

@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class ChannelScheduleEngine
{
private readonly ITornado3Adapter _adapter;
private readonly IDataRefreshGate _dataRefreshGate;
private readonly Func<BroadcastStationProfile> _stationProvider;
private readonly Func<string> _imageRootProvider;
private readonly Func<string, FormatTemplateDefinition?> _templateResolver;
private readonly LogService _logService;
private readonly SemaphoreSlim _executionLock = new(1, 1);
private CancellationTokenSource? _playbackCts;
private TaskCompletionSource<bool>? _advanceSignal;
public ChannelScheduleEngine(
BroadcastChannel channel,
ObservableCollection<ChannelScheduleItem> queue,
ITornado3Adapter adapter,
IDataRefreshGate dataRefreshGate,
Func<BroadcastStationProfile> stationProvider,
Func<string> imageRootProvider,
Func<string, FormatTemplateDefinition?> templateResolver,
LogService logService)
{
Channel = channel;
Queue = queue;
_adapter = adapter;
_dataRefreshGate = dataRefreshGate;
_stationProvider = stationProvider;
_imageRootProvider = imageRootProvider;
_templateResolver = templateResolver;
_logService = logService;
}
public BroadcastChannel Channel { get; }
public ObservableCollection<ChannelScheduleItem> Queue { get; }
public bool LoopEnabled { get; set; }
public EmptyScheduleBehavior EmptyScheduleBehavior { get; set; } = EmptyScheduleBehavior.ImmediateOut;
public bool IsRunning { get; private set; }
public event EventHandler? QueueChanged;
public async Task StartAsync()
{
if (IsRunning)
{
return;
}
_playbackCts = new CancellationTokenSource();
IsRunning = true;
RefreshQueueMarkers();
_ = RunAsync(_playbackCts.Token);
await Task.CompletedTask;
}
public async Task StopAsync()
{
if (!IsRunning)
{
return;
}
_playbackCts?.Cancel();
_advanceSignal?.TrySetResult(true);
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
foreach (var item in Queue.Where(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending))
{
item.State = ScheduleQueueItemState.Queued;
}
IsRunning = false;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
public void Reset()
{
foreach (var item in Queue)
{
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
}
RefreshQueueMarkers();
}
public async Task ForceNextAsync()
{
if (!IsRunning)
{
return;
}
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
_advanceSignal?.TrySetResult(true);
}
public bool Remove(ChannelScheduleItem? item)
{
if (item is null || !item.CanDelete)
{
return false;
}
var removed = Queue.Remove(item);
if (removed)
{
RefreshQueueMarkers();
}
return removed;
}
public void Enqueue(ChannelScheduleItem item)
{
item.State = ScheduleQueueItemState.Queued;
Queue.Add(item);
RefreshQueueMarkers();
}
public bool MoveUp(ChannelScheduleItem? item) => Move(item, -1);
public bool MoveDown(ChannelScheduleItem? item) => Move(item, 1);
public bool PromoteToNext(ChannelScheduleItem? item)
{
if (item is null)
{
return false;
}
var currentIndex = Queue.IndexOf(item);
if (currentIndex < 0)
{
return false;
}
var onAirIndex = Queue.ToList().FindIndex(entry => entry.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
var targetIndex = onAirIndex >= 0 ? onAirIndex + 1 : 0;
if (targetIndex == currentIndex)
{
return false;
}
Queue.Move(currentIndex, Math.Min(targetIndex, Queue.Count - 1));
RefreshQueueMarkers();
return true;
}
private bool Move(ChannelScheduleItem? item, int delta)
{
if (item is null)
{
return false;
}
var currentIndex = Queue.IndexOf(item);
var targetIndex = currentIndex + delta;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= Queue.Count)
{
return false;
}
Queue.Move(currentIndex, targetIndex);
RefreshQueueMarkers();
return true;
}
private async Task RunAsync(CancellationToken cancellationToken)
{
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
while (!cancellationToken.IsCancellationRequested)
{
var next = GetNextPlayableItem();
if (next is null)
{
if (LoopEnabled && Queue.Count > 0)
{
Reset();
continue;
}
if (EmptyScheduleBehavior == EmptyScheduleBehavior.ImmediateOut)
{
await _adapter.OutAsync(Channel, cancellationToken).ConfigureAwait(false);
}
IsRunning = false;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return;
}
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
var template = _templateResolver(next.FormatId);
if (template is null)
{
next.State = ScheduleQueueItemState.Error;
next.LastError = "포맷을 찾을 수 없습니다.";
_logService.Error($"[{Channel}] Missing template: {next.FormatId}");
RefreshQueueMarkers();
continue;
}
if (!_dataRefreshGate.ValidateForFormat(template, out var validationError))
{
next.State = ScheduleQueueItemState.Error;
next.LastError = validationError;
_logService.Warning($"[{Channel}] Blocked by validation: {validationError}");
RefreshQueueMarkers();
continue;
}
await PlayItemAsync(next, template, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
}
}
catch (OperationCanceledException)
{
IsRunning = false;
RefreshQueueMarkers();
}
finally
{
_executionLock.Release();
}
}
private async Task PlayItemAsync(ChannelScheduleItem queueItem, FormatTemplateDefinition template, CancellationToken cancellationToken)
{
var station = _stationProvider();
var imageRootPath = _imageRootProvider();
var resolvedCuts = ResolveCuts(template, station);
foreach (var cut in resolvedCuts)
{
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
queueItem.State = ScheduleQueueItemState.Sending;
RefreshQueueMarkers();
var snapshot = _dataRefreshGate.GetCurrentSnapshot();
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
queueItem.State = ScheduleQueueItemState.OnAir;
queueItem.LastPlayedAt = DateTimeOffset.Now;
RefreshQueueMarkers();
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_advanceSignal = signal;
var delayTask = Task.Delay(TimeSpan.FromSeconds(cut.DurationSeconds), cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
}
queueItem.State = ScheduleQueueItemState.Completed;
queueItem.LastError = string.Empty;
RefreshQueueMarkers();
}
private IReadOnlyList<FormatCutDefinition> ResolveCuts(FormatTemplateDefinition template, BroadcastStationProfile station)
{
if (template.LoopMode != LoopMode.StationRegions)
{
return template.Cuts;
}
var loopCuts = new List<FormatCutDefinition>();
foreach (var region in station.RegionFilters)
{
loopCuts.Add(new FormatCutDefinition
{
Name = $"{template.Cuts[0].Name} - {region}",
DurationSeconds = template.Cuts[0].DurationSeconds
});
}
return loopCuts;
}
private ChannelScheduleItem? GetNextPlayableItem()
{
return Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next);
}
public void RefreshQueueMarkers()
{
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
var nextItem = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
foreach (var item in Queue)
{
if (item == activeItem || item.State == ScheduleQueueItemState.Completed || item.State == ScheduleQueueItemState.Error)
{
continue;
}
item.State = item == nextItem ? ScheduleQueueItemState.Next : ScheduleQueueItemState.Queued;
}
}
}

View File

@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class FormatCatalogService
{
private readonly IReadOnlyList<FormatTemplateDefinition> _formats =
[
new FormatTemplateDefinition
{
Id = "normal-national-summary",
Name = "노멀 전국 요약",
Description = "개표율과 상위 후보 요약을 2컷으로 송출합니다.",
RecommendedChannel = BroadcastChannel.Normal,
RequiresImage = false,
LoopMode = LoopMode.None,
Cuts =
[
new FormatCutDefinition { Name = "오프닝 컷", DurationSeconds = 5 },
new FormatCutDefinition { Name = "상위 후보 컷", DurationSeconds = 7 }
]
},
new FormatTemplateDefinition
{
Id = "normal-regional-loop",
Name = "노멀 시도 루프",
Description = "방송사 지역 필터 기준으로 시도별 컷을 반복 송출합니다.",
RecommendedChannel = BroadcastChannel.Normal,
RequiresImage = false,
LoopMode = LoopMode.StationRegions,
Cuts =
[
new FormatCutDefinition { Name = "지역 루프 컷", DurationSeconds = 4 }
]
},
new FormatTemplateDefinition
{
Id = "top-left-status",
Name = "좌상단 현황",
Description = "좌상단 영역에 간단한 현황 템플릿을 송출합니다.",
RecommendedChannel = BroadcastChannel.TopLeft,
RequiresImage = false,
LoopMode = LoopMode.None,
Cuts =
[
new FormatCutDefinition { Name = "좌상단 단일 컷", DurationSeconds = 6 }
]
},
new FormatTemplateDefinition
{
Id = "bottom-candidate-bar",
Name = "하단 후보 바",
Description = "하단 라인에 후보별 득표 상황을 표시합니다.",
RecommendedChannel = BroadcastChannel.Bottom,
RequiresImage = false,
LoopMode = LoopMode.None,
Cuts =
[
new FormatCutDefinition { Name = "하단 바 컷", DurationSeconds = 8 }
]
},
new FormatTemplateDefinition
{
Id = "videowall-winner-card",
Name = "비디오월 당선 카드",
Description = "당선/확실 후보를 사진 포함 카드로 송출합니다.",
RecommendedChannel = BroadcastChannel.VideoWall,
RequiresImage = true,
LoopMode = LoopMode.None,
Cuts =
[
new FormatCutDefinition { Name = "당선 카드 컷", DurationSeconds = 9 }
]
}
];
public IReadOnlyList<FormatTemplateDefinition> GetAll() => _formats;
public IReadOnlyList<FormatTemplateDefinition> GetByChannel(BroadcastChannel channel)
{
return _formats.Where(format => format.RecommendedChannel == channel).ToArray();
}
public FormatTemplateDefinition? FindById(string formatId)
{
return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, System.StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public interface IDataRefreshGate
{
bool IsRefreshing { get; }
Task WaitForRefreshAsync(CancellationToken cancellationToken);
ElectionDataSnapshot GetCurrentSnapshot();
bool ValidateForFormat(FormatTemplateDefinition template, out string errorMessage);
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public interface ITornado3Adapter
{
TornadoConnectionState State { get; }
event EventHandler<TornadoConnectionState>? StateChanged;
Task EnsureConnectedAsync(CancellationToken cancellationToken);
Task ApplyCutAsync(
BroadcastChannel channel,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
BroadcastStationProfile station,
string imageRootPath,
CancellationToken cancellationToken);
Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.ObjectModel;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class LogService
{
private const int MaxEntries = 400;
public ObservableCollection<LogEntry> Entries { get; } = [];
public void Info(string message) => Add(LogLevel.Info, message);
public void Warning(string message) => Add(LogLevel.Warning, message);
public void Error(string message) => Add(LogLevel.Error, message);
public void Clear() => Common.UiDispatcher.Enqueue(Entries.Clear);
private void Add(LogLevel level, string message)
{
Common.UiDispatcher.Enqueue(() =>
{
Entries.Insert(0, new LogEntry
{
Timestamp = DateTimeOffset.Now,
Level = level,
Message = message
});
while (Entries.Count > MaxEntries)
{
Entries.RemoveAt(Entries.Count - 1);
}
});
}
}

View File

@@ -0,0 +1,103 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class MockTornado3Adapter : ITornado3Adapter
{
private readonly LogService _logService;
private TornadoConnectionState _state = TornadoConnectionState.Idle;
public MockTornado3Adapter(LogService logService)
{
_logService = logService;
}
public TornadoConnectionState State
{
get => _state;
private set
{
if (_state == value)
{
return;
}
_state = value;
StateChanged?.Invoke(this, value);
}
}
public event EventHandler<TornadoConnectionState>? StateChanged;
public async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
await Task.Delay(120, cancellationToken).ConfigureAwait(false);
State = TornadoConnectionState.Ready;
_logService.Info("Tornado3 mock adapter connected.");
}, cancellationToken).ConfigureAwait(false);
}
public async Task ApplyCutAsync(
BroadcastChannel channel,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
BroadcastStationProfile station,
string imageRootPath,
CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.Sending;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
var summary = snapshot.BroadcastPhase == BroadcastPhase.PreElection
? $"turnout={snapshot.TurnoutRate:0.0}% voters={snapshot.TurnoutVotes:N0}"
: $"leader={snapshot.Candidates.OrderByDescending(candidate => candidate.VoteCount).FirstOrDefault()?.Name ?? ""} counted={snapshot.CountedVotes:N0}";
_logService.Info($"[{channel}] Apply {template.Name}/{cut.Name} station={station.Name} {summary} imageRoot={imageRootPath}");
State = TornadoConnectionState.Ready;
}, cancellationToken).ConfigureAwait(false);
}
public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.Ready;
await Task.Delay(60, cancellationToken).ConfigureAwait(false);
_logService.Info($"[{channel}] Prepare");
}, cancellationToken).ConfigureAwait(false);
}
public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.OnAir;
await Task.Delay(60, cancellationToken).ConfigureAwait(false);
_logService.Info($"[{channel}] Take");
}, cancellationToken).ConfigureAwait(false);
}
public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.Idle;
await Task.Delay(60, cancellationToken).ConfigureAwait(false);
_logService.Info($"[{channel}] Layer Out");
}, cancellationToken).ConfigureAwait(false);
}
private static async Task ExecuteWithTimeoutAsync(Func<Task> action, CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
await action().WaitAsync(timeoutCts.Token).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class StationCatalogService
{
private readonly IReadOnlyList<BroadcastStationProfile> _stations =
[
new BroadcastStationProfile { Id = "KNN", Name = "KNN", LogoAssetPath = @"Assets\Stations\knn.png", RegionFilters = ["부산", "울산", "경남"] },
new BroadcastStationProfile { Id = "TBC", Name = "TBC", LogoAssetPath = @"Assets\Stations\tbc.png", RegionFilters = ["대구", "경북"] },
new BroadcastStationProfile { Id = "KBC", Name = "KBC", LogoAssetPath = @"Assets\Stations\kbc.png", RegionFilters = ["광주", "전남"] },
new BroadcastStationProfile { Id = "G1", Name = "G1", LogoAssetPath = @"Assets\Stations\g1.png", RegionFilters = ["강원"] },
new BroadcastStationProfile { Id = "TJB", Name = "TJB", LogoAssetPath = @"Assets\Stations\tjb.png", RegionFilters = ["대전", "세종", "충남"] },
new BroadcastStationProfile { Id = "JTV", Name = "JTV", LogoAssetPath = @"Assets\Stations\jtv.png", RegionFilters = ["전북"] }
];
public IReadOnlyList<BroadcastStationProfile> GetAll() => _stations;
}

View File

@@ -0,0 +1,104 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>Tornado3_2026Election</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>
<EnableMsixTooling>true</EnableMsixTooling>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\Stations\**\*.*" />
<Content Include="Assets\AppIcon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\AppIcon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\LockScreenLogo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\SplashScreen.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\LockScreenLogo.scale-200.png" />
<Content Include="Assets\Square150x150Logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\Square150x150Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\Square44x44Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\Stations\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\Wide310x150Logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
<Content Include="Data\LocationCatalog.seed.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Data\ManualCandidateSamples.seed.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<!--
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
Tools extension to be activated for this project even if the Windows App SDK Nuget
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260209005" />
</ItemGroup>
<PropertyGroup Label="Globals">
<WebView2EnableCsWinRTProjection>False</WebView2EnableCsWinRTProjection>
</PropertyGroup>
<!--
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
Explorer "Package and Publish" context menu entry to be enabled for this project even if
the Windows App SDK Nuget package has not yet been restored.
-->
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
<!-- Publish Properties -->
<PropertyGroup>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
<GenerateAppInstallerFile>True</GenerateAppInstallerFile>
<AppxPackageSigningEnabled>True</AppxPackageSigningEnabled>
<PackageCertificateKeyFile>Tornado3_2026Election_TemporaryKey.pfx</PackageCertificateKeyFile>
<AppxPackageSigningTimestampDigestAlgorithm>SHA256</AppxPackageSigningTimestampDigestAlgorithm>
<AppxAutoIncrementPackageRevision>True</AppxAutoIncrementPackageRevision>
<GenerateTestArtifacts>True</GenerateTestArtifacts>
<AppxBundle>Never</AppxBundle>
<AppInstallerUri>http://172.30.1.36/</AppInstallerUri>
<HoursBetweenUpdateChecks>0</HoursBetweenUpdateChecks>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,277 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Services;
namespace Tornado3_2026Election.ViewModels;
public sealed class ChannelScheduleViewModel : ObservableObject
{
private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter;
private readonly LogService _logService;
private FormatTemplateDefinition? _selectedFormat;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
public ChannelScheduleViewModel(
BroadcastChannel channel,
string title,
IReadOnlyList<FormatTemplateDefinition> formats,
ITornado3Adapter adapter,
ChannelScheduleEngine engine,
LogService logService)
{
Channel = channel;
Title = title;
_adapter = adapter;
_engine = engine;
_logService = logService;
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>(formats);
EmptyBehaviorOptions =
[
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.HoldLastFrame, "마지막 프레임 유지")
];
Queue = engine.Queue;
StartCommand = new AsyncRelayCommand(StartAsync);
StopCommand = new AsyncRelayCommand(StopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
AddFormatCommand = new RelayCommand(AddFormat, () => SelectedFormat is not null);
ResetQueueCommand = new RelayCommand(ResetQueue);
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
SelectedFormat = AvailableFormats.FirstOrDefault();
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
_engine.QueueChanged += (_, _) => RefreshSummary();
_adapter.StateChanged += (_, _) => RefreshSummary();
RefreshSummary();
}
public BroadcastChannel Channel { get; }
public string Title { get; }
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
public ObservableCollection<ChannelScheduleItem> Queue { get; }
public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand ForceNextCommand { 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 FormatTemplateDefinition? SelectedFormat
{
get => _selectedFormat;
set
{
if (SetProperty(ref _selectedFormat, value))
{
if (AddFormatCommand is not null)
{
AddFormatCommand.NotifyCanExecuteChanged();
}
}
}
}
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)?.FormatName ?? "대기 화면";
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "다음 포맷 없음";
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)?.FormatName ?? "-";
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "-";
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
}
}
public string LoopSummary => LoopEnabled ? "전체 반복 켜짐" : "한 번 재생";
public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";
public string OperatorQuickSummary => $"{AdapterStateLabel} · {LoopSummary} · 빈 스케줄 {EmptyBehaviorLabel}";
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 ForceNextAsync()
{
await _engine.ForceNextAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 현재 포맷 강제 중지 후 전환");
}
private void AddFormat()
{
if (SelectedFormat is null)
{
return;
}
_engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat));
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();
}
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(LoopSummary),
nameof(EmptyBehaviorLabel),
nameof(OperatorQuickSummary));
}
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
{
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
}
}

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

View File

@@ -0,0 +1,754 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Persistence;
using Tornado3_2026Election.Services;
namespace Tornado3_2026Election.ViewModels;
public sealed class MainViewModel : ObservableObject
{
private readonly FormatCatalogService _formatCatalogService;
private readonly AppStateStore _stateStore;
private readonly LogService _logService;
private readonly SemaphoreSlim _stateSaveLock = new(1, 1);
private AppPage _currentPage = AppPage.IntegratedSchedule;
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
private bool _isSituationRoomExpanded = true;
private bool _suppressAutomaticSave;
private int? _windowX;
private int? _windowY;
private int? _windowWidth;
private int? _windowHeight;
private bool _isWindowMaximized;
private SelectionOption<LogLevel?>? _selectedLogFilterOption;
public MainViewModel()
{
_formatCatalogService = new FormatCatalogService();
_stateStore = new AppStateStore();
_logService = new LogService();
Data = new DataViewModel(_logService);
Settings = new SettingsViewModel(new StationCatalogService().GetAll());
RestoreSelection = new RestoreSelectionViewModel();
LogFilterOptions =
[
new SelectionOption<LogLevel?>(null, "전체"),
new SelectionOption<LogLevel?>(LogLevel.Info, "정보"),
new SelectionOption<LogLevel?>(LogLevel.Warning, "경고"),
new SelectionOption<LogLevel?>(LogLevel.Error, "오류")
];
FilteredLogs = [];
Settings.PropertyChanged += Settings_PropertyChanged;
Data.PropertyChanged += Data_PropertyChanged;
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀");
TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단");
BottomChannel = CreateChannelViewModel(BroadcastChannel.Bottom, "하단");
VideoWallChannel = CreateChannelViewModel(BroadcastChannel.VideoWall, "비디오월");
Channels = [NormalChannel, TopLeftChannel, BottomChannel, VideoWallChannel];
foreach (var channel in Channels)
{
channel.PropertyChanged += Channel_PropertyChanged;
}
SaveStateCommand = new AsyncRelayCommand(SaveStateAsync);
RestoreStateCommand = new AsyncRelayCommand(RestoreAsync);
ClearLogsCommand = new RelayCommand(_logService.Clear);
ToggleSituationRoomCommand = new RelayCommand(ToggleSituationRoom);
RestoreSelection.PropertyChanged += RestoreSelection_PropertyChanged;
foreach (var station in Settings.Stations)
{
station.PropertyChanged += Station_PropertyChanged;
}
_logService.Entries.CollectionChanged += (_, _) => RebuildFilteredLogs();
SelectedLogFilterOption = LogFilterOptions[0];
_logService.Info("SYSTEM_SPEC 기반 MVVM 구조를 초기화했습니다.");
}
public DataViewModel Data { get; }
public SettingsViewModel Settings { get; }
public RestoreSelectionViewModel RestoreSelection { get; }
public ChannelScheduleViewModel NormalChannel { get; }
public ChannelScheduleViewModel TopLeftChannel { get; }
public ChannelScheduleViewModel BottomChannel { get; }
public ChannelScheduleViewModel VideoWallChannel { get; }
public IReadOnlyList<ChannelScheduleViewModel> Channels { get; }
public AsyncRelayCommand SaveStateCommand { get; }
public AsyncRelayCommand RestoreStateCommand { get; }
public RelayCommand ClearLogsCommand { get; }
public RelayCommand ToggleSituationRoomCommand { get; }
public ObservableCollection<LogEntry> Logs => _logService.Entries;
public ObservableCollection<LogEntry> FilteredLogs { get; }
public IReadOnlyList<SelectionOption<LogLevel?>> LogFilterOptions { get; }
public ChannelOperationMode OperationMode
{
get => _operationMode;
set
{
if (SetProperty(ref _operationMode, value))
{
EnsureCurrentPageAvailableForMode();
OnPropertyChanged(
nameof(IsGeneralOperationMode),
nameof(IsVideoWallOperationMode),
nameof(OperationModeLabel),
nameof(OperationModeBadgeText),
nameof(OperationModeDetailText),
nameof(NormalMenuVisibility),
nameof(TopLeftMenuVisibility),
nameof(BottomMenuVisibility),
nameof(VideoWallMenuVisibility),
nameof(GeneralIntegratedVisibility),
nameof(VideoWallIntegratedVisibility),
nameof(NormalVisibility),
nameof(TopLeftVisibility),
nameof(BottomVisibility),
nameof(VideoWallVisibility),
nameof(HeaderStatus),
nameof(TornadoConnectionSummary),
nameof(TornadoConnectionDetail));
QueueAutomaticSave();
}
}
}
public AppPage CurrentPage
{
get => _currentPage;
set
{
if (SetProperty(ref _currentPage, value))
{
OnPropertyChanged(
nameof(IntegratedScheduleVisibility),
nameof(NormalVisibility),
nameof(TopLeftVisibility),
nameof(BottomVisibility),
nameof(VideoWallVisibility),
nameof(DataVisibility),
nameof(SettingsVisibility),
nameof(LogVisibility),
nameof(CurrentPageTitle));
}
}
}
public string CurrentPageTitle => CurrentPage switch
{
AppPage.Normal => "노멀",
AppPage.TopLeft => "좌상단",
AppPage.Bottom => "하단",
AppPage.VideoWall => "비디오월",
AppPage.Data => "데이터",
AppPage.Settings => "설정",
AppPage.Log => "로그",
_ => "통합 스케줄"
};
public bool IsGeneralOperationMode => OperationMode == ChannelOperationMode.General;
public bool IsVideoWallOperationMode => OperationMode == ChannelOperationMode.VideoWall;
public string OperationModeLabel => IsGeneralOperationMode ? "일반" : "비디오월";
public string OperationModeBadgeText => IsGeneralOperationMode ? "일반 3채널" : "비디오월 단독";
public string OperationModeDetailText => IsGeneralOperationMode
? "일반 모드에서는 노멀, 좌상단, 하단만 노출하고 운영합니다."
: "비디오월 모드에서는 비디오월만 단독으로 노출하고 운영합니다.";
public Visibility NormalMenuVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility TopLeftMenuVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility BottomMenuVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility VideoWallMenuVisibility => IsVideoWallOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility GeneralIntegratedVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility VideoWallIntegratedVisibility => IsVideoWallOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility IntegratedScheduleVisibility => CurrentPage == AppPage.IntegratedSchedule ? Visibility.Visible : Visibility.Collapsed;
public Visibility NormalVisibility => CurrentPage == AppPage.Normal && IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility TopLeftVisibility => CurrentPage == AppPage.TopLeft && IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility BottomVisibility => CurrentPage == AppPage.Bottom && IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility VideoWallVisibility => CurrentPage == AppPage.VideoWall && IsVideoWallOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility DataVisibility => CurrentPage == AppPage.Data ? Visibility.Visible : Visibility.Collapsed;
public Visibility SettingsVisibility => CurrentPage == AppPage.Settings ? Visibility.Visible : Visibility.Collapsed;
public Visibility LogVisibility => CurrentPage == AppPage.Log ? Visibility.Visible : Visibility.Collapsed;
public bool IsSituationRoomExpanded
{
get => _isSituationRoomExpanded;
set
{
if (SetProperty(ref _isSituationRoomExpanded, value))
{
OnPropertyChanged(nameof(SituationRoomBodyVisibility), nameof(SituationRoomToggleText));
}
}
}
public Visibility SituationRoomBodyVisibility => IsSituationRoomExpanded ? Visibility.Visible : Visibility.Collapsed;
public string SituationRoomToggleText => IsSituationRoomExpanded ? "상황실 접기" : "상황실 펼치기";
public ImageSource? SelectedStationLogo
{
get
{
var logoPath = TryGetSelectedStationLogoPath();
return logoPath is null ? null : new BitmapImage(new Uri(logoPath, UriKind.Absolute));
}
}
public Visibility SelectedStationLogoVisibility => TryGetSelectedStationLogoPath() is null
? Visibility.Collapsed
: Visibility.Visible;
public SelectionOption<LogLevel?>? SelectedLogFilterOption
{
get => _selectedLogFilterOption;
set
{
if (value is null)
{
return;
}
if (SetProperty(ref _selectedLogFilterOption, value))
{
RebuildFilteredLogs();
OnPropertyChanged(nameof(LogFilterSummary));
}
}
}
public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건";
public string TornadoConnectionSummary
{
get
{
var activeChannels = GetActiveChannels().ToArray();
if (activeChannels.Length == 0)
{
return "채널 없음";
}
var healthyCount = activeChannels.Count(channel => channel.AdapterState != TornadoConnectionState.Error);
return $"{healthyCount}/{activeChannels.Length} 채널 정상";
}
}
public string TornadoConnectionDetail
{
get
{
var activeChannels = GetActiveChannels().ToArray();
if (activeChannels.Length == 0)
{
return "운영 대상 없음";
}
if (activeChannels.Any(channel => channel.AdapterState == TornadoConnectionState.Error))
{
return "오류 채널 확인 필요";
}
if (activeChannels.Any(channel => channel.AdapterState == TornadoConnectionState.Sending))
{
return "전송 중 채널 포함";
}
if (activeChannels.Any(channel => channel.AdapterState == TornadoConnectionState.OnAir))
{
return "송출 중 채널 포함";
}
return "전체 준비 상태";
}
}
public string HeaderStatus => $"{Settings.SelectedStation.Name} / {OperationModeLabel} / {Data.DistrictName} / {Data.HeaderMetricSummary}";
public void Navigate(string tag)
{
CurrentPage = tag switch
{
"normal" when IsGeneralOperationMode => AppPage.Normal,
"top-left" when IsGeneralOperationMode => AppPage.TopLeft,
"bottom" when IsGeneralOperationMode => AppPage.Bottom,
"videowall" when IsVideoWallOperationMode => AppPage.VideoWall,
"data" => AppPage.Data,
"settings" => AppPage.Settings,
"log" => AppPage.Log,
_ => AppPage.IntegratedSchedule
};
}
public bool IsPageAvailable(AppPage page)
{
return page switch
{
AppPage.Normal or AppPage.TopLeft or AppPage.Bottom => IsGeneralOperationMode,
AppPage.VideoWall => IsVideoWallOperationMode,
_ => true
};
}
public async Task<bool> HasRestorableStateAsync()
{
return await _stateStore.LoadAsync() is not null;
}
public async Task<(int? X, int? Y, int Width, int Height, bool IsMaximized)?> GetSavedWindowPlacementAsync()
{
var state = await _stateStore.LoadAsync();
if (state is null || state.WindowWidth <= 0 || state.WindowHeight <= 0)
{
return null;
}
_windowX = state.WindowX;
_windowY = state.WindowY;
_windowWidth = state.WindowWidth;
_windowHeight = state.WindowHeight;
_isWindowMaximized = state.IsWindowMaximized;
return (state.WindowX, state.WindowY, state.WindowWidth, state.WindowHeight, state.IsWindowMaximized);
}
public async Task RestoreStartupStateAsync()
{
var state = await _stateStore.LoadAsync();
if (state is null)
{
return;
}
_suppressAutomaticSave = true;
try
{
RestoreSelection.RestoreSchedules = state.AutoRestoreSchedules;
RestoreSelection.RestoreStations = state.AutoRestoreStations;
RestoreSelection.RestoreStatusValues = state.AutoRestoreStatusValues;
_windowX = state.WindowX;
_windowY = state.WindowY;
_windowWidth = state.WindowWidth > 0 ? state.WindowWidth : _windowWidth;
_windowHeight = state.WindowHeight > 0 ? state.WindowHeight : _windowHeight;
_isWindowMaximized = state.IsWindowMaximized;
ApplyState(state);
}
finally
{
_suppressAutomaticSave = false;
}
_logService.Info("저장된 시작 옵션에 따라 상태를 자동 복원했습니다.");
}
public async Task RestoreAsync()
{
var state = await _stateStore.LoadAsync();
if (state is null)
{
_logService.Warning("복원 가능한 저장 상태가 없습니다.");
return;
}
_suppressAutomaticSave = true;
try
{
ApplyState(state);
}
finally
{
_suppressAutomaticSave = false;
}
_logService.Info("저장 상태 복원을 완료했습니다.");
}
public async Task SaveStateAsync()
{
await SaveStateCoreAsync(writeLog: true);
}
public async Task ShutdownAsync()
{
await SaveStateCoreAsync(writeLog: false);
Data.Dispose();
}
public void UpdateWindowPlacement(int? x, int? y, int width, int height, bool isMaximized)
{
if (width <= 0 || height <= 0)
{
return;
}
_windowX = x;
_windowY = y;
_windowWidth = width;
_windowHeight = height;
_isWindowMaximized = isMaximized;
}
public void ApplyBroadcastPhase(BroadcastPhase phase)
{
Data.ApplyBroadcastPhase(phase);
OnPropertyChanged(nameof(HeaderStatus));
}
public void ApplyOperationMode(ChannelOperationMode mode)
{
if (OperationMode == mode)
{
return;
}
OperationMode = mode;
_logService.Info($"운영 모드를 {OperationModeLabel}로 전환했습니다.");
}
private void ToggleSituationRoom()
{
IsSituationRoomExpanded = !IsSituationRoomExpanded;
}
private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName is nameof(SettingsViewModel.SelectedStation) or nameof(SettingsViewModel.SelectedStationId))
{
OnPropertyChanged(nameof(HeaderStatus), nameof(SelectedStationLogo), nameof(SelectedStationLogoVisibility));
}
if (args.PropertyName is nameof(SettingsViewModel.SelectedStationId) or nameof(SettingsViewModel.ImageRootPath))
{
QueueAutomaticSave();
}
}
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName is nameof(DataViewModel.DistrictName) or nameof(DataViewModel.HeaderMetricSummary) or nameof(DataViewModel.BroadcastPhase))
{
OnPropertyChanged(nameof(HeaderStatus));
}
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.PollingIntervalSeconds)
or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.ElectionType)
or nameof(DataViewModel.DistrictName)
or nameof(DataViewModel.DistrictCode)
or nameof(DataViewModel.TotalExpectedVotes)
or nameof(DataViewModel.TurnoutVotes))
{
QueueAutomaticSave();
}
}
private void RestoreSelection_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
QueueAutomaticSave();
}
private void Channel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ChannelScheduleViewModel.AdapterState) or nameof(ChannelScheduleViewModel.AdapterStateLabel))
{
OnPropertyChanged(nameof(TornadoConnectionSummary), nameof(TornadoConnectionDetail));
}
}
private void Station_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(StationFilterItemViewModel.RegionFiltersText))
{
QueueAutomaticSave();
}
}
private void ApplyState(AppState state)
{
if (RestoreSelection.RestoreStations)
{
Settings.SelectedStationId = state.SelectedStationId;
Settings.ImageRootPath = state.ImageRootPath;
foreach (var station in Settings.Stations)
{
if (state.StationRegionFilters.TryGetValue(station.Id, out var filters))
{
station.RegionFiltersText = filters;
}
}
}
if (RestoreSelection.RestoreStatusValues)
{
if (!Enum.TryParse<ChannelOperationMode>(state.OperationMode, ignoreCase: true, out var operationMode))
{
operationMode = ChannelOperationMode.General;
}
OperationMode = operationMode;
if (!Enum.TryParse<BroadcastPhase>(state.BroadcastPhase, ignoreCase: true, out var broadcastPhase))
{
broadcastPhase = BroadcastPhase.Counting;
}
Data.BroadcastPhase = broadcastPhase;
Data.ElectionType = state.ElectionType;
Data.DistrictName = string.IsNullOrWhiteSpace(state.DistrictName) ? Data.DistrictName : state.DistrictName;
Data.DistrictCode = string.IsNullOrWhiteSpace(state.DistrictCode) ? Data.DistrictCode : state.DistrictCode;
Data.TotalExpectedVotes = state.TotalExpectedVotes > 0 ? state.TotalExpectedVotes : Data.TotalExpectedVotes;
Data.TurnoutVotes = state.TurnoutVotes;
Data.IsPollingEnabled = state.IsPollingEnabled;
Data.PollingIntervalSeconds = state.PollingIntervalSeconds;
Data.ReplaceCandidates(state.Candidates.Select(candidate => new CandidateEntry
{
CandidateCode = candidate.CandidateCode,
Name = candidate.Name,
Party = candidate.Party,
VoteCount = candidate.VoteCount,
VoteRate = candidate.VoteRate,
HasImage = candidate.HasImage,
ManualJudgement = candidate.ManualJudgement
}));
}
if (RestoreSelection.RestoreSchedules)
{
RestoreChannelState(NormalChannel, state, BroadcastChannel.Normal);
RestoreChannelState(TopLeftChannel, state, BroadcastChannel.TopLeft);
RestoreChannelState(BottomChannel, state, BroadcastChannel.Bottom);
RestoreChannelState(VideoWallChannel, state, BroadcastChannel.VideoWall);
}
OnPropertyChanged(nameof(HeaderStatus));
}
private void QueueAutomaticSave()
{
if (_suppressAutomaticSave)
{
return;
}
_ = SaveStateCoreAsync(writeLog: false);
}
private async Task SaveStateCoreAsync(bool writeLog)
{
await _stateSaveLock.WaitAsync();
try
{
var state = new AppState
{
SelectedStationId = Settings.SelectedStationId,
ImageRootPath = Settings.ImageRootPath,
AutoRestoreSchedules = RestoreSelection.RestoreSchedules,
AutoRestoreStations = RestoreSelection.RestoreStations,
AutoRestoreStatusValues = RestoreSelection.RestoreStatusValues,
WindowX = _windowX,
WindowY = _windowY,
WindowWidth = _windowWidth ?? 0,
WindowHeight = _windowHeight ?? 0,
IsWindowMaximized = _isWindowMaximized,
OperationMode = OperationMode.ToString(),
BroadcastPhase = Data.BroadcastPhase.ToString(),
IsPollingEnabled = Data.IsPollingEnabled,
PollingIntervalSeconds = Data.PollingIntervalSeconds,
ElectionType = Data.ElectionType,
DistrictName = Data.DistrictName,
DistrictCode = Data.DistrictCode,
TotalExpectedVotes = Data.TotalExpectedVotes,
TurnoutVotes = Data.TurnoutVotes,
Candidates = Data.Candidates.Select(candidate => new CandidateState
{
CandidateCode = candidate.CandidateCode,
Name = candidate.Name,
Party = candidate.Party,
VoteCount = candidate.VoteCount,
VoteRate = candidate.VoteRate,
HasImage = candidate.HasImage,
ManualJudgement = candidate.ManualJudgement
}).ToList(),
Channels = BuildChannelStateMap(),
StationRegionFilters = Settings.Stations.ToDictionary(station => station.Id, station => station.RegionFiltersText)
};
await _stateStore.SaveAsync(state);
if (writeLog)
{
_logService.Info("현재 통합 방송 상태값을 저장했습니다.");
}
}
finally
{
_stateSaveLock.Release();
}
}
private ChannelScheduleViewModel CreateChannelViewModel(BroadcastChannel channel, string title)
{
var adapter = new MockTornado3Adapter(_logService);
var queue = new ObservableCollection<ChannelScheduleItem>();
var engine = new ChannelScheduleEngine(
channel,
queue,
adapter,
Data,
Settings.BuildSelectedStationProfile,
() => Settings.ImageRootPath,
formatId => _formatCatalogService.FindById(formatId),
_logService);
return new ChannelScheduleViewModel(
channel,
title,
_formatCatalogService.GetByChannel(channel),
adapter,
engine,
_logService);
}
private Dictionary<string, ChannelState> BuildChannelStateMap()
{
return Channels.ToDictionary(
channel => channel.Channel.ToString(),
channel => new ChannelState
{
LoopEnabled = channel.LoopEnabled,
EmptyScheduleBehavior = channel.EmptyScheduleBehavior,
Items = channel.Queue.Select(item => new ScheduleItemState
{
Id = item.Id,
FormatId = item.FormatId,
FormatName = item.FormatName,
Description = item.Description,
Channel = item.Channel,
RequiresImage = item.RequiresImage,
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds,
TotalCuts = item.TotalCuts,
State = item.State
}).ToList()
});
}
private void EnsureCurrentPageAvailableForMode()
{
if (!IsPageAvailable(CurrentPage))
{
CurrentPage = AppPage.IntegratedSchedule;
}
}
private string? TryGetSelectedStationLogoPath()
{
var relativePath = Settings.SelectedStationLogoAssetPath;
if (string.IsNullOrWhiteSpace(relativePath))
{
return null;
}
var normalizedPath = relativePath
.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar);
var absolutePath = Path.Combine(AppContext.BaseDirectory, normalizedPath);
return File.Exists(absolutePath) ? absolutePath : null;
}
private IEnumerable<ChannelScheduleViewModel> GetActiveChannels()
{
return IsGeneralOperationMode
? [NormalChannel, TopLeftChannel, BottomChannel]
: [VideoWallChannel];
}
private void RebuildFilteredLogs()
{
var selectedLevel = SelectedLogFilterOption?.Value;
var filteredEntries = Logs
.Where(entry => selectedLevel is null || entry.Level == selectedLevel.Value)
.ToArray();
FilteredLogs.Clear();
foreach (var entry in filteredEntries)
{
FilteredLogs.Add(entry);
}
OnPropertyChanged(nameof(LogFilterSummary));
}
private static void RestoreChannelState(ChannelScheduleViewModel channelViewModel, AppState state, BroadcastChannel channel)
{
if (!state.Channels.TryGetValue(channel.ToString(), out var channelState))
{
return;
}
channelViewModel.Queue.Clear();
foreach (var item in channelState.Items)
{
channelViewModel.Queue.Add(new ChannelScheduleItem
{
Id = item.Id,
FormatId = item.FormatId,
FormatName = item.FormatName,
Description = item.Description,
Channel = item.Channel,
RequiresImage = item.RequiresImage,
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds,
TotalCuts = item.TotalCuts,
State = item.State
});
}
channelViewModel.LoopEnabled = channelState.LoopEnabled;
channelViewModel.EmptyScheduleBehavior = channelState.EmptyScheduleBehavior;
channelViewModel.RefreshSummary();
}
}

View File

@@ -0,0 +1,31 @@
using System;
using Tornado3_2026Election.Common;
namespace Tornado3_2026Election.ViewModels;
public sealed class RegionOptionViewModel : ObservableObject
{
private readonly Action _selectionChanged;
private bool _isSelected;
public RegionOptionViewModel(string name, bool isSelected, Action selectionChanged)
{
Name = name;
_isSelected = isSelected;
_selectionChanged = selectionChanged;
}
public string Name { get; }
public bool IsSelected
{
get => _isSelected;
set
{
if (SetProperty(ref _isSelected, value))
{
_selectionChanged();
}
}
}
}

View File

@@ -0,0 +1,28 @@
using Tornado3_2026Election.Common;
namespace Tornado3_2026Election.ViewModels;
public sealed class RestoreSelectionViewModel : ObservableObject
{
private bool _restoreSchedules = true;
private bool _restoreStations = true;
private bool _restoreStatusValues = true;
public bool RestoreSchedules
{
get => _restoreSchedules;
set => SetProperty(ref _restoreSchedules, value);
}
public bool RestoreStations
{
get => _restoreStations;
set => SetProperty(ref _restoreStations, value);
}
public bool RestoreStatusValues
{
get => _restoreStatusValues;
set => SetProperty(ref _restoreStatusValues, value);
}
}

View File

@@ -0,0 +1,14 @@
namespace Tornado3_2026Election.ViewModels;
public sealed class SelectionOption<T>
{
public SelectionOption(T value, string label)
{
Value = value;
Label = label;
}
public T Value { get; }
public string Label { get; }
}

View File

@@ -0,0 +1,69 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.ViewModels;
public sealed class SettingsViewModel : ObservableObject
{
private string _selectedStationId = "KNN";
private string _imageRootPath = @"C:\ElectionImages";
public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations)
{
Stations = new ObservableCollection<StationFilterItemViewModel>(
stations.Select(station => new StationFilterItemViewModel(station)));
foreach (var station in Stations)
{
station.PropertyChanged += (_, args) =>
{
if (station == SelectedStation && args.PropertyName is nameof(StationFilterItemViewModel.RegionFiltersText) or nameof(StationFilterItemViewModel.RegionSelectionSummary))
{
OnPropertyChanged(nameof(SelectedStationRegionSummary));
}
};
}
if (Stations.Count > 0)
{
_selectedStationId = Stations[0].Id;
}
}
public ObservableCollection<StationFilterItemViewModel> Stations { get; }
public string SelectedStationId
{
get => _selectedStationId;
set
{
if (SetProperty(ref _selectedStationId, value))
{
OnPropertyChanged(nameof(SelectedStation), nameof(SelectedStationLogoAssetPath), nameof(SelectedStationRegions), nameof(SelectedStationRegionSummary));
}
}
}
public string ImageRootPath
{
get => _imageRootPath;
set => SetProperty(ref _imageRootPath, value);
}
public StationFilterItemViewModel SelectedStation
=> Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0];
public string SelectedStationLogoAssetPath => SelectedStation.LogoAssetPath;
public ObservableCollection<RegionOptionViewModel> SelectedStationRegions => SelectedStation.Regions;
public string SelectedStationRegionSummary => SelectedStation.RegionSelectionSummary;
public BroadcastStationProfile BuildSelectedStationProfile()
{
return SelectedStation.ToProfile();
}
}

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.ViewModels;
public sealed class StationFilterItemViewModel : ObservableObject
{
private static readonly string[] SupportedRegions =
[
"서울",
"부산",
"대구",
"인천",
"광주",
"대전",
"울산",
"세종",
"경기",
"강원",
"충북",
"충남",
"전북",
"전남",
"경북",
"경남",
"제주"
];
private static readonly IReadOnlyDictionary<string, string> RegionAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["서울"] = "서울",
["서울특별시"] = "서울",
["부산"] = "부산",
["부산광역시"] = "부산",
["대구"] = "대구",
["대구광역시"] = "대구",
["인천"] = "인천",
["인천광역시"] = "인천",
["광주"] = "광주",
["광주광역시"] = "광주",
["대전"] = "대전",
["대전광역시"] = "대전",
["울산"] = "울산",
["울산광역시"] = "울산",
["세종"] = "세종",
["세종특별자치시"] = "세종",
["경기"] = "경기",
["경기도"] = "경기",
["강원"] = "강원",
["강원도"] = "강원",
["강원특별자치도"] = "강원",
["충북"] = "충북",
["충청북도"] = "충북",
["충남"] = "충남",
["충청남도"] = "충남",
["전북"] = "전북",
["전라북도"] = "전북",
["전북특별자치도"] = "전북",
["전남"] = "전남",
["전라남도"] = "전남",
["경북"] = "경북",
["경상북도"] = "경북",
["경남"] = "경남",
["경상남도"] = "경남",
["제주"] = "제주",
["제주도"] = "제주",
["제주특별자치도"] = "제주"
};
public StationFilterItemViewModel(BroadcastStationProfile station)
{
Id = station.Id;
Name = station.Name;
LogoAssetPath = station.LogoAssetPath;
var selectedRegions = ParseRegions(station.RegionFilters);
Regions = new ObservableCollection<RegionOptionViewModel>(
SupportedRegions.Select(region => new RegionOptionViewModel(region, selectedRegions.Contains(region), NotifyRegionSelectionChanged)));
}
public string Id { get; }
public string Name { get; }
public string LogoAssetPath { get; }
public ObservableCollection<RegionOptionViewModel> Regions { get; }
public string RegionFiltersText
{
get => string.Join(", ", Regions.Where(region => region.IsSelected).Select(region => region.Name));
set => ApplyRegionSelection(value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
}
public int SelectedRegionCount => Regions.Count(region => region.IsSelected);
public string RegionSelectionSummary
=> SelectedRegionCount == 0 ? "선택된 시도가 없습니다." : $"선택된 시도 {SelectedRegionCount}개";
public BroadcastStationProfile ToProfile()
{
return new BroadcastStationProfile
{
Id = Id,
Name = Name,
LogoAssetPath = LogoAssetPath,
RegionFilters = Regions
.Where(region => region.IsSelected)
.Select(region => region.Name)
.ToArray()
};
}
private void ApplyRegionSelection(IEnumerable<string> regionNames)
{
var selectedRegions = ParseRegions(regionNames);
foreach (var region in Regions)
{
region.IsSelected = selectedRegions.Contains(region.Name);
}
NotifyRegionSelectionChanged();
}
private void NotifyRegionSelectionChanged()
{
OnPropertyChanged(nameof(RegionFiltersText), nameof(SelectedRegionCount), nameof(RegionSelectionSummary));
}
private static HashSet<string> ParseRegions(IEnumerable<string> regionNames)
{
var parsedRegions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var regionName in regionNames)
{
var normalizedRegion = NormalizeRegion(regionName);
if (SupportedRegions.Contains(normalizedRegion, StringComparer.Ordinal))
{
parsedRegions.Add(normalizedRegion);
}
}
return parsedRegions;
}
private static string NormalizeRegion(string? regionName)
{
if (string.IsNullOrWhiteSpace(regionName))
{
return string.Empty;
}
var trimmedRegion = regionName.Trim();
return RegionAliases.TryGetValue(trimmedRegion, out var normalizedRegion)
? normalizedRegion
: trimmedRegion;
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Tornado3_2026Election.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

1440
g1.html Normal file

File diff suppressed because it is too large Load Diff

1865
jtv.html Normal file

File diff suppressed because it is too large Load Diff

1282
kbc.html Normal file

File diff suppressed because it is too large Load Diff

2040
knn.html Normal file

File diff suppressed because it is too large Load Diff

1542
tbc.html Normal file

File diff suppressed because it is too large Load Diff

2700
tjb-layout.css Normal file

File diff suppressed because it is too large Load Diff

2176
tjb.html Normal file

File diff suppressed because it is too large Load Diff