feat(election): modern results dashboard with donut chart, KPI panel, and symbol fix

- Full redesign: backdrop blobs, KPI metrics panel, split-layout card (donut + candidates list)
- Donut chart (CSS conic-gradient) per post showing vote share by candidate color
- Winner spotlight banner; unopposed candidate celebration card
- Fixed loadSymbols() timing bug: results iterated unconditionally, candidates use hasSymbol guard
- Added fetchAndCacheSymbol() helper; loadSymbols() called after declareResults()
- Fixed saveManualCounts -> saveManualVoteCounts method name mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-01 18:34:27 +05:30
co-authored by Claude Sonnet 4.6
parent f2f2338c78
commit bc513e7c89
3 changed files with 1660 additions and 992 deletions
@@ -6,269 +6,419 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="election-results">
<div class="page-header">
<a routerLink="/election" class="back-link"><mat-icon>arrow_back</mat-icon></a>
<div>
<h2>Election Results</h2>
@if (election) {
<p class="sub-title">{{ election.title }}</p>
}
</div>
@if (canDeclare) {
<button class="btn-declare" (click)="declareResults()" [disabled]="declaring">
<mat-icon>gavel</mat-icon>
{{ declaring ? 'Declaring...' : 'Declare Results' }}
</button>
}
<div class="results-dashboard">
<!-- Backdrop Decorative Blobs -->
<div class="backdrop-blobs">
<div class="blob blob-indigo"></div>
<div class="blob blob-cyan"></div>
<div class="blob blob-amber"></div>
</div>
<!-- Page Header -->
<div class="page-header">
<div class="header-left">
<a routerLink="/election" class="back-link">
<mat-icon>arrow_back</mat-icon>
</a>
<div class="title-section">
<h2>Election Results Portal</h2>
@if (election) {
<p class="election-title">{{ election.title }}</p>
}
</div>
</div>
<div class="header-actions">
@if (canDeclare) {
<button class="btn-declare" (click)="declareResults()" [disabled]="declaring">
<mat-icon>gavel</mat-icon>
<span>{{ declaring ? 'Declaring...' : 'Declare Results' }}</span>
</button>
}
</div>
</div>
<!-- KPI Metrics Panel -->
@if (!loading && (results.length > 0 || candidates.length > 0)) {
<div class="kpi-panel">
<!-- Status KPI -->
<div class="kpi-card kpi-status">
<div class="kpi-icon-wrapper">
<mat-icon>info</mat-icon>
</div>
<div class="kpi-content">
<span class="kpi-label">Election Status</span>
<span class="status-badge" [class]="election?.status">
{{ election?.status?.replace('_', ' ') }}
</span>
</div>
</div>
<!-- Total Votes KPI -->
@if (results.length > 0) {
<div class="kpi-card">
<div class="kpi-icon-wrapper" style="background: rgba(99, 102, 241, 0.1); color: #6366f1">
<mat-icon>how_to_vote</mat-icon>
</div>
<div class="kpi-content">
<span class="kpi-label">Total Votes Cast</span>
<span class="kpi-value">{{ kpis.totalVotes | number }}</span>
</div>
</div>
}
<!-- Contested Posts KPI -->
<div class="kpi-card">
<div class="kpi-icon-wrapper" style="background: rgba(16, 185, 129, 0.1); color: #10b981">
<mat-icon>people</mat-icon>
</div>
<div class="kpi-content">
<span class="kpi-label">Contested Posts</span>
<span class="kpi-value">{{ kpis.contestedCount }}</span>
</div>
</div>
<!-- Unopposed Posts KPI -->
<div class="kpi-card">
<div class="kpi-icon-wrapper" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b">
<mat-icon>workspace_premium</mat-icon>
</div>
<div class="kpi-content">
<span class="kpi-label">Unopposed Posts</span>
<span class="kpi-value">{{ kpis.unopposedCount }}</span>
</div>
</div>
</div>
}
<!-- Loading State -->
@if (loading) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading results...</p>
<p>Fetching election results and metadata...</p>
</div>
} @else {
<!-- ── Manual Vote Entry (shown when voting is open/closed and results not yet declared) ── -->
<!-- ── Manual Vote Entry Panel ── -->
@if (canEnterManualCounts && candidates.length > 0) {
<div class="manual-entry-panel">
<div class="mep-header">
<div class="mep-header-left">
<div class="mep-icon"><mat-icon>how_to_vote</mat-icon></div>
<div>
<div class="panel-header">
<div class="panel-header-left">
<div class="panel-icon"><mat-icon>how_to_vote</mat-icon></div>
<div class="panel-text">
<h3>Enter Manual Vote Counts</h3>
<p>Physical voting has been completed. Enter the vote count received by each candidate below.</p>
<p>Physical voting is complete. Please input the vote counts received by each candidate below.</p>
</div>
</div>
@if (countsSaved) {
<span class="saved-chip"><mat-icon>check_circle</mat-icon> Counts Saved</span>
<span class="saved-badge"> <mat-icon>check_circle</mat-icon> Counts Saved </span>
}
</div>
@for (group of groupCandidatesByPost(); track group.postId) {
<div class="mep-post-block">
<div class="mep-post-name"><mat-icon>badge</mat-icon> {{ group.postName }}</div>
@if (isUnopposed(group)) {
<div class="unopposed-banner">
<mat-icon>how_to_vote</mat-icon>
<div class="unopposed-info">
<strong>{{ group.candidates[0].candidateName }}</strong>
<span>No other candidate filed nomination for this post</span>
</div>
<span class="unopposed-chip">ELECTED UNOPPOSED</span>
<div class="entry-grid">
@for (group of groupCandidatesByPost(); track group.postId) {
<div class="entry-post-card">
<div class="entry-post-header">
<mat-icon>badge</mat-icon>
<h4>{{ group.postName }}</h4>
</div>
} @else {
<div class="mep-table">
<div class="mep-table-head">
<span class="col-serial">S.No</span>
<span class="col-name">Candidate</span>
<span class="col-votes">Votes Received</span>
</div>
@for (candidate of group.candidates; track candidate.id) {
<div class="mep-row">
<span class="col-serial">#{{ candidate.serialNumber }}</span>
<span class="col-name">{{ candidate.candidateName }}</span>
<span class="col-votes">
<input
type="number"
class="vote-input"
min="0"
[(ngModel)]="manualCounts[candidate.id]"
placeholder="0"
[disabled]="countsSaved && !editMode"
/>
</span>
@if (isUnopposed(group)) {
<div class="unopposed-entry-banner">
<mat-icon>workspace_premium</mat-icon>
<div class="banner-body">
<strong>{{ group.candidates[0].candidateName }}</strong>
<span>Elected unopposed (no other nominees)</span>
</div>
}
</div>
}
</div>
}
<span class="badge-elected">ELECTED</span>
</div>
} @else {
<div class="entry-table">
<div class="table-header">
<span class="col-sn">S.No</span>
<span class="col-candidate">Candidate Name</span>
<span class="col-input">Votes Received</span>
</div>
@for (candidate of group.candidates; track candidate.id; let idx = $index) {
<div class="table-row">
<span class="col-sn">#{{ candidate.serialNumber }}</span>
<span class="col-candidate-name">
<div
class="avatar-sm"
[style.background-color]="candidateColor(idx) + '15'"
[style.color]="candidateColor(idx)"
>
@if (symbolCache[candidate.id]) {
<img [src]="symbolCache[candidate.id]" class="avatar-sm-img" alt="" />
} @else {
{{ candidate.candidateName?.charAt(0) }}
}
</div>
<span>{{ candidate.candidateName }}</span>
</span>
<span class="col-input-field">
<input
type="number"
class="vote-count-input"
min="0"
[(ngModel)]="manualCounts[candidate.id]"
placeholder="0"
[disabled]="countsSaved && !editMode"
/>
</span>
</div>
}
</div>
}
</div>
}
</div>
<div class="mep-actions">
<div class="panel-actions">
@if (!countsSaved) {
<button class="btn-save-counts" (click)="saveManualCounts()" [disabled]="savingCounts">
<button class="btn-primary" (click)="saveManualVoteCounts()" [disabled]="savingCounts">
<mat-icon>save</mat-icon>
{{ savingCounts ? 'Saving...' : saveButtonLabel }}
<span>{{ savingCounts ? 'Saving...' : saveButtonLabel }}</span>
</button>
} @else if (!editMode) {
<button class="btn-edit-counts" (click)="startEdit()"><mat-icon>edit</mat-icon> Edit Vote Counts</button>
<button class="btn-secondary" (click)="startEdit()">
<mat-icon>edit</mat-icon>
<span>Edit Vote Counts</span>
</button>
} @else {
<div class="edit-reason-row">
<div class="edit-reason-container">
<input
type="text"
class="reason-input"
class="reason-text-input"
[(ngModel)]="editReason"
placeholder="Reason for updating vote counts (required)"
placeholder="Provide a reason for updating vote counts (required)"
maxlength="200"
/>
<div class="edit-action-buttons">
<button
class="btn-primary"
(click)="saveManualVoteCounts()"
[disabled]="savingCounts || !editReason.trim()"
>
<mat-icon>save</mat-icon>
<span>{{ savingCounts ? 'Updating...' : 'Update' }}</span>
</button>
<button class="btn-cancel" (click)="cancelEdit()">
<mat-icon>close</mat-icon>
<span>Cancel</span>
</button>
</div>
</div>
<button
class="btn-save-counts"
(click)="saveManualCounts()"
[disabled]="savingCounts || !editReason.trim()"
>
<mat-icon>save</mat-icon>
{{ savingCounts ? 'Updating...' : 'Update Vote Counts' }}
</button>
<button class="btn-cancel-edit" (click)="cancelEdit()"><mat-icon>close</mat-icon> Cancel</button>
}
@if (canDeclare) {
<button class="btn-declare-inline" (click)="declareResults()" [disabled]="declaring || !countsSaved">
<button class="btn-declare-accent" (click)="declareResults()" [disabled]="declaring || !countsSaved">
<mat-icon>gavel</mat-icon>
{{ declaring ? 'Declaring...' : 'Declare Results' }}
<span>Declare Final Results</span>
</button>
@if (!countsSaved) {
<span class="declare-hint">Pehle counts save karein, phir declare karein</span>
<span class="hint-text">Please save vote counts first before declaring results.</span>
}
}
</div>
</div>
}
<!-- Empty State -->
@if (results.length === 0 && !canEnterManualCounts) {
<div class="empty-state">
<mat-icon>bar_chart</mat-icon>
<h3>Results not yet declared</h3>
<p>Results will appear here once the election is complete and results are declared.</p>
<div class="empty-results-card">
<mat-icon class="empty-icon">bar_chart</mat-icon>
<h3>Results are not yet declared</h3>
<p>
Once the voting period closes and the results are declared by the CEO/Election Officer, the interactive charts
and winners list will display here.
</p>
</div>
}
<!-- ── Declared Results ── -->
<!-- Declared Results Dashboard -->
@if (results.length > 0) {
<div class="results-header-meta">
<div class="rh-chip">
<mat-icon>calendar_today</mat-icon> Election: {{ election?.electionDate | date: 'dd MMM yyyy' }}
<div class="results-metadata">
<div class="meta-item">
<mat-icon>event</mat-icon>
<span>Election Date: {{ election?.electionDate | date: 'dd MMM yyyy' }}</span>
</div>
@if (results[0]?.declaredDate) {
<div class="rh-chip declared">
<mat-icon>gavel</mat-icon> Declared: {{ results[0].declaredDate | date: 'dd MMM yyyy, hh:mm a' }}
<div class="meta-item meta-declared">
<mat-icon>verified</mat-icon>
<span>Declared on: {{ results[0].declaredDate | date: 'dd MMM yyyy, hh:mm a' }}</span>
</div>
}
</div>
@for (group of groupByPost(); track group.postName) {
<div class="post-results-wrap">
<div class="post-section-header">
<div class="psh-left">
<div class="psh-icon"><mat-icon>badge</mat-icon></div>
<h3>{{ group.postName }}</h3>
</div>
<span class="total-votes-chip">
<mat-icon>how_to_vote</mat-icon>
{{ totalVotes(group.results) | number }} Total Votes
</span>
</div>
@if (winnersOf(group.results).length === 1) {
<div class="winner-hero">
<div class="wh-left">
<div class="wh-trophy"><mat-icon>emoji_events</mat-icon></div>
<div class="wh-info">
<span class="wh-label">{{ group.results.length === 1 ? 'ELECTED UNOPPOSED' : 'WINNER' }}</span>
<h2 class="wh-name">{{ winnersOf(group.results)[0].candidateName }}</h2>
@if (group.results.length > 1) {
<div class="wh-stats">
<span class="wh-votes">{{ winnersOf(group.results)[0].voteCount | number }} votes</span>
<span class="wh-dot">·</span>
<span class="wh-pct"
>{{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}%</span
>
<span class="wh-dot">·</span>
<span class="wh-rank">Rank #1</span>
</div>
} @else {
<div class="wh-stats"><span class="wh-votes">No contest — sole candidate</span></div>
}
<div class="results-grid">
@for (group of groupByPost(); track group.postName) {
@if (group.results.length > 1) {
<!-- Contested Post Results Card -->
<div class="result-post-card">
<div class="card-header">
<div class="post-header-left">
<mat-icon>badge</mat-icon>
<h3>{{ group.postName }}</h3>
</div>
<div class="votes-badge">
<mat-icon>how_to_vote</mat-icon>
<span>{{ totalVotes(group.results) | number }} votes cast</span>
</div>
</div>
@if (group.results.length > 1) {
<div
class="wh-donut"
[style.background]="conicGradient(winnersOf(group.results)[0].voteCount, group.results)"
>
<div class="wh-donut-hole">
<span class="wh-donut-pct"
>{{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}%</span
>
<span class="wh-donut-label">share</span>
</div>
</div>
}
</div>
} @else if (winnersOf(group.results).length > 1) {
<div class="winners-header">
<mat-icon>emoji_events</mat-icon>
<span>{{ winnersOf(group.results).length }} Elected</span>
<span class="wh-seat-note">Top {{ winnersOf(group.results).length }} seats filled</span>
</div>
<div class="winners-grid">
@for (winner of winnersOf(group.results); track winner.id) {
<div class="winner-card">
<div class="wc-rank">#{{ winner.rankPosition }}</div>
<div class="wc-info">
<span class="wc-name">{{ winner.candidateName }}</span>
<span class="wc-votes"
>{{ winner.voteCount | number }} votes · {{ votePercent(winner.voteCount, group.results) }}%</span
>
</div>
<mat-icon class="wc-trophy-icon">emoji_events</mat-icon>
</div>
}
</div>
}
<div class="vote-chart">
<div class="vc-dist-strip">
@for (result of group.results; track result.id) {
<div
class="vcd-seg"
[class.vcd-won]="result.result === 'WON'"
[style.flex]="result.voteCount"
[title]="result.candidateName + ': ' + votePercent(result.voteCount, group.results) + '%'"
></div>
}
</div>
@for (result of group.results; track result.id) {
<div class="vc-row" [class.vc-won]="result.result === 'WON'">
<div class="vc-meta">
<div class="vc-rank" [class.vc-rank-won]="result.result === 'WON'">
@if (result.result === 'WON') {
<mat-icon>emoji_events</mat-icon>
} @else {
<span>{{ result.rankPosition }}</span>
<div class="card-body-split">
<!-- Donut Chart Side -->
<div class="chart-column">
<div class="donut-chart-container">
<div class="donut-chart-svg" [style.background]="getPostConicGradient(group.results)">
<div class="donut-center-hole">
<span class="total-count">{{ totalVotes(group.results) | number }}</span>
<span class="total-label">Total Votes</span>
</div>
</div>
</div>
</div>
<!-- Candidates List Side -->
<div class="list-column">
<!-- Single Winner Spotlight -->
@if (winnersOf(group.results).length === 1) {
<div class="winner-spotlight">
<div class="award-icon"><mat-icon>emoji_events</mat-icon></div>
<div class="winner-details">
<span class="spotlight-badge">WINNER ELECT</span>
<h4 class="spotlight-name">{{ winnersOf(group.results)[0].candidateName }}</h4>
</div>
<div class="winner-stats">
<span class="stat-votes">{{ winnersOf(group.results)[0].voteCount | number }} votes</span>
<span class="stat-pct"
>{{ votePercent(winnersOf(group.results)[0].voteCount, group.results) }}% share</span
>
</div>
</div>
}
<!-- Joint Winners Spotlight -->
@if (winnersOf(group.results).length > 1) {
<div class="winner-spotlight multi-winner">
<div class="award-icon"><mat-icon>diversity_3</mat-icon></div>
<div class="winner-details">
<span class="spotlight-badge"
>{{ winnersOf(group.results).length }} Candidates Elected (Tie/Multiple)</span
>
<div class="multi-winner-pills">
@for (w of winnersOf(group.results); track w.id) {
<span class="winner-pill">{{ w.candidateName }}</span>
}
</div>
</div>
</div>
}
<!-- List of Candidates -->
<div class="candidates-results-list">
@for (r of group.results; track r.id; let idx = $index) {
<div class="candidate-result-row" [class.winner-row]="r.result === 'WON'">
<div class="rank-avatar-col">
@if (r.result === 'WON') {
<div class="rank-badge rank-winner">
<mat-icon>emoji_events</mat-icon>
</div>
} @else {
<div class="rank-badge">
{{ r.rankPosition }}
</div>
}
<div class="candidate-avatar">
@if (symbolCache[r.candidateId]) {
<img [src]="symbolCache[r.candidateId]" class="avatar-image" alt="Symbol" />
} @else {
<div
class="avatar-placeholder"
[style.background-color]="candidateColor(idx) + '20'"
[style.color]="candidateColor(idx)"
>
{{ r.candidateName?.charAt(0) }}
</div>
}
</div>
</div>
<div class="row-details">
<div class="row-meta">
<span class="candidate-name">
{{ r.candidateName }}
@if (r.result === 'WON') {
<span class="won-chip">Elected</span>
}
</span>
<div class="votes-share">
<span class="vote-num">{{ r.voteCount | number }}</span>
<span
class="vote-pct-badge"
[style.background-color]="candidateColor(idx) + '15'"
[style.color]="candidateColor(idx)"
>
{{ votePercent(r.voteCount, group.results) }}%
</span>
</div>
</div>
<div class="bar-progress-container">
<div
class="bar-progress-fill"
[style.background-color]="candidateColor(idx)"
[style.width.%]="barWidth(r.voteCount, maxVotes(group.results))"
></div>
</div>
</div>
</div>
}
</div>
<div class="vc-name-wrap">
<span class="vc-name">{{ result.candidateName }}</span>
<span
class="vc-badge"
[class.vcb-won]="result.result === 'WON'"
[class.vcb-lost]="result.result === 'LOST'"
>
{{ result.result }}
</span>
</div>
</div>
<div class="vc-bar-area">
<div class="vc-bar-track">
<div
class="vc-bar-fill"
[class.vcbf-won]="result.result === 'WON'"
[style.width.%]="barWidth(result.voteCount, maxVotes(group.results))"
>
<span class="vc-bar-label">{{ result.voteCount | number }}</span>
</div>
</div>
<span class="vc-pct-out">{{ votePercent(result.voteCount, group.results) }}%</span>
</div>
</div>
}
</div>
</div>
}
</div>
} @else {
<!-- Unopposed Post Card Celebration Layout -->
<div class="result-post-card unopposed-post-card">
<div class="card-header">
<div class="post-header-left">
<mat-icon>badge</mat-icon>
<h3>{{ group.postName }}</h3>
</div>
<span class="unopposed-status-badge">ELECTED UNOPPOSED</span>
</div>
<div class="celebration-body">
<div class="confetti-decor"></div>
<div class="celebration-icon">
<mat-icon>workspace_premium</mat-icon>
</div>
<div class="celebration-avatar-wrapper">
@if (symbolCache[group.results[0].candidateId]) {
<img [src]="symbolCache[group.results[0].candidateId]" class="celebration-avatar-img" alt="" />
} @else {
<div class="celebration-avatar-placeholder">
{{ group.results[0].candidateName?.charAt(0) }}
</div>
}
<div class="celebration-avatar-glow"></div>
</div>
<div class="celebration-text">
<span class="celebration-sub-title">Winner Elect</span>
<h4 class="celebration-name">{{ group.results[0].candidateName }}</h4>
<p class="celebration-desc">
Elected unopposed as the sole candidate for the position of <strong>{{ group.postName }}</strong
>.
</p>
</div>
</div>
</div>
}
}
</div>
}
}
</div>
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { RouterModule, ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { catchError, of } from 'rxjs';
import { ElectionService } from '../../election.service';
@@ -32,6 +33,7 @@ export class ElectionResultsComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
private sanitizer = inject(DomSanitizer);
electionId!: number;
election: any = null;
@@ -46,6 +48,20 @@ export class ElectionResultsComponent implements OnInit {
// candidateId → entered vote count
manualCounts: Record<number, number> = {};
symbolCache: Record<number, SafeResourceUrl | null> = {};
readonly candidateColors = [
'#6366f1', // Indigo
'#10b981', // Emerald
'#f59e0b', // Amber
'#3b82f6', // Blue
'#ec4899', // Pink
'#8b5cf6', // Violet
'#ef4444', // Red
'#06b6d4', // Cyan
'#f97316', // Orange
'#14b8a6' // Teal
];
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
@@ -56,9 +72,7 @@ export class ElectionResultsComponent implements OnInit {
loadElection(): void {
this.service.getElection(this.electionId).subscribe((e) => {
this.election = e;
if (this.canEnterManualCounts) {
this.loadCandidates();
}
this.loadCandidates();
});
}
@@ -70,6 +84,7 @@ export class ElectionResultsComponent implements OnInit {
.subscribe((data: any[]) => {
this.results = data;
this.loading = false;
this.loadSymbols();
});
}
@@ -85,9 +100,32 @@ export class ElectionResultsComponent implements OnInit {
if (c.manualVoteCount != null) anySaved = true;
}
if (anySaved) this.countsSaved = true;
this.loadSymbols();
});
}
loadSymbols(): void {
// Results: always try — no hasSymbol guard (result objects don't carry it)
for (const r of this.results) {
const cid = r.candidateId;
if (!cid || this.symbolCache[cid] !== undefined) continue;
this.fetchAndCacheSymbol(cid);
}
// Candidates: respect hasSymbol to avoid redundant 404s in manual-entry mode
for (const c of this.candidates) {
const cid = c.id;
if (!cid || this.symbolCache[cid] !== undefined) continue;
if (!c.hasSymbol) continue;
this.fetchAndCacheSymbol(cid);
}
}
private fetchAndCacheSymbol(cid: number): void {
this.service.getCandidateSymbol(this.electionId, cid).subscribe((base64) => {
this.symbolCache[cid] = base64 ? this.sanitizer.bypassSecurityTrustResourceUrl(base64) : null;
});
}
groupCandidatesByPost(): { postId: number; postName: string; candidates: any[] }[] {
const grouped: Record<number, { postId: number; postName: string; candidates: any[] }> = {};
for (const c of this.candidates) {
@@ -106,7 +144,7 @@ export class ElectionResultsComponent implements OnInit {
return hasContested ? 'Save Vote Counts' : 'Confirm Unopposed Election';
}
saveManualCounts(): void {
saveManualVoteCounts(): void {
const groups = this.groupCandidatesByPost();
const contestedGroups = groups.filter((g) => g.candidates.length > 1);
@@ -163,6 +201,7 @@ export class ElectionResultsComponent implements OnInit {
next: (data: any[]) => {
this.results = data;
this.declaring = false;
this.loadSymbols();
this.snackBar.open('Results declared successfully!', 'Close', { duration: 4000 });
this.loadElection();
},
@@ -205,9 +244,55 @@ export class ElectionResultsComponent implements OnInit {
return Math.round((voteCount / total) * 1000) / 10;
}
conicGradient(voteCount: number, postResults: any[]): string {
const pct = this.votePercent(voteCount, postResults);
return `conic-gradient(#f59e0b 0% ${pct}%, #e2e8f0 ${pct}% 100%)`;
candidateColor(index: number): string {
return this.candidateColors[index % this.candidateColors.length];
}
getPostConicGradient(postResults: any[]): string {
const total = this.totalVotes(postResults);
if (total === 0) return 'conic-gradient(#e2e8f0 0% 100%)';
const parts: string[] = [];
let accumulated = 0;
postResults.forEach((r, idx) => {
const pct = ((r.voteCount ?? 0) / total) * 100;
if (pct > 0) {
const color = this.candidateColor(idx);
parts.push(`${color} ${accumulated}% ${accumulated + pct}%`);
accumulated += pct;
}
});
if (parts.length === 0) return 'conic-gradient(#e2e8f0 0% 100%)';
return `conic-gradient(${parts.join(', ')})`;
}
get kpis(): { totalVotes: number; contestedCount: number; unopposedCount: number } {
let totalVotes = 0;
let contestedCount = 0;
let unopposedCount = 0;
if (this.results.length > 0) {
const grouped = this.groupByPost();
totalVotes = this.results.reduce((sum, r) => sum + (r.voteCount ?? 0), 0);
for (const g of grouped) {
if (g.results.length > 1) {
contestedCount++;
} else {
unopposedCount++;
}
}
} else if (this.candidates.length > 0) {
const grouped = this.groupCandidatesByPost();
for (const g of grouped) {
if (g.candidates.length > 1) {
contestedCount++;
} else {
unopposedCount++;
}
}
}
return { totalVotes, contestedCount, unopposedCount };
}
winnersOf(postResults: any[]): any[] {