26.04.17 작업 진행 사항

This commit is contained in:
2026-04-17 00:39:25 +09:00
parent e0c5f4dbfe
commit fa49317b34
38 changed files with 7735 additions and 298 deletions

View 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 처리 확인

View 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` 반영 확인

View 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 | |

View 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 | |

View 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
View 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 생성 자산을 사용한다.

View File

@@ -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`

View File

@@ -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`).
### 인코딩 확인 원칙
- 터미널 출력이 깨져 보이는 것과 파일 자체 인코딩 손상을 구분해서 판단한다.

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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

View File

@@ -5,5 +5,8 @@ public enum CandidateJudgement
None,
Leading,
Confirmed,
Elected
Elected,
ElectedInProgress,
UnopposedElected,
ElectedAfterCountComplete
}

View File

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

View File

@@ -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">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="투표율 시트" />
<ScrollViewer HorizontalScrollBarVisibility="Auto"
HorizontalScrollMode="Enabled"
VerticalScrollBarVisibility="Disabled"
VerticalScrollMode="Disabled">
<StackPanel Spacing="0">
<Grid MinWidth="720">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220" />
<ColumnDefinition Width="220" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="180" />
<ColumnDefinition Width="180" />
<ColumnDefinition Width="180" />
<ColumnDefinition Width="180" />
</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>
<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>
<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>
</ListView.ItemTemplate>
</ListView>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</StackPanel>
</Border>
</StackPanel>

View File

@@ -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;

View File

@@ -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) { }

View File

@@ -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,11 +186,20 @@ 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");
foreach (var reportName in PreferredReportNames)
{
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

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

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
namespace Tornado3_2026Election.Services;
public readonly record struct KarismaVisibilityUpdate(string ObjectName, bool IsVisible);

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

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

View File

@@ -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(() =>

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 }
]

View File

@@ -0,0 +1,4 @@
[
{ "objectName": "개표율01", "method": "SetValue", "value": "개표 98.7%" },
{ "objectName": "시도명01", "method": "SetValue", "value": "부산시장" }
]

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
ui_live_check2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

BIN
ui_live_check3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

BIN
ui_live_check4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

BIN
ui_live_check5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

BIN
ui_test_data_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB