Apply council seat table by sigungu

This commit is contained in:
2026-05-14 12:35:55 +09:00
parent df01f07c44
commit 7e3f496ae4
2 changed files with 676 additions and 28 deletions

View File

@@ -301,10 +301,11 @@ public sealed class SbsElectionApiClient : IDisposable
foreach (var responseItem in countingItems) foreach (var responseItem in countingItems)
{ {
var item = responseItem.Item; var item = responseItem.Item;
var regionId = item.Region?.Id; var matchedRegionCode = GetCountingRegionMatchKeys(item.Region)
if (string.IsNullOrWhiteSpace(regionId) || .FirstOrDefault(key => districtMap.ContainsKey(key));
!districtMap.TryGetValue(regionId, out var districtOption) || if (string.IsNullOrWhiteSpace(matchedRegionCode) ||
!orderMap.TryGetValue(regionId, out var order)) !districtMap.TryGetValue(matchedRegionCode, out var districtOption) ||
!orderMap.TryGetValue(matchedRegionCode, out var order))
{ {
continue; continue;
} }
@@ -322,6 +323,179 @@ public sealed class SbsElectionApiClient : IDisposable
.ToArray(); .ToArray();
} }
public async Task<IReadOnlyList<DistrictSelectionOption>> GetCouncilSeatRegionOptionsAsync(
string electionType,
IEnumerable<string> regionFilters,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return Array.Empty<DistrictSelectionOption>();
}
var scopedSidoCodes = ResolveCouncilSeatSidoCodes(configuration.SungerType, regionFilters);
var items = await GetCouncilSeatItemsAsync(
configuration,
scopedSidoCodes,
cancellationToken)
.ConfigureAwait(false);
return items
.Where(item => item.Sido is not null)
.GroupBy(item => FormatSidoCode(item.Sido!.Code), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var item = group.First();
var code = FormatSidoCode(item.Sido!.Code);
var regionName = BuildOutputRegionName(ExpandRegionName(item.Sido.Name));
return new
{
Order = item.Sido.Order,
Option = new DistrictSelectionOption(
DisplayName: regionName,
DistrictCode: code,
RegionName: regionName,
DistrictName: regionName,
ParentRegionCode: code)
};
})
.OrderBy(item => item.Order > 0 ? item.Order : int.MaxValue)
.ThenBy(item => item.Option.RegionName, StringComparer.Ordinal)
.Select(item => item.Option)
.ToArray();
}
public async Task<IReadOnlyList<DistrictSelectionOption>> GetCouncilSeatSigunguOptionsAsync(
string electionType,
IEnumerable<string> regionFilters,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return Array.Empty<DistrictSelectionOption>();
}
var scopedSidoCodes = ResolveCouncilSeatSidoCodes(configuration.SungerType, regionFilters);
var items = await GetCouncilSeatItemsAsync(
configuration,
scopedSidoCodes,
cancellationToken)
.ConfigureAwait(false);
return items
.Where(item => item.Sido is not null)
.SelectMany(item =>
{
var sidoCode = FormatSidoCode(item.Sido!.Code);
var regionName = BuildOutputRegionName(ExpandRegionName(item.Sido.Name));
return (item.Sigungus ?? [])
.Where(sigungu => sigungu.Sigungu is not null &&
!string.IsNullOrWhiteSpace(sigungu.Sigungu.Id))
.Select(sigungu =>
{
var districtName = sigungu.Sigungu!.Name.Trim();
return new
{
SidoOrder = item.Sido.Order,
SigunguOrder = sigungu.Sigungu.Order,
Option = new DistrictSelectionOption(
DisplayName: BuildFullDistrictDisplayName(regionName, districtName),
DistrictCode: sigungu.Sigungu.Id.Trim(),
RegionName: regionName,
DistrictName: districtName,
ParentRegionCode: sidoCode)
};
});
})
.GroupBy(item => item.Option.DistrictCode, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(item => item.SidoOrder > 0 ? item.SidoOrder : int.MaxValue)
.ThenBy(item => item.SigunguOrder > 0 ? item.SigunguOrder : int.MaxValue)
.ThenBy(item => item.Option.DisplayName, StringComparer.Ordinal)
.Select(item => item.Option)
.ToArray();
}
public async Task<IReadOnlyList<CouncilSeatPartySummary>> GetCouncilSeatSummariesAsync(
string electionType,
IReadOnlyList<string> sidoCodes,
CancellationToken cancellationToken)
{
return await GetCouncilSeatSummariesAsync(
electionType,
sidoCodes,
Array.Empty<string>(),
cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<CouncilSeatPartySummary>> GetCouncilSeatSummariesAsync(
string electionType,
IReadOnlyList<string> sidoCodes,
IReadOnlyList<string> sigunguCodes,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return Array.Empty<CouncilSeatPartySummary>();
}
var items = await GetCouncilSeatItemsAsync(
configuration,
sidoCodes,
cancellationToken)
.ConfigureAwait(false);
var sigunguCodeSet = NormalizeCouncilSeatSigunguCodes(sigunguCodes);
return items
.SelectMany(item => ResolveCouncilSeatPartyItems(item, sigunguCodeSet))
.GroupBy(item => string.IsNullOrWhiteSpace(item.Id) ? item.Name : item.Id, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return new CouncilSeatPartySummary(
PartyId: first.Id ?? string.Empty,
PartyName: string.IsNullOrWhiteSpace(first.Name) ? "무기타" : first.Name.Trim(),
RegionalSeats: group.Sum(item => Math.Max(0, item.Regional)),
ProportionalSeats: group.Sum(item => Math.Max(0, item.Proportional)),
TotalSeats: group.Sum(item => Math.Max(0, item.Total)));
})
.Where(row => row.TotalSeats > 0 || row.RegionalSeats > 0 || row.ProportionalSeats > 0)
.OrderByDescending(row => row.TotalSeats)
.ThenBy(row => row.PartyName, StringComparer.Ordinal)
.ToArray();
}
public async Task<CouncilPanseResult?> GetCouncilPanseAsync(
string electionType,
CancellationToken cancellationToken)
{
if (!ElectionConfigurations.TryGetValue(electionType, out var configuration) ||
!IsBasicCouncilCountingType(configuration.SungerType))
{
return null;
}
var path = BuildPansePath(configuration);
var json = await GetJsonAsync(
configuration.BaseUri,
path,
cancellationToken)
.ConfigureAwait(false);
var item = JsonSerializer.Deserialize<SbsCouncilPanseItem>(json, SerializerOptions);
return item is null
? null
: new CouncilPanseResult(
item.Minju,
item.Kukhim,
item.Etc,
DateTimeOffset.Now,
$"GET /{path}");
}
public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync( public async Task<TurnoutOverviewResult> GetTurnoutOverviewAsync(
string electionType, string electionType,
IReadOnlyList<DistrictSelectionOption> districts, IReadOnlyList<DistrictSelectionOption> districts,
@@ -1224,6 +1398,186 @@ public sealed class SbsElectionApiClient : IDisposable
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus" ? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus"
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}"; : $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/sungergus?{query}";
private static string BuildPansePath(SbsElectionConfiguration configuration)
=> $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/panse";
private static string BuildCouncilSeatPath(SbsElectionConfiguration configuration, string query)
=> string.IsNullOrWhiteSpace(query)
? $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/uiseok"
: $"{configuration.CountingEndpointSegment}/{configuration.SungerType}/uiseok?{query}";
private async Task<IReadOnlyList<SbsCouncilSeatSidoItem>> GetCouncilSeatItemsAsync(
SbsElectionConfiguration configuration,
IReadOnlyList<string> sidoCodes,
CancellationToken cancellationToken)
{
var normalizedSidoCodes = NormalizeSidoCodes(sidoCodes);
if (configuration.SungerType == 5 && normalizedSidoCodes.Count == 0)
{
return await GetArrayAsync<SbsCouncilSeatSidoItem>(
configuration.BaseUri,
BuildCouncilSeatPath(configuration, string.Empty),
cancellationToken)
.ConfigureAwait(false);
}
var querySidoCodes = normalizedSidoCodes.Count > 0
? normalizedSidoCodes
: ResolveAllBasicApiSidoCodes();
var items = new List<SbsCouncilSeatSidoItem>();
var failures = 0;
foreach (var sidoCode in querySidoCodes)
{
try
{
var path = BuildCouncilSeatPath(
configuration,
$"sidos={Uri.EscapeDataString(sidoCode)}");
items.AddRange(await GetArrayAsync<SbsCouncilSeatSidoItem>(
configuration.BaseUri,
path,
cancellationToken)
.ConfigureAwait(false));
}
catch (HttpRequestException)
{
failures++;
}
}
if (items.Count == 0 && failures > 0)
{
throw new HttpRequestException("SBS API 의석표 데이터를 불러오지 못했습니다.");
}
return items
.GroupBy(item => FormatSidoCode(item.Sido?.Code ?? 0), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Where(item => item.Sido is not null)
.ToArray();
}
private static IReadOnlyList<string> NormalizeSidoCodes(IEnumerable<string> sidoCodes)
{
return (sidoCodes ?? Array.Empty<string>())
.Select(code => code?.Trim() ?? string.Empty)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.PadLeft(2, '0'))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(code => code, StringComparer.Ordinal)
.ToArray();
}
private static IReadOnlyList<string> ResolveAllBasicApiSidoCodes()
{
return
[
"11",
"26",
"27",
"28",
"29",
"30",
"31",
"41",
"43",
"44",
"46",
"47",
"48",
"49",
"51",
"52",
"53"
];
}
private static IReadOnlyList<string> ResolveCouncilSeatSidoCodes(int sungerType, IEnumerable<string> regionNames)
{
return (regionNames ?? Array.Empty<string>())
.Select(regionName => ResolveCouncilSeatSidoCode(sungerType, regionName))
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(code => code, StringComparer.Ordinal)
.ToArray();
}
private static string ResolveCouncilSeatSidoCode(int sungerType, string? regionName)
{
if (string.IsNullOrWhiteSpace(regionName))
{
return string.Empty;
}
var normalized = NormalizeRegionName(regionName);
return normalized switch
{
"세종" => "51",
"강원" => "52",
"전북" => "53",
"광주" when sungerType == 5 => "29",
"전남" when sungerType == 5 => "29",
"전남광주" when sungerType == 5 => "29",
"광주전남" when sungerType == 5 => "29",
_ => ResolveBasicApiSidoCode(regionName)
};
}
private static IEnumerable<SbsCouncilSeatPartyItem> ResolveCouncilSeatPartyItems(SbsCouncilSeatSidoItem item)
{
if (item.Parties is { Count: > 0 })
{
return item.Parties;
}
return item.Sigungus?
.SelectMany(sigungu => sigungu.Parties ?? [])
.ToArray() ?? [];
}
private static IEnumerable<SbsCouncilSeatPartyItem> ResolveCouncilSeatPartyItems(
SbsCouncilSeatSidoItem item,
ISet<string> sigunguCodes)
{
if (sigunguCodes.Count == 0)
{
return ResolveCouncilSeatPartyItems(item);
}
return item.Sigungus?
.Where(sigungu => sigungu.Sigungu is not null &&
sigunguCodes.Contains(sigungu.Sigungu.Id.Trim()))
.SelectMany(sigungu => sigungu.Parties ?? [])
.ToArray() ?? [];
}
private static ISet<string> NormalizeCouncilSeatSigunguCodes(IEnumerable<string> sigunguCodes)
{
return (sigunguCodes ?? Array.Empty<string>())
.Select(code => code?.Trim() ?? string.Empty)
.Where(code => !string.IsNullOrWhiteSpace(code))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> GetCountingRegionMatchKeys(SbsTurnoutRegion? region)
{
if (region is null)
{
yield break;
}
foreach (var key in new[] { region.Id, region.Name4Id, region.Name3Id, region.Name2Id })
{
if (!string.IsNullOrWhiteSpace(key))
{
yield return key.Trim();
}
}
}
private static string FormatSidoCode(int code)
=> code <= 0 ? string.Empty : code.ToString("00", CultureInfo.InvariantCulture);
private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration) private static bool CanDeriveDistrictsFromCounting(SbsElectionConfiguration configuration)
=> Uri.Compare( => Uri.Compare(
configuration.BaseUri, configuration.BaseUri,
@@ -1711,6 +2065,20 @@ public sealed class SbsElectionApiClient : IDisposable
string JudgementBadgeText = "", string JudgementBadgeText = "",
string JudgementDetailText = ""); string JudgementDetailText = "");
public sealed record CouncilSeatPartySummary(
string PartyId,
string PartyName,
int RegionalSeats,
int ProportionalSeats,
int TotalSeats);
public sealed record CouncilPanseResult(
int Minju,
int Kukhim,
int Etc,
DateTimeOffset ReceivedAt,
string SourcePath);
public sealed record TurnoutOverviewItem( public sealed record TurnoutOverviewItem(
string DisplayName, string DisplayName,
string RegionName, string RegionName,
@@ -1832,6 +2200,81 @@ public sealed class SbsElectionApiClient : IDisposable
public int Tupyosu { get; set; } public int Tupyosu { get; set; }
} }
private sealed class SbsCouncilPanseItem
{
[JsonPropertyName("minju")]
public int Minju { get; set; }
[JsonPropertyName("kukhim")]
public int Kukhim { get; set; }
[JsonPropertyName("etc")]
public int Etc { get; set; }
}
private sealed class SbsCouncilSeatSidoItem
{
[JsonPropertyName("sido")]
public SbsCouncilSeatSido? Sido { get; set; }
[JsonPropertyName("sigungus")]
public List<SbsCouncilSeatSigunguItem>? Sigungus { get; set; }
[JsonPropertyName("parties")]
public List<SbsCouncilSeatPartyItem>? Parties { get; set; }
}
private sealed class SbsCouncilSeatSido
{
[JsonPropertyName("code")]
public int Code { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("order")]
public int Order { get; set; }
}
private sealed class SbsCouncilSeatSigunguItem
{
[JsonPropertyName("sigungu")]
public SbsCouncilSeatSigungu? Sigungu { get; set; }
[JsonPropertyName("parties")]
public List<SbsCouncilSeatPartyItem>? Parties { get; set; }
}
private sealed class SbsCouncilSeatSigungu
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("order")]
public int Order { get; set; }
}
private sealed class SbsCouncilSeatPartyItem
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("regional")]
public int Regional { get; set; }
[JsonPropertyName("proportional")]
public int Proportional { get; set; }
[JsonPropertyName("total")]
public int Total { get; set; }
}
private sealed class SbsCountingItem private sealed class SbsCountingItem
{ {
[JsonPropertyName("region")] [JsonPropertyName("region")]

View File

@@ -1165,9 +1165,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
} }
}; };
if (IsByElectionTemplate(template) || if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
IsNormalPreElectionTurnoutDistrictBoardTemplate(template) || {
ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name)) regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, 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
}));
}
else if (IsByElectionTemplate(template) ||
IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
{ {
regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType)); regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
} }
@@ -3684,6 +3696,25 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
FormatTemplateDefinition? template, FormatTemplateDefinition? template,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name) &&
IsBasicCouncilElectionType(electionType))
{
try
{
var councilSeatRegions = await _apiClient
.GetCouncilSeatSigunguOptionsAsync(electionType, ResolveApiDistrictRegionScope(electionType), cancellationToken)
.ConfigureAwait(false);
if (councilSeatRegions.Count > 0)
{
return councilSeatRegions;
}
}
catch (Exception ex)
{
_logService.Warning($"{electionType} 의석표 지역 목록 수신 실패: {ex.Message}");
}
}
if (UsesHistoricalScheduleOptions(template)) if (UsesHistoricalScheduleOptions(template))
{ {
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType); var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
@@ -4166,31 +4197,73 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다."); throw new InvalidOperationException("의석 집계 대상 선거구가 없습니다.");
} }
var sidoCodes = selectedTargets
.Select(ResolveCouncilSeatTargetSidoCode)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (sidoCodes.Length == 0)
{
throw new InvalidOperationException("의석 집계 대상 시도코드가 없습니다.");
}
var sigunguCodes = selectedTargets
.Select(ResolveCouncilSeatTargetSigunguCode)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var councilSeats = await _apiClient
.GetCouncilSeatSummariesAsync(electionType, sidoCodes, sigunguCodes, cancellationToken)
.ConfigureAwait(false);
if (councilSeats.Count == 0)
{
throw new InvalidOperationException("의석 집계용 SBS API 의석표 데이터가 없습니다.");
}
var seatCandidates = BuildCouncilSeatSummaryCandidates(councilSeats);
if (seatCandidates.Length == 0)
{
throw new InvalidOperationException("의석표에 반영할 정당별 의석 데이터가 없습니다.");
}
var refreshResults = await GetCountingSnapshotsForScheduleTargetsAsync( var refreshResults = await GetCountingSnapshotsForScheduleTargetsAsync(
electionType, electionType,
selectedTargets, selectedTargets,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (refreshResults.Count == 0)
{
throw new InvalidOperationException("의석 집계용 개표 데이터가 없습니다.");
}
var seatCandidates = BuildCouncilSeatSummaryCandidates(electionType, refreshResults);
if (seatCandidates.Length == 0)
{
throw new InvalidOperationException("의석으로 집계할 현재 득표 기준 후보가 없습니다.");
}
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes)); var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes)); var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0)); var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0)); var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0));
var countedRate = ResolveCouncilSeatAggregateCountedRate(refreshResults); var countedRate = refreshResults.Count == 0
? 0d
: ResolveAggregateCountedRate(totalVotes, countedVotes, refreshResults);
var totalSeats = councilSeats.Sum(row => Math.Max(row.TotalSeats, row.RegionalSeats + row.ProportionalSeats));
var regionName = ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets); var regionName = ResolveCouncilSeatAggregateRegionLabel(station, selectedTargets);
var districtName = selectedTargets.Length == 1 var isSingleSigunguTarget = selectedTargets.Length == 1 && sigunguCodes.Length == 1;
var districtName = isSingleSigunguTarget
? FirstNonWhiteSpace(selectedTargets[0].DistrictName, selectedTargets[0].DisplayName)
: selectedTargets.Length == 1
? selectedTargets[0].DisplayName ? selectedTargets[0].DisplayName
: regionName; : regionName;
var receivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max();
var countedVotesForSnapshot = refreshResults.Count == 0
? totalSeats
: countedVotes;
var remainingVotesForSnapshot = refreshResults.Count == 0
? 0
: remainingVotes;
var totalExpectedVotesForSnapshot = totalVotes > 0
? totalVotes
: totalSeats;
var turnoutVotesForSnapshot = turnoutVotes > 0
? turnoutVotes
: totalSeats;
var electionDistrictName = isSingleSigunguTarget
? districtName
: selectedTargets.Length == 1
? selectedTargets[0].DistrictName
: regionName;
var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName); var history = ResolvePreElectionHistoryRecords(electionType, regionName, districtName);
return new ElectionDataSnapshot return new ElectionDataSnapshot
@@ -4200,14 +4273,14 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
DistrictName = districtName, DistrictName = districtName,
DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty, DistrictCode = selectedTargets.Length == 1 ? selectedTargets[0].DistrictCode : string.Empty,
RegionName = regionName, RegionName = regionName,
ElectionDistrictName = selectedTargets.Length == 1 ? selectedTargets[0].DistrictName : regionName, ElectionDistrictName = electionDistrictName,
Candidates = seatCandidates, Candidates = seatCandidates,
TotalExpectedVotes = totalVotes, TotalExpectedVotes = totalExpectedVotesForSnapshot,
TurnoutVotes = turnoutVotes, TurnoutVotes = turnoutVotesForSnapshot,
CountedVotesFromApi = countedVotes, CountedVotesFromApi = countedVotesForSnapshot,
RemainingVotesFromApi = remainingVotes, RemainingVotesFromApi = remainingVotesForSnapshot,
CountedRateFromApi = countedRate, CountedRateFromApi = countedRate,
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max(), ReceivedAt = receivedAt,
HistoricalTurnoutHistory = history.TurnoutHistory, HistoricalTurnoutHistory = history.TurnoutHistory,
HistoricalWinnerHistory = history.WinnerHistory HistoricalWinnerHistory = history.WinnerHistory
}; };
@@ -4716,6 +4789,95 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character))); return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
} }
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(
IReadOnlyList<SbsElectionApiClient.CouncilSeatPartySummary> councilSeats)
{
var candidates = new List<CandidateEntry>(councilSeats.Count * 2);
for (var index = 0; index < councilSeats.Count; index++)
{
var row = councilSeats[index];
if (row.RegionalSeats > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate(
$"{CouncilSeatDistrictCandidateCodePrefix}{index + 1:00}",
row.PartyName,
row.RegionalSeats,
index + 1));
}
if (row.ProportionalSeats > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate(
$"{CouncilSeatProportionalCandidateCodePrefix}{index + 1:00}",
row.PartyName,
row.ProportionalSeats,
index + 1));
}
if (row.RegionalSeats <= 0 && row.ProportionalSeats <= 0 && row.TotalSeats > 0)
{
candidates.Add(CreateCouncilSeatSummaryCandidate(
$"{CouncilSeatCandidateCodePrefix}{index + 1:00}",
row.PartyName,
row.TotalSeats,
index + 1));
}
}
return candidates.ToArray();
}
private static CandidateEntry[] BuildProportionalCouncilCandidates(
IReadOnlyList<SbsElectionApiClient.CouncilSeatPartySummary> councilSeats)
{
var totalProportionalSeats = councilSeats.Sum(row => Math.Max(0, row.ProportionalSeats));
return councilSeats
.OrderByDescending(row => row.ProportionalSeats)
.ThenBy(row => row.PartyName, StringComparer.Ordinal)
.Select((row, index) =>
{
var seatCount = Math.Max(0, row.ProportionalSeats);
return new CandidateEntry
{
CandidateCode = $"{CouncilSeatProportionalCandidateCodePrefix}{index + 1:00}",
BallotNumber = (index + 1).ToString(),
Name = row.PartyName,
Party = row.PartyName,
ColorParty = row.PartyName,
VoteCount = seatCount,
VoteRate = totalProportionalSeats <= 0
? 0
: Math.Round(seatCount * 100d / totalProportionalSeats, 1, MidpointRounding.AwayFromZero),
HasImage = false,
ManualJudgement = CandidateJudgement.None,
AutomaticJudgement = seatCount > 0 ? CandidateJudgement.Elected : CandidateJudgement.None
};
})
.ToArray();
}
private static CandidateEntry CreateCouncilSeatSummaryCandidate(
string candidateCode,
string partyName,
int seatCount,
int ballotNumber)
{
var normalizedParty = string.IsNullOrWhiteSpace(partyName) ? "무기타" : partyName.Trim();
return new CandidateEntry
{
CandidateCode = candidateCode,
BallotNumber = ballotNumber.ToString(),
Name = normalizedParty,
Party = normalizedParty,
ColorParty = normalizedParty,
VoteCount = seatCount,
VoteRate = seatCount,
HasImage = false,
ManualJudgement = CandidateJudgement.None,
AutomaticJudgement = CandidateJudgement.Elected
};
}
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates( private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(
string electionType, string electionType,
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults) IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
@@ -5084,6 +5246,49 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName); return SbsElectionApiClient.ResolveBasicApiSidoCode(target.RegionName);
} }
private string ResolveCouncilSeatTargetSidoCode(ScheduleRegionTarget target)
{
if (!string.IsNullOrWhiteSpace(target.DistrictCode) &&
target.DistrictCode.Length == 2 &&
target.DistrictCode.All(char.IsDigit))
{
return target.DistrictCode;
}
return ResolveScheduleTargetParentRegionCode(target);
}
private static string ResolveCouncilSeatTargetSigunguCode(ScheduleRegionTarget target)
{
if (string.IsNullOrWhiteSpace(target.DistrictCode) ||
target.DistrictCode.Length == 2 && target.DistrictCode.All(char.IsDigit))
{
return string.Empty;
}
return target.DistrictCode.Trim();
}
private string ResolveSelectedCouncilSeatSidoCode()
{
if (!string.IsNullOrWhiteSpace(DistrictCode) &&
DistrictCode.Length == 2 &&
DistrictCode.All(char.IsDigit))
{
return DistrictCode;
}
if (!string.IsNullOrWhiteSpace(DistrictName) &&
_districtOptionMap.TryGetValue(DistrictName, out var option))
{
return !string.IsNullOrWhiteSpace(option.ParentRegionCode)
? option.ParentRegionCode
: option.DistrictCode;
}
return SbsElectionApiClient.ResolveBasicApiSidoCode(FirstNonWhiteSpace(RegionName, DistrictName));
}
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template) private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{ {
return template.RecommendedChannel == BroadcastChannel.Bottom && return template.RecommendedChannel == BroadcastChannel.Bottom &&