기초
This commit is contained in:
248
RGB_COLOR_AUDIT_2026-04-29.md
Normal file
248
RGB_COLOR_AUDIT_2026-04-29.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# RGB 색상 지침 전수조사
|
||||||
|
|
||||||
|
- 작성일: 2026-04-29
|
||||||
|
- 기준 폴더: `E:\김의연\지역민방\T3_Cut`
|
||||||
|
- 기준 문서/코드: `RGB_SPEC_CUT_MAPPING.md`, `TSCN_VARIABLE_DISCOVERY_E_DRIVE.md`, `PartyColorCatalog`
|
||||||
|
- 판정 기준:
|
||||||
|
- 장면 변수에는 `정당명`, `정당바`, `정당판`, `정당원`, `정당색`, `그룹`, `득표율` 등이 있으나 RGB txt에 같은 항목의 색상 지침이 없으면 “색상 지침 없음”으로 정리했다.
|
||||||
|
- RGB txt가 컷명과 1:1로 대응하지 않고 shared/family/inferred/naming bridge/historical로 연결되면 “기준 파일 안내 필요”로 정리했다.
|
||||||
|
- 아래 내용은 RGB txt와 장면 변수 기준 1차 전수조사다. 샘플 이미지와 실제 송출 화면의 육안 색상 차이는 별도 캡처 대조가 필요하다.
|
||||||
|
|
||||||
|
## 외부 공유용 핵심 이슈
|
||||||
|
|
||||||
|
[Normal] 1-2위_ani_광역단체장
|
||||||
|
- 정당명 오브젝트가 있으나 RGB txt에는 정당명 색상 지침이 없음
|
||||||
|
- RGB txt는 `정당판/득표율`, `정당바` 기준만 제공하므로, RGB txt대로 정당명까지 바꾸면 샘플 색상과 다르게 보일 수 있음
|
||||||
|
- 현재 live 검증은 `정당판01`, `정당바01`, `득표율01`의 `SetStyleColor`만 확인되어 있음
|
||||||
|
|
||||||
|
## 색상 지침 누락 의심 컷
|
||||||
|
|
||||||
|
[Normal] 1-2위_광역단체장_시도별영상
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `1-2위_광역단체장,기초단체장_시도별영상.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-2위_교육감
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
|
||||||
|
[Normal] 1-2위_기초단체장
|
||||||
|
- 정당바 오브젝트가 있으나 RGB txt에는 정당바 색상 지침이 없음
|
||||||
|
- 현재 RGB txt는 득표율 기준만 있음
|
||||||
|
|
||||||
|
[Normal] 1-2위_기초단체장_시도별영상
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `1-2위_광역단체장,기초단체장_시도별영상.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-3위_ani_광역단체장
|
||||||
|
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `1-3위_ani_광역단체장,보궐.txt`를 family 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-3위_ani_기초단체장
|
||||||
|
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `1-3위_ani_기초단체장(5760동일).txt`를 family 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-3위_기초단체장_5760
|
||||||
|
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `1-3위_ani_기초단체장(5760동일).txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-3위_보궐선거
|
||||||
|
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `1-3위_ani_광역단체장,보궐.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 모든후보_광역단체장
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 모든후보_광역단체장_5760
|
||||||
|
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 모든후보_교육감
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
|
||||||
|
[Normal] 모든후보_교육감_5760
|
||||||
|
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `모든후보_교육감.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 모든후보_기초단체장
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 모든후보_기초단체장_5760
|
||||||
|
- 정당명, 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `모든후보.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 접전_광역단체장
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 접전_기초단체장
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 초접전_광역단체장
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 초접전_기초단체장
|
||||||
|
- 정당명, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `접전,초접전.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 이시각1위_광역단체장
|
||||||
|
- 정당바, 정당색, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- 현재 RGB txt는 정당명/득표율 기준만 있음
|
||||||
|
|
||||||
|
[Normal] 이시각1위_기초단체장
|
||||||
|
- 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `이시각1위_기초단체장(5760동일).txt`를 family 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 이시각1위_기초단체장_HD
|
||||||
|
- 그룹, 득표율 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `이시각1위_기초단체장(5760동일).txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 판세_광역단체장
|
||||||
|
- 정당명, 정당바 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- 현재 RGB txt는 지역명 기준만 있음
|
||||||
|
|
||||||
|
[Normal] 판세_기초단체장
|
||||||
|
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
|
||||||
|
- `판세_광역단체장.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 판세_기초단체장_5760
|
||||||
|
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
|
||||||
|
- `판세_광역단체장.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 1-3위_광역단체장
|
||||||
|
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
|
||||||
|
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 1-3위_기초단체장
|
||||||
|
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
|
||||||
|
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 1위_광역단체장
|
||||||
|
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
|
||||||
|
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 1위_기초단체장
|
||||||
|
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
|
||||||
|
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 당선_광역단체장
|
||||||
|
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 당선_광역의원
|
||||||
|
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 당선_기초단체장
|
||||||
|
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 당선_기초의원
|
||||||
|
- 정당명, 그룹 오브젝트가 있으나 RGB txt에는 해당 색상 지침이 없음
|
||||||
|
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 전후보_광역단체장
|
||||||
|
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
|
||||||
|
- `모든후보.txt`로 naming bridge 연결 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 전후보_교육감
|
||||||
|
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
|
||||||
|
- `모든후보_교육감.txt`로 naming bridge 연결 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 전후보_기초단체장
|
||||||
|
- 그룹 오브젝트가 있으나 RGB txt에는 그룹 색상 지침이 없음
|
||||||
|
- `모든후보.txt`로 naming bridge 연결 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Top] 광역단체장_2인_텍스트
|
||||||
|
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
|
||||||
|
- `1-2위_텍스트.txt`를 text layout 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Top] 기초단체장_2인_텍스트
|
||||||
|
- 득표율 오브젝트가 있으나 RGB txt에는 득표율 색상 지침이 없음
|
||||||
|
- `1-2위_텍스트.txt`를 text layout 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
## 기준 파일 안내가 필요한 컷
|
||||||
|
|
||||||
|
[Normal] 1-2위_ani_기초단체장_5760
|
||||||
|
- `1-2위_ani_기초단체장.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-2위_광역단체장
|
||||||
|
- `1-2위_광역단체장, 보궐.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-2위_광역단체장_5760
|
||||||
|
- `1-2위_광역단체장, 보궐.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 1-2위_보궐선거
|
||||||
|
- `1-2위_광역단체장, 보궐.txt`를 추정 연결 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 경력_광역단체장_in
|
||||||
|
- `경력.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 경력_기초단체장_in
|
||||||
|
- `경력.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 당선_광역단체장/HD, 당선_광역의원/HD, 당선_기초단체장/HD, 당선_기초의원/HD
|
||||||
|
- `당선.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 당선_교육감_HD
|
||||||
|
- `당선_교육감.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 사전_역대당선자, 사전_역대당선자_기초단체장
|
||||||
|
- `사전_역대당선.txt`를 historical 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 사전_역대당선자_교육감
|
||||||
|
- `사전_역대당선_교육감.txt`를 historical 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Normal] 이시각1위_광역단체장_HD
|
||||||
|
- `이시각1위_광역단체장.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Bottom] 1-2위_광역단체장, 1-2위_기초단체장
|
||||||
|
- `1-2위, 1-3위, 이시각1위.txt`를 공유 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
[Top] 광역단체장_2인, 기초단체장_2인
|
||||||
|
- `1-2위_사진.txt`를 photo layout 기준으로 사용 중이라 기준 파일 안내가 필요함
|
||||||
|
|
||||||
|
## RGB 명시 매핑이 아직 없는 컷
|
||||||
|
|
||||||
|
[Normal] 광역의원표
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Normal] 광역의원표_HD
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Normal] 기초의원표
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Normal] 기초의원표_HD
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Normal] 역대시도판세_광역단체장
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Normal] 역대시도판세_기초단체장
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Top] 판세_광역단체장
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Top] 판세_광역의원
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Top] 판세_교육감
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Top] 판세_기초단체장
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
[Top] 판세_기초의원
|
||||||
|
- RGB txt 명시 매핑이 아직 없음
|
||||||
|
|
||||||
|
## 색상 작업 대상에서 제외해도 되는 컷
|
||||||
|
|
||||||
|
[공통] 민방_타이틀*, 사전_역대투표율*, 사전투표율, 투표율*
|
||||||
|
- 정당색/후보색 중심 컷이 아니라 현재 RGB 정당 색상 이슈 대상에서는 제외 가능함
|
||||||
|
|
||||||
|
[공통] 투표율_사진, 투표율_선거구별, 투표율_선거구별 사전, 투표율_시도별, 투표율_영상
|
||||||
|
- 정당색/후보색 중심 컷이 아니라 현재 RGB 정당 색상 이슈 대상에서는 제외 가능함
|
||||||
@@ -196,12 +196,10 @@
|
|||||||
- `TORNADO_KARISMA_BIND_BOTTOM`
|
- `TORNADO_KARISMA_BIND_BOTTOM`
|
||||||
- `TORNADO_KARISMA_BIND_VIDEOWALL`
|
- `TORNADO_KARISMA_BIND_VIDEOWALL`
|
||||||
|
|
||||||
### 9.4 T3_Cut 탐색
|
### 9.4 T3_Cut 경로
|
||||||
|
|
||||||
- `TORNADO_T3CUT_PATH`
|
- 앱, 송출 어댑터, 썸네일 생성기, Karisma 디버깅 도구는 `D:\\Elect2026\\T3_Cut`를 고정 기준 경로로 사용한다.
|
||||||
- `문서\\Tornado3 Data\\T3_Cut\\T3_Cut`
|
- 사용자 설정값, 저장된 상태값, `TORNADO_T3CUT_PATH`, 디버깅 도구의 `--image-root`/`--root` 입력은 T3_Cut 기준 경로를 바꾸지 않는다.
|
||||||
- `문서\\Tornado3 Data\\T3_Cut`
|
|
||||||
- `다운로드\\T3_Cut`
|
|
||||||
|
|
||||||
### 9.5 폴백
|
### 9.5 폴백
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ public enum AppPage
|
|||||||
Bottom,
|
Bottom,
|
||||||
VideoWall,
|
VideoWall,
|
||||||
PreElectionData,
|
PreElectionData,
|
||||||
|
TurnoutData,
|
||||||
|
CountingData,
|
||||||
Data,
|
Data,
|
||||||
CutList,
|
CutList,
|
||||||
Settings,
|
Settings,
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ public sealed class FormatCutDefinition
|
|||||||
public required double DurationSeconds { get; set; }
|
public required double DurationSeconds { get; set; }
|
||||||
|
|
||||||
public int CandidateStartIndex { get; init; }
|
public int CandidateStartIndex { get; init; }
|
||||||
|
|
||||||
|
public bool UseEndScene { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Download" /></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="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||||
<NavigationViewItem Content="사전데이터" Tag="pre-election-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Library" /></NavigationViewItem.Icon></NavigationViewItem>
|
<NavigationViewItem Content="사전데이터" Tag="pre-election-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Library" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||||
<NavigationViewItem Content="데이터" Tag="data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
|
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||||
|
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||||
<NavigationViewItem Content="컷리스트" Tag="cut-list"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
|
<NavigationViewItem Content="컷리스트" Tag="cut-list"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||||
<NavigationViewItem Content="설정" Tag="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></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>
|
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
<StackPanel Spacing="2">
|
<StackPanel Spacing="2">
|
||||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="단계" HorizontalAlignment="Center" />
|
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="단계" HorizontalAlignment="Center" />
|
||||||
<ToggleSwitch x:Name="BroadcastPhaseToggleSwitch"
|
<ToggleSwitch x:Name="BroadcastPhaseToggleSwitch"
|
||||||
OffContent="사전"
|
OffContent="투표"
|
||||||
OnContent="개표"
|
OnContent="개표"
|
||||||
IsOn="{x:Bind ViewModel.Data.IsCountingPhase, Mode=OneWay}"
|
IsOn="{x:Bind ViewModel.Data.IsCountingPhase, Mode=OneWay}"
|
||||||
Toggled="BroadcastPhaseToggleSwitch_Toggled" />
|
Toggled="BroadcastPhaseToggleSwitch_Toggled" />
|
||||||
@@ -893,7 +894,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<NumberBox Grid.Column="3"
|
<NumberBox Grid.Column="3"
|
||||||
Minimum="1"
|
Minimum="{x:Bind MinimumDurationSeconds, Mode=OneWay}"
|
||||||
SmallChange="1"
|
SmallChange="1"
|
||||||
SpinButtonPlacementMode="Compact"
|
SpinButtonPlacementMode="Compact"
|
||||||
Value="{x:Bind DurationSeconds, Mode=TwoWay}" />
|
Value="{x:Bind DurationSeconds, Mode=TwoWay}" />
|
||||||
@@ -917,13 +918,13 @@
|
|||||||
<Grid ColumnSpacing="12"><Grid.ColumnDefinitions><ColumnDefinition Width="280" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
<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" />
|
<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 Grid.Column="1" ColumnSpacing="10">
|
||||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||||
<TextBox IsReadOnly="True"
|
<TextBox IsReadOnly="True"
|
||||||
IsSpellCheckEnabled="False"
|
IsSpellCheckEnabled="False"
|
||||||
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}">
|
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}">
|
||||||
<TextBox.Header>
|
<TextBox.Header>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<TextBlock Text="T3_Cut 경로" />
|
<TextBlock Text="T3_Cut 고정 경로" />
|
||||||
<Button Width="22"
|
<Button Width="22"
|
||||||
Height="22"
|
Height="22"
|
||||||
MinWidth="22"
|
MinWidth="22"
|
||||||
@@ -936,7 +937,7 @@
|
|||||||
<Button.Flyout>
|
<Button.Flyout>
|
||||||
<Flyout>
|
<Flyout>
|
||||||
<TextBlock MaxWidth="260"
|
<TextBlock MaxWidth="260"
|
||||||
Text="송출에 사용할 .tscn 컷 폴더입니다. 폴더를 바꾸면 컷 검색 기준도 함께 바뀝니다."
|
Text="송출과 디버깅에 사용할 .tscn 컷 폴더입니다. 기준 경로는 D:\Elect2026\T3_Cut로 고정됩니다."
|
||||||
TextWrapping="WrapWholeWords" />
|
TextWrapping="WrapWholeWords" />
|
||||||
</Flyout>
|
</Flyout>
|
||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
@@ -944,11 +945,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TextBox.Header>
|
</TextBox.Header>
|
||||||
</TextBox>
|
</TextBox>
|
||||||
<Button Grid.Column="1"
|
|
||||||
Click="PickImageRootFolderButton_Click"
|
|
||||||
Content="폴더 선택"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid ColumnSpacing="12">
|
<Grid ColumnSpacing="12">
|
||||||
@@ -960,7 +956,7 @@
|
|||||||
<ComboBox DisplayMemberPath="Label"
|
<ComboBox DisplayMemberPath="Label"
|
||||||
Header="비디오월 화면"
|
Header="비디오월 화면"
|
||||||
ItemsSource="{x:Bind ViewModel.Settings.VideoWallLayoutOptions, Mode=OneWay}"
|
ItemsSource="{x:Bind ViewModel.Settings.VideoWallLayoutOptions, Mode=OneWay}"
|
||||||
SelectedValue="{x:Bind ViewModel.Settings.SelectedStationVideoWallLayoutPreset, Mode=TwoWay}"
|
SelectedValue="{x:Bind ViewModel.Settings.SelectedStationVideoWallLayoutPresetSelection, Mode=TwoWay}"
|
||||||
SelectedValuePath="Value" />
|
SelectedValuePath="Value" />
|
||||||
|
|
||||||
<Border Grid.Column="1"
|
<Border Grid.Column="1"
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ public sealed partial class MainWindow : Window
|
|||||||
var folder = await picker.PickSingleFolderAsync();
|
var folder = await picker.PickSingleFolderAsync();
|
||||||
if (folder is not null)
|
if (folder is not null)
|
||||||
{
|
{
|
||||||
ViewModel.Settings.ImageRootPath = folder.Path;
|
ViewModel.Settings.ImageRootPath = Services.TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -202,6 +202,7 @@ public sealed partial class MainWindow : Window
|
|||||||
if (confirmed)
|
if (confirmed)
|
||||||
{
|
{
|
||||||
ViewModel.ApplyBroadcastPhase(targetPhase);
|
ViewModel.ApplyBroadcastPhase(targetPhase);
|
||||||
|
EnsureNavigationSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,9 +352,9 @@ public sealed partial class MainWindow : Window
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetLabel = targetPhase == BroadcastPhase.PreElection ? "사전" : "개표";
|
var targetLabel = targetPhase == BroadcastPhase.PreElection ? "투표" : "개표";
|
||||||
var description = targetPhase == BroadcastPhase.PreElection
|
var description = targetPhase == BroadcastPhase.PreElection
|
||||||
? "사전 단계에서는 투표율과 투표자 수 중심으로 수신합니다."
|
? "투표 단계에서는 투표율과 투표자 수 중심으로 수신합니다."
|
||||||
: "개표 단계에서는 후보 득표수와 당선 판정 중심으로 수신합니다.";
|
: "개표 단계에서는 후보 득표수와 당선 판정 중심으로 수신합니다.";
|
||||||
|
|
||||||
var dialog = new ContentDialog
|
var dialog = new ContentDialog
|
||||||
@@ -458,7 +459,9 @@ public sealed partial class MainWindow : Window
|
|||||||
AppPage.Bottom => "bottom",
|
AppPage.Bottom => "bottom",
|
||||||
AppPage.VideoWall => "videowall",
|
AppPage.VideoWall => "videowall",
|
||||||
AppPage.PreElectionData => "pre-election-data",
|
AppPage.PreElectionData => "pre-election-data",
|
||||||
AppPage.Data => "data",
|
AppPage.TurnoutData => "turnout-data",
|
||||||
|
AppPage.CountingData => "counting-data",
|
||||||
|
AppPage.Data => ViewModel.Data.IsPreElectionPhase ? "turnout-data" : "counting-data",
|
||||||
AppPage.CutList => "cut-list",
|
AppPage.CutList => "cut-list",
|
||||||
AppPage.Settings => "settings",
|
AppPage.Settings => "settings",
|
||||||
AppPage.Log => "log",
|
AppPage.Log => "log",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ public sealed class ChannelScheduleEngine
|
|||||||
var station = _stationProvider();
|
var station = _stationProvider();
|
||||||
var imageRootPath = _imageRootProvider();
|
var imageRootPath = _imageRootProvider();
|
||||||
var resolvedCuts = ResolveCuts(template, station);
|
var resolvedCuts = ResolveCuts(template, station);
|
||||||
|
var hasEndScene = KarismaSceneResolver.HasEndScene(template, imageRootPath);
|
||||||
var regionTargets = await _dataRefreshGate
|
var regionTargets = await _dataRefreshGate
|
||||||
.ResolveScheduleRegionTargetsAsync(queueItem, template, station, cancellationToken)
|
.ResolveScheduleRegionTargetsAsync(queueItem, template, station, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
@@ -320,8 +321,9 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
queueItem.CurrentRegionLabel = queueItem.SelectionRegionLabel;
|
queueItem.CurrentRegionLabel = queueItem.SelectionRegionLabel;
|
||||||
|
|
||||||
foreach (var cut in resolvedCuts)
|
for (var cutIndex = 0; cutIndex < resolvedCuts.Count; cutIndex++)
|
||||||
{
|
{
|
||||||
|
var cut = ResolveScheduledCut(resolvedCuts[cutIndex], hasEndScene, cutIndex == resolvedCuts.Count - 1);
|
||||||
queueItem.State = ScheduleQueueItemState.Sending;
|
queueItem.State = ScheduleQueueItemState.Sending;
|
||||||
RefreshQueueMarkers();
|
RefreshQueueMarkers();
|
||||||
|
|
||||||
@@ -336,7 +338,8 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_advanceSignal = signal;
|
_advanceSignal = signal;
|
||||||
var delayTask = Task.Delay(TimeSpan.FromSeconds(cut.DurationSeconds), cancellationToken);
|
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||||
|
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
|
||||||
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,8 +350,9 @@ public sealed class ChannelScheduleEngine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var regionTarget in regionTargets)
|
for (var regionIndex = 0; regionIndex < regionTargets.Count; regionIndex++)
|
||||||
{
|
{
|
||||||
|
var regionTarget = regionTargets[regionIndex];
|
||||||
ElectionDataSnapshot snapshot;
|
ElectionDataSnapshot snapshot;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -376,7 +380,8 @@ public sealed class ChannelScheduleEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
queueItem.CurrentRegionLabel = regionTarget.DisplayName;
|
queueItem.CurrentRegionLabel = regionTarget.DisplayName;
|
||||||
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot);
|
var isLastRegion = regionIndex == regionTargets.Count - 1;
|
||||||
|
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
|
||||||
queueItem.TotalCuts = playbackCuts.Count;
|
queueItem.TotalCuts = playbackCuts.Count;
|
||||||
|
|
||||||
foreach (var cut in playbackCuts)
|
foreach (var cut in playbackCuts)
|
||||||
@@ -395,7 +400,8 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_advanceSignal = signal;
|
_advanceSignal = signal;
|
||||||
var delayTask = Task.Delay(TimeSpan.FromSeconds(cut.DurationSeconds), cancellationToken);
|
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||||
|
var delayTask = Task.Delay(TimeSpan.FromSeconds(durationSeconds), cancellationToken);
|
||||||
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +416,11 @@ public sealed class ChannelScheduleEngine
|
|||||||
|
|
||||||
private static bool ShouldUseAggregateScheduleSnapshot(FormatTemplateDefinition template)
|
private static bool ShouldUseAggregateScheduleSnapshot(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (template.RecommendedChannel == BroadcastChannel.Bottom)
|
if (template.RecommendedChannel == BroadcastChannel.Bottom)
|
||||||
{
|
{
|
||||||
return string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
|
return string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
|
||||||
@@ -423,17 +434,18 @@ public sealed class ChannelScheduleEngine
|
|||||||
private static IReadOnlyList<FormatCutDefinition> ResolvePlaybackCuts(
|
private static IReadOnlyList<FormatCutDefinition> ResolvePlaybackCuts(
|
||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
IReadOnlyList<FormatCutDefinition> baseCuts,
|
IReadOnlyList<FormatCutDefinition> baseCuts,
|
||||||
ElectionDataSnapshot snapshot)
|
ElectionDataSnapshot snapshot,
|
||||||
|
bool useEndSceneOnLastCut)
|
||||||
{
|
{
|
||||||
if (!IsCareerTemplate(template) || baseCuts.Count == 0)
|
if (!IsCareerTemplate(template) || baseCuts.Count == 0)
|
||||||
{
|
{
|
||||||
return baseCuts;
|
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
|
||||||
}
|
}
|
||||||
|
|
||||||
var candidateCount = snapshot.Candidates.Count;
|
var candidateCount = snapshot.Candidates.Count;
|
||||||
if (candidateCount <= 1)
|
if (candidateCount <= 1)
|
||||||
{
|
{
|
||||||
return baseCuts;
|
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
|
||||||
}
|
}
|
||||||
|
|
||||||
var playbackCuts = new List<FormatCutDefinition>(baseCuts.Count * candidateCount);
|
var playbackCuts = new List<FormatCutDefinition>(baseCuts.Count * candidateCount);
|
||||||
@@ -445,12 +457,46 @@ public sealed class ChannelScheduleEngine
|
|||||||
{
|
{
|
||||||
Name = $"{baseCut.Name} #{candidateIndex + 1}",
|
Name = $"{baseCut.Name} #{candidateIndex + 1}",
|
||||||
DurationSeconds = baseCut.DurationSeconds,
|
DurationSeconds = baseCut.DurationSeconds,
|
||||||
CandidateStartIndex = candidateIndex
|
CandidateStartIndex = candidateIndex,
|
||||||
|
UseEndScene = baseCut.UseEndScene
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return playbackCuts;
|
return ApplyEndSceneToLastCut(playbackCuts, useEndSceneOnLastCut);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<FormatCutDefinition> ApplyEndSceneToLastCut(
|
||||||
|
IReadOnlyList<FormatCutDefinition> cuts,
|
||||||
|
bool useEndSceneOnLastCut)
|
||||||
|
{
|
||||||
|
if (!useEndSceneOnLastCut || cuts.Count == 0)
|
||||||
|
{
|
||||||
|
return cuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = cuts.ToArray();
|
||||||
|
result[^1] = ResolveScheduledCut(result[^1], hasEndScene: true, useEndScene: true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FormatCutDefinition ResolveScheduledCut(
|
||||||
|
FormatCutDefinition cut,
|
||||||
|
bool hasEndScene,
|
||||||
|
bool useEndScene)
|
||||||
|
{
|
||||||
|
if (!hasEndScene || !useEndScene || cut.UseEndScene)
|
||||||
|
{
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FormatCutDefinition
|
||||||
|
{
|
||||||
|
Name = cut.Name,
|
||||||
|
DurationSeconds = cut.DurationSeconds,
|
||||||
|
CandidateStartIndex = cut.CandidateStartIndex,
|
||||||
|
UseEndScene = true
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCareerTemplate(FormatTemplateDefinition template)
|
private static bool IsCareerTemplate(FormatTemplateDefinition template)
|
||||||
|
|||||||
@@ -6,17 +6,7 @@ namespace Tornado3_2026Election.Services;
|
|||||||
internal static class CutAppearancePolicyCatalog
|
internal static class CutAppearancePolicyCatalog
|
||||||
{
|
{
|
||||||
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> DefaultAppearanceSectionsByTemplate =
|
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> DefaultAppearanceSectionsByTemplate =
|
||||||
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal)
|
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal);
|
||||||
{
|
|
||||||
["1-2위_ani_광역단체장"] = CreateSectionSet(
|
|
||||||
"정당판",
|
|
||||||
"정당바",
|
|
||||||
"득표수바",
|
|
||||||
"정당원",
|
|
||||||
"정당색",
|
|
||||||
"정당명",
|
|
||||||
"득표율")
|
|
||||||
};
|
|
||||||
|
|
||||||
public static bool UsesTemplateDefaultAppearance(string templateName, string sectionName)
|
public static bool UsesTemplateDefaultAppearance(string templateName, string sectionName)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ public sealed class FormatCatalogService
|
|||||||
"사전_역대당선자_교육감",
|
"사전_역대당선자_교육감",
|
||||||
"사전_역대당선자_기초단체장",
|
"사전_역대당선자_기초단체장",
|
||||||
"사전_역대투표율",
|
"사전_역대투표율",
|
||||||
"사전_역대투표율_loop",
|
|
||||||
"사전_역대투표율_5760",
|
"사전_역대투표율_5760",
|
||||||
"역대시도판세_광역단체장",
|
"역대시도판세_광역단체장",
|
||||||
"역대시도판세_기초단체장",
|
"역대시도판세_기초단체장",
|
||||||
@@ -166,17 +165,20 @@ public sealed class FormatCatalogService
|
|||||||
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
|
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
|
||||||
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
|
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
|
||||||
var sceneResolution = TryReadSceneResolution(relativeFolder, baseName, t3CutPath);
|
var sceneResolution = TryReadSceneResolution(relativeFolder, baseName, t3CutPath);
|
||||||
|
var recommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution);
|
||||||
|
|
||||||
yield return new FormatTemplateDefinition
|
yield return new FormatTemplateDefinition
|
||||||
{
|
{
|
||||||
Id = Path.Combine(relativeFolder, baseName),
|
Id = Path.Combine(relativeFolder, baseName),
|
||||||
Name = baseName,
|
Name = baseName,
|
||||||
Description = $"{relativeFolder} 컷",
|
Description = $"{relativeFolder} 컷",
|
||||||
RecommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution),
|
RecommendedChannel = recommendedChannel,
|
||||||
RequiresImage = false,
|
RequiresImage = false,
|
||||||
SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat,
|
SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat,
|
||||||
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
|
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
|
||||||
RequiresCandidateData = !isPreElectionOnlyFormat && !IsHistoricalPreElectionWinnerFormat(baseName),
|
RequiresCandidateData = !isPreElectionOnlyFormat &&
|
||||||
|
!IsHistoricalPreElectionWinnerFormat(baseName) &&
|
||||||
|
!ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(baseName),
|
||||||
LoopMode = LoopMode.None,
|
LoopMode = LoopMode.None,
|
||||||
SceneWidth = sceneResolution?.Width,
|
SceneWidth = sceneResolution?.Width,
|
||||||
SceneHeight = sceneResolution?.Height,
|
SceneHeight = sceneResolution?.Height,
|
||||||
@@ -185,7 +187,10 @@ public sealed class FormatCatalogService
|
|||||||
new FormatCutDefinition
|
new FormatCutDefinition
|
||||||
{
|
{
|
||||||
Name = baseName,
|
Name = baseName,
|
||||||
DurationSeconds = defaultCutDurationSeconds
|
DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
|
||||||
|
defaultCutDurationSeconds,
|
||||||
|
recommendedChannel,
|
||||||
|
baseName)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -228,6 +233,8 @@ public sealed class FormatCatalogService
|
|||||||
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_L_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_L_END")] = Path.Combine("Elect2026_Normal_민방", "모든후보_기초단체장_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "민방_타이틀_L")] = Path.Combine("Elect2026_Normal_민방", "민방_타이틀_1920"),
|
[Path.Combine("Elect2026_Normal_민방", "민방_타이틀_L")] = Path.Combine("Elect2026_Normal_민방", "민방_타이틀_1920"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "민방_타이틀_L_nologo")] = Path.Combine("Elect2026_Normal_민방", "민방_타이틀_5760_nologo"),
|
[Path.Combine("Elect2026_Normal_민방", "민방_타이틀_L_nologo")] = Path.Combine("Elect2026_Normal_민방", "민방_타이틀_5760_nologo"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_loop")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율"),
|
||||||
|
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760_loop")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||||
[Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_HD"),
|
[Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_L")] = Path.Combine("Elect2026_Normal_민방", "이시각1위_광역단체장_HD"),
|
||||||
@@ -308,12 +315,6 @@ public sealed class FormatCatalogService
|
|||||||
|
|
||||||
private static string ResolveT3CutPath(string? configuredT3CutPath)
|
private static string ResolveT3CutPath(string? configuredT3CutPath)
|
||||||
{
|
{
|
||||||
var normalizedPath = TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(normalizedPath))
|
|
||||||
{
|
|
||||||
return normalizedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TornadoPathResolver.GetDefaultT3CutPath();
|
return TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
Tornado3_2026Election/Services/KarismaChartCellUpdate.cs
Normal file
7
Tornado3_2026Election/Services/KarismaChartCellUpdate.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Tornado3_2026Election.Services;
|
||||||
|
|
||||||
|
public readonly record struct KarismaChartCellUpdate(
|
||||||
|
string ObjectName,
|
||||||
|
int Row,
|
||||||
|
int Column,
|
||||||
|
float Value);
|
||||||
10
Tornado3_2026Election/Services/KarismaPositionUpdate.cs
Normal file
10
Tornado3_2026Election/Services/KarismaPositionUpdate.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using KAsyncEngineLib;
|
||||||
|
|
||||||
|
namespace Tornado3_2026Election.Services;
|
||||||
|
|
||||||
|
public readonly record struct KarismaPositionUpdate(
|
||||||
|
string ObjectName,
|
||||||
|
float X,
|
||||||
|
float Y,
|
||||||
|
float Z,
|
||||||
|
eKVectorType VectorType);
|
||||||
@@ -6,13 +6,22 @@ namespace Tornado3_2026Election.Services;
|
|||||||
|
|
||||||
internal static class KarismaSceneResolver
|
internal static class KarismaSceneResolver
|
||||||
{
|
{
|
||||||
public static KarismaResolvedScene ResolveScene(FormatTemplateDefinition template, string t3CutPath, bool useLoop)
|
public static KarismaResolvedScene ResolveScene(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
string t3CutPath,
|
||||||
|
bool useLoop,
|
||||||
|
bool useEnd = false)
|
||||||
{
|
{
|
||||||
var baseScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
|
var baseScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
|
||||||
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
|
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
|
||||||
|
var endScenePath = Path.Combine(t3CutPath, template.Id + "_END.tscn");
|
||||||
|
|
||||||
string selectedPath;
|
string selectedPath;
|
||||||
if (useLoop && File.Exists(loopScenePath))
|
if (useEnd && File.Exists(endScenePath))
|
||||||
|
{
|
||||||
|
selectedPath = endScenePath;
|
||||||
|
}
|
||||||
|
else if (useLoop && File.Exists(loopScenePath))
|
||||||
{
|
{
|
||||||
selectedPath = loopScenePath;
|
selectedPath = loopScenePath;
|
||||||
}
|
}
|
||||||
@@ -33,6 +42,11 @@ internal static class KarismaSceneResolver
|
|||||||
selectedPath,
|
selectedPath,
|
||||||
template.Id.Replace('\\', '_').Replace('/', '_'));
|
template.Id.Replace('\\', '_').Replace('/', '_'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool HasEndScene(FormatTemplateDefinition template, string t3CutPath)
|
||||||
|
{
|
||||||
|
return File.Exists(Path.Combine(t3CutPath, template.Id + "_END.tscn"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal readonly record struct KarismaResolvedScene(string Path, string Alias);
|
internal readonly record struct KarismaResolvedScene(string Path, string Alias);
|
||||||
|
|||||||
@@ -253,7 +253,8 @@ public sealed class KarismaSceneVariableCatalog
|
|||||||
{
|
{
|
||||||
return variableName.StartsWith("득표율", StringComparison.Ordinal) ||
|
return variableName.StartsWith("득표율", StringComparison.Ordinal) ||
|
||||||
variableName.StartsWith("투표율", StringComparison.Ordinal) ||
|
variableName.StartsWith("투표율", StringComparison.Ordinal) ||
|
||||||
variableName.StartsWith("전국투표율", StringComparison.Ordinal);
|
variableName.StartsWith("전국투표율", StringComparison.Ordinal) ||
|
||||||
|
variableName.StartsWith("의석수", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> FindDiscoveryReportPaths()
|
private static IEnumerable<string> FindDiscoveryReportPaths()
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ public sealed class KarismaThumbnailGeneratorService
|
|||||||
VideoWallLayoutPreset videoWallLayoutPreset,
|
VideoWallLayoutPreset videoWallLayoutPreset,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var t3CutPath = string.IsNullOrWhiteSpace(configuredT3CutPath)
|
var t3CutPath = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
? TornadoPathResolver.GetDefaultT3CutPath()
|
|
||||||
: TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
|
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Linq;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using KAsyncEngineLib;
|
||||||
using Tornado3_2026Election.Domain;
|
using Tornado3_2026Election.Domain;
|
||||||
|
|
||||||
namespace Tornado3_2026Election.Services;
|
namespace Tornado3_2026Election.Services;
|
||||||
@@ -15,6 +16,19 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
private const int DefaultKarismaPort = 30001;
|
private const int DefaultKarismaPort = 30001;
|
||||||
private const int DefaultCandidateSlotClearCount = 8;
|
private const int DefaultCandidateSlotClearCount = 8;
|
||||||
private const int DefaultTurnoutSlotClearCount = 6;
|
private const int DefaultTurnoutSlotClearCount = 6;
|
||||||
|
private const string HistoricalTurnoutChartObjectName = "차트01";
|
||||||
|
private const string HistoricalTurnoutCircleObjectPrefix = "투표율원";
|
||||||
|
private const int HistoricalTurnoutChartCellRowCount = 8;
|
||||||
|
private const float HistoricalTurnoutCounterBaseX = -0.37133408f;
|
||||||
|
private const float HistoricalTurnoutCounterBaseY = 24.838575f;
|
||||||
|
private const float HistoricalTurnoutCounterBaseZ = 0f;
|
||||||
|
private const float HistoricalTurnoutCircleBaseY = -43.72818f;
|
||||||
|
private const float HistoricalTurnoutCircleBaseZ = 0f;
|
||||||
|
// Scene-local Y scale: 100% stays at the template's top label position; lower rates move downward.
|
||||||
|
private const float HistoricalTurnoutCounterYUnitsPerPercent = 5.1f;
|
||||||
|
private const float HistoricalTurnoutCircleYUnitsPerPercent = 6.32f;
|
||||||
|
private const int DefaultCouncilSeatSlotCount = 6;
|
||||||
|
private const string CouncilSeatCandidateCodePrefix = "SEAT:";
|
||||||
private static readonly string[] CandidateSlotVariablePrefixes =
|
private static readonly string[] CandidateSlotVariablePrefixes =
|
||||||
[
|
[
|
||||||
"순위",
|
"순위",
|
||||||
@@ -34,6 +48,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
"정당심볼",
|
"정당심볼",
|
||||||
"그룹"
|
"그룹"
|
||||||
];
|
];
|
||||||
|
private static readonly string[] CouncilSeatObjectSuffixes = ["A", "B", "C"];
|
||||||
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
||||||
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
||||||
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
|
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
|
||||||
@@ -66,6 +81,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
|
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
|
||||||
private readonly string _connectionTarget;
|
private readonly string _connectionTarget;
|
||||||
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
|
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
|
||||||
|
private readonly Dictionary<BroadcastChannel, bool> _pendingEndScenes = new();
|
||||||
private readonly Dictionary<BroadcastChannel, bool> _channelOnAir = new();
|
private readonly Dictionary<BroadcastChannel, bool> _channelOnAir = new();
|
||||||
private TornadoConnectionState _state = TornadoConnectionState.Idle;
|
private TornadoConnectionState _state = TornadoConnectionState.Idle;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -137,20 +153,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
logService.Info($"Karisma adapter using default port {DefaultKarismaPort}.");
|
logService.Info($"Karisma adapter using default port {DefaultKarismaPort}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var configuredT3CutPath = t3CutPathProvider();
|
var t3CutPath = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
var t3CutPath = string.IsNullOrWhiteSpace(configuredT3CutPath)
|
logService.Info($"Karisma adapter using fixed T3_Cut path '{t3CutPath}'.");
|
||||||
? TornadoPathResolver.GetDefaultT3CutPath()
|
|
||||||
: TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
|
|
||||||
|
|
||||||
if (!string.Equals(configuredT3CutPath, t3CutPath, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!string.IsNullOrWhiteSpace(t3CutPath))
|
|
||||||
{
|
|
||||||
logService.Info($"Karisma adapter normalized T3_Cut path to '{t3CutPath}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
|
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
|
||||||
{
|
{
|
||||||
logService.Warning($"Karisma adapter disabled: set a valid T3_Cut path in Settings. current='{configuredT3CutPath}'");
|
logService.Warning($"Karisma adapter disabled: fixed T3_Cut path does not exist. path='{t3CutPath}'");
|
||||||
adapter = new MockTornado3Adapter(logService);
|
adapter = new MockTornado3Adapter(logService);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -194,10 +202,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
{
|
{
|
||||||
var binding = ResolveBinding(channel);
|
var binding = ResolveBinding(channel);
|
||||||
var t3CutPath = ResolveT3CutPath();
|
var t3CutPath = ResolveT3CutPath();
|
||||||
var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
|
var resolvedScene = KarismaSceneResolver.ResolveScene(
|
||||||
|
template,
|
||||||
|
t3CutPath,
|
||||||
|
IsChannelOnAir(channel),
|
||||||
|
cut.UseEndScene);
|
||||||
var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
|
var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
|
||||||
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
|
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
|
||||||
var counterNumberKeys = BuildCounterNumberKeyUpdates(template, cut, snapshot, sceneVariables);
|
var counterNumberKeys = BuildCounterNumberKeyUpdates(template, cut, snapshot, sceneVariables);
|
||||||
|
var chartCellUpdates = BuildChartCellUpdates(template, snapshot, sceneVariables);
|
||||||
|
var positionUpdates = BuildPositionUpdates(template, snapshot, sceneVariables);
|
||||||
var styleColorUpdates = BuildStyleColorUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
|
var styleColorUpdates = BuildStyleColorUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
|
||||||
var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
|
var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
|
||||||
var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
|
var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
|
||||||
@@ -266,6 +280,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
.ToArray(),
|
.ToArray(),
|
||||||
overriddenValues,
|
overriddenValues,
|
||||||
overriddenCounterNumberKeys,
|
overriddenCounterNumberKeys,
|
||||||
|
chartCellUpdates,
|
||||||
|
positionUpdates,
|
||||||
overriddenStyleColorUpdates,
|
overriddenStyleColorUpdates,
|
||||||
overriddenJudgementVisibilityUpdates.ShowAfterValue
|
overriddenJudgementVisibilityUpdates.ShowAfterValue
|
||||||
.Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
|
.Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
|
||||||
@@ -274,6 +290,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
_pendingScenes[channel] = resolvedScene.Alias;
|
_pendingScenes[channel] = resolvedScene.Alias;
|
||||||
|
_pendingEndScenes[channel] = cut.UseEndScene;
|
||||||
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
|
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
|
||||||
},
|
},
|
||||||
$"apply {template.Id}/{cut.Name}",
|
$"apply {template.Id}/{cut.Name}",
|
||||||
@@ -306,7 +323,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
{
|
{
|
||||||
var binding = ResolveBinding(channel);
|
var binding = ResolveBinding(channel);
|
||||||
await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
|
await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
|
||||||
_channelOnAir[channel] = true;
|
var isEndScene = _pendingEndScenes.TryGetValue(channel, out var pendingEndScene) && pendingEndScene;
|
||||||
|
_channelOnAir[channel] = !isEndScene;
|
||||||
State = TornadoConnectionState.OnAir;
|
State = TornadoConnectionState.OnAir;
|
||||||
},
|
},
|
||||||
$"take {channel}",
|
$"take {channel}",
|
||||||
@@ -321,12 +339,36 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
var binding = ResolveBinding(channel);
|
var binding = ResolveBinding(channel);
|
||||||
await _manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false);
|
await _manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false);
|
||||||
_channelOnAir[channel] = false;
|
_channelOnAir[channel] = false;
|
||||||
|
_pendingEndScenes[channel] = false;
|
||||||
State = TornadoConnectionState.Idle;
|
State = TornadoConnectionState.Idle;
|
||||||
},
|
},
|
||||||
$"out {channel}",
|
$"out {channel}",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SavePendingSceneImageAsync(
|
||||||
|
BroadcastChannel channel,
|
||||||
|
string fileName,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
int frame,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ExecuteAsync(
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
if (!_pendingScenes.TryGetValue(channel, out var sceneAlias))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"[{channel}] No Karisma scene pending for image capture.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _manager.SaveSceneImageAsync(sceneAlias, fileName, width, height, frame, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
},
|
||||||
|
$"save image {channel}",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -365,14 +407,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
|
|
||||||
private string ResolveT3CutPath()
|
private string ResolveT3CutPath()
|
||||||
{
|
{
|
||||||
var configuredPath = _t3CutPathProvider();
|
var path = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
var path = string.IsNullOrWhiteSpace(configuredPath)
|
|
||||||
? TornadoPathResolver.GetDefaultT3CutPath()
|
|
||||||
: TornadoPathResolver.NormalizeConfiguredPath(configuredPath);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||||
{
|
{
|
||||||
throw new DirectoryNotFoundException("T3_Cut path is not configured or does not exist.");
|
throw new DirectoryNotFoundException($"Fixed T3_Cut path does not exist: {path}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return path;
|
return path;
|
||||||
@@ -589,9 +628,279 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
ApplyCareerPromiseValues(values, template, t3CutPath, templateFolderPath, orderedCandidates.FirstOrDefault());
|
ApplyCareerPromiseValues(values, template, t3CutPath, templateFolderPath, orderedCandidates.FirstOrDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyCouncilSeatTableValues(values, template, snapshot, t3CutPath, templateFolderPath, sceneVariables);
|
||||||
|
|
||||||
return FilterValuesForScene(values, sceneVariables);
|
return FilterValuesForScene(values, sceneVariables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ApplyCouncilSeatTableValues(
|
||||||
|
IDictionary<string, string> values,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
string t3CutPath,
|
||||||
|
string templateFolderPath,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
|
{
|
||||||
|
if (!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seatRows = BuildCouncilSeatSummaries(snapshot);
|
||||||
|
var slotCount = ResolveCouncilSeatSlotCount(sceneVariables);
|
||||||
|
for (var slot = 1; slot <= slotCount; slot++)
|
||||||
|
{
|
||||||
|
var partyBarPath = slot <= seatRows.Length
|
||||||
|
? ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, seatRows[slot - 1].ColorParty, PartyAssetKind.Bar)
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
ApplyCouncilSeatSlotValue(values, sceneVariables, "정당바", slot, partyBarPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CouncilSeatSummary[] BuildCouncilSeatSummaries(ElectionDataSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var syntheticSeatRows = snapshot.Candidates
|
||||||
|
.Where(IsCouncilSeatSummaryCandidate)
|
||||||
|
.ToArray();
|
||||||
|
if (syntheticSeatRows.Length > 0)
|
||||||
|
{
|
||||||
|
return syntheticSeatRows
|
||||||
|
.GroupBy(candidate => ResolveCouncilSeatPartyKey(candidate), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
var districtSeats = 0;
|
||||||
|
var proportionalSeats = 0;
|
||||||
|
var explicitTotalSeats = 0;
|
||||||
|
foreach (var candidate in group)
|
||||||
|
{
|
||||||
|
var seatCount = Math.Max(0, candidate.VoteCount);
|
||||||
|
switch (ResolveCouncilSeatSource(candidate))
|
||||||
|
{
|
||||||
|
case CouncilSeatSource.District:
|
||||||
|
districtSeats += seatCount;
|
||||||
|
break;
|
||||||
|
case CouncilSeatSource.Proportional:
|
||||||
|
proportionalSeats += seatCount;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
explicitTotalSeats += seatCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitTotalSeats > 0 && districtSeats + proportionalSeats == 0)
|
||||||
|
{
|
||||||
|
districtSeats = explicitTotalSeats;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CouncilSeatSummary(
|
||||||
|
ResolveCouncilSeatParty(first),
|
||||||
|
ResolveCouncilSeatColorParty(first),
|
||||||
|
districtSeats,
|
||||||
|
proportionalSeats);
|
||||||
|
})
|
||||||
|
.Where(row => row.TotalSeatCount > 0)
|
||||||
|
.OrderByDescending(row => row.TotalSeatCount)
|
||||||
|
.ThenBy(row => row.Party, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.Candidates
|
||||||
|
.Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
|
||||||
|
.GroupBy(candidate => ResolveCouncilSeatPartyKey(candidate), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
var districtSeats = group.Count();
|
||||||
|
return new CouncilSeatSummary(
|
||||||
|
ResolveCouncilSeatParty(first),
|
||||||
|
ResolveCouncilSeatColorParty(first),
|
||||||
|
districtSeats,
|
||||||
|
ProportionalSeatCount: 0);
|
||||||
|
})
|
||||||
|
.Where(row => row.TotalSeatCount > 0)
|
||||||
|
.OrderByDescending(row => row.TotalSeatCount)
|
||||||
|
.ThenBy(row => row.Party, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCouncilSeatSummaryCandidate(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
return candidate.CandidateCode.StartsWith(CouncilSeatCandidateCodePrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CountsAsCouncilSeat(CandidateJudgement judgement)
|
||||||
|
{
|
||||||
|
return judgement is CandidateJudgement.Leading or
|
||||||
|
CandidateJudgement.Confirmed or
|
||||||
|
CandidateJudgement.Elected or
|
||||||
|
CandidateJudgement.ElectedInProgress or
|
||||||
|
CandidateJudgement.UnopposedElected or
|
||||||
|
CandidateJudgement.ElectedAfterCountComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCouncilSeatPartyKey(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
var party = ResolveCouncilSeatParty(candidate);
|
||||||
|
return string.IsNullOrWhiteSpace(party) ? "무소속" : party.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCouncilSeatParty(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(candidate.Party))
|
||||||
|
{
|
||||||
|
return candidate.Party.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
|
||||||
|
? "무소속"
|
||||||
|
: candidate.EffectiveColorParty.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCouncilSeatColorParty(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
|
||||||
|
? ResolveCouncilSeatParty(candidate)
|
||||||
|
: candidate.EffectiveColorParty.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CouncilSeatSource ResolveCouncilSeatSource(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
if (!candidate.CandidateCode.StartsWith(CouncilSeatCandidateCodePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return CouncilSeatSource.District;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = candidate.CandidateCode.Substring(CouncilSeatCandidateCodePrefix.Length);
|
||||||
|
if (suffix.StartsWith("D:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
suffix.StartsWith("DISTRICT:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
suffix.StartsWith("A:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return CouncilSeatSource.District;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix.StartsWith("P:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
suffix.StartsWith("PROPORTIONAL:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
suffix.StartsWith("B:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return CouncilSeatSource.Proportional;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CouncilSeatSource.Total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveCouncilSeatSlotCount(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
|
{
|
||||||
|
var maxSlot = 0;
|
||||||
|
foreach (var variableName in sceneVariables.Keys)
|
||||||
|
{
|
||||||
|
if ((TryParseCouncilSeatSlot(variableName, "의석수", out var seatSlot) ||
|
||||||
|
TryParseCouncilSeatSlot(variableName, "정당바", out seatSlot)) &&
|
||||||
|
seatSlot > maxSlot)
|
||||||
|
{
|
||||||
|
maxSlot = seatSlot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxSlot > 0 ? maxSlot : DefaultCouncilSeatSlotCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyCouncilSeatSlotValue(
|
||||||
|
IDictionary<string, string> values,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
||||||
|
string prefix,
|
||||||
|
int slot,
|
||||||
|
string value)
|
||||||
|
{
|
||||||
|
var matched = false;
|
||||||
|
foreach (var variableName in sceneVariables.Keys)
|
||||||
|
{
|
||||||
|
if (!TryParseCouncilSeatSlot(variableName, prefix, out var parsedSlot) ||
|
||||||
|
parsedSlot != slot)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[variableName] = value;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched || sceneVariables.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var suffix in CouncilSeatObjectSuffixes)
|
||||||
|
{
|
||||||
|
values[$"{prefix}{slot:00}{suffix}"] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseCouncilSeatSlot(string variableName, string prefix, out int slot)
|
||||||
|
{
|
||||||
|
return TryParseCouncilSeatSlotColumn(variableName, prefix, out slot, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseCouncilSeatSlotColumn(
|
||||||
|
string variableName,
|
||||||
|
string prefix,
|
||||||
|
out int slot,
|
||||||
|
out CouncilSeatColumn column)
|
||||||
|
{
|
||||||
|
slot = 0;
|
||||||
|
column = CouncilSeatColumn.Total;
|
||||||
|
if (string.IsNullOrWhiteSpace(variableName) ||
|
||||||
|
!variableName.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = variableName.Substring(prefix.Length);
|
||||||
|
var digitLength = 0;
|
||||||
|
while (digitLength < suffix.Length && char.IsDigit(suffix[digitLength]))
|
||||||
|
{
|
||||||
|
digitLength++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digitLength < 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var slotText = digitLength >= 4
|
||||||
|
? suffix.Substring(digitLength - 2, 2)
|
||||||
|
: suffix.Substring(0, 2);
|
||||||
|
if (!int.TryParse(slotText, NumberStyles.None, CultureInfo.InvariantCulture, out slot) || slot <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
column = ResolveCouncilSeatColumn(suffix.Substring(digitLength));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CouncilSeatColumn ResolveCouncilSeatColumn(string suffix)
|
||||||
|
{
|
||||||
|
return suffix.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"A" => CouncilSeatColumn.District,
|
||||||
|
"B" => CouncilSeatColumn.Proportional,
|
||||||
|
_ => CouncilSeatColumn.Total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveCouncilSeatColumnCount(CouncilSeatSummary summary, CouncilSeatColumn column)
|
||||||
|
{
|
||||||
|
return column switch
|
||||||
|
{
|
||||||
|
CouncilSeatColumn.District => summary.DistrictSeatCount,
|
||||||
|
CouncilSeatColumn.Proportional => summary.ProportionalSeatCount,
|
||||||
|
_ => summary.TotalSeatCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<KarismaStyleColorUpdate> BuildStyleColorUpdates(
|
private static IReadOnlyList<KarismaStyleColorUpdate> BuildStyleColorUpdates(
|
||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
FormatCutDefinition cut,
|
FormatCutDefinition cut,
|
||||||
@@ -780,7 +1089,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return BuildTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
|
return BuildTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsAnimatedTemplate(template))
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||||
|
{
|
||||||
|
return BuildCouncilSeatCounterNumberKeyUpdates(snapshot, sceneVariables);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAnimatedTemplate(template) && !HasVoteRateCounterVariables(sceneVariables))
|
||||||
{
|
{
|
||||||
return Array.Empty<KarismaCounterNumberKeyUpdate>();
|
return Array.Empty<KarismaCounterNumberKeyUpdate>();
|
||||||
}
|
}
|
||||||
@@ -812,6 +1126,69 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasVoteRateCounterVariables(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
|
{
|
||||||
|
return sceneVariables.Values.Any(variable =>
|
||||||
|
variable.Kind == KarismaSceneVariableKind.Counter &&
|
||||||
|
IsVoteRateVariable(variable.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildCouncilSeatCounterNumberKeyUpdates(
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
|
{
|
||||||
|
var seatRows = BuildCouncilSeatSummaries(snapshot);
|
||||||
|
var slotCount = ResolveCouncilSeatSlotCount(sceneVariables);
|
||||||
|
if (slotCount <= 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<KarismaCounterNumberKeyUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates = new List<KarismaCounterNumberKeyUpdate>(slotCount * CouncilSeatObjectSuffixes.Length);
|
||||||
|
for (var slot = 1; slot <= slotCount; slot++)
|
||||||
|
{
|
||||||
|
var seatRow = slot <= seatRows.Length ? seatRows[slot - 1] : default;
|
||||||
|
AddCouncilSeatCounterSlotUpdates(updates, sceneVariables, slot, seatRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCouncilSeatCounterSlotUpdates(
|
||||||
|
ICollection<KarismaCounterNumberKeyUpdate> updates,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
||||||
|
int slot,
|
||||||
|
CouncilSeatSummary seatRow)
|
||||||
|
{
|
||||||
|
var matched = false;
|
||||||
|
foreach (var variableName in sceneVariables.Keys)
|
||||||
|
{
|
||||||
|
if (!TryParseCouncilSeatSlotColumn(variableName, "의석수", out var parsedSlot, out var column) ||
|
||||||
|
parsedSlot != slot)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, ResolveCouncilSeatColumnCount(seatRow, column)));
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched || sceneVariables.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.Add(new KarismaCounterNumberKeyUpdate($"의석수{slot:00}", 1, seatRow.TotalSeatCount));
|
||||||
|
foreach (var suffix in CouncilSeatObjectSuffixes)
|
||||||
|
{
|
||||||
|
var column = ResolveCouncilSeatColumn(suffix);
|
||||||
|
updates.Add(new KarismaCounterNumberKeyUpdate(
|
||||||
|
$"의석수{slot:00}{suffix}",
|
||||||
|
1,
|
||||||
|
ResolveCouncilSeatColumnCount(seatRow, column)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
|
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
|
||||||
ElectionDataSnapshot snapshot,
|
ElectionDataSnapshot snapshot,
|
||||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
@@ -857,6 +1234,98 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<KarismaChartCellUpdate> BuildChartCellUpdates(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
|
{
|
||||||
|
if (!IsHistoricalTurnoutTemplate(template.Name))
|
||||||
|
{
|
||||||
|
return Array.Empty<KarismaChartCellUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(HistoricalTurnoutChartObjectName))
|
||||||
|
{
|
||||||
|
return Array.Empty<KarismaChartCellUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
|
||||||
|
var updates = new List<KarismaChartCellUpdate>(HistoricalTurnoutChartCellRowCount);
|
||||||
|
updates.Add(new KarismaChartCellUpdate(HistoricalTurnoutChartObjectName, 0, 0, 0f));
|
||||||
|
|
||||||
|
for (var row = 0; row < HistoricalTurnoutChartCellRowCount; row++)
|
||||||
|
{
|
||||||
|
if (row is 0 or 7)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceIndex = row - 1;
|
||||||
|
var value = (float)Math.Round(rates[sourceIndex], 1, MidpointRounding.AwayFromZero);
|
||||||
|
updates.Add(new KarismaChartCellUpdate(
|
||||||
|
HistoricalTurnoutChartObjectName,
|
||||||
|
row,
|
||||||
|
0,
|
||||||
|
value));
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.Add(new KarismaChartCellUpdate(HistoricalTurnoutChartObjectName, 7, 0, 100f));
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<KarismaPositionUpdate> BuildPositionUpdates(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
|
{
|
||||||
|
if (!IsHistoricalTurnoutTemplate(template.Name))
|
||||||
|
{
|
||||||
|
return Array.Empty<KarismaPositionUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rates = ResolveHistoricalTurnoutSlotRates(snapshot);
|
||||||
|
var updates = new List<KarismaPositionUpdate>(rates.Length * 2);
|
||||||
|
var shouldMoveCircles = ShouldMoveHistoricalTurnoutCircles(template);
|
||||||
|
for (var slot = 1; slot <= rates.Length; slot++)
|
||||||
|
{
|
||||||
|
var counterName = $"투표율{slot:00}";
|
||||||
|
if (sceneVariables.Count == 0 || sceneVariables.ContainsKey(counterName))
|
||||||
|
{
|
||||||
|
updates.Add(new KarismaPositionUpdate(
|
||||||
|
counterName,
|
||||||
|
HistoricalTurnoutCounterBaseX,
|
||||||
|
ResolveHistoricalTurnoutCounterY(rates[slot - 1]),
|
||||||
|
HistoricalTurnoutCounterBaseZ,
|
||||||
|
eKVectorType.VECTOR_TYPE_Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldMoveCircles)
|
||||||
|
{
|
||||||
|
var circleName = $"{HistoricalTurnoutCircleObjectPrefix}{slot:00}";
|
||||||
|
updates.Add(new KarismaPositionUpdate(
|
||||||
|
circleName,
|
||||||
|
0f,
|
||||||
|
ResolveHistoricalTurnoutCircleY(rates[slot - 1]),
|
||||||
|
HistoricalTurnoutCircleBaseZ,
|
||||||
|
eKVectorType.VECTOR_TYPE_Y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldMoveHistoricalTurnoutCircles(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
if (template.Name.Contains("_5760", StringComparison.Ordinal) ||
|
||||||
|
template.Name.EndsWith("_L", StringComparison.Ordinal) ||
|
||||||
|
template.Name.EndsWith("_L_1", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildHistoricalTurnoutCounterNumberKeyUpdates(
|
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildHistoricalTurnoutCounterNumberKeyUpdates(
|
||||||
ElectionDataSnapshot snapshot,
|
ElectionDataSnapshot snapshot,
|
||||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||||
@@ -909,6 +1378,37 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double[] ResolveHistoricalTurnoutSlotRates(ElectionDataSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var rates = new double[DefaultTurnoutSlotClearCount];
|
||||||
|
foreach (var turnout in snapshot.HistoricalTurnoutHistory)
|
||||||
|
{
|
||||||
|
var slot = turnout.ElectionOrder - 2;
|
||||||
|
if (slot is < 1 or > DefaultTurnoutSlotClearCount)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rates[slot - 1] = Math.Round(turnout.TurnoutRate, 1, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ResolveHistoricalTurnoutCounterY(double turnoutRate)
|
||||||
|
{
|
||||||
|
var clampedRate = Math.Clamp(turnoutRate, 0d, 100d);
|
||||||
|
return HistoricalTurnoutCounterBaseY -
|
||||||
|
(float)((100d - clampedRate) * HistoricalTurnoutCounterYUnitsPerPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ResolveHistoricalTurnoutCircleY(double turnoutRate)
|
||||||
|
{
|
||||||
|
var clampedRate = Math.Clamp(turnoutRate, 0d, 100d);
|
||||||
|
return HistoricalTurnoutCircleBaseY -
|
||||||
|
(float)((100d - clampedRate) * HistoricalTurnoutCircleYUnitsPerPercent);
|
||||||
|
}
|
||||||
|
|
||||||
private void LogCutDebugSummary(
|
private void LogCutDebugSummary(
|
||||||
BroadcastChannel channel,
|
BroadcastChannel channel,
|
||||||
FormatTemplateDefinition template,
|
FormatTemplateDefinition template,
|
||||||
@@ -1393,12 +1893,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
string.Equals(variableName, "전국투표율", StringComparison.Ordinal) ||
|
string.Equals(variableName, "전국투표율", StringComparison.Ordinal) ||
|
||||||
MatchesIndexedVariable(variableName, "득표율") ||
|
MatchesIndexedVariable(variableName, "득표율") ||
|
||||||
MatchesIndexedVariable(variableName, "투표율") ||
|
MatchesIndexedVariable(variableName, "투표율") ||
|
||||||
MatchesIndexedVariable(variableName, "전국투표율");
|
MatchesIndexedVariable(variableName, "전국투표율") ||
|
||||||
|
TryParseCouncilSeatSlot(variableName, "의석수", out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsImageValueVariable(string variableName)
|
private static bool IsImageValueVariable(string variableName)
|
||||||
{
|
{
|
||||||
return IsJudgementVariableName(variableName) ||
|
return IsJudgementVariableName(variableName) ||
|
||||||
|
TryParseCouncilSeatSlot(variableName, "정당바", out _) ||
|
||||||
MatchesIndexedVariable(variableName, "후보사진") ||
|
MatchesIndexedVariable(variableName, "후보사진") ||
|
||||||
MatchesIndexedVariable(variableName, "득표수바") ||
|
MatchesIndexedVariable(variableName, "득표수바") ||
|
||||||
MatchesIndexedVariable(variableName, "정당바") ||
|
MatchesIndexedVariable(variableName, "정당바") ||
|
||||||
@@ -1910,6 +2412,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
MatchesIndexedVariable(variableName, "기호텍스트") ||
|
MatchesIndexedVariable(variableName, "기호텍스트") ||
|
||||||
MatchesIndexedVariable(variableName, "후보명") ||
|
MatchesIndexedVariable(variableName, "후보명") ||
|
||||||
MatchesIndexedVariable(variableName, "정당명") ||
|
MatchesIndexedVariable(variableName, "정당명") ||
|
||||||
|
TryParseCouncilSeatSlot(variableName, "의석수", out _) ||
|
||||||
MatchesIndexedVariable(variableName, "득표수") ||
|
MatchesIndexedVariable(variableName, "득표수") ||
|
||||||
MatchesIndexedVariable(variableName, "득표율") ||
|
MatchesIndexedVariable(variableName, "득표율") ||
|
||||||
MatchesIndexedVariable(variableName, "표차") ||
|
MatchesIndexedVariable(variableName, "표차") ||
|
||||||
@@ -1917,6 +2420,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
MatchesIndexedVariable(variableName, "유확당") ||
|
MatchesIndexedVariable(variableName, "유확당") ||
|
||||||
MatchesIndexedVariable(variableName, "후보사진") ||
|
MatchesIndexedVariable(variableName, "후보사진") ||
|
||||||
MatchesIndexedVariable(variableName, "득표수바") ||
|
MatchesIndexedVariable(variableName, "득표수바") ||
|
||||||
|
TryParseCouncilSeatSlot(variableName, "정당바", out _) ||
|
||||||
MatchesIndexedVariable(variableName, "정당바") ||
|
MatchesIndexedVariable(variableName, "정당바") ||
|
||||||
MatchesIndexedVariable(variableName, "정당판") ||
|
MatchesIndexedVariable(variableName, "정당판") ||
|
||||||
MatchesIndexedVariable(variableName, "정당원") ||
|
MatchesIndexedVariable(variableName, "정당원") ||
|
||||||
@@ -2531,12 +3035,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized is "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
|
if (normalized is "국힘" or "미래통합당" or "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
|
||||||
{
|
{
|
||||||
yield return "국민의힘";
|
yield return "국민의힘";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized is "민주당" or "새천년민주당" or "열린우리당" or "새정치민주연합")
|
if (normalized is "민주당" or "민주당1991" or "민주당2000" or "민주당2008" or "새정치국민회의" or "새천년민주당" or "열린민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
|
||||||
{
|
{
|
||||||
yield return "더불어민주당";
|
yield return "더불어민주당";
|
||||||
}
|
}
|
||||||
@@ -2705,6 +3209,29 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
Group
|
Group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum CouncilSeatColumn
|
||||||
|
{
|
||||||
|
District,
|
||||||
|
Proportional,
|
||||||
|
Total
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CouncilSeatSource
|
||||||
|
{
|
||||||
|
District,
|
||||||
|
Proportional,
|
||||||
|
Total
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct CouncilSeatSummary(
|
||||||
|
string Party,
|
||||||
|
string ColorParty,
|
||||||
|
int DistrictSeatCount,
|
||||||
|
int ProportionalSeatCount)
|
||||||
|
{
|
||||||
|
public int TotalSeatCount => DistrictSeatCount + ProportionalSeatCount;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
|
private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -523,12 +523,12 @@ internal static class PartyColorCatalog
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized is "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
|
if (normalized is "국힘" or "미래통합당" or "민주자유당" or "신한국당" or "한나라당" or "새누리당" or "자유한국당")
|
||||||
{
|
{
|
||||||
yield return "국민의힘";
|
yield return "국민의힘";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized is "민주당" or "새천년민주당" or "열린우리당" or "새정치민주연합")
|
if (normalized is "민주당" or "민주당1991" or "민주당2000" or "민주당2008" or "새정치국민회의" or "새천년민주당" or "열린민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
|
||||||
{
|
{
|
||||||
yield return "더불어민주당";
|
yield return "더불어민주당";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,6 +451,15 @@ public sealed class PreElectionHistoryService
|
|||||||
return Path.Combine(current.FullName, RelativeAssetPath);
|
return Path.Combine(current.FullName, RelativeAssetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var repositoryProjectPath = Path.Combine(
|
||||||
|
current.FullName,
|
||||||
|
"Tornado3_2026Election",
|
||||||
|
"Tornado3_2026Election.csproj");
|
||||||
|
if (File.Exists(repositoryProjectPath))
|
||||||
|
{
|
||||||
|
return Path.Combine(current.FullName, "Tornado3_2026Election", RelativeAssetPath);
|
||||||
|
}
|
||||||
|
|
||||||
current = current.Parent;
|
current = current.Parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ namespace Tornado3_2026Election.Services;
|
|||||||
|
|
||||||
public sealed class SbsElectionApiClient : IDisposable
|
public sealed class SbsElectionApiClient : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly Uri BaseUri = new("http://202.31.153.141:8421/");
|
private const string BasicApiBaseUrlEnvironmentVariable = "SBS_BASIC_API_BASE_URL";
|
||||||
|
private const string BasicApiModeEnvironmentVariable = "SBS_BASIC_API_MODE";
|
||||||
|
private static readonly TimeSpan BasicCouncilCountingCacheDuration = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly Uri LegacyBaseUri = new("http://202.31.153.141:8421/");
|
||||||
|
private static readonly Uri BasicApiBaseUri = ResolveBasicApiBaseUri();
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -23,9 +27,12 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
private static readonly IReadOnlyDictionary<string, SbsElectionConfiguration> ElectionConfigurations =
|
private static readonly IReadOnlyDictionary<string, SbsElectionConfiguration> ElectionConfigurations =
|
||||||
new Dictionary<string, SbsElectionConfiguration>(StringComparer.Ordinal)
|
new Dictionary<string, SbsElectionConfiguration>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
["광역단체장"] = new SbsElectionConfiguration(3, true),
|
["국회의원"] = new SbsElectionConfiguration(2, false, LegacyBaseUri, "gaepyo"),
|
||||||
["교육감"] = new SbsElectionConfiguration(11, false),
|
["광역단체장"] = new SbsElectionConfiguration(3, true, LegacyBaseUri, "gaepyo"),
|
||||||
["기초단체장"] = new SbsElectionConfiguration(4, false)
|
["교육감"] = new SbsElectionConfiguration(11, false, LegacyBaseUri, "gaepyo"),
|
||||||
|
["광역의원"] = new SbsElectionConfiguration(5, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment()),
|
||||||
|
["기초단체장"] = new SbsElectionConfiguration(4, false, LegacyBaseUri, "gaepyo"),
|
||||||
|
["기초의원"] = new SbsElectionConfiguration(6, false, BasicApiBaseUri, ResolveBasicApiCountingEndpointSegment())
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
|
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
|
||||||
@@ -50,14 +57,60 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
["제주"] = "제주특별자치도"
|
["제주"] = "제주특별자치도"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> BasicApiSidoCodes =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["서울"] = "11",
|
||||||
|
["서울특별시"] = "11",
|
||||||
|
["부산"] = "26",
|
||||||
|
["부산광역시"] = "26",
|
||||||
|
["대구"] = "27",
|
||||||
|
["대구광역시"] = "27",
|
||||||
|
["인천"] = "28",
|
||||||
|
["인천광역시"] = "28",
|
||||||
|
["광주"] = "29",
|
||||||
|
["광주광역시"] = "29",
|
||||||
|
["전남광주"] = "29",
|
||||||
|
["광주전남"] = "29",
|
||||||
|
["대전"] = "30",
|
||||||
|
["대전광역시"] = "30",
|
||||||
|
["울산"] = "31",
|
||||||
|
["울산광역시"] = "31",
|
||||||
|
["세종"] = "36",
|
||||||
|
["세종특별자치시"] = "36",
|
||||||
|
["경기"] = "41",
|
||||||
|
["경기도"] = "41",
|
||||||
|
["충북"] = "43",
|
||||||
|
["충청북도"] = "43",
|
||||||
|
["충남"] = "44",
|
||||||
|
["충청남도"] = "44",
|
||||||
|
["전남"] = "29",
|
||||||
|
["전라남도"] = "29",
|
||||||
|
["경북"] = "47",
|
||||||
|
["경상북도"] = "47",
|
||||||
|
["경남"] = "48",
|
||||||
|
["경상남도"] = "48",
|
||||||
|
["제주"] = "50",
|
||||||
|
["제주도"] = "50",
|
||||||
|
["제주특별자치도"] = "50",
|
||||||
|
["강원"] = "52",
|
||||||
|
["강원도"] = "52",
|
||||||
|
["강원특별자치도"] = "52",
|
||||||
|
["전북"] = "53",
|
||||||
|
["전라북도"] = "53",
|
||||||
|
["전북특별자치도"] = "53"
|
||||||
|
};
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly bool _disposeHttpClient;
|
private readonly bool _disposeHttpClient;
|
||||||
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
|
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
|
||||||
private readonly Dictionary<int, IReadOnlyList<SbsRegionInfo>> _districtRegions = new();
|
private readonly Dictionary<string, IReadOnlyList<SbsRegionInfo>> _districtRegions = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, SbsCountingCacheEntry> _countingCache = new(StringComparer.Ordinal);
|
||||||
|
private readonly SemaphoreSlim _countingCacheLock = new(1, 1);
|
||||||
|
|
||||||
public SbsElectionApiClient(HttpClient? httpClient = null)
|
public SbsElectionApiClient(HttpClient? httpClient = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient ?? new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(10) };
|
_httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
_disposeHttpClient = httpClient is null;
|
_disposeHttpClient = httpClient is null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +124,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
|
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"'{electionType}'은 현재 SBS API 실연동 범위에 없습니다. 현재는 광역단체장, 교육감, 기초단체장까지만 연결되어 있습니다.");
|
$"'{electionType}'은 현재 SBS API 실연동 범위에 없습니다. 현재는 국회의원, 광역단체장, 교육감, 광역의원, 기초단체장, 기초의원만 연결되어 있습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return phase switch
|
return phase switch
|
||||||
@@ -85,18 +138,47 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
|
public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
|
||||||
string electionType,
|
string electionType,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await GetDistrictOptionsAsync(
|
||||||
|
electionType,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
|
||||||
|
string electionType,
|
||||||
|
IEnumerable<string> regionFilters,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
|
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
|
||||||
{
|
{
|
||||||
return Array.Empty<DistrictSelectionOption>();
|
return Array.Empty<DistrictSelectionOption>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var regions = await GetElectionDistrictRegionsAsync(configuration.SungerType, cancellationToken).ConfigureAwait(false);
|
var scopedSidoCodes = ResolveBasicApiSidoCodes(regionFilters);
|
||||||
return regions
|
var regions = await GetElectionDistrictRegionsAsync(configuration, scopedSidoCodes, cancellationToken).ConfigureAwait(false);
|
||||||
.Select(region => CreateDistrictSelectionOption(configuration.SungerType, region))
|
var options = regions
|
||||||
.Where(option => !string.IsNullOrWhiteSpace(option.DisplayName))
|
.Select((region, index) => new
|
||||||
.OrderBy(option => option.RegionName, StringComparer.Ordinal)
|
{
|
||||||
.ThenBy(option => option.DistrictName, StringComparer.Ordinal)
|
Region = region,
|
||||||
|
Index = index,
|
||||||
|
Option = CreateDistrictSelectionOption(configuration.SungerType, region)
|
||||||
|
})
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.Option.DisplayName));
|
||||||
|
|
||||||
|
if (configuration.SungerType is 2 or 3 or 4 or 5 or 6)
|
||||||
|
{
|
||||||
|
return options
|
||||||
|
.OrderBy(item => item.Region.Order > 0 ? item.Region.Order : int.MaxValue)
|
||||||
|
.ThenBy(item => item.Index)
|
||||||
|
.Select(item => item.Option)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.OrderBy(item => item.Option.RegionName, StringComparer.Ordinal)
|
||||||
|
.ThenBy(item => item.Option.DistrictName, StringComparer.Ordinal)
|
||||||
|
.Select(item => item.Option)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,35 +209,33 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
|
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
|
||||||
var overviewItems = new List<(int Order, CountingOverviewItem Item)>();
|
var overviewItems = new List<(int Order, CountingOverviewItem Item)>();
|
||||||
|
|
||||||
foreach (var districtChunk in requestedDistricts.Chunk(24))
|
var countingItems = await GetCountingItemsForDistrictsAsync(
|
||||||
|
configuration,
|
||||||
|
requestedDistricts,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var responseItem in countingItems)
|
||||||
{
|
{
|
||||||
var ids = string.Join(",", districtChunk.Select(district => district.DistrictCode));
|
var item = responseItem.Item;
|
||||||
var items = await GetArrayAsync<SbsCountingItem>(
|
var regionId = item.Region?.Id;
|
||||||
$"gaepyo/{configuration.SungerType}/sungergus?ids={ids}",
|
if (string.IsNullOrWhiteSpace(regionId) ||
|
||||||
cancellationToken).ConfigureAwait(false);
|
!districtMap.TryGetValue(regionId, out var districtOption) ||
|
||||||
|
!orderMap.TryGetValue(regionId, out var order))
|
||||||
foreach (var item in items)
|
|
||||||
{
|
{
|
||||||
var regionId = item.Region?.Id;
|
continue;
|
||||||
if (string.IsNullOrWhiteSpace(regionId) ||
|
|
||||||
!districtMap.TryGetValue(regionId, out var districtOption) ||
|
|
||||||
!orderMap.TryGetValue(regionId, out var order))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalVotes = Math.Max(0, item.Total?.Tupyosu ?? 0);
|
|
||||||
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
|
|
||||||
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
|
|
||||||
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
|
|
||||||
|
|
||||||
overviewItems.Add((order, new CountingOverviewItem(
|
|
||||||
DisplayName: districtOption.DisplayName,
|
|
||||||
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
|
|
||||||
CountedVotes: countedVotes,
|
|
||||||
TotalVotes: totalVotes,
|
|
||||||
UncountedVotes: Math.Max(0, uncountedVotes))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var totalVotes = Math.Max(0, item.Total?.Tupyosu ?? 0);
|
||||||
|
var countedVotes = Math.Max(0, item.Total?.Gaepyosu ?? 0);
|
||||||
|
var uncountedVotes = item.Total?.UncountedPyosu ?? Math.Max(0, totalVotes - countedVotes);
|
||||||
|
var countedRate = item.Total?.GaepyoRate ?? (totalVotes <= 0 ? 0 : countedVotes * 100d / totalVotes);
|
||||||
|
|
||||||
|
overviewItems.Add((order, new CountingOverviewItem(
|
||||||
|
DisplayName: districtOption.DisplayName,
|
||||||
|
CountedRate: Math.Round(countedRate, 1, MidpointRounding.AwayFromZero),
|
||||||
|
CountedVotes: countedVotes,
|
||||||
|
TotalVotes: totalVotes,
|
||||||
|
UncountedVotes: Math.Max(0, uncountedVotes))));
|
||||||
}
|
}
|
||||||
|
|
||||||
return overviewItems
|
return overviewItems
|
||||||
@@ -164,6 +244,61 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SbsElectionRefreshResult>> GetCountingSnapshotsAsync(
|
||||||
|
string electionType,
|
||||||
|
IReadOnlyList<DistrictSelectionOption> districts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || districts.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<SbsElectionRefreshResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedDistricts = districts
|
||||||
|
.Where(district => !string.IsNullOrWhiteSpace(district.DistrictCode))
|
||||||
|
.GroupBy(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToArray();
|
||||||
|
if (requestedDistricts.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<SbsElectionRefreshResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderMap = requestedDistricts
|
||||||
|
.Select((district, index) => new { district.DistrictCode, Index = index })
|
||||||
|
.ToDictionary(item => item.DistrictCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var results = new List<(int Order, SbsElectionRefreshResult Result)>();
|
||||||
|
|
||||||
|
var countingItems = await GetCountingItemsForDistrictsAsync(
|
||||||
|
configuration,
|
||||||
|
requestedDistricts,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var responseItem in countingItems)
|
||||||
|
{
|
||||||
|
var item = responseItem.Item;
|
||||||
|
var regionId = item.Region?.Id;
|
||||||
|
if (string.IsNullOrWhiteSpace(regionId) ||
|
||||||
|
!districtMap.TryGetValue(regionId, out var districtOption) ||
|
||||||
|
!orderMap.TryGetValue(regionId, out var order))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add((order, CreateCountingRefreshResult(
|
||||||
|
configuration,
|
||||||
|
districtOption,
|
||||||
|
item,
|
||||||
|
responseItem.SourcePath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
.OrderBy(item => item.Order)
|
||||||
|
.Select(item => item.Result)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
|
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
|
||||||
string electionType,
|
string electionType,
|
||||||
IReadOnlyList<DistrictSelectionOption> districts,
|
IReadOnlyList<DistrictSelectionOption> districts,
|
||||||
@@ -181,8 +316,13 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var requestedDistricts = districts
|
var requestedDistricts = districts
|
||||||
.Where(district => !string.IsNullOrWhiteSpace(district.DistrictCode))
|
.Select(district => new
|
||||||
.GroupBy(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase)
|
{
|
||||||
|
District = district,
|
||||||
|
RegionCode = ResolveTurnoutRegionCode(district)
|
||||||
|
})
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.RegionCode))
|
||||||
|
.GroupBy(item => item.RegionCode, StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(group => group.First())
|
.Select(group => group.First())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -196,17 +336,18 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
var orderMap = requestedDistricts
|
var orderMap = requestedDistricts
|
||||||
.Select((district, index) => new { district.DistrictCode, Index = index })
|
.Select((item, index) => new { item.RegionCode, Index = index })
|
||||||
.ToDictionary(item => item.DistrictCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(item => item.RegionCode, item => item.Index, StringComparer.OrdinalIgnoreCase);
|
||||||
var districtMap = requestedDistricts.ToDictionary(district => district.DistrictCode, StringComparer.OrdinalIgnoreCase);
|
var districtMap = requestedDistricts.ToDictionary(item => item.RegionCode, item => item.District, StringComparer.OrdinalIgnoreCase);
|
||||||
var turnoutItems = new List<(int Order, TurnoutOverviewItem Item)>();
|
var turnoutItems = new List<(int Order, TurnoutOverviewItem Item)>();
|
||||||
var totalExpectedVotes = 0;
|
var totalExpectedVotes = 0;
|
||||||
var turnoutVotes = 0;
|
var turnoutVotes = 0;
|
||||||
|
|
||||||
foreach (var districtChunk in requestedDistricts.Chunk(24))
|
foreach (var districtChunk in requestedDistricts.Chunk(24))
|
||||||
{
|
{
|
||||||
var ids = string.Join(",", districtChunk.Select(district => district.DistrictCode));
|
var ids = string.Join(",", districtChunk.Select(item => item.RegionCode));
|
||||||
var items = await GetArrayAsync<SbsTurnoutItem>(
|
var items = await GetArrayAsync<SbsTurnoutItem>(
|
||||||
|
configuration.BaseUri,
|
||||||
$"tupyo/{configuration.SungerType}/sidos?ids={ids}",
|
$"tupyo/{configuration.SungerType}/sidos?ids={ids}",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -250,8 +391,16 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
DateTimeOffset.Now);
|
DateTimeOffset.Now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveTurnoutRegionCode(DistrictSelectionOption district)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(district.ParentRegionCode)
|
||||||
|
? district.ParentRegionCode
|
||||||
|
: district.DistrictCode;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_countingCacheLock.Dispose();
|
||||||
if (_disposeHttpClient)
|
if (_disposeHttpClient)
|
||||||
{
|
{
|
||||||
_httpClient.Dispose();
|
_httpClient.Dispose();
|
||||||
@@ -267,11 +416,12 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
if (!configuration.SupportsPreElection)
|
if (!configuration.SupportsPreElection)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"선택한 선거 종류는 SBS API 문서 기준으로 사전 투표율 연동 대상이 아닙니다.");
|
"선택한 선거 종류는 SBS API 문서 기준으로 투표율 연동 대상이 아닙니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var sido = await ResolveSidoRegionAsync(districtName, districtCode, cancellationToken).ConfigureAwait(false);
|
var sido = await ResolveSidoRegionAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false);
|
||||||
var items = await GetArrayAsync<SbsTurnoutItem>(
|
var items = await GetArrayAsync<SbsTurnoutItem>(
|
||||||
|
configuration.BaseUri,
|
||||||
$"tupyo/{configuration.SungerType}/sidos?ids={Uri.EscapeDataString(sido.Id)}",
|
$"tupyo/{configuration.SungerType}/sidos?ids={Uri.EscapeDataString(sido.Id)}",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -301,41 +451,210 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
string districtCode,
|
string districtCode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (CanQueryBasicCouncilByDistrictId(configuration) &&
|
||||||
|
!string.IsNullOrWhiteSpace(districtCode))
|
||||||
|
{
|
||||||
|
return await RefreshBasicCouncilCountingByDistrictIdAsync(
|
||||||
|
configuration,
|
||||||
|
districtName,
|
||||||
|
districtCode,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
var district = await ResolveElectionDistrictAsync(
|
var district = await ResolveElectionDistrictAsync(
|
||||||
configuration.SungerType,
|
configuration,
|
||||||
districtName,
|
districtName,
|
||||||
districtCode,
|
districtCode,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var path = BuildCountingPath(configuration, $"ids={Uri.EscapeDataString(district.Id)}");
|
||||||
var items = await GetArrayAsync<SbsCountingItem>(
|
var items = await GetArrayAsync<SbsCountingItem>(
|
||||||
$"gaepyo/{configuration.SungerType}/sungergus?ids={Uri.EscapeDataString(district.Id)}",
|
configuration.BaseUri,
|
||||||
|
path,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var item = items.FirstOrDefault()
|
var item = items.FirstOrDefault()
|
||||||
?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
|
?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
|
||||||
|
var result = CreateCountingRefreshResult(
|
||||||
|
configuration,
|
||||||
|
CreateDistrictSelectionOption(configuration.SungerType, district),
|
||||||
|
item,
|
||||||
|
$"GET /{path}");
|
||||||
|
|
||||||
|
if (result.Candidates is null || result.Candidates.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SbsElectionRefreshResult> RefreshBasicCouncilCountingByDistrictIdAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
string districtName,
|
||||||
|
string districtCode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = BuildCountingPath(configuration, $"ids={Uri.EscapeDataString(districtCode)}");
|
||||||
|
var items = await GetCountingItemsForPathAsync(
|
||||||
|
configuration,
|
||||||
|
path,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var item = items.FirstOrDefault().Item
|
||||||
|
?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
|
||||||
|
var region = CreateRegionInfo(item.Region) ?? new SbsRegionInfo
|
||||||
|
{
|
||||||
|
Id = districtCode,
|
||||||
|
Name = districtName,
|
||||||
|
Name1 = districtName,
|
||||||
|
Name4 = districtName,
|
||||||
|
Name1Id = ResolveBasicApiSidoCode(districtName)
|
||||||
|
};
|
||||||
|
var result = CreateCountingRefreshResult(
|
||||||
|
configuration,
|
||||||
|
CreateDistrictSelectionOption(configuration.SungerType, region),
|
||||||
|
item,
|
||||||
|
$"GET /{path}");
|
||||||
|
|
||||||
|
if (result.Candidates is null || result.Candidates.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForDistrictsAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
IReadOnlyList<DistrictSelectionOption> districts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (CanQueryCountingBySido(configuration, districts))
|
||||||
|
{
|
||||||
|
return await GetCountingItemsBySidoAsync(configuration, districts, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new List<SbsCountingResponseItem>();
|
||||||
|
foreach (var districtChunk in districts.Chunk(24))
|
||||||
|
{
|
||||||
|
var ids = string.Join(",", districtChunk.Select(district => Uri.EscapeDataString(district.DistrictCode)));
|
||||||
|
var path = BuildCountingPath(configuration, $"ids={ids}");
|
||||||
|
results.AddRange(await GetCountingItemsForPathAsync(
|
||||||
|
configuration,
|
||||||
|
path,
|
||||||
|
cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsBySidoAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
IReadOnlyList<DistrictSelectionOption> districts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sidoCodes = districts
|
||||||
|
.Select(district => district.ParentRegionCode)
|
||||||
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(code => code, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return await GetCountingItemsBySidoCodesAsync(
|
||||||
|
configuration,
|
||||||
|
sidoCodes,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsBySidoCodesAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
IReadOnlyList<string> sidoCodes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var results = new List<SbsCountingResponseItem>();
|
||||||
|
|
||||||
|
foreach (var sidoChunk in sidoCodes.Chunk(24))
|
||||||
|
{
|
||||||
|
var sidos = string.Join(",", sidoChunk.Select(Uri.EscapeDataString));
|
||||||
|
var path = BuildCountingPath(configuration, $"sidos={sidos}");
|
||||||
|
results.AddRange(await GetCountingItemsForPathAsync(
|
||||||
|
configuration,
|
||||||
|
path,
|
||||||
|
cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<SbsCountingResponseItem>> GetCountingItemsForPathAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!ShouldCacheBasicCouncilCounting(configuration))
|
||||||
|
{
|
||||||
|
var uncachedItems = await GetArrayAsync<SbsCountingItem>(
|
||||||
|
configuration.BaseUri,
|
||||||
|
path,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return uncachedItems.Select(item => new SbsCountingResponseItem(item, $"GET /{path}")).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = $"{configuration.BaseUri.AbsoluteUri}|{path}";
|
||||||
|
await _countingCacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
|
if (_countingCache.TryGetValue(cacheKey, out var cached) &&
|
||||||
|
now - cached.ReceivedAt < BasicCouncilCountingCacheDuration)
|
||||||
|
{
|
||||||
|
return cached.Items
|
||||||
|
.Select(item => new SbsCountingResponseItem(item, cached.SourcePath))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await GetArrayAsync<SbsCountingItem>(
|
||||||
|
configuration.BaseUri,
|
||||||
|
path,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
var responseItems = items.ToArray();
|
||||||
|
_countingCache[cacheKey] = new SbsCountingCacheEntry(
|
||||||
|
now,
|
||||||
|
responseItems,
|
||||||
|
$"GET /{path}");
|
||||||
|
|
||||||
|
return responseItems.Select(item => new SbsCountingResponseItem(item, $"GET /{path}")).ToArray();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_countingCacheLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SbsElectionRefreshResult CreateCountingRefreshResult(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
DistrictSelectionOption district,
|
||||||
|
SbsCountingItem item,
|
||||||
|
string sourcePath)
|
||||||
|
{
|
||||||
var candidates = (item.Hubojas ?? [])
|
var candidates = (item.Hubojas ?? [])
|
||||||
.Select(MapCandidate)
|
.Select(MapCandidate)
|
||||||
.OrderByDescending(candidate => candidate.VoteCount)
|
.OrderByDescending(candidate => candidate.VoteCount)
|
||||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (candidates.Length == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalVotes = item.Total?.Tupyosu ?? candidates.Sum(candidate => candidate.VoteCount);
|
var totalVotes = item.Total?.Tupyosu ?? candidates.Sum(candidate => candidate.VoteCount);
|
||||||
var regionName = ExpandRegionName(item.Region?.Name1 ?? district.Name1 ?? districtName);
|
var fallbackRegion = CreateRegionInfo(district);
|
||||||
|
var regionName = ExpandRegionName(item.Region?.Name1 ?? fallbackRegion.Name1 ?? district.RegionName);
|
||||||
var outputRegionName = BuildOutputRegionName(regionName);
|
var outputRegionName = BuildOutputRegionName(regionName);
|
||||||
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, district);
|
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, fallbackRegion);
|
||||||
var displayName = configuration.SungerType == 4
|
var displayName = configuration.SungerType is 2 or 4 or 5 or 6
|
||||||
? BuildFullDistrictDisplayName(regionName, districtLabel)
|
? BuildFullDistrictDisplayName(regionName, districtLabel)
|
||||||
: regionName;
|
: regionName;
|
||||||
|
|
||||||
return new SbsElectionRefreshResult(
|
return new SbsElectionRefreshResult(
|
||||||
DistrictName: displayName,
|
DistrictName: displayName,
|
||||||
DistrictCode: district.Id,
|
DistrictCode: district.DistrictCode,
|
||||||
RegionName: outputRegionName,
|
RegionName: outputRegionName,
|
||||||
ElectionDistrictName: districtLabel,
|
ElectionDistrictName: districtLabel,
|
||||||
TotalExpectedVotes: Math.Max(totalVotes, 1),
|
TotalExpectedVotes: Math.Max(totalVotes, 1),
|
||||||
@@ -345,7 +664,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
RemainingVotes: item.Total?.UncountedPyosu,
|
RemainingVotes: item.Total?.UncountedPyosu,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
ReceivedAt: DateTimeOffset.Now,
|
ReceivedAt: DateTimeOffset.Now,
|
||||||
SourcePath: $"GET /gaepyo/{configuration.SungerType}/sungergus?ids={district.Id}");
|
SourcePath: sourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CandidateEntry MapCandidate(SbsCandidateItem item)
|
private static CandidateEntry MapCandidate(SbsCandidateItem item)
|
||||||
@@ -371,6 +690,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
"40" => CandidateJudgement.Leading,
|
"40" => CandidateJudgement.Leading,
|
||||||
"50" => CandidateJudgement.Confirmed,
|
"50" => CandidateJudgement.Confirmed,
|
||||||
"60" => CandidateJudgement.ElectedInProgress,
|
"60" => CandidateJudgement.ElectedInProgress,
|
||||||
|
"70" => CandidateJudgement.Elected,
|
||||||
"80" => CandidateJudgement.UnopposedElected,
|
"80" => CandidateJudgement.UnopposedElected,
|
||||||
"90" => CandidateJudgement.ElectedAfterCountComplete,
|
"90" => CandidateJudgement.ElectedAfterCountComplete,
|
||||||
_ => CandidateJudgement.None
|
_ => CandidateJudgement.None
|
||||||
@@ -378,11 +698,15 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
|
private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
string districtName,
|
string districtName,
|
||||||
string districtCode,
|
string districtCode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_sidoRegions ??= await GetValueAsync<SbsRegionInfo>("sungerInfo/region?type=시도", cancellationToken).ConfigureAwait(false);
|
_sidoRegions ??= await GetValueAsync<SbsRegionInfo>(
|
||||||
|
configuration.BaseUri,
|
||||||
|
"sungerInfo/region?type=시도",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(districtCode))
|
if (!string.IsNullOrWhiteSpace(districtCode))
|
||||||
{
|
{
|
||||||
@@ -403,12 +727,15 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SbsRegionInfo> ResolveElectionDistrictAsync(
|
private async Task<SbsRegionInfo> ResolveElectionDistrictAsync(
|
||||||
int sungerType,
|
SbsElectionConfiguration configuration,
|
||||||
string districtName,
|
string districtName,
|
||||||
string districtCode,
|
string districtCode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var regions = await GetElectionDistrictRegionsAsync(sungerType, cancellationToken).ConfigureAwait(false);
|
var regions = await GetElectionDistrictRegionsAsync(
|
||||||
|
configuration,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(districtCode))
|
if (!string.IsNullOrWhiteSpace(districtCode))
|
||||||
{
|
{
|
||||||
@@ -462,39 +789,177 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<SbsRegionInfo>> GetElectionDistrictRegionsAsync(
|
private async Task<IReadOnlyList<SbsRegionInfo>> GetElectionDistrictRegionsAsync(
|
||||||
int sungerType,
|
SbsElectionConfiguration configuration,
|
||||||
|
IReadOnlyList<string> scopedSidoCodes,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_districtRegions.TryGetValue(sungerType, out var regions))
|
var scopedKey = scopedSidoCodes.Count == 0
|
||||||
|
? "all"
|
||||||
|
: string.Join(",", scopedSidoCodes.OrderBy(code => code, StringComparer.Ordinal));
|
||||||
|
var cacheKey = $"{configuration.BaseUri.AbsoluteUri}|{configuration.SungerType}|{scopedKey}";
|
||||||
|
if (!_districtRegions.TryGetValue(cacheKey, out var regions))
|
||||||
{
|
{
|
||||||
regions = await GetValueAsync<SbsRegionInfo>(
|
if (CanDeriveDistrictsFromCounting(configuration))
|
||||||
$"sungerInfo/region?type=선거구&sungerType={sungerType}",
|
{
|
||||||
cancellationToken).ConfigureAwait(false);
|
regions = scopedSidoCodes.Count == 0
|
||||||
_districtRegions[sungerType] = regions;
|
? Array.Empty<SbsRegionInfo>()
|
||||||
|
: await GetCountingDistrictRegionsAsync(configuration, scopedSidoCodes, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
regions = await GetValueAsync<SbsRegionInfo>(
|
||||||
|
configuration.BaseUri,
|
||||||
|
$"sungerInfo/region?type=선거구&sungerType={configuration.SungerType}",
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_districtRegions[cacheKey] = regions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return regions;
|
return regions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<T>> GetValueAsync<T>(string relativePath, CancellationToken cancellationToken)
|
private async Task<IReadOnlyList<SbsRegionInfo>> GetCountingDistrictRegionsAsync(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
IReadOnlyList<string> scopedSidoCodes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
|
var items = await GetCountingItemsBySidoCodesAsync(
|
||||||
|
configuration,
|
||||||
|
scopedSidoCodes,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return items
|
||||||
|
.Select(item => CreateRegionInfo(item.Item.Region))
|
||||||
|
.Where(region => region is not null && !string.IsNullOrWhiteSpace(region.Id))
|
||||||
|
.Select(region => region!)
|
||||||
|
.GroupBy(region => region.Id, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderBy(region => region.Order > 0 ? region.Order : int.MaxValue)
|
||||||
|
.ThenBy(region => region.Id, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<T>> GetValueAsync<T>(Uri baseUri, string relativePath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var json = await GetJsonAsync(baseUri, relativePath, cancellationToken).ConfigureAwait(false);
|
||||||
return DeserializeList<T>(json, relativePath, preferValueProperty: true);
|
return DeserializeList<T>(json, relativePath, preferValueProperty: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<T>> GetArrayAsync<T>(string relativePath, CancellationToken cancellationToken)
|
private async Task<IReadOnlyList<T>> GetArrayAsync<T>(Uri baseUri, string relativePath, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
|
var json = await GetJsonAsync(baseUri, relativePath, cancellationToken).ConfigureAwait(false);
|
||||||
return DeserializeList<T>(json, relativePath, preferValueProperty: false);
|
return DeserializeList<T>(json, relativePath, preferValueProperty: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetJsonAsync(string relativePath, CancellationToken cancellationToken)
|
private async Task<string> GetJsonAsync(Uri baseUri, string relativePath, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var response = await _httpClient.GetAsync(relativePath, cancellationToken).ConfigureAwait(false);
|
var requestUri = new Uri(baseUri, relativePath);
|
||||||
response.EnsureSuccessStatusCode();
|
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||||
return Encoding.UTF8.GetString(bytes);
|
var body = Encoding.UTF8.GetString(bytes);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var detail = CreateResponseDetail(body);
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"SBS API 요청 실패: GET {requestUri.PathAndQuery} -> {(int)response.StatusCode} {response.ReasonPhrase}{detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildCountingPath(SbsElectionConfiguration configuration, string query)
|
||||||
|
=> string.IsNullOrWhiteSpace(query)
|
||||||
|
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
|
||||||
|
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}";
|
||||||
|
|
||||||
|
private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration)
|
||||||
|
=> Uri.Compare(
|
||||||
|
configuration.BaseUri,
|
||||||
|
BasicApiBaseUri,
|
||||||
|
UriComponents.SchemeAndServer | UriComponents.Path,
|
||||||
|
UriFormat.SafeUnescaped,
|
||||||
|
StringComparison.OrdinalIgnoreCase) == 0 &&
|
||||||
|
configuration.SungerType == 6;
|
||||||
|
|
||||||
|
private static bool CanQueryCountingBySido(
|
||||||
|
SbsElectionConfiguration configuration,
|
||||||
|
IReadOnlyList<DistrictSelectionOption> districts)
|
||||||
|
=> configuration.SungerType == 6 &&
|
||||||
|
CanDeriveDistrictsFromCounting(configuration) &&
|
||||||
|
districts.Count > 0 &&
|
||||||
|
districts.All(district => !string.IsNullOrWhiteSpace(district.ParentRegionCode));
|
||||||
|
|
||||||
|
private static bool CanQueryBasicCouncilByDistrictId(SbsElectionConfiguration configuration)
|
||||||
|
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
|
||||||
|
|
||||||
|
private static bool ShouldCacheBasicCouncilCounting(SbsElectionConfiguration configuration)
|
||||||
|
=> configuration.SungerType == 6 && CanDeriveDistrictsFromCounting(configuration);
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> ResolveBasicApiSidoCodes(IEnumerable<string> regionNames)
|
||||||
|
{
|
||||||
|
return (regionNames ?? Array.Empty<string>())
|
||||||
|
.Select(ResolveBasicApiSidoCode)
|
||||||
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(code => code, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ResolveBasicApiSidoCode(string? regionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(regionName))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizeRegionName(regionName);
|
||||||
|
return BasicApiSidoCodes.TryGetValue(normalized, out var code) ||
|
||||||
|
BasicApiSidoCodes.TryGetValue(regionName.Trim(), out code)
|
||||||
|
? code
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri ResolveBasicApiBaseUri()
|
||||||
|
{
|
||||||
|
var configuredBaseUrl = Environment.GetEnvironmentVariable(BasicApiBaseUrlEnvironmentVariable);
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredBaseUrl) &&
|
||||||
|
Uri.TryCreate(EnsureTrailingSlash(configuredBaseUrl.Trim()), UriKind.Absolute, out var configuredUri))
|
||||||
|
{
|
||||||
|
return configuredUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uri("http://210.180.17.148/sbs-basic-api/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveBasicApiCountingEndpointSegment()
|
||||||
|
{
|
||||||
|
var mode = Environment.GetEnvironmentVariable(BasicApiModeEnvironmentVariable)?.Trim().ToLowerInvariant();
|
||||||
|
return mode switch
|
||||||
|
{
|
||||||
|
"dev" or "development" or "test" or "stage" or "staging" or "개발" => "gaepyo-dev",
|
||||||
|
"prod" or "production" or "real" or "operating" or "운영" => "gaepyo",
|
||||||
|
_ => "gaepyo"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureTrailingSlash(string value)
|
||||||
|
=> value.EndsWith("/", StringComparison.Ordinal) ? value : $"{value}/";
|
||||||
|
|
||||||
|
private static string CreateResponseDetail(string body)
|
||||||
|
{
|
||||||
|
var trimmed = body.Replace("\r", " ", StringComparison.Ordinal)
|
||||||
|
.Replace("\n", " ", StringComparison.Ordinal)
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $" / {trimmed[..Math.Min(trimmed.Length, 180)]}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<T> DeserializeList<T>(string json, string relativePath, bool preferValueProperty)
|
private static IReadOnlyList<T> DeserializeList<T>(string json, string relativePath, bool preferValueProperty)
|
||||||
@@ -549,7 +1014,7 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
return sungerType switch
|
return sungerType switch
|
||||||
{
|
{
|
||||||
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
|
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
|
||||||
4 => BuildElectionDistrictLabel(region, fallback),
|
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region, fallback),
|
||||||
_ => regionName
|
_ => regionName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -644,10 +1109,10 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
var districtName = sungerType switch
|
var districtName = sungerType switch
|
||||||
{
|
{
|
||||||
3 => BuildMayorGovernorLabel(regionName, region.Name4),
|
3 => BuildMayorGovernorLabel(regionName, region.Name4),
|
||||||
4 => BuildElectionDistrictLabel(region),
|
2 or 4 or 5 or 6 => BuildElectionDistrictLabel(region),
|
||||||
_ => regionName
|
_ => regionName
|
||||||
};
|
};
|
||||||
var displayName = sungerType == 4
|
var displayName = sungerType is 2 or 4 or 5 or 6
|
||||||
? BuildFullDistrictDisplayName(regionName, districtName)
|
? BuildFullDistrictDisplayName(regionName, districtName)
|
||||||
: regionName;
|
: regionName;
|
||||||
|
|
||||||
@@ -659,6 +1124,51 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
ParentRegionCode: region.Name1Id ?? string.Empty);
|
ParentRegionCode: region.Name1Id ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SbsRegionInfo CreateRegionInfo(DistrictSelectionOption option)
|
||||||
|
{
|
||||||
|
return new SbsRegionInfo
|
||||||
|
{
|
||||||
|
Id = option.DistrictCode,
|
||||||
|
Name = option.DisplayName,
|
||||||
|
Name1 = option.RegionName,
|
||||||
|
Name2 = option.DistrictName,
|
||||||
|
Name4 = option.DistrictName,
|
||||||
|
Name1Id = option.ParentRegionCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SbsRegionInfo? CreateRegionInfo(SbsTurnoutRegion? region)
|
||||||
|
{
|
||||||
|
if (region is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = !string.IsNullOrWhiteSpace(region.Id)
|
||||||
|
? region.Id
|
||||||
|
: region.Name4Id ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SbsRegionInfo
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = region.Name ?? region.Name4 ?? string.Empty,
|
||||||
|
Name1 = region.Name1,
|
||||||
|
Name2 = region.Name2,
|
||||||
|
Name3 = region.Name3,
|
||||||
|
Name4 = region.Name4,
|
||||||
|
Name1Id = region.Name1Id,
|
||||||
|
Name2Id = region.Name2Id,
|
||||||
|
Name3Id = region.Name3Id,
|
||||||
|
Name4Id = region.Name4Id,
|
||||||
|
Order = region.Order
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static string NormalizeRegionName(string? value)
|
private static string NormalizeRegionName(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
@@ -707,7 +1217,11 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
: value.Trim();
|
: value.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct SbsElectionConfiguration(int SungerType, bool SupportsPreElection);
|
private readonly record struct SbsElectionConfiguration(
|
||||||
|
int SungerType,
|
||||||
|
bool SupportsPreElection,
|
||||||
|
Uri BaseUri,
|
||||||
|
string CountingEndpointSegment);
|
||||||
|
|
||||||
public sealed record DistrictSelectionOption(
|
public sealed record DistrictSelectionOption(
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
@@ -771,6 +1285,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
[JsonPropertyName("name2")]
|
[JsonPropertyName("name2")]
|
||||||
public string? Name2 { get; set; }
|
public string? Name2 { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name3")]
|
||||||
|
public string? Name3 { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("name4")]
|
[JsonPropertyName("name4")]
|
||||||
public string? Name4 { get; set; }
|
public string? Name4 { get; set; }
|
||||||
|
|
||||||
@@ -779,6 +1296,15 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
|
|
||||||
[JsonPropertyName("name2Id")]
|
[JsonPropertyName("name2Id")]
|
||||||
public string? Name2Id { get; set; }
|
public string? Name2Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name3Id")]
|
||||||
|
public string? Name3Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name4Id")]
|
||||||
|
public string? Name4Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("order")]
|
||||||
|
public int Order { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SbsTurnoutItem
|
private sealed class SbsTurnoutItem
|
||||||
@@ -807,6 +1333,9 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
[JsonPropertyName("name2")]
|
[JsonPropertyName("name2")]
|
||||||
public string? Name2 { get; set; }
|
public string? Name2 { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name3")]
|
||||||
|
public string? Name3 { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("name4")]
|
[JsonPropertyName("name4")]
|
||||||
public string? Name4 { get; set; }
|
public string? Name4 { get; set; }
|
||||||
|
|
||||||
@@ -816,8 +1345,14 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
[JsonPropertyName("name2Id")]
|
[JsonPropertyName("name2Id")]
|
||||||
public string? Name2Id { get; set; }
|
public string? Name2Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name3Id")]
|
||||||
|
public string? Name3Id { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("name4Id")]
|
[JsonPropertyName("name4Id")]
|
||||||
public string? Name4Id { get; set; }
|
public string? Name4Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("order")]
|
||||||
|
public int Order { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class SbsTurnoutVoteSnapshot
|
private sealed class SbsTurnoutVoteSnapshot
|
||||||
@@ -838,6 +1373,15 @@ public sealed class SbsElectionApiClient : IDisposable
|
|||||||
public List<SbsCandidateItem>? Hubojas { get; set; }
|
public List<SbsCandidateItem>? Hubojas { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct SbsCountingResponseItem(
|
||||||
|
SbsCountingItem Item,
|
||||||
|
string SourcePath);
|
||||||
|
|
||||||
|
private readonly record struct SbsCountingCacheEntry(
|
||||||
|
DateTimeOffset ReceivedAt,
|
||||||
|
IReadOnlyList<SbsCountingItem> Items,
|
||||||
|
string SourcePath);
|
||||||
|
|
||||||
private sealed class SbsCountingVoteSnapshot
|
private sealed class SbsCountingVoteSnapshot
|
||||||
{
|
{
|
||||||
[JsonPropertyName("tupyosu")]
|
[JsonPropertyName("tupyosu")]
|
||||||
|
|||||||
86
Tornado3_2026Election/Services/ScheduleTemplatePolicy.cs
Normal file
86
Tornado3_2026Election/Services/ScheduleTemplatePolicy.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using Tornado3_2026Election.Domain;
|
||||||
|
|
||||||
|
namespace Tornado3_2026Election.Services;
|
||||||
|
|
||||||
|
internal static class ScheduleTemplatePolicy
|
||||||
|
{
|
||||||
|
public const string SingleRegionLabel = "단일";
|
||||||
|
|
||||||
|
public static double GetMinimumCutDurationSeconds(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return GetMinimumCutDurationSeconds(template.RecommendedChannel, template.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double GetMinimumCutDurationSeconds(BroadcastChannel channel, string? templateName)
|
||||||
|
{
|
||||||
|
var name = templateName ?? string.Empty;
|
||||||
|
if (name.Contains("영상", StringComparison.Ordinal) ||
|
||||||
|
name.Contains("ani", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.StartsWith("사전_", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return 10d;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel == BroadcastChannel.VideoWall)
|
||||||
|
{
|
||||||
|
return 8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel is BroadcastChannel.Normal or BroadcastChannel.Bottom)
|
||||||
|
{
|
||||||
|
return 8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double NormalizeCutDurationSeconds(double durationSeconds, FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return NormalizeCutDurationSeconds(durationSeconds, template.RecommendedChannel, template.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double NormalizeCutDurationSeconds(
|
||||||
|
double durationSeconds,
|
||||||
|
BroadcastChannel channel,
|
||||||
|
string? templateName)
|
||||||
|
{
|
||||||
|
if (double.IsNaN(durationSeconds) || double.IsInfinity(durationSeconds))
|
||||||
|
{
|
||||||
|
return GetMinimumCutDurationSeconds(channel, templateName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var minimum = GetMinimumCutDurationSeconds(channel, templateName);
|
||||||
|
return Math.Max(minimum, Math.Round(durationSeconds, 1, MidpointRounding.AwayFromZero));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool UsesSingleRegionOption(FormatTemplateDefinition? template)
|
||||||
|
{
|
||||||
|
return template is not null && IsStaticHistoricalTrendFormat(template.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsStaticHistoricalTrendFormat(string? templateName)
|
||||||
|
{
|
||||||
|
return string.Equals(templateName, "역대시도판세_광역단체장", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(templateName, "역대시도판세_기초단체장", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsHistoricalTurnoutFormat(string? templateName)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(templateName) &&
|
||||||
|
templateName.StartsWith("사전_역대투표율", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsHistoricalWinnerFormat(string? templateName)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(templateName) &&
|
||||||
|
templateName.StartsWith("사전_역대당선", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsCouncilSeatTableFormat(string? templateName)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(templateName) &&
|
||||||
|
(templateName.Contains("광역의원표", StringComparison.Ordinal) ||
|
||||||
|
templateName.Contains("기초의원표", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public sealed class TornadoManager : IDisposable
|
|||||||
private readonly Timer _reconnectTimer;
|
private readonly Timer _reconnectTimer;
|
||||||
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, DateTime> _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<int, KAScenePlayer> _scenePlayers = new();
|
private readonly Dictionary<int, KAScenePlayer> _scenePlayers = new();
|
||||||
|
|
||||||
private IKAEngine? _engine;
|
private IKAEngine? _engine;
|
||||||
@@ -65,13 +66,16 @@ public sealed class TornadoManager : IDisposable
|
|||||||
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
|
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sceneWriteTime = ResolveSceneWriteTime(scenePath);
|
||||||
var existingAlias = await _dispatcher.InvokeAsync(() =>
|
var existingAlias = await _dispatcher.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
EnsureConnectedCore();
|
EnsureConnectedCore();
|
||||||
|
|
||||||
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
|
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
|
||||||
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase))
|
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
_sceneWriteTimes.TryGetValue(sceneAlias, out var existingWriteTime) &&
|
||||||
|
existingWriteTime == sceneWriteTime)
|
||||||
{
|
{
|
||||||
return sceneAlias;
|
return sceneAlias;
|
||||||
}
|
}
|
||||||
@@ -104,6 +108,7 @@ public sealed class TornadoManager : IDisposable
|
|||||||
|
|
||||||
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
|
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
|
||||||
_scenePaths[sceneAlias] = scenePath;
|
_scenePaths[sceneAlias] = scenePath;
|
||||||
|
_sceneWriteTimes[sceneAlias] = sceneWriteTime;
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
@@ -126,6 +131,7 @@ public sealed class TornadoManager : IDisposable
|
|||||||
{
|
{
|
||||||
_scenes.Remove(sceneAlias);
|
_scenes.Remove(sceneAlias);
|
||||||
_scenePaths.Remove(sceneAlias);
|
_scenePaths.Remove(sceneAlias);
|
||||||
|
_sceneWriteTimes.Remove(sceneAlias);
|
||||||
}, CancellationToken.None).ConfigureAwait(false);
|
}, CancellationToken.None).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -141,6 +147,8 @@ public sealed class TornadoManager : IDisposable
|
|||||||
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
|
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
|
||||||
IReadOnlyDictionary<string, string> values,
|
IReadOnlyDictionary<string, string> values,
|
||||||
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
|
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
|
||||||
|
IReadOnlyList<KarismaChartCellUpdate> chartCellUpdates,
|
||||||
|
IReadOnlyList<KarismaPositionUpdate> positionUpdates,
|
||||||
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
|
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
|
||||||
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
|
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -205,6 +213,60 @@ public sealed class TornadoManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var chartCellUpdate in chartCellUpdates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(chartCellUpdate.ObjectName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sceneObject = scene.GetObject(chartCellUpdate.ObjectName);
|
||||||
|
if (sceneObject is not IKAChart chart)
|
||||||
|
{
|
||||||
|
_logService.Warning(
|
||||||
|
$"Karisma chart cell update skipped: scene={sceneAlias} object={chartCellUpdate.ObjectName} reason=object is not a chart");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.SetChartCellData(chartCellUpdate.Row, chartCellUpdate.Column, chartCellUpdate.Value);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logService.Warning(
|
||||||
|
$"Karisma chart cell update skipped: scene={sceneAlias} object={chartCellUpdate.ObjectName} row={chartCellUpdate.Row} column={chartCellUpdate.Column} reason={ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var positionUpdate in positionUpdates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(positionUpdate.ObjectName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
|
||||||
|
if (sceneObject is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneObject.SetPosition(
|
||||||
|
positionUpdate.X,
|
||||||
|
positionUpdate.Y,
|
||||||
|
positionUpdate.Z,
|
||||||
|
positionUpdate.VectorType);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logService.Warning(
|
||||||
|
$"Karisma position update skipped: scene={sceneAlias} object={positionUpdate.ObjectName} reason={ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var styleColorUpdate in styleColorUpdates)
|
foreach (var styleColorUpdate in styleColorUpdates)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(styleColorUpdate.ObjectName))
|
if (string.IsNullOrWhiteSpace(styleColorUpdate.ObjectName))
|
||||||
@@ -246,6 +308,18 @@ public sealed class TornadoManager : IDisposable
|
|||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DateTime ResolveSceneWriteTime(string scenePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.GetLastWriteTimeUtc(scenePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyVisibilityUpdates(string sceneAlias, KAScene scene, IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdates)
|
private void ApplyVisibilityUpdates(string sceneAlias, KAScene scene, IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdates)
|
||||||
{
|
{
|
||||||
foreach (var visibilityUpdate in visibilityUpdates)
|
foreach (var visibilityUpdate in visibilityUpdates)
|
||||||
|
|||||||
@@ -1,109 +1,16 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace Tornado3_2026Election.Services;
|
namespace Tornado3_2026Election.Services;
|
||||||
|
|
||||||
public static class TornadoPathResolver
|
public static class TornadoPathResolver
|
||||||
{
|
{
|
||||||
|
public const string FixedT3CutPath = @"D:\Elect2026\T3_Cut";
|
||||||
|
|
||||||
public static string GetDefaultT3CutPath()
|
public static string GetDefaultT3CutPath()
|
||||||
{
|
{
|
||||||
foreach (var candidate in GetCandidatePaths())
|
return FixedT3CutPath;
|
||||||
{
|
|
||||||
var normalized = NormalizeConfiguredPath(candidate);
|
|
||||||
if (!string.IsNullOrWhiteSpace(normalized) && Directory.Exists(normalized))
|
|
||||||
{
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NormalizeConfiguredPath(Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
|
||||||
"Tornado3 Data",
|
|
||||||
"T3_Cut",
|
|
||||||
"T3_Cut"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string NormalizeConfiguredPath(string? configuredPath)
|
public static string NormalizeConfiguredPath(string? configuredPath)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
return FixedT3CutPath;
|
||||||
{
|
|
||||||
return string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
var trimmedPath = configuredPath.Trim();
|
|
||||||
if (!Directory.Exists(trimmedPath))
|
|
||||||
{
|
|
||||||
return trimmedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nestedDefault = Path.Combine(trimmedPath, "T3_Cut");
|
|
||||||
if (ContainsSceneFiles(nestedDefault))
|
|
||||||
{
|
|
||||||
return nestedDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContainsSceneFiles(trimmedPath))
|
|
||||||
{
|
|
||||||
return trimmedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nestedDirectory = TryFindNestedSceneDirectory(trimmedPath);
|
|
||||||
return nestedDirectory ?? trimmedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<string> GetCandidatePaths()
|
|
||||||
{
|
|
||||||
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
||||||
var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
|
||||||
var configured = Environment.GetEnvironmentVariable("TORNADO_T3CUT_PATH");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(configured))
|
|
||||||
{
|
|
||||||
yield return configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(documents))
|
|
||||||
{
|
|
||||||
yield return Path.Combine(documents, "Tornado3 Data", "T3_Cut", "T3_Cut");
|
|
||||||
yield return Path.Combine(documents, "Tornado3 Data", "T3_Cut");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(userProfile))
|
|
||||||
{
|
|
||||||
yield return Path.Combine(userProfile, "Downloads", "T3_Cut");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? TryFindNestedSceneDirectory(string rootPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Directory.EnumerateDirectories(rootPath, "*", SearchOption.TopDirectoryOnly)
|
|
||||||
.Select(NormalizeConfiguredPath)
|
|
||||||
.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && ContainsSceneFiles(path));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ContainsSceneFiles(string? path)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Directory.EnumerateFiles(path, "*.tscn", SearchOption.AllDirectories).Any();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
private bool _loopEnabled;
|
private bool _loopEnabled;
|
||||||
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
||||||
private int _regionOptionsRevision;
|
private int _regionOptionsRevision;
|
||||||
|
private string _lastRegionOptionFormatId = string.Empty;
|
||||||
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
|
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
|
||||||
private double _selectedFormatThumbnailWidth = 320;
|
private double _selectedFormatThumbnailWidth = 320;
|
||||||
private double _selectedFormatThumbnailHeight = 180;
|
private double _selectedFormatThumbnailHeight = 180;
|
||||||
@@ -506,10 +507,12 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
RegionOptions.Clear();
|
RegionOptions.Clear();
|
||||||
SelectedRegionOption = null;
|
SelectedRegionOption = null;
|
||||||
|
_lastRegionOptionFormatId = string.Empty;
|
||||||
AddFormatCommand.NotifyCanExecuteChanged();
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var previousRegionOptionFormatId = _lastRegionOptionFormatId;
|
||||||
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat);
|
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat);
|
||||||
if (revision != _regionOptionsRevision)
|
if (revision != _regionOptionsRevision)
|
||||||
{
|
{
|
||||||
@@ -522,7 +525,12 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
RegionOptions.Add(option);
|
RegionOptions.Add(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection);
|
var shouldUseDefaultSelection = !string.Equals(
|
||||||
|
previousRegionOptionFormatId,
|
||||||
|
selectedFormat.Id,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
|
||||||
|
_lastRegionOptionFormatId = selectedFormat.Id;
|
||||||
AddFormatCommand.NotifyCanExecuteChanged();
|
AddFormatCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,16 +562,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
|
|
||||||
private static ScheduleRegionOption? ResolvePreferredRegionOption(
|
private static ScheduleRegionOption? ResolvePreferredRegionOption(
|
||||||
IReadOnlyList<ScheduleRegionOption> options,
|
IReadOnlyList<ScheduleRegionOption> options,
|
||||||
ScheduleRegionOption? previousSelection)
|
ScheduleRegionOption? previousSelection,
|
||||||
|
FormatTemplateDefinition selectedFormat,
|
||||||
|
bool shouldUseDefaultSelection)
|
||||||
{
|
{
|
||||||
if (options.Count == 0)
|
if (options.Count == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousSelection is null)
|
if (previousSelection is null || shouldUseDefaultSelection)
|
||||||
{
|
{
|
||||||
return options[0];
|
return ResolveDefaultRegionOption(options, selectedFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousSelection.Scope == ScheduleRegionScope.Single)
|
if (previousSelection.Scope == ScheduleRegionScope.Single)
|
||||||
@@ -577,7 +587,26 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ?? options[0];
|
return options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ??
|
||||||
|
ResolveDefaultRegionOption(options, selectedFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScheduleRegionOption ResolveDefaultRegionOption(
|
||||||
|
IReadOnlyList<ScheduleRegionOption> options,
|
||||||
|
FormatTemplateDefinition selectedFormat)
|
||||||
|
{
|
||||||
|
var defaultScope = UsesAllDefaultRegionScope(selectedFormat)
|
||||||
|
? ScheduleRegionScope.All
|
||||||
|
: ScheduleRegionScope.StationRegions;
|
||||||
|
|
||||||
|
return options.FirstOrDefault(option => option.Scope == defaultScope) ?? options[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
|
||||||
|
{
|
||||||
|
var source = $"{format.Name} {format.Id}";
|
||||||
|
return source.Contains("광역단체장", StringComparison.Ordinal) ||
|
||||||
|
source.Contains("교육감", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
|
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
_cut = cut;
|
_cut = cut;
|
||||||
_durationChanged = durationChanged;
|
_durationChanged = durationChanged;
|
||||||
_videoWallLayoutPreset = videoWallLayoutPreset;
|
_videoWallLayoutPreset = videoWallLayoutPreset;
|
||||||
_durationSeconds = cut.DurationSeconds;
|
_durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||||
|
_cut.DurationSeconds = _durationSeconds;
|
||||||
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
|
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
|
||||||
ApplyThumbnailLayout();
|
ApplyThumbnailLayout();
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,8 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
|
|
||||||
public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
|
public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
|
||||||
|
|
||||||
|
public double MinimumDurationSeconds => ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(_template);
|
||||||
|
|
||||||
public ImageSource? ThumbnailSource => _thumbnailSource;
|
public ImageSource? ThumbnailSource => _thumbnailSource;
|
||||||
|
|
||||||
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
|
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
|
||||||
@@ -74,7 +77,7 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalized = Math.Max(1, Math.Round(value, 1, MidpointRounding.AwayFromZero));
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, _template);
|
||||||
if (!SetProperty(ref _durationSeconds, normalized))
|
if (!SetProperty(ref _durationSeconds, normalized))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -92,7 +95,8 @@ public sealed class CutListEntryViewModel : ObservableObject
|
|||||||
|
|
||||||
public void RefreshFromSource()
|
public void RefreshFromSource()
|
||||||
{
|
{
|
||||||
var sourceValue = _cut.DurationSeconds;
|
var sourceValue = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(_cut.DurationSeconds, _template);
|
||||||
|
_cut.DurationSeconds = sourceValue;
|
||||||
if (Math.Abs(_durationSeconds - sourceValue) >= 0.001d)
|
if (Math.Abs(_durationSeconds - sourceValue) >= 0.001d)
|
||||||
{
|
{
|
||||||
SetProperty(ref _durationSeconds, sourceValue);
|
SetProperty(ref _durationSeconds, sourceValue);
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
private const string DistrictOverviewOptionLabel = "전체보기";
|
private const string DistrictOverviewOptionLabel = "전체보기";
|
||||||
private const string StationRegionOverviewOptionValue = "__SELECTED_REGIONS__";
|
private const string StationRegionOverviewOptionValue = "__SELECTED_REGIONS__";
|
||||||
private const string StationRegionOverviewOptionLabel = "선택권역보기";
|
private const string StationRegionOverviewOptionLabel = "선택권역보기";
|
||||||
|
private const string CouncilSeatCandidateCodePrefix = "SEAT:";
|
||||||
|
private const string CouncilSeatDistrictCandidateCodePrefix = "SEAT:D:";
|
||||||
|
private const string CouncilSeatProportionalCandidateCodePrefix = "SEAT:P:";
|
||||||
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
||||||
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
||||||
|
|
||||||
@@ -184,6 +187,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
ElectionTypeOptions =
|
ElectionTypeOptions =
|
||||||
[
|
[
|
||||||
|
new SelectionOption<string>("국회의원", "국회의원"),
|
||||||
new SelectionOption<string>("광역단체장", "광역단체장"),
|
new SelectionOption<string>("광역단체장", "광역단체장"),
|
||||||
new SelectionOption<string>("교육감", "교육감"),
|
new SelectionOption<string>("교육감", "교육감"),
|
||||||
new SelectionOption<string>("광역의원", "광역의원"),
|
new SelectionOption<string>("광역의원", "광역의원"),
|
||||||
@@ -292,16 +296,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
public bool IsCountingPhase => BroadcastPhase == BroadcastPhase.Counting;
|
public bool IsCountingPhase => BroadcastPhase == BroadcastPhase.Counting;
|
||||||
|
|
||||||
public string BroadcastPhaseLabel => IsPreElectionPhase ? "사전" : "개표";
|
public string BroadcastPhaseLabel => IsPreElectionPhase ? "투표" : "개표";
|
||||||
|
|
||||||
public string BroadcastPhaseBadgeText => IsPreElectionPhase ? "사전 투표율 수신" : "개표 득표수 수신";
|
public string BroadcastPhaseBadgeText => IsPreElectionPhase ? "투표율 수신" : "개표 득표수 수신";
|
||||||
|
|
||||||
public string BroadcastPhaseDetailText => IsPreElectionPhase
|
public string BroadcastPhaseDetailText => IsPreElectionPhase
|
||||||
? "사전 단계에서는 투표율과 투표자 수를 수신합니다."
|
? "투표 단계에서는 투표율과 투표자 수를 수신합니다."
|
||||||
: "개표 단계에서는 후보 득표수와 판정 데이터를 수신합니다.";
|
: "개표 단계에서는 후보 득표수와 판정 데이터를 수신합니다.";
|
||||||
|
|
||||||
public string HeaderMetricSummary => IsPreElectionPhase
|
public string HeaderMetricSummary => IsPreElectionPhase
|
||||||
? $"사전 투표율 {TurnoutRateDisplay} / 투표자 {TurnoutVotes:N0}"
|
? $"투표율 {TurnoutRateDisplay} / 투표자 {TurnoutVotes:N0}"
|
||||||
: $"개표율 {CountedRateDisplay} / 개표 {CountedVotes:N0} / 남은표 {RemainingVotes:N0}";
|
: $"개표율 {CountedRateDisplay} / 개표 {CountedVotes:N0} / 남은표 {RemainingVotes:N0}";
|
||||||
|
|
||||||
public string SituationMetricPrimaryLabel => IsPreElectionPhase ? "투표율" : "개표수";
|
public string SituationMetricPrimaryLabel => IsPreElectionPhase ? "투표율" : "개표수";
|
||||||
@@ -355,8 +359,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
? "선택 권역 개표율"
|
? "선택 권역 개표율"
|
||||||
: "전체 개표율"
|
: "전체 개표율"
|
||||||
: IsStationRegionOverviewMode
|
: IsStationRegionOverviewMode
|
||||||
? "선택 권역 보기"
|
? "선택 권역 투표율"
|
||||||
: "전체 보기";
|
: "전체 투표율";
|
||||||
|
|
||||||
public string DistrictOverviewStatusText
|
public string DistrictOverviewStatusText
|
||||||
{
|
{
|
||||||
@@ -879,7 +883,14 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
_configuredRegions = normalizedRegions;
|
_configuredRegions = normalizedRegions;
|
||||||
OnPropertyChanged(nameof(HasConfiguredRegionFilter), nameof(ConfiguredRegionFilterHintText));
|
OnPropertyChanged(nameof(HasConfiguredRegionFilter), nameof(ConfiguredRegionFilterHintText));
|
||||||
ReapplyDistrictSelectionOptions();
|
if (IsBasicCouncilElectionType(ElectionType))
|
||||||
|
{
|
||||||
|
_ = RefreshDistrictOptionsForElectionTypeAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReapplyDistrictSelectionOptions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetSelectedStationContext(string stationId, string stationName)
|
public void SetSelectedStationContext(string stationId, string stationName)
|
||||||
@@ -929,6 +940,19 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var electionType = ResolveScheduleElectionType(template);
|
var electionType = ResolveScheduleElectionType(template);
|
||||||
|
if (ScheduleTemplatePolicy.UsesSingleRegionOption(template))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new ScheduleRegionOption
|
||||||
|
{
|
||||||
|
Scope = ScheduleRegionScope.Single,
|
||||||
|
Label = ScheduleTemplatePolicy.SingleRegionLabel,
|
||||||
|
ElectionType = electionType
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var regionOptions = new List<ScheduleRegionOption>
|
var regionOptions = new List<ScheduleRegionOption>
|
||||||
@@ -967,6 +991,30 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
public bool ValidateSnapshotForFormat(FormatTemplateDefinition template, ElectionDataSnapshot snapshot, out string errorMessage)
|
public bool ValidateSnapshotForFormat(FormatTemplateDefinition template, ElectionDataSnapshot snapshot, out string errorMessage)
|
||||||
{
|
{
|
||||||
|
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
|
||||||
|
{
|
||||||
|
if (snapshot.HistoricalTurnoutHistory.Count == 0)
|
||||||
|
{
|
||||||
|
errorMessage = "역대 투표율 데이터가 없습니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
|
||||||
|
{
|
||||||
|
if (snapshot.HistoricalWinnerHistory.Count == 0)
|
||||||
|
{
|
||||||
|
errorMessage = "역대 당선자 데이터가 없습니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (IsTurnoutTemplate(template) &&
|
if (IsTurnoutTemplate(template) &&
|
||||||
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
|
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
|
||||||
{
|
{
|
||||||
@@ -1098,6 +1146,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var electionType = ResolveScheduleElectionType(template, item.ScheduleElectionType);
|
var electionType = ResolveScheduleElectionType(template, item.ScheduleElectionType);
|
||||||
|
if (ScheduleTemplatePolicy.UsesSingleRegionOption(template))
|
||||||
|
{
|
||||||
|
return [CreateSingleScheduleRegionTarget(electionType)];
|
||||||
|
}
|
||||||
|
|
||||||
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||||
if (options.Count == 0)
|
if (options.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -1121,6 +1174,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
var electionType = string.IsNullOrWhiteSpace(target.ElectionType)
|
var electionType = string.IsNullOrWhiteSpace(target.ElectionType)
|
||||||
? ResolveScheduleElectionType(item.FormatName, item.ScheduleElectionType)
|
? ResolveScheduleElectionType(item.FormatName, item.ScheduleElectionType)
|
||||||
: target.ElectionType;
|
: target.ElectionType;
|
||||||
|
if (ScheduleTemplatePolicy.UsesSingleRegionOption(template))
|
||||||
|
{
|
||||||
|
return CreateSingleScheduleSnapshot(electionType, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
|
||||||
|
{
|
||||||
|
return CreateHistoricalTurnoutScheduleSnapshot(electionType, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
|
||||||
|
{
|
||||||
|
return CreateHistoricalWinnerScheduleSnapshot(electionType, target);
|
||||||
|
}
|
||||||
|
|
||||||
if (ShouldUseTurnoutOverviewSnapshot(template, electionType))
|
if (ShouldUseTurnoutOverviewSnapshot(template, electionType))
|
||||||
{
|
{
|
||||||
return await CreateTurnoutScheduleSnapshotAsync(
|
return await CreateTurnoutScheduleSnapshotAsync(
|
||||||
@@ -1148,6 +1216,15 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var electionType = ResolveScheduleElectionType(template, item.ScheduleElectionType);
|
var electionType = ResolveScheduleElectionType(template, item.ScheduleElectionType);
|
||||||
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||||
|
{
|
||||||
|
return CreateCouncilSeatScheduleSnapshotAsync(
|
||||||
|
electionType,
|
||||||
|
station,
|
||||||
|
regionTargets,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (IsBottomTurnoutBoardTemplate(template) && ShouldUseTurnoutOverviewSnapshot(template, electionType))
|
if (IsBottomTurnoutBoardTemplate(template) && ShouldUseTurnoutOverviewSnapshot(template, electionType))
|
||||||
{
|
{
|
||||||
return CreateTurnoutScheduleSnapshotAsync(
|
return CreateTurnoutScheduleSnapshotAsync(
|
||||||
@@ -1182,7 +1259,10 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
if (SupportsApiDistrictOptions(ElectionType))
|
if (SupportsApiDistrictOptions(ElectionType))
|
||||||
{
|
{
|
||||||
_logService.Info($"{ElectionType} 선거구 목록을 SBS API 기준으로 불러옵니다.");
|
_logService.Info($"{ElectionType} 선거구 목록을 SBS API 기준으로 불러옵니다.");
|
||||||
var options = await _apiClient.GetDistrictOptionsAsync(ElectionType, CancellationToken.None);
|
var options = await _apiClient.GetDistrictOptionsAsync(
|
||||||
|
ElectionType,
|
||||||
|
ResolveApiDistrictRegionScope(ElectionType),
|
||||||
|
CancellationToken.None);
|
||||||
if (revision != _districtOptionsRevision)
|
if (revision != _districtOptionsRevision)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -1196,6 +1276,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
QueueSelectionRefresh("선거 종류 변경");
|
QueueSelectionRefresh("선거 종류 변경");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsBasicCouncilElectionType(ElectionType))
|
||||||
|
{
|
||||||
|
ApplyDistrictSelectionSource(
|
||||||
|
Array.Empty<SbsElectionApiClient.DistrictSelectionOption>(),
|
||||||
|
preferredName,
|
||||||
|
preferredCode,
|
||||||
|
preferredRegionName);
|
||||||
|
_logService.Warning($"{ElectionType} 선거구 목록을 불러오지 못했습니다. 선택권역 설정을 확인하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fallbackPreferences = CaptureCurrentDistrictPreferences();
|
var fallbackPreferences = CaptureCurrentDistrictPreferences();
|
||||||
@@ -1214,6 +1305,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fallbackPreferences = CaptureCurrentDistrictPreferences();
|
var fallbackPreferences = CaptureCurrentDistrictPreferences();
|
||||||
|
if (IsBasicCouncilElectionType(ElectionType))
|
||||||
|
{
|
||||||
|
ApplyDistrictSelectionSource(
|
||||||
|
Array.Empty<SbsElectionApiClient.DistrictSelectionOption>(),
|
||||||
|
fallbackPreferences.PreferredName,
|
||||||
|
fallbackPreferences.PreferredCode,
|
||||||
|
fallbackPreferences.PreferredRegionName);
|
||||||
|
_logService.Warning($"기초의원 선거구 목록 갱신 실패: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ApplyDistrictSelectionSource(
|
ApplyDistrictSelectionSource(
|
||||||
DefaultDistrictOptions,
|
DefaultDistrictOptions,
|
||||||
fallbackPreferences.PreferredName,
|
fallbackPreferences.PreferredName,
|
||||||
@@ -1910,9 +2012,10 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
private (IReadOnlyList<PreElectionHistoricalTurnoutEntry> TurnoutHistory, IReadOnlyList<PreElectionHistoricalWinnerEntry> WinnerHistory) ResolvePreElectionHistoryRecords(
|
private (IReadOnlyList<PreElectionHistoricalTurnoutEntry> TurnoutHistory, IReadOnlyList<PreElectionHistoricalWinnerEntry> WinnerHistory) ResolvePreElectionHistoryRecords(
|
||||||
string electionType,
|
string electionType,
|
||||||
string regionName,
|
string regionName,
|
||||||
string districtName)
|
string districtName,
|
||||||
|
bool includeOutsidePreElection = false)
|
||||||
{
|
{
|
||||||
if (BroadcastPhase != BroadcastPhase.PreElection)
|
if (!includeOutsidePreElection && BroadcastPhase != BroadcastPhase.PreElection)
|
||||||
{
|
{
|
||||||
return (Array.Empty<PreElectionHistoricalTurnoutEntry>(), Array.Empty<PreElectionHistoricalWinnerEntry>());
|
return (Array.Empty<PreElectionHistoricalTurnoutEntry>(), Array.Empty<PreElectionHistoricalWinnerEntry>());
|
||||||
}
|
}
|
||||||
@@ -2025,7 +2128,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sourceOptions
|
return sourceOptions
|
||||||
.Where(option => _configuredRegions.Contains(NormalizeConfiguredRegion(option.RegionName)))
|
.Where(option => MatchesConfiguredRegion(option.RegionName, _configuredRegions))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2244,13 +2347,6 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsCountingPhase)
|
|
||||||
{
|
|
||||||
ReplaceDistrictOverviewCards([]);
|
|
||||||
DistrictOverviewStatusText = $"{GetDistrictOverviewModeLabel()}는 개표 단계에서 지역별 개표율 카드로 표시됩니다.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var visibleOptions = GetDistrictOverviewSelectionOptions(_districtSelectionSource);
|
var visibleOptions = GetDistrictOverviewSelectionOptions(_districtSelectionSource);
|
||||||
if (visibleOptions.Count == 0)
|
if (visibleOptions.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -2261,10 +2357,35 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
var revision = Interlocked.Increment(ref _districtOverviewRefreshRevision);
|
var revision = Interlocked.Increment(ref _districtOverviewRefreshRevision);
|
||||||
_isRefreshingDistrictOverview = true;
|
_isRefreshingDistrictOverview = true;
|
||||||
DistrictOverviewStatusText = $"{reason}으로 지역별 개표율을 불러오는 중입니다.";
|
DistrictOverviewStatusText = IsPreElectionPhase
|
||||||
|
? $"{reason}으로 지역별 투표율을 불러오는 중입니다."
|
||||||
|
: $"{reason}으로 지역별 개표율을 불러오는 중입니다.";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (IsPreElectionPhase)
|
||||||
|
{
|
||||||
|
var turnoutOverview = await _apiClient.GetTurnoutOverviewAsync(ElectionType, visibleOptions, CancellationToken.None);
|
||||||
|
if (revision != _districtOverviewRefreshRevision || !IsDistrictOverviewMode)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReplaceDistrictOverviewCards(
|
||||||
|
turnoutOverview.Items.Select(item => new DistrictOverviewCardViewModel
|
||||||
|
{
|
||||||
|
DistrictViewName = item.DisplayName,
|
||||||
|
RegionName = item.DisplayName,
|
||||||
|
CountedRateDisplay = $"{item.TurnoutRate:0.0}%",
|
||||||
|
DetailText = $"투표 {item.TurnoutVotes:N0} / 미투표 {Math.Max(0, item.TotalExpectedVotes - item.TurnoutVotes):N0}"
|
||||||
|
}));
|
||||||
|
|
||||||
|
DistrictOverviewStatusText = DistrictOverviewCards.Count == 0
|
||||||
|
? "수신된 지역별 투표율이 없습니다."
|
||||||
|
: $"총 {DistrictOverviewCards.Count}개 지역 / 전국 {turnoutOverview.NationalTurnoutRate:0.0}% / 마지막 갱신 {DateTimeOffset.Now:HH:mm:ss}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var overviewItems = await _apiClient.GetCountingOverviewAsync(ElectionType, visibleOptions, CancellationToken.None);
|
var overviewItems = await _apiClient.GetCountingOverviewAsync(ElectionType, visibleOptions, CancellationToken.None);
|
||||||
if (revision != _districtOverviewRefreshRevision || !IsDistrictOverviewMode)
|
if (revision != _districtOverviewRefreshRevision || !IsDistrictOverviewMode)
|
||||||
{
|
{
|
||||||
@@ -2334,7 +2455,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
{
|
{
|
||||||
if (!IsDistrictOverviewMode)
|
if (!IsDistrictOverviewMode)
|
||||||
{
|
{
|
||||||
DistrictOverviewStatusText = "전체보기나 선택권역보기를 선택하면 지역별 개표율을 확인할 수 있습니다.";
|
DistrictOverviewStatusText = $"전체보기나 선택권역보기를 선택하면 지역별 {GetDistrictOverviewMetricLabel()}을 확인할 수 있습니다.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2344,12 +2465,6 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsCountingPhase)
|
|
||||||
{
|
|
||||||
DistrictOverviewStatusText = $"{GetDistrictOverviewModeLabel()}는 개표 단계에서 지역별 개표율 카드로 표시됩니다.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isRefreshingDistrictOverview)
|
if (_isRefreshingDistrictOverview)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -2366,11 +2481,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
if (IsStationRegionOverviewMode)
|
if (IsStationRegionOverviewMode)
|
||||||
{
|
{
|
||||||
return _configuredRegions.Count == 0
|
return _configuredRegions.Count == 0
|
||||||
? "설정에서 권역을 선택하면 선택권역보기로 해당 지역 개표율을 볼 수 있습니다."
|
? $"설정에서 권역을 선택하면 선택권역보기로 해당 지역 {GetDistrictOverviewMetricLabel()}을 볼 수 있습니다."
|
||||||
: "선택한 권역에 표시할 선거구가 없습니다.";
|
: "선택한 권역에 표시할 선거구가 없습니다.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "전체보기에서 지역별 개표율을 불러올 수 있습니다.";
|
return $"전체보기에서 지역별 {GetDistrictOverviewMetricLabel()}을 불러올 수 있습니다.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetDistrictOverviewModeLabel()
|
private string GetDistrictOverviewModeLabel()
|
||||||
@@ -2378,6 +2493,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
return IsStationRegionOverviewMode ? StationRegionOverviewOptionLabel : DistrictOverviewOptionLabel;
|
return IsStationRegionOverviewMode ? StationRegionOverviewOptionLabel : DistrictOverviewOptionLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetDistrictOverviewMetricLabel()
|
||||||
|
{
|
||||||
|
return IsPreElectionPhase ? "투표율" : "개표율";
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<SelectionOption<string>> CreateDistrictViewSelectionOptions(
|
private static IEnumerable<SelectionOption<string>> CreateDistrictViewSelectionOptions(
|
||||||
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> options)
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> options)
|
||||||
{
|
{
|
||||||
@@ -2401,11 +2521,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
if (SupportsApiDistrictOptions(electionType))
|
if (SupportsApiDistrictOptions(electionType))
|
||||||
{
|
{
|
||||||
var options = await _apiClient.GetDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
var options = await _apiClient
|
||||||
|
.GetDistrictOptionsAsync(electionType, ResolveApiDistrictRegionScope(electionType), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
if (options.Count > 0)
|
if (options.Count > 0)
|
||||||
{
|
{
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsBasicCouncilElectionType(electionType))
|
||||||
|
{
|
||||||
|
return Array.Empty<SbsElectionApiClient.DistrictSelectionOption>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return _districtSelectionSource.Count > 0 ? _districtSelectionSource : DefaultDistrictOptions;
|
return _districtSelectionSource.Count > 0 ? _districtSelectionSource : DefaultDistrictOptions;
|
||||||
@@ -2424,15 +2551,27 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
return "교육감";
|
return "교육감";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal) ||
|
if (resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
|
||||||
resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
|
{
|
||||||
|
return "기초의원";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return "기초단체장";
|
return "기초단체장";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal) ||
|
if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal))
|
||||||
resolvedFormatName.Contains("광역의원", StringComparison.Ordinal) ||
|
{
|
||||||
resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
|
return "광역의원";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "국회의원";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return "광역단체장";
|
return "광역단체장";
|
||||||
}
|
}
|
||||||
@@ -2459,7 +2598,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
.Where(option => configuredRegions.Contains(NormalizeConfiguredRegion(option.RegionName)))
|
.Where(option => MatchesConfiguredRegion(option.RegionName, configuredRegions))
|
||||||
.Select(option => CreateScheduleRegionTarget(option, electionType))
|
.Select(option => CreateScheduleRegionTarget(option, electionType))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
@@ -2523,6 +2662,165 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ElectionDataSnapshot> CreateCouncilSeatScheduleSnapshotAsync(
|
||||||
|
string electionType,
|
||||||
|
BroadcastStationProfile station,
|
||||||
|
IReadOnlyList<ScheduleRegionTarget> regionTargets,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!SupportsApiDistrictOptions(electionType))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{electionType} 의석 집계는 현재 SBS API 연동 대상이 아닙니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedTargets = regionTargets
|
||||||
|
.Where(target => !string.IsNullOrWhiteSpace(target.DistrictCode))
|
||||||
|
.GroupBy(target => target.DistrictCode, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToArray();
|
||||||
|
if (selectedTargets.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var districtOptions = selectedTargets
|
||||||
|
.Select(target => new SbsElectionApiClient.DistrictSelectionOption(
|
||||||
|
target.DisplayName,
|
||||||
|
target.DistrictCode,
|
||||||
|
target.RegionName,
|
||||||
|
target.DistrictName,
|
||||||
|
ResolveScheduleTargetParentRegionCode(target)))
|
||||||
|
.ToArray();
|
||||||
|
var refreshResults = await _apiClient
|
||||||
|
.GetCountingSnapshotsAsync(electionType, districtOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (refreshResults.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("의석 집계용 개표 데이터가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCandidates = refreshResults
|
||||||
|
.SelectMany(result => result.Candidates ?? Array.Empty<CandidateEntry>())
|
||||||
|
.ToArray();
|
||||||
|
var seatCandidates = BuildCouncilSeatSummaryCandidates(allCandidates);
|
||||||
|
if (seatCandidates.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("의석으로 집계할 유력/확정/당선 후보가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
|
||||||
|
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
|
||||||
|
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
|
||||||
|
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0));
|
||||||
|
var countedRate = totalVotes <= 0
|
||||||
|
? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max()
|
||||||
|
: Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero);
|
||||||
|
var regionName = ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets);
|
||||||
|
var districtName = selectedTargets.Length == 1
|
||||||
|
? selectedTargets[0].DisplayName
|
||||||
|
: regionName;
|
||||||
|
var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName);
|
||||||
|
|
||||||
|
return new ElectionDataSnapshot
|
||||||
|
{
|
||||||
|
BroadcastPhase = BroadcastPhase.Counting,
|
||||||
|
ElectionType = electionType,
|
||||||
|
DistrictName = districtName,
|
||||||
|
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
|
||||||
|
RegionName = regionName,
|
||||||
|
ElectionDistrictName = selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
|
||||||
|
Candidates = seatCandidates,
|
||||||
|
TotalExpectedVotes = totalVotes,
|
||||||
|
TurnoutVotes = turnoutVotes,
|
||||||
|
CountedVotesFromApi = countedVotes,
|
||||||
|
RemainingVotesFromApi = remainingVotes,
|
||||||
|
CountedRateFromApi = countedRate,
|
||||||
|
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
|
||||||
|
HistoricalTurnoutHistory = history.TurnoutHistory,
|
||||||
|
HistoricalWinnerHistory = history.WinnerHistory
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(IReadOnlyList<CandidateEntry> candidates)
|
||||||
|
{
|
||||||
|
return candidates
|
||||||
|
.Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
|
||||||
|
.GroupBy(candidate => ResolveCouncilSeatParty(candidate), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
Party = group.Key,
|
||||||
|
ColorParty = ResolveCouncilSeatColorParty(first),
|
||||||
|
SeatCount = group.Count()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.Where(row => row.SeatCount > 0)
|
||||||
|
.OrderByDescending(row => row.SeatCount)
|
||||||
|
.ThenBy(row => row.Party, StringComparer.Ordinal)
|
||||||
|
.Select((row, index) => new CandidateEntry
|
||||||
|
{
|
||||||
|
CandidateCode = $"{CouncilSeatDistrictCandidateCodePrefix}{index + 1:00}",
|
||||||
|
BallotNumber = (index + 1).ToString(),
|
||||||
|
Name = row.Party,
|
||||||
|
Party = row.Party,
|
||||||
|
ColorParty = row.ColorParty,
|
||||||
|
VoteCount = row.SeatCount,
|
||||||
|
VoteRate = row.SeatCount,
|
||||||
|
HasImage = false,
|
||||||
|
ManualJudgement = CandidateJudgement.None,
|
||||||
|
AutomaticJudgement = CandidateJudgement.Elected
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CountsAsCouncilSeat(CandidateJudgement judgement)
|
||||||
|
{
|
||||||
|
return judgement is CandidateJudgement.Leading or
|
||||||
|
CandidateJudgement.Confirmed or
|
||||||
|
CandidateJudgement.Elected or
|
||||||
|
CandidateJudgement.ElectedInProgress or
|
||||||
|
CandidateJudgement.UnopposedElected or
|
||||||
|
CandidateJudgement.ElectedAfterCountComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCouncilSeatParty(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(candidate.Party))
|
||||||
|
{
|
||||||
|
return candidate.Party.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
|
||||||
|
? "무소속"
|
||||||
|
: candidate.EffectiveColorParty.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCouncilSeatColorParty(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(candidate.EffectiveColorParty)
|
||||||
|
? ResolveCouncilSeatParty(candidate)
|
||||||
|
: candidate.EffectiveColorParty.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCouncilSeatAggregateRegionLabel(
|
||||||
|
BroadcastStationProfile station,
|
||||||
|
IReadOnlyList<ScheduleRegionTarget> selectedTargets)
|
||||||
|
{
|
||||||
|
var regionNames = selectedTargets
|
||||||
|
.Select(target => target.RegionName)
|
||||||
|
.Where(regionName => !string.IsNullOrWhiteSpace(regionName))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (regionNames.Length == 1)
|
||||||
|
{
|
||||||
|
return regionNames[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(station.Name) ? "전체" : station.Name;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<ElectionDataSnapshot> CreateTurnoutScheduleSnapshotAsync(
|
private async Task<ElectionDataSnapshot> CreateTurnoutScheduleSnapshotAsync(
|
||||||
string electionType,
|
string electionType,
|
||||||
IReadOnlyList<ScheduleRegionTarget> regionTargets,
|
IReadOnlyList<ScheduleRegionTarget> regionTargets,
|
||||||
@@ -2622,9 +2920,29 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
private static bool ShouldUseTurnoutOverviewSnapshot(FormatTemplateDefinition template, string electionType)
|
private static bool ShouldUseTurnoutOverviewSnapshot(FormatTemplateDefinition template, string electionType)
|
||||||
{
|
{
|
||||||
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) &&
|
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) &&
|
||||||
|
!ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) &&
|
||||||
template.Name.Contains("투표율", StringComparison.Ordinal);
|
template.Name.Contains("투표율", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> ResolveApiDistrictRegionScope(string electionType)
|
||||||
|
{
|
||||||
|
return IsBasicCouncilElectionType(electionType)
|
||||||
|
? _configuredRegions
|
||||||
|
: Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveScheduleTargetParentRegionCode(ScheduleRegionTarget target)
|
||||||
|
{
|
||||||
|
var matchedOption = _districtSelectionSource.FirstOrDefault(option =>
|
||||||
|
string.Equals(option.DistrictCode, target.DistrictCode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (matchedOption is not null && !string.IsNullOrWhiteSpace(matchedOption.ParentRegionCode))
|
||||||
|
{
|
||||||
|
return matchedOption.ParentRegionCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
|
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||||
@@ -2675,6 +2993,114 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
option.DistrictCode);
|
option.DistrictCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ScheduleRegionTarget CreateSingleScheduleRegionTarget(string electionType)
|
||||||
|
{
|
||||||
|
return new ScheduleRegionTarget(
|
||||||
|
electionType,
|
||||||
|
ScheduleTemplatePolicy.SingleRegionLabel,
|
||||||
|
string.Empty,
|
||||||
|
ScheduleTemplatePolicy.SingleRegionLabel,
|
||||||
|
string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ElectionDataSnapshot CreateSingleScheduleSnapshot(
|
||||||
|
string electionType,
|
||||||
|
ScheduleRegionTarget target)
|
||||||
|
{
|
||||||
|
var displayName = string.IsNullOrWhiteSpace(target.DisplayName)
|
||||||
|
? ScheduleTemplatePolicy.SingleRegionLabel
|
||||||
|
: target.DisplayName;
|
||||||
|
|
||||||
|
return new ElectionDataSnapshot
|
||||||
|
{
|
||||||
|
BroadcastPhase = BroadcastPhase,
|
||||||
|
ElectionType = electionType,
|
||||||
|
DistrictName = displayName,
|
||||||
|
DistrictCode = string.Empty,
|
||||||
|
RegionName = string.Empty,
|
||||||
|
ElectionDistrictName = displayName,
|
||||||
|
Candidates = Array.Empty<CandidateEntry>(),
|
||||||
|
TotalExpectedVotes = 0,
|
||||||
|
TurnoutVotes = 0,
|
||||||
|
CountedVotesFromApi = null,
|
||||||
|
RemainingVotesFromApi = null,
|
||||||
|
CountedRateFromApi = null,
|
||||||
|
ReceivedAt = DateTimeOffset.Now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ElectionDataSnapshot CreateHistoricalTurnoutScheduleSnapshot(
|
||||||
|
string electionType,
|
||||||
|
ScheduleRegionTarget target)
|
||||||
|
{
|
||||||
|
var regionName = target.RegionName ?? string.Empty;
|
||||||
|
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
|
||||||
|
? target.DistrictName
|
||||||
|
: !string.IsNullOrWhiteSpace(target.DisplayName)
|
||||||
|
? target.DisplayName
|
||||||
|
: regionName;
|
||||||
|
var history = ResolvePreElectionHistoryRecords(
|
||||||
|
electionType,
|
||||||
|
regionName,
|
||||||
|
districtName,
|
||||||
|
includeOutsidePreElection: true);
|
||||||
|
|
||||||
|
return new ElectionDataSnapshot
|
||||||
|
{
|
||||||
|
BroadcastPhase = BroadcastPhase,
|
||||||
|
ElectionType = electionType,
|
||||||
|
DistrictName = districtName,
|
||||||
|
DistrictCode = target.DistrictCode,
|
||||||
|
RegionName = regionName,
|
||||||
|
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
|
||||||
|
Candidates = Array.Empty<CandidateEntry>(),
|
||||||
|
TotalExpectedVotes = 0,
|
||||||
|
TurnoutVotes = 0,
|
||||||
|
CountedVotesFromApi = null,
|
||||||
|
RemainingVotesFromApi = null,
|
||||||
|
CountedRateFromApi = null,
|
||||||
|
ReceivedAt = DateTimeOffset.Now,
|
||||||
|
HistoricalTurnoutHistory = history.TurnoutHistory,
|
||||||
|
HistoricalWinnerHistory = history.WinnerHistory
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ElectionDataSnapshot CreateHistoricalWinnerScheduleSnapshot(
|
||||||
|
string electionType,
|
||||||
|
ScheduleRegionTarget target)
|
||||||
|
{
|
||||||
|
var regionName = target.RegionName ?? string.Empty;
|
||||||
|
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
|
||||||
|
? target.DistrictName
|
||||||
|
: !string.IsNullOrWhiteSpace(target.DisplayName)
|
||||||
|
? target.DisplayName
|
||||||
|
: regionName;
|
||||||
|
var history = ResolvePreElectionHistoryRecords(
|
||||||
|
electionType,
|
||||||
|
regionName,
|
||||||
|
districtName,
|
||||||
|
includeOutsidePreElection: true);
|
||||||
|
|
||||||
|
return new ElectionDataSnapshot
|
||||||
|
{
|
||||||
|
BroadcastPhase = BroadcastPhase,
|
||||||
|
ElectionType = electionType,
|
||||||
|
DistrictName = districtName,
|
||||||
|
DistrictCode = target.DistrictCode,
|
||||||
|
RegionName = regionName,
|
||||||
|
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
|
||||||
|
Candidates = Array.Empty<CandidateEntry>(),
|
||||||
|
TotalExpectedVotes = 0,
|
||||||
|
TurnoutVotes = 0,
|
||||||
|
CountedVotesFromApi = null,
|
||||||
|
RemainingVotesFromApi = null,
|
||||||
|
CountedRateFromApi = null,
|
||||||
|
ReceivedAt = DateTimeOffset.Now,
|
||||||
|
HistoricalTurnoutHistory = history.TurnoutHistory,
|
||||||
|
HistoricalWinnerHistory = history.WinnerHistory
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static string NormalizeDistrictKey(string? value)
|
private static string NormalizeDistrictKey(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
@@ -2697,9 +3123,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
|
|
||||||
private static bool SupportsApiDistrictOptions(string electionType)
|
private static bool SupportsApiDistrictOptions(string electionType)
|
||||||
{
|
{
|
||||||
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
|
return string.Equals(electionType, "국회의원", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
|
||||||
string.Equals(electionType, "교육감", StringComparison.Ordinal) ||
|
string.Equals(electionType, "교육감", StringComparison.Ordinal) ||
|
||||||
string.Equals(electionType, "기초단체장", StringComparison.Ordinal);
|
string.Equals(electionType, "광역의원", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(electionType, "기초단체장", StringComparison.Ordinal) ||
|
||||||
|
string.Equals(electionType, "기초의원", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBasicCouncilElectionType(string electionType)
|
||||||
|
{
|
||||||
|
return string.Equals(electionType, "기초의원", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool SupportsApiRefresh(BroadcastPhase phase, string electionType)
|
private static bool SupportsApiRefresh(BroadcastPhase phase, string electionType)
|
||||||
@@ -2740,6 +3174,34 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
: trimmedRegion;
|
: trimmedRegion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool MatchesConfiguredRegion(string? regionName, ISet<string> configuredRegions)
|
||||||
|
{
|
||||||
|
if (configuredRegions.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetNormalizedRegionKeys(regionName).Any(configuredRegions.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetNormalizedRegionKeys(string? regionName)
|
||||||
|
{
|
||||||
|
var normalizedRegion = NormalizeConfiguredRegion(regionName);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalizedRegion))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return normalizedRegion;
|
||||||
|
|
||||||
|
if (string.Equals(normalizedRegion, "전남광주", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(normalizedRegion, "광주전남", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return "광주";
|
||||||
|
yield return "전남";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsStationRegionOverviewMode =>
|
private bool IsStationRegionOverviewMode =>
|
||||||
string.Equals(SelectedDistrictViewName, StationRegionOverviewOptionValue, StringComparison.Ordinal);
|
string.Equals(SelectedDistrictViewName, StationRegionOverviewOptionValue, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
nameof(CutListVisibility),
|
nameof(CutListVisibility),
|
||||||
nameof(SettingsVisibility),
|
nameof(SettingsVisibility),
|
||||||
nameof(LogVisibility),
|
nameof(LogVisibility),
|
||||||
nameof(CurrentPageTitle));
|
nameof(CurrentPageTitle),
|
||||||
|
nameof(HeaderStatus));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +239,8 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
AppPage.Bottom => "하단",
|
AppPage.Bottom => "하단",
|
||||||
AppPage.VideoWall => "비디오월",
|
AppPage.VideoWall => "비디오월",
|
||||||
AppPage.PreElectionData => "사전데이터",
|
AppPage.PreElectionData => "사전데이터",
|
||||||
AppPage.Data => "데이터",
|
AppPage.TurnoutData => "투표데이터",
|
||||||
|
AppPage.CountingData or AppPage.Data => "개표데이터",
|
||||||
AppPage.CutList => "컷리스트",
|
AppPage.CutList => "컷리스트",
|
||||||
AppPage.Settings => "설정",
|
AppPage.Settings => "설정",
|
||||||
AppPage.Log => "로그",
|
AppPage.Log => "로그",
|
||||||
@@ -281,7 +283,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
public Visibility PreElectionDataVisibility => CurrentPage == AppPage.PreElectionData ? Visibility.Visible : Visibility.Collapsed;
|
public Visibility PreElectionDataVisibility => CurrentPage == AppPage.PreElectionData ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
public Visibility DataVisibility => CurrentPage == AppPage.Data ? Visibility.Visible : Visibility.Collapsed;
|
public Visibility DataVisibility => IsLiveDataPage(CurrentPage) ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
public Visibility CutListVisibility => CurrentPage == AppPage.CutList ? Visibility.Visible : Visibility.Collapsed;
|
public Visibility CutListVisibility => CurrentPage == AppPage.CutList ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
@@ -504,19 +506,24 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
public void Navigate(string tag)
|
public void Navigate(string tag)
|
||||||
{
|
{
|
||||||
CurrentPage = tag switch
|
var targetPage = tag switch
|
||||||
{
|
{
|
||||||
"normal" when IsGeneralOperationMode => AppPage.Normal,
|
"normal" when IsGeneralOperationMode => AppPage.Normal,
|
||||||
"top-left" when IsGeneralOperationMode => AppPage.TopLeft,
|
"top-left" when IsGeneralOperationMode => AppPage.TopLeft,
|
||||||
"bottom" when IsGeneralOperationMode => AppPage.Bottom,
|
"bottom" when IsGeneralOperationMode => AppPage.Bottom,
|
||||||
"videowall" when IsVideoWallOperationMode => AppPage.VideoWall,
|
"videowall" when IsVideoWallOperationMode => AppPage.VideoWall,
|
||||||
"pre-election-data" => AppPage.PreElectionData,
|
"pre-election-data" => AppPage.PreElectionData,
|
||||||
"data" => AppPage.Data,
|
"turnout-data" => AppPage.TurnoutData,
|
||||||
|
"counting-data" => AppPage.CountingData,
|
||||||
|
"data" => Data.IsPreElectionPhase ? AppPage.TurnoutData : AppPage.CountingData,
|
||||||
"cut-list" => AppPage.CutList,
|
"cut-list" => AppPage.CutList,
|
||||||
"settings" => AppPage.Settings,
|
"settings" => AppPage.Settings,
|
||||||
"log" => AppPage.Log,
|
"log" => AppPage.Log,
|
||||||
_ => GetDefaultPage()
|
_ => GetDefaultPage()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
CurrentPage = targetPage;
|
||||||
|
SyncBroadcastPhaseForLiveDataPage(targetPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsPageAvailable(AppPage page)
|
public bool IsPageAvailable(AppPage page)
|
||||||
@@ -529,6 +536,26 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsLiveDataPage(AppPage page)
|
||||||
|
{
|
||||||
|
return page is AppPage.TurnoutData or AppPage.CountingData or AppPage.Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncBroadcastPhaseForLiveDataPage(AppPage page)
|
||||||
|
{
|
||||||
|
var targetPhase = page switch
|
||||||
|
{
|
||||||
|
AppPage.TurnoutData => BroadcastPhase.PreElection,
|
||||||
|
AppPage.CountingData or AppPage.Data => BroadcastPhase.Counting,
|
||||||
|
_ => (BroadcastPhase?)null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (targetPhase is { } phase)
|
||||||
|
{
|
||||||
|
Data.ApplyBroadcastPhase(phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> HasRestorableStateAsync()
|
public async Task<bool> HasRestorableStateAsync()
|
||||||
{
|
{
|
||||||
return await _stateStore.LoadAsync() is not null;
|
return await _stateStore.LoadAsync() is not null;
|
||||||
@@ -637,6 +664,13 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
public void ApplyBroadcastPhase(BroadcastPhase phase)
|
public void ApplyBroadcastPhase(BroadcastPhase phase)
|
||||||
{
|
{
|
||||||
Data.ApplyBroadcastPhase(phase);
|
Data.ApplyBroadcastPhase(phase);
|
||||||
|
if (IsLiveDataPage(CurrentPage))
|
||||||
|
{
|
||||||
|
CurrentPage = phase == BroadcastPhase.PreElection
|
||||||
|
? AppPage.TurnoutData
|
||||||
|
: AppPage.CountingData;
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HeaderStatus));
|
OnPropertyChanged(nameof(HeaderStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,7 +1242,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
cut.DurationSeconds = Math.Max(1, Math.Round(duration, 1, MidpointRounding.AwayFromZero));
|
cut.DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(duration, template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,9 +1351,13 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
Description = template?.Description ?? item.Description,
|
Description = template?.Description ?? item.Description,
|
||||||
Channel = item.Channel,
|
Channel = item.Channel,
|
||||||
RequiresImage = template?.RequiresImage ?? item.RequiresImage,
|
RequiresImage = template?.RequiresImage ?? item.RequiresImage,
|
||||||
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds > 0
|
DefaultCutDurationSeconds = template is null
|
||||||
? item.DefaultCutDurationSeconds
|
? item.DefaultCutDurationSeconds
|
||||||
: template?.Cuts.FirstOrDefault()?.DurationSeconds ?? item.DefaultCutDurationSeconds,
|
: ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
|
||||||
|
item.DefaultCutDurationSeconds > 0
|
||||||
|
? item.DefaultCutDurationSeconds
|
||||||
|
: template.Cuts.FirstOrDefault()?.DurationSeconds ?? item.DefaultCutDurationSeconds,
|
||||||
|
template),
|
||||||
TotalCuts = template?.Cuts.Count ?? item.TotalCuts,
|
TotalCuts = template?.Cuts.Count ?? item.TotalCuts,
|
||||||
RegionScope = item.RegionScope,
|
RegionScope = item.RegionScope,
|
||||||
ScheduleElectionType = item.ScheduleElectionType,
|
ScheduleElectionType = item.ScheduleElectionType,
|
||||||
|
|||||||
@@ -162,12 +162,12 @@ public sealed class PreElectionHistoryWinnerEditRowViewModel : ObservableObject
|
|||||||
return OtherPreview;
|
return OtherPreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedParty is "국민의힘" or "미래통합당" or "자유한국당" or "새누리당" or "한나라당" or "민주자유당")
|
if (normalizedParty is "국민의힘" or "국힘" or "미래통합당" or "자유한국당" or "새누리당" or "한나라당" or "신한국당" or "민주자유당")
|
||||||
{
|
{
|
||||||
return ConservativePreview;
|
return ConservativePreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedParty is "더불어민주당" or "민주당" or "새천년민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
|
if (normalizedParty is "더불어민주당" or "민주당" or "민주당1991" or "민주당2000" or "민주당2008" or "새정치국민회의" or "새천년민주당" or "열린민주당" or "열린우리당" or "민주통합당" or "새정치민주연합")
|
||||||
{
|
{
|
||||||
return DemocraticPreview;
|
return DemocraticPreview;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ public sealed class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
if (station == SelectedStation && args.PropertyName is nameof(StationFilterItemViewModel.VideoWallLayoutPreset) or nameof(StationFilterItemViewModel.VideoWallLayoutSummary))
|
if (station == SelectedStation && args.PropertyName is nameof(StationFilterItemViewModel.VideoWallLayoutPreset) or nameof(StationFilterItemViewModel.VideoWallLayoutSummary))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
|
OnPropertyChanged(
|
||||||
|
nameof(SelectedStationVideoWallLayoutPreset),
|
||||||
|
nameof(SelectedStationVideoWallLayoutPresetSelection),
|
||||||
|
nameof(SelectedStationVideoWallLayoutSummary));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,7 @@ public sealed class SettingsViewModel : ObservableObject
|
|||||||
nameof(SelectedStationRegions),
|
nameof(SelectedStationRegions),
|
||||||
nameof(SelectedStationRegionSummary),
|
nameof(SelectedStationRegionSummary),
|
||||||
nameof(SelectedStationVideoWallLayoutPreset),
|
nameof(SelectedStationVideoWallLayoutPreset),
|
||||||
|
nameof(SelectedStationVideoWallLayoutPresetSelection),
|
||||||
nameof(SelectedStationVideoWallLayoutSummary));
|
nameof(SelectedStationVideoWallLayoutSummary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +74,8 @@ public sealed class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
public string ImageRootPath
|
public string ImageRootPath
|
||||||
{
|
{
|
||||||
get => _imageRootPath;
|
get => TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
set => SetProperty(ref _imageRootPath, TornadoPathResolver.NormalizeConfiguredPath(value));
|
set => SetProperty(ref _imageRootPath, TornadoPathResolver.GetDefaultT3CutPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsDebugFeaturesEnabled
|
public bool IsDebugFeaturesEnabled
|
||||||
@@ -100,7 +104,22 @@ public sealed class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
SelectedStation.VideoWallLayoutPreset = value;
|
SelectedStation.VideoWallLayoutPreset = value;
|
||||||
OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
|
OnPropertyChanged(
|
||||||
|
nameof(SelectedStationVideoWallLayoutPreset),
|
||||||
|
nameof(SelectedStationVideoWallLayoutPresetSelection),
|
||||||
|
nameof(SelectedStationVideoWallLayoutSummary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public VideoWallLayoutPreset? SelectedStationVideoWallLayoutPresetSelection
|
||||||
|
{
|
||||||
|
get => SelectedStationVideoWallLayoutPreset;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value.HasValue)
|
||||||
|
{
|
||||||
|
SelectedStationVideoWallLayoutPreset = value.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Use this skill to make the smallest safe change to a cut-related workflow, then
|
|||||||
- Use [validation-workflow.md](references/validation-workflow.md) for command selection.
|
- Use [validation-workflow.md](references/validation-workflow.md) for command selection.
|
||||||
- For a scoped live pass, prefer `scripts/validate-cut.ps1`.
|
- For a scoped live pass, prefer `scripts/validate-cut.ps1`.
|
||||||
- For scene-level snapshots or raw object checks, use `tools/KarismaTcpProbe` directly.
|
- For scene-level snapshots or raw object checks, use `tools/KarismaTcpProbe` directly.
|
||||||
- If live Karisma or `T3_Cut` is unavailable, still run the build and document the missing external dependency.
|
- If live Karisma or the fixed `D:\Elect2026\T3_Cut` root is unavailable, still run the build and document the missing external dependency.
|
||||||
|
|
||||||
4. Report the result in operational terms.
|
4. Report the result in operational terms.
|
||||||
- Name the files changed.
|
- Name the files changed.
|
||||||
@@ -35,7 +35,7 @@ Use this skill to make the smallest safe change to a cut-related workflow, then
|
|||||||
## Repo Notes
|
## Repo Notes
|
||||||
|
|
||||||
- Treat the repo as the source of truth for cut metadata and validation helpers.
|
- Treat the repo as the source of truth for cut metadata and validation helpers.
|
||||||
- Treat `T3_Cut` as an external dependency that may contain the real scene or asset causing the issue.
|
- Treat `D:\Elect2026\T3_Cut` as the fixed external dependency that may contain the real scene or asset causing the issue.
|
||||||
- Prefer targeted validation with a template or cut filter instead of sweeping the whole catalog unless the user asks for a broad audit.
|
- Prefer targeted validation with a template or cut filter instead of sweeping the whole catalog unless the user asks for a broad audit.
|
||||||
- Reuse existing `tools/KarismaTcpProbe/scene-ops/*.json` fixtures when they match the symptom instead of inventing a new validation format.
|
- Reuse existing `tools/KarismaTcpProbe/scene-ops/*.json` fixtures when they match the symptom instead of inventing a new validation format.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Use this reference to decide where a cut-related change belongs.
|
|||||||
- `Tornado3_2026Election/Services/FormatCatalogService.cs`: template and cut catalog.
|
- `Tornado3_2026Election/Services/FormatCatalogService.cs`: template and cut catalog.
|
||||||
- `Tornado3_2026Election/Services/KarismaSceneResolver.cs`: resolve the actual `.tscn` or `_loop.tscn` path.
|
- `Tornado3_2026Election/Services/KarismaSceneResolver.cs`: resolve the actual `.tscn` or `_loop.tscn` path.
|
||||||
- `Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs`: scene-variable discovery and lookup cache.
|
- `Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs`: scene-variable discovery and lookup cache.
|
||||||
- `Tornado3_2026Election/Services/TornadoPathResolver.cs`: default and normalized `T3_Cut` path handling.
|
- `Tornado3_2026Election/Services/TornadoPathResolver.cs`: fixed `D:\Elect2026\T3_Cut` path handling.
|
||||||
- `Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs`: project thumbnail asset locations.
|
- `Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs`: project thumbnail asset locations.
|
||||||
|
|
||||||
## Runtime apply logic
|
## Runtime apply logic
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ dotnet build Tornado3_2026Election.slnx
|
|||||||
|
|
||||||
## 2. Run scoped live validation for a cut or template
|
## 2. Run scoped live validation for a cut or template
|
||||||
|
|
||||||
Use the local wrapper when Karisma and `T3_Cut` are available.
|
Use the local wrapper when Karisma and `D:\Elect2026\T3_Cut` are available. The root is fixed; do not pass a custom image root.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1 `
|
powershell -ExecutionPolicy Bypass -File plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1 `
|
||||||
-ImageRootPath 'C:\Path\To\T3_Cut' `
|
|
||||||
-Filter '1-2위_ani_광역단체장'
|
-Filter '1-2위_ani_광역단체장'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ Use this when the problem is visual and you already know the exact `.tscn` scene
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
|
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
|
||||||
--save-scene-image `
|
--save-scene-image `
|
||||||
--scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
|
--scene 'D:\Elect2026\T3_Cut\SomeScene.tscn' `
|
||||||
--output artifacts\scene-captures\some-scene.png
|
--output artifacts\scene-captures\some-scene.png
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ Use this when the issue is about values, visibility, or style updates for known
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
|
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
|
||||||
--validate-scene-values `
|
--validate-scene-values `
|
||||||
--scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
|
--scene 'D:\Elect2026\T3_Cut\SomeScene.tscn' `
|
||||||
--operations tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장_style.json `
|
--operations tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장_style.json `
|
||||||
--output artifacts\scene-validation\style.md
|
--output artifacts\scene-validation\style.md
|
||||||
```
|
```
|
||||||
@@ -52,6 +51,5 @@ dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
|
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
|
||||||
--inspect-tscn-folder `
|
--inspect-tscn-folder `
|
||||||
--root 'C:\Path\To\T3_Cut' `
|
|
||||||
--output artifacts\scene-inspection\inspection.md
|
--output artifacts\scene-inspection\inspection.md
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ param(
|
|||||||
|
|
||||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..\..")).Path
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..\..")).Path
|
||||||
$probeProject = Join-Path $repoRoot "tools\KarismaTcpProbe\KarismaTcpProbe.csproj"
|
$probeProject = Join-Path $repoRoot "tools\KarismaTcpProbe\KarismaTcpProbe.csproj"
|
||||||
|
$fixedImageRootPath = "D:\Elect2026\T3_Cut"
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($OutputPath))
|
if ([string]::IsNullOrWhiteSpace($OutputPath))
|
||||||
{
|
{
|
||||||
@@ -27,11 +28,6 @@ $commandArgs = @(
|
|||||||
"--between-delay-ms", $BetweenDelayMs.ToString()
|
"--between-delay-ms", $BetweenDelayMs.ToString()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($ImageRootPath))
|
|
||||||
{
|
|
||||||
$commandArgs += @("--image-root", $ImageRootPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($Filter))
|
if (-not [string]::IsNullOrWhiteSpace($Filter))
|
||||||
{
|
{
|
||||||
$commandArgs += @("--filter", $Filter)
|
$commandArgs += @("--filter", $Filter)
|
||||||
@@ -49,7 +45,12 @@ if ($IncludeVideoWall)
|
|||||||
|
|
||||||
Write-Host "Running live-cut validation..."
|
Write-Host "Running live-cut validation..."
|
||||||
Write-Host "Repo Root : $repoRoot"
|
Write-Host "Repo Root : $repoRoot"
|
||||||
|
Write-Host "Image Root: $fixedImageRootPath"
|
||||||
Write-Host "Output : $OutputPath"
|
Write-Host "Output : $OutputPath"
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($ImageRootPath))
|
||||||
|
{
|
||||||
|
Write-Host "ImageRootPath parameter ignored; fixed root is used."
|
||||||
|
}
|
||||||
if (-not [string]::IsNullOrWhiteSpace($Filter))
|
if (-not [string]::IsNullOrWhiteSpace($Filter))
|
||||||
{
|
{
|
||||||
Write-Host "Filter : $Filter"
|
Write-Host "Filter : $Filter"
|
||||||
|
|||||||
@@ -431,6 +431,8 @@ internal sealed class CatalogEventHandler : KAEventHandler
|
|||||||
|
|
||||||
internal sealed class CatalogOptions
|
internal sealed class CatalogOptions
|
||||||
{
|
{
|
||||||
|
private const string FixedT3CutPath = @"D:\Elect2026\T3_Cut";
|
||||||
|
|
||||||
public string Host { get; private set; }
|
public string Host { get; private set; }
|
||||||
public int Port { get; private set; }
|
public int Port { get; private set; }
|
||||||
public TimeSpan Timeout { get; private set; }
|
public TimeSpan Timeout { get; private set; }
|
||||||
@@ -444,11 +446,7 @@ internal sealed class CatalogOptions
|
|||||||
Host = "127.0.0.1";
|
Host = "127.0.0.1";
|
||||||
Port = 30001;
|
Port = 30001;
|
||||||
Timeout = TimeSpan.FromSeconds(5);
|
Timeout = TimeSpan.FromSeconds(5);
|
||||||
RootPath = Path.Combine(
|
RootPath = FixedT3CutPath;
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
|
||||||
"Tornado3 Data",
|
|
||||||
"T3_Cut",
|
|
||||||
"T3_Cut");
|
|
||||||
OutputPath = Path.Combine(Environment.CurrentDirectory, "SCENE_OBJECT_CATALOG.md");
|
OutputPath = Path.Combine(Environment.CurrentDirectory, "SCENE_OBJECT_CATALOG.md");
|
||||||
SceneFilter = string.Empty;
|
SceneFilter = string.Empty;
|
||||||
MaxScenes = 0;
|
MaxScenes = 0;
|
||||||
@@ -474,7 +472,7 @@ internal sealed class CatalogOptions
|
|||||||
index++;
|
index++;
|
||||||
break;
|
break;
|
||||||
case "--root" when index + 1 < args.Length:
|
case "--root" when index + 1 < args.Length:
|
||||||
options.RootPath = Path.GetFullPath(args[++index]);
|
index++;
|
||||||
break;
|
break;
|
||||||
case "--output" when index + 1 < args.Length:
|
case "--output" when index + 1 < args.Length:
|
||||||
options.OutputPath = Path.GetFullPath(args[++index]);
|
options.OutputPath = Path.GetFullPath(args[++index]);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
Console.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}");
|
Console.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}");
|
||||||
Console.WriteLine($"- Region Scope: {options.RegionScope}");
|
Console.WriteLine($"- Region Scope: {options.RegionScope}");
|
||||||
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
|
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
|
||||||
Console.WriteLine($"- Simulated Sends: {(options.SimulateSend ? options.SendLimit.ToString() : "off")}");
|
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
|
||||||
Console.WriteLine($"- Output: {options.OutputPath}");
|
Console.WriteLine($"- Output: {options.OutputPath}");
|
||||||
|
|
||||||
var stationCatalog = new StationCatalogService().GetAll();
|
var stationCatalog = new StationCatalogService().GetAll();
|
||||||
@@ -80,6 +80,9 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
.Where(template => string.IsNullOrWhiteSpace(options.Filter) ||
|
.Where(template => string.IsNullOrWhiteSpace(options.Filter) ||
|
||||||
template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) ||
|
template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) ||
|
||||||
template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase))
|
template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(template => string.IsNullOrWhiteSpace(options.ExcludeFilter) ||
|
||||||
|
(!template.Id.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!template.Name.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (options.TemplateLimit is int templateLimit && templateLimit > 0)
|
if (options.TemplateLimit is int templateLimit && templateLimit > 0)
|
||||||
@@ -99,7 +102,17 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
|
|
||||||
using var apiClient = new SbsElectionApiClient();
|
using var apiClient = new SbsElectionApiClient();
|
||||||
var logService = new LogService();
|
var logService = new LogService();
|
||||||
var adapter = options.SimulateSend ? new MockTornado3Adapter(logService) : null;
|
var preElectionHistoryService = new PreElectionHistoryService(logService);
|
||||||
|
ITornado3Adapter? adapter;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
adapter = CreateSendAdapter(options, logService);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine(ex.Message);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
var districtCache = new Dictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>>(StringComparer.Ordinal);
|
var districtCache = new Dictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>>(StringComparer.Ordinal);
|
||||||
var results = new List<CurrentApiCutDiagnosticResult>();
|
var results = new List<CurrentApiCutDiagnosticResult>();
|
||||||
var simulatedSendCount = 0;
|
var simulatedSendCount = 0;
|
||||||
@@ -109,7 +122,17 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
foreach (var template in formats)
|
foreach (var template in formats)
|
||||||
{
|
{
|
||||||
var electionType = ResolveScheduleElectionType(template.Name, phase, options.DefaultElectionType);
|
var electionType = ResolveScheduleElectionType(template.Name, phase, options.DefaultElectionType);
|
||||||
var districts = await GetDistrictsAsync(apiClient, districtCache, electionType).ConfigureAwait(false);
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
districts = await GetDistrictsAsync(apiClient, districtCache, electionType, station).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var targets = ResolveTargets(districts, station, options)
|
var targets = ResolveTargets(districts, station, options)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -136,11 +159,24 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var refreshResult = await apiClient
|
ElectionDataSnapshot snapshot;
|
||||||
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
|
if (UsesStoredPreElectionHistory(template))
|
||||||
.ConfigureAwait(false);
|
{
|
||||||
var snapshot = CreateSnapshot(phase, electionType, refreshResult);
|
snapshot = CreateStoredPreElectionHistorySnapshot(
|
||||||
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
|
phase,
|
||||||
|
electionType,
|
||||||
|
target,
|
||||||
|
preElectionHistoryService);
|
||||||
|
PopulateDataFields(result, snapshot, "stored pre-election history");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var refreshResult = await apiClient
|
||||||
|
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
snapshot = CreateSnapshot(phase, electionType, refreshResult);
|
||||||
|
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
|
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
|
||||||
{
|
{
|
||||||
@@ -152,8 +188,10 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
{
|
{
|
||||||
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
|
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
|
||||||
simulatedSendCount++;
|
simulatedSendCount++;
|
||||||
result.Status = "sent-mock";
|
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
|
||||||
result.Detail = "validated and mock send completed";
|
result.Detail = options.LiveSend
|
||||||
|
? "validated and live send completed"
|
||||||
|
: "validated and mock send completed";
|
||||||
result.Warning = warning;
|
result.Warning = warning;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -174,6 +212,11 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (adapter is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
WriteReports(options, results);
|
WriteReports(options, results);
|
||||||
PrintSummary(results, options.OutputPath);
|
PrintSummary(results, options.OutputPath);
|
||||||
|
|
||||||
@@ -182,15 +225,53 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ITornado3Adapter? CreateSendAdapter(CurrentApiCutDiagnosticsOptions options, LogService logService)
|
||||||
|
{
|
||||||
|
if (!options.SimulateSend)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.LiveSend)
|
||||||
|
{
|
||||||
|
return new MockTornado3Adapter(logService);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cutDebugStateStore = new CutDebugStateStore();
|
||||||
|
if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) ||
|
||||||
|
!adapter.IsLiveCg)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Karisma adapter is not available. Live send cannot continue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSendModeLabel(CurrentApiCutDiagnosticsOptions options)
|
||||||
|
{
|
||||||
|
if (!options.SimulateSend)
|
||||||
|
{
|
||||||
|
return "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.LiveSend
|
||||||
|
? $"live ({options.SendLimit})"
|
||||||
|
: $"mock ({options.SendLimit})";
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
|
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
|
||||||
SbsElectionApiClient apiClient,
|
SbsElectionApiClient apiClient,
|
||||||
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
|
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
|
||||||
string electionType)
|
string electionType,
|
||||||
|
BroadcastStationProfile station)
|
||||||
{
|
{
|
||||||
if (!districtCache.TryGetValue(electionType, out var districts))
|
var cacheKey = $"{electionType}|{string.Join(",", station.RegionFilters)}";
|
||||||
|
if (!districtCache.TryGetValue(cacheKey, out var districts))
|
||||||
{
|
{
|
||||||
districts = await apiClient.GetDistrictOptionsAsync(electionType, CancellationToken.None).ConfigureAwait(false);
|
districts = await apiClient
|
||||||
districtCache[electionType] = districts;
|
.GetDistrictOptionsAsync(electionType, station.RegionFilters, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
districtCache[cacheKey] = districts;
|
||||||
}
|
}
|
||||||
|
|
||||||
return districts;
|
return districts;
|
||||||
@@ -265,6 +346,42 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ElectionDataSnapshot CreateStoredPreElectionHistorySnapshot(
|
||||||
|
BroadcastPhase phase,
|
||||||
|
string electionType,
|
||||||
|
SbsElectionApiClient.DistrictSelectionOption target,
|
||||||
|
PreElectionHistoryService preElectionHistoryService)
|
||||||
|
{
|
||||||
|
var regionName = target.RegionName ?? string.Empty;
|
||||||
|
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
|
||||||
|
? target.DistrictName
|
||||||
|
: !string.IsNullOrWhiteSpace(target.DisplayName)
|
||||||
|
? target.DisplayName
|
||||||
|
: regionName;
|
||||||
|
var history = preElectionHistoryService.ResolveHistory(electionType, regionName, districtName);
|
||||||
|
|
||||||
|
return new ElectionDataSnapshot
|
||||||
|
{
|
||||||
|
BroadcastPhase = phase,
|
||||||
|
ElectionType = electionType,
|
||||||
|
DistrictName = districtName,
|
||||||
|
DistrictCode = target.DistrictCode,
|
||||||
|
RegionName = regionName,
|
||||||
|
ElectionDistrictName = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
|
||||||
|
Candidates = Array.Empty<CandidateEntry>(),
|
||||||
|
TotalExpectedVotes = 0,
|
||||||
|
TurnoutVotes = 0,
|
||||||
|
CountedVotesFromApi = null,
|
||||||
|
RemainingVotesFromApi = null,
|
||||||
|
CountedRateFromApi = null,
|
||||||
|
ReceivedAt = DateTimeOffset.Now,
|
||||||
|
HistoricalTurnoutHistory = history?.TurnoutHistory.OrderBy(entry => entry.Year).ToArray()
|
||||||
|
?? Array.Empty<PreElectionHistoricalTurnoutEntry>(),
|
||||||
|
HistoricalWinnerHistory = history?.WinnerHistory.OrderBy(entry => entry.ElectionOrder).ToArray()
|
||||||
|
?? Array.Empty<PreElectionHistoricalWinnerEntry>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task SimulateSendAsync(
|
private static async Task SimulateSendAsync(
|
||||||
ITornado3Adapter adapter,
|
ITornado3Adapter adapter,
|
||||||
BroadcastStationProfile station,
|
BroadcastStationProfile station,
|
||||||
@@ -274,11 +391,84 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
{
|
{
|
||||||
foreach (var cut in template.Cuts)
|
foreach (var cut in template.Cuts)
|
||||||
{
|
{
|
||||||
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
Exception? lastException = null;
|
||||||
|
for (var attempt = 1; attempt <= 3; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).ConfigureAwait(false);
|
||||||
|
lastException = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (attempt < 3)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
|
||||||
|
await Task.Delay(750, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastException is not null)
|
||||||
|
{
|
||||||
|
throw lastException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendSingleCutAsync(
|
||||||
|
ITornado3Adapter adapter,
|
||||||
|
BroadcastStationProfile station,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
FormatCutDefinition cut,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
string imageRootPath)
|
||||||
|
{
|
||||||
|
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
ThrowIfAdapterErrored(adapter, "connect");
|
||||||
|
try
|
||||||
|
{
|
||||||
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, imageRootPath, CancellationToken.None).ConfigureAwait(false);
|
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, imageRootPath, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
ThrowIfAdapterErrored(adapter, "apply");
|
||||||
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
ThrowIfAdapterErrored(adapter, "prepare");
|
||||||
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
await adapter.OutAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
ThrowIfAdapterErrored(adapter, "take");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
|
||||||
|
if (adapter.IsLiveCg)
|
||||||
|
{
|
||||||
|
await Task.Delay(250, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task TryOutAsync(ITornado3Adapter adapter, BroadcastChannel channel)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
ThrowIfAdapterErrored(adapter, "out");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (!adapter.IsLiveCg)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
|
||||||
|
{
|
||||||
|
if (adapter.State == TornadoConnectionState.Error)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Karisma live send failed during {action}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +480,30 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
{
|
{
|
||||||
warning = string.Empty;
|
warning = string.Empty;
|
||||||
|
|
||||||
|
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
|
||||||
|
{
|
||||||
|
if (snapshot.HistoricalTurnoutHistory.Count == 0)
|
||||||
|
{
|
||||||
|
errorMessage = "historical turnout data is empty";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
|
||||||
|
{
|
||||||
|
if (snapshot.HistoricalWinnerHistory.Count == 0)
|
||||||
|
{
|
||||||
|
errorMessage = "historical winner data is empty";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (IsTurnoutTemplate(template) &&
|
if (IsTurnoutTemplate(template) &&
|
||||||
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
|
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
|
||||||
{
|
{
|
||||||
@@ -355,6 +569,12 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool UsesStoredPreElectionHistory(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
|
||||||
|
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
|
||||||
|
}
|
||||||
|
|
||||||
private static string JoinWarning(string current, string next)
|
private static string JoinWarning(string current, string next)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(current))
|
if (string.IsNullOrWhiteSpace(current))
|
||||||
@@ -443,15 +663,27 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
return "교육감";
|
return "교육감";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal) ||
|
if (resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
|
||||||
resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
|
{
|
||||||
|
return "기초의원";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return "기초단체장";
|
return "기초단체장";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal) ||
|
if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal))
|
||||||
resolvedFormatName.Contains("광역의원", StringComparison.Ordinal) ||
|
{
|
||||||
resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
|
return "광역의원";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "국회의원";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return "광역단체장";
|
return "광역단체장";
|
||||||
}
|
}
|
||||||
@@ -578,8 +810,12 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
|
|
||||||
public string Filter { get; init; } = string.Empty;
|
public string Filter { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ExcludeFilter { get; init; } = string.Empty;
|
||||||
|
|
||||||
public bool SimulateSend { get; init; } = true;
|
public bool SimulateSend { get; init; } = true;
|
||||||
|
|
||||||
|
public bool LiveSend { get; init; }
|
||||||
|
|
||||||
public int SendLimit { get; init; } = 24;
|
public int SendLimit { get; init; } = 24;
|
||||||
|
|
||||||
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
|
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
@@ -601,9 +837,10 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
var includeVideoWall = false;
|
var includeVideoWall = false;
|
||||||
int? templateLimit = null;
|
int? templateLimit = null;
|
||||||
var filter = string.Empty;
|
var filter = string.Empty;
|
||||||
|
var excludeFilter = string.Empty;
|
||||||
var simulateSend = true;
|
var simulateSend = true;
|
||||||
|
var liveSend = false;
|
||||||
var sendLimit = 24;
|
var sendLimit = 24;
|
||||||
var imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
|
|
||||||
var outputPath = Path.Combine(
|
var outputPath = Path.Combine(
|
||||||
"artifacts",
|
"artifacts",
|
||||||
"current-api-cut-diagnostics",
|
"current-api-cut-diagnostics",
|
||||||
@@ -644,8 +881,16 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
case "--filter":
|
case "--filter":
|
||||||
filter = NextValue();
|
filter = NextValue();
|
||||||
break;
|
break;
|
||||||
|
case "--exclude-filter":
|
||||||
|
excludeFilter = NextValue();
|
||||||
|
break;
|
||||||
case "--no-send":
|
case "--no-send":
|
||||||
simulateSend = false;
|
simulateSend = false;
|
||||||
|
liveSend = false;
|
||||||
|
break;
|
||||||
|
case "--live-send":
|
||||||
|
simulateSend = true;
|
||||||
|
liveSend = true;
|
||||||
break;
|
break;
|
||||||
case "--send-limit":
|
case "--send-limit":
|
||||||
if (int.TryParse(NextValue(), out var parsedSendLimit))
|
if (int.TryParse(NextValue(), out var parsedSendLimit))
|
||||||
@@ -654,7 +899,7 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "--image-root":
|
case "--image-root":
|
||||||
imageRootPath = TornadoPathResolver.NormalizeConfiguredPath(NextValue());
|
_ = NextValue();
|
||||||
break;
|
break;
|
||||||
case "--output":
|
case "--output":
|
||||||
outputPath = NextValue();
|
outputPath = NextValue();
|
||||||
@@ -675,9 +920,11 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
IncludeVideoWall = includeVideoWall,
|
IncludeVideoWall = includeVideoWall,
|
||||||
TemplateLimit = templateLimit,
|
TemplateLimit = templateLimit,
|
||||||
Filter = filter,
|
Filter = filter,
|
||||||
|
ExcludeFilter = excludeFilter,
|
||||||
SimulateSend = simulateSend,
|
SimulateSend = simulateSend,
|
||||||
|
LiveSend = liveSend,
|
||||||
SendLimit = sendLimit,
|
SendLimit = sendLimit,
|
||||||
ImageRootPath = imageRootPath,
|
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
|
||||||
OutputPath = outputPath,
|
OutputPath = outputPath,
|
||||||
DefaultElectionType = defaultElectionType
|
DefaultElectionType = defaultElectionType
|
||||||
};
|
};
|
||||||
@@ -751,5 +998,25 @@ internal static class CurrentApiCutDiagnostics
|
|||||||
Detail = "no matching schedule regions"
|
Detail = "no matching schedule regions"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static CurrentApiCutDiagnosticResult DistrictLoadFailed(
|
||||||
|
BroadcastStationProfile station,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
BroadcastPhase phase,
|
||||||
|
string electionType,
|
||||||
|
string detail)
|
||||||
|
{
|
||||||
|
return new CurrentApiCutDiagnosticResult
|
||||||
|
{
|
||||||
|
Station = station.Id,
|
||||||
|
Channel = template.RecommendedChannel.ToString(),
|
||||||
|
TemplateId = template.Id,
|
||||||
|
TemplateName = template.Name,
|
||||||
|
Phase = phase.ToString(),
|
||||||
|
ElectionType = electionType,
|
||||||
|
Status = "api-or-send-failed",
|
||||||
|
Detail = detail
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,10 @@
|
|||||||
<Compile Include="..\..\Tornado3_2026Election\Services\CutAppearancePolicyCatalog.cs" Link="AppSource\Services\CutAppearancePolicyCatalog.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\CutAppearancePolicyCatalog.cs" Link="AppSource\Services\CutAppearancePolicyCatalog.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\FormatCatalogService.cs" Link="AppSource\Services\FormatCatalogService.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\FormatCatalogService.cs" Link="AppSource\Services\FormatCatalogService.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
|
||||||
|
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
|
||||||
|
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolver.cs" Link="AppSource\Services\KarismaSceneResolver.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolver.cs" Link="AppSource\Services\KarismaSceneResolver.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneVariableCatalog.cs" Link="AppSource\Services\KarismaSceneVariableCatalog.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneVariableCatalog.cs" Link="AppSource\Services\KarismaSceneVariableCatalog.cs" />
|
||||||
@@ -52,6 +54,8 @@
|
|||||||
<Compile Include="..\..\Tornado3_2026Election\Services\LogService.cs" Link="AppSource\Services\LogService.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\LogService.cs" Link="AppSource\Services\LogService.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\MockTornado3Adapter.cs" Link="AppSource\Services\MockTornado3Adapter.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\MockTornado3Adapter.cs" Link="AppSource\Services\MockTornado3Adapter.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\PartyColorCatalog.cs" Link="AppSource\Services\PartyColorCatalog.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\PartyColorCatalog.cs" Link="AppSource\Services\PartyColorCatalog.cs" />
|
||||||
|
<Compile Include="..\..\Tornado3_2026Election\Services\PreElectionHistoryService.cs" Link="AppSource\Services\PreElectionHistoryService.cs" />
|
||||||
|
<Compile Include="..\..\Tornado3_2026Election\Services\ScheduleTemplatePolicy.cs" Link="AppSource\Services\ScheduleTemplatePolicy.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\SbsElectionApiClient.cs" Link="AppSource\Services\SbsElectionApiClient.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\SbsElectionApiClient.cs" Link="AppSource\Services\SbsElectionApiClient.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\StationCatalogService.cs" Link="AppSource\Services\StationCatalogService.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\StationCatalogService.cs" Link="AppSource\Services\StationCatalogService.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoManager.cs" Link="AppSource\Services\TornadoManager.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoManager.cs" Link="AppSource\Services\TornadoManager.cs" />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal static class LiveCutValidation
|
|||||||
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
|
Console.WriteLine($"- Image Root: {options.ImageRootPath}");
|
||||||
Console.WriteLine($"- Output: {options.OutputPath}");
|
Console.WriteLine($"- Output: {options.OutputPath}");
|
||||||
Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
|
Console.WriteLine($"- Include VideoWall: {(options.IncludeVideoWall ? "yes" : "no")}");
|
||||||
|
Console.WriteLine($"- Capture Mode: {options.CaptureMode}");
|
||||||
|
|
||||||
var logService = new LogService();
|
var logService = new LogService();
|
||||||
var cutDebugStateStore = new CutDebugStateStore();
|
var cutDebugStateStore = new CutDebugStateStore();
|
||||||
@@ -70,6 +71,9 @@ internal static class LiveCutValidation
|
|||||||
CutName = item.Cut.Name,
|
CutName = item.Cut.Name,
|
||||||
Channel = item.Template.RecommendedChannel.ToString(),
|
Channel = item.Template.RecommendedChannel.ToString(),
|
||||||
Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(),
|
Phase = preElection ? BroadcastPhase.PreElection.ToString() : BroadcastPhase.Counting.ToString(),
|
||||||
|
CaptureMode = options.CaptureMode.ToString(),
|
||||||
|
CaptureComparable = options.CaptureMode == LiveCutCaptureMode.Scene ||
|
||||||
|
(pgmWindow is not null && item.Template.RecommendedChannel != BroadcastChannel.VideoWall),
|
||||||
OutputVisibleInPgm = pgmWindow is not null &&
|
OutputVisibleInPgm = pgmWindow is not null &&
|
||||||
item.Template.RecommendedChannel != BroadcastChannel.VideoWall
|
item.Template.RecommendedChannel != BroadcastChannel.VideoWall
|
||||||
};
|
};
|
||||||
@@ -81,8 +85,22 @@ internal static class LiveCutValidation
|
|||||||
await OutAllAsync(adapter).ConfigureAwait(false);
|
await OutAllAsync(adapter).ConfigureAwait(false);
|
||||||
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
await Task.Delay(options.BetweenDelayMs).ConfigureAwait(false);
|
||||||
|
|
||||||
var snapshotA = CreateSnapshot(item.Template.Name, index, variant: 0, preElection, options.SwapTopTwoCandidates);
|
var snapshotA = CreateSnapshot(
|
||||||
var snapshotB = CreateSnapshot(item.Template.Name, index, variant: 1, preElection, options.SwapTopTwoCandidates);
|
item.Template.Name,
|
||||||
|
index,
|
||||||
|
variant: 0,
|
||||||
|
preElection,
|
||||||
|
options.SwapTopTwoCandidates,
|
||||||
|
options.CycleTopThreeCandidates,
|
||||||
|
options.StressTopRankValues);
|
||||||
|
var snapshotB = CreateSnapshot(
|
||||||
|
item.Template.Name,
|
||||||
|
index,
|
||||||
|
variant: 1,
|
||||||
|
preElection,
|
||||||
|
options.SwapTopTwoCandidates,
|
||||||
|
options.CycleTopThreeCandidates,
|
||||||
|
options.StressTopRankValues);
|
||||||
|
|
||||||
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotA, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
||||||
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -90,7 +108,14 @@ internal static class LiveCutValidation
|
|||||||
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
|
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
|
||||||
|
|
||||||
result.CaptureAPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_A.png");
|
result.CaptureAPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_A.png");
|
||||||
result.HashA = CapturePgm(pgmWindow, result.CaptureAPath, !result.OutputVisibleInPgm);
|
result.HashA = await CaptureValidationImageAsync(
|
||||||
|
adapter,
|
||||||
|
pgmWindow,
|
||||||
|
item,
|
||||||
|
options,
|
||||||
|
result.CaptureAPath,
|
||||||
|
result.OutputVisibleInPgm,
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
await adapter.ApplyCutAsync(item.Template.RecommendedChannel, item.Template, item.Cut, snapshotB, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
|
||||||
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
await adapter.PrepareAsync(item.Template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -98,11 +123,18 @@ internal static class LiveCutValidation
|
|||||||
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
|
await Task.Delay(ResolveTemplateOnAirDelayMs(item.Template, options.OnAirDelayMs)).ConfigureAwait(false);
|
||||||
|
|
||||||
result.CaptureBPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_B.png");
|
result.CaptureBPath = Path.Combine(options.OutputPath, $"{index + 1:000}_{SanitizeFileName(item.Template.Name)}_B.png");
|
||||||
result.HashB = CapturePgm(pgmWindow, result.CaptureBPath, !result.OutputVisibleInPgm);
|
result.HashB = await CaptureValidationImageAsync(
|
||||||
|
adapter,
|
||||||
|
pgmWindow,
|
||||||
|
item,
|
||||||
|
options,
|
||||||
|
result.CaptureBPath,
|
||||||
|
result.OutputVisibleInPgm,
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
|
result.VisualChanged = !string.Equals(result.HashA, result.HashB, StringComparison.OrdinalIgnoreCase);
|
||||||
result.Success = true;
|
result.Success = true;
|
||||||
result.Detail = result.OutputVisibleInPgm
|
result.Detail = result.CaptureComparable
|
||||||
? (result.VisualChanged ? "A/B capture changed" : "A/B capture hash identical")
|
? (result.VisualChanged ? $"{options.CaptureMode} A/B capture changed" : $"{options.CaptureMode} A/B capture hash identical")
|
||||||
: "VideoWall output is not visible in the current PGM window";
|
: "VideoWall output is not visible in the current PGM window";
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -145,7 +177,7 @@ internal static class LiveCutValidation
|
|||||||
|
|
||||||
var successCount = results.Count(result => result.Success);
|
var successCount = results.Count(result => result.Success);
|
||||||
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
||||||
var unchangedCount = results.Count(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged);
|
var unchangedCount = results.Count(result => result.Success && result.CaptureComparable && !result.VisualChanged);
|
||||||
var failureCount = results.Count(result => !result.Success);
|
var failureCount = results.Count(result => !result.Success);
|
||||||
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
@@ -753,6 +785,7 @@ internal static class LiveCutValidation
|
|||||||
var value when value.StartsWith("개표율", StringComparison.OrdinalIgnoreCase) => 3,
|
var value when value.StartsWith("개표율", StringComparison.OrdinalIgnoreCase) => 3,
|
||||||
var value when value.StartsWith("선거구명", StringComparison.OrdinalIgnoreCase) => 4,
|
var value when value.StartsWith("선거구명", StringComparison.OrdinalIgnoreCase) => 4,
|
||||||
var value when value.StartsWith("시도명", StringComparison.OrdinalIgnoreCase) => 4,
|
var value when value.StartsWith("시도명", StringComparison.OrdinalIgnoreCase) => 4,
|
||||||
|
var value when value.StartsWith("의석수", StringComparison.OrdinalIgnoreCase) => 5,
|
||||||
var value when value.StartsWith("득표수", StringComparison.OrdinalIgnoreCase) => 5,
|
var value when value.StartsWith("득표수", StringComparison.OrdinalIgnoreCase) => 5,
|
||||||
var value when value.StartsWith("정당명", StringComparison.OrdinalIgnoreCase) => 6,
|
var value when value.StartsWith("정당명", StringComparison.OrdinalIgnoreCase) => 6,
|
||||||
var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7,
|
var value when value.StartsWith("유확당", StringComparison.OrdinalIgnoreCase) => 7,
|
||||||
@@ -898,7 +931,7 @@ internal static class LiveCutValidation
|
|||||||
yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common");
|
yield return new CutDebugItemDescriptor($"{prefix}01", CutDebugItemKind.TextValue, "common");
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
|
foreach (var prefix in new[] { "순위", "기호", "기호텍스트", "후보명", "정당명", "의석수", "득표수", "득표율", "표차", "득표차", "선거구명", "시도명", "개표율", "투표율" })
|
||||||
{
|
{
|
||||||
for (var slot = 1; slot <= slotCount; slot++)
|
for (var slot = 1; slot <= slotCount; slot++)
|
||||||
{
|
{
|
||||||
@@ -945,6 +978,11 @@ internal static class LiveCutValidation
|
|||||||
|
|
||||||
private static int ResolveDebugSlotCount(FormatTemplateDefinition template)
|
private static int ResolveDebugSlotCount(FormatTemplateDefinition template)
|
||||||
{
|
{
|
||||||
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||||
|
{
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
var source = $"{template.Name} {template.Id}";
|
var source = $"{template.Name} {template.Id}";
|
||||||
var topRankMatch = System.Text.RegularExpressions.Regex.Match(source, @"1-(\d+)위");
|
var topRankMatch = System.Text.RegularExpressions.Regex.Match(source, @"1-(\d+)위");
|
||||||
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlots))
|
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlots))
|
||||||
@@ -1349,7 +1387,14 @@ internal static class LiveCutValidation
|
|||||||
templateName.StartsWith("사전_", StringComparison.Ordinal);
|
templateName.StartsWith("사전_", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ElectionDataSnapshot CreateSnapshot(string templateName, int index, int variant, bool preElection, bool swapTopTwoCandidates)
|
private static ElectionDataSnapshot CreateSnapshot(
|
||||||
|
string templateName,
|
||||||
|
int index,
|
||||||
|
int variant,
|
||||||
|
bool preElection,
|
||||||
|
bool swapTopTwoCandidates,
|
||||||
|
bool cycleTopThreeCandidates = false,
|
||||||
|
bool stressTopRankValues = false)
|
||||||
{
|
{
|
||||||
var metadata = BuildScenarioMetadata(templateName, index, variant);
|
var metadata = BuildScenarioMetadata(templateName, index, variant);
|
||||||
return new ElectionDataSnapshot
|
return new ElectionDataSnapshot
|
||||||
@@ -1360,7 +1405,9 @@ internal static class LiveCutValidation
|
|||||||
DistrictCode = metadata.DistrictCode,
|
DistrictCode = metadata.DistrictCode,
|
||||||
RegionName = metadata.RegionName,
|
RegionName = metadata.RegionName,
|
||||||
ElectionDistrictName = metadata.ElectionDistrictName,
|
ElectionDistrictName = metadata.ElectionDistrictName,
|
||||||
Candidates = preElection ? Array.Empty<CandidateEntry>() : CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates),
|
Candidates = preElection
|
||||||
|
? Array.Empty<CandidateEntry>()
|
||||||
|
: CreateCandidates(templateName, metadata, variant, swapTopTwoCandidates, cycleTopThreeCandidates, stressTopRankValues),
|
||||||
TotalExpectedVotes = metadata.TotalExpectedVotes,
|
TotalExpectedVotes = metadata.TotalExpectedVotes,
|
||||||
TurnoutVotes = metadata.TurnoutVotes,
|
TurnoutVotes = metadata.TurnoutVotes,
|
||||||
CountedVotesFromApi = metadata.CountedVotes,
|
CountedVotesFromApi = metadata.CountedVotes,
|
||||||
@@ -1374,11 +1421,27 @@ internal static class LiveCutValidation
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<CandidateEntry> CreateCandidates(string templateName, ScenarioMetadata metadata, int variant, bool swapTopTwoCandidates)
|
private static IReadOnlyList<CandidateEntry> CreateCandidates(
|
||||||
|
string templateName,
|
||||||
|
ScenarioMetadata metadata,
|
||||||
|
int variant,
|
||||||
|
bool swapTopTwoCandidates,
|
||||||
|
bool cycleTopThreeCandidates,
|
||||||
|
bool stressTopRankValues)
|
||||||
{
|
{
|
||||||
|
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(templateName))
|
||||||
|
{
|
||||||
|
return CreateCouncilSeatCandidates(templateName, metadata, variant, stressTopRankValues);
|
||||||
|
}
|
||||||
|
|
||||||
var candidateNames = ResolveCandidateNames(templateName);
|
var candidateNames = ResolveCandidateNames(templateName);
|
||||||
var parties = ResolveParties(candidateNames.Length);
|
var parties = ResolveParties(candidateNames.Length);
|
||||||
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
|
var shares = ResolveVoteShares(templateName, candidateNames.Length, variant);
|
||||||
|
if (stressTopRankValues && variant % 2 == 1)
|
||||||
|
{
|
||||||
|
shares = StressTopRankShares(shares);
|
||||||
|
}
|
||||||
|
|
||||||
var automaticJudgement = ResolveAutomaticJudgement(templateName);
|
var automaticJudgement = ResolveAutomaticJudgement(templateName);
|
||||||
var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray();
|
var identityOrder = Enumerable.Range(0, candidateNames.Length).ToArray();
|
||||||
|
|
||||||
@@ -1387,6 +1450,11 @@ internal static class LiveCutValidation
|
|||||||
(identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]);
|
(identityOrder[0], identityOrder[1]) = (identityOrder[1], identityOrder[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cycleTopThreeCandidates && variant % 2 == 1)
|
||||||
|
{
|
||||||
|
CycleTopCandidateIdentities(identityOrder);
|
||||||
|
}
|
||||||
|
|
||||||
var candidates = new List<CandidateEntry>(candidateNames.Length);
|
var candidates = new List<CandidateEntry>(candidateNames.Length);
|
||||||
for (var index = 0; index < candidateNames.Length; index++)
|
for (var index = 0; index < candidateNames.Length; index++)
|
||||||
{
|
{
|
||||||
@@ -1408,6 +1476,99 @@ internal static class LiveCutValidation
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CandidateEntry> CreateCouncilSeatCandidates(
|
||||||
|
string templateName,
|
||||||
|
ScenarioMetadata metadata,
|
||||||
|
int variant,
|
||||||
|
bool stressTopRankValues)
|
||||||
|
{
|
||||||
|
var parties = ResolveParties(6);
|
||||||
|
var totalSeats = ResolveCouncilSeatPattern(templateName, variant, stressTopRankValues)
|
||||||
|
.Take(parties.Length)
|
||||||
|
.ToArray();
|
||||||
|
var proportionalSeats = ResolveCouncilSeatProportionalPattern(templateName, variant, stressTopRankValues)
|
||||||
|
.Take(parties.Length)
|
||||||
|
.ToArray();
|
||||||
|
var candidates = new List<CandidateEntry>(parties.Length * 2);
|
||||||
|
|
||||||
|
for (var partyIndex = 0; partyIndex < parties.Length; partyIndex++)
|
||||||
|
{
|
||||||
|
var party = parties[partyIndex];
|
||||||
|
var proportionalCount = Math.Min(totalSeats[partyIndex], proportionalSeats.ElementAtOrDefault(partyIndex));
|
||||||
|
var districtCount = Math.Max(0, totalSeats[partyIndex] - proportionalCount);
|
||||||
|
candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:D:{partyIndex + 1:00}", party, partyIndex, districtCount));
|
||||||
|
|
||||||
|
if (proportionalCount > 0)
|
||||||
|
{
|
||||||
|
candidates.Add(CreateCouncilSeatSummaryCandidate($"SEAT:P:{partyIndex + 1:00}", party, partyIndex, proportionalCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CandidateEntry CreateCouncilSeatSummaryCandidate(string candidateCode, string party, int partyIndex, int seatCount)
|
||||||
|
{
|
||||||
|
return new CandidateEntry
|
||||||
|
{
|
||||||
|
CandidateCode = candidateCode,
|
||||||
|
BallotNumber = (partyIndex + 1).ToString(),
|
||||||
|
Name = party,
|
||||||
|
Party = party,
|
||||||
|
ColorParty = party,
|
||||||
|
VoteRate = seatCount,
|
||||||
|
VoteCount = seatCount,
|
||||||
|
HasImage = false,
|
||||||
|
ManualJudgement = CandidateJudgement.None,
|
||||||
|
AutomaticJudgement = CandidateJudgement.Elected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] ResolveCouncilSeatPattern(string templateName, int variant, bool stressTopRankValues)
|
||||||
|
{
|
||||||
|
if (templateName.Contains("기초의원", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return stressTopRankValues && variant % 2 == 1
|
||||||
|
? [34, 21, 13, 7, 3, 1]
|
||||||
|
: [29, 25, 9, 5, 2, 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return stressTopRankValues && variant % 2 == 1
|
||||||
|
? [22, 17, 9, 4, 2, 1]
|
||||||
|
: [18, 15, 6, 3, 1, 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] ResolveCouncilSeatProportionalPattern(string templateName, int variant, bool stressTopRankValues)
|
||||||
|
{
|
||||||
|
if (templateName.Contains("기초의원", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return stressTopRankValues && variant % 2 == 1
|
||||||
|
? [5, 3, 2, 1, 0, 0]
|
||||||
|
: [4, 3, 1, 0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return stressTopRankValues && variant % 2 == 1
|
||||||
|
? [4, 3, 2, 1, 0, 0]
|
||||||
|
: [3, 3, 1, 0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CycleTopCandidateIdentities(int[] identityOrder)
|
||||||
|
{
|
||||||
|
var topCount = Math.Min(3, identityOrder.Length);
|
||||||
|
if (topCount < 2)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = identityOrder[0];
|
||||||
|
for (var index = 0; index < topCount - 1; index++)
|
||||||
|
{
|
||||||
|
identityOrder[index] = identityOrder[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
identityOrder[topCount - 1] = first;
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
|
private static IReadOnlyList<PreElectionHistoricalTurnoutEntry> CreateHistoricalTurnout(ScenarioMetadata metadata, int variant)
|
||||||
{
|
{
|
||||||
// The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn.
|
// The historical turnout scenes currently ship with a fixed line/marker drawing in the source tscn.
|
||||||
@@ -1549,6 +1710,36 @@ internal static class LiveCutValidation
|
|||||||
return shares;
|
return shares;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static double[] StressTopRankShares(double[] shares)
|
||||||
|
{
|
||||||
|
var stressed = shares.ToArray();
|
||||||
|
if (stressed.Length >= 3)
|
||||||
|
{
|
||||||
|
var trailingTotal = Math.Round(stressed.Skip(3).Sum(), 1, MidpointRounding.AwayFromZero);
|
||||||
|
var topTotal = Math.Max(0d, 100d - trailingTotal);
|
||||||
|
stressed[0] = Math.Round(topTotal * 0.46d, 1, MidpointRounding.AwayFromZero);
|
||||||
|
stressed[1] = Math.Round(topTotal * 0.32d, 1, MidpointRounding.AwayFromZero);
|
||||||
|
stressed[2] = Math.Round(topTotal - stressed[0] - stressed[1], 1, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
else if (stressed.Length == 2)
|
||||||
|
{
|
||||||
|
stressed[0] = 62.7d;
|
||||||
|
stressed[1] = 37.3d;
|
||||||
|
}
|
||||||
|
else if (stressed.Length == 1)
|
||||||
|
{
|
||||||
|
stressed[0] = 99.9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delta = Math.Round(100d - stressed.Sum(), 1, MidpointRounding.AwayFromZero);
|
||||||
|
if (stressed.Length > 0)
|
||||||
|
{
|
||||||
|
stressed[0] = Math.Round(stressed[0] + delta, 1, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stressed;
|
||||||
|
}
|
||||||
|
|
||||||
private static CandidateJudgement ResolveAutomaticJudgement(string templateName)
|
private static CandidateJudgement ResolveAutomaticJudgement(string templateName)
|
||||||
{
|
{
|
||||||
if (templateName.Contains("당선", StringComparison.Ordinal))
|
if (templateName.Contains("당선", StringComparison.Ordinal))
|
||||||
@@ -1599,6 +1790,11 @@ internal static class LiveCutValidation
|
|||||||
return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
return new ScenarioMetadata("광역의원", "44001", "충남 제1선거구", "충남", "충남도의원 제1선거구", totalExpectedVotes / 3, turnoutVotes / 3, countedVotes / 3, countedRate, 55.4 + (seed % 3) + (variant * 1.6), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (templateName.Contains("보궐선거", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new ScenarioMetadata("국회의원", "2411502", "평택 을", "경기", "평택 을", totalExpectedVotes / 2, turnoutVotes / 2, countedVotes / 2, countedRate, 56.5 + (seed % 4) + (variant * 1.5), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||||
|
}
|
||||||
|
|
||||||
return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
return new ScenarioMetadata("광역단체장", "44", "충청남도", "충남", "충남도지사", totalExpectedVotes, turnoutVotes, countedVotes, countedRate, 57.3 + (seed % 5) + (variant * 1.8), DateTimeOffset.Now.AddMinutes(seed + variant), seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1615,6 +1811,57 @@ internal static class LiveCutValidation
|
|||||||
return ComputeSha256(outputPath);
|
return ComputeSha256(outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<string> CaptureValidationImageAsync(
|
||||||
|
ITornado3Adapter adapter,
|
||||||
|
PgmWindow? pgmWindow,
|
||||||
|
LiveCutWorkItem item,
|
||||||
|
LiveCutValidationOptions options,
|
||||||
|
string outputPath,
|
||||||
|
bool outputVisibleInPgm,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (options.CaptureMode == LiveCutCaptureMode.Pgm)
|
||||||
|
{
|
||||||
|
return CapturePgm(pgmWindow, outputPath, !outputVisibleInPgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adapter is not KarismaTornado3Adapter karismaAdapter)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Scene capture mode requires the Karisma adapter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (width, height) = ResolveSceneCaptureSize(item.Template);
|
||||||
|
await karismaAdapter.SavePendingSceneImageAsync(
|
||||||
|
item.Template.RecommendedChannel,
|
||||||
|
outputPath,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
frame: -1,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ComputeSha256(outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
var sourceWidth = template.SceneWidth.GetValueOrDefault(1920);
|
||||||
|
var sourceHeight = template.SceneHeight.GetValueOrDefault(1080);
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0)
|
||||||
|
{
|
||||||
|
return (1280, 720);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int maxWidth = 1280;
|
||||||
|
if (sourceWidth <= maxWidth)
|
||||||
|
{
|
||||||
|
return (sourceWidth, sourceHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
var scale = maxWidth / (double)sourceWidth;
|
||||||
|
return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero)));
|
||||||
|
}
|
||||||
|
|
||||||
private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds)
|
private static Bitmap CaptureWindowBitmap(IntPtr handle, Rect bounds)
|
||||||
{
|
{
|
||||||
var width = Math.Max(1, bounds.Width);
|
var width = Math.Max(1, bounds.Width);
|
||||||
@@ -1657,7 +1904,7 @@ internal static class LiveCutValidation
|
|||||||
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
|
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
|
||||||
|
|
||||||
var csv = new StringBuilder();
|
var csv = new StringBuilder();
|
||||||
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
|
csv.AppendLine("Index,TemplateId,TemplateName,CutName,Channel,Phase,CaptureMode,CaptureComparable,Success,VisualChanged,OutputVisibleInPgm,CaptureAPath,CaptureBPath,HashA,HashB,Detail");
|
||||||
foreach (var result in results)
|
foreach (var result in results)
|
||||||
{
|
{
|
||||||
csv.AppendLine(string.Join(",",
|
csv.AppendLine(string.Join(",",
|
||||||
@@ -1667,6 +1914,8 @@ internal static class LiveCutValidation
|
|||||||
Csv(result.CutName),
|
Csv(result.CutName),
|
||||||
Csv(result.Channel),
|
Csv(result.Channel),
|
||||||
Csv(result.Phase),
|
Csv(result.Phase),
|
||||||
|
Csv(result.CaptureMode),
|
||||||
|
Csv(result.CaptureComparable.ToString()),
|
||||||
Csv(result.Success.ToString()),
|
Csv(result.Success.ToString()),
|
||||||
Csv(result.VisualChanged.ToString()),
|
Csv(result.VisualChanged.ToString()),
|
||||||
Csv(result.OutputVisibleInPgm.ToString()),
|
Csv(result.OutputVisibleInPgm.ToString()),
|
||||||
@@ -1682,7 +1931,7 @@ internal static class LiveCutValidation
|
|||||||
|
|
||||||
var successCount = results.Count(result => result.Success);
|
var successCount = results.Count(result => result.Success);
|
||||||
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
var changedCount = results.Count(result => result.Success && result.VisualChanged);
|
||||||
var unchanged = results.Where(result => result.Success && result.OutputVisibleInPgm && !result.VisualChanged).ToList();
|
var unchanged = results.Where(result => result.Success && result.CaptureComparable && !result.VisualChanged).ToList();
|
||||||
var failures = results.Where(result => !result.Success).ToList();
|
var failures = results.Where(result => !result.Success).ToList();
|
||||||
|
|
||||||
var summary = new StringBuilder();
|
var summary = new StringBuilder();
|
||||||
@@ -1691,6 +1940,7 @@ internal static class LiveCutValidation
|
|||||||
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
|
summary.AppendLine($"- Run At: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}");
|
||||||
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
|
summary.AppendLine($"- Image Root: {options.ImageRootPath}");
|
||||||
summary.AppendLine($"- Output: {options.OutputPath}");
|
summary.AppendLine($"- Output: {options.OutputPath}");
|
||||||
|
summary.AppendLine($"- Capture Mode: {options.CaptureMode}");
|
||||||
summary.AppendLine($"- Success: {successCount}/{results.Count}");
|
summary.AppendLine($"- Success: {successCount}/{results.Count}");
|
||||||
summary.AppendLine($"- Visual Changed: {changedCount}");
|
summary.AppendLine($"- Visual Changed: {changedCount}");
|
||||||
summary.AppendLine($"- Unchanged Captures: {unchanged.Count}");
|
summary.AppendLine($"- Unchanged Captures: {unchanged.Count}");
|
||||||
@@ -1820,6 +2070,9 @@ internal static class LiveCutValidation
|
|||||||
public int BetweenDelayMs { get; init; } = 250;
|
public int BetweenDelayMs { get; init; } = 250;
|
||||||
public bool IncludeVideoWall { get; init; }
|
public bool IncludeVideoWall { get; init; }
|
||||||
public bool SwapTopTwoCandidates { get; init; }
|
public bool SwapTopTwoCandidates { get; init; }
|
||||||
|
public LiveCutCaptureMode CaptureMode { get; init; } = LiveCutCaptureMode.Pgm;
|
||||||
|
public bool CycleTopThreeCandidates { get; init; }
|
||||||
|
public bool StressTopRankValues { get; init; }
|
||||||
|
|
||||||
public static LiveCutValidationOptions Parse(string[] args)
|
public static LiveCutValidationOptions Parse(string[] args)
|
||||||
{
|
{
|
||||||
@@ -1836,7 +2089,7 @@ internal static class LiveCutValidation
|
|||||||
switch (args[index])
|
switch (args[index])
|
||||||
{
|
{
|
||||||
case "--image-root":
|
case "--image-root":
|
||||||
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
|
_ = RequireValue(args, ref index, "--image-root");
|
||||||
break;
|
break;
|
||||||
case "--output":
|
case "--output":
|
||||||
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
||||||
@@ -1859,6 +2112,15 @@ internal static class LiveCutValidation
|
|||||||
case "--swap-top-two":
|
case "--swap-top-two":
|
||||||
options = options with { SwapTopTwoCandidates = true };
|
options = options with { SwapTopTwoCandidates = true };
|
||||||
break;
|
break;
|
||||||
|
case "--cycle-top-three":
|
||||||
|
options = options with { CycleTopThreeCandidates = true };
|
||||||
|
break;
|
||||||
|
case "--stress-top-ranks":
|
||||||
|
options = options with { StressTopRankValues = true };
|
||||||
|
break;
|
||||||
|
case "--capture-mode":
|
||||||
|
options = options with { CaptureMode = ParseCaptureMode(RequireValue(args, ref index, "--capture-mode")) };
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentException($"Unknown option: {args[index]}");
|
throw new ArgumentException($"Unknown option: {args[index]}");
|
||||||
}
|
}
|
||||||
@@ -1866,7 +2128,7 @@ internal static class LiveCutValidation
|
|||||||
|
|
||||||
return options with
|
return options with
|
||||||
{
|
{
|
||||||
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
|
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
|
||||||
OutputPath = Path.GetFullPath(options.OutputPath),
|
OutputPath = Path.GetFullPath(options.OutputPath),
|
||||||
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
|
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
|
||||||
};
|
};
|
||||||
@@ -1882,6 +2144,16 @@ internal static class LiveCutValidation
|
|||||||
|
|
||||||
return args[index];
|
return args[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static LiveCutCaptureMode ParseCaptureMode(string raw)
|
||||||
|
{
|
||||||
|
return raw.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"pgm" or "window" => LiveCutCaptureMode.Pgm,
|
||||||
|
"scene" or "save-scene-image" => LiveCutCaptureMode.Scene,
|
||||||
|
_ => throw new ArgumentException($"Unknown capture mode: {raw}")
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record CutDebugCoverageOptions
|
private sealed record CutDebugCoverageOptions
|
||||||
@@ -1908,7 +2180,7 @@ internal static class LiveCutValidation
|
|||||||
switch (args[index])
|
switch (args[index])
|
||||||
{
|
{
|
||||||
case "--image-root":
|
case "--image-root":
|
||||||
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
|
_ = RequireValue(args, ref index, "--image-root");
|
||||||
break;
|
break;
|
||||||
case "--output":
|
case "--output":
|
||||||
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
||||||
@@ -1935,7 +2207,7 @@ internal static class LiveCutValidation
|
|||||||
|
|
||||||
return options with
|
return options with
|
||||||
{
|
{
|
||||||
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
|
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
|
||||||
OutputPath = Path.GetFullPath(options.OutputPath)
|
OutputPath = Path.GetFullPath(options.OutputPath)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1985,7 +2257,7 @@ internal static class LiveCutValidation
|
|||||||
switch (args[index])
|
switch (args[index])
|
||||||
{
|
{
|
||||||
case "--image-root":
|
case "--image-root":
|
||||||
options = options with { ImageRootPath = RequireValue(args, ref index, "--image-root") };
|
_ = RequireValue(args, ref index, "--image-root");
|
||||||
break;
|
break;
|
||||||
case "--output":
|
case "--output":
|
||||||
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
options = options with { OutputPath = RequireValue(args, ref index, "--output") };
|
||||||
@@ -2036,7 +2308,7 @@ internal static class LiveCutValidation
|
|||||||
|
|
||||||
return options with
|
return options with
|
||||||
{
|
{
|
||||||
ImageRootPath = Path.GetFullPath(options.ImageRootPath),
|
ImageRootPath = Path.GetFullPath(TornadoPathResolver.GetDefaultT3CutPath()),
|
||||||
OutputPath = Path.GetFullPath(options.OutputPath),
|
OutputPath = Path.GetFullPath(options.OutputPath),
|
||||||
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
|
StationLogoPath = Path.GetFullPath(options.StationLogoPath)
|
||||||
};
|
};
|
||||||
@@ -2087,6 +2359,12 @@ internal static class LiveCutValidation
|
|||||||
Replace
|
Replace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum LiveCutCaptureMode
|
||||||
|
{
|
||||||
|
Pgm,
|
||||||
|
Scene
|
||||||
|
}
|
||||||
|
|
||||||
private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);
|
private readonly record struct CutDebugReplacementAssets(string MagentaPath, string CyanPath);
|
||||||
|
|
||||||
private sealed class CutDebugSweepResult
|
private sealed class CutDebugSweepResult
|
||||||
@@ -2163,6 +2441,8 @@ internal static class LiveCutValidation
|
|||||||
public string CutName { get; init; } = string.Empty;
|
public string CutName { get; init; } = string.Empty;
|
||||||
public string Channel { get; init; } = string.Empty;
|
public string Channel { get; init; } = string.Empty;
|
||||||
public string Phase { get; init; } = string.Empty;
|
public string Phase { get; init; } = string.Empty;
|
||||||
|
public string CaptureMode { get; set; } = string.Empty;
|
||||||
|
public bool CaptureComparable { get; set; }
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public bool VisualChanged { get; set; }
|
public bool VisualChanged { get; set; }
|
||||||
public bool OutputVisibleInPgm { get; set; }
|
public bool OutputVisibleInPgm { get; set; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Text.Json;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using KAsyncEngineLib;
|
using KAsyncEngineLib;
|
||||||
|
using Tornado3_2026Election.Services;
|
||||||
|
|
||||||
if (args.Length > 0 && string.Equals(args[0], "--reflect-api", StringComparison.OrdinalIgnoreCase))
|
if (args.Length > 0 && string.Equals(args[0], "--reflect-api", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -737,6 +738,70 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.CloneObject is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[SAVE-IMAGE] Adding clone source={options.CloneObject.SourceObjectName} " +
|
||||||
|
$"variable={options.CloneObject.VariableName}...");
|
||||||
|
var sceneObject = scene.GetObject(options.CloneObject.SourceObjectName);
|
||||||
|
if (sceneObject is null)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.CloneObject.SourceObjectName}' was not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ResetAddCloneObjectTask();
|
||||||
|
scene.AddCloneObject(sceneObject, options.CloneObject.VariableName);
|
||||||
|
|
||||||
|
if (!WaitForTaskWithMessagePump(handler.AddCloneObjectTask, options.Connection.Timeout))
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnAddCloneObject timed out for '{options.CloneObject.SourceObjectName}'." ));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloneResult = handler.AddCloneObjectTask.Result;
|
||||||
|
if (cloneResult != eKResult.RESULT_SUCCESS)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", cloneResult.ToString(), options.OutputPath, $"OnAddCloneObject result={cloneResult} source={options.CloneObject.SourceObjectName} variable={options.CloneObject.VariableName}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.VariableName is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[SAVE-IMAGE] Setting variable name object={options.VariableName.ObjectName} " +
|
||||||
|
$"value={options.VariableName.VariableName}...");
|
||||||
|
var sceneObject = scene.GetObject(options.VariableName.ObjectName);
|
||||||
|
if (sceneObject is null)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.VariableName.ObjectName}' was not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ResetVariableNameTask();
|
||||||
|
sceneObject.SetVariableName(options.VariableName.VariableName);
|
||||||
|
|
||||||
|
if (!WaitForTaskWithMessagePump(handler.VariableNameTask, options.Connection.Timeout))
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetVariableName timed out for '{options.VariableName.ObjectName}'." ));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var variableNameResult = handler.VariableNameTask.Result;
|
||||||
|
if (variableNameResult != eKResult.RESULT_SUCCESS)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", variableNameResult.ToString(), options.OutputPath, $"OnSetVariableName result={variableNameResult} object={options.VariableName.ObjectName}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.SetObjectName))
|
if (!string.IsNullOrWhiteSpace(options.SetObjectName))
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[SAVE-IMAGE] Setting value object={options.SetObjectName}...");
|
Console.WriteLine($"[SAVE-IMAGE] Setting value object={options.SetObjectName}...");
|
||||||
@@ -797,6 +862,46 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.MaterialOpacity is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[SAVE-IMAGE] Setting material opacity object={options.MaterialOpacity.ObjectName} " +
|
||||||
|
$"value={options.MaterialOpacity.Opacity}...");
|
||||||
|
var sceneObject = scene.GetObject(options.MaterialOpacity.ObjectName);
|
||||||
|
if (sceneObject is null)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.MaterialOpacity.ObjectName}' was not found."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var material = sceneObject.GetTargetMaterial(eKMaterialTarget.MATERIAL_TARGET_DEFAULT);
|
||||||
|
if (material is not IKAMaterial targetMaterial)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.MaterialOpacity.ObjectName}' did not return a material."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ResetMaterialOpacityTask();
|
||||||
|
targetMaterial.SetTransparencyOpacity(options.MaterialOpacity.Opacity);
|
||||||
|
|
||||||
|
if (!WaitForTaskWithMessagePump(handler.MaterialOpacityTask, options.Connection.Timeout))
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetTransparencyOpacity timed out for '{options.MaterialOpacity.ObjectName}'." ));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opacityResult = handler.MaterialOpacityTask.Result;
|
||||||
|
if (opacityResult != eKResult.RESULT_SUCCESS)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(
|
||||||
|
new SaveSceneImageProbeResult(true, "SUCCESS", opacityResult.ToString(), options.OutputPath, $"OnSetTransparencyOpacity result={opacityResult} object={options.MaterialOpacity.ObjectName}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.Size is not null)
|
if (options.Size is not null)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
@@ -828,30 +933,37 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var positionUpdates = new List<PositionUpdate>();
|
||||||
if (options.Position is not null)
|
if (options.Position is not null)
|
||||||
|
{
|
||||||
|
positionUpdates.Add(options.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
positionUpdates.AddRange(options.Positions);
|
||||||
|
foreach (var positionUpdate in positionUpdates)
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"[SAVE-IMAGE] Setting position object={options.Position.ObjectName} " +
|
$"[SAVE-IMAGE] Setting position object={positionUpdate.ObjectName} " +
|
||||||
$"value=({options.Position.X},{options.Position.Y},{options.Position.Z}) vector={options.Position.VectorType}...");
|
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
|
||||||
var sceneObject = scene.GetObject(options.Position.ObjectName);
|
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
|
||||||
if (sceneObject is null)
|
if (sceneObject is null)
|
||||||
{
|
{
|
||||||
completion.TrySetResult(
|
completion.TrySetResult(
|
||||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.Position.ObjectName}' was not found."));
|
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.ResetPositionTask();
|
handler.ResetPositionTask();
|
||||||
sceneObject.SetPosition(
|
sceneObject.SetPosition(
|
||||||
options.Position.X,
|
positionUpdate.X,
|
||||||
options.Position.Y,
|
positionUpdate.Y,
|
||||||
options.Position.Z,
|
positionUpdate.Z,
|
||||||
options.Position.VectorType);
|
positionUpdate.VectorType);
|
||||||
|
|
||||||
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
|
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
|
||||||
{
|
{
|
||||||
completion.TrySetResult(
|
completion.TrySetResult(
|
||||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{options.Position.ObjectName}'." ));
|
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{positionUpdate.ObjectName}'." ));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,7 +971,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
|||||||
if (positionResult != eKResult.RESULT_SUCCESS)
|
if (positionResult != eKResult.RESULT_SUCCESS)
|
||||||
{
|
{
|
||||||
completion.TrySetResult(
|
completion.TrySetResult(
|
||||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={options.Position.ObjectName}"));
|
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3279,8 +3391,12 @@ internal sealed record SaveSceneImageOptions(
|
|||||||
string? SetObjectValue,
|
string? SetObjectValue,
|
||||||
string? VisibleObjectName,
|
string? VisibleObjectName,
|
||||||
bool? VisibleObjectValue,
|
bool? VisibleObjectValue,
|
||||||
|
VariableNameUpdate? VariableName,
|
||||||
|
CloneObjectUpdate? CloneObject,
|
||||||
|
MaterialOpacityUpdate? MaterialOpacity,
|
||||||
SizeUpdate? Size,
|
SizeUpdate? Size,
|
||||||
PositionUpdate? Position,
|
PositionUpdate? Position,
|
||||||
|
IReadOnlyList<PositionUpdate> Positions,
|
||||||
PositionKeyUpdate? PositionKey,
|
PositionKeyUpdate? PositionKey,
|
||||||
string? ChartObjectName,
|
string? ChartObjectName,
|
||||||
string? ChartCsvPath,
|
string? ChartCsvPath,
|
||||||
@@ -3299,10 +3415,17 @@ internal sealed record SaveSceneImageOptions(
|
|||||||
string? setObjectValue = null;
|
string? setObjectValue = null;
|
||||||
string? visibleObjectName = null;
|
string? visibleObjectName = null;
|
||||||
bool? visibleObjectValue = null;
|
bool? visibleObjectValue = null;
|
||||||
|
string? variableNameObjectName = null;
|
||||||
|
string? variableNameValue = null;
|
||||||
|
string? cloneSourceObjectName = null;
|
||||||
|
string? cloneVariableName = null;
|
||||||
|
string? materialOpacityObjectName = null;
|
||||||
|
float? materialOpacityValue = null;
|
||||||
string? sizeObjectName = null;
|
string? sizeObjectName = null;
|
||||||
string? sizeRaw = null;
|
string? sizeRaw = null;
|
||||||
string? positionObjectName = null;
|
string? positionObjectName = null;
|
||||||
string? positionRaw = null;
|
string? positionRaw = null;
|
||||||
|
string? positionsRaw = null;
|
||||||
string? positionKeyObjectName = null;
|
string? positionKeyObjectName = null;
|
||||||
int positionKeyIndex = 1;
|
int positionKeyIndex = 1;
|
||||||
string? positionKeyRaw = null;
|
string? positionKeyRaw = null;
|
||||||
@@ -3346,6 +3469,25 @@ internal sealed record SaveSceneImageOptions(
|
|||||||
_ => throw new ArgumentException("--visible must be true/false/1/0.")
|
_ => throw new ArgumentException("--visible must be true/false/1/0.")
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case "--variable-name-object" when index + 1 < args.Length:
|
||||||
|
variableNameObjectName = args[++index];
|
||||||
|
break;
|
||||||
|
case "--variable-name" when index + 1 < args.Length:
|
||||||
|
variableNameValue = args[++index];
|
||||||
|
break;
|
||||||
|
case "--clone-source" when index + 1 < args.Length:
|
||||||
|
cloneSourceObjectName = args[++index];
|
||||||
|
break;
|
||||||
|
case "--clone-name" when index + 1 < args.Length:
|
||||||
|
cloneVariableName = args[++index];
|
||||||
|
break;
|
||||||
|
case "--material-opacity-object" when index + 1 < args.Length:
|
||||||
|
materialOpacityObjectName = args[++index];
|
||||||
|
break;
|
||||||
|
case "--material-opacity" when index + 1 < args.Length && float.TryParse(args[index + 1], out var parsedMaterialOpacity):
|
||||||
|
materialOpacityValue = parsedMaterialOpacity;
|
||||||
|
index++;
|
||||||
|
break;
|
||||||
case "--size-object" when index + 1 < args.Length:
|
case "--size-object" when index + 1 < args.Length:
|
||||||
sizeObjectName = args[++index];
|
sizeObjectName = args[++index];
|
||||||
break;
|
break;
|
||||||
@@ -3358,6 +3500,9 @@ internal sealed record SaveSceneImageOptions(
|
|||||||
case "--position" when index + 1 < args.Length:
|
case "--position" when index + 1 < args.Length:
|
||||||
positionRaw = args[++index];
|
positionRaw = args[++index];
|
||||||
break;
|
break;
|
||||||
|
case "--positions" when index + 1 < args.Length:
|
||||||
|
positionsRaw = args[++index];
|
||||||
|
break;
|
||||||
case "--position-key-object" when index + 1 < args.Length:
|
case "--position-key-object" when index + 1 < args.Length:
|
||||||
positionKeyObjectName = args[++index];
|
positionKeyObjectName = args[++index];
|
||||||
break;
|
break;
|
||||||
@@ -3426,8 +3571,12 @@ internal sealed record SaveSceneImageOptions(
|
|||||||
setObjectValue,
|
setObjectValue,
|
||||||
visibleObjectName,
|
visibleObjectName,
|
||||||
visibleObjectValue,
|
visibleObjectValue,
|
||||||
|
ParseVariableName(variableNameObjectName, variableNameValue),
|
||||||
|
ParseCloneObject(cloneSourceObjectName, cloneVariableName),
|
||||||
|
ParseMaterialOpacity(materialOpacityObjectName, materialOpacityValue),
|
||||||
ParseSize(sizeObjectName, sizeRaw),
|
ParseSize(sizeObjectName, sizeRaw),
|
||||||
ParsePosition(positionObjectName, positionRaw),
|
ParsePosition(positionObjectName, positionRaw),
|
||||||
|
ParsePositions(positionsRaw),
|
||||||
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
|
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
|
||||||
chartObjectName,
|
chartObjectName,
|
||||||
chartCsvPath,
|
chartCsvPath,
|
||||||
@@ -3437,6 +3586,36 @@ internal sealed record SaveSceneImageOptions(
|
|||||||
ParsePathModifications(modifyPathRaw));
|
ParsePathModifications(modifyPathRaw));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CloneObjectUpdate(sourceObjectName, variableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MaterialOpacityUpdate? ParseMaterialOpacity(string? objectName, float? opacity)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(objectName) || !opacity.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MaterialOpacityUpdate(objectName, opacity.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VariableNameUpdate? ParseVariableName(string? objectName, string? variableName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(variableName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VariableNameUpdate(objectName, variableName);
|
||||||
|
}
|
||||||
|
|
||||||
private static SizeUpdate? ParseSize(string? objectName, string? raw)
|
private static SizeUpdate? ParseSize(string? objectName, string? raw)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
|
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
|
||||||
@@ -3487,6 +3666,32 @@ internal sealed record SaveSceneImageOptions(
|
|||||||
return new PositionUpdate(objectName, x, y, z, vectorType);
|
return new PositionUpdate(objectName, x, y, z, vectorType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PositionUpdate> ParsePositions(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return Array.Empty<PositionUpdate>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates = new List<PositionUpdate>();
|
||||||
|
foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
var nameParts = token.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (nameParts.Length != 2)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid position update: {token}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var update = ParsePosition(nameParts[0], nameParts[1]);
|
||||||
|
if (update is not null)
|
||||||
|
{
|
||||||
|
updates.Add(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
private static PositionKeyUpdate? ParsePositionKey(string? objectName, int keyIndex, string? raw)
|
private static PositionKeyUpdate? ParsePositionKey(string? objectName, int keyIndex, string? raw)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
|
if (string.IsNullOrWhiteSpace(objectName) || string.IsNullOrWhiteSpace(raw))
|
||||||
@@ -3642,7 +3847,7 @@ internal sealed record SceneCatalogOptions(
|
|||||||
public static SceneCatalogOptions Parse(string[] args)
|
public static SceneCatalogOptions Parse(string[] args)
|
||||||
{
|
{
|
||||||
var connection = ProbeOptions.Parse(args);
|
var connection = ProbeOptions.Parse(args);
|
||||||
string? rootPath = null;
|
var rootPath = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
string? outputPath = null;
|
string? outputPath = null;
|
||||||
string? sceneFilter = null;
|
string? sceneFilter = null;
|
||||||
int? maxScenes = null;
|
int? maxScenes = null;
|
||||||
@@ -3652,7 +3857,7 @@ internal sealed record SceneCatalogOptions(
|
|||||||
switch (args[index])
|
switch (args[index])
|
||||||
{
|
{
|
||||||
case "--root" when index + 1 < args.Length:
|
case "--root" when index + 1 < args.Length:
|
||||||
rootPath = args[++index];
|
index++;
|
||||||
break;
|
break;
|
||||||
case "--output" when index + 1 < args.Length:
|
case "--output" when index + 1 < args.Length:
|
||||||
outputPath = args[++index];
|
outputPath = args[++index];
|
||||||
@@ -3667,12 +3872,6 @@ internal sealed record SceneCatalogOptions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootPath ??= Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
|
||||||
"Tornado3 Data",
|
|
||||||
"T3_Cut",
|
|
||||||
"T3_Cut");
|
|
||||||
|
|
||||||
if (!Directory.Exists(rootPath))
|
if (!Directory.Exists(rootPath))
|
||||||
{
|
{
|
||||||
throw new DirectoryNotFoundException($"Catalog root path does not exist: {rootPath}");
|
throw new DirectoryNotFoundException($"Catalog root path does not exist: {rootPath}");
|
||||||
@@ -3986,7 +4185,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
|
|||||||
public static FolderInspectionOptions Parse(string[] args)
|
public static FolderInspectionOptions Parse(string[] args)
|
||||||
{
|
{
|
||||||
var connection = ProbeOptions.Parse(args);
|
var connection = ProbeOptions.Parse(args);
|
||||||
string? rootPath = null;
|
var rootPath = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
string? outputPath = null;
|
string? outputPath = null;
|
||||||
string? sceneFilter = null;
|
string? sceneFilter = null;
|
||||||
int? maxScenes = null;
|
int? maxScenes = null;
|
||||||
@@ -3996,7 +4195,7 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
|
|||||||
switch (args[index])
|
switch (args[index])
|
||||||
{
|
{
|
||||||
case "--root" when index + 1 < args.Length:
|
case "--root" when index + 1 < args.Length:
|
||||||
rootPath = args[++index];
|
index++;
|
||||||
break;
|
break;
|
||||||
case "--output" when index + 1 < args.Length:
|
case "--output" when index + 1 < args.Length:
|
||||||
outputPath = args[++index];
|
outputPath = args[++index];
|
||||||
@@ -4011,11 +4210,6 @@ internal sealed record FolderInspectionOptions(ProbeOptions Connection, string R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rootPath))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("--root is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
rootPath = Path.GetFullPath(rootPath);
|
rootPath = Path.GetFullPath(rootPath);
|
||||||
outputPath ??= Path.Combine(Environment.CurrentDirectory, "TSCN_VARIABLE_DISCOVERY.md");
|
outputPath ??= Path.Combine(Environment.CurrentDirectory, "TSCN_VARIABLE_DISCOVERY.md");
|
||||||
outputPath = Path.GetFullPath(outputPath);
|
outputPath = Path.GetFullPath(outputPath);
|
||||||
@@ -4029,6 +4223,9 @@ internal sealed record ChartCellSnapshot(int Row, int Column, string Value);
|
|||||||
internal sealed record ChartCellUpdate(int Row, int Column, float Value);
|
internal sealed record ChartCellUpdate(int Row, int Column, float Value);
|
||||||
internal sealed record PathPoint3(float X, float Y, float Z);
|
internal sealed record PathPoint3(float X, float Y, float Z);
|
||||||
internal sealed record SizeUpdate(string ObjectName, float Width, float Height);
|
internal sealed record SizeUpdate(string ObjectName, float Width, float Height);
|
||||||
|
internal sealed record VariableNameUpdate(string ObjectName, string VariableName);
|
||||||
|
internal sealed record CloneObjectUpdate(string SourceObjectName, string VariableName);
|
||||||
|
internal sealed record MaterialOpacityUpdate(string ObjectName, float Opacity);
|
||||||
internal sealed record PositionUpdate(string ObjectName, float X, float Y, float Z, eKVectorType VectorType);
|
internal sealed record PositionUpdate(string ObjectName, float X, float Y, float Z, eKVectorType VectorType);
|
||||||
internal sealed record PositionKeyUpdate(string ObjectName, int KeyIndex, float X, float Y, float Z, eKVectorType VectorType);
|
internal sealed record PositionKeyUpdate(string ObjectName, int KeyIndex, float X, float Y, float Z, eKVectorType VectorType);
|
||||||
internal sealed record PathPointModification(int Index, float X, float Y, float Z, eKVectorType VectorType);
|
internal sealed record PathPointModification(int Index, float X, float Y, float Z, eKVectorType VectorType);
|
||||||
@@ -4176,6 +4373,9 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
|||||||
private TaskCompletionSource<eKResult> _counterNumberKeyTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private TaskCompletionSource<eKResult> _counterNumberKeyTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private TaskCompletionSource<eKResult> _styleColorTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private TaskCompletionSource<eKResult> _styleColorTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private TaskCompletionSource<eKResult> _visibleTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private TaskCompletionSource<eKResult> _visibleTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private TaskCompletionSource<eKResult> _variableNameTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private TaskCompletionSource<eKResult> _addCloneObjectTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private TaskCompletionSource<eKResult> _materialOpacityTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private TaskCompletionSource<eKResult> _setValueTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private TaskCompletionSource<eKResult> _setValueTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private TaskCompletionSource<eKResult> _sizeTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private TaskCompletionSource<eKResult> _sizeTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
private TaskCompletionSource<eKResult> _saveSceneImageTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private TaskCompletionSource<eKResult> _saveSceneImageTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -4203,6 +4403,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
|||||||
|
|
||||||
public Task<eKResult> VisibleTask => _visibleTask.Task;
|
public Task<eKResult> VisibleTask => _visibleTask.Task;
|
||||||
|
|
||||||
|
public Task<eKResult> VariableNameTask => _variableNameTask.Task;
|
||||||
|
|
||||||
|
public Task<eKResult> AddCloneObjectTask => _addCloneObjectTask.Task;
|
||||||
|
|
||||||
|
public Task<eKResult> MaterialOpacityTask => _materialOpacityTask.Task;
|
||||||
|
|
||||||
public Task<eKResult> SetValueTask => _setValueTask.Task;
|
public Task<eKResult> SetValueTask => _setValueTask.Task;
|
||||||
|
|
||||||
public Task<eKResult> SizeTask => _sizeTask.Task;
|
public Task<eKResult> SizeTask => _sizeTask.Task;
|
||||||
@@ -4235,6 +4441,12 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
|||||||
|
|
||||||
public void ResetVisibleTask() => _visibleTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
public void ResetVisibleTask() => _visibleTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public void ResetVariableNameTask() => _variableNameTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public void ResetAddCloneObjectTask() => _addCloneObjectTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public void ResetMaterialOpacityTask() => _materialOpacityTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
public void ResetSetValueTask() => _setValueTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
public void ResetSetValueTask() => _setValueTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
public void ResetSizeTask() => _sizeTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
public void ResetSizeTask() => _sizeTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -4363,7 +4575,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
|||||||
public void OnResetDuration(eKResult Result, string SceneName) { }
|
public void OnResetDuration(eKResult Result, string SceneName) { }
|
||||||
public void OnSetDuration(eKResult Result, string SceneName) { }
|
public void OnSetDuration(eKResult Result, string SceneName) { }
|
||||||
public void OnAddObject(eKResult Result, string SceneName) { }
|
public void OnAddObject(eKResult Result, string SceneName) { }
|
||||||
public void OnAddCloneObject(eKResult Result, string SceneName) { }
|
|
||||||
public void OnUpdateThumbnail(eKResult Result, string SceneName) { }
|
public void OnUpdateThumbnail(eKResult Result, string SceneName) { }
|
||||||
public void OnExportVideo(eKResult Result, string SceneName) { }
|
public void OnExportVideo(eKResult Result, string SceneName) { }
|
||||||
public void OnStopVideoExporting(eKResult Result) { }
|
public void OnStopVideoExporting(eKResult Result) { }
|
||||||
@@ -4462,6 +4673,21 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
|||||||
|
|
||||||
_visibleTask.TrySetResult(Result);
|
_visibleTask.TrySetResult(Result);
|
||||||
}
|
}
|
||||||
|
public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[SDK] OnSetVariableName result={Result} scene={SceneName} object={ObjectName}");
|
||||||
|
_variableNameTask.TrySetResult(Result);
|
||||||
|
}
|
||||||
|
public void OnAddCloneObject(eKResult Result, string SceneName)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[SDK] OnAddCloneObject result={Result} scene={SceneName}");
|
||||||
|
_addCloneObjectTask.TrySetResult(Result);
|
||||||
|
}
|
||||||
|
public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[SDK] OnSetTransparencyOpacity result={Result} scene={SceneName} object={ObjectName}");
|
||||||
|
_materialOpacityTask.TrySetResult(Result);
|
||||||
|
}
|
||||||
public void OnSetValue(eKResult Result, string SceneName, string ObjectName)
|
public void OnSetValue(eKResult Result, string SceneName, string ObjectName)
|
||||||
{
|
{
|
||||||
if (Result != eKResult.RESULT_ERROR_NO_VARIABLE_OBJECT)
|
if (Result != eKResult.RESULT_ERROR_NO_VARIABLE_OBJECT)
|
||||||
@@ -4573,7 +4799,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
|||||||
public void OnAddScrollObject(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnAddScrollObject(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnAdjustScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnAdjustScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName) { }
|
|
||||||
public void OnSetLoftPositionKey(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetLoftPositionKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetChangeOut(eKResult Result, string SceneName) { }
|
public void OnSetChangeOut(eKResult Result, string SceneName) { }
|
||||||
public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName)
|
public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName)
|
||||||
@@ -4596,7 +4821,6 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
|||||||
public void OnSetColorKey(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetColorKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetEmissiveColor(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetEmissiveColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetEmissiveColorKey(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetEmissiveColorKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName) { }
|
|
||||||
public void OnSetTransparencyOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetTransparencyOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetExposure(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetExposure(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
public void OnSetExposureKey(eKResult Result, string SceneName, string ObjectName) { }
|
public void OnSetExposureKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||||
|
|||||||
Reference in New Issue
Block a user