초기 커밋.
63
.gitattributes
vendored
Normal 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
@@ -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
@@ -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. 핵심 개념
|
||||
|
||||
- 포맷 기반
|
||||
- 컷 단위 송출
|
||||
- 스케줄 큐 구조
|
||||
- 상태 머신 기반 제어
|
||||
13
Tornado3_2026Election.slnx
Normal 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>
|
||||
86
Tornado3_2026Election/App.xaml
Normal 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>
|
||||
52
Tornado3_2026Election/App.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Tornado3_2026Election/Assets/AppIcon.ico
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
Tornado3_2026Election/Assets/AppIcon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
7
Tornado3_2026Election/Assets/ElectionSealIcon.svg
Normal 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 |
BIN
Tornado3_2026Election/Assets/LockScreenLogo.png
Normal file
|
After Width: | Height: | Size: 576 B |
BIN
Tornado3_2026Election/Assets/LockScreenLogo.scale-200.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
Tornado3_2026Election/Assets/SplashScreen.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
Tornado3_2026Election/Assets/SplashScreen.scale-200.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
Tornado3_2026Election/Assets/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
Tornado3_2026Election/Assets/Square150x150Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
Tornado3_2026Election/Assets/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 966 B |
BIN
Tornado3_2026Election/Assets/Square44x44Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 576 B |
BIN
Tornado3_2026Election/Assets/Stations/g1.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
Tornado3_2026Election/Assets/Stations/jtv.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
Tornado3_2026Election/Assets/Stations/kbc.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Tornado3_2026Election/Assets/Stations/knn.png
Normal file
|
After Width: | Height: | Size: 879 B |
BIN
Tornado3_2026Election/Assets/Stations/tbc.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
Tornado3_2026Election/Assets/Stations/tjb.png
Normal file
|
After Width: | Height: | Size: 624 B |
BIN
Tornado3_2026Election/Assets/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
Tornado3_2026Election/Assets/Wide310x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
Tornado3_2026Election/Assets/Wide310x150Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
54
Tornado3_2026Election/Common/AsyncRelayCommand.cs
Normal 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));
|
||||
}
|
||||
35
Tornado3_2026Election/Common/ObservableObject.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Tornado3_2026Election/Common/RelayCommand.cs
Normal 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));
|
||||
}
|
||||
44
Tornado3_2026Election/Common/RelayCommandOfT.cs
Normal 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));
|
||||
}
|
||||
56
Tornado3_2026Election/Common/UiDispatcher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
331
Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
Normal 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>
|
||||
26
Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1236
Tornado3_2026Election/Data/LocationCatalog.seed.json
Normal file
44
Tornado3_2026Election/Data/ManualCandidateSamples.seed.json
Normal 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": "-없음"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
Tornado3_2026Election/Domain/AppPage.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum AppPage
|
||||
{
|
||||
IntegratedSchedule,
|
||||
Normal,
|
||||
TopLeft,
|
||||
Bottom,
|
||||
VideoWall,
|
||||
Data,
|
||||
Settings,
|
||||
Log
|
||||
}
|
||||
9
Tornado3_2026Election/Domain/BroadcastChannel.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum BroadcastChannel
|
||||
{
|
||||
Normal,
|
||||
TopLeft,
|
||||
Bottom,
|
||||
VideoWall
|
||||
}
|
||||
7
Tornado3_2026Election/Domain/BroadcastPhase.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum BroadcastPhase
|
||||
{
|
||||
PreElection,
|
||||
Counting
|
||||
}
|
||||
14
Tornado3_2026Election/Domain/BroadcastStationProfile.cs
Normal 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; }
|
||||
}
|
||||
115
Tornado3_2026Election/Domain/CandidateEntry.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
9
Tornado3_2026Election/Domain/CandidateJudgement.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum CandidateJudgement
|
||||
{
|
||||
None,
|
||||
Leading,
|
||||
Confirmed,
|
||||
Elected
|
||||
}
|
||||
7
Tornado3_2026Election/Domain/ChannelOperationMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum ChannelOperationMode
|
||||
{
|
||||
General,
|
||||
VideoWall
|
||||
}
|
||||
99
Tornado3_2026Election/Domain/ChannelScheduleItem.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
32
Tornado3_2026Election/Domain/ElectionDataSnapshot.cs
Normal 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);
|
||||
}
|
||||
7
Tornado3_2026Election/Domain/EmptyScheduleBehavior.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum EmptyScheduleBehavior
|
||||
{
|
||||
ImmediateOut,
|
||||
HoldLastFrame
|
||||
}
|
||||
8
Tornado3_2026Election/Domain/FormatCutDefinition.cs
Normal 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; }
|
||||
}
|
||||
20
Tornado3_2026Election/Domain/FormatTemplateDefinition.cs
Normal 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; }
|
||||
}
|
||||
22
Tornado3_2026Election/Domain/LogEntry.cs
Normal 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}";
|
||||
}
|
||||
8
Tornado3_2026Election/Domain/LogLevel.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum LogLevel
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
7
Tornado3_2026Election/Domain/LoopMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum LoopMode
|
||||
{
|
||||
None,
|
||||
StationRegions
|
||||
}
|
||||
11
Tornado3_2026Election/Domain/ScheduleQueueItemState.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum ScheduleQueueItemState
|
||||
{
|
||||
Queued,
|
||||
Next,
|
||||
Sending,
|
||||
OnAir,
|
||||
Completed,
|
||||
Error
|
||||
}
|
||||
10
Tornado3_2026Election/Domain/TornadoConnectionState.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum TornadoConnectionState
|
||||
{
|
||||
Idle,
|
||||
Ready,
|
||||
Sending,
|
||||
OnAir,
|
||||
Error
|
||||
}
|
||||
520
Tornado3_2026Election/MainWindow.xaml
Normal 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>
|
||||
444
Tornado3_2026Election/MainWindow.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
53
Tornado3_2026Election/Package.appxmanifest
Normal 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>
|
||||
50
Tornado3_2026Election/Persistence/AppState.cs
Normal 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; } = [];
|
||||
}
|
||||
42
Tornado3_2026Election/Persistence/AppStateStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
Tornado3_2026Election/Persistence/CandidateState.cs
Normal 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; }
|
||||
}
|
||||
13
Tornado3_2026Election/Persistence/ChannelState.cs
Normal 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; } = [];
|
||||
}
|
||||
25
Tornado3_2026Election/Persistence/ScheduleItemState.cs
Normal 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; }
|
||||
}
|
||||
10
Tornado3_2026Election/Properties/launchSettings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Tornado3_2026Election (Package)": {
|
||||
"commandName": "MsixPackage"
|
||||
},
|
||||
"Tornado3_2026Election (Unpackaged)": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
321
Tornado3_2026Election/Services/ChannelScheduleEngine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Tornado3_2026Election/Services/FormatCatalogService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
16
Tornado3_2026Election/Services/IDataRefreshGate.cs
Normal 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);
|
||||
}
|
||||
30
Tornado3_2026Election/Services/ITornado3Adapter.cs
Normal 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);
|
||||
}
|
||||
38
Tornado3_2026Election/Services/LogService.cs
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
103
Tornado3_2026Election/Services/MockTornado3Adapter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
Tornado3_2026Election/Services/StationCatalogService.cs
Normal 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;
|
||||
}
|
||||
104
Tornado3_2026Election/Tornado3_2026Election.csproj
Normal 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>
|
||||
277
Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
730
Tornado3_2026Election/ViewModels/DataViewModel.cs
Normal file
@@ -0,0 +1,730 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Tornado3_2026Election.Common;
|
||||
using Tornado3_2026Election.Domain;
|
||||
using Tornado3_2026Election.Services;
|
||||
|
||||
namespace Tornado3_2026Election.ViewModels;
|
||||
|
||||
public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposable
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> DistrictCodeMap = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["서울특별시"] = "1100",
|
||||
["부산광역시"] = "2600",
|
||||
["대구광역시"] = "2700",
|
||||
["인천광역시"] = "2800",
|
||||
["광주광역시"] = "2900",
|
||||
["대전광역시"] = "3000",
|
||||
["울산광역시"] = "3100",
|
||||
["세종특별자치시"] = "3600",
|
||||
["경기도"] = "4100",
|
||||
["강원특별자치도"] = "4200",
|
||||
["충청북도"] = "4300",
|
||||
["충청남도"] = "4400",
|
||||
["전북특별자치도"] = "4500",
|
||||
["전라남도"] = "4600",
|
||||
["경상북도"] = "4700",
|
||||
["경상남도"] = "4800",
|
||||
["제주특별자치도"] = "5000"
|
||||
};
|
||||
|
||||
private readonly LogService _logService;
|
||||
private readonly Random _random = new();
|
||||
private CancellationTokenSource? _pollingCts;
|
||||
private TaskCompletionSource<bool>? _refreshSignal;
|
||||
private BroadcastPhase _broadcastPhase = BroadcastPhase.Counting;
|
||||
private bool _isRefreshing;
|
||||
private bool _isPollingEnabled = true;
|
||||
private DateTimeOffset _lastRefreshAt = DateTimeOffset.MinValue;
|
||||
private DateTimeOffset _lastManualRefreshAt = DateTimeOffset.MinValue;
|
||||
private string _electionType = "광역단체장";
|
||||
private string _districtName = "부산광역시";
|
||||
private string _districtCode = "2600";
|
||||
private int _totalExpectedVotes = 1_240_000;
|
||||
private int _turnoutVotes = 528_400;
|
||||
private int _pollingIntervalSeconds = 12;
|
||||
private int _pollingCountdownSeconds;
|
||||
|
||||
public DataViewModel(LogService logService)
|
||||
{
|
||||
_logService = logService;
|
||||
|
||||
ElectionTypeOptions =
|
||||
[
|
||||
new SelectionOption<string>("광역단체장", "광역단체장"),
|
||||
new SelectionOption<string>("교육감", "교육감"),
|
||||
new SelectionOption<string>("광역의원", "광역의원"),
|
||||
new SelectionOption<string>("기초단체장", "기초단체장"),
|
||||
new SelectionOption<string>("기초의원", "기초의원"),
|
||||
new SelectionOption<string>("비례대표광역의원", "비례대표광역의원"),
|
||||
new SelectionOption<string>("비례대표기초의원", "비례대표기초의원")
|
||||
];
|
||||
|
||||
DistrictOptions =
|
||||
[
|
||||
new SelectionOption<string>("서울특별시", "서울특별시"),
|
||||
new SelectionOption<string>("부산광역시", "부산광역시"),
|
||||
new SelectionOption<string>("대구광역시", "대구광역시"),
|
||||
new SelectionOption<string>("인천광역시", "인천광역시"),
|
||||
new SelectionOption<string>("광주광역시", "광주광역시"),
|
||||
new SelectionOption<string>("대전광역시", "대전광역시"),
|
||||
new SelectionOption<string>("울산광역시", "울산광역시"),
|
||||
new SelectionOption<string>("세종특별자치시", "세종특별자치시"),
|
||||
new SelectionOption<string>("경기도", "경기도"),
|
||||
new SelectionOption<string>("강원특별자치도", "강원특별자치도"),
|
||||
new SelectionOption<string>("충청북도", "충청북도"),
|
||||
new SelectionOption<string>("충청남도", "충청남도"),
|
||||
new SelectionOption<string>("전북특별자치도", "전북특별자치도"),
|
||||
new SelectionOption<string>("전라남도", "전라남도"),
|
||||
new SelectionOption<string>("경상북도", "경상북도"),
|
||||
new SelectionOption<string>("경상남도", "경상남도"),
|
||||
new SelectionOption<string>("제주특별자치도", "제주특별자치도")
|
||||
];
|
||||
|
||||
EnsureOptionExists(ElectionTypeOptions, _electionType);
|
||||
EnsureOptionExists(DistrictOptions, _districtName);
|
||||
|
||||
Candidates =
|
||||
[
|
||||
new CandidateEntry { CandidateCode = "A01", Name = "김하늘", Party = "미래연합", VoteCount = 312000, VoteRate = 34.8, HasImage = true },
|
||||
new CandidateEntry { CandidateCode = "A02", Name = "박준호", Party = "국민실행", VoteCount = 287000, VoteRate = 32.0, HasImage = true },
|
||||
new CandidateEntry { CandidateCode = "A03", Name = "이서윤", Party = "정의미래", VoteCount = 168000, VoteRate = 18.7, HasImage = false },
|
||||
new CandidateEntry { CandidateCode = "A04", Name = "정민석", Party = "무소속", VoteCount = 129000, VoteRate = 14.5, HasImage = true }
|
||||
];
|
||||
|
||||
JudgementOptions =
|
||||
[
|
||||
new SelectionOption<CandidateJudgement>(CandidateJudgement.None, "자동 판정 사용"),
|
||||
new SelectionOption<CandidateJudgement>(CandidateJudgement.Leading, "유력"),
|
||||
new SelectionOption<CandidateJudgement>(CandidateJudgement.Confirmed, "확실"),
|
||||
new SelectionOption<CandidateJudgement>(CandidateJudgement.Elected, "당선")
|
||||
];
|
||||
|
||||
ManualRefreshCommand = new AsyncRelayCommand(() => RefreshAsync(isManualRequest: true));
|
||||
AddCandidateCommand = new RelayCommand(AddCandidate);
|
||||
ResetManualJudgementsCommand = new RelayCommand(ResetManualJudgements);
|
||||
RemoveCandidateCommand = new RelayCommand<CandidateEntry>(RemoveCandidate);
|
||||
ToggleCandidateImageCommand = new RelayCommand<CandidateEntry>(ToggleCandidateImage);
|
||||
|
||||
RecalculateJudgements();
|
||||
StartPolling();
|
||||
}
|
||||
|
||||
public ObservableCollection<CandidateEntry> Candidates { get; }
|
||||
|
||||
public ObservableCollection<SelectionOption<string>> ElectionTypeOptions { get; }
|
||||
|
||||
public ObservableCollection<SelectionOption<string>> DistrictOptions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption<CandidateJudgement>> JudgementOptions { get; }
|
||||
|
||||
public AsyncRelayCommand ManualRefreshCommand { get; }
|
||||
|
||||
public RelayCommand AddCandidateCommand { get; }
|
||||
|
||||
public RelayCommand ResetManualJudgementsCommand { get; }
|
||||
|
||||
public RelayCommand<CandidateEntry> RemoveCandidateCommand { get; }
|
||||
|
||||
public RelayCommand<CandidateEntry> ToggleCandidateImageCommand { get; }
|
||||
|
||||
public BroadcastPhase BroadcastPhase
|
||||
{
|
||||
get => _broadcastPhase;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _broadcastPhase, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
NotifyModePresentationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPreElectionPhase => BroadcastPhase == BroadcastPhase.PreElection;
|
||||
|
||||
public bool IsCountingPhase => BroadcastPhase == BroadcastPhase.Counting;
|
||||
|
||||
public string BroadcastPhaseLabel => IsPreElectionPhase ? "사전" : "개표";
|
||||
|
||||
public string BroadcastPhaseBadgeText => IsPreElectionPhase ? "사전 투표율 수신" : "개표 득표수 수신";
|
||||
|
||||
public string BroadcastPhaseDetailText => IsPreElectionPhase
|
||||
? "사전 단계에서는 투표율과 투표자 수를 수신합니다."
|
||||
: "개표 단계에서는 후보 득표수와 판정 데이터를 수신합니다.";
|
||||
|
||||
public string HeaderMetricSummary => IsPreElectionPhase
|
||||
? $"사전 투표율 {TurnoutRateDisplay} / 투표자 {TurnoutVotes:N0}"
|
||||
: $"개표 {CountedVotes:N0} / 남은표 {RemainingVotes:N0}";
|
||||
|
||||
public string SituationMetricPrimaryLabel => IsPreElectionPhase ? "투표율" : "개표수";
|
||||
|
||||
public string SituationMetricPrimaryValue => IsPreElectionPhase ? TurnoutRateDisplay : CountedVotes.ToString("N0");
|
||||
|
||||
public string SituationMetricSecondaryLabel => IsPreElectionPhase ? "투표자" : "남은표";
|
||||
|
||||
public string SituationMetricSecondaryValue => IsPreElectionPhase ? TurnoutVotes.ToString("N0") : RemainingVotes.ToString("N0");
|
||||
|
||||
public string TotalExpectedVotesLabel => IsPreElectionPhase ? "총 선거인수" : "총 예상표";
|
||||
|
||||
public Visibility TurnoutBoardVisibility => IsPreElectionPhase ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility CandidateBoardVisibility => IsCountingPhase ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public Visibility CountingActionsVisibility => IsCountingPhase ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public bool IsRefreshing
|
||||
{
|
||||
get => _isRefreshing;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isRefreshing, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPollingEnabled
|
||||
{
|
||||
get => _isPollingEnabled;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isPollingEnabled, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText), nameof(PollingCountdownText), nameof(PollingModeLabel), nameof(PollingStateDetail));
|
||||
if (value)
|
||||
{
|
||||
StartPolling();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset LastRefreshAt
|
||||
{
|
||||
get => _lastRefreshAt;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _lastRefreshAt, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText), nameof(LastRefreshDisplay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string ElectionType
|
||||
{
|
||||
get => _electionType;
|
||||
set
|
||||
{
|
||||
EnsureOptionExists(ElectionTypeOptions, value);
|
||||
SetProperty(ref _electionType, value);
|
||||
}
|
||||
}
|
||||
|
||||
public string DistrictName
|
||||
{
|
||||
get => _districtName;
|
||||
set
|
||||
{
|
||||
EnsureOptionExists(DistrictOptions, value);
|
||||
if (SetProperty(ref _districtName, value) && DistrictCodeMap.TryGetValue(value, out var districtCode))
|
||||
{
|
||||
DistrictCode = districtCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string DistrictCode
|
||||
{
|
||||
get => _districtCode;
|
||||
set => SetProperty(ref _districtCode, value);
|
||||
}
|
||||
|
||||
public int TotalExpectedVotes
|
||||
{
|
||||
get => _totalExpectedVotes;
|
||||
set
|
||||
{
|
||||
var normalized = Math.Max(1, value);
|
||||
if (SetProperty(ref _totalExpectedVotes, normalized))
|
||||
{
|
||||
if (_turnoutVotes > normalized)
|
||||
{
|
||||
_turnoutVotes = normalized;
|
||||
OnPropertyChanged(nameof(TurnoutVotes));
|
||||
}
|
||||
|
||||
RecalculateJudgements();
|
||||
NotifyMetricPresentationChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int TurnoutVotes
|
||||
{
|
||||
get => _turnoutVotes;
|
||||
set
|
||||
{
|
||||
var normalized = Math.Clamp(value, 0, TotalExpectedVotes);
|
||||
if (SetProperty(ref _turnoutVotes, normalized))
|
||||
{
|
||||
OnPropertyChanged(nameof(TurnoutRemainingVotes), nameof(TurnoutRate), nameof(TurnoutRateDisplay));
|
||||
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int PollingIntervalSeconds
|
||||
{
|
||||
get => _pollingIntervalSeconds;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _pollingIntervalSeconds, Math.Max(3, value)))
|
||||
{
|
||||
OnPropertyChanged(nameof(PollingCountdownText));
|
||||
StartPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int PollingCountdownSeconds
|
||||
{
|
||||
get => _pollingCountdownSeconds;
|
||||
private set
|
||||
{
|
||||
var normalized = Math.Max(0, value);
|
||||
if (SetProperty(ref _pollingCountdownSeconds, normalized))
|
||||
{
|
||||
OnPropertyChanged(nameof(PollingCountdownText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int CountedVotes => Candidates.Sum(candidate => candidate.VoteCount);
|
||||
|
||||
public int RemainingVotes => Math.Max(0, TotalExpectedVotes - CountedVotes);
|
||||
|
||||
public int TurnoutRemainingVotes => Math.Max(0, TotalExpectedVotes - TurnoutVotes);
|
||||
|
||||
public double TurnoutRate => TotalExpectedVotes <= 0
|
||||
? 0
|
||||
: Math.Round(TurnoutVotes * 100d / TotalExpectedVotes, 1, MidpointRounding.AwayFromZero);
|
||||
|
||||
public string TurnoutRateDisplay => $"{TurnoutRate:0.0}%";
|
||||
|
||||
public string LastRefreshDisplay => LastRefreshAt == DateTimeOffset.MinValue
|
||||
? "미수신"
|
||||
: LastRefreshAt.ToString("HH:mm:ss");
|
||||
|
||||
public string PollingModeLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsRefreshing)
|
||||
{
|
||||
return "수신 중";
|
||||
}
|
||||
|
||||
return IsPollingEnabled ? "자동 수신" : "수동 수신";
|
||||
}
|
||||
}
|
||||
|
||||
public string PollingStateDetail => IsRefreshing
|
||||
? "갱신 완료 후 송출"
|
||||
: PollingCountdownText;
|
||||
|
||||
public string PollingCountdownText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsPollingEnabled)
|
||||
{
|
||||
return "API 자동 수신 OFF";
|
||||
}
|
||||
|
||||
if (IsRefreshing)
|
||||
{
|
||||
return "API 수신 진행 중";
|
||||
}
|
||||
|
||||
return $"다음 API 수신까지 {PollingCountdownSeconds}초";
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsRefreshing)
|
||||
{
|
||||
return $"{BroadcastPhaseLabel} 데이터 갱신 중. 송출 요청은 갱신 완료 후 진행됩니다.";
|
||||
}
|
||||
|
||||
if (LastRefreshAt == DateTimeOffset.MinValue)
|
||||
{
|
||||
return $"{BroadcastPhaseLabel} 단계 초기 데이터 유지 중";
|
||||
}
|
||||
|
||||
return $"마지막 갱신 {LastRefreshAt:HH:mm:ss} / 단계 {BroadcastPhaseLabel} / 폴링 {(IsPollingEnabled ? "ON" : "OFF")}";
|
||||
}
|
||||
}
|
||||
|
||||
public void StartPolling()
|
||||
{
|
||||
_pollingCts?.Cancel();
|
||||
if (!IsPollingEnabled)
|
||||
{
|
||||
PollingCountdownSeconds = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_pollingCts = new CancellationTokenSource();
|
||||
_ = RunPollingLoopAsync(_pollingCts.Token);
|
||||
}
|
||||
|
||||
public void ApplyBroadcastPhase(BroadcastPhase phase)
|
||||
{
|
||||
if (BroadcastPhase == phase)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BroadcastPhase = phase;
|
||||
_logService.Info($"방송 단계를 {BroadcastPhaseLabel} 모드로 전환했습니다.");
|
||||
}
|
||||
|
||||
public async Task WaitForRefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (IsRefreshing)
|
||||
{
|
||||
var waiter = _refreshSignal;
|
||||
if (waiter is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await waiter.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public ElectionDataSnapshot GetCurrentSnapshot()
|
||||
{
|
||||
return new ElectionDataSnapshot
|
||||
{
|
||||
BroadcastPhase = BroadcastPhase,
|
||||
ElectionType = ElectionType,
|
||||
DistrictName = DistrictName,
|
||||
DistrictCode = DistrictCode,
|
||||
Candidates = Candidates.Select(candidate => candidate.Clone()).ToArray(),
|
||||
TotalExpectedVotes = TotalExpectedVotes,
|
||||
TurnoutVotes = TurnoutVotes,
|
||||
ReceivedAt = LastRefreshAt == DateTimeOffset.MinValue ? DateTimeOffset.Now : LastRefreshAt
|
||||
};
|
||||
}
|
||||
|
||||
public bool ValidateForFormat(FormatTemplateDefinition template, out string errorMessage)
|
||||
{
|
||||
if (Candidates.Count == 0)
|
||||
{
|
||||
errorMessage = "후보 데이터가 없습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var candidate in Candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate.Name)
|
||||
|| string.IsNullOrWhiteSpace(candidate.Party)
|
||||
|| string.IsNullOrWhiteSpace(candidate.CandidateCode))
|
||||
{
|
||||
errorMessage = "필수 후보 필드가 비어 있어 송출할 수 없습니다.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (template.RequiresImage && Candidates.Any(candidate => !candidate.HasImage))
|
||||
{
|
||||
errorMessage = "이미지 필수 포맷인데 후보 이미지가 없는 항목이 있습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task RefreshAsync(bool isManualRequest)
|
||||
{
|
||||
if (isManualRequest && DateTimeOffset.Now - _lastManualRefreshAt < TimeSpan.FromSeconds(3))
|
||||
{
|
||||
_logService.Warning("수동 수신은 3초 이내 재요청할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshSignal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
IsRefreshing = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (isManualRequest)
|
||||
{
|
||||
_lastManualRefreshAt = DateTimeOffset.Now;
|
||||
_logService.Info($"수동 수신 요청을 처리합니다. 현재 단계는 {BroadcastPhaseLabel}입니다.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logService.Info($"Polling 기반 {BroadcastPhaseLabel} 데이터 수신을 처리합니다.");
|
||||
}
|
||||
|
||||
await Task.Delay(650);
|
||||
|
||||
if (IsPreElectionPhase)
|
||||
{
|
||||
ApplySimulatedTurnoutDelta();
|
||||
LastRefreshAt = DateTimeOffset.Now;
|
||||
_logService.Info($"데이터 갱신 완료. 투표율={TurnoutRateDisplay}, 투표자={TurnoutVotes:N0}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplySimulatedVoteDelta();
|
||||
LastRefreshAt = DateTimeOffset.Now;
|
||||
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
||||
_logService.Info($"데이터 갱신 완료. 개표수={CountedVotes:N0}, 남은표={RemainingVotes:N0}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsRefreshing = false;
|
||||
_refreshSignal?.TrySetResult(true);
|
||||
_refreshSignal = null;
|
||||
|
||||
if (isManualRequest && IsPollingEnabled)
|
||||
{
|
||||
StartPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ReplaceCandidates(IEnumerable<CandidateEntry> candidates)
|
||||
{
|
||||
Candidates.Clear();
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
Candidates.Add(candidate);
|
||||
}
|
||||
|
||||
RecalculateJudgements();
|
||||
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
||||
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopPolling();
|
||||
}
|
||||
|
||||
private async Task RunPollingLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var remainingSeconds = PollingIntervalSeconds;
|
||||
PollingCountdownSeconds = remainingSeconds;
|
||||
|
||||
while (remainingSeconds > 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
remainingSeconds--;
|
||||
PollingCountdownSeconds = remainingSeconds;
|
||||
}
|
||||
|
||||
await RefreshAsync(isManualRequest: false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StopPolling()
|
||||
{
|
||||
_pollingCts?.Cancel();
|
||||
_pollingCts = null;
|
||||
PollingCountdownSeconds = 0;
|
||||
}
|
||||
|
||||
private void ApplySimulatedTurnoutDelta()
|
||||
{
|
||||
if (TurnoutVotes >= TotalExpectedVotes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TurnoutVotes = Math.Min(TotalExpectedVotes, TurnoutVotes + _random.Next(4_500, 18_000));
|
||||
}
|
||||
|
||||
private void ApplySimulatedVoteDelta()
|
||||
{
|
||||
if (RemainingVotes <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var candidate in Candidates)
|
||||
{
|
||||
candidate.VoteCount += _random.Next(1200, 3800);
|
||||
}
|
||||
|
||||
var total = Math.Max(1, Candidates.Sum(candidate => candidate.VoteCount));
|
||||
foreach (var candidate in Candidates)
|
||||
{
|
||||
candidate.VoteRate = Math.Round(candidate.VoteCount * 100d / total, 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
RecalculateJudgements();
|
||||
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
||||
}
|
||||
|
||||
private void RecalculateJudgements()
|
||||
{
|
||||
var orderedCandidates = Candidates.OrderByDescending(candidate => candidate.VoteCount).ToArray();
|
||||
if (orderedCandidates.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var candidate in orderedCandidates)
|
||||
{
|
||||
candidate.AutomaticJudgement = CandidateJudgement.None;
|
||||
}
|
||||
|
||||
if (orderedCandidates.Length == 1)
|
||||
{
|
||||
orderedCandidates[0].AutomaticJudgement = CandidateJudgement.Elected;
|
||||
return;
|
||||
}
|
||||
|
||||
var first = orderedCandidates[0];
|
||||
var second = orderedCandidates[1];
|
||||
var difference = first.VoteCount - second.VoteCount;
|
||||
if (difference > RemainingVotes)
|
||||
{
|
||||
first.AutomaticJudgement = CandidateJudgement.Elected;
|
||||
}
|
||||
else if (difference > RemainingVotes / 2)
|
||||
{
|
||||
first.AutomaticJudgement = CandidateJudgement.Confirmed;
|
||||
}
|
||||
else
|
||||
{
|
||||
first.AutomaticJudgement = CandidateJudgement.Leading;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCandidate()
|
||||
{
|
||||
Candidates.Add(new CandidateEntry
|
||||
{
|
||||
CandidateCode = $"NEW{Candidates.Count + 1:00}",
|
||||
Name = "신규 후보",
|
||||
Party = "정당 입력",
|
||||
VoteCount = 0,
|
||||
VoteRate = 0,
|
||||
HasImage = true
|
||||
});
|
||||
|
||||
RecalculateJudgements();
|
||||
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
||||
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
||||
_logService.Info("후보 행을 추가했습니다.");
|
||||
}
|
||||
|
||||
private void RemoveCandidate(CandidateEntry? candidate)
|
||||
{
|
||||
if (candidate is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Candidates.Remove(candidate);
|
||||
RecalculateJudgements();
|
||||
OnPropertyChanged(nameof(CountedVotes), nameof(RemainingVotes));
|
||||
OnPropertyChanged(nameof(SituationMetricPrimaryValue), nameof(SituationMetricSecondaryValue), nameof(HeaderMetricSummary));
|
||||
_logService.Info($"후보 행 삭제: {candidate.Name}");
|
||||
}
|
||||
|
||||
private void ToggleCandidateImage(CandidateEntry? candidate)
|
||||
{
|
||||
if (candidate is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
candidate.HasImage = !candidate.HasImage;
|
||||
_logService.Info($"후보 이미지 상태 변경: {candidate.Name} => {(candidate.HasImage ? "있음" : "없음")}");
|
||||
}
|
||||
|
||||
private void ResetManualJudgements()
|
||||
{
|
||||
foreach (var candidate in Candidates)
|
||||
{
|
||||
candidate.ManualJudgement = CandidateJudgement.None;
|
||||
}
|
||||
|
||||
_logService.Info("수동 유력/확실/당선 지정값을 초기화했습니다.");
|
||||
}
|
||||
|
||||
private void NotifyModePresentationChanged()
|
||||
{
|
||||
OnPropertyChanged(
|
||||
nameof(IsPreElectionPhase),
|
||||
nameof(IsCountingPhase),
|
||||
nameof(BroadcastPhaseLabel),
|
||||
nameof(BroadcastPhaseBadgeText),
|
||||
nameof(BroadcastPhaseDetailText),
|
||||
nameof(HeaderMetricSummary),
|
||||
nameof(SituationMetricPrimaryLabel),
|
||||
nameof(SituationMetricPrimaryValue),
|
||||
nameof(SituationMetricSecondaryLabel),
|
||||
nameof(SituationMetricSecondaryValue),
|
||||
nameof(TotalExpectedVotesLabel),
|
||||
nameof(TurnoutBoardVisibility),
|
||||
nameof(CandidateBoardVisibility),
|
||||
nameof(CountingActionsVisibility));
|
||||
}
|
||||
|
||||
private void NotifyMetricPresentationChanged()
|
||||
{
|
||||
OnPropertyChanged(
|
||||
nameof(CountedVotes),
|
||||
nameof(RemainingVotes),
|
||||
nameof(TurnoutRemainingVotes),
|
||||
nameof(TurnoutRate),
|
||||
nameof(TurnoutRateDisplay),
|
||||
nameof(SituationMetricPrimaryValue),
|
||||
nameof(SituationMetricSecondaryValue),
|
||||
nameof(HeaderMetricSummary));
|
||||
}
|
||||
|
||||
private static void EnsureOptionExists(ObservableCollection<SelectionOption<string>> options, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || options.Any(option => string.Equals(option.Value, value, StringComparison.Ordinal)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.Add(new SelectionOption<string>(value, value));
|
||||
}
|
||||
}
|
||||
754
Tornado3_2026Election/ViewModels/MainViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
31
Tornado3_2026Election/ViewModels/RegionOptionViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
Tornado3_2026Election/ViewModels/SelectionOption.cs
Normal 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; }
|
||||
}
|
||||
69
Tornado3_2026Election/ViewModels/SettingsViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
162
Tornado3_2026Election/ViewModels/StationFilterItemViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
19
Tornado3_2026Election/app.manifest
Normal 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>
|
||||