스케줄에
This commit is contained in:
@@ -1,36 +1,31 @@
|
||||
# 2026-04-17 구현 현황 정리
|
||||
# 2026-04-17 현재 구현 상태 정리
|
||||
|
||||
이 문서는 2026-04-17 현재 기준으로 Tornado3 2026 Election 프로젝트의 주요 구현 사항을 정리한 현행화 문서이다.
|
||||
기존 메모와 검증 산출물이 여러 파일에 흩어져 있으므로, 실제 운영에 필요한 상태만 이 문서에서 한 번에 확인할 수 있게 정리했다.
|
||||
이 문서는 2026-04-17 기준으로 Tornado3 2026 Election 프로젝트에서 실제 반영된 구현 사항과 검증 결과를 한 번에 확인하기 위한 현행화 문서다.
|
||||
|
||||
## 1. 데이터 화면 현행 상태
|
||||
## 1. 데이터 화면
|
||||
|
||||
- 데이터 화면에서 원본 JSON 전체를 보여주는 "데이터 시트"는 제거했다.
|
||||
- 현재 데이터 화면은 아래 두 시트만 유지한다.
|
||||
- 개표율 시트
|
||||
- 후보 시트
|
||||
- 선거구명 콤보박스 첫 항목에 `전체보기`를 추가했다.
|
||||
- `전체보기` 선택 시, 개표 단계에서 지역별 개표율을 작은 카드 목록으로 표시한다.
|
||||
- 카드 형식: `지역명 - 개표율`
|
||||
- 지원 위치: 개표 단계에서 지원되는 선거 종류
|
||||
- 광역단체장 데이터는 지역명 대신 실제 직책명이 보이도록 보정했다.
|
||||
- 데이터 화면에서 원본 JSON 형태로 보여주던 `데이터 시트`는 제거했다.
|
||||
- 현재 데이터 화면에는 아래 두 시트만 남겨두었다.
|
||||
- `개표율 시트`
|
||||
- `후보 시트`
|
||||
- `데이터 - 선거구명` 콤보박스 첫 항목에 `전체보기`를 추가했다.
|
||||
- `전체보기` 선택 시, 현재 수신 가능한 지역 데이터를 작은 카드 형태로 한 번에 볼 수 있도록 바꿨다.
|
||||
- 표시 형식: `지역명 - 개표율`
|
||||
- 목적: 전체 데이터 수신 상태를 빠르게 확인하기 위한 개요 화면
|
||||
|
||||
## 2. 후보/개표 데이터 처리 규칙
|
||||
|
||||
- 후보가 나오는 데이터는 개표 데이터 기준으로 처리한다.
|
||||
- 개표 데이터에는 반드시 `개표율`도 함께 표시되도록 맞췄다.
|
||||
- 개표율 텍스트는 단순 숫자가 아니라 아래 형식으로 송출되도록 수정했다.
|
||||
- 예: `개표 98.7%`
|
||||
- 광역단체장 컷에서는 지역명 대신 실제 직함이 나오도록 보정했다.
|
||||
- 예: `부산광역시` -> `부산시장`
|
||||
- 예: `경기도` -> `경기도지사`
|
||||
|
||||
## 2. 개표 데이터 표시 규칙
|
||||
## 3. 당선/유력/확정 코드 반영
|
||||
|
||||
- 후보 데이터는 개표 데이터 기준으로 처리한다.
|
||||
- 개표율 표시는 숫자만 넣지 않고 라벨을 포함한 형식으로 통일했다.
|
||||
- 형식: `개표 99.9%`
|
||||
- 광역단체장 컷의 `시도명` 계열 변수는 광역 지명 대신 직책명으로 송출한다.
|
||||
- 예: `시도명01 = 부산시장`
|
||||
- 후보 슬롯 수는 씬 변수 카탈로그 기준으로 제한한다.
|
||||
- 2인 컷에는 `후보명03`, `유확당03` 같은 없는 변수를 보내지 않는다.
|
||||
- 3인 컷은 3번 슬롯까지 반영한다.
|
||||
|
||||
## 3. 후보 판정 코드 반영 상태
|
||||
|
||||
SBS API 판정 코드는 아래와 같이 반영한다.
|
||||
SBS API 판정 코드는 아래 기준으로 반영되어 있다.
|
||||
|
||||
- `40`: 유력
|
||||
- `50`: 확정
|
||||
@@ -38,118 +33,137 @@ SBS API 판정 코드는 아래와 같이 반영한다.
|
||||
- `80`: 무투표 당선
|
||||
- `90`: 개표마감 당선
|
||||
|
||||
추가 메모:
|
||||
추가 반영 사항:
|
||||
|
||||
- `80` 무투표 당선은 실제 발생 가능한 값으로 간주하고 별도 예외 없이 반영한다.
|
||||
- 수동 판정이 있으면 수동 판정을 우선하고, 없으면 API 판정값을 사용한다.
|
||||
- `80` 무투표 당선 케이스도 실제 발생 가능한 값으로 보고 정상 처리하도록 반영했다.
|
||||
- 수동 판정값이 있으면 수동 판정을 우선 적용하고, 없으면 API 판정값을 사용한다.
|
||||
|
||||
## 4. 유확당 변수 처리 규칙
|
||||
|
||||
`유확당` 계열 변수는 현재 아래 규칙으로 공통 처리한다.
|
||||
`유확당` 계열 변수는 전 컷 공통으로 아래 순서로 처리하도록 정리했다.
|
||||
|
||||
1. 해당 씬에 존재하는 `유확당*` 변수를 먼저 전부 `visible=false`로 숨긴다.
|
||||
2. 실제 판정 이미지 경로가 있는 슬롯만 `SetValue(...)`로 값을 적용한다.
|
||||
3. 값이 들어간 슬롯만 다시 `visible=true`로 켠다.
|
||||
1. 해당 컷에 존재하는 `유확당*` 변수들을 먼저 모두 `visible=false`로 숨긴다.
|
||||
2. 실제 판정 이미지가 필요한 후보에게만 `SetValue(...)`를 넣는다.
|
||||
3. 값이 들어간 변수만 다시 `visible=true`로 켠다.
|
||||
|
||||
적용 범위:
|
||||
|
||||
- 1인 / 2인 / 3인 후보 컷 공통
|
||||
- `당선`, `이시각1위`, `접전`, `초접전`, `모든후보`, `1-2위`, `1-3위` 계열 포함
|
||||
- 1위/2위/3위 후보 슬롯 공통
|
||||
- `1-2위`, `1-3위`, `당선`, `모든후보`, `접전`, `실시간` 계열 포함
|
||||
|
||||
검증 결과:
|
||||
|
||||
- 최신 씬 스캔 기준 `유확당` 변수를 사용하는 컷은 총 69개다.
|
||||
- 전체 스캔 리포트: `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
- 실동작 검증 리포트: `LIVE_VALIDATE_1-2위_ani_광역단체장_judgement_visibility.md`
|
||||
- 최신 전체 스캔 기준 `유확당` 변수를 사용하는 컷은 총 69개
|
||||
- 전체 변수 스캔 리포트: `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
- 라이브 검증 리포트: `LIVE_VALIDATE_1-2위_ani_광역단체장_judgement_visibility.md`
|
||||
|
||||
## 5. 정당 색상 / RGB 매핑 상태
|
||||
## 5. RGB / 정당 색상 매핑
|
||||
|
||||
정당 색상은 `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\RGB\` 폴더 기준으로 매핑한다.
|
||||
정당 색상은 아래 경로의 RGB 텍스트 기준으로 매핑한다.
|
||||
|
||||
현재 규칙:
|
||||
- `E:\김의연\지역민방\T3_Cut\Elect2026_Normal_민방\RGB\`
|
||||
|
||||
- RGB txt에 `style > ... > color`가 지정된 항목은 이미지 교체보다 `SetStyleColor(...)`를 우선 적용한다.
|
||||
- 대표 적용 섹션:
|
||||
- 정당판
|
||||
- 정당바
|
||||
- 득표율
|
||||
- 정당명
|
||||
- 기타 style color 지정 섹션
|
||||
- RGB txt에 style color 지정이 없는 항목만 기존 asset `SetValue(...)` 경로를 사용한다.
|
||||
현재 적용 규칙:
|
||||
|
||||
특히 `1-2위_ani_광역단체장.txt` 기준으로 아래가 반영되도록 디버깅했다.
|
||||
- RGB txt에 `style > ... > color` 지시가 있으면 이미지 교체보다 `SetStyleColor(...)`를 우선 사용한다.
|
||||
- style color 지시가 없는 항목만 기존 asset 기반 `SetValue(...)` 경로를 사용한다.
|
||||
|
||||
- `정당판`: `style > face > color`
|
||||
- `득표율`: `style > edge > color`
|
||||
- `정당바`: `style > face > color`
|
||||
특히 `1-2위_ani_광역단체장.txt` 기준으로 아래 항목을 실제 style color 변경 방식으로 맞췄다.
|
||||
|
||||
관련 문서:
|
||||
- 정당판: `style > face > color`
|
||||
- 득표율: `style > edge > color`
|
||||
- 정당바: `style > face > color`
|
||||
|
||||
- 매핑 문서: `RGB_SPEC_CUT_MAPPING.md`
|
||||
- 스타일 검증 리포트: `LIVE_VALIDATE_1-2위_ani_광역단체장_style.md`
|
||||
관련 구현 사항:
|
||||
|
||||
## 6. 설정 저장 / 자동 갱신 현행 상태
|
||||
- RGB txt에서 style color 대상을 파싱하도록 `PartyColorCatalog`를 보완했다.
|
||||
- Karisma 적용 단계에서 `IKAStyle.SetStyleColor`를 호출하도록 처리 경로를 추가했다.
|
||||
- style color 대상에는 잘못된 이미지 값이 들어가지 않도록 분기 처리했다.
|
||||
|
||||
- API 자동 갱신 기본값은 `60초`다.
|
||||
- 설정 변경 시 별도 저장 버튼 없이 자동 저장되도록 구성했다.
|
||||
- 자동 저장 대상에는 아래 값이 포함된다.
|
||||
- API 자동 갱신 ON/OFF
|
||||
- 갱신 주기
|
||||
- 데이터 관련 주요 선택 상태
|
||||
- 앱 상태 복원에 필요한 주요 설정
|
||||
검증 문서:
|
||||
|
||||
관련 구현 포인트:
|
||||
- 컷별 RGB 매핑 문서: `RGB_SPEC_CUT_MAPPING.md`
|
||||
- style color 검증 리포트: `LIVE_VALIDATE_1-2위_ani_광역단체장_style.md`
|
||||
|
||||
- 기본값: `Tornado3_2026Election/Persistence/AppState.cs`
|
||||
- 자동 저장 루프: `Tornado3_2026Election/ViewModels/MainViewModel.cs`
|
||||
## 6. 후보 슬롯 수 처리
|
||||
|
||||
## 7. 씬 변수 카탈로그 현행화
|
||||
|
||||
씬 변수 카탈로그는 현재 최신 스캔 결과를 우선 사용한다.
|
||||
- 실제 컷에 존재하는 후보 슬롯 수만큼만 값을 넣도록 보정했다.
|
||||
- 예를 들어 후보가 2명까지 있는 컷에서는 `후보명03`, `유확당03` 같은 존재하지 않는 변수를 억지로 쓰지 않도록 수정했다.
|
||||
- 최신 변수 카탈로그를 우선 참조해서 컷별 실제 변수 개수를 판단한다.
|
||||
|
||||
우선순위:
|
||||
|
||||
1. `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
2. `TSCN_VARIABLE_DISCOVERY_E_DRIVE.md`
|
||||
3. `TSCN_VARIABLE_DISCOVERY.md`
|
||||
4. 동일 패턴의 최신 `TSCN_VARIABLE_DISCOVERY*.md`
|
||||
3. 기타 `TSCN_VARIABLE_DISCOVERY*.md`
|
||||
|
||||
추가 조치:
|
||||
추가 사항:
|
||||
|
||||
- 위 카탈로그 파일들은 앱 출력 폴더와 `AppX` 폴더에도 함께 복사되도록 했다.
|
||||
- 실행본도 최신 씬 변수 정보를 그대로 사용한다.
|
||||
- 변수 카탈로그 파일들은 실행 출력 폴더와 `AppX` 배포 폴더에도 함께 복사되도록 정리했다.
|
||||
|
||||
최신 전체 스캔 결과:
|
||||
## 7. 설정 저장 및 API 주기
|
||||
|
||||
- 전체 씬 수: 114
|
||||
- `유확당` 사용 컷 수: 69
|
||||
- 출력 리포트: `TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md`
|
||||
- API 자동 갱신 기본값을 `60초`로 변경했다.
|
||||
- 설정 변경 시 별도 저장 버튼 없이 자동 저장되도록 반영했다.
|
||||
|
||||
## 8. 주요 검증 산출물
|
||||
자동 저장 대상에는 아래 항목들이 포함된다.
|
||||
|
||||
- API 자동 갱신 ON/OFF
|
||||
- API 갱신 주기
|
||||
- 데이터 관련 주요 선택 상태
|
||||
- 앱 재시작 후 복원이 필요한 주요 설정
|
||||
|
||||
관련 구현 파일:
|
||||
|
||||
- 상태 저장 모델: `Tornado3_2026Election/Persistence/AppState.cs`
|
||||
- 자동 저장 루프: `Tornado3_2026Election/ViewModels/MainViewModel.cs`
|
||||
|
||||
## 8. 데이터/라벨 관련 라이브 검증 결과
|
||||
|
||||
- `LIVE_VALIDATE_1-2위_ani_광역단체장.md`
|
||||
- 기본 변수 송출 검증
|
||||
- `LIVE_VALIDATE_1-2위_ani_광역단체장_labels.md`
|
||||
- `개표 99.9%` 형식, 광역장 직책명 검증
|
||||
- `개표 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_민방 전체 씬 변수 스캔 결과
|
||||
- Elect2026_Normal_민방 전체 컷 변수 스캔 결과
|
||||
|
||||
## 9. 현재 운영 기준 요약
|
||||
## 9. 스케줄 지역 선택 송출
|
||||
|
||||
- 데이터 화면은 `개표율 시트 + 후보 시트 + 전체보기 카드` 기준으로 본다.
|
||||
- 광역단체장 표기는 지역명이 아니라 직책명 기준으로 본다.
|
||||
- 개표율 문구는 항상 `개표 x.x%` 형식으로 보낸다.
|
||||
- 정당 색상은 RGB txt 기준이며, style color 지정이 있으면 이미지보다 style color를 우선한다.
|
||||
- `유확당`은 전 컷 공통으로 먼저 숨기고, 판정이 있는 슬롯만 다시 보이게 한다.
|
||||
- 스케줄 추가 시 컷만 고르는 방식에서 `컷 + 지역 범위`를 함께 고르는 방식으로 확장했다.
|
||||
- 스케줄 제어 패널에 지역 선택 콤보박스를 추가했다.
|
||||
- 지역 콤보박스 구성은 아래와 같다.
|
||||
- 첫 번째: `전체`
|
||||
- 두 번째: `선택권역`
|
||||
- 이후: 개별 지역 목록
|
||||
- 개별 지역 선택 시 해당 지역 데이터만 송출한다.
|
||||
- `전체` 선택 시 현재 포맷에 대응되는 지역 데이터 전체를 순서대로 송출한다.
|
||||
- `선택권역` 선택 시 방송사 설정에 잡혀 있는 권역만 순서대로 송출한다.
|
||||
- `전체`와 `선택권역`은 데이터 순서에 따라 송출 가능한 지역만 순차적으로 처리한다.
|
||||
- 스케줄 큐 항목에는 지역 범위 정보가 함께 저장되며, 앱 상태 저장/복원에도 포함된다.
|
||||
- 송출 중에는 큐 카드와 현재/다음 표시에서 실제 송출 중인 지역명이 함께 보이도록 반영했다.
|
||||
|
||||
구현 범위:
|
||||
|
||||
- 스케줄 UI 콤보박스 추가
|
||||
- 스케줄 아이템에 지역 범위 저장
|
||||
- 스케줄 상태 저장/복원 반영
|
||||
- 송출 시 지역별 API 재조회 후 순차 송출
|
||||
|
||||
## 10. 현재 기준 운영 요약
|
||||
|
||||
- 데이터 화면은 `개표율 시트 + 후보 시트 + 전체보기 카드` 기준으로 동작한다.
|
||||
- 후보 데이터는 개표 데이터 기준으로 처리되고, 개표율이 함께 표시된다.
|
||||
- 개표율은 항상 `개표 x.x%` 형식으로 송출한다.
|
||||
- 광역단체장 컷의 지역 표기는 지역명이 아니라 실제 직함 기준으로 보정한다.
|
||||
- 정당 색상은 RGB txt 기준으로 매핑하며, style color 지시가 있으면 이미지보다 style color를 우선 적용한다.
|
||||
- `유확당` 변수는 전 컷 공통으로 먼저 숨긴 뒤 필요한 경우에만 값을 넣고 다시 보이게 한다.
|
||||
- 설정은 변경 즉시 자동 저장되며, API 기본 갱신 주기는 60초다.
|
||||
- 스케줄은 `전체 / 선택권역 / 개별 지역` 기준으로 지역별 순차 송출이 가능하다.
|
||||
|
||||
## 10. 후속 확인 권장 사항
|
||||
## 11. 참고
|
||||
|
||||
- 실제 송출 중인 주요 컷에서 `유확당` 표식이 없는 슬롯이 숨겨지는지 최종 육안 확인
|
||||
- 2인 / 3인 컷 각각에서 존재하지 않는 슬롯 변수 송출이 없는지 로그 확인
|
||||
- 광역단체장 컷에서 `시도명`이 지역명이 아니라 직책명으로 나가는지 최종 확인
|
||||
- 무투표 당선(`80`) 케이스 수신 시 `유확당` asset과 visible 처리 확인
|
||||
이 문서는 완료된 구현 사항 위주로 정리했다. 이후 추가 변경이 생기면 같은 문서를 기준으로 계속 현행화한다.
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
<Grid ColumnSpacing="12" RowSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="240" />
|
||||
<ColumnDefinition Width="220" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -170,26 +171,32 @@
|
||||
ItemsSource="{x:Bind ViewModel.AvailableFormats, Mode=OneWay}"
|
||||
SelectedItem="{x:Bind ViewModel.SelectedFormat, Mode=TwoWay}" />
|
||||
|
||||
<Button
|
||||
<ComboBox
|
||||
Grid.Column="1"
|
||||
DisplayMemberPath="Label"
|
||||
ItemsSource="{x:Bind ViewModel.RegionOptions, Mode=OneWay}"
|
||||
SelectedItem="{x:Bind ViewModel.SelectedRegionOption, Mode=TwoWay}" />
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Command="{x:Bind ViewModel.AddFormatCommand}"
|
||||
Content="컷 추가"
|
||||
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
|
||||
<ToggleSwitch
|
||||
Grid.Column="2"
|
||||
Grid.Column="3"
|
||||
Header="반복"
|
||||
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
|
||||
|
||||
<ComboBox
|
||||
Grid.Column="3"
|
||||
Grid.Column="4"
|
||||
Width="150"
|
||||
DisplayMemberPath="Label"
|
||||
ItemsSource="{x:Bind ViewModel.EmptyBehaviorOptions, Mode=OneWay}"
|
||||
SelectedItem="{x:Bind ViewModel.SelectedEmptyBehaviorOption, Mode=TwoWay}" />
|
||||
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
Grid.Column="5"
|
||||
Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
@@ -332,10 +339,13 @@
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="18"
|
||||
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||
Text="{x:Bind FormatName}" />
|
||||
Text="{x:Bind DisplayName}" />
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind Description}" />
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind DisplayRegionLabel, Mode=OneWay}" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}">
|
||||
<Run Text="컷 " />
|
||||
<Run Text="{x:Bind TotalCuts}" />
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
private ScheduleQueueItemState _state = ScheduleQueueItemState.Queued;
|
||||
private string _lastError = string.Empty;
|
||||
private DateTimeOffset? _lastPlayedAt;
|
||||
private string _currentRegionLabel = string.Empty;
|
||||
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
@@ -29,6 +30,14 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
|
||||
public required int TotalCuts { get; init; }
|
||||
|
||||
public ScheduleRegionScope RegionScope { get; set; } = ScheduleRegionScope.All;
|
||||
|
||||
public string ScheduleElectionType { get; set; } = string.Empty;
|
||||
|
||||
public string RegionLabel { get; set; } = string.Empty;
|
||||
|
||||
public string RegionCode { get; set; } = string.Empty;
|
||||
|
||||
public ScheduleQueueItemState State
|
||||
{
|
||||
get => _state;
|
||||
@@ -56,6 +65,19 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
set => SetProperty(ref _lastPlayedAt, value);
|
||||
}
|
||||
|
||||
public string CurrentRegionLabel
|
||||
{
|
||||
get => _currentRegionLabel;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _currentRegionLabel, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(DisplayRegionLabel));
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string StateLabel => State switch
|
||||
{
|
||||
@@ -82,10 +104,32 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
|
||||
|
||||
[JsonIgnore]
|
||||
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "아직 송출 안 함";
|
||||
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "아직 송출 전";
|
||||
|
||||
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template)
|
||||
[JsonIgnore]
|
||||
public string SelectionRegionLabel => RegionScope switch
|
||||
{
|
||||
ScheduleRegionScope.All => "전체",
|
||||
ScheduleRegionScope.StationRegions => "선택권역",
|
||||
_ => string.IsNullOrWhiteSpace(RegionLabel) ? "개별 지역" : RegionLabel
|
||||
};
|
||||
|
||||
[JsonIgnore]
|
||||
public string DisplayRegionLabel => string.IsNullOrWhiteSpace(CurrentRegionLabel)
|
||||
? SelectionRegionLabel
|
||||
: CurrentRegionLabel;
|
||||
|
||||
[JsonIgnore]
|
||||
public string DisplayName => $"{FormatName} / {DisplayRegionLabel}";
|
||||
|
||||
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
|
||||
{
|
||||
var selectedRegion = regionOption ?? new ScheduleRegionOption
|
||||
{
|
||||
Scope = ScheduleRegionScope.All,
|
||||
Label = "전체"
|
||||
};
|
||||
|
||||
return new ChannelScheduleItem
|
||||
{
|
||||
FormatId = template.Id,
|
||||
@@ -94,7 +138,11 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
Channel = template.RecommendedChannel,
|
||||
RequiresImage = template.RequiresImage,
|
||||
DefaultCutDurationSeconds = template.Cuts.First().DurationSeconds,
|
||||
TotalCuts = template.Cuts.Count
|
||||
TotalCuts = template.Cuts.Count,
|
||||
RegionScope = selectedRegion.Scope,
|
||||
ScheduleElectionType = selectedRegion.ElectionType,
|
||||
RegionLabel = selectedRegion.Scope == ScheduleRegionScope.Single ? selectedRegion.Label : string.Empty,
|
||||
RegionCode = selectedRegion.DistrictCode
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
16
Tornado3_2026Election/Domain/ScheduleRegionOption.cs
Normal file
16
Tornado3_2026Election/Domain/ScheduleRegionOption.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public sealed record ScheduleRegionOption
|
||||
{
|
||||
public required ScheduleRegionScope Scope { get; init; }
|
||||
|
||||
public required string Label { get; init; }
|
||||
|
||||
public string ElectionType { get; init; } = string.Empty;
|
||||
|
||||
public string RegionName { get; init; } = string.Empty;
|
||||
|
||||
public string DistrictName { get; init; } = string.Empty;
|
||||
|
||||
public string DistrictCode { get; init; } = string.Empty;
|
||||
}
|
||||
8
Tornado3_2026Election/Domain/ScheduleRegionScope.cs
Normal file
8
Tornado3_2026Election/Domain/ScheduleRegionScope.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public enum ScheduleRegionScope
|
||||
{
|
||||
All,
|
||||
StationRegions,
|
||||
Single
|
||||
}
|
||||
8
Tornado3_2026Election/Domain/ScheduleRegionTarget.cs
Normal file
8
Tornado3_2026Election/Domain/ScheduleRegionTarget.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
|
||||
public sealed record ScheduleRegionTarget(
|
||||
string ElectionType,
|
||||
string DisplayName,
|
||||
string RegionName,
|
||||
string DistrictName,
|
||||
string DistrictCode);
|
||||
@@ -21,5 +21,13 @@ public sealed class ScheduleItemState
|
||||
|
||||
public int TotalCuts { get; set; }
|
||||
|
||||
public ScheduleRegionScope RegionScope { get; set; } = ScheduleRegionScope.All;
|
||||
|
||||
public string ScheduleElectionType { get; set; } = string.Empty;
|
||||
|
||||
public string RegionLabel { get; set; } = string.Empty;
|
||||
|
||||
public string RegionCode { get; set; } = string.Empty;
|
||||
|
||||
public Domain.ScheduleQueueItemState State { get; set; }
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ public sealed class ChannelScheduleEngine
|
||||
foreach (var item in Queue.Where(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending))
|
||||
{
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
item.CurrentRegionLabel = string.Empty;
|
||||
}
|
||||
|
||||
_preferredNextItemId = null;
|
||||
@@ -96,6 +97,7 @@ public sealed class ChannelScheduleEngine
|
||||
{
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
item.LastError = string.Empty;
|
||||
item.CurrentRegionLabel = string.Empty;
|
||||
}
|
||||
|
||||
RefreshQueueMarkers();
|
||||
@@ -114,6 +116,7 @@ public sealed class ChannelScheduleEngine
|
||||
{
|
||||
activeItem.State = ScheduleQueueItemState.Completed;
|
||||
activeItem.LastError = string.Empty;
|
||||
activeItem.CurrentRegionLabel = string.Empty;
|
||||
}
|
||||
|
||||
RefreshQueueMarkers();
|
||||
@@ -228,15 +231,6 @@ public sealed class ChannelScheduleEngine
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_dataRefreshGate.ValidateForFormat(template, out var validationError))
|
||||
{
|
||||
next.State = ScheduleQueueItemState.Error;
|
||||
next.LastError = validationError;
|
||||
_logService.Warning($"[{Channel}] Blocked by validation: {validationError}");
|
||||
RefreshQueueMarkers();
|
||||
continue;
|
||||
}
|
||||
|
||||
await PlayItemAsync(next, template, cancellationToken).ConfigureAwait(false);
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -257,32 +251,78 @@ public sealed class ChannelScheduleEngine
|
||||
var station = _stationProvider();
|
||||
var imageRootPath = _imageRootProvider();
|
||||
var resolvedCuts = ResolveCuts(template, station);
|
||||
var regionTargets = await _dataRefreshGate
|
||||
.ResolveScheduleRegionTargetsAsync(queueItem, template, station, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var cut in resolvedCuts)
|
||||
if (regionTargets.Count == 0)
|
||||
{
|
||||
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
queueItem.State = ScheduleQueueItemState.Sending;
|
||||
queueItem.State = ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
RefreshQueueMarkers();
|
||||
|
||||
var snapshot = _dataRefreshGate.GetCurrentSnapshot();
|
||||
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
queueItem.State = ScheduleQueueItemState.OnAir;
|
||||
queueItem.LastPlayedAt = DateTimeOffset.Now;
|
||||
RefreshQueueMarkers();
|
||||
|
||||
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_advanceSignal = signal;
|
||||
var delayTask = Task.Delay(TimeSpan.FromSeconds(cut.DurationSeconds), cancellationToken);
|
||||
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
queueItem.State = ScheduleQueueItemState.Completed;
|
||||
queueItem.LastError = string.Empty;
|
||||
var playedAny = false;
|
||||
var lastFailure = string.Empty;
|
||||
|
||||
foreach (var regionTarget in regionTargets)
|
||||
{
|
||||
ElectionDataSnapshot snapshot;
|
||||
try
|
||||
{
|
||||
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||
snapshot = await _dataRefreshGate
|
||||
.GetScheduleSnapshotAsync(queueItem, regionTarget, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastFailure = $"{regionTarget.DisplayName}: {ex.Message}";
|
||||
_logService.Warning($"[{Channel}] 스케줄 지역 수신 실패: {lastFailure}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out var validationError))
|
||||
{
|
||||
lastFailure = $"{regionTarget.DisplayName}: {validationError}";
|
||||
_logService.Warning($"[{Channel}] 스케줄 지역 검증 실패: {lastFailure}");
|
||||
continue;
|
||||
}
|
||||
|
||||
queueItem.CurrentRegionLabel = regionTarget.DisplayName;
|
||||
|
||||
foreach (var cut in resolvedCuts)
|
||||
{
|
||||
queueItem.State = ScheduleQueueItemState.Sending;
|
||||
RefreshQueueMarkers();
|
||||
|
||||
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
queueItem.State = ScheduleQueueItemState.OnAir;
|
||||
queueItem.LastPlayedAt = DateTimeOffset.Now;
|
||||
RefreshQueueMarkers();
|
||||
|
||||
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_advanceSignal = signal;
|
||||
var delayTask = Task.Delay(TimeSpan.FromSeconds(cut.DurationSeconds), cancellationToken);
|
||||
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
playedAny = true;
|
||||
}
|
||||
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
||||
RefreshQueueMarkers();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tornado3_2026Election.Domain;
|
||||
@@ -13,4 +14,17 @@ public interface IDataRefreshGate
|
||||
ElectionDataSnapshot GetCurrentSnapshot();
|
||||
|
||||
bool ValidateForFormat(FormatTemplateDefinition template, out string errorMessage);
|
||||
|
||||
bool ValidateSnapshotForFormat(FormatTemplateDefinition template, ElectionDataSnapshot snapshot, out string errorMessage);
|
||||
|
||||
Task<IReadOnlyList<ScheduleRegionTarget>> ResolveScheduleRegionTargetsAsync(
|
||||
ChannelScheduleItem item,
|
||||
FormatTemplateDefinition template,
|
||||
BroadcastStationProfile station,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<ElectionDataSnapshot> GetScheduleSnapshotAsync(
|
||||
ChannelScheduleItem item,
|
||||
ScheduleRegionTarget target,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tornado3_2026Election.Common;
|
||||
using Tornado3_2026Election.Domain;
|
||||
@@ -17,9 +18,11 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
private readonly LogService _logService;
|
||||
private readonly IReadOnlyList<FormatTemplateDefinition> _allFormats;
|
||||
private FormatTemplateDefinition? _selectedFormat;
|
||||
private ScheduleRegionOption? _selectedRegionOption;
|
||||
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
|
||||
private bool _loopEnabled;
|
||||
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
||||
private int _regionOptionsRevision;
|
||||
|
||||
public ChannelScheduleViewModel(
|
||||
BroadcastChannel channel,
|
||||
@@ -38,6 +41,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_logService = logService;
|
||||
_allFormats = formats.ToArray();
|
||||
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
|
||||
RegionOptions = new ObservableCollection<ScheduleRegionOption>();
|
||||
EmptyBehaviorOptions =
|
||||
[
|
||||
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
|
||||
@@ -62,6 +66,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_data.PropertyChanged += Data_PropertyChanged;
|
||||
|
||||
RebuildAvailableFormats();
|
||||
_ = RebuildRegionOptionsAsync();
|
||||
RefreshSummary();
|
||||
}
|
||||
|
||||
@@ -83,6 +88,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
|
||||
|
||||
public ObservableCollection<ScheduleRegionOption> RegionOptions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
|
||||
|
||||
public ObservableCollection<ChannelScheduleItem> Queue { get; }
|
||||
@@ -112,10 +119,20 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
if (SetProperty(ref _selectedFormat, value))
|
||||
{
|
||||
if (AddFormatCommand is not null)
|
||||
{
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
_ = RebuildRegionOptionsAsync();
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ScheduleRegionOption? SelectedRegionOption
|
||||
{
|
||||
get => _selectedRegionOption;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _selectedRegionOption, value))
|
||||
{
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,9 +198,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
? "송출 중"
|
||||
: "대기";
|
||||
|
||||
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.FormatName ?? "대기 화면";
|
||||
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "대기 화면";
|
||||
|
||||
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "다음 컷 없음";
|
||||
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "다음 컷 없음";
|
||||
|
||||
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
|
||||
|
||||
@@ -193,8 +210,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.FormatName ?? "-";
|
||||
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "-";
|
||||
var current = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.DisplayName ?? "-";
|
||||
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "-";
|
||||
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
|
||||
}
|
||||
}
|
||||
@@ -205,6 +222,11 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
|
||||
|
||||
public async Task RefreshRegionOptionsAsync()
|
||||
{
|
||||
await RebuildRegionOptionsAsync();
|
||||
}
|
||||
|
||||
private async Task StartAsync()
|
||||
{
|
||||
await _engine.StartAsync().ConfigureAwait(false);
|
||||
@@ -239,13 +261,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_data.ValidateForFormat(SelectedFormat, out var validationError))
|
||||
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
||||
if (regionOption is null)
|
||||
{
|
||||
_logService.Warning($"[{Title}] {validationError}");
|
||||
_logService.Warning($"[{Title}] 선택 가능한 지역 정보가 아직 준비되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
_engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat));
|
||||
_engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption));
|
||||
RefreshSummary();
|
||||
}
|
||||
|
||||
@@ -307,18 +330,19 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
private bool CanAddFormat()
|
||||
{
|
||||
return SelectedFormat is not null && SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase);
|
||||
return SelectedFormat is not null &&
|
||||
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
|
||||
SelectedRegionOption is not null;
|
||||
}
|
||||
|
||||
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||
{
|
||||
if (args.PropertyName != nameof(DataViewModel.BroadcastPhase))
|
||||
if (args.PropertyName is nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.ElectionType))
|
||||
{
|
||||
return;
|
||||
RebuildAvailableFormats();
|
||||
_ = RebuildRegionOptionsAsync();
|
||||
RefreshSummary();
|
||||
}
|
||||
|
||||
RebuildAvailableFormats();
|
||||
RefreshSummary();
|
||||
}
|
||||
|
||||
private void RebuildAvailableFormats()
|
||||
@@ -342,6 +366,64 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(QueueFootnote));
|
||||
}
|
||||
|
||||
private async Task RebuildRegionOptionsAsync()
|
||||
{
|
||||
var revision = Interlocked.Increment(ref _regionOptionsRevision);
|
||||
var selectedFormat = SelectedFormat;
|
||||
var previousSelection = SelectedRegionOption;
|
||||
|
||||
if (selectedFormat is null)
|
||||
{
|
||||
RegionOptions.Clear();
|
||||
SelectedRegionOption = null;
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat);
|
||||
if (revision != _regionOptionsRevision)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RegionOptions.Clear();
|
||||
foreach (var option in options)
|
||||
{
|
||||
RegionOptions.Add(option);
|
||||
}
|
||||
|
||||
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection);
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static ScheduleRegionOption? ResolvePreferredRegionOption(
|
||||
IReadOnlyList<ScheduleRegionOption> options,
|
||||
ScheduleRegionOption? previousSelection)
|
||||
{
|
||||
if (options.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previousSelection is null)
|
||||
{
|
||||
return options[0];
|
||||
}
|
||||
|
||||
if (previousSelection.Scope == ScheduleRegionScope.Single)
|
||||
{
|
||||
var matchedSingle = options.FirstOrDefault(option =>
|
||||
option.Scope == ScheduleRegionScope.Single &&
|
||||
string.Equals(option.DistrictCode, previousSelection.DistrictCode, System.StringComparison.OrdinalIgnoreCase));
|
||||
if (matchedSingle is not null)
|
||||
{
|
||||
return matchedSingle;
|
||||
}
|
||||
}
|
||||
|
||||
return options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ?? options[0];
|
||||
}
|
||||
|
||||
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
|
||||
{
|
||||
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
|
||||
|
||||
@@ -647,15 +647,62 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScheduleRegionOption>> GetScheduleRegionOptionsAsync(
|
||||
FormatTemplateDefinition? template,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var electionType = ResolveScheduleElectionType(template);
|
||||
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var regionOptions = new List<ScheduleRegionOption>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Scope = ScheduleRegionScope.All,
|
||||
Label = "전체",
|
||||
ElectionType = electionType
|
||||
},
|
||||
new()
|
||||
{
|
||||
Scope = ScheduleRegionScope.StationRegions,
|
||||
Label = "선택권역",
|
||||
ElectionType = electionType
|
||||
}
|
||||
};
|
||||
|
||||
regionOptions.AddRange(options.Select(option => new ScheduleRegionOption
|
||||
{
|
||||
Scope = ScheduleRegionScope.Single,
|
||||
Label = option.DisplayName,
|
||||
ElectionType = electionType,
|
||||
RegionName = option.RegionName,
|
||||
DistrictName = option.DistrictName,
|
||||
DistrictCode = option.DistrictCode
|
||||
}));
|
||||
|
||||
return regionOptions;
|
||||
}
|
||||
|
||||
public bool ValidateForFormat(FormatTemplateDefinition template, out string errorMessage)
|
||||
{
|
||||
if (Candidates.Count == 0)
|
||||
return ValidateSnapshotForFormat(template, GetCurrentSnapshot(), out errorMessage);
|
||||
}
|
||||
|
||||
public bool ValidateSnapshotForFormat(FormatTemplateDefinition template, ElectionDataSnapshot snapshot, out string errorMessage)
|
||||
{
|
||||
if (!template.RequiresCandidateData)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (snapshot.Candidates.Count == 0)
|
||||
{
|
||||
errorMessage = "후보 데이터가 없습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var candidate in Candidates)
|
||||
foreach (var candidate in snapshot.Candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate.Name)
|
||||
|| string.IsNullOrWhiteSpace(candidate.Party)
|
||||
@@ -666,7 +713,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
}
|
||||
}
|
||||
|
||||
if (template.RequiresImage && Candidates.Any(candidate => !candidate.HasImage))
|
||||
if (template.RequiresImage && snapshot.Candidates.Any(candidate => !candidate.HasImage))
|
||||
{
|
||||
errorMessage = "이미지 필수 포맷인데 후보 이미지가 없는 항목이 있습니다.";
|
||||
return false;
|
||||
@@ -676,6 +723,44 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScheduleRegionTarget>> ResolveScheduleRegionTargetsAsync(
|
||||
ChannelScheduleItem item,
|
||||
FormatTemplateDefinition template,
|
||||
BroadcastStationProfile station,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var electionType = string.IsNullOrWhiteSpace(item.ScheduleElectionType)
|
||||
? ResolveScheduleElectionType(template)
|
||||
: item.ScheduleElectionType;
|
||||
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||
if (options.Count == 0)
|
||||
{
|
||||
return Array.Empty<ScheduleRegionTarget>();
|
||||
}
|
||||
|
||||
return item.RegionScope switch
|
||||
{
|
||||
ScheduleRegionScope.StationRegions => ResolveStationRegionTargets(options, electionType, station),
|
||||
ScheduleRegionScope.Single => ResolveSingleRegionTarget(item, options, electionType),
|
||||
_ => options.Select(option => CreateScheduleRegionTarget(option, electionType)).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ElectionDataSnapshot> GetScheduleSnapshotAsync(
|
||||
ChannelScheduleItem item,
|
||||
ScheduleRegionTarget target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var electionType = string.IsNullOrWhiteSpace(target.ElectionType)
|
||||
? (string.IsNullOrWhiteSpace(item.ScheduleElectionType) ? ElectionType : item.ScheduleElectionType)
|
||||
: target.ElectionType;
|
||||
var refreshResult = await _apiClient
|
||||
.RefreshAsync(BroadcastPhase, electionType, target.DisplayName, target.DistrictCode, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return CreateSnapshotFromRefreshResult(electionType, refreshResult);
|
||||
}
|
||||
|
||||
private async Task RefreshDistrictOptionsForElectionTypeAsync()
|
||||
{
|
||||
var revision = Interlocked.Increment(ref _districtOptionsRevision);
|
||||
@@ -1434,6 +1519,132 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetScheduleDistrictOptionsAsync(
|
||||
string electionType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(electionType, ElectionType, StringComparison.Ordinal) && _districtSelectionSource.Count > 0)
|
||||
{
|
||||
return _districtSelectionSource;
|
||||
}
|
||||
|
||||
if (SupportsApiDistrictOptions(electionType))
|
||||
{
|
||||
var options = await _apiClient.GetDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||
if (options.Count > 0)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
return _districtSelectionSource.Count > 0 ? _districtSelectionSource : DefaultDistrictOptions;
|
||||
}
|
||||
|
||||
private string ResolveScheduleElectionType(FormatTemplateDefinition? template)
|
||||
{
|
||||
var formatName = template?.Name ?? string.Empty;
|
||||
if (formatName.Contains("교육감", StringComparison.Ordinal))
|
||||
{
|
||||
return "교육감";
|
||||
}
|
||||
|
||||
if (formatName.Contains("기초단체장", StringComparison.Ordinal) ||
|
||||
formatName.Contains("기초의원", StringComparison.Ordinal))
|
||||
{
|
||||
return "기초단체장";
|
||||
}
|
||||
|
||||
if (formatName.Contains("광역단체장", StringComparison.Ordinal) ||
|
||||
formatName.Contains("광역의원", StringComparison.Ordinal) ||
|
||||
formatName.Contains("보궐선거", StringComparison.Ordinal))
|
||||
{
|
||||
return "광역단체장";
|
||||
}
|
||||
|
||||
return ElectionType;
|
||||
}
|
||||
|
||||
private IReadOnlyList<ScheduleRegionTarget> ResolveStationRegionTargets(
|
||||
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> options,
|
||||
string electionType,
|
||||
BroadcastStationProfile station)
|
||||
{
|
||||
var configuredRegions = NormalizeConfiguredRegions(station.RegionFilters);
|
||||
if (configuredRegions.Count == 0)
|
||||
{
|
||||
return options.Select(option => CreateScheduleRegionTarget(option, electionType)).ToArray();
|
||||
}
|
||||
|
||||
return options
|
||||
.Where(option => configuredRegions.Contains(NormalizeConfiguredRegion(option.RegionName)))
|
||||
.Select(option => CreateScheduleRegionTarget(option, electionType))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<ScheduleRegionTarget> ResolveSingleRegionTarget(
|
||||
ChannelScheduleItem item,
|
||||
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> options,
|
||||
string electionType)
|
||||
{
|
||||
var matchedOption = options.FirstOrDefault(option =>
|
||||
!string.IsNullOrWhiteSpace(item.RegionCode) &&
|
||||
string.Equals(option.DistrictCode, item.RegionCode, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
matchedOption ??= options.FirstOrDefault(option =>
|
||||
string.Equals(option.DisplayName, item.RegionLabel, StringComparison.Ordinal) ||
|
||||
string.Equals(option.RegionName, item.RegionLabel, StringComparison.Ordinal));
|
||||
|
||||
if (matchedOption is null)
|
||||
{
|
||||
return Array.Empty<ScheduleRegionTarget>();
|
||||
}
|
||||
|
||||
return [CreateScheduleRegionTarget(matchedOption, electionType)];
|
||||
}
|
||||
|
||||
private ElectionDataSnapshot CreateSnapshotFromRefreshResult(
|
||||
string electionType,
|
||||
SbsElectionApiClient.SbsElectionRefreshResult refreshResult)
|
||||
{
|
||||
var districtName = string.IsNullOrWhiteSpace(refreshResult.DistrictName)
|
||||
? refreshResult.ElectionDistrictName
|
||||
: refreshResult.DistrictName;
|
||||
var regionName = string.IsNullOrWhiteSpace(refreshResult.RegionName)
|
||||
? districtName
|
||||
: refreshResult.RegionName;
|
||||
var electionDistrictName = string.IsNullOrWhiteSpace(refreshResult.ElectionDistrictName)
|
||||
? districtName
|
||||
: refreshResult.ElectionDistrictName;
|
||||
|
||||
return new ElectionDataSnapshot
|
||||
{
|
||||
BroadcastPhase = BroadcastPhase,
|
||||
ElectionType = electionType,
|
||||
DistrictName = districtName ?? string.Empty,
|
||||
DistrictCode = refreshResult.DistrictCode ?? string.Empty,
|
||||
RegionName = regionName ?? string.Empty,
|
||||
ElectionDistrictName = electionDistrictName ?? string.Empty,
|
||||
Candidates = (refreshResult.Candidates ?? Array.Empty<CandidateEntry>())
|
||||
.Select(candidate => candidate.Clone())
|
||||
.ToArray(),
|
||||
TotalExpectedVotes = refreshResult.TotalExpectedVotes,
|
||||
TurnoutVotes = refreshResult.TurnoutVotes,
|
||||
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static ScheduleRegionTarget CreateScheduleRegionTarget(
|
||||
SbsElectionApiClient.DistrictSelectionOption option,
|
||||
string electionType)
|
||||
{
|
||||
return new ScheduleRegionTarget(
|
||||
electionType,
|
||||
option.DisplayName,
|
||||
option.RegionName,
|
||||
option.DistrictName,
|
||||
option.DistrictCode);
|
||||
}
|
||||
|
||||
private static string NormalizeDistrictKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -831,6 +831,10 @@ public sealed class MainViewModel : ObservableObject
|
||||
RequiresImage = item.RequiresImage,
|
||||
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds,
|
||||
TotalCuts = item.TotalCuts,
|
||||
RegionScope = item.RegionScope,
|
||||
ScheduleElectionType = item.ScheduleElectionType,
|
||||
RegionLabel = item.RegionLabel,
|
||||
RegionCode = item.RegionCode,
|
||||
State = item.State
|
||||
}).ToList()
|
||||
});
|
||||
@@ -902,6 +906,10 @@ public sealed class MainViewModel : ObservableObject
|
||||
RequiresImage = item.RequiresImage,
|
||||
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds,
|
||||
TotalCuts = item.TotalCuts,
|
||||
RegionScope = item.RegionScope,
|
||||
ScheduleElectionType = item.ScheduleElectionType,
|
||||
RegionLabel = item.RegionLabel,
|
||||
RegionCode = item.RegionCode,
|
||||
State = item.State
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user