diff --git a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs index edc73e7..b61d5d8 100644 --- a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs +++ b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs @@ -1488,7 +1488,8 @@ public sealed class ChannelScheduleEngine private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template) { return template.RecommendedChannel == BroadcastChannel.Normal && - string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal); + (string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal) || + template.Name.StartsWith("판세_기초단체장", StringComparison.Ordinal)); } private static string NormalizeRegionKey(string value) diff --git a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs index 617d3b2..a677ae1 100644 --- a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs +++ b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs @@ -1425,7 +1425,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable }; var candidateSlotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables); - if (candidateSlotCount > 0 && !IsTopPanseTemplate(template) && !IsNormalPanseMapTemplate(template)) + if (candidateSlotCount > 0 && !IsTopPanseTemplate(template) && !IsNormalPanseTemplate(template)) { ClearCandidateSlotValues(values, candidateSlotCount, template, sceneVariables); } @@ -1478,6 +1478,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable return FilterValuesForScene(values, sceneVariables, template); } + if (IsNormalBasicMayorPanseTemplate(template)) + { + ApplyNormalBasicMayorPanseValues(values, template, snapshot, templateFolderPath, sceneVariables); + return FilterValuesForScene(values, sceneVariables, template); + } + if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name)) { return FilterValuesForScene(values, sceneVariables, template); @@ -1667,6 +1673,35 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable } } + private static void ApplyNormalBasicMayorPanseValues( + IDictionary values, + FormatTemplateDefinition template, + ElectionDataSnapshot snapshot, + string templateFolderPath, + IReadOnlyDictionary sceneVariables) + { + var rows = BuildNormalBasicMayorPanseSummaries(snapshot); + var slotCount = ResolveNormalBasicMayorPanseSlotCount(sceneVariables); + var totalPlaces = ResolveNormalBasicMayorPanseTotalPlaces(snapshot); + var totalDisplay = FormattableString.Invariant($"총 {FormatCount(totalPlaces)}곳"); + + SetAliases(values, totalDisplay, "총", "총01", "총1"); + SetAliases(values, snapshot.RegionName, "시도명", "시도명01", "시도명1"); + + for (var slot = 1; slot <= slotCount; slot++) + { + var row = slot <= rows.Length ? rows[slot - 1] : default; + var partyLabel = string.IsNullOrWhiteSpace(row.Party) ? string.Empty : row.Party; + var colorParty = string.IsNullOrWhiteSpace(row.ColorParty) ? partyLabel : row.ColorParty; + var rateDisplay = FormatRate(row.Rate); + var graphColorPath = ResolvePartyGraphColorAssetPath(templateFolderPath, template.Name, colorParty); + + SetAliases(values, partyLabel, $"정당명{slot:00}", $"정당명{slot}"); + SetAliases(values, rateDisplay, $"득표율{slot:00}", $"득표율{slot}"); + SetOptionalAliases(values, graphColorPath, $"그래프{slot:00}", $"그래프{slot}"); + } + } + private static PanseSummary[] BuildNormalPanseMapSummaries(ElectionDataSnapshot snapshot) { var counts = snapshot.Candidates @@ -1679,6 +1714,33 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable .ToArray(); } + private static NormalBasicMayorPanseSummary[] BuildNormalBasicMayorPanseSummaries(ElectionDataSnapshot snapshot) + { + var totalPlaces = ResolveNormalBasicMayorPanseTotalPlaces(snapshot); + var counts = snapshot.Candidates + .Where(candidate => !IsPanseSummaryCandidate(candidate)) + .GroupBy(ResolveNormalPanseMapPartyKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + + return NormalPanseMapPartySlots + .Select(slot => + { + var count = counts.TryGetValue(slot.ColorParty, out var value) ? value : 0; + var rate = totalPlaces <= 0 + ? 0d + : Math.Round(count * 100d / totalPlaces, 1, MidpointRounding.AwayFromZero); + return new NormalBasicMayorPanseSummary(slot.Label, slot.ColorParty, count, rate); + }) + .ToArray(); + } + + private static int ResolveNormalBasicMayorPanseTotalPlaces(ElectionDataSnapshot snapshot) + { + return snapshot.TotalExpectedVotes > 0 + ? snapshot.TotalExpectedVotes + : snapshot.Candidates.Count(candidate => !IsPanseSummaryCandidate(candidate)); + } + private static PanseSummary[] BuildTopPanseSummaries( FormatTemplateDefinition template, ElectionDataSnapshot snapshot) @@ -2175,6 +2237,23 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount; } + private static int ResolveNormalBasicMayorPanseSlotCount(IReadOnlyDictionary sceneVariables) + { + var maxSlot = 0; + foreach (var variableName in sceneVariables.Keys) + { + if ((TryParseSimpleIndexedSlot(variableName, "득표율", out var slot) || + TryParseSimpleIndexedSlot(variableName, "그래프", out slot) || + TryParseSimpleIndexedSlot(variableName, "정당명", out slot)) && + slot > maxSlot) + { + maxSlot = slot; + } + } + + return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount; + } + private static bool TryParseSimpleIndexedSlot(string variableName, string prefix, out int slot) { slot = 0; @@ -2303,6 +2382,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables); } + if (IsNormalBasicMayorPanseTemplate(template)) + { + return Array.Empty(); + } + var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables); if (orderedCandidates.Length == 0) { @@ -2402,7 +2486,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable string t3CutPath, IReadOnlyDictionary sceneVariables) { - if (IsNormalPanseMapTemplate(template)) + if (IsNormalPanseTemplate(template)) { return (Array.Empty(), Array.Empty()); } @@ -2649,7 +2733,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable string t3CutPath, IReadOnlyDictionary sceneVariables) { - if (IsNormalPanseMapTemplate(template)) + if (IsNormalPanseTemplate(template)) { return (Array.Empty(), Array.Empty()); } @@ -2798,6 +2882,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables); } + if (IsNormalBasicMayorPanseTemplate(template)) + { + return BuildNormalBasicMayorPanseCounterNumberKeyUpdates(snapshot, sceneVariables); + } + if (IsNormalPanseMapTemplate(template)) { return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables); @@ -3011,6 +3100,43 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable return updates; } + private static IReadOnlyList BuildNormalBasicMayorPanseCounterNumberKeyUpdates( + ElectionDataSnapshot snapshot, + IReadOnlyDictionary sceneVariables) + { + var rows = BuildNormalBasicMayorPanseSummaries(snapshot); + var slotCount = ResolveNormalBasicMayorPanseSlotCount(sceneVariables); + if (slotCount <= 0) + { + return Array.Empty(); + } + + var updates = new List(slotCount); + for (var slot = 1; slot <= slotCount; slot++) + { + var rate = slot <= rows.Length ? NormalizeRateForBroadcast(rows[slot - 1].Rate) : 0d; + var matched = false; + foreach (var variableName in sceneVariables.Keys) + { + if (!TryParseSimpleIndexedSlot(variableName, "득표율", out var parsedSlot) || + parsedSlot != slot) + { + continue; + } + + updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, rate)); + matched = true; + } + + if (!matched && sceneVariables.Count == 0) + { + updates.Add(new KarismaCounterNumberKeyUpdate($"득표율{slot:00}", 1, rate)); + } + } + + return updates; + } + private static IReadOnlyList BuildTurnoutCounterNumberKeyUpdates( FormatTemplateDefinition template, ElectionDataSnapshot snapshot, @@ -5304,7 +5430,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable return false; } - return variableName is "선거구명" or "시도명" or "개표율" or "투표율" or "전국투표율" or + return variableName is "선거구명" or "시도명" or "개표율" or "투표율" or "전국투표율" or "총" or "기준시" or "기준시01" or "기준시02" or "유권자수" or "유권자수01" or "투표자수" or "투표자수01" or "유확당" || NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) || IsBottomWinnerBallotNumberVariableName(variableName) || @@ -5314,6 +5440,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable MatchesIndexedVariable(variableName, "개표율") || MatchesIndexedVariable(variableName, "투표율") || MatchesIndexedVariable(variableName, "전국투표율") || + MatchesIndexedVariable(variableName, "총") || MatchesIndexedVariable(variableName, "사진") || MatchesIndexedVariable(variableName, "순위") || IsSpecialRankVariableName(variableName) || @@ -6157,6 +6284,17 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal); } + private static bool IsNormalBasicMayorPanseTemplate(FormatTemplateDefinition template) + { + return template.RecommendedChannel == BroadcastChannel.Normal && + template.Name.StartsWith("판세_기초단체장", StringComparison.Ordinal); + } + + private static bool IsNormalPanseTemplate(FormatTemplateDefinition template) + { + return IsNormalPanseMapTemplate(template) || IsNormalBasicMayorPanseTemplate(template); + } + private static bool IsPanseEducationTemplate(string templateName) { return string.Equals(templateName, "판세_교육감", StringComparison.Ordinal); @@ -7199,6 +7337,8 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable private readonly record struct PanseSummary(string Party, int Count); + private readonly record struct NormalBasicMayorPanseSummary(string Party, string ColorParty, int Count, double Rate); + private readonly record struct NormalPansePartySlot(string Label, string ColorParty); private sealed record SceneUpdatePayload( diff --git a/Tornado3_2026Election/ViewModels/DataViewModel.cs b/Tornado3_2026Election/ViewModels/DataViewModel.cs index 767928b..7a889dc 100644 --- a/Tornado3_2026Election/ViewModels/DataViewModel.cs +++ b/Tornado3_2026Election/ViewModels/DataViewModel.cs @@ -1136,6 +1136,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa ]; } + if (IsNormalBasicMayorPanseTemplate(template)) + { + var panseOptions = await GetScheduleDistrictOptionsAsync(electionType, template, cancellationToken).ConfigureAwait(false); + return CreateScheduleRegionGroupOptions(panseOptions, electionType).ToArray(); + } + if (ShouldUseTurnoutPhotoRegionLevelOptions(template)) { return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false); @@ -1769,10 +1775,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa cancellationToken); } - if (IsNormalPanseMapTemplate(template)) + if (IsNormalPanseMapTemplate(template) || IsNormalBasicMayorPanseTemplate(template)) { return CreateNormalPanseMapScheduleSnapshotAsync( electionType, + template, + station, regionTargets, cancellationToken); } @@ -4379,12 +4387,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa private async Task CreateNormalPanseMapScheduleSnapshotAsync( string electionType, + FormatTemplateDefinition template, + BroadcastStationProfile station, IReadOnlyList regionTargets, CancellationToken cancellationToken) { + var isBasicMayorPanse = IsNormalBasicMayorPanseTemplate(template); + var panseLabel = isBasicMayorPanse ? "기초단체장 판세" : "전국 판세 지도"; if (!SupportsApiDistrictOptions(electionType)) { - throw new InvalidOperationException($"{electionType} 전국 판세 지도는 현재 SBS API 연동 대상이 아닙니다."); + throw new InvalidOperationException($"{electionType} {panseLabel}는 현재 SBS API 연동 대상이 아닙니다."); } var selectedTargets = regionTargets @@ -4394,7 +4406,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa .ToArray(); if (selectedTargets.Length == 0) { - throw new InvalidOperationException("전국 판세 지도 대상 선거구가 없습니다."); + throw new InvalidOperationException($"{panseLabel} 대상 선거구가 없습니다."); } var districtOptions = selectedTargets @@ -4410,7 +4422,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa .ConfigureAwait(false); if (refreshResults.Count == 0) { - throw new InvalidOperationException("전국 판세 지도용 개표 데이터가 없습니다."); + throw new InvalidOperationException($"{panseLabel}용 개표 데이터가 없습니다."); } var targetMap = selectedTargets @@ -4427,7 +4439,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa .ToArray(); if (leaders.Length == 0) { - throw new InvalidOperationException("전국 판세 지도에 반영할 1위 후보 데이터가 없습니다."); + throw new InvalidOperationException($"{panseLabel}에 반영할 1위 후보 데이터가 없습니다."); } var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes)); @@ -4437,21 +4449,28 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa var countedRate = totalVotes <= 0 ? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max() : Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero); - var history = ResolvePreElectionHistoryRecords(electionType, "전국", "전국"); + var regionName = isBasicMayorPanse + ? ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets) + : "전국"; + var districtName = isBasicMayorPanse && selectedTargets.Length == 1 + ? selectedTargets[0].DisplayName + : regionName; + var totalPlaces = selectedTargets.Length; + var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName); return new ElectionDataSnapshot { BroadcastPhase = BroadcastPhase.Counting, ElectionType = electionType, - DistrictName = "전국", - DistrictCode = string.Empty, - RegionName = "전국", - ElectionDistrictName = "전국", + DistrictName = districtName, + DistrictCode = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty, + RegionName = regionName, + ElectionDistrictName = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName, Candidates = leaders, - TotalExpectedVotes = totalVotes, - TurnoutVotes = turnoutVotes, - CountedVotesFromApi = countedVotes, - RemainingVotesFromApi = remainingVotes, + TotalExpectedVotes = isBasicMayorPanse ? totalPlaces : totalVotes, + TurnoutVotes = isBasicMayorPanse ? totalPlaces : turnoutVotes, + CountedVotesFromApi = isBasicMayorPanse ? leaders.Length : countedVotes, + RemainingVotesFromApi = isBasicMayorPanse ? Math.Max(0, totalPlaces - leaders.Length) : remainingVotes, CountedRateFromApi = countedRate, ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(), HistoricalTurnoutHistory = history.TurnoutHistory, @@ -5035,6 +5054,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal); } + private static bool IsNormalBasicMayorPanseTemplate(FormatTemplateDefinition? template) + { + return template?.RecommendedChannel == BroadcastChannel.Normal && + template.Name.StartsWith("판세_기초단체장", StringComparison.Ordinal); + } + private static bool IsPanseEducationTemplate(FormatTemplateDefinition template) { return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal);