26.04.17 작업 진행 사항
155
CURRENT_IMPLEMENTATION_STATUS_2026-04-17.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 2026-04-17 구현 현황 정리
|
||||
|
||||
이 문서는 2026-04-17 현재 기준으로 Tornado3 2026 Election 프로젝트의 주요 구현 사항을 정리한 현행화 문서이다.
|
||||
기존 메모와 검증 산출물이 여러 파일에 흩어져 있으므로, 실제 운영에 필요한 상태만 이 문서에서 한 번에 확인할 수 있게 정리했다.
|
||||
|
||||
## 1. 데이터 화면 현행 상태
|
||||
|
||||
- 데이터 화면에서 원본 JSON 전체를 보여주는 "데이터 시트"는 제거했다.
|
||||
- 현재 데이터 화면은 아래 두 시트만 유지한다.
|
||||
- 개표율 시트
|
||||
- 후보 시트
|
||||
- 선거구명 콤보박스 첫 항목에 `전체보기`를 추가했다.
|
||||
- `전체보기` 선택 시, 개표 단계에서 지역별 개표율을 작은 카드 목록으로 표시한다.
|
||||
- 카드 형식: `지역명 - 개표율`
|
||||
- 지원 위치: 개표 단계에서 지원되는 선거 종류
|
||||
- 광역단체장 데이터는 지역명 대신 실제 직책명이 보이도록 보정했다.
|
||||
- 예: `부산광역시` -> `부산시장`
|
||||
- 예: `경기도` -> `경기도지사`
|
||||
|
||||
## 2. 개표 데이터 표시 규칙
|
||||
|
||||
- 후보 데이터는 개표 데이터 기준으로 처리한다.
|
||||
- 개표율 표시는 숫자만 넣지 않고 라벨을 포함한 형식으로 통일했다.
|
||||
- 형식: `개표 99.9%`
|
||||
- 광역단체장 컷의 `시도명` 계열 변수는 광역 지명 대신 직책명으로 송출한다.
|
||||
- 예: `시도명01 = 부산시장`
|
||||
- 후보 슬롯 수는 씬 변수 카탈로그 기준으로 제한한다.
|
||||
- 2인 컷에는 `후보명03`, `유확당03` 같은 없는 변수를 보내지 않는다.
|
||||
- 3인 컷은 3번 슬롯까지 반영한다.
|
||||
|
||||
## 3. 후보 판정 코드 반영 상태
|
||||
|
||||
SBS API 판정 코드는 아래와 같이 반영한다.
|
||||
|
||||
- `40`: 유력
|
||||
- `50`: 확정
|
||||
- `60`: 개표중 당선
|
||||
- `80`: 무투표 당선
|
||||
- `90`: 개표마감 당선
|
||||
|
||||
추가 메모:
|
||||
|
||||
- `80` 무투표 당선은 실제 발생 가능한 값으로 간주하고 별도 예외 없이 반영한다.
|
||||
- 수동 판정이 있으면 수동 판정을 우선하고, 없으면 API 판정값을 사용한다.
|
||||
|
||||
## 4. 유확당 변수 처리 규칙
|
||||
|
||||
`유확당` 계열 변수는 현재 아래 규칙으로 공통 처리한다.
|
||||
|
||||
1. 해당 씬에 존재하는 `유확당*` 변수를 먼저 전부 `visible=false`로 숨긴다.
|
||||
2. 실제 판정 이미지 경로가 있는 슬롯만 `SetValue(...)`로 값을 적용한다.
|
||||
3. 값이 들어간 슬롯만 다시 `visible=true`로 켠다.
|
||||
|
||||
적용 범위:
|
||||
|
||||
- 1인 / 2인 / 3인 후보 컷 공통
|
||||
- `당선`, `이시각1위`, `접전`, `초접전`, `모든후보`, `1-2위`, `1-3위` 계열 포함
|
||||
|
||||
검증 결과:
|
||||
|
||||
- 최신 씬 스캔 기준 `유확당` 변수를 사용하는 컷은 총 69개다.
|
||||
- 전체 스캔 리포트: `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
- 실동작 검증 리포트: `LIVE_VALIDATE_1-2위_ani_광역단체장_judgement_visibility.md`
|
||||
|
||||
## 5. 정당 색상 / RGB 매핑 상태
|
||||
|
||||
정당 색상은 `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\RGB\` 폴더 기준으로 매핑한다.
|
||||
|
||||
현재 규칙:
|
||||
|
||||
- RGB txt에 `style > ... > color`가 지정된 항목은 이미지 교체보다 `SetStyleColor(...)`를 우선 적용한다.
|
||||
- 대표 적용 섹션:
|
||||
- 정당판
|
||||
- 정당바
|
||||
- 득표율
|
||||
- 정당명
|
||||
- 기타 style color 지정 섹션
|
||||
- RGB txt에 style color 지정이 없는 항목만 기존 asset `SetValue(...)` 경로를 사용한다.
|
||||
|
||||
특히 `1-2위_ani_광역단체장.txt` 기준으로 아래가 반영되도록 디버깅했다.
|
||||
|
||||
- `정당판`: `style > face > color`
|
||||
- `득표율`: `style > edge > color`
|
||||
- `정당바`: `style > face > color`
|
||||
|
||||
관련 문서:
|
||||
|
||||
- 매핑 문서: `RGB_SPEC_CUT_MAPPING.md`
|
||||
- 스타일 검증 리포트: `LIVE_VALIDATE_1-2위_ani_광역단체장_style.md`
|
||||
|
||||
## 6. 설정 저장 / 자동 갱신 현행 상태
|
||||
|
||||
- API 자동 갱신 기본값은 `60초`다.
|
||||
- 설정 변경 시 별도 저장 버튼 없이 자동 저장되도록 구성했다.
|
||||
- 자동 저장 대상에는 아래 값이 포함된다.
|
||||
- API 자동 갱신 ON/OFF
|
||||
- 갱신 주기
|
||||
- 데이터 관련 주요 선택 상태
|
||||
- 앱 상태 복원에 필요한 주요 설정
|
||||
|
||||
관련 구현 포인트:
|
||||
|
||||
- 기본값: `Tornado3_2026Election/Persistence/AppState.cs`
|
||||
- 자동 저장 루프: `Tornado3_2026Election/ViewModels/MainViewModel.cs`
|
||||
|
||||
## 7. 씬 변수 카탈로그 현행화
|
||||
|
||||
씬 변수 카탈로그는 현재 최신 스캔 결과를 우선 사용한다.
|
||||
|
||||
우선순위:
|
||||
|
||||
1. `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
2. `TSCN_VARIABLE_DISCOVERY_E_DRIVE.md`
|
||||
3. `TSCN_VARIABLE_DISCOVERY.md`
|
||||
4. 동일 패턴의 최신 `TSCN_VARIABLE_DISCOVERY*.md`
|
||||
|
||||
추가 조치:
|
||||
|
||||
- 위 카탈로그 파일들은 앱 출력 폴더와 `AppX` 폴더에도 함께 복사되도록 했다.
|
||||
- 실행본도 최신 씬 변수 정보를 그대로 사용한다.
|
||||
|
||||
최신 전체 스캔 결과:
|
||||
|
||||
- 전체 씬 수: 114
|
||||
- `유확당` 사용 컷 수: 69
|
||||
- 출력 리포트: `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
|
||||
## 8. 주요 검증 산출물
|
||||
|
||||
- `LIVE_VALIDATE_1-2위_ani_광역단체장.md`
|
||||
- 기본 변수 송출 검증
|
||||
- `LIVE_VALIDATE_1-2위_ani_광역단체장_labels.md`
|
||||
- `개표 99.9%` 형식, 광역장 직책명 검증
|
||||
- `LIVE_VALIDATE_1-2위_ani_광역단체장_style.md`
|
||||
- style color 적용 검증
|
||||
- `LIVE_VALIDATE_1-2위_ani_광역단체장_judgement_visibility.md`
|
||||
- `유확당` visible 처리 검증
|
||||
- `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
- Elect2026_Normal_민방 전체 씬 변수 스캔 결과
|
||||
|
||||
## 9. 현재 운영 기준 요약
|
||||
|
||||
- 데이터 화면은 `개표율 시트 + 후보 시트 + 전체보기 카드` 기준으로 본다.
|
||||
- 광역단체장 표기는 지역명이 아니라 직책명 기준으로 본다.
|
||||
- 개표율 문구는 항상 `개표 x.x%` 형식으로 보낸다.
|
||||
- 정당 색상은 RGB txt 기준이며, style color 지정이 있으면 이미지보다 style color를 우선한다.
|
||||
- `유확당`은 전 컷 공통으로 먼저 숨기고, 판정이 있는 슬롯만 다시 보이게 한다.
|
||||
- 설정은 변경 즉시 자동 저장되며, API 기본 갱신 주기는 60초다.
|
||||
|
||||
## 10. 후속 확인 권장 사항
|
||||
|
||||
- 실제 송출 중인 주요 컷에서 `유확당` 표식이 없는 슬롯이 숨겨지는지 최종 육안 확인
|
||||
- 2인 / 3인 컷 각각에서 존재하지 않는 슬롯 변수 송출이 없는지 로그 확인
|
||||
- 광역단체장 컷에서 `시도명`이 지역명이 아니라 직책명으로 나가는지 최종 확인
|
||||
- 무투표 당선(`80`) 케이스 수신 시 `유확당` asset과 visible 처리 확인
|
||||
177
INTEGRATION_NOTES_2026-04-15.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 2026-04-15 통합 작업 메모
|
||||
|
||||
## 0. 작업 요약
|
||||
|
||||
- SBS 선거 API를 기준으로 `광역단체장(3)`, `기초단체장(4)`, `교육감(11)` 실연동을 정리했다.
|
||||
- 선거 종류 변경 시 실제 SBS `선거구` 코드 기준으로 지역 목록과 요청 코드가 맞물리도록 보정했다.
|
||||
- `기초단체장` 지역 옵션 교체 중 발생하던 `ArgumentNullException (key)`를 ViewModel 방어 로직으로 수정했다.
|
||||
- 콤보박스 변경 후 값이 그대로 남아 있던 문제를 선택 변경 즉시 재조회 흐름으로 바꿨다.
|
||||
- `System.Text.Json.JsonException`은 region API 응답이 최상위 배열(`[...]`)인 경우를 허용하도록 수정했다.
|
||||
- 미지원 조합에서는 이전 값이 계속 보이지 않도록 상태 문구와 보드 표시 규칙을 보정했다.
|
||||
|
||||
## 1. 선거종류(Sunger Types)별 점검 결과
|
||||
|
||||
### `3` 시도지사 / 광역단체장
|
||||
|
||||
- 선거구 목록: `sungerInfo/region?type=선거구&sungerType=3`
|
||||
- 개표 요청: `gaepyo/3/sungergus?ids=<선거구 id>`
|
||||
- 사전 투표율 요청: `tupyo/3/sidos?ids=<시도 id>`
|
||||
- 부산 기준 확인:
|
||||
- 개표 코드: `3260000`
|
||||
- 사전 코드: `26`
|
||||
- 결론:
|
||||
- 개표 연동 정상
|
||||
- 사전 투표율 연동 정상
|
||||
|
||||
### `4` 시군구장 / 기초단체장
|
||||
|
||||
- 선거구 목록: `sungerInfo/region?type=선거구&sungerType=4`
|
||||
- 개표 요청: `gaepyo/4/sungergus?ids=<선거구 id>`
|
||||
- 부산 해운대구 기준 확인:
|
||||
- 개표 코드: `4260900`
|
||||
- 결론:
|
||||
- 개표 연동 정상
|
||||
- 사전 투표율은 SBS API가 `400` 반환으로 미지원
|
||||
|
||||
### `11` 교육감
|
||||
|
||||
- 선거구 목록: `sungerInfo/region?type=선거구&sungerType=11`
|
||||
- 개표 요청: `gaepyo/11/sungergus?ids=<선거구 id>`
|
||||
- 부산 기준 확인:
|
||||
- 개표 코드: `11260000`
|
||||
- 결론:
|
||||
- 개표 연동 정상
|
||||
- 사전 투표율은 SBS API가 `400` 반환으로 미지원
|
||||
|
||||
## 2. SBS 선거 API 실연동
|
||||
|
||||
- 기준 문서: `https://documenter.getpostman.com/view/39408930/2sBXqCNNyS`
|
||||
- 기준 호스트: `http://202.31.153.141:8421/`
|
||||
- API 응답 헤더에 charset이 없어서, 본문은 UTF-8 바이트 기준으로 직접 디코딩한다.
|
||||
|
||||
### 현재 지원 범위
|
||||
|
||||
- `광역단체장`
|
||||
- 사전 투표율: 지원
|
||||
- 개표: 지원
|
||||
- `교육감`
|
||||
- 사전 투표율: 미지원
|
||||
- 개표: 지원
|
||||
- `기초단체장`
|
||||
- 사전 투표율: 미지원
|
||||
- 개표: 지원
|
||||
|
||||
### 확인된 API 제한사항
|
||||
|
||||
- `tupyo/4/...` 계열은 현재 `기초단체장`에 대해 `400`을 반환한다.
|
||||
- `tupyo/11/...` 계열은 현재 `교육감`에 대해 `400`을 반환한다.
|
||||
- 위 두 경우는 앱 오류가 아니라 API 제공 범위 밖으로 본다.
|
||||
|
||||
## 3. 기초단체장 지역 선택 규칙
|
||||
|
||||
- 기초단체장 목록은 `시도 + 시군구/기초단체장명` 결합 문자열로 표시한다.
|
||||
- 예: `부산광역시 해운대구`
|
||||
- 실제 개표 요청은 SBS API의 `선거구 id`를 사용한다.
|
||||
- 예: `부산광역시 해운대구 -> 4260900`
|
||||
- 데이터 탭의 `지역 코드`는 현재 선택된 요청 코드(시도 코드 또는 선거구 코드)를 표시한다.
|
||||
- 기초단체장 선택 목록은 API `sungerInfo/region?type=선거구&sungerType=4` 기준으로 구성한다.
|
||||
|
||||
## 4. 데이터 탭 표시 기준
|
||||
|
||||
- 데이터 탭에는 기존 `선거구명`, `지역 코드` 외에 다음 정보를 함께 표시한다.
|
||||
- `시도명`
|
||||
- `송출 선거구명`
|
||||
- 데이터 탭은 원본 JSON 전문을 그대로 보여주는 용도는 아니다.
|
||||
- API 응답을 ViewModel용 값으로 정규화한 뒤 표시한다.
|
||||
- `DistrictName`은 선택/저장용 원본 문자열로 유지한다.
|
||||
- 송출용 분리 값은 별도 필드로 관리한다.
|
||||
- `RegionName`
|
||||
- `ElectionDistrictName`
|
||||
- Karisma/Tornado3 송출 변수 `시도명NN`, `선거구명NN`은 위 분리 값을 직접 사용한다.
|
||||
|
||||
## 5. Null-Key 예외 원인과 처리
|
||||
|
||||
- 원인:
|
||||
- `기초단체장`으로 전환하면서 `DistrictOptions`를 교체하는 순간,
|
||||
`ComboBox.SelectedValue`가 일시적으로 `null`을 보낼 수 있었다.
|
||||
- 이 값이 그대로 `Dictionary.TryGetValue(null, ...)` 경로로 들어가며
|
||||
`System.ArgumentNullException (Parameter 'key')`가 발생했다.
|
||||
|
||||
### 적용한 방어 규칙
|
||||
|
||||
- `DistrictName`, `DistrictCode`, `ElectionType`는 내부적으로 null이 아닌 문자열 상태를 유지한다.
|
||||
- 지역 옵션 교체 중에는 `_isUpdatingDistrictOptions` 플래그로 transient UI 값을 무시한다.
|
||||
- 옵션 교체 전의 `preferredName`, `preferredCode`를 캡처해 마지막 유효 선택값 기준으로 복원한다.
|
||||
|
||||
## 6. 변수 매핑 관련 기록
|
||||
|
||||
- Karisma 장면 변수 지원 범위 로깅을 추가했다.
|
||||
- 현재 컷에 없는 변수명은 경고로 확인 가능하다.
|
||||
- 다음 alias 매핑을 보강했다.
|
||||
- `기준시`, `기준시01`, `기준시02`
|
||||
- `유권자수`, `유권자수01`
|
||||
- `투표자수`, `투표자수01`
|
||||
- `득표수바NN`
|
||||
- `정당원NN`
|
||||
- `정당색NN`
|
||||
|
||||
### 아직 주의가 필요한 변수군
|
||||
|
||||
- 의석수 계열
|
||||
- 판세 지도/그래프 계열
|
||||
- 경력/공약 계열
|
||||
- 일부 사진/차트 전용 컷 변수
|
||||
|
||||
## 7. 콤보박스 변경 즉시 갱신
|
||||
|
||||
- `선거 종류`, `선거구명`, `지역 코드`, `방송 단계`가 바뀌면 짧은 debounce 후 자동으로 `RefreshAsync(...)`를 다시 호출한다.
|
||||
- 지역 옵션 재구성 중(`_isUpdatingDistrictOptions`)이나 API 결과 반영 중(`_isApplyingRefreshResult`)에는
|
||||
내부 setter 연쇄로 인한 재조회는 막는다.
|
||||
- 이 변경으로 콤보박스만 바뀌고 `후보현황`, `정보값`이 이전 조회 결과에 머물러 있던 문제를 줄인다.
|
||||
|
||||
## 8. JSON 응답 파싱 보정
|
||||
|
||||
- `sungerInfo/region...` 계열은 `{ value: [...] }`가 아니라 최상위 배열(`[...]`)로 오는 경우가 있다.
|
||||
- 클라이언트는 이제 `value` envelope와 최상위 배열을 모두 허용한다.
|
||||
- 광역단체장/교육감/기초단체장 지역 옵션도 SBS `선거구` 목록 기준으로 읽어서 실제 요청 코드와 맞춘다.
|
||||
- 선택 변경 debounce는 `CancellationTokenSource` 취소 대신 revision 비교로 바꿔
|
||||
디버그 출력에 반복 `TaskCanceledException`이 쌓이는 흐름을 줄였다.
|
||||
- 선거 종류 전환/상태 복원 도중 비동기 선거구 목록 갱신이 늦게 끝나더라도,
|
||||
마지막 현재 선택값 기준으로 다시 매칭하도록 보정했다.
|
||||
- 미지원 단계나 API 오류가 나면 `StatusText`에 마지막 경고 메시지를 남겨
|
||||
화면에서 이유를 바로 확인할 수 있게 했다.
|
||||
- 데이터 탭 상단에 `StatusText`를 노출해 마지막 갱신 상태/미지원 사유를 바로 볼 수 있게 했다.
|
||||
- 미지원 조합에서는 투표율/후보 보드를 숨기고 상태 문구만 보이도록 해
|
||||
이전 수신값을 현재 데이터로 오해하는 흐름을 줄였다.
|
||||
|
||||
## 9. 주요 변경 파일
|
||||
|
||||
- `Tornado3_2026Election/Services/SbsElectionApiClient.cs`
|
||||
- SBS API 요청/응답 처리
|
||||
- 선거 종류별 지역 옵션 구성
|
||||
- JSON 파싱 보정
|
||||
- `Tornado3_2026Election/ViewModels/DataViewModel.cs`
|
||||
- null-safe 지역 선택 처리
|
||||
- 선택 변경 즉시 갱신
|
||||
- 상태 문구 및 미지원 조합 표시
|
||||
- `Tornado3_2026Election/MainWindow.xaml`
|
||||
- 데이터 탭에 `시도명`, `송출 선거구명`, `StatusText` 표시
|
||||
- `Tornado3_2026Election/Services/KarismaTornado3Adapter.cs`
|
||||
- `RegionName`, `ElectionDistrictName` 기반 송출 변수 매핑
|
||||
- `Tornado3_2026Election/Domain/ElectionDataSnapshot.cs`
|
||||
- 송출용 분리 지역 필드 추가
|
||||
- `Tornado3_2026Election/Persistence/AppState.cs`
|
||||
- 실제 연동 기준 기본값 정리
|
||||
|
||||
## 10. 현재 상태 및 남은 체크포인트
|
||||
|
||||
- `dotnet build Tornado3_2026Election.slnx` 통과
|
||||
- 기존 경고는 유지
|
||||
- `WindowsBase` 참조 충돌 경고
|
||||
- MSIX 인증서 경고
|
||||
- `MockTornado3Adapter.ConnectionChanged` 미사용 경고
|
||||
- 코드상 연동/파싱/표시 흐름은 정리 완료
|
||||
- 남은 실운영 체크:
|
||||
- 앱 실행 상태에서 `광역단체장 -> 교육감 -> 기초단체장` 전환 확인
|
||||
- `개표 <-> 사전` 전환 시 상태 문구와 보드 표시 확인
|
||||
- 실제 송출 컷에서 `시도명NN`, `선거구명NN` 반영 확인
|
||||
14
LIVE_VALIDATE_1-2위_ani_광역단체장_judgement_visibility.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Scene Variable Validation
|
||||
|
||||
- Generated: 2026-04-17 00:08:19
|
||||
- Scene: `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
|
||||
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장_judgement_visibility.json`
|
||||
- Success Count: 4
|
||||
- Failure Count: 0
|
||||
|
||||
| Object | Method | Payload | Result | Detail |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 유확당01 | SetVisible | visible=False | RESULT_SUCCESS | |
|
||||
| 유확당01 | SetValue | E:\\김의연\\지역민방\\T3_Cut\\Elect2026_Normal_민방\\Images\\Tag\\당선.vrv | RESULT_SUCCESS | |
|
||||
| 유확당01 | SetVisible | visible=True | RESULT_SUCCESS | |
|
||||
| 유확당02 | SetVisible | visible=False | RESULT_SUCCESS | |
|
||||
12
LIVE_VALIDATE_1-2위_ani_광역단체장_labels.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Scene Variable Validation
|
||||
|
||||
- Generated: 2026-04-17 00:01:13
|
||||
- Scene: `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
|
||||
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장_labels.json`
|
||||
- Success Count: 2
|
||||
- Failure Count: 0
|
||||
|
||||
| Object | Method | Payload | Result | Detail |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 개표율01 | SetValue | 개표 98.7% | RESULT_SUCCESS | |
|
||||
| 시도명01 | SetValue | 부산시장 | RESULT_SUCCESS | |
|
||||
13
LIVE_VALIDATE_1-2위_ani_광역단체장_style.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Scene Variable Validation
|
||||
|
||||
- Generated: 2026-04-16 23:55:53
|
||||
- Scene: `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
|
||||
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장_style.json`
|
||||
- Success Count: 3
|
||||
- Failure Count: 0
|
||||
|
||||
| Object | Method | Payload | Result | Detail |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 정당판01 | SetStyleColor | styleType=face, order=0, rgba=(57, 84, 199, 255) | RESULT_SUCCESS | |
|
||||
| 정당바01 | SetStyleColor | styleType=face, order=0, rgba=(0, 30, 84, 255) | RESULT_SUCCESS | |
|
||||
| 득표율01 | SetStyleColor | styleType=edge, order=0, rgba=(57, 84, 199, 255) | RESULT_SUCCESS | |
|
||||
176
RGB_SPEC_CUT_MAPPING.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# RGB Spec Cut Mapping
|
||||
|
||||
- Updated: 2026-04-16
|
||||
- Source root:
|
||||
`E:\김의연\지역민방\T3_Cut`
|
||||
- Runtime priority:
|
||||
1. explicit cut-to-RGB mapping
|
||||
2. same-name RGB txt
|
||||
3. heuristic fallback by similar file name
|
||||
|
||||
## Runtime Rule
|
||||
|
||||
- RGB txt header가 `style > ... > color` 또는 `> ... > color` 형태면 해당 오브젝트는 이미지 `SetValue` 대신 `SetStyleColor`를 우선 적용
|
||||
- `face 2번째`처럼 순서가 명시된 경우 `SetStyleColor(..., order=1, ...)`로 적용
|
||||
- `정당바`: RGB spec section `정당바` 우선
|
||||
- `정당판`: RGB spec section `정당판` 우선
|
||||
- `정당원`: RGB spec section `정당원` 우선, 없으면 `정당색`, `정당판`, `정당바` 순
|
||||
- `정당색`: RGB spec section `정당색` 우선, 없으면 `정당바`, `정당판` 순
|
||||
- `그룹`: `그룹`이 없으면 `정당바`, `정당판` 순
|
||||
- 기존 png 자산이 있으면 그대로 사용
|
||||
- 기존 png가 없으면 `%LOCALAPPDATA%\Tornado3_2026Election\GeneratedPartyAssets` 아래에 RGB 기반 fallback png 생성
|
||||
|
||||
## Verified
|
||||
|
||||
- `1-2위_ani_광역단체장`
|
||||
- `정당판01` -> `SetStyleColor(face, order=0)`
|
||||
- `정당바01` -> `SetStyleColor(face, order=0)`
|
||||
- `득표율01` -> `SetStyleColor(edge, order=0)`
|
||||
- 검증 로그: `LIVE_VALIDATE_1-2위_ani_광역단체장_style.md`
|
||||
|
||||
## Implemented Mapping
|
||||
|
||||
### Normal
|
||||
|
||||
| Template | RGB spec file | Note |
|
||||
| --- | --- | --- |
|
||||
| `1-2위_ani_광역단체장` | `1-2위_ani_광역단체장.txt` | exact |
|
||||
| `1-2위_ani_기초단체장` | `1-2위_ani_기초단체장.txt` | exact |
|
||||
| `1-2위_ani_기초단체장_5760` | `1-2위_ani_기초단체장.txt` | shared |
|
||||
| `1-2위_ani_기초단체장_L` | `1-2위_ani_기초단체장.txt` | shared |
|
||||
| `1-2위_광역단체장` | `1-2위_광역단체장, 보궐.txt` | shared |
|
||||
| `1-2위_광역단체장_5760` | `1-2위_광역단체장, 보궐.txt` | shared |
|
||||
| `1-2위_광역단체장_L` | `1-2위_광역단체장, 보궐.txt` | shared |
|
||||
| `1-2위_광역단체장_시도별영상` | `1-2위_광역단체장,기초단체장_시도별영상.txt` | exact family |
|
||||
| `1-2위_교육감` | `1-2위_교육감.txt` | exact |
|
||||
| `1-2위_기초단체장` | `1-2위_기초단체장.txt` | exact |
|
||||
| `1-2위_기초단체장_시도별영상` | `1-2위_광역단체장,기초단체장_시도별영상.txt` | exact family |
|
||||
| `1-2위_보궐선거` | `1-2위_광역단체장, 보궐.txt` | inferred |
|
||||
| `1-3위_ani_광역단체장` | `1-3위_ani_광역단체장,보궐.txt` | exact family |
|
||||
| `1-3위_보궐선거` | `1-3위_ani_광역단체장,보궐.txt` | inferred |
|
||||
| `1-3위_ani_기초단체장` | `1-3위_ani_기초단체장(5760동일).txt` | exact family |
|
||||
| `1-3위_기초단체장_5760` | `1-3위_ani_기초단체장(5760동일).txt` | shared |
|
||||
| `1-3위_기초단체장_L` | `1-3위_ani_기초단체장(5760동일).txt` | shared |
|
||||
| `1-3위_기초단체장_L_1` | `1-3위_ani_기초단체장(5760동일).txt` | shared |
|
||||
| `경력_광역단체장_in` | `경력.txt` | shared |
|
||||
| `경력_기초단체장_in` | `경력.txt` | shared |
|
||||
| `당선_광역단체장` | `당선.txt` | shared |
|
||||
| `당선_광역단체장_HD` | `당선.txt` | shared |
|
||||
| `당선_광역단체장_L` | `당선.txt` | shared |
|
||||
| `당선_광역의원` | `당선.txt` | shared |
|
||||
| `당선_광역의원_HD` | `당선.txt` | shared |
|
||||
| `당선_광역의원_L` | `당선.txt` | shared |
|
||||
| `당선_교육감` | `당선_교육감.txt` | exact |
|
||||
| `당선_교육감_HD` | `당선_교육감.txt` | shared |
|
||||
| `당선_교육감_L` | `당선_교육감.txt` | shared |
|
||||
| `당선_기초단체장` | `당선.txt` | shared |
|
||||
| `당선_기초단체장_HD` | `당선.txt` | shared |
|
||||
| `당선_기초단체장_L` | `당선.txt` | shared |
|
||||
| `당선_기초의원` | `당선.txt` | shared |
|
||||
| `당선_기초의원_HD` | `당선.txt` | shared |
|
||||
| `당선_기초의원_L` | `당선.txt` | shared |
|
||||
| `모든후보_광역단체장` | `모든후보.txt` | shared |
|
||||
| `모든후보_광역단체장_5760` | `모든후보.txt` | shared |
|
||||
| `모든후보_광역단체장_5760_END` | `모든후보.txt` | shared |
|
||||
| `모든후보_광역단체장_END` | `모든후보.txt` | shared |
|
||||
| `모든후보_광역단체장_L` | `모든후보.txt` | shared |
|
||||
| `모든후보_광역단체장_L_END` | `모든후보.txt` | shared |
|
||||
| `모든후보_교육감` | `모든후보_교육감.txt` | exact |
|
||||
| `모든후보_교육감_5760` | `모든후보_교육감.txt` | shared |
|
||||
| `모든후보_교육감_5760_END` | `모든후보_교육감.txt` | shared |
|
||||
| `모든후보_교육감_END` | `모든후보_교육감.txt` | shared |
|
||||
| `모든후보_교육감_L` | `모든후보_교육감.txt` | shared |
|
||||
| `모든후보_교육감_L_END` | `모든후보_교육감.txt` | shared |
|
||||
| `모든후보_기초단체장` | `모든후보.txt` | shared |
|
||||
| `모든후보_기초단체장_5760` | `모든후보.txt` | shared |
|
||||
| `모든후보_기초단체장_5760_END` | `모든후보.txt` | shared |
|
||||
| `모든후보_기초단체장_END` | `모든후보.txt` | shared |
|
||||
| `모든후보_기초단체장_L` | `모든후보.txt` | shared |
|
||||
| `모든후보_기초단체장_L_END` | `모든후보.txt` | shared |
|
||||
| `사전_역대당선자` | `사전_역대당선.txt` | shared |
|
||||
| `사전_역대당선자_기초단체장` | `사전_역대당선.txt` | shared |
|
||||
| `사전_역대당선자_교육감` | `사전_역대당선_교육감.txt` | exact family |
|
||||
| `이시각1위_광역단체장` | `이시각1위_광역단체장.txt` | exact |
|
||||
| `이시각1위_광역단체장_HD` | `이시각1위_광역단체장.txt` | shared |
|
||||
| `이시각1위_광역단체장_L` | `이시각1위_광역단체장.txt` | shared |
|
||||
| `이시각1위_기초단체장` | `이시각1위_기초단체장(5760동일).txt` | exact family |
|
||||
| `이시각1위_기초단체장_HD` | `이시각1위_기초단체장(5760동일).txt` | shared |
|
||||
| `이시각1위_기초단체장_L` | `이시각1위_기초단체장(5760동일).txt` | shared |
|
||||
| `접전_광역단체장` | `접전,초접전.txt` | shared |
|
||||
| `접전_기초단체장` | `접전,초접전.txt` | shared |
|
||||
| `초접전_광역단체장` | `접전,초접전.txt` | shared |
|
||||
| `초접전_기초단체장` | `접전,초접전.txt` | shared |
|
||||
| `판세_광역단체장` | `판세_광역단체장.txt` | exact |
|
||||
| `판세_기초단체장` | `판세_광역단체장.txt` | inferred |
|
||||
| `판세_기초단체장_5760` | `판세_광역단체장.txt` | inferred |
|
||||
| `판세_기초단체장_7680` | `판세_광역단체장.txt` | inferred |
|
||||
|
||||
### Bottom
|
||||
|
||||
| Template | RGB spec file | Note |
|
||||
| --- | --- | --- |
|
||||
| `1-2위_광역단체장` | `1-2위, 1-3위, 이시각1위.txt` | shared |
|
||||
| `1-2위_기초단체장` | `1-2위, 1-3위, 이시각1위.txt` | shared |
|
||||
| `1-3위_광역단체장` | `1-2위, 1-3위, 이시각1위.txt` | shared |
|
||||
| `1-3위_기초단체장` | `1-2위, 1-3위, 이시각1위.txt` | shared |
|
||||
| `1위_광역단체장` | `1-2위, 1-3위, 이시각1위.txt` | shared |
|
||||
| `1위_기초단체장` | `1-2위, 1-3위, 이시각1위.txt` | shared |
|
||||
| `당선_광역단체장` | `당선.txt` | shared |
|
||||
| `당선_광역의원` | `당선.txt` | shared |
|
||||
| `당선_기초단체장` | `당선.txt` | shared |
|
||||
| `당선_기초의원` | `당선.txt` | shared |
|
||||
| `전후보_광역단체장` | `모든후보.txt` | naming bridge |
|
||||
| `전후보_기초단체장` | `모든후보.txt` | naming bridge |
|
||||
| `전후보_교육감` | `모든후보_교육감.txt` | naming bridge |
|
||||
|
||||
### Top
|
||||
|
||||
| Template | RGB spec file | Note |
|
||||
| --- | --- | --- |
|
||||
| `광역단체장_2인` | `1-2위_사진.txt` | photo layout |
|
||||
| `기초단체장_2인` | `1-2위_사진.txt` | photo layout |
|
||||
| `광역단체장_2인_텍스트` | `1-2위_텍스트.txt` | text layout |
|
||||
| `기초단체장_2인_텍스트` | `1-2위_텍스트.txt` | text layout |
|
||||
|
||||
## No Explicit Mapping Yet
|
||||
|
||||
### Normal
|
||||
|
||||
- `광역의원표`
|
||||
- `광역의원표_HD`
|
||||
- `광역의원표_L`
|
||||
- `광역의원표_L_1`
|
||||
- `기초의원표`
|
||||
- `기초의원표_HD`
|
||||
- `기초의원표_L`
|
||||
- `기초의원표_L_1`
|
||||
- `역대시도판세_광역단체장`
|
||||
- `역대시도판세_기초단체장`
|
||||
|
||||
### Top
|
||||
|
||||
- `판세_광역단체장`
|
||||
- `판세_광역의원`
|
||||
- `판세_교육감`
|
||||
- `판세_기초단체장`
|
||||
- `판세_기초의원`
|
||||
|
||||
## No Party Color Work Needed Right Now
|
||||
|
||||
- `민방_타이틀*`
|
||||
- `사전_역대투표율*`
|
||||
- `사전투표율`
|
||||
- `투표율*`
|
||||
- `투표율_사진`
|
||||
- `투표율_선거구별`
|
||||
- `투표율_선거구별 사전`
|
||||
- `투표율_시도별`
|
||||
- `투표율_시도별_L`
|
||||
- `투표율_영상`
|
||||
|
||||
## Notes
|
||||
|
||||
- `판세_기초단체장*`은 현재 `판세_광역단체장.txt`로 연결했다.
|
||||
전용 RGB txt가 추가되면 그쪽으로 분리하는 것이 맞다.
|
||||
- `보궐선거` 계열은 현재 가장 가까운 광역/보궐 계열 spec로 연결했다.
|
||||
- `정당색`, `정당원`은 RGB txt 전용 색이 있으면 기존 png보다 우선해서 fallback 생성 자산을 사용한다.
|
||||
@@ -1,5 +1,6 @@
|
||||
# Scene Variable Validation
|
||||
|
||||
- Note: 이 문서는 초기 변수명 가정으로 수행한 1차 검증 결과다. 현재 정상 동작 기준 최종 검증 결과는 `LIVE_VALIDATE_1-2위_ani_광역단체장.md`를 기준으로 본다.
|
||||
- Generated: 2026-04-14 15:14:18
|
||||
- Scene: `C:\Users\MD\Documents\Tornado3 Data\T3_Cut\T3_Cut\Elect2026_Normal_민방\1-2위_ani_광역단체장.tscn`
|
||||
- Operations: `C:\Users\MD\source\repos\Tornado3_2026Election\tools\KarismaTcpProbe\scene-ops\1-2위_ani_광역단체장.json`
|
||||
|
||||
@@ -232,7 +232,7 @@ IDLE → READY → SENDING → ON_AIR → NEXT
|
||||
|
||||
- 한글 문자열이 포함된 파일을 수정한 뒤에는 반드시 인코딩 깨짐 여부를 다시 확인한다.
|
||||
- UI 문구, 로그 문구, 기본값 문자열은 저장 직후 한글이 정상 표시되는지 우선 점검한다.
|
||||
- `?`, `<EFBFBD>`, 비정상 한자 형태의 모지바케가 보이면 즉시 수정 대상으로 간주한다.
|
||||
- `?`, `U+FFFD(치환 문자)`, 비정상 한자 형태의 모지바케가 보이면 즉시 수정 대상으로 간주한다.
|
||||
- 텍스트 파일은 UTF-8 기준으로 관리한다.
|
||||
---
|
||||
|
||||
@@ -282,7 +282,7 @@ IDLE → READY → SENDING → ON_AIR → NEXT
|
||||
- 로그에는 콜백 이름, 결과 enum 이름, 숫자 코드, 추가 정보(scene, object, output, layer 등)를 함께 남긴다.
|
||||
- `OnConnect(int ErrorCode)`는 `0`을 성공으로 간주하고, `0`이 아닌 값은 실패로 기록한다.
|
||||
- `eKResult.RESULT_SUCCESS`는 정보 로그로 남기고, 그 외 결과는 경고 로그로 남긴다.
|
||||
- 현재 로깅 대상에는 `OnConnect`, `OnClose`, `OnLogMessage`, `OnMessageNo`, `OnLoadScene`, `OnLoadSceneForce`, `OnBeginTransaction`, `OnEndTransaction`, `OnHeartBeat`, `OnSetValue`, `OnScenePrepare`, `OnScenePrepareEx`, `OnPlay`, `OnPlayOut`, `OnPause`, `OnResume`, `OnStop`, `OnStopAll`, `OnCutIn`, `OnCutOut`, `OnTrigger`, `OnTriggerObject`, `OnQueryIsOnAir`, `OnQueryLayerCount`, `OnScenePlayingStarted`, `OnScenePlayed`, `OnSceneAnimationPlayed`, `OnScenePaused`를 포함한다.
|
||||
- 현재 로깅 대상에는 `OnConnect`, `OnClose`, `OnLogMessage`, `OnMessageNo`, `OnLoadScene`, `OnLoadSceneForce`, `OnBeginTransaction`, `OnEndTransaction`, `OnHeartBeat`, `OnSetValue`, `OnSetCounterNumberKey`, `OnScenePrepare`, `OnScenePrepareEx`, `OnPlay`, `OnPlayOut`, `OnPause`, `OnResume`, `OnStop`, `OnStopAll`, `OnCutIn`, `OnCutOut`, `OnTrigger`, `OnTriggerObject`, `OnQueryIsOnAir`, `OnQueryLayerCount`, `OnScenePlayingStarted`, `OnScenePlayed`, `OnSceneAnimationPlayed`, `OnScenePaused`를 포함한다.
|
||||
- CG 콜백 로그와 앱 내부 로그는 같은 로그 시스템에 합쳐서 표시한다.
|
||||
## 2026-04-09 운영 규칙 업데이트
|
||||
|
||||
@@ -334,6 +334,11 @@ IDLE → READY → SENDING → ON_AIR → NEXT
|
||||
- Karisma SDK 콜백 수신을 위해 전용 STA 스레드에서 메시지 펌프를 유지한다.
|
||||
- `SetValue` 검증을 위해 후보 이름 키를 기존 `Candidate1Name`, `Candidate2Name` 외에 `후보명01`, `후보명02`로도 함께 전달한다.
|
||||
- 현재 테스트 빌드 기준 `후보명01=김후보`, `후보명02=이후보`를 함께 송신해 실제 장면 변수 반영 여부를 확인한다.
|
||||
- `.tscn`별 실제 변수 목록은 사전 스캔 리포트(`TSCN_VARIABLE_DISCOVERY_E_DRIVE.md`)를 기준으로 읽고, 현재 장면에 존재하는 변수에만 `SetValue`/`SetCounterNumberKey`를 보낸다.
|
||||
- `ElectionDataSnapshot`에서 `후보명NN`, `정당명NN`, `득표수NN`, `득표율NN`, `순위NN`, `표차NN`, `득표차NN`, `선거구명NN`, `시도명NN`, `개표율NN`, `투표율01`, `전국투표율01`을 장면 변수로 자동 매핑한다.
|
||||
- `ani`가 포함된 컷은 `득표율NN` 텍스트 값과 별도로 `IKACounter.SetCounterNumberKey(1, voteRate)`를 함께 전송한다.
|
||||
- `유확당NN`은 `유력.vrv`, `확정.vrv` 또는 `확실.vrv`, `당선.vrv`로 해석하고, `후보사진NN`, `정당바NN`, `정당판NN`, `정당심볼NN`, `그룹NN`은 `T3_Cut` 아래 로컬 에셋 폴더에서 해석한다.
|
||||
- 대표 검증 컷 `1-2위_ani_광역단체장`에 대해서는 실경로 기준 라이브 검증에서 `Success Count: 21`, `Failure Count: 0`을 확인했다(`LIVE_VALIDATE_1-2위_ani_광역단체장.md`).
|
||||
|
||||
### 인코딩 확인 원칙
|
||||
- 터미널 출력이 깨져 보이는 것과 파일 자체 인코딩 손상을 구분해서 판단한다.
|
||||
|
||||
3446
TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md
Normal file
@@ -1,5 +1,6 @@
|
||||
# TSCN Variable Discovery
|
||||
|
||||
- Note: 이 문서는 초기 샘플 3개 컷에 대한 탐색 결과다. 전체 `T3_Cut` 기준 확정 스캔 결과는 `TSCN_VARIABLE_DISCOVERY_E_DRIVE.md`를 기준으로 본다.
|
||||
- Generated: 2026-04-14 15:21:19
|
||||
- Root: `E:\김의연\지역민방\T3_Cut`
|
||||
- Scene Count: 3
|
||||
|
||||
@@ -50,7 +50,13 @@ public sealed class CandidateEntry : ObservableObject
|
||||
public bool HasImage
|
||||
{
|
||||
get => _hasImage;
|
||||
set => SetProperty(ref _hasImage, value);
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _hasImage, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasImageLabel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public CandidateJudgement ManualJudgement
|
||||
@@ -88,6 +94,9 @@ public sealed class CandidateEntry : ObservableObject
|
||||
CandidateJudgement.Leading => "유력",
|
||||
CandidateJudgement.Confirmed => "확정",
|
||||
CandidateJudgement.Elected => "당선",
|
||||
CandidateJudgement.ElectedInProgress => "개표중 당선",
|
||||
CandidateJudgement.UnopposedElected => "무투표 당선",
|
||||
CandidateJudgement.ElectedAfterCountComplete => "개표마감 당선",
|
||||
_ => "-"
|
||||
};
|
||||
|
||||
@@ -97,6 +106,9 @@ public sealed class CandidateEntry : ObservableObject
|
||||
[JsonIgnore]
|
||||
public string VoteRateDisplay => Math.Round(VoteRate, 1, MidpointRounding.AwayFromZero).ToString("0.0");
|
||||
|
||||
[JsonIgnore]
|
||||
public string HasImageLabel => HasImage ? "있음" : "없음";
|
||||
|
||||
public CandidateEntry Clone()
|
||||
{
|
||||
return new CandidateEntry
|
||||
|
||||
@@ -5,5 +5,8 @@ public enum CandidateJudgement
|
||||
None,
|
||||
Leading,
|
||||
Confirmed,
|
||||
Elected
|
||||
Elected,
|
||||
ElectedInProgress,
|
||||
UnopposedElected,
|
||||
ElectedAfterCountComplete
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public sealed class ElectionDataSnapshot
|
||||
|
||||
public required string DistrictCode { get; init; }
|
||||
|
||||
public required string RegionName { get; init; }
|
||||
|
||||
public required string ElectionDistrictName { get; init; }
|
||||
|
||||
public required IReadOnlyList<CandidateEntry> Candidates { get; init; }
|
||||
|
||||
public required int TotalExpectedVotes { get; init; }
|
||||
|
||||
@@ -255,9 +255,9 @@
|
||||
<StackPanel Spacing="20">
|
||||
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="Data Ingest Deck" />
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="원본 데이터 조회" />
|
||||
<Grid ColumnSpacing="12" RowSpacing="12">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="220" /><ColumnDefinition Width="220" /><ColumnDefinition Width="180" /><ColumnDefinition Width="180" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="220" /><ColumnDefinition Width="220" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||
<ComboBox Header="선거 종류"
|
||||
DisplayMemberPath="Label"
|
||||
ItemsSource="{x:Bind ViewModel.Data.ElectionTypeOptions, Mode=OneWay}"
|
||||
@@ -266,19 +266,71 @@
|
||||
<ComboBox Grid.Column="1"
|
||||
Header="선거구명"
|
||||
DisplayMemberPath="Label"
|
||||
ItemsSource="{x:Bind ViewModel.Data.DistrictOptions, Mode=OneWay}"
|
||||
SelectedValue="{x:Bind ViewModel.Data.DistrictName, Mode=TwoWay}"
|
||||
ItemsSource="{x:Bind ViewModel.Data.DistrictViewOptions, Mode=OneWay}"
|
||||
SelectedValue="{x:Bind ViewModel.Data.SelectedDistrictViewName, Mode=TwoWay}"
|
||||
SelectedValuePath="Value" />
|
||||
<TextBox Grid.Column="2" Header="지역 코드" Text="{x:Bind ViewModel.Data.DistrictCode, Mode=TwoWay}" />
|
||||
<NumberBox Grid.Column="3" Header="{x:Bind ViewModel.Data.TotalExpectedVotesLabel, Mode=OneWay}" Minimum="1" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.TotalExpectedVotes, Mode=TwoWay}" />
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
|
||||
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||
<NumberBox Width="140" Header="주기(초)" Minimum="3" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
|
||||
<ToggleSwitch Header="설정 권역만 보기"
|
||||
IsEnabled="{x:Bind ViewModel.Data.HasConfiguredRegionFilter, Mode=OneWay}"
|
||||
IsOn="{x:Bind ViewModel.Data.ShowOnlyConfiguredRegions, Mode=TwoWay}" />
|
||||
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 갱신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
<Button Command="{x:Bind ViewModel.Data.AddCandidateCommand}" Content="후보 추가" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
|
||||
<Button Command="{x:Bind ViewModel.Data.ResetManualJudgementsCommand}" Content="수동 판정 초기화" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.ConfiguredRegionFilterHintText, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.StatusText, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="20"
|
||||
Background="{StaticResource ControlRoomPanelGradientBrush}"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="24"
|
||||
Visibility="{x:Bind ViewModel.Data.DistrictOverviewVisibility, Mode=OneWay}">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.DistrictOverviewTitle, Mode=OneWay}" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.Data.DistrictOverviewStatusText, Mode=OneWay}"
|
||||
TextWrapping="Wrap" />
|
||||
<ItemsControl ItemsSource="{x:Bind ViewModel.Data.DistrictOverviewCards, Mode=OneWay}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<ItemsWrapGrid MaximumRowsOrColumns="4"
|
||||
Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:DistrictOverviewCardViewModel">
|
||||
<Border MinWidth="180"
|
||||
Margin="0,0,12,12"
|
||||
Padding="14,12"
|
||||
Background="#132338"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||
Text="{x:Bind RegionName}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock FontFamily="Consolas"
|
||||
FontSize="24"
|
||||
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
|
||||
Text="{x:Bind CountedRateDisplay}" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind DetailText}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -289,62 +341,38 @@
|
||||
CornerRadius="24"
|
||||
Visibility="{x:Bind ViewModel.Data.TurnoutBoardVisibility, Mode=OneWay}">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="투표율 현황" />
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220" />
|
||||
<ColumnDefinition Width="220" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<NumberBox Header="투표수"
|
||||
Minimum="0"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.Data.TurnoutVotes, Mode=TwoWay}" />
|
||||
<Border Grid.Column="1"
|
||||
Padding="16"
|
||||
Background="#132338"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="18">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="투표율" />
|
||||
<TextBlock FontFamily="Consolas"
|
||||
FontSize="24"
|
||||
Foreground="{StaticResource ControlRoomSignalGreenBrush}"
|
||||
Text="{x:Bind ViewModel.Data.TurnoutRateDisplay, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2"
|
||||
Padding="16"
|
||||
Background="#101C2E"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="18">
|
||||
<StackPanel Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="개표 단계" />
|
||||
<Button Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
MinHeight="22"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="?"
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="11">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<TextBlock MaxWidth="260"
|
||||
Text="현재 송출 기준 단계입니다. 단계에 따라 표시와 갱신 항목이 달라집니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.Data.BroadcastPhaseBadgeText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="투표율 시트" />
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
HorizontalScrollMode="Enabled"
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
VerticalScrollMode="Disabled">
|
||||
<StackPanel Spacing="0">
|
||||
<Grid MinWidth="720">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.TotalExpectedVotesLabel, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="투표수" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="미투표수" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="투표율(%)" /></Border>
|
||||
</Grid>
|
||||
<Grid MinWidth="720">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.TotalExpectedVotes, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.TurnoutVotes, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.TurnoutRemainingVotes, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomSignalGreenBrush}" Text="{x:Bind ViewModel.Data.TurnoutRateDisplay, Mode=OneWay}" /></Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -355,31 +383,99 @@
|
||||
CornerRadius="24"
|
||||
Visibility="{x:Bind ViewModel.Data.CandidateBoardVisibility, Mode=OneWay}">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="후보 현황" />
|
||||
<ListView ItemsSource="{x:Bind ViewModel.Data.Candidates, Mode=OneWay}" SelectionMode="None">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="domain:CandidateEntry">
|
||||
<Border Margin="0,0,0,10" Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="20">
|
||||
<Grid ColumnSpacing="14">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="120" /><ColumnDefinition Width="140" /><ColumnDefinition Width="140" /><ColumnDefinition Width="120" /><ColumnDefinition Width="120" /><ColumnDefinition Width="140" /><ColumnDefinition Width="110" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Header="후보코드" Text="{Binding CandidateCode, Mode=TwoWay}" />
|
||||
<TextBox Grid.Column="1" Header="이름" Text="{Binding Name, Mode=TwoWay}" />
|
||||
<TextBox Grid.Column="2" Header="정당" Text="{Binding Party, Mode=TwoWay}" />
|
||||
<NumberBox Grid.Column="3" Header="득표수" Minimum="0" SpinButtonPlacementMode="Compact" Value="{Binding VoteCount, Mode=TwoWay}" />
|
||||
<NumberBox Grid.Column="4" Header="득표율" Minimum="0" Maximum="100" SmallChange="0.1" SpinButtonPlacementMode="Compact" Value="{Binding VoteRate, Mode=TwoWay}" />
|
||||
<ComboBox Grid.Column="5"
|
||||
Header="수동 판정"
|
||||
DisplayMemberPath="Label"
|
||||
ItemsSource="{Binding ViewModel.Data.JudgementOptions, ElementName=RootWindow}"
|
||||
SelectedValue="{Binding ManualJudgement, Mode=TwoWay}"
|
||||
SelectedValuePath="Value" />
|
||||
<StackPanel Grid.Column="6" Spacing="6"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="판정" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="18" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind EffectiveJudgementLabel}" /><ToggleButton IsChecked="{Binding HasImage, Mode=TwoWay}" Content="사진" /></StackPanel>
|
||||
<StackPanel Grid.Column="7" Orientation="Horizontal" Spacing="8" VerticalAlignment="Bottom"><Button Command="{Binding ViewModel.Data.RemoveCandidateCommand, ElementName=RootWindow}" CommandParameter="{Binding}" Content="제거" Style="{StaticResource ConsoleGhostButtonStyle}" /></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="개표율 시트" />
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
HorizontalScrollMode="Enabled"
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
VerticalScrollMode="Disabled">
|
||||
<StackPanel Spacing="0">
|
||||
<Grid MinWidth="720">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.CountedRateBaseLabel, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="개표수" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="남은표" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="개표율(%)" /></Border>
|
||||
</Grid>
|
||||
<Grid MinWidth="720">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
<ColumnDefinition Width="180" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.CountedRateBaseVotes, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.CountedVotes, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.RemainingVotes, Mode=OneWay}" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomSignalBlueBrush}" Text="{x:Bind ViewModel.Data.CountedRateDisplay, Mode=OneWay}" /></Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="20"
|
||||
Background="{StaticResource ControlRoomPanelGradientBrush}"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="24"
|
||||
Visibility="{x:Bind ViewModel.Data.CandidateBoardVisibility, Mode=OneWay}">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="후보 시트" />
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
HorizontalScrollMode="Enabled"
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
VerticalScrollMode="Disabled">
|
||||
<StackPanel Spacing="0">
|
||||
<Grid MinWidth="1050">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="200" />
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="140" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="후보코드" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="이름" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="정당" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="득표수" /></Border>
|
||||
<Border Grid.Column="4" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="득표율(%)" /></Border>
|
||||
<Border Grid.Column="5" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="판정" /></Border>
|
||||
<Border Grid.Column="6" Padding="12,10" Background="#0E1726" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="사진" /></Border>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{x:Bind ViewModel.Data.Candidates, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="domain:CandidateEntry">
|
||||
<Grid MinWidth="1050">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="200" />
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="140" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind CandidateCode}" TextWrapping="WrapWholeWords" /></Border>
|
||||
<Border Grid.Column="1" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind Name}" TextWrapping="WrapWholeWords" /></Border>
|
||||
<Border Grid.Column="2" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind Party}" TextWrapping="WrapWholeWords" /></Border>
|
||||
<Border Grid.Column="3" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind VoteCountDisplay}" TextAlignment="Right" /></Border>
|
||||
<Border Grid.Column="4" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind VoteRateDisplay}" TextAlignment="Right" /></Border>
|
||||
<Border Grid.Column="5" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomSignalBlueBrush}" Text="{x:Bind EffectiveJudgementLabel}" TextAlignment="Center" TextWrapping="Wrap" /></Border>
|
||||
<Border Grid.Column="6" Padding="12,10" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1"><TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind HasImageLabel}" TextAlignment="Center" /></Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
@@ -31,13 +31,15 @@ public sealed class AppState
|
||||
|
||||
public bool IsPollingEnabled { get; set; } = true;
|
||||
|
||||
public int PollingIntervalSeconds { get; set; } = 12;
|
||||
public int PollingIntervalSeconds { get; set; } = 60;
|
||||
|
||||
public string ElectionType { get; set; } = "광역단체장";
|
||||
|
||||
public string DistrictName { get; set; } = "부산광역시";
|
||||
|
||||
public string DistrictCode { get; set; } = "2600";
|
||||
public string DistrictCode { get; set; } = "26";
|
||||
|
||||
public bool ShowOnlyConfiguredRegions { get; set; }
|
||||
|
||||
public int TotalExpectedVotes { get; set; } = 1_240_000;
|
||||
|
||||
|
||||
@@ -123,12 +123,12 @@ public class KarismaEventHandler : KAEventHandler
|
||||
virtual public void OnQuerySceneEffectType(eKResult Result, string SceneName, int bInEffect, eKEffectType EffectType, int Duration) { }
|
||||
virtual public void OnQueryDuration(eKResult Result, string SceneName, string AnimationName, int Duration) { }
|
||||
virtual public void OnQueryContentsOfTextObjects(eKResult Result, string SceneName, KAStrings pTexts) { }
|
||||
virtual public void OnSetStyleColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetStyleColor(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetStyleColor), Result, $"scene={SceneName} object={ObjectName}");
|
||||
virtual public void OnSetStyleTexture(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetFaceTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetEdgeTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetShadowTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetVisible(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetFaceTextColor(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetFaceTextColor), Result, $"scene={SceneName} object={ObjectName}");
|
||||
public void OnSetEdgeTextColor(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetEdgeTextColor), Result, $"scene={SceneName} object={ObjectName}");
|
||||
public void OnSetShadowTextColor(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetShadowTextColor), Result, $"scene={SceneName} object={ObjectName}");
|
||||
public void OnSetVisible(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetVisible), Result, $"scene={SceneName} object={ObjectName}");
|
||||
public void OnSetValue(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetValue), Result, $"scene={SceneName} object={ObjectName}");
|
||||
virtual public void OnAddText(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnStoreTextStyle(eKResult Result, string SceneName, string ObjectName) { }
|
||||
|
||||
@@ -8,6 +8,13 @@ namespace Tornado3_2026Election.Services;
|
||||
|
||||
public sealed class KarismaSceneVariableCatalog
|
||||
{
|
||||
private static readonly string[] PreferredReportNames =
|
||||
[
|
||||
"TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md",
|
||||
"TSCN_VARIABLE_DISCOVERY_E_DRIVE.md",
|
||||
"TSCN_VARIABLE_DISCOVERY.md"
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, KarismaSceneVariableDefinition> EmptySceneVariables =
|
||||
new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -179,10 +186,19 @@ public sealed class KarismaSceneVariableCatalog
|
||||
var current = startPath;
|
||||
for (var depth = 0; depth < 8 && !string.IsNullOrWhiteSpace(current); depth++)
|
||||
{
|
||||
var candidate = Path.Combine(current, "TSCN_VARIABLE_DISCOVERY_E_DRIVE.md");
|
||||
if (File.Exists(candidate))
|
||||
foreach (var reportName in PreferredReportNames)
|
||||
{
|
||||
return candidate;
|
||||
var candidate = Path.Combine(current, reportName);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
var wildcardCandidate = TryFindLatestDiscoveryReport(current);
|
||||
if (!string.IsNullOrWhiteSpace(wildcardCandidate))
|
||||
{
|
||||
return wildcardCandidate;
|
||||
}
|
||||
|
||||
current = Path.GetDirectoryName(current);
|
||||
@@ -206,6 +222,22 @@ public sealed class KarismaSceneVariableCatalog
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static string? TryFindLatestDiscoveryReport(string directoryPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(directoryPath, "TSCN_VARIABLE_DISCOVERY*.md", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => !Path.GetFileName(path).Contains("SAMPLE", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(path => File.GetLastWriteTimeUtc(path))
|
||||
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRelativePath(string relativePath)
|
||||
{
|
||||
return relativePath
|
||||
|
||||
12
Tornado3_2026Election/Services/KarismaStyleColorUpdate.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using KAsyncEngineLib;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public readonly record struct KarismaStyleColorUpdate(
|
||||
string ObjectName,
|
||||
eKStyleType StyleType,
|
||||
int Order,
|
||||
byte R,
|
||||
byte G,
|
||||
byte B,
|
||||
byte A = byte.MaxValue);
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tornado3_2026Election.Domain;
|
||||
@@ -12,6 +13,27 @@ namespace Tornado3_2026Election.Services;
|
||||
public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
{
|
||||
private const int DefaultKarismaPort = 30001;
|
||||
private static readonly string[] CandidateSlotVariablePrefixes =
|
||||
[
|
||||
"순위",
|
||||
"후보명",
|
||||
"정당명",
|
||||
"득표수",
|
||||
"득표율",
|
||||
"표차",
|
||||
"득표차",
|
||||
"유확당",
|
||||
"후보사진",
|
||||
"득표수바",
|
||||
"정당바",
|
||||
"정당판",
|
||||
"정당원",
|
||||
"정당색",
|
||||
"정당심볼",
|
||||
"그룹"
|
||||
];
|
||||
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
||||
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
||||
|
||||
private readonly TornadoManager _manager;
|
||||
private readonly LogService _logService;
|
||||
@@ -148,12 +170,22 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
var resolvedScene = ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
|
||||
var sceneVariables = _sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
|
||||
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath, sceneVariables);
|
||||
var counterNumberKeys = BuildCounterNumberKeyUpdates(template, snapshot, sceneVariables);
|
||||
var counterNumberKeys = BuildCounterNumberKeyUpdates(template, cut, snapshot, sceneVariables);
|
||||
var styleColorUpdates = BuildStyleColorUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
|
||||
var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
|
||||
LogUnsupportedSceneVariables(channel, template, sceneVariables);
|
||||
|
||||
State = TornadoConnectionState.Sending;
|
||||
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
|
||||
await _manager.ApplyValuesAsync(resolvedScene.Alias, values, counterNumberKeys, cancellationToken).ConfigureAwait(false);
|
||||
await _manager.ApplyValuesAsync(
|
||||
resolvedScene.Alias,
|
||||
judgementVisibilityUpdates.HideBeforeValue,
|
||||
values,
|
||||
counterNumberKeys,
|
||||
styleColorUpdates,
|
||||
judgementVisibilityUpdates.ShowAfterValue,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_pendingScenes[channel] = resolvedScene.Alias;
|
||||
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
|
||||
@@ -329,8 +361,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
|
||||
var countedRateDisplay = FormatRate(CalculateCountedRate(snapshot));
|
||||
var countedRateDisplay = FormatCountedRateLabel(CalculateCountedRate(snapshot));
|
||||
var referenceTimeDisplay = FormatClock(snapshot.ReceivedAt);
|
||||
var totalExpectedVotesDisplay = FormatCount(snapshot.TotalExpectedVotes);
|
||||
var turnoutVotesDisplay = FormatCount(snapshot.TurnoutVotes);
|
||||
var turnoutRateDisplay = FormatRate(snapshot.TurnoutRate);
|
||||
var regionName = string.IsNullOrWhiteSpace(snapshot.RegionName) ? snapshot.DistrictName : snapshot.RegionName;
|
||||
var electionDistrictName = string.IsNullOrWhiteSpace(snapshot.ElectionDistrictName) ? snapshot.DistrictName : snapshot.ElectionDistrictName;
|
||||
var regionLabelDisplay = string.Equals(snapshot.ElectionType, "광역단체장", StringComparison.Ordinal)
|
||||
? electionDistrictName
|
||||
: regionName;
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["TemplateId"] = template.Id,
|
||||
@@ -343,12 +383,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
["StationRegions"] = string.Join(", ", station.RegionFilters),
|
||||
["BroadcastPhase"] = snapshot.BroadcastPhase.ToString(),
|
||||
["ElectionType"] = snapshot.ElectionType,
|
||||
["RegionName"] = regionName,
|
||||
["ElectionDistrictName"] = electionDistrictName,
|
||||
["DistrictName"] = snapshot.DistrictName,
|
||||
["DistrictCode"] = snapshot.DistrictCode,
|
||||
["TotalExpectedVotes"] = snapshot.TotalExpectedVotes.ToString(CultureInfo.InvariantCulture),
|
||||
["TotalExpectedVotesDisplay"] = snapshot.TotalExpectedVotes.ToString("N0", CultureInfo.InvariantCulture),
|
||||
["TotalExpectedVotesDisplay"] = totalExpectedVotesDisplay,
|
||||
["TurnoutVotes"] = snapshot.TurnoutVotes.ToString(CultureInfo.InvariantCulture),
|
||||
["TurnoutVotesDisplay"] = snapshot.TurnoutVotes.ToString("N0", CultureInfo.InvariantCulture),
|
||||
["TurnoutVotesDisplay"] = turnoutVotesDisplay,
|
||||
["TurnoutRate"] = snapshot.TurnoutRate.ToString("0.0", CultureInfo.InvariantCulture),
|
||||
["CountedVotes"] = snapshot.CountedVotes.ToString(CultureInfo.InvariantCulture),
|
||||
["CountedVotesDisplay"] = snapshot.CountedVotes.ToString("N0", CultureInfo.InvariantCulture),
|
||||
@@ -359,14 +401,15 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
SetAliases(values, snapshot.DistrictName, "선거구명", "선거구명01", "시도명", "시도명01");
|
||||
SetAliases(values, electionDistrictName, "선거구명", "선거구명01");
|
||||
SetAliases(values, regionLabelDisplay, "시도명", "시도명01");
|
||||
SetAliases(values, countedRateDisplay, "개표율", "개표율01");
|
||||
SetAliases(values, turnoutRateDisplay, "투표율", "투표율01", "전국투표율", "전국투표율01");
|
||||
SetAliases(values, referenceTimeDisplay, "기준시", "기준시01", "기준시02");
|
||||
SetAliases(values, totalExpectedVotesDisplay, "유권자수", "유권자수01");
|
||||
SetAliases(values, turnoutVotesDisplay, "투표자수", "투표자수01");
|
||||
|
||||
var orderedCandidates = snapshot.Candidates
|
||||
.OrderByDescending(candidate => candidate.VoteCount)
|
||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
|
||||
|
||||
for (var index = 0; index < orderedCandidates.Length; index++)
|
||||
{
|
||||
@@ -379,10 +422,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
var rankImagePath = ResolveRankAssetPath(t3CutPath, templateFolderPath, slot);
|
||||
var judgementPath = ResolveJudgementAssetPath(t3CutPath, templateFolderPath, candidate.EffectiveJudgement);
|
||||
var candidateImagePath = ResolveCandidateImagePath(t3CutPath, templateFolderPath, candidate);
|
||||
var partyBarPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Bar);
|
||||
var partyPlatePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Plate);
|
||||
var partySymbolPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Symbol);
|
||||
var groupPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, candidate.Party, PartyAssetKind.Group);
|
||||
var partyBarPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.Party, PartyAssetKind.Bar);
|
||||
var partyPlatePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.Party, PartyAssetKind.Plate);
|
||||
var partyOutlinePath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.Party, PartyAssetKind.Outline);
|
||||
var partyColorPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.Party, PartyAssetKind.Color);
|
||||
var partySymbolPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.Party, PartyAssetKind.Symbol);
|
||||
var groupPath = ResolvePartyAssetPath(t3CutPath, templateFolderPath, template.Name, candidate.Party, PartyAssetKind.Group);
|
||||
|
||||
values[$"Candidate{slot}Code"] = candidate.CandidateCode;
|
||||
values[$"Candidate{slot}Name"] = candidate.Name;
|
||||
@@ -401,16 +446,22 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
SetAliases(values, voteGapDisplay, $"표차{slot:00}", $"표차{slot}", $"득표차{slot:00}", $"득표차{slot}");
|
||||
SetAliases(
|
||||
values,
|
||||
snapshot.DistrictName,
|
||||
electionDistrictName,
|
||||
$"선거구명{slot:00}",
|
||||
$"선거구명{slot}",
|
||||
$"선거구명{slot}");
|
||||
SetAliases(
|
||||
values,
|
||||
regionLabelDisplay,
|
||||
$"시도명{slot:00}",
|
||||
$"시도명{slot}");
|
||||
SetAliases(values, countedRateDisplay, $"개표율{slot:00}", $"개표율{slot}");
|
||||
SetOptionalAliases(values, judgementPath, $"유확당{slot:00}", $"유확당{slot}");
|
||||
SetOptionalAliases(values, judgementPath, GetJudgementAliases(slot));
|
||||
SetOptionalAliases(values, candidateImagePath, $"후보사진{slot:00}", $"후보사진{slot}");
|
||||
SetOptionalAliases(values, partyBarPath, $"정당바{slot:00}", $"정당바{slot}");
|
||||
SetOptionalAliases(values, partyPlatePath, $"정당판{slot:00}", $"정당판{slot}");
|
||||
SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "득표수바", partyBarPath, $"득표수바{slot:00}", $"득표수바{slot}");
|
||||
SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당바", partyBarPath, $"정당바{slot:00}", $"정당바{slot}");
|
||||
SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당판", partyPlatePath, $"정당판{slot:00}", $"정당판{slot}");
|
||||
SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당원", partyOutlinePath, $"정당원{slot:00}", $"정당원{slot}");
|
||||
SetOptionalAssetAliasesUnlessStyleBound(values, templateFolderPath, template.Name, "정당색", partyColorPath, $"정당색{slot:00}", $"정당색{slot}");
|
||||
SetOptionalAliases(values, partySymbolPath, $"정당심볼{slot:00}", $"정당심볼{slot}");
|
||||
SetOptionalAliases(values, groupPath, $"그룹{slot:00}", $"그룹{slot}");
|
||||
}
|
||||
@@ -430,8 +481,86 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
return FilterValuesForScene(values, sceneVariables);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KarismaStyleColorUpdate> BuildStyleColorUpdates(
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
string t3CutPath,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
|
||||
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
|
||||
if (orderedCandidates.Length == 0)
|
||||
{
|
||||
return Array.Empty<KarismaStyleColorUpdate>();
|
||||
}
|
||||
|
||||
var updates = new List<KarismaStyleColorUpdate>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < orderedCandidates.Length; index++)
|
||||
{
|
||||
var candidate = orderedCandidates[index];
|
||||
var slot = index + 1;
|
||||
AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.Party, "득표수바", $"득표수바{slot:00}", $"득표수바{slot}");
|
||||
AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.Party, "정당바", $"정당바{slot:00}", $"정당바{slot}");
|
||||
AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.Party, "정당판", $"정당판{slot:00}", $"정당판{slot}");
|
||||
AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.Party, "정당원", $"정당원{slot:00}", $"정당원{slot}");
|
||||
AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.Party, "정당색", $"정당색{slot:00}", $"정당색{slot}");
|
||||
AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.Party, "정당명", $"정당명{slot:00}", $"정당명{slot}");
|
||||
AddStyleColorUpdates(updates, seen, sceneVariables, templateFolderPath, template.Name, candidate.Party, "득표율", $"득표율{slot:00}", $"득표율{slot}");
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) BuildJudgementVisibilityUpdates(
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
string t3CutPath,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
var templateFolderPath = ResolveTemplateFolderPath(t3CutPath, template);
|
||||
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
|
||||
var hideBeforeValue = new List<KarismaVisibilityUpdate>();
|
||||
var showAfterValue = new List<KarismaVisibilityUpdate>();
|
||||
var hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var shown = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (sceneVariables.Count > 0)
|
||||
{
|
||||
foreach (var variableName in sceneVariables.Keys.Where(IsJudgementVariableName).OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
AddVisibilityUpdate(hideBeforeValue, hidden, variableName, false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var slotCount = Math.Max(ResolveMaxCandidateSlots(template, cut, sceneVariables), orderedCandidates.Length);
|
||||
for (var slot = 1; slot <= slotCount; slot++)
|
||||
{
|
||||
AddVisibilityUpdates(hideBeforeValue, hidden, sceneVariables, false, GetJudgementAliases(slot));
|
||||
}
|
||||
}
|
||||
|
||||
for (var index = 0; index < orderedCandidates.Length; index++)
|
||||
{
|
||||
var slot = index + 1;
|
||||
var judgementPath = ResolveJudgementAssetPath(t3CutPath, templateFolderPath, orderedCandidates[index].EffectiveJudgement);
|
||||
if (string.IsNullOrWhiteSpace(judgementPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddVisibilityUpdates(showAfterValue, shown, sceneVariables, true, GetJudgementAliases(slot));
|
||||
}
|
||||
|
||||
return (hideBeforeValue, showAfterValue);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildCounterNumberKeyUpdates(
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
@@ -440,10 +569,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
return Array.Empty<KarismaCounterNumberKeyUpdate>();
|
||||
}
|
||||
|
||||
var orderedCandidates = snapshot.Candidates
|
||||
.OrderByDescending(candidate => candidate.VoteCount)
|
||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
|
||||
|
||||
if (orderedCandidates.Length == 0)
|
||||
{
|
||||
@@ -469,6 +595,55 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
return updates;
|
||||
}
|
||||
|
||||
private static CandidateEntry[] GetOrderedCandidates(
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
var orderedCandidates = snapshot.Candidates
|
||||
.OrderByDescending(candidate => candidate.VoteCount)
|
||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var maxCandidateSlots = ResolveMaxCandidateSlots(template, cut, sceneVariables);
|
||||
if (maxCandidateSlots > 0 && orderedCandidates.Length > maxCandidateSlots)
|
||||
{
|
||||
orderedCandidates = orderedCandidates.Take(maxCandidateSlots).ToArray();
|
||||
}
|
||||
|
||||
return orderedCandidates;
|
||||
}
|
||||
|
||||
private void LogUnsupportedSceneVariables(
|
||||
BroadcastChannel channel,
|
||||
FormatTemplateDefinition template,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
if (sceneVariables.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unsupportedVariables = sceneVariables.Keys
|
||||
.Where(variableName => !IsSupportedSceneVariable(variableName))
|
||||
.OrderBy(variableName => variableName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (unsupportedVariables.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var preview = string.Join(", ", unsupportedVariables.Take(10));
|
||||
if (unsupportedVariables.Length > 10)
|
||||
{
|
||||
preview += ", ...";
|
||||
}
|
||||
|
||||
_logService.Warning(
|
||||
$"[{channel}] Scene '{template.Id}' has {unsupportedVariables.Length} variables without runtime mapping: {preview}");
|
||||
}
|
||||
|
||||
private static string ResolveCandidateImagePath(string t3CutPath, string templateFolderPath, CandidateEntry candidate)
|
||||
{
|
||||
if (!candidate.HasImage || string.IsNullOrWhiteSpace(t3CutPath))
|
||||
@@ -515,6 +690,9 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
CandidateJudgement.Leading => new[] { "유력.vrv" },
|
||||
CandidateJudgement.Confirmed => new[] { "확정.vrv", "확실.vrv" },
|
||||
CandidateJudgement.Elected => new[] { "당선.vrv" },
|
||||
CandidateJudgement.ElectedInProgress => new[] { "개표중당선.vrv", "개표중_당선.vrv", "당선.vrv" },
|
||||
CandidateJudgement.UnopposedElected => new[] { "무투표당선.vrv", "무투표_당선.vrv", "당선.vrv" },
|
||||
CandidateJudgement.ElectedAfterCountComplete => new[] { "개표마감당선.vrv", "개표마감_당선.vrv", "당선.vrv" },
|
||||
_ => Array.Empty<string>()
|
||||
};
|
||||
|
||||
@@ -532,6 +710,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
private static string ResolvePartyAssetPath(
|
||||
string t3CutPath,
|
||||
string templateFolderPath,
|
||||
string templateName,
|
||||
string partyName,
|
||||
PartyAssetKind assetKind)
|
||||
{
|
||||
@@ -553,6 +732,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
|
||||
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
|
||||
break;
|
||||
case PartyAssetKind.Outline:
|
||||
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
|
||||
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
|
||||
break;
|
||||
case PartyAssetKind.Color:
|
||||
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Map", candidateFileName + ".png"));
|
||||
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Round", candidateFileName + ".png"));
|
||||
break;
|
||||
case PartyAssetKind.Symbol:
|
||||
relativePaths.Add(Path.Combine("Images", "Dang", "Dang_Symbol", candidateFileName + ".png"));
|
||||
break;
|
||||
@@ -564,7 +751,40 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
return ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
|
||||
var preferredUsage = assetKind switch
|
||||
{
|
||||
PartyAssetKind.Outline => PartyColorAssetUsage.Outline,
|
||||
PartyAssetKind.Color => PartyColorAssetUsage.Color,
|
||||
_ => (PartyColorAssetUsage?)null
|
||||
};
|
||||
if (preferredUsage is not null)
|
||||
{
|
||||
var preferredPath = PartyColorCatalog.ResolveFallbackAssetPath(templateFolderPath, templateName, partyName, preferredUsage.Value);
|
||||
if (!string.IsNullOrWhiteSpace(preferredPath))
|
||||
{
|
||||
return preferredPath;
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedPath = ResolveAssetAcrossRoots(t3CutPath, templateFolderPath, relativePaths);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedPath) || assetKind == PartyAssetKind.Symbol)
|
||||
{
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
var usage = assetKind switch
|
||||
{
|
||||
PartyAssetKind.Bar => PartyColorAssetUsage.Bar,
|
||||
PartyAssetKind.Plate => PartyColorAssetUsage.Plate,
|
||||
PartyAssetKind.Outline => PartyColorAssetUsage.Outline,
|
||||
PartyAssetKind.Color => PartyColorAssetUsage.Color,
|
||||
PartyAssetKind.Group => PartyColorAssetUsage.Group,
|
||||
_ => (PartyColorAssetUsage?)null
|
||||
};
|
||||
|
||||
return usage is null
|
||||
? string.Empty
|
||||
: PartyColorCatalog.ResolveFallbackAssetPath(templateFolderPath, templateName, partyName, usage.Value);
|
||||
}
|
||||
|
||||
private static string ResolveTemplateFolderPath(string t3CutPath, FormatTemplateDefinition template)
|
||||
@@ -608,6 +828,163 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
template.Name.Contains("ani", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int ResolveMaxCandidateSlots(
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
var sceneSlotCount = ResolveSceneCandidateSlotCount(sceneVariables);
|
||||
if (sceneSlotCount > 0)
|
||||
{
|
||||
return sceneSlotCount;
|
||||
}
|
||||
|
||||
foreach (var source in new[] { cut.Name, template.Name, template.Id })
|
||||
{
|
||||
var slotCount = ResolveTemplateCandidateSlotCount(source);
|
||||
if (slotCount > 0)
|
||||
{
|
||||
return slotCount;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int ResolveSceneCandidateSlotCount(
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
|
||||
{
|
||||
if (sceneVariables.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var maxSlot = 0;
|
||||
foreach (var variableName in sceneVariables.Keys)
|
||||
{
|
||||
if (TryParseCandidateSlot(variableName, out var slot) && slot > maxSlot)
|
||||
{
|
||||
maxSlot = slot;
|
||||
}
|
||||
}
|
||||
|
||||
return maxSlot;
|
||||
}
|
||||
|
||||
private static bool TryParseCandidateSlot(string variableName, out int slot)
|
||||
{
|
||||
slot = 0;
|
||||
if (string.IsNullOrWhiteSpace(variableName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var prefix in CandidateSlotVariablePrefixes)
|
||||
{
|
||||
if (!variableName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var suffix = variableName.Substring(prefix.Length);
|
||||
if (suffix.Length is > 0 and <= 2 &&
|
||||
int.TryParse(suffix, NumberStyles.None, CultureInfo.InvariantCulture, out slot) &&
|
||||
slot > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int ResolveTemplateCandidateSlotCount(string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var sourceName = Path.GetFileNameWithoutExtension(
|
||||
source
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar));
|
||||
|
||||
var topRankMatch = TopRankSlotCountPattern.Match(sourceName);
|
||||
if (topRankMatch.Success &&
|
||||
int.TryParse(topRankMatch.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var topRankSlotCount) &&
|
||||
topRankSlotCount > 0)
|
||||
{
|
||||
return topRankSlotCount;
|
||||
}
|
||||
|
||||
var peopleMatch = PeopleSlotCountPattern.Match(sourceName);
|
||||
if (peopleMatch.Success &&
|
||||
int.TryParse(peopleMatch.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var peopleSlotCount) &&
|
||||
peopleSlotCount > 0)
|
||||
{
|
||||
return peopleSlotCount;
|
||||
}
|
||||
|
||||
if (sourceName.StartsWith("1위_", StringComparison.Ordinal) ||
|
||||
sourceName.Contains("이시각1위", StringComparison.Ordinal) ||
|
||||
sourceName.StartsWith("당선_", StringComparison.Ordinal) ||
|
||||
sourceName.StartsWith("경력_", StringComparison.Ordinal))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (sourceName.Contains("접전", StringComparison.Ordinal))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsSupportedSceneVariable(string variableName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(variableName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return variableName is "선거구명" or "시도명" or "개표율" or "투표율" or "전국투표율" or
|
||||
"기준시" or "기준시01" or "기준시02" or "유권자수" or "유권자수01" or "투표자수" or "투표자수01" or "유확당" ||
|
||||
MatchesIndexedVariable(variableName, "선거구명") ||
|
||||
MatchesIndexedVariable(variableName, "시도명") ||
|
||||
MatchesIndexedVariable(variableName, "개표율") ||
|
||||
MatchesIndexedVariable(variableName, "투표율") ||
|
||||
MatchesIndexedVariable(variableName, "전국투표율") ||
|
||||
MatchesIndexedVariable(variableName, "순위") ||
|
||||
MatchesIndexedVariable(variableName, "후보명") ||
|
||||
MatchesIndexedVariable(variableName, "정당명") ||
|
||||
MatchesIndexedVariable(variableName, "득표수") ||
|
||||
MatchesIndexedVariable(variableName, "득표율") ||
|
||||
MatchesIndexedVariable(variableName, "표차") ||
|
||||
MatchesIndexedVariable(variableName, "득표차") ||
|
||||
MatchesIndexedVariable(variableName, "유확당") ||
|
||||
MatchesIndexedVariable(variableName, "후보사진") ||
|
||||
MatchesIndexedVariable(variableName, "득표수바") ||
|
||||
MatchesIndexedVariable(variableName, "정당바") ||
|
||||
MatchesIndexedVariable(variableName, "정당판") ||
|
||||
MatchesIndexedVariable(variableName, "정당원") ||
|
||||
MatchesIndexedVariable(variableName, "정당색") ||
|
||||
MatchesIndexedVariable(variableName, "정당심볼") ||
|
||||
MatchesIndexedVariable(variableName, "그룹");
|
||||
}
|
||||
|
||||
private static bool MatchesIndexedVariable(string variableName, string prefix)
|
||||
{
|
||||
if (!variableName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var suffix = variableName.Substring(prefix.Length);
|
||||
return suffix.Length is > 0 and <= 2 && suffix.All(char.IsDigit);
|
||||
}
|
||||
|
||||
private static string FormatCount(int value)
|
||||
{
|
||||
return value.ToString("N0", CultureInfo.InvariantCulture);
|
||||
@@ -618,6 +995,22 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
return Math.Round(value, 1, MidpointRounding.AwayFromZero).ToString("0.0", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string FormatCountedRateLabel(double value)
|
||||
{
|
||||
return FormattableString.Invariant($"개표 {FormatRate(value)}%");
|
||||
}
|
||||
|
||||
private static string FormatClock(DateTimeOffset value)
|
||||
{
|
||||
return value.ToString("HH:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static bool IsJudgementVariableName(string variableName)
|
||||
{
|
||||
return string.Equals(variableName, "유확당", StringComparison.Ordinal) ||
|
||||
MatchesIndexedVariable(variableName, "유확당");
|
||||
}
|
||||
|
||||
private static void SetAliases(IDictionary<string, string> values, string value, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
@@ -629,6 +1022,13 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] GetJudgementAliases(int slot)
|
||||
{
|
||||
return slot == 1
|
||||
? ["유확당", $"유확당{slot:00}", $"유확당{slot}"]
|
||||
: [$"유확당{slot:00}", $"유확당{slot}"];
|
||||
}
|
||||
|
||||
private static void SetOptionalAliases(IDictionary<string, string> values, string? value, params string[] keys)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
@@ -637,6 +1037,105 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetOptionalAssetAliasesUnlessStyleBound(
|
||||
IDictionary<string, string> values,
|
||||
string templateFolderPath,
|
||||
string templateName,
|
||||
string sectionName,
|
||||
string? value,
|
||||
params string[] keys)
|
||||
{
|
||||
if (PartyColorCatalog.HasStyleColorBinding(templateFolderPath, templateName, sectionName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetOptionalAliases(values, value, keys);
|
||||
}
|
||||
|
||||
private static void AddVisibilityUpdates(
|
||||
ICollection<KarismaVisibilityUpdate> updates,
|
||||
ISet<string> seen,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
||||
bool isVisible,
|
||||
params string[] objectNames)
|
||||
{
|
||||
foreach (var objectName in objectNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(objectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddVisibilityUpdate(updates, seen, objectName, isVisible);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddVisibilityUpdate(
|
||||
ICollection<KarismaVisibilityUpdate> updates,
|
||||
ISet<string> seen,
|
||||
string objectName,
|
||||
bool isVisible)
|
||||
{
|
||||
var updateKey = FormattableString.Invariant($"{objectName}|{isVisible}");
|
||||
if (!seen.Add(updateKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
updates.Add(new KarismaVisibilityUpdate(objectName, isVisible));
|
||||
}
|
||||
|
||||
private static void AddStyleColorUpdates(
|
||||
ICollection<KarismaStyleColorUpdate> updates,
|
||||
ISet<string> seen,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
||||
string templateFolderPath,
|
||||
string templateName,
|
||||
string partyName,
|
||||
string sectionName,
|
||||
params string[] objectNames)
|
||||
{
|
||||
if (!PartyColorCatalog.TryResolveStyleColor(templateFolderPath, templateName, sectionName, partyName, out var styleColor))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var objectName in objectNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(objectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var updateKey = FormattableString.Invariant(
|
||||
$"{objectName}|{styleColor.StyleType}|{styleColor.Order}|{styleColor.R}|{styleColor.G}|{styleColor.B}|{styleColor.A}");
|
||||
if (!seen.Add(updateKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
updates.Add(new KarismaStyleColorUpdate(
|
||||
objectName,
|
||||
styleColor.StyleType,
|
||||
styleColor.Order,
|
||||
styleColor.R,
|
||||
styleColor.G,
|
||||
styleColor.B,
|
||||
styleColor.A));
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetRankAliases(
|
||||
IDictionary<string, string> values,
|
||||
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
||||
@@ -768,6 +1267,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, "무소속", StringComparison.Ordinal))
|
||||
{
|
||||
yield return "무기타";
|
||||
yield return "무소속기타";
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, "무기타", StringComparison.Ordinal))
|
||||
{
|
||||
yield return "무소속기타";
|
||||
}
|
||||
@@ -777,6 +1282,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
{
|
||||
Bar,
|
||||
Plate,
|
||||
Outline,
|
||||
Color,
|
||||
Symbol,
|
||||
Group
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public readonly record struct KarismaVisibilityUpdate(string ObjectName, bool IsVisible);
|
||||
892
Tornado3_2026Election/Services/PartyColorCatalog.cs
Normal file
@@ -0,0 +1,892 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using KAsyncEngineLib;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
internal static class PartyColorCatalog
|
||||
{
|
||||
private const int DefaultBarWidth = 140;
|
||||
private const int DefaultBarHeight = 174;
|
||||
private const int DefaultPlateWidth = 552;
|
||||
private const int DefaultPlateHeight = 736;
|
||||
|
||||
private static readonly Regex StyleTargetPattern = new(@"(?<target>face|edge|shadow|underline|frame)(?:\s*(?<order>\d+)번째)?", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex RgbRowPattern = new(@"^(?<party>.+?)\s+(?<r>\d{1,3})\s+(?<g>\d{1,3})\s+(?<b>\d{1,3})\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex CleanupPattern = new(@"[^\p{L}\p{Nd}]+", RegexOptions.Compiled);
|
||||
private static readonly Regex InvalidFileNamePattern = new(@"[^\p{L}\p{Nd}_-]+", RegexOptions.Compiled);
|
||||
private static readonly ConcurrentDictionary<string, CachedCatalog> CatalogCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly IReadOnlyDictionary<string, string> ExplicitRgbSpecMap = BuildExplicitRgbSpecMap();
|
||||
|
||||
public static string ResolveFallbackAssetPath(
|
||||
string templateFolderPath,
|
||||
string templateName,
|
||||
string partyName,
|
||||
PartyColorAssetUsage usage)
|
||||
{
|
||||
var catalog = LoadCatalog(templateFolderPath, templateName);
|
||||
if (catalog is null || !TryResolveColor(catalog, partyName, usage, out var sectionName, out var color))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return GenerateSolidColorPng(templateName, partyName, usage, sectionName, color);
|
||||
}
|
||||
|
||||
public static bool HasStyleColorBinding(string templateFolderPath, string templateName, string sectionName)
|
||||
{
|
||||
var catalog = LoadCatalog(templateFolderPath, templateName);
|
||||
return catalog is not null &&
|
||||
catalog.Sections.TryGetValue(NormalizeSectionKey(sectionName), out var section) &&
|
||||
section.StyleBinding is not null;
|
||||
}
|
||||
|
||||
public static bool TryResolveStyleColor(
|
||||
string templateFolderPath,
|
||||
string templateName,
|
||||
string sectionName,
|
||||
string partyName,
|
||||
out PartyStyleColorSpec styleColor)
|
||||
{
|
||||
styleColor = default;
|
||||
|
||||
var catalog = LoadCatalog(templateFolderPath, templateName);
|
||||
if (catalog is null ||
|
||||
!catalog.Sections.TryGetValue(NormalizeSectionKey(sectionName), out var section) ||
|
||||
section.StyleBinding is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName))
|
||||
{
|
||||
if (!section.PartyColors.TryGetValue(candidatePartyName, out var color))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
styleColor = new PartyStyleColorSpec(
|
||||
section.StyleBinding.StyleType,
|
||||
section.StyleBinding.Order,
|
||||
color.R,
|
||||
color.G,
|
||||
color.B,
|
||||
byte.MaxValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static TemplatePartyColorCatalog? LoadCatalog(string templateFolderPath, string templateName)
|
||||
{
|
||||
var rgbFilePath = ResolveRgbSpecPath(templateFolderPath, templateName);
|
||||
if (string.IsNullOrWhiteSpace(rgbFilePath) || !File.Exists(rgbFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lastWriteTimeUtc = File.GetLastWriteTimeUtc(rgbFilePath);
|
||||
if (CatalogCache.TryGetValue(rgbFilePath, out var cached) &&
|
||||
cached.LastWriteTimeUtc == lastWriteTimeUtc)
|
||||
{
|
||||
return cached.Catalog;
|
||||
}
|
||||
|
||||
var parsed = ParseCatalog(rgbFilePath);
|
||||
CatalogCache[rgbFilePath] = new CachedCatalog(lastWriteTimeUtc, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static string? ResolveRgbSpecPath(string templateFolderPath, string templateName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templateFolderPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var rgbDirectoryPath = Path.Combine(templateFolderPath, "RGB");
|
||||
if (!Directory.Exists(rgbDirectoryPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var folderName = Path.GetFileName(Path.GetFullPath(templateFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (TryGetExplicitRgbSpecBaseName(folderName, templateName, out var explicitSpecBaseName) &&
|
||||
!string.IsNullOrWhiteSpace(explicitSpecBaseName))
|
||||
{
|
||||
var explicitSpecPath = Path.Combine(rgbDirectoryPath, explicitSpecBaseName + ".txt");
|
||||
if (File.Exists(explicitSpecPath))
|
||||
{
|
||||
return explicitSpecPath;
|
||||
}
|
||||
}
|
||||
|
||||
var exactPath = Path.Combine(rgbDirectoryPath, templateName + ".txt");
|
||||
if (File.Exists(exactPath))
|
||||
{
|
||||
return exactPath;
|
||||
}
|
||||
|
||||
var candidates = Directory.GetFiles(rgbDirectoryPath, "*.txt");
|
||||
if (candidates.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidates
|
||||
.Select(path => new
|
||||
{
|
||||
Path = path,
|
||||
Score = ScoreTemplateMatch(templateName, Path.GetFileNameWithoutExtension(path))
|
||||
})
|
||||
.Where(candidate => candidate.Score > 0)
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault()
|
||||
?.Path;
|
||||
}
|
||||
|
||||
private static bool TryGetExplicitRgbSpecBaseName(string folderName, string templateName, out string? specBaseName)
|
||||
{
|
||||
return ExplicitRgbSpecMap.TryGetValue(BuildExplicitMapKey(folderName, templateName), out specBaseName);
|
||||
}
|
||||
|
||||
private static int ScoreTemplateMatch(string templateName, string fileBaseName)
|
||||
{
|
||||
if (string.Equals(templateName, fileBaseName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1000;
|
||||
}
|
||||
|
||||
var normalizedTemplateName = NormalizeTemplateName(templateName);
|
||||
var normalizedFileBaseName = NormalizeTemplateName(fileBaseName);
|
||||
if (string.Equals(normalizedTemplateName, normalizedFileBaseName, StringComparison.Ordinal))
|
||||
{
|
||||
return 900;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
if (normalizedFileBaseName.Contains(normalizedTemplateName, StringComparison.Ordinal) ||
|
||||
normalizedTemplateName.Contains(normalizedFileBaseName, StringComparison.Ordinal))
|
||||
{
|
||||
score += 300;
|
||||
}
|
||||
|
||||
var templateTokens = GetTemplateTokens(templateName);
|
||||
var fileTokens = GetTemplateTokens(fileBaseName);
|
||||
foreach (var token in templateTokens)
|
||||
{
|
||||
if (fileTokens.Contains(token))
|
||||
{
|
||||
score += 100;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static HashSet<string> GetTemplateTokens(string value)
|
||||
{
|
||||
return value
|
||||
.Split(['_', ',', ' ', '(', ')'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(token => CleanupPattern.Replace(token, string.Empty))
|
||||
.Where(token => !string.IsNullOrWhiteSpace(token))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string NormalizeTemplateName(string value)
|
||||
{
|
||||
return CleanupPattern.Replace(value, string.Empty);
|
||||
}
|
||||
|
||||
private static string BuildExplicitMapKey(string folderName, string templateName)
|
||||
{
|
||||
return $"{folderName}|{templateName}";
|
||||
}
|
||||
|
||||
private static TemplatePartyColorCatalog ParseCatalog(string rgbFilePath)
|
||||
{
|
||||
var sections = new Dictionary<string, SectionBuilder>(StringComparer.OrdinalIgnoreCase);
|
||||
var headerBuilder = new StringBuilder();
|
||||
List<SectionHeaderEntry>? currentSectionHeaders = null;
|
||||
var inHeader = false;
|
||||
|
||||
foreach (var rawLine in File.ReadLines(rgbFilePath, Encoding.UTF8))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("(", StringComparison.Ordinal))
|
||||
{
|
||||
headerBuilder.Clear();
|
||||
headerBuilder.AppendLine(line);
|
||||
inHeader = !line.Contains(')');
|
||||
if (!inHeader)
|
||||
{
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inHeader)
|
||||
{
|
||||
headerBuilder.AppendLine(line);
|
||||
if (line.Contains(')'))
|
||||
{
|
||||
inHeader = false;
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseRgbRow(line, out var partyName, out var color))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var sectionHeader in currentSectionHeaders)
|
||||
{
|
||||
var sectionKey = NormalizeSectionKey(sectionHeader.SectionName);
|
||||
if (!sections.TryGetValue(sectionKey, out var sectionBuilder))
|
||||
{
|
||||
sectionBuilder = new SectionBuilder(sectionHeader.SectionName, sectionHeader.StyleBinding);
|
||||
sections[sectionKey] = sectionBuilder;
|
||||
}
|
||||
else if (sectionBuilder.StyleBinding is null && sectionHeader.StyleBinding is not null)
|
||||
{
|
||||
sectionBuilder.StyleBinding = sectionHeader.StyleBinding;
|
||||
}
|
||||
|
||||
sectionBuilder.PartyColors[NormalizePartyKey(partyName)] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return new TemplatePartyColorCatalog(
|
||||
sections.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => new PartyColorSection(
|
||||
pair.Value.DisplayName,
|
||||
pair.Value.StyleBinding,
|
||||
pair.Value.PartyColors.ToDictionary(
|
||||
colorPair => colorPair.Key,
|
||||
colorPair => colorPair.Value,
|
||||
StringComparer.OrdinalIgnoreCase)),
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static List<SectionHeaderEntry> ExtractSectionHeaders(string header)
|
||||
{
|
||||
var entries = new List<SectionHeaderEntry>();
|
||||
foreach (var rawLine in header.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var line = rawLine.Trim().Trim('(', ')').Trim();
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseSectionHeaderLine(line, out var sectionNames, out var styleBinding))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var sectionName in sectionNames)
|
||||
{
|
||||
var normalizedSectionName = NormalizeSectionKey(sectionName);
|
||||
if (entries.Any(existing => string.Equals(existing.SectionKey, normalizedSectionName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new SectionHeaderEntry(sectionName, normalizedSectionName, styleBinding));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static bool TryParseSectionHeaderLine(
|
||||
string line,
|
||||
out IReadOnlyList<string> sectionNames,
|
||||
out PartyStyleBinding? styleBinding)
|
||||
{
|
||||
sectionNames = Array.Empty<string>();
|
||||
styleBinding = null;
|
||||
|
||||
var colonIndex = line.IndexOf(':');
|
||||
var firstArrowIndex = line.IndexOf('>');
|
||||
if (colonIndex < 0 && firstArrowIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string sectionsPart;
|
||||
string bindingPart;
|
||||
if (colonIndex >= 0 && (firstArrowIndex < 0 || colonIndex < firstArrowIndex))
|
||||
{
|
||||
sectionsPart = line[..colonIndex];
|
||||
bindingPart = line[(colonIndex + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
sectionsPart = line[..firstArrowIndex];
|
||||
bindingPart = line[(firstArrowIndex + 1)..];
|
||||
}
|
||||
|
||||
var parsedSections = sectionsPart
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (parsedSections.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
styleBinding = TryParseStyleBinding(bindingPart);
|
||||
sectionNames = parsedSections;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PartyStyleBinding? TryParseStyleBinding(string bindingPart)
|
||||
{
|
||||
var tokens = bindingPart
|
||||
.Split('>', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(token => token.Trim())
|
||||
.Where(token => !string.IsNullOrWhiteSpace(token))
|
||||
.ToArray();
|
||||
if (tokens.Length == 0 ||
|
||||
!tokens.Any(token => token.Contains("color", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var styleToken = tokens.FirstOrDefault(token => StyleTargetPattern.IsMatch(token));
|
||||
if (styleToken is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = StyleTargetPattern.Match(styleToken);
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var styleType = match.Groups["target"].Value.ToLowerInvariant() switch
|
||||
{
|
||||
"face" => eKStyleType.STYLE_TYPE_FACE,
|
||||
"edge" => eKStyleType.STYLE_TYPE_EDGE,
|
||||
"shadow" => eKStyleType.STYLE_TYPE_SHADOW,
|
||||
"underline" => eKStyleType.STYLE_TYPE_UNDERLINE,
|
||||
"frame" => eKStyleType.STYLE_TYPE_FRAME,
|
||||
_ => (eKStyleType?)null
|
||||
};
|
||||
if (styleType is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var order = 0;
|
||||
if (int.TryParse(match.Groups["order"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedOrder) &&
|
||||
parsedOrder > 1)
|
||||
{
|
||||
order = parsedOrder - 1;
|
||||
}
|
||||
|
||||
return new PartyStyleBinding(styleType.Value, order);
|
||||
}
|
||||
|
||||
private static bool TryParseRgbRow(string line, out string partyName, out PartyRgbColor color)
|
||||
{
|
||||
partyName = string.Empty;
|
||||
color = default;
|
||||
|
||||
var match = RgbRowPattern.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!byte.TryParse(match.Groups["r"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var r) ||
|
||||
!byte.TryParse(match.Groups["g"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var g) ||
|
||||
!byte.TryParse(match.Groups["b"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var b))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
partyName = match.Groups["party"].Value.Trim();
|
||||
color = new PartyRgbColor(r, g, b);
|
||||
return !string.IsNullOrWhiteSpace(partyName);
|
||||
}
|
||||
|
||||
private static bool TryResolveColor(
|
||||
TemplatePartyColorCatalog catalog,
|
||||
string partyName,
|
||||
PartyColorAssetUsage usage,
|
||||
out string sectionName,
|
||||
out PartyRgbColor color)
|
||||
{
|
||||
foreach (var candidateSectionName in GetSectionCandidates(usage))
|
||||
{
|
||||
if (!catalog.Sections.TryGetValue(NormalizeSectionKey(candidateSectionName), out var section))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidatePartyName in GetPartyKeyCandidates(partyName))
|
||||
{
|
||||
if (section.PartyColors.TryGetValue(candidatePartyName, out color))
|
||||
{
|
||||
sectionName = section.DisplayName;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sectionName = string.Empty;
|
||||
color = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetSectionCandidates(PartyColorAssetUsage usage)
|
||||
{
|
||||
return usage switch
|
||||
{
|
||||
PartyColorAssetUsage.Bar => ["정당바", "정당판"],
|
||||
PartyColorAssetUsage.Plate => ["정당판", "정당바"],
|
||||
PartyColorAssetUsage.Outline => ["정당원", "정당색", "정당판", "정당바"],
|
||||
PartyColorAssetUsage.Color => ["정당색", "정당바", "정당판"],
|
||||
PartyColorAssetUsage.Group => ["그룹", "정당바", "정당판"],
|
||||
_ => Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetPartyKeyCandidates(string partyName)
|
||||
{
|
||||
var trimmed = partyName.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return NormalizePartyKey(trimmed);
|
||||
|
||||
var noSpaces = NormalizePartyKey(trimmed.Replace(" ", string.Empty));
|
||||
if (!string.Equals(noSpaces, NormalizePartyKey(trimmed), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return noSpaces;
|
||||
}
|
||||
|
||||
if (trimmed.Contains("무소속", StringComparison.Ordinal) ||
|
||||
trimmed.Contains("무기타", StringComparison.Ordinal) ||
|
||||
trimmed.Contains("기타", StringComparison.Ordinal))
|
||||
{
|
||||
yield return "무기타";
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePartyKey(string partyName)
|
||||
{
|
||||
return partyName.Trim().Replace(" ", string.Empty);
|
||||
}
|
||||
|
||||
private static string NormalizeSectionKey(string sectionName)
|
||||
{
|
||||
return sectionName.Trim().Replace(" ", string.Empty);
|
||||
}
|
||||
|
||||
private static string GenerateSolidColorPng(
|
||||
string templateName,
|
||||
string partyName,
|
||||
PartyColorAssetUsage usage,
|
||||
string sectionName,
|
||||
PartyRgbColor color)
|
||||
{
|
||||
var cacheDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Tornado3_2026Election",
|
||||
"GeneratedPartyAssets");
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
|
||||
var safeTemplateName = SanitizeFileName(templateName);
|
||||
var safePartyName = SanitizeFileName(partyName);
|
||||
var safeSectionName = SanitizeFileName(sectionName);
|
||||
var fileName = $"{safeTemplateName}_{usage}_{safeSectionName}_{safePartyName}_{color.R}_{color.G}_{color.B}.png";
|
||||
var filePath = Path.Combine(cacheDirectory, fileName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
var (width, height) = usage == PartyColorAssetUsage.Bar
|
||||
? (DefaultBarWidth, DefaultBarHeight)
|
||||
: (DefaultPlateWidth, DefaultPlateHeight);
|
||||
WriteSolidColorPng(filePath, width, height, color);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var sanitized = InvalidFileNamePattern.Replace(value, "_").Trim('_');
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "party" : sanitized;
|
||||
}
|
||||
|
||||
private static void WriteSolidColorPng(string filePath, int width, int height, PartyRgbColor color)
|
||||
{
|
||||
var rawBytes = new byte[height * (1 + (width * 4))];
|
||||
var index = 0;
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
rawBytes[index++] = 0;
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
rawBytes[index++] = color.R;
|
||||
rawBytes[index++] = color.G;
|
||||
rawBytes[index++] = color.B;
|
||||
rawBytes[index++] = byte.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
using var compressedStream = new MemoryStream();
|
||||
using (var zlibStream = new ZLibStream(compressedStream, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
{
|
||||
zlibStream.Write(rawBytes, 0, rawBytes.Length);
|
||||
}
|
||||
|
||||
using var outputStream = File.Create(filePath);
|
||||
outputStream.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 });
|
||||
|
||||
Span<byte> headerBytes =
|
||||
[
|
||||
0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
];
|
||||
WriteBigEndian(width, headerBytes[..4]);
|
||||
WriteBigEndian(height, headerBytes.Slice(4, 4));
|
||||
WriteChunk(outputStream, "IHDR", headerBytes.ToArray());
|
||||
WriteChunk(outputStream, "IDAT", compressedStream.ToArray());
|
||||
WriteChunk(outputStream, "IEND", Array.Empty<byte>());
|
||||
}
|
||||
|
||||
private static void WriteChunk(Stream outputStream, string chunkType, byte[] data)
|
||||
{
|
||||
Span<byte> lengthBytes = stackalloc byte[4];
|
||||
WriteBigEndian(data.Length, lengthBytes);
|
||||
outputStream.Write(lengthBytes);
|
||||
|
||||
var chunkTypeBytes = Encoding.ASCII.GetBytes(chunkType);
|
||||
outputStream.Write(chunkTypeBytes, 0, chunkTypeBytes.Length);
|
||||
outputStream.Write(data, 0, data.Length);
|
||||
|
||||
var crc = ComputeCrc32(chunkTypeBytes, data);
|
||||
Span<byte> crcBytes = stackalloc byte[4];
|
||||
WriteBigEndian(unchecked((int)crc), crcBytes);
|
||||
outputStream.Write(crcBytes);
|
||||
}
|
||||
|
||||
private static void WriteBigEndian(int value, Span<byte> bytes)
|
||||
{
|
||||
bytes[0] = (byte)((value >> 24) & 255);
|
||||
bytes[1] = (byte)((value >> 16) & 255);
|
||||
bytes[2] = (byte)((value >> 8) & 255);
|
||||
bytes[3] = (byte)(value & 255);
|
||||
}
|
||||
|
||||
private static uint ComputeCrc32(byte[] chunkTypeBytes, byte[] data)
|
||||
{
|
||||
var crc = 4294967295u;
|
||||
Update(chunkTypeBytes);
|
||||
Update(data);
|
||||
return ~crc;
|
||||
|
||||
void Update(byte[] bytes)
|
||||
{
|
||||
foreach (var value in bytes)
|
||||
{
|
||||
crc ^= value;
|
||||
for (var bit = 0; bit < 8; bit++)
|
||||
{
|
||||
crc = (crc & 1) != 0
|
||||
? 3988292384u ^ (crc >> 1)
|
||||
: crc >> 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CachedCatalog(DateTime LastWriteTimeUtc, TemplatePartyColorCatalog Catalog);
|
||||
private sealed record TemplatePartyColorCatalog(IReadOnlyDictionary<string, PartyColorSection> Sections);
|
||||
private sealed record PartyColorSection(
|
||||
string DisplayName,
|
||||
PartyStyleBinding? StyleBinding,
|
||||
IReadOnlyDictionary<string, PartyRgbColor> PartyColors);
|
||||
private sealed record SectionHeaderEntry(string SectionName, string SectionKey, PartyStyleBinding? StyleBinding);
|
||||
private sealed class SectionBuilder
|
||||
{
|
||||
public SectionBuilder(string displayName, PartyStyleBinding? styleBinding)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
StyleBinding = styleBinding;
|
||||
}
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public PartyStyleBinding? StyleBinding { get; set; }
|
||||
|
||||
public Dictionary<string, PartyRgbColor> PartyColors { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record PartyStyleBinding(eKStyleType StyleType, int Order);
|
||||
private readonly record struct PartyRgbColor(byte R, byte G, byte B);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildExplicitRgbSpecMap()
|
||||
{
|
||||
var mappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static void Add(
|
||||
IDictionary<string, string> target,
|
||||
string folderName,
|
||||
string specBaseName,
|
||||
params string[] templateNames)
|
||||
{
|
||||
foreach (var templateName in templateNames)
|
||||
{
|
||||
target[BuildExplicitMapKey(folderName, templateName)] = specBaseName;
|
||||
}
|
||||
}
|
||||
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-2위_ani_광역단체장",
|
||||
"1-2위_ani_광역단체장");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-2위_ani_기초단체장",
|
||||
"1-2위_ani_기초단체장",
|
||||
"1-2위_ani_기초단체장_5760",
|
||||
"1-2위_ani_기초단체장_L");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-2위_광역단체장, 보궐",
|
||||
"1-2위_광역단체장",
|
||||
"1-2위_광역단체장_5760",
|
||||
"1-2위_광역단체장_L",
|
||||
"1-2위_보궐선거");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-2위_광역단체장,기초단체장_시도별영상",
|
||||
"1-2위_광역단체장_시도별영상",
|
||||
"1-2위_기초단체장_시도별영상");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-2위_교육감",
|
||||
"1-2위_교육감");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-2위_기초단체장",
|
||||
"1-2위_기초단체장");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-3위_ani_광역단체장,보궐",
|
||||
"1-3위_ani_광역단체장",
|
||||
"1-3위_보궐선거");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"1-3위_ani_기초단체장(5760동일)",
|
||||
"1-3위_ani_기초단체장",
|
||||
"1-3위_기초단체장_5760",
|
||||
"1-3위_기초단체장_L",
|
||||
"1-3위_기초단체장_L_1");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"경력",
|
||||
"경력_광역단체장_in",
|
||||
"경력_기초단체장_in");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"당선",
|
||||
"당선_광역단체장",
|
||||
"당선_광역단체장_HD",
|
||||
"당선_광역단체장_L",
|
||||
"당선_광역의원",
|
||||
"당선_광역의원_HD",
|
||||
"당선_광역의원_L",
|
||||
"당선_기초단체장",
|
||||
"당선_기초단체장_HD",
|
||||
"당선_기초단체장_L",
|
||||
"당선_기초의원",
|
||||
"당선_기초의원_HD",
|
||||
"당선_기초의원_L");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"당선_교육감",
|
||||
"당선_교육감",
|
||||
"당선_교육감_HD",
|
||||
"당선_교육감_L");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"모든후보",
|
||||
"모든후보_광역단체장",
|
||||
"모든후보_광역단체장_5760",
|
||||
"모든후보_광역단체장_5760_END",
|
||||
"모든후보_광역단체장_END",
|
||||
"모든후보_광역단체장_L",
|
||||
"모든후보_광역단체장_L_END",
|
||||
"모든후보_기초단체장",
|
||||
"모든후보_기초단체장_5760",
|
||||
"모든후보_기초단체장_5760_END",
|
||||
"모든후보_기초단체장_END",
|
||||
"모든후보_기초단체장_L",
|
||||
"모든후보_기초단체장_L_END");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"모든후보_교육감",
|
||||
"모든후보_교육감",
|
||||
"모든후보_교육감_5760",
|
||||
"모든후보_교육감_5760_END",
|
||||
"모든후보_교육감_END",
|
||||
"모든후보_교육감_L",
|
||||
"모든후보_교육감_L_END");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"사전_역대당선",
|
||||
"사전_역대당선자",
|
||||
"사전_역대당선자_기초단체장");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"사전_역대당선_교육감",
|
||||
"사전_역대당선자_교육감");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"이시각1위_광역단체장",
|
||||
"이시각1위_광역단체장",
|
||||
"이시각1위_광역단체장_HD",
|
||||
"이시각1위_광역단체장_L");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"이시각1위_기초단체장(5760동일)",
|
||||
"이시각1위_기초단체장",
|
||||
"이시각1위_기초단체장_HD",
|
||||
"이시각1위_기초단체장_L");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"접전,초접전",
|
||||
"접전_광역단체장",
|
||||
"접전_기초단체장",
|
||||
"초접전_광역단체장",
|
||||
"초접전_기초단체장");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"판세_광역단체장",
|
||||
"판세_광역단체장",
|
||||
"판세_기초단체장",
|
||||
"판세_기초단체장_5760",
|
||||
"판세_기초단체장_7680");
|
||||
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Bottom_민방",
|
||||
"1-2위, 1-3위, 이시각1위",
|
||||
"1-2위_광역단체장",
|
||||
"1-2위_기초단체장",
|
||||
"1-3위_광역단체장",
|
||||
"1-3위_기초단체장",
|
||||
"1위_광역단체장",
|
||||
"1위_기초단체장");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Bottom_민방",
|
||||
"당선",
|
||||
"당선_광역단체장",
|
||||
"당선_광역의원",
|
||||
"당선_기초단체장",
|
||||
"당선_기초의원");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Bottom_민방",
|
||||
"모든후보",
|
||||
"전후보_광역단체장",
|
||||
"전후보_기초단체장");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Bottom_민방",
|
||||
"모든후보_교육감",
|
||||
"전후보_교육감");
|
||||
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Top_민방",
|
||||
"1-2위_사진",
|
||||
"광역단체장_2인",
|
||||
"기초단체장_2인");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Top_민방",
|
||||
"1-2위_텍스트",
|
||||
"광역단체장_2인_텍스트",
|
||||
"기초단체장_2인_텍스트");
|
||||
|
||||
return mappings;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PartyColorAssetUsage
|
||||
{
|
||||
Bar,
|
||||
Plate,
|
||||
Outline,
|
||||
Color,
|
||||
Group
|
||||
}
|
||||
|
||||
internal readonly record struct PartyStyleColorSpec(
|
||||
eKStyleType StyleType,
|
||||
int Order,
|
||||
byte R,
|
||||
byte G,
|
||||
byte B,
|
||||
byte A);
|
||||
789
Tornado3_2026Election/Services/SbsElectionApiClient.cs
Normal file
@@ -0,0 +1,789 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tornado3_2026Election.Domain;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public sealed class SbsElectionApiClient : IDisposable
|
||||
{
|
||||
private static readonly Uri BaseUri = new("http://202.31.153.141:8421/");
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, SbsElectionConfiguration> ElectionConfigurations =
|
||||
new Dictionary<string, SbsElectionConfiguration>(StringComparer.Ordinal)
|
||||
{
|
||||
["광역단체장"] = new SbsElectionConfiguration(3, true),
|
||||
["교육감"] = new SbsElectionConfiguration(11, false),
|
||||
["기초단체장"] = new SbsElectionConfiguration(4, false)
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> FullRegionNames =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["서울"] = "서울특별시",
|
||||
["부산"] = "부산광역시",
|
||||
["대구"] = "대구광역시",
|
||||
["인천"] = "인천광역시",
|
||||
["광주"] = "광주광역시",
|
||||
["대전"] = "대전광역시",
|
||||
["울산"] = "울산광역시",
|
||||
["세종"] = "세종특별자치시",
|
||||
["경기"] = "경기도",
|
||||
["강원"] = "강원특별자치도",
|
||||
["충북"] = "충청북도",
|
||||
["충남"] = "충청남도",
|
||||
["전북"] = "전북특별자치도",
|
||||
["전남"] = "전라남도",
|
||||
["경북"] = "경상북도",
|
||||
["경남"] = "경상남도",
|
||||
["제주"] = "제주특별자치도"
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _disposeHttpClient;
|
||||
private IReadOnlyList<SbsRegionInfo>? _sidoRegions;
|
||||
private readonly Dictionary<int, IReadOnlyList<SbsRegionInfo>> _districtRegions = new();
|
||||
|
||||
public SbsElectionApiClient(HttpClient? httpClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(10) };
|
||||
_disposeHttpClient = httpClient is null;
|
||||
}
|
||||
|
||||
public async Task<SbsElectionRefreshResult> RefreshAsync(
|
||||
BroadcastPhase phase,
|
||||
string electionType,
|
||||
string districtName,
|
||||
string districtCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"'{electionType}'은 현재 SBS API 실연동 범위에 없습니다. 현재는 광역단체장, 교육감, 기초단체장까지만 연결되어 있습니다.");
|
||||
}
|
||||
|
||||
return phase switch
|
||||
{
|
||||
BroadcastPhase.PreElection => await RefreshTurnoutAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false),
|
||||
BroadcastPhase.Counting => await RefreshCountingAsync(configuration, districtName, districtCode, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw new InvalidOperationException($"지원하지 않는 방송 단계입니다: {phase}")
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DistrictSelectionOption>> GetDistrictOptionsAsync(
|
||||
string electionType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration))
|
||||
{
|
||||
return Array.Empty<DistrictSelectionOption>();
|
||||
}
|
||||
|
||||
var regions = await GetElectionDistrictRegionsAsync(configuration.SungerType, cancellationToken).ConfigureAwait(false);
|
||||
return regions
|
||||
.Select(region => CreateDistrictSelectionOption(configuration.SungerType, region))
|
||||
.Where(option => !string.IsNullOrWhiteSpace(option.DisplayName))
|
||||
.OrderBy(option => option.RegionName, StringComparer.Ordinal)
|
||||
.ThenBy(option => option.DistrictName, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CountingOverviewItem>> GetCountingOverviewAsync(
|
||||
string electionType,
|
||||
IReadOnlyList<DistrictSelectionOption> districts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) || districts.Count == 0)
|
||||
{
|
||||
return Array.Empty<CountingOverviewItem>();
|
||||
}
|
||||
|
||||
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<CountingOverviewItem>();
|
||||
}
|
||||
|
||||
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 overviewItems = new List<(int Order, CountingOverviewItem Item)>();
|
||||
|
||||
foreach (var districtChunk in requestedDistricts.Chunk(24))
|
||||
{
|
||||
var ids = string.Join(",", districtChunk.Select(district => district.DistrictCode));
|
||||
var items = await GetArrayAsync<SbsCountingItem>(
|
||||
$"gaepyo/{configuration.SungerType}/sungergus?ids={ids}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var regionId = item.Region?.Id;
|
||||
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))));
|
||||
}
|
||||
}
|
||||
|
||||
return overviewItems
|
||||
.OrderBy(item => item.Order)
|
||||
.Select(item => item.Item)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposeHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SbsElectionRefreshResult> RefreshTurnoutAsync(
|
||||
SbsElectionConfiguration configuration,
|
||||
string districtName,
|
||||
string districtCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!configuration.SupportsPreElection)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"선택한 선거 종류는 SBS API 문서 기준으로 사전 투표율 연동 대상이 아닙니다.");
|
||||
}
|
||||
|
||||
var sido = await ResolveSidoRegionAsync(districtName, districtCode, cancellationToken).ConfigureAwait(false);
|
||||
var items = await GetArrayAsync<SbsTurnoutItem>(
|
||||
$"tupyo/{configuration.SungerType}/sidos?ids={Uri.EscapeDataString(sido.Id)}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var item = items.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("SBS API가 해당 지역의 투표 데이터를 반환하지 않았습니다.");
|
||||
|
||||
var regionName = ExpandRegionName(item.Region?.Name1 ?? item.Region?.Name ?? districtName);
|
||||
return new SbsElectionRefreshResult(
|
||||
DistrictName: regionName,
|
||||
DistrictCode: sido.Id,
|
||||
RegionName: regionName,
|
||||
ElectionDistrictName: regionName,
|
||||
TotalExpectedVotes: item.Sungerinsu,
|
||||
TurnoutVotes: item.Total?.Tupyosu ?? 0,
|
||||
CountedRate: null,
|
||||
CountedVotes: null,
|
||||
RemainingVotes: null,
|
||||
Candidates: null,
|
||||
ReceivedAt: DateTimeOffset.Now,
|
||||
SourcePath: $"GET /tupyo/{configuration.SungerType}/sidos?ids={sido.Id}");
|
||||
}
|
||||
|
||||
private async Task<SbsElectionRefreshResult> RefreshCountingAsync(
|
||||
SbsElectionConfiguration configuration,
|
||||
string districtName,
|
||||
string districtCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var district = await ResolveElectionDistrictAsync(
|
||||
configuration.SungerType,
|
||||
districtName,
|
||||
districtCode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = await GetArrayAsync<SbsCountingItem>(
|
||||
$"gaepyo/{configuration.SungerType}/sungergus?ids={Uri.EscapeDataString(district.Id)}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var item = items.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("SBS API가 해당 지역의 개표 데이터를 반환하지 않았습니다.");
|
||||
|
||||
var candidates = (item.Hubojas ?? [])
|
||||
.Select(MapCandidate)
|
||||
.OrderByDescending(candidate => candidate.VoteCount)
|
||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (candidates.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SBS API 응답에 후보자 정보가 없습니다.");
|
||||
}
|
||||
|
||||
var totalVotes = item.Total?.Tupyosu ?? candidates.Sum(candidate => candidate.VoteCount);
|
||||
var regionName = ExpandRegionName(item.Region?.Name1 ?? district.Name1 ?? districtName);
|
||||
var districtLabel = BuildElectionDistrictLabel(configuration.SungerType, regionName, item.Region, district);
|
||||
var displayName = configuration.SungerType == 4
|
||||
? BuildFullDistrictDisplayName(regionName, districtLabel)
|
||||
: regionName;
|
||||
|
||||
return new SbsElectionRefreshResult(
|
||||
DistrictName: displayName,
|
||||
DistrictCode: district.Id,
|
||||
RegionName: regionName,
|
||||
ElectionDistrictName: districtLabel,
|
||||
TotalExpectedVotes: Math.Max(totalVotes, 1),
|
||||
TurnoutVotes: Math.Max(totalVotes, 0),
|
||||
CountedRate: item.Total?.GaepyoRate,
|
||||
CountedVotes: item.Total?.Gaepyosu,
|
||||
RemainingVotes: item.Total?.UncountedPyosu,
|
||||
Candidates: candidates,
|
||||
ReceivedAt: DateTimeOffset.Now,
|
||||
SourcePath: $"GET /gaepyo/{configuration.SungerType}/sungergus?ids={district.Id}");
|
||||
}
|
||||
|
||||
private static CandidateEntry MapCandidate(SbsCandidateItem item)
|
||||
{
|
||||
var total = item.Total ?? new SbsCandidateVoteSnapshot();
|
||||
return new CandidateEntry
|
||||
{
|
||||
CandidateCode = string.IsNullOrWhiteSpace(item.Giho) ? (item.Name ?? "후보") : item.Giho,
|
||||
Name = item.Name ?? "후보자명 미상",
|
||||
Party = item.Jeongdang?.Name ?? "무소속",
|
||||
VoteCount = total.Dugpyosu,
|
||||
VoteRate = total.DugpyoRate,
|
||||
HasImage = true,
|
||||
ManualJudgement = MapJudgement(item.Degree)
|
||||
};
|
||||
}
|
||||
|
||||
private static CandidateJudgement MapJudgement(string? degree)
|
||||
{
|
||||
return degree switch
|
||||
{
|
||||
"40" => CandidateJudgement.Leading,
|
||||
"50" => CandidateJudgement.Confirmed,
|
||||
"60" => CandidateJudgement.ElectedInProgress,
|
||||
"80" => CandidateJudgement.UnopposedElected,
|
||||
"90" => CandidateJudgement.ElectedAfterCountComplete,
|
||||
_ => CandidateJudgement.None
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SbsRegionInfo> ResolveSidoRegionAsync(
|
||||
string districtName,
|
||||
string districtCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_sidoRegions ??= await GetValueAsync<SbsRegionInfo>("sungerInfo/region?type=시도", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(districtCode))
|
||||
{
|
||||
var matchedById = _sidoRegions.FirstOrDefault(region => string.Equals(region.Id, districtCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (matchedById is not null)
|
||||
{
|
||||
return matchedById;
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedName = NormalizeRegionName(districtName);
|
||||
var matchedByName = _sidoRegions.FirstOrDefault(region =>
|
||||
string.Equals(NormalizeRegionName(region.Name), normalizedName, StringComparison.Ordinal) ||
|
||||
string.Equals(NormalizeRegionName(region.Name1), normalizedName, StringComparison.Ordinal));
|
||||
|
||||
return matchedByName
|
||||
?? throw new InvalidOperationException($"시도 정보를 찾지 못했습니다: '{districtName}'");
|
||||
}
|
||||
|
||||
private async Task<SbsRegionInfo> ResolveElectionDistrictAsync(
|
||||
int sungerType,
|
||||
string districtName,
|
||||
string districtCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var regions = await GetElectionDistrictRegionsAsync(sungerType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(districtCode))
|
||||
{
|
||||
var matchedById = regions.FirstOrDefault(region => string.Equals(region.Id, districtCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (matchedById is not null)
|
||||
{
|
||||
return matchedById;
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedName = NormalizeRegionName(districtName);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
var matchedByName = regions
|
||||
.Where(region => MatchesElectionDistrictName(region, normalizedName))
|
||||
.ToArray();
|
||||
|
||||
if (matchedByName.Length == 1)
|
||||
{
|
||||
return matchedByName[0];
|
||||
}
|
||||
|
||||
if (matchedByName.Length > 1 && !string.IsNullOrWhiteSpace(districtCode))
|
||||
{
|
||||
var narrowed = matchedByName
|
||||
.Where(region =>
|
||||
string.Equals(region.Name1Id, districtCode, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(region.Name2Id, districtCode, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (narrowed.Length == 1)
|
||||
{
|
||||
return narrowed[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(districtCode))
|
||||
{
|
||||
var matchedBySecondaryCode = regions
|
||||
.Where(region => string.Equals(region.Name2Id, districtCode, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (matchedBySecondaryCode.Length == 1)
|
||||
{
|
||||
return matchedBySecondaryCode[0];
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"선거구 정보를 찾지 못했습니다: '{districtName}'");
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SbsRegionInfo>> GetElectionDistrictRegionsAsync(
|
||||
int sungerType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_districtRegions.TryGetValue(sungerType, out var regions))
|
||||
{
|
||||
regions = await GetValueAsync<SbsRegionInfo>(
|
||||
$"sungerInfo/region?type=선거구&sungerType={sungerType}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_districtRegions[sungerType] = regions;
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<T>> GetValueAsync<T>(string relativePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeList<T>(json, relativePath, preferValueProperty: true);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<T>> GetArrayAsync<T>(string relativePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = await GetJsonAsync(relativePath, cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeList<T>(json, relativePath, preferValueProperty: false);
|
||||
}
|
||||
|
||||
private async Task<string> GetJsonAsync(string relativePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(relativePath, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> DeserializeList<T>(string json, string relativePath, bool preferValueProperty)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
JsonElement arrayElement;
|
||||
if (preferValueProperty &&
|
||||
root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("value", out var valueElement) &&
|
||||
valueElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
arrayElement = valueElement;
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
arrayElement = root;
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("value", out valueElement) &&
|
||||
valueElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
arrayElement = valueElement;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"SBS API 배열 응답을 확인하지 못했습니다: {relativePath}");
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<List<T>>(arrayElement.GetRawText(), SerializerOptions);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
private static bool MatchesElectionDistrictName(SbsRegionInfo region, string normalizedName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(NormalizeRegionName(region.Name), normalizedName, StringComparison.Ordinal) ||
|
||||
string.Equals(NormalizeRegionName(region.Name1), normalizedName, StringComparison.Ordinal) ||
|
||||
string.Equals(NormalizeRegionName(region.Name2), normalizedName, StringComparison.Ordinal) ||
|
||||
string.Equals(NormalizeRegionName(region.Name4), normalizedName, StringComparison.Ordinal) ||
|
||||
string.Equals(NormalizeRegionName(BuildElectionDistrictLabel(region)), normalizedName, StringComparison.Ordinal) ||
|
||||
string.Equals(NormalizeRegionName(BuildFullDistrictDisplayName(region)), normalizedName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string BuildElectionDistrictLabel(int sungerType, string regionName, SbsTurnoutRegion? region, SbsRegionInfo fallback)
|
||||
{
|
||||
return sungerType switch
|
||||
{
|
||||
3 => BuildMayorGovernorLabel(regionName, region?.Name4 ?? fallback.Name4),
|
||||
4 => BuildElectionDistrictLabel(region, fallback),
|
||||
_ => regionName
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildElectionDistrictLabel(SbsRegionInfo region)
|
||||
{
|
||||
return BuildElectionDistrictLabel(region.Name4, region.Name2 ?? region.Name);
|
||||
}
|
||||
|
||||
private static string BuildElectionDistrictLabel(SbsTurnoutRegion? region, SbsRegionInfo fallback)
|
||||
{
|
||||
if (region is not null)
|
||||
{
|
||||
var resolved = BuildElectionDistrictLabel(region.Name4, region.Name2 ?? region.Name);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return BuildElectionDistrictLabel(fallback);
|
||||
}
|
||||
|
||||
private static string BuildElectionDistrictLabel(string? officeName, string? shortName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(officeName))
|
||||
{
|
||||
var trimmed = officeName.Trim();
|
||||
if (trimmed.EndsWith("구청장", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed[..^2];
|
||||
}
|
||||
|
||||
if (trimmed.EndsWith("시장", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed[..^1];
|
||||
}
|
||||
|
||||
if (trimmed.EndsWith("군수", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed[..^1];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return shortName?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string BuildMayorGovernorLabel(string regionName, string? officeName)
|
||||
{
|
||||
var normalizedRegionName = ExpandRegionName(regionName);
|
||||
if (!string.IsNullOrWhiteSpace(officeName))
|
||||
{
|
||||
var trimmedOfficeName = officeName.Trim();
|
||||
if (trimmedOfficeName.EndsWith("시장", StringComparison.Ordinal))
|
||||
{
|
||||
return $"{NormalizeRegionName(normalizedRegionName)}시장";
|
||||
}
|
||||
|
||||
if (trimmedOfficeName.EndsWith("지사", StringComparison.Ordinal))
|
||||
{
|
||||
return $"{normalizedRegionName}지사";
|
||||
}
|
||||
|
||||
return trimmedOfficeName;
|
||||
}
|
||||
|
||||
if (normalizedRegionName.EndsWith("시", StringComparison.Ordinal))
|
||||
{
|
||||
return $"{NormalizeRegionName(normalizedRegionName)}시장";
|
||||
}
|
||||
|
||||
if (normalizedRegionName.EndsWith("도", StringComparison.Ordinal))
|
||||
{
|
||||
return $"{normalizedRegionName}지사";
|
||||
}
|
||||
|
||||
return normalizedRegionName;
|
||||
}
|
||||
|
||||
private static string BuildFullDistrictDisplayName(SbsRegionInfo region)
|
||||
{
|
||||
return BuildFullDistrictDisplayName(
|
||||
ExpandRegionName(region.Name1 ?? region.Name),
|
||||
BuildElectionDistrictLabel(region));
|
||||
}
|
||||
|
||||
private static string BuildFullDistrictDisplayName(string regionName, string districtLabel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(regionName))
|
||||
{
|
||||
return districtLabel;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(districtLabel) || string.Equals(regionName, districtLabel, StringComparison.Ordinal))
|
||||
{
|
||||
return regionName;
|
||||
}
|
||||
|
||||
return $"{regionName} {districtLabel}";
|
||||
}
|
||||
|
||||
private static DistrictSelectionOption CreateDistrictSelectionOption(int sungerType, SbsRegionInfo region)
|
||||
{
|
||||
var regionName = ExpandRegionName(region.Name1 ?? region.Name);
|
||||
var districtName = sungerType switch
|
||||
{
|
||||
3 => BuildMayorGovernorLabel(regionName, region.Name4),
|
||||
4 => BuildElectionDistrictLabel(region),
|
||||
_ => regionName
|
||||
};
|
||||
var displayName = sungerType == 4
|
||||
? BuildFullDistrictDisplayName(regionName, districtName)
|
||||
: regionName;
|
||||
|
||||
return new DistrictSelectionOption(
|
||||
DisplayName: displayName,
|
||||
DistrictCode: region.Id,
|
||||
RegionName: regionName,
|
||||
DistrictName: districtName,
|
||||
ParentRegionCode: region.Name1Id ?? string.Empty);
|
||||
}
|
||||
|
||||
private static string NormalizeRegionName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value.Trim();
|
||||
foreach (var pair in FullRegionNames)
|
||||
{
|
||||
normalized = normalized.Replace(pair.Value, pair.Key, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Replace("특별자치도", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("특별자치시", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("특별시", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("광역시", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("구청장", "구", StringComparison.Ordinal)
|
||||
.Replace("시장", "시", StringComparison.Ordinal)
|
||||
.Replace("군수", "군", StringComparison.Ordinal)
|
||||
.Replace("지사", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("교육감", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static string ExpandRegionName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = NormalizeRegionName(value);
|
||||
return FullRegionNames.TryGetValue(normalized, out var fullName)
|
||||
? fullName
|
||||
: value.Trim();
|
||||
}
|
||||
|
||||
private readonly record struct SbsElectionConfiguration(int SungerType, bool SupportsPreElection);
|
||||
|
||||
public sealed record DistrictSelectionOption(
|
||||
string DisplayName,
|
||||
string DistrictCode,
|
||||
string RegionName,
|
||||
string DistrictName,
|
||||
string ParentRegionCode);
|
||||
|
||||
public sealed record SbsElectionRefreshResult(
|
||||
string DistrictName,
|
||||
string DistrictCode,
|
||||
string RegionName,
|
||||
string ElectionDistrictName,
|
||||
int TotalExpectedVotes,
|
||||
int TurnoutVotes,
|
||||
double? CountedRate,
|
||||
int? CountedVotes,
|
||||
int? RemainingVotes,
|
||||
IReadOnlyList<CandidateEntry>? Candidates,
|
||||
DateTimeOffset ReceivedAt,
|
||||
string SourcePath);
|
||||
|
||||
public sealed record CountingOverviewItem(
|
||||
string DisplayName,
|
||||
double CountedRate,
|
||||
int CountedVotes,
|
||||
int TotalVotes,
|
||||
int UncountedVotes);
|
||||
|
||||
private sealed class SbsRegionInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name1")]
|
||||
public string? Name1 { get; set; }
|
||||
|
||||
[JsonPropertyName("name2")]
|
||||
public string? Name2 { get; set; }
|
||||
|
||||
[JsonPropertyName("name4")]
|
||||
public string? Name4 { get; set; }
|
||||
|
||||
[JsonPropertyName("name1Id")]
|
||||
public string? Name1Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name2Id")]
|
||||
public string? Name2Id { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsTurnoutItem
|
||||
{
|
||||
[JsonPropertyName("region")]
|
||||
public SbsTurnoutRegion? Region { get; set; }
|
||||
|
||||
[JsonPropertyName("sungerinsu")]
|
||||
public int Sungerinsu { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public SbsTurnoutVoteSnapshot? Total { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsTurnoutRegion
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("name1")]
|
||||
public string? Name1 { get; set; }
|
||||
|
||||
[JsonPropertyName("name2")]
|
||||
public string? Name2 { get; set; }
|
||||
|
||||
[JsonPropertyName("name4")]
|
||||
public string? Name4 { get; set; }
|
||||
|
||||
[JsonPropertyName("name1Id")]
|
||||
public string? Name1Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name2Id")]
|
||||
public string? Name2Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name4Id")]
|
||||
public string? Name4Id { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsTurnoutVoteSnapshot
|
||||
{
|
||||
[JsonPropertyName("tupyosu")]
|
||||
public int Tupyosu { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsCountingItem
|
||||
{
|
||||
[JsonPropertyName("region")]
|
||||
public SbsTurnoutRegion? Region { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public SbsCountingVoteSnapshot? Total { get; set; }
|
||||
|
||||
[JsonPropertyName("hubojas")]
|
||||
public List<SbsCandidateItem>? Hubojas { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsCountingVoteSnapshot
|
||||
{
|
||||
[JsonPropertyName("tupyosu")]
|
||||
public int Tupyosu { get; set; }
|
||||
|
||||
[JsonPropertyName("gaepyosu")]
|
||||
public int Gaepyosu { get; set; }
|
||||
|
||||
[JsonPropertyName("gaepyoRate")]
|
||||
public double GaepyoRate { get; set; }
|
||||
|
||||
[JsonPropertyName("uncountedPyosu")]
|
||||
public int UncountedPyosu { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsCandidateItem
|
||||
{
|
||||
[JsonPropertyName("giho")]
|
||||
public string? Giho { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("degree")]
|
||||
public string? Degree { get; set; }
|
||||
|
||||
[JsonPropertyName("jeongdang")]
|
||||
public SbsPartyInfo? Jeongdang { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public SbsCandidateVoteSnapshot? Total { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsPartyInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SbsCandidateVoteSnapshot
|
||||
{
|
||||
[JsonPropertyName("rank")]
|
||||
public int Rank { get; set; }
|
||||
|
||||
[JsonPropertyName("dugpyosu")]
|
||||
public int Dugpyosu { get; set; }
|
||||
|
||||
[JsonPropertyName("dugpyoRate")]
|
||||
public double DugpyoRate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -91,8 +91,11 @@ public sealed class TornadoManager : IDisposable
|
||||
|
||||
public Task ApplyValuesAsync(
|
||||
string sceneAlias,
|
||||
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
|
||||
IReadOnlyDictionary<string, string> values,
|
||||
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
|
||||
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
|
||||
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
@@ -104,6 +107,8 @@ public sealed class TornadoManager : IDisposable
|
||||
_engine!.BeginTransaction();
|
||||
try
|
||||
{
|
||||
ApplyVisibilityUpdates(sceneAlias, scene, visibilityUpdatesBeforeValue);
|
||||
|
||||
foreach (var pair in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
@@ -152,6 +157,40 @@ public sealed class TornadoManager : IDisposable
|
||||
$"Karisma counter update skipped: scene={sceneAlias} object={counterNumberKey.ObjectName} keyIndex={counterNumberKey.KeyIndex} reason={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var styleColorUpdate in styleColorUpdates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(styleColorUpdate.ObjectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sceneObject = scene.GetObject(styleColorUpdate.ObjectName);
|
||||
if (sceneObject is not IKAStyle style)
|
||||
{
|
||||
_logService.Warning(
|
||||
$"Karisma style color update skipped: scene={sceneAlias} object={styleColorUpdate.ObjectName} reason=object does not implement IKAStyle");
|
||||
continue;
|
||||
}
|
||||
|
||||
style.SetStyleColor(
|
||||
styleColorUpdate.StyleType,
|
||||
styleColorUpdate.Order,
|
||||
styleColorUpdate.R,
|
||||
styleColorUpdate.G,
|
||||
styleColorUpdate.B,
|
||||
styleColorUpdate.A);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning(
|
||||
$"Karisma style color update skipped: scene={sceneAlias} object={styleColorUpdate.ObjectName} style={styleColorUpdate.StyleType} order={styleColorUpdate.Order} reason={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
ApplyVisibilityUpdates(sceneAlias, scene, visibilityUpdatesAfterValue);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -160,6 +199,33 @@ public sealed class TornadoManager : IDisposable
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private void ApplyVisibilityUpdates(string sceneAlias, KAScene scene, IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdates)
|
||||
{
|
||||
foreach (var visibilityUpdate in visibilityUpdates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(visibilityUpdate.ObjectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sceneObject = scene.GetObject(visibilityUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sceneObject.SetVisible(visibilityUpdate.IsVisible ? 1 : 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning(
|
||||
$"Karisma visibility update skipped: scene={sceneAlias} object={visibilityUpdate.ObjectName} visible={visibilityUpdate.IsVisible} reason={ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
<Content Include="Data\ManualCandidateSamples.seed.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\TSCN_VARIABLE_DISCOVERY*.md">
|
||||
<Link>%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Tornado3_2026Election.ViewModels;
|
||||
|
||||
public sealed class DistrictOverviewCardViewModel
|
||||
{
|
||||
public required string RegionName { get; init; }
|
||||
|
||||
public required string CountedRateDisplay { get; init; }
|
||||
|
||||
public required string DetailText { get; init; }
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
{
|
||||
private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
|
||||
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
|
||||
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
|
||||
private readonly FormatCatalogService _formatCatalogService;
|
||||
private readonly AppStateStore _stateStore;
|
||||
private readonly LogService _logService;
|
||||
@@ -30,6 +31,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
|
||||
private bool _isSituationRoomExpanded;
|
||||
private bool _suppressAutomaticSave;
|
||||
private CancellationTokenSource? _automaticSaveCts;
|
||||
private int? _windowX;
|
||||
private int? _windowY;
|
||||
private int? _windowWidth;
|
||||
@@ -45,6 +47,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
Data = new DataViewModel(_logService);
|
||||
Settings = new SettingsViewModel(new StationCatalogService().GetAll());
|
||||
Data.SetConfiguredRegions(Settings.BuildSelectedStationProfile().RegionFilters);
|
||||
RestoreSelection = new RestoreSelectionViewModel();
|
||||
LogFilterOptions =
|
||||
[
|
||||
@@ -474,6 +477,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
CancelPendingAutomaticSave();
|
||||
await SaveStateCoreAsync(writeLog: false);
|
||||
if (_sharedTornadoAdapter is IDisposable disposableAdapter)
|
||||
{
|
||||
@@ -495,6 +499,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
_windowWidth = width;
|
||||
_windowHeight = height;
|
||||
_isWindowMaximized = isMaximized;
|
||||
QueueAutomaticSave();
|
||||
}
|
||||
|
||||
public void ApplyBroadcastPhase(BroadcastPhase phase)
|
||||
@@ -523,12 +528,19 @@ public sealed class MainViewModel : ObservableObject
|
||||
{
|
||||
if (args.PropertyName is nameof(SettingsViewModel.SelectedStation) or nameof(SettingsViewModel.SelectedStationId))
|
||||
{
|
||||
Data.SetConfiguredRegions(Settings.BuildSelectedStationProfile().RegionFilters);
|
||||
OnPropertyChanged(nameof(HeaderStatus), nameof(SelectedStationLogo), nameof(SelectedStationLogoVisibility));
|
||||
}
|
||||
|
||||
if (args.PropertyName is not nameof(SettingsViewModel.SelectedStationLogoAssetPath)
|
||||
and not nameof(SettingsViewModel.SelectedStationRegions)
|
||||
and not nameof(SettingsViewModel.SelectedStationRegionSummary))
|
||||
{
|
||||
QueueAutomaticSave();
|
||||
}
|
||||
|
||||
if (args.PropertyName is nameof(SettingsViewModel.SelectedStationId) or nameof(SettingsViewModel.ImageRootPath))
|
||||
{
|
||||
QueueAutomaticSave();
|
||||
_ = WarmupSharedCgConnectionAsync();
|
||||
}
|
||||
}
|
||||
@@ -546,6 +558,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
or nameof(DataViewModel.ElectionType)
|
||||
or nameof(DataViewModel.DistrictName)
|
||||
or nameof(DataViewModel.DistrictCode)
|
||||
or nameof(DataViewModel.ShowOnlyConfiguredRegions)
|
||||
or nameof(DataViewModel.TotalExpectedVotes)
|
||||
or nameof(DataViewModel.TurnoutVotes))
|
||||
{
|
||||
@@ -576,8 +589,15 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
private void Station_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(StationFilterItemViewModel.RegionFiltersText))
|
||||
if (e.PropertyName is nameof(StationFilterItemViewModel.RegionFiltersText)
|
||||
or nameof(StationFilterItemViewModel.RegionSelectionSummary)
|
||||
or nameof(StationFilterItemViewModel.SelectedRegionCount))
|
||||
{
|
||||
if (sender == Settings.SelectedStation)
|
||||
{
|
||||
Data.SetConfiguredRegions(Settings.BuildSelectedStationProfile().RegionFilters);
|
||||
}
|
||||
|
||||
QueueAutomaticSave();
|
||||
}
|
||||
}
|
||||
@@ -615,6 +635,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
Data.ElectionType = state.ElectionType;
|
||||
Data.DistrictName = string.IsNullOrWhiteSpace(state.DistrictName) ? Data.DistrictName : state.DistrictName;
|
||||
Data.DistrictCode = string.IsNullOrWhiteSpace(state.DistrictCode) ? Data.DistrictCode : state.DistrictCode;
|
||||
Data.ShowOnlyConfiguredRegions = state.ShowOnlyConfiguredRegions;
|
||||
Data.TotalExpectedVotes = state.TotalExpectedVotes > 0 ? state.TotalExpectedVotes : Data.TotalExpectedVotes;
|
||||
Data.TurnoutVotes = state.TurnoutVotes;
|
||||
Data.IsPollingEnabled = state.IsPollingEnabled;
|
||||
@@ -649,7 +670,54 @@ public sealed class MainViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
_ = SaveStateCoreAsync(writeLog: false);
|
||||
CancelPendingAutomaticSave();
|
||||
|
||||
var automaticSaveCts = new CancellationTokenSource();
|
||||
_automaticSaveCts = automaticSaveCts;
|
||||
_ = RunAutomaticSaveAsync(automaticSaveCts);
|
||||
}
|
||||
|
||||
private async Task RunAutomaticSaveAsync(CancellationTokenSource automaticSaveCts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cancellationToken = automaticSaveCts.Token;
|
||||
await Task.Delay(AutomaticSaveDelay, cancellationToken).ConfigureAwait(false);
|
||||
if (_suppressAutomaticSave || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SaveStateCoreAsync(writeLog: false).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning($"자동 저장 실패: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_automaticSaveCts, automaticSaveCts))
|
||||
{
|
||||
_automaticSaveCts = null;
|
||||
}
|
||||
|
||||
automaticSaveCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelPendingAutomaticSave()
|
||||
{
|
||||
if (_automaticSaveCts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_automaticSaveCts.Cancel();
|
||||
_automaticSaveCts.Dispose();
|
||||
_automaticSaveCts = null;
|
||||
}
|
||||
|
||||
private async Task SaveStateCoreAsync(bool writeLog)
|
||||
@@ -676,6 +744,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
ElectionType = Data.ElectionType,
|
||||
DistrictName = Data.DistrictName,
|
||||
DistrictCode = Data.DistrictCode,
|
||||
ShowOnlyConfiguredRegions = Data.ShowOnlyConfiguredRegions,
|
||||
TotalExpectedVotes = Data.TotalExpectedVotes,
|
||||
TurnoutVotes = Data.TurnoutVotes,
|
||||
Candidates = Data.Candidates.Select(candidate => new CandidateState
|
||||
|
||||
@@ -564,6 +564,73 @@ static Task<SceneValidationProbeResult> ValidateSceneOperationsAsync(SceneValida
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
handler.ResetVisibleTask();
|
||||
sceneObject.SetVisible(operation.Visible ? 1 : 0);
|
||||
if (!WaitForTaskWithMessagePump(handler.VisibleTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetVisible timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.VisibleTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (sceneObject is not IKAStyle style)
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"NOT_A_STYLE_OBJECT",
|
||||
"Object does not implement IKAStyle."));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetStyleColorTask();
|
||||
style.SetStyleColor(
|
||||
ParseStyleType(operation.StyleType),
|
||||
operation.Order,
|
||||
operation.R,
|
||||
operation.G,
|
||||
operation.B,
|
||||
operation.A);
|
||||
if (!WaitForTaskWithMessagePump(handler.StyleColorTask, options.Connection.Timeout))
|
||||
{
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
"TIMEOUT",
|
||||
"OnSetStyleColor timed out."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var callbackResult = handler.StyleColorTask.Result;
|
||||
results.Add(new SceneOperationValidationResult(
|
||||
operation.ObjectName,
|
||||
operation.Method,
|
||||
DescribeOperationPayload(operation),
|
||||
callbackResult.ToString(),
|
||||
string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
handler.ResetSetValueTask();
|
||||
sceneObject.SetValue(operation.Value ?? string.Empty);
|
||||
if (!WaitForTaskWithMessagePump(handler.SetValueTask, options.Connection.Timeout))
|
||||
@@ -1023,11 +1090,34 @@ static List<SceneValidationOperation> LoadValidationOperations(SceneValidationOp
|
||||
|
||||
static string DescribeOperationPayload(SceneValidationOperation operation)
|
||||
{
|
||||
if (string.Equals(operation.Method, "SetVisible", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"visible={operation.Visible}";
|
||||
}
|
||||
|
||||
if (string.Equals(operation.Method, "SetStyleColor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"styleType={operation.StyleType}, order={operation.Order}, rgba=({operation.R}, {operation.G}, {operation.B}, {operation.A})";
|
||||
}
|
||||
|
||||
return string.Equals(operation.Method, "SetCounterNumberKey", StringComparison.OrdinalIgnoreCase)
|
||||
? $"keyIndex={operation.KeyIndex}, number={operation.Number:0.###}"
|
||||
: operation.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
static eKStyleType ParseStyleType(string? value)
|
||||
{
|
||||
return (value ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"face" => eKStyleType.STYLE_TYPE_FACE,
|
||||
"edge" => eKStyleType.STYLE_TYPE_EDGE,
|
||||
"shadow" => eKStyleType.STYLE_TYPE_SHADOW,
|
||||
"underline" => eKStyleType.STYLE_TYPE_UNDERLINE,
|
||||
"frame" => eKStyleType.STYLE_TYPE_FRAME,
|
||||
_ => throw new ArgumentException($"Unsupported style type: {value}")
|
||||
};
|
||||
}
|
||||
|
||||
static void WriteSceneValidationMarkdown(SceneValidationOptions options, IReadOnlyList<SceneOperationValidationResult> results)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(options.OutputPath)!);
|
||||
@@ -1477,6 +1567,20 @@ internal sealed class SceneValidationOperation
|
||||
public int KeyIndex { get; set; }
|
||||
|
||||
public double Number { get; set; }
|
||||
|
||||
public string? StyleType { get; set; }
|
||||
|
||||
public int Order { get; set; }
|
||||
|
||||
public int R { get; set; }
|
||||
|
||||
public int G { get; set; }
|
||||
|
||||
public int B { get; set; }
|
||||
|
||||
public int A { get; set; } = 255;
|
||||
|
||||
public bool Visible { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail);
|
||||
@@ -1490,6 +1594,8 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
private TaskCompletionSource<eKResult> _loadSceneTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _unloadSceneTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _counterNumberKeyTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _styleColorTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _visibleTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<eKResult> _setValueTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private TaskCompletionSource<ObjectInfosProbeResult> _objectInfosTask = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private IKAScene? _sceneToQueryOnLoad;
|
||||
@@ -1504,6 +1610,10 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
|
||||
public Task<eKResult> CounterNumberKeyTask => _counterNumberKeyTask.Task;
|
||||
|
||||
public Task<eKResult> StyleColorTask => _styleColorTask.Task;
|
||||
|
||||
public Task<eKResult> VisibleTask => _visibleTask.Task;
|
||||
|
||||
public Task<eKResult> SetValueTask => _setValueTask.Task;
|
||||
|
||||
public Task<ObjectInfosProbeResult> ObjectInfosTask => _objectInfosTask.Task;
|
||||
@@ -1514,6 +1624,10 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
|
||||
public void ResetCounterNumberKeyTask() => _counterNumberKeyTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void ResetStyleColorTask() => _styleColorTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void ResetVisibleTask() => _visibleTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void ResetSetValueTask() => _setValueTask = new TaskCompletionSource<eKResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public void ResetObjectInfosTask() => _objectInfosTask = new TaskCompletionSource<ObjectInfosProbeResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -1657,12 +1771,28 @@ internal sealed class ProbeEventHandler : KAEventHandler
|
||||
public void OnQuerySceneEffectType(eKResult Result, string SceneName, int bInEffect, eKEffectType EffectType, int Duration) { }
|
||||
public void OnQueryDuration(eKResult Result, string SceneName, string AnimationName, int Duration) { }
|
||||
public void OnQueryContentsOfTextObjects(eKResult Result, string SceneName, KAStrings pTexts) { }
|
||||
public void OnSetStyleColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetStyleColor(eKResult Result, string SceneName, string ObjectName)
|
||||
{
|
||||
if (Result != eKResult.RESULT_ERROR_NO_VARIABLE_OBJECT)
|
||||
{
|
||||
Console.WriteLine($"[SDK] OnSetStyleColor result={Result} scene={SceneName} object={ObjectName}");
|
||||
}
|
||||
|
||||
_styleColorTask.TrySetResult(Result);
|
||||
}
|
||||
public void OnSetStyleTexture(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetFaceTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetEdgeTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetShadowTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetVisible(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetVisible(eKResult Result, string SceneName, string ObjectName)
|
||||
{
|
||||
if (Result != eKResult.RESULT_ERROR_NO_VARIABLE_OBJECT)
|
||||
{
|
||||
Console.WriteLine($"[SDK] OnSetVisible result={Result} scene={SceneName} object={ObjectName}");
|
||||
}
|
||||
|
||||
_visibleTask.TrySetResult(Result);
|
||||
}
|
||||
public void OnSetValue(eKResult Result, string SceneName, string ObjectName)
|
||||
{
|
||||
if (Result != eKResult.RESULT_ERROR_NO_VARIABLE_OBJECT)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{ "objectName": "유확당01", "method": "SetVisible", "visible": false },
|
||||
{ "objectName": "유확당01", "method": "SetValue", "value": "${SCENE_DIR}\\Images\\Tag\\당선.vrv" },
|
||||
{ "objectName": "유확당01", "method": "SetVisible", "visible": true },
|
||||
{ "objectName": "유확당02", "method": "SetVisible", "visible": false }
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{ "objectName": "개표율01", "method": "SetValue", "value": "개표 98.7%" },
|
||||
{ "objectName": "시도명01", "method": "SetValue", "value": "부산시장" }
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "objectName": "정당판01", "method": "SetStyleColor", "styleType": "face", "order": 0, "r": 57, "g": 84, "b": 199, "a": 255 },
|
||||
{ "objectName": "정당바01", "method": "SetStyleColor", "styleType": "face", "order": 0, "r": 0, "g": 30, "b": 84, "a": 255 },
|
||||
{ "objectName": "득표율01", "method": "SetStyleColor", "styleType": "edge", "order": 0, "r": 57, "g": 84, "b": 199, "a": 255 }
|
||||
]
|
||||
BIN
ui_live_check.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
ui_live_check2.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
ui_live_check3.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
ui_live_check4.png
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
BIN
ui_live_check5.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
ui_test_data_page.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
ui_test_data_page_after_fix.png
Normal file
|
After Width: | Height: | Size: 94 KiB |