Fix normal basic mayor panse aggregation

This commit is contained in:
2026-05-14 12:21:06 +09:00
parent 72afee11fc
commit 24915c1dca
3 changed files with 185 additions and 19 deletions

View File

@@ -1488,7 +1488,8 @@ public sealed class ChannelScheduleEngine
private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template) private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
{ {
return template.RecommendedChannel == BroadcastChannel.Normal && 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) private static string NormalizeRegionKey(string value)

View File

@@ -1425,7 +1425,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
}; };
var candidateSlotCount = ResolveBroadcastCandidateSlotCount(template, cut, snapshot, sceneVariables); 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); ClearCandidateSlotValues(values, candidateSlotCount, template, sceneVariables);
} }
@@ -1478,6 +1478,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return FilterValuesForScene(values, sceneVariables, template); return FilterValuesForScene(values, sceneVariables, template);
} }
if (IsNormalBasicMayorPanseTemplate(template))
{
ApplyNormalBasicMayorPanseValues(values, template, snapshot, templateFolderPath, sceneVariables);
return FilterValuesForScene(values, sceneVariables, template);
}
if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name)) if (ScheduleTemplatePolicy.IsStaticHistoricalTrendFormat(template.Name))
{ {
return FilterValuesForScene(values, sceneVariables, template); return FilterValuesForScene(values, sceneVariables, template);
@@ -1667,6 +1673,35 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
} }
} }
private static void ApplyNormalBasicMayorPanseValues(
IDictionary<string, string> values,
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
string templateFolderPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> 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) private static PanseSummary[] BuildNormalPanseMapSummaries(ElectionDataSnapshot snapshot)
{ {
var counts = snapshot.Candidates var counts = snapshot.Candidates
@@ -1679,6 +1714,33 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
.ToArray(); .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( private static PanseSummary[] BuildTopPanseSummaries(
FormatTemplateDefinition template, FormatTemplateDefinition template,
ElectionDataSnapshot snapshot) ElectionDataSnapshot snapshot)
@@ -2175,6 +2237,23 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount; return maxSlot > 0 ? maxSlot : DefaultNormalPanseMapSlotCount;
} }
private static int ResolveNormalBasicMayorPanseSlotCount(IReadOnlyDictionary<string, KarismaSceneVariableDefinition> 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) private static bool TryParseSimpleIndexedSlot(string variableName, string prefix, out int slot)
{ {
slot = 0; slot = 0;
@@ -2303,6 +2382,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables); return BuildNormalPanseMapStyleColorUpdates(template, snapshot, templateFolderPath, sceneVariables);
} }
if (IsNormalBasicMayorPanseTemplate(template))
{
return Array.Empty<KarismaStyleColorUpdate>();
}
var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables); var orderedCandidates = GetOrderedCandidates(template, cut, snapshot, sceneVariables);
if (orderedCandidates.Length == 0) if (orderedCandidates.Length == 0)
{ {
@@ -2402,7 +2486,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string t3CutPath, string t3CutPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables) IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{ {
if (IsNormalPanseMapTemplate(template)) if (IsNormalPanseTemplate(template))
{ {
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>()); return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
} }
@@ -2649,7 +2733,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string t3CutPath, string t3CutPath,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables) IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{ {
if (IsNormalPanseMapTemplate(template)) if (IsNormalPanseTemplate(template))
{ {
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>()); return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
} }
@@ -2798,6 +2882,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables); return BuildTopPanseCounterNumberKeyUpdates(template, snapshot, sceneVariables);
} }
if (IsNormalBasicMayorPanseTemplate(template))
{
return BuildNormalBasicMayorPanseCounterNumberKeyUpdates(snapshot, sceneVariables);
}
if (IsNormalPanseMapTemplate(template)) if (IsNormalPanseMapTemplate(template))
{ {
return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables); return BuildNormalPanseMapCounterNumberKeyUpdates(snapshot, sceneVariables);
@@ -3011,6 +3100,43 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates; return updates;
} }
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildNormalBasicMayorPanseCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var rows = BuildNormalBasicMayorPanseSummaries(snapshot);
var slotCount = ResolveNormalBasicMayorPanseSlotCount(sceneVariables);
if (slotCount <= 0)
{
return Array.Empty<KarismaCounterNumberKeyUpdate>();
}
var updates = new List<KarismaCounterNumberKeyUpdate>(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<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates( private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
FormatTemplateDefinition template, FormatTemplateDefinition template,
ElectionDataSnapshot snapshot, ElectionDataSnapshot snapshot,
@@ -5304,7 +5430,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return false; 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 "유확당" || "기준시" or "기준시01" or "기준시02" or "유권자수" or "유권자수01" or "투표자수" or "투표자수01" or "유확당" ||
NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) || NormalPanseMapRegions.Contains(variableName, StringComparer.Ordinal) ||
IsBottomWinnerBallotNumberVariableName(variableName) || IsBottomWinnerBallotNumberVariableName(variableName) ||
@@ -5314,6 +5440,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
MatchesIndexedVariable(variableName, "개표율") || MatchesIndexedVariable(variableName, "개표율") ||
MatchesIndexedVariable(variableName, "투표율") || MatchesIndexedVariable(variableName, "투표율") ||
MatchesIndexedVariable(variableName, "전국투표율") || MatchesIndexedVariable(variableName, "전국투표율") ||
MatchesIndexedVariable(variableName, "총") ||
MatchesIndexedVariable(variableName, "사진") || MatchesIndexedVariable(variableName, "사진") ||
MatchesIndexedVariable(variableName, "순위") || MatchesIndexedVariable(variableName, "순위") ||
IsSpecialRankVariableName(variableName) || IsSpecialRankVariableName(variableName) ||
@@ -6157,6 +6284,17 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal); 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) private static bool IsPanseEducationTemplate(string templateName)
{ {
return string.Equals(templateName, "판세_교육감", StringComparison.Ordinal); 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 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 readonly record struct NormalPansePartySlot(string Label, string ColorParty);
private sealed record SceneUpdatePayload( private sealed record SceneUpdatePayload(

View File

@@ -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)) if (ShouldUseTurnoutPhotoRegionLevelOptions(template))
{ {
return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false); return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false);
@@ -1769,10 +1775,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
cancellationToken); cancellationToken);
} }
if (IsNormalPanseMapTemplate(template)) if (IsNormalPanseMapTemplate(template) || IsNormalBasicMayorPanseTemplate(template))
{ {
return CreateNormalPanseMapScheduleSnapshotAsync( return CreateNormalPanseMapScheduleSnapshotAsync(
electionType, electionType,
template,
station,
regionTargets, regionTargets,
cancellationToken); cancellationToken);
} }
@@ -4379,12 +4387,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private async Task<ElectionDataSnapshot> CreateNormalPanseMapScheduleSnapshotAsync( private async Task<ElectionDataSnapshot> CreateNormalPanseMapScheduleSnapshotAsync(
string electionType, string electionType,
FormatTemplateDefinition template,
BroadcastStationProfile station,
IReadOnlyList<ScheduleRegionTarget> regionTargets, IReadOnlyList<ScheduleRegionTarget> regionTargets,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var isBasicMayorPanse = IsNormalBasicMayorPanseTemplate(template);
var panseLabel = isBasicMayorPanse ? "기초단체장 판세" : "전국 판세 지도";
if (!SupportsApiDistrictOptions(electionType)) if (!SupportsApiDistrictOptions(electionType))
{ {
throw new InvalidOperationException($"{electionType} 전국 판세 지도는 현재 SBS API 연동 대상이 아닙니다."); throw new InvalidOperationException($"{electionType} {panseLabel}는 현재 SBS API 연동 대상이 아닙니다.");
} }
var selectedTargets = regionTargets var selectedTargets = regionTargets
@@ -4394,7 +4406,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray(); .ToArray();
if (selectedTargets.Length == 0) if (selectedTargets.Length == 0)
{ {
throw new InvalidOperationException("전국 판세 지도 대상 선거구가 없습니다."); throw new InvalidOperationException($"{panseLabel} 대상 선거구가 없습니다.");
} }
var districtOptions = selectedTargets var districtOptions = selectedTargets
@@ -4410,7 +4422,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ConfigureAwait(false); .ConfigureAwait(false);
if (refreshResults.Count == 0) if (refreshResults.Count == 0)
{ {
throw new InvalidOperationException("전국 판세 지도용 개표 데이터가 없습니다."); throw new InvalidOperationException($"{panseLabel}용 개표 데이터가 없습니다.");
} }
var targetMap = selectedTargets var targetMap = selectedTargets
@@ -4427,7 +4439,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray(); .ToArray();
if (leaders.Length == 0) if (leaders.Length == 0)
{ {
throw new InvalidOperationException("전국 판세 지도에 반영할 1위 후보 데이터가 없습니다."); throw new InvalidOperationException($"{panseLabel}에 반영할 1위 후보 데이터가 없습니다.");
} }
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes)); 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 var countedRate = totalVotes <= 0
? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max() ? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max()
: Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero); : 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 return new ElectionDataSnapshot
{ {
BroadcastPhase = BroadcastPhase.Counting, BroadcastPhase = BroadcastPhase.Counting,
ElectionType = electionType, ElectionType = electionType,
DistrictName = "전국", DistrictName = districtName,
DistrictCode = string.Empty, DistrictCode = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
RegionName = "전국", RegionName = regionName,
ElectionDistrictName = "전국", ElectionDistrictName = isBasicMayorPanse && selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName,
Candidates = leaders, Candidates = leaders,
TotalExpectedVotes = totalVotes, TotalExpectedVotes = isBasicMayorPanse ? totalPlaces : totalVotes,
TurnoutVotes = turnoutVotes, TurnoutVotes = isBasicMayorPanse ? totalPlaces : turnoutVotes,
CountedVotesFromApi = countedVotes, CountedVotesFromApi = isBasicMayorPanse ? leaders.Length : countedVotes,
RemainingVotesFromApi = remainingVotes, RemainingVotesFromApi = isBasicMayorPanse ? Math.Max(0, totalPlaces - leaders.Length) : remainingVotes,
CountedRateFromApi = countedRate, CountedRateFromApi = countedRate,
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(), ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(),
HistoricalTurnoutHistory = history.TurnoutHistory, HistoricalTurnoutHistory = history.TurnoutHistory,
@@ -5035,6 +5054,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal); 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) private static bool IsPanseEducationTemplate(FormatTemplateDefinition template)
{ {
return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal); return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal);