feat(election): candidate voter analysis — Chart.js charts, cross-dimension insights
- New CandidateAnalysisComponent with Chart.js doughnut charts for all demographic/financial/social dimensions (gender, age, area, caste, sub-caste, religion, occupation, share-holding, tenure, loan, education, marital status, monthly income, savings account depth) - Section 4: Multi-Dimension Voter Alignment card showing core base (caste + area cross-match), caste %, area %, and savings engagement - Candidate board: added Analyse button linking to analysis page - Hero header, KPI strip, 4 dashboard sections with hover charts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
960e4e181b
commit
dac169ae15
@@ -0,0 +1,779 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<div class="va-page">
|
||||
<div class="backdrop-blobs">
|
||||
<div class="blob blob-1"></div>
|
||||
<div class="blob blob-2"></div>
|
||||
<div class="blob blob-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Hero Header ──────────────────────────────────────────────────────── -->
|
||||
<div class="hero">
|
||||
<div class="hero-overlay"></div>
|
||||
<a [routerLink]="['/election', electionId, 'candidates']" class="hero-back">
|
||||
<mat-icon>chevron_left</mat-icon> Back to Candidates
|
||||
</a>
|
||||
@if (data) {
|
||||
<div class="hero-body">
|
||||
<div class="hero-avatar-wrapper">
|
||||
<div class="hero-avatar">{{ data.candidateName?.charAt(0) }}</div>
|
||||
<div class="hero-avatar-glow"></div>
|
||||
</div>
|
||||
<div class="hero-info">
|
||||
<div class="hero-badge-row">
|
||||
<span class="post-badge">{{ data.postName }}</span>
|
||||
<span class="analysis-tag">Voter Influence Analysis</span>
|
||||
</div>
|
||||
<h1 class="hero-name">{{ data.candidateName }}</h1>
|
||||
|
||||
@if (hasCriteria(data)) {
|
||||
<div class="hero-criteria">
|
||||
<span class="criteria-title">Target Criteria:</span>
|
||||
@if (data.postGenderType && data.postGenderType !== 'ANY') {
|
||||
<span class="cp">Gender: {{ data.postGenderType }}</span>
|
||||
}
|
||||
@if (data.postMinAge && data.postMaxAge) {
|
||||
<span class="cp">Age: {{ data.postMinAge }}–{{ data.postMaxAge }} yrs</span>
|
||||
} @else if (data.postMinAge) {
|
||||
<span class="cp">Min Age: {{ data.postMinAge }} yrs</span>
|
||||
} @else if (data.postMaxAge) {
|
||||
<span class="cp">Max Age: {{ data.postMaxAge }} yrs</span>
|
||||
}
|
||||
@if (data.postMinShareHolding) {
|
||||
<span class="cp">Min Shares: {{ data.postMinShareHolding }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="state-box">
|
||||
<div class="ring-spinner"></div>
|
||||
<p>Building voter analysis...</p>
|
||||
</div>
|
||||
} @else if (error || !data) {
|
||||
<div class="state-box">
|
||||
<mat-icon class="err-icon">error_outline</mat-icon>
|
||||
<p class="err-msg">{{ errorMessage || 'Analysis not available.' }}</p>
|
||||
<a [routerLink]="['/election', electionId, 'candidates']" class="err-btn"> Return to Candidates </a>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- ── KPI Strip ─────────────────────────────────────────────────────────── -->
|
||||
<div class="kpi-row">
|
||||
<div class="kpi kpi-featured">
|
||||
<div class="kpi-icon" style="background: rgba(37, 99, 235, 0.1)">
|
||||
<mat-icon style="color: #2563eb">how_to_vote</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-val featured-val">{{ data.actualEligibleVoters }}</div>
|
||||
<div class="kpi-lbl">Post-Eligible Voters</div>
|
||||
<div class="kpi-sub">{{ eligiblePct(data) }}% of shareholders</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon" style="background: rgba(2, 132, 199, 0.1)">
|
||||
<mat-icon style="color: #0284c7">group</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-val">{{ data.totalActiveShareholders }}</div>
|
||||
<div class="kpi-lbl">Total Shareholders</div>
|
||||
<div class="kpi-sub-text">Active members</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon" style="background: rgba(22, 163, 74, 0.1)">
|
||||
<mat-icon style="color: #16a34a">cake</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-val">{{ data.eligibleByAge }}</div>
|
||||
<div class="kpi-lbl">
|
||||
@if (!data.postMinAge && !data.postMaxAge) {
|
||||
Age-Eligible
|
||||
} @else {
|
||||
Age-Eligible ({{ data.postMinAge }}–{{ data.postMaxAge }})
|
||||
}
|
||||
</div>
|
||||
<div class="kpi-sub-text">Fits age threshold</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon" style="background: rgba(126, 34, 206, 0.1)">
|
||||
<mat-icon style="color: #7e22ce">diversity_3</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-val">{{ data.casteMatchCount }}</div>
|
||||
<div class="kpi-lbl">Same Social Category</div>
|
||||
<div class="kpi-sub-text">Matches candidate</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon" style="background: rgba(180, 83, 9, 0.1)">
|
||||
<mat-icon style="color: #b45309">account_balance</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-val">{{ data.activeLoanHolders }}</div>
|
||||
<div class="kpi-lbl">Active Loan Holders</div>
|
||||
<div class="kpi-sub-text">With active accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-icon" style="background: rgba(190, 24, 93, 0.1)">
|
||||
<mat-icon style="color: #be185d">family_restroom</mat-icon>
|
||||
</div>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-val">{{ data.shareholdersWithFamily }}</div>
|
||||
<div class="kpi-lbl">With Family Records</div>
|
||||
<div class="kpi-sub-text">Household influence</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Analysis Layout ───────────────────────────────────────────────────── -->
|
||||
<div class="dashboard-sections">
|
||||
<!-- SECTION 1: DEMOGRAPHICS & PROFILE -->
|
||||
<div class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<mat-icon>people_outline</mat-icon>
|
||||
<h2>Demographic & Professional Profile</h2>
|
||||
<span class="section-badge">6 Analysis Dimensions</span>
|
||||
</div>
|
||||
|
||||
<div class="a-grid">
|
||||
<!-- Gender — DONUT -->
|
||||
<div class="a-card" style="--ac: #ec4899; --ac-bg: rgba(236, 72, 153, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>people</mat-icon></div>
|
||||
<span class="ac-title">Gender Distribution</span>
|
||||
@if (data.candidateGender) {
|
||||
<span class="ac-tag">Candidate: {{ data.candidateGender }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="gender-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="gender-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="gender-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.genderBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row" [class.cl-match]="item.match">
|
||||
<span class="cl-dot" [style.background]="itemColor(item, i, '#ec4899', genderPalette)"></span>
|
||||
<span class="cl-label">
|
||||
{{ item.label }}
|
||||
@if (item.match) {
|
||||
<span class="mbadge">★ Match</span>
|
||||
}
|
||||
</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Age Groups — DONUT -->
|
||||
<div class="a-card" style="--ac: #3b82f6; --ac-bg: rgba(59, 130, 246, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>cake</mat-icon></div>
|
||||
<span class="ac-title">Age Group Distribution</span>
|
||||
@if (data.candidateAge) {
|
||||
<span class="ac-tag">Age: {{ data.candidateAge }} yrs</span>
|
||||
}
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="age-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="age-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="age-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.ageBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row" [class.cl-match]="item.match">
|
||||
<span class="cl-dot" [style.background]="itemColor(item, i, '#3b82f6', agePalette)"></span>
|
||||
<span class="cl-label">
|
||||
{{ item.label }} Group
|
||||
@if (item.match) {
|
||||
<span class="mbadge">★ Match</span>
|
||||
}
|
||||
</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Religion — DONUT -->
|
||||
<div class="a-card" style="--ac: #f97316; --ac-bg: rgba(249, 115, 22, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>temple_hindu</mat-icon></div>
|
||||
<span class="ac-title">Religion Profile</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
@if (data.religionBreakdown?.length > 0) {
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="religion-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="religion-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="religion-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.religionBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row">
|
||||
<span class="cl-dot" [style.background]="itemColor(item, i, '#f97316', religionPalette)"></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-data">No religion data recorded yet.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Occupation — DONUT -->
|
||||
<div class="a-card" style="--ac: #0891b2; --ac-bg: rgba(8, 145, 178, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>work</mat-icon></div>
|
||||
<span class="ac-title">Occupation Profile</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
@if (data.occupationBreakdown?.length > 0) {
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="occupation-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="occupation-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="occupation-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.occupationBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row">
|
||||
<span
|
||||
class="cl-dot"
|
||||
[style.background]="itemColor(item, i, '#0891b2', ['#22d3ee', '#67e8f9', '#a5f3fc', '#cbd5e1'])"
|
||||
></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-data">No occupation data recorded yet.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Education Level — DONUT -->
|
||||
<div class="a-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>school</mat-icon></div>
|
||||
<span class="ac-title">Education Level</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
@if (data.educationBreakdown?.length > 0) {
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="education-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="education-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="education-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.educationBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row">
|
||||
<span class="cl-dot" [style.background]="itemColor(item, i, '#6366f1', educationPalette)"></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-data">No education data recorded yet.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marital Status — DONUT -->
|
||||
<div class="a-card" style="--ac: #f59e0b; --ac-bg: rgba(245, 158, 11, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>favorite</mat-icon></div>
|
||||
<span class="ac-title">Marital Status Profile</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
@if (data.maritalBreakdown?.length > 0) {
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="marital-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="marital-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="marital-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.maritalBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row">
|
||||
<span class="cl-dot" [style.background]="itemColor(item, i, '#f59e0b', maritalPalette)"></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-data">No marital status data recorded yet.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: SOCIAL & GEOGRAPHIC PROFILE -->
|
||||
<div class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<mat-icon>public</mat-icon>
|
||||
<h2>Social & Geographic Influence</h2>
|
||||
<span class="section-badge">3 Analysis Dimensions</span>
|
||||
</div>
|
||||
|
||||
<div class="a-grid">
|
||||
<!-- Area / City — BARS -->
|
||||
<div class="a-card" style="--ac: #10b981; --ac-bg: rgba(16, 185, 129, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>location_on</mat-icon></div>
|
||||
<span class="ac-title">Area / City Distribution</span>
|
||||
@if (data.candidateArea) {
|
||||
<span class="ac-tag">Candidate: {{ data.candidateArea }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="area-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="area-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="area-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.areaBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row" [class.cl-match]="item.match">
|
||||
<span class="cl-dot" [style.background]="item.match ? '#10b981' : '#cbd5e1'"></span>
|
||||
<span class="cl-label">
|
||||
{{ item.label }}
|
||||
@if (item.match) {
|
||||
<span class="mbadge">★ Match</span>
|
||||
}
|
||||
</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Category — DONUT -->
|
||||
<div class="a-card" style="--ac: #8b5cf6; --ac-bg: rgba(139, 92, 246, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>diversity_3</mat-icon></div>
|
||||
<span class="ac-title">Social Category (Caste)</span>
|
||||
@if (data.candidateCaste) {
|
||||
<span class="ac-tag">Candidate: {{ data.candidateCaste }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="caste-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="caste-chart-val">{{ data.casteMatchCount }}</span>
|
||||
<span class="dc-sub" id="caste-chart-sub">match</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.casteBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row" [class.cl-match]="item.match">
|
||||
<span class="cl-dot" [style.background]="itemColor(item, i, '#8b5cf6', castePalette)"></span>
|
||||
<span class="cl-label">
|
||||
{{ item.label }}
|
||||
@if (item.match) {
|
||||
<span class="mbadge">★ Match</span>
|
||||
}
|
||||
</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-Caste — FULL WIDTH BARS -->
|
||||
<div class="a-card a-full" style="--ac: #d946ef; --ac-bg: rgba(217, 70, 239, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>account_tree</mat-icon></div>
|
||||
<span class="ac-title">Sub-Caste Breakdown</span>
|
||||
@if (data.candidateSubCaste) {
|
||||
<span class="ac-tag">Candidate: {{ data.candidateSubCaste }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (data.subCasteBreakdown?.length > 0) {
|
||||
<div class="full-card-split">
|
||||
<div class="split-left flex-col">
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="subcaste-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="subcaste-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="subcaste-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-right">
|
||||
<div class="caste-details-grid">
|
||||
@for (item of data.subCasteBreakdown; track item.label; let i = $index) {
|
||||
<div class="caste-detail-item" [class.caste-match]="item.match">
|
||||
<div class="cdi-header">
|
||||
<span class="cdi-label">{{ item.label }}</span>
|
||||
@if (item.match) {
|
||||
<span class="mbadge">★ Match</span>
|
||||
}
|
||||
</div>
|
||||
<div class="cdi-footer">
|
||||
<span class="cdi-count">{{ item.count }} members</span>
|
||||
<span class="cdi-percent">{{ item.percent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-data">No sub-caste data recorded for shareholders yet.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 3: FINANCIAL & FAMILY ENGAGEMENT -->
|
||||
<div class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<mat-icon>savings</mat-icon>
|
||||
<h2>Financial & Family Engagement</h2>
|
||||
<span class="section-badge">6 Analysis Dimensions</span>
|
||||
</div>
|
||||
|
||||
<div class="a-grid">
|
||||
<!-- Share Holdings — BARS -->
|
||||
<div class="a-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>account_balance_wallet</mat-icon></div>
|
||||
<span class="ac-title">Share Holding Distribution</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="shareholding-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="shareholding-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="shareholding-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.shareHoldingBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row">
|
||||
<span class="cl-dot" style="background: #6366f1"></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member Tenure — BARS -->
|
||||
<div class="a-card" style="--ac: #06b6d4; --ac-bg: rgba(6, 182, 212, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>history</mat-icon></div>
|
||||
<span class="ac-title">Member Tenure</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="tenure-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="tenure-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="tenure-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.tenureBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row">
|
||||
<span class="cl-dot" style="background: #06b6d4"></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Income — DONUT -->
|
||||
<div class="a-card" style="--ac: #16a34a; --ac-bg: rgba(22, 163, 74, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>currency_rupee</mat-icon></div>
|
||||
<span class="ac-title">Monthly Income Distribution</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
@if (data.monthlyIncomeBreakdown?.length > 0) {
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="income-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="income-chart-val">{{ data.totalActiveShareholders }}</span>
|
||||
<span class="dc-sub" id="income-chart-sub">total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.monthlyIncomeBreakdown; track item.label; let i = $index) {
|
||||
<div class="cl-row">
|
||||
<span class="cl-dot" [style.background]="itemColor(item, i, '#16a34a', incomePalette)"></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-data">No monthly income data recorded yet.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Savings Account Depth — DONUT -->
|
||||
<div class="a-card" style="--ac: #0ea5e9; --ac-bg: rgba(14, 165, 233, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>savings</mat-icon></div>
|
||||
<span class="ac-title">Savings Account Depth</span>
|
||||
</div>
|
||||
<div class="chart-body flex-col">
|
||||
@if (data.savingsBreakdown?.length > 0) {
|
||||
<div class="donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="savings-chart"></canvas>
|
||||
</div>
|
||||
<div class="donut-center">
|
||||
<span class="dc-val" id="savings-chart-val">{{ data.activeSavingsHolders }}</span>
|
||||
<span class="dc-sub" id="savings-chart-sub">active savings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
@for (item of data.savingsBreakdown; track item.label) {
|
||||
<div class="cl-row" [class.cl-match]="item.match">
|
||||
<span class="cl-dot" [style.background]="item.match ? '#0ea5e9' : '#cbd5e1'"></span>
|
||||
<span class="cl-label">{{ item.label }}</span>
|
||||
<span class="cl-pct-badge">{{ item.percent }}%</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="no-data">No savings data available.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Engagement — DONUT + STATS (full width) -->
|
||||
<div class="a-card a-full" style="--ac: #f59e0b; --ac-bg: rgba(245, 158, 11, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>account_balance</mat-icon></div>
|
||||
<span class="ac-title">Financial Engagement — Active Loans</span>
|
||||
</div>
|
||||
<div class="loan-layout">
|
||||
<div class="loan-chart-section">
|
||||
<div class="loan-donut-wrap">
|
||||
<div class="chart-canvas-wrap">
|
||||
<canvas id="loan-chart"></canvas>
|
||||
</div>
|
||||
<div class="gauge-center">
|
||||
<span class="ldc-val" id="loan-chart-val">{{ activeLoanPct(data) }}%</span>
|
||||
<span class="ldc-sub" id="loan-chart-sub">active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loan-stats-section">
|
||||
<div class="loan-stats">
|
||||
@for (item of data.loanHolderBreakdown; track item.label) {
|
||||
<div class="loan-stat" [class.loan-stat-active]="item.label === 'Active Loan Holders'">
|
||||
<div class="ls-header">
|
||||
<span
|
||||
class="ls-dot"
|
||||
[style.background]="item.label === 'Active Loan Holders' ? '#f59e0b' : '#cbd5e1'"
|
||||
></span>
|
||||
<span class="ls-label">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="ls-num">{{ item.count }}</div>
|
||||
<div class="ls-pct">{{ item.percent }}% of shareholders</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="loan-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span
|
||||
>Members with active loans have an ongoing financial relationship, indicating higher engagement and
|
||||
interest in institutional decisions.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Family Member Network (full width) -->
|
||||
<div class="a-card a-full" style="--ac: #0d9488; --ac-bg: rgba(13, 148, 136, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>family_restroom</mat-icon></div>
|
||||
<span class="ac-title">Family Member Network Influence</span>
|
||||
</div>
|
||||
<div class="fam-metrics-layout">
|
||||
<div class="fam-metrics">
|
||||
<div class="fam-metric">
|
||||
<div class="fm-icon-badge" style="color: #0d9488; background: rgba(13, 148, 136, 0.08)">
|
||||
<mat-icon>group</mat-icon>
|
||||
</div>
|
||||
<div class="fm-details">
|
||||
<div class="fm-val">{{ data.shareholdersWithFamily }}</div>
|
||||
<div class="fm-lbl">Shareholders with Family Records</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fam-sep"></div>
|
||||
<div class="fam-metric">
|
||||
<div class="fm-icon-badge" style="color: #0f766e; background: rgba(15, 118, 110, 0.08)">
|
||||
<mat-icon>diversity_1</mat-icon>
|
||||
</div>
|
||||
<div class="fm-details">
|
||||
<div class="fm-val">{{ data.totalFamilyMembers }}</div>
|
||||
<div class="fm-lbl">Total Family Members Registered</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fam-sep"></div>
|
||||
<div class="fam-metric">
|
||||
<div class="fm-icon-badge" style="color: #115e59; background: rgba(17, 94, 89, 0.08)">
|
||||
<mat-icon>calculate</mat-icon>
|
||||
</div>
|
||||
<div class="fm-details">
|
||||
<div class="fm-val">
|
||||
{{
|
||||
data.shareholdersWithFamily > 0
|
||||
? (data.totalFamilyMembers / data.shareholdersWithFamily | number: '1.1-1')
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
<div class="fm-lbl">Avg Registered Members / Household</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insight-note">
|
||||
<mat-icon>insights</mat-icon>
|
||||
<span
|
||||
>Family records indicate broader community networks. A single voting shareholder with multiple family
|
||||
records acts as a proxy shapeholder, often scaling voting decisions across their entire
|
||||
household.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 4: VOTER INFLUENCE INSIGHTS -->
|
||||
<div class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<mat-icon>auto_graph</mat-icon>
|
||||
<h2>Voter Influence Insights</h2>
|
||||
<span class="section-badge">Multi-Dimension Analysis</span>
|
||||
</div>
|
||||
|
||||
<div class="a-grid">
|
||||
<!-- Cross-Dimension Alignment (full width) -->
|
||||
<div class="a-card a-full" style="--ac: #8b5cf6; --ac-bg: rgba(139, 92, 246, 0.08)">
|
||||
<div class="ac-head">
|
||||
<div class="ac-icon"><mat-icon>hub</mat-icon></div>
|
||||
<span class="ac-title">Multi-Dimension Voter Alignment</span>
|
||||
<span class="ac-tag">Candidate's core voter base overlap</span>
|
||||
</div>
|
||||
<div class="influence-insight">
|
||||
<div class="influence-metric im-highlight">
|
||||
<div class="im-icon" style="background: rgba(139, 92, 246, 0.12)">
|
||||
<mat-icon style="color: #8b5cf6">hub</mat-icon>
|
||||
</div>
|
||||
<div class="im-val">{{ data.crossMatchCount }}</div>
|
||||
<div class="im-pct" style="color: #8b5cf6">{{ crossMatchPct(data) }}%</div>
|
||||
<div class="im-label">Core Base — Same Caste & Same Area</div>
|
||||
</div>
|
||||
<div class="influence-metric">
|
||||
<div class="im-icon" style="background: rgba(139, 92, 246, 0.08)">
|
||||
<mat-icon style="color: #a78bfa">diversity_3</mat-icon>
|
||||
</div>
|
||||
<div class="im-val">{{ data.casteMatchCount }}</div>
|
||||
<div class="im-pct" style="color: #a78bfa">{{ casteMatchPct(data) }}%</div>
|
||||
<div class="im-label">Same Social Category (Caste Alignment)</div>
|
||||
</div>
|
||||
<div class="influence-metric">
|
||||
<div class="im-icon" style="background: rgba(16, 185, 129, 0.08)">
|
||||
<mat-icon style="color: #10b981">location_on</mat-icon>
|
||||
</div>
|
||||
<div class="im-val">{{ data.areaMatchCount }}</div>
|
||||
<div class="im-pct" style="color: #10b981">{{ areaMatchPct(data) }}%</div>
|
||||
<div class="im-label">Same Area / City (Geographic Proximity)</div>
|
||||
</div>
|
||||
<div class="influence-metric">
|
||||
<div class="im-icon" style="background: rgba(14, 165, 233, 0.08)">
|
||||
<mat-icon style="color: #0ea5e9">savings</mat-icon>
|
||||
</div>
|
||||
<div class="im-val">{{ data.activeSavingsHolders }}</div>
|
||||
<div class="im-pct" style="color: #0ea5e9">{{ activeSavingsPct(data) }}%</div>
|
||||
<div class="im-label">Active Savings Holders (Financially Engaged)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="insight-note" style="margin-top: 20px">
|
||||
<mat-icon>lightbulb</mat-icon>
|
||||
<span
|
||||
>The <strong>Core Base</strong> — voters matching both the candidate's social category AND geographic
|
||||
area — represents the strongest aligned voter segment. Caste, area, and financial engagement are the
|
||||
three strongest predictors of voter support in cooperative elections.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, ActivatedRoute } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ElectionService } from '../../election.service';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-candidate-analysis',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './candidate-analysis.component.html',
|
||||
styleUrls: ['./candidate-analysis.component.scss']
|
||||
})
|
||||
export class CandidateAnalysisComponent implements OnInit, OnDestroy {
|
||||
private service = inject(ElectionService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
electionId!: number;
|
||||
candidateId!: number;
|
||||
data: any = null;
|
||||
loading = true;
|
||||
error = false;
|
||||
errorMessage = '';
|
||||
|
||||
private chartInstances: any[] = [];
|
||||
|
||||
// ── Color palettes for donut charts ───────────────────────────────────────
|
||||
readonly genderPalette = [
|
||||
'#93c5fd',
|
||||
'#f472b6',
|
||||
'#cbd5e1'
|
||||
];
|
||||
readonly castePalette = [
|
||||
'#c084fc',
|
||||
'#67e8f9',
|
||||
'#6ee7b7',
|
||||
'#fca5a5',
|
||||
'#cbd5e1'
|
||||
];
|
||||
readonly religionPalette = [
|
||||
'#fdb574',
|
||||
'#fca5a5',
|
||||
'#fde047',
|
||||
'#93c5fd',
|
||||
'#c084fc',
|
||||
'#86efac',
|
||||
'#cbd5e1'
|
||||
];
|
||||
readonly agePalette = [
|
||||
'#a5b4fc',
|
||||
'#c084fc',
|
||||
'#d8b4fe',
|
||||
'#f9a8d4',
|
||||
'#fda4af',
|
||||
'#cbd5e1'
|
||||
];
|
||||
readonly loanPalette = ['#cbd5e1'];
|
||||
readonly educationPalette = [
|
||||
'#818cf8',
|
||||
'#a5b4fc',
|
||||
'#c7d2fe',
|
||||
'#e0e7ff',
|
||||
'#cbd5e1'
|
||||
];
|
||||
readonly maritalPalette = [
|
||||
'#fbbf24',
|
||||
'#fb923c',
|
||||
'#a3e635',
|
||||
'#34d399',
|
||||
'#94a3b8'
|
||||
];
|
||||
readonly incomePalette = [
|
||||
'#4ade80',
|
||||
'#86efac',
|
||||
'#6ee7b7',
|
||||
'#5eead4',
|
||||
'#a5f3fc',
|
||||
'#cbd5e1'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.electionId = +this.route.snapshot.params['electionId'];
|
||||
this.candidateId = +this.route.snapshot.params['candidateId'];
|
||||
|
||||
this.service.getCandidateVoterAnalysis(this.electionId, this.candidateId).subscribe({
|
||||
next: (d) => {
|
||||
this.data = d;
|
||||
this.loading = false;
|
||||
setTimeout(() => this.initCharts(), 0);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.error = true;
|
||||
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Analysis not available.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.chartInstances.forEach((chart) => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
this.chartInstances = [];
|
||||
}
|
||||
|
||||
initCharts() {
|
||||
if (!this.data) return;
|
||||
|
||||
if (this.data.genderBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'gender-chart',
|
||||
this.data.genderBreakdown,
|
||||
'#ec4899',
|
||||
this.genderPalette,
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.ageBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'age-chart',
|
||||
this.data.ageBreakdown,
|
||||
'#3b82f6',
|
||||
this.agePalette,
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.areaBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'area-chart',
|
||||
this.data.areaBreakdown,
|
||||
'#10b981',
|
||||
[
|
||||
'#34d399',
|
||||
'#6ee7b7',
|
||||
'#a7f3d0',
|
||||
'#cbd5e1'
|
||||
],
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.casteBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'caste-chart',
|
||||
this.data.casteBreakdown,
|
||||
'#8b5cf6',
|
||||
this.castePalette,
|
||||
this.data.casteMatchCount,
|
||||
'match'
|
||||
);
|
||||
}
|
||||
if (this.data.subCasteBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'subcaste-chart',
|
||||
this.data.subCasteBreakdown,
|
||||
'#d946ef',
|
||||
[
|
||||
'#e879f9',
|
||||
'#f0abfc',
|
||||
'#f5d0fe',
|
||||
'#cbd5e1'
|
||||
],
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.religionBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'religion-chart',
|
||||
this.data.religionBreakdown,
|
||||
'#f97316',
|
||||
this.religionPalette,
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.occupationBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'occupation-chart',
|
||||
this.data.occupationBreakdown,
|
||||
'#0891b2',
|
||||
[
|
||||
'#22d3ee',
|
||||
'#67e8f9',
|
||||
'#a5f3fc',
|
||||
'#cbd5e1'
|
||||
],
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.shareHoldingBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'shareholding-chart',
|
||||
this.data.shareHoldingBreakdown,
|
||||
'#6366f1',
|
||||
[
|
||||
'#818cf8',
|
||||
'#a5b4fc',
|
||||
'#c7d2fe',
|
||||
'#cbd5e1'
|
||||
],
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.tenureBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'tenure-chart',
|
||||
this.data.tenureBreakdown,
|
||||
'#06b6d4',
|
||||
[
|
||||
'#22d3ee',
|
||||
'#67e8f9',
|
||||
'#cbd5e1'
|
||||
],
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.loanHolderBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'loan-chart',
|
||||
this.data.loanHolderBreakdown,
|
||||
'#f59e0b',
|
||||
this.loanPalette,
|
||||
this.activeLoanPct(this.data),
|
||||
'active',
|
||||
true
|
||||
);
|
||||
}
|
||||
if (this.data.educationBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'education-chart',
|
||||
this.data.educationBreakdown,
|
||||
'#6366f1',
|
||||
this.educationPalette,
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.maritalBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'marital-chart',
|
||||
this.data.maritalBreakdown,
|
||||
'#f59e0b',
|
||||
this.maritalPalette,
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.monthlyIncomeBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'income-chart',
|
||||
this.data.monthlyIncomeBreakdown,
|
||||
'#16a34a',
|
||||
this.incomePalette,
|
||||
this.data.totalActiveShareholders,
|
||||
'total'
|
||||
);
|
||||
}
|
||||
if (this.data.savingsBreakdown?.length) {
|
||||
this.createDoughnutChart(
|
||||
'savings-chart',
|
||||
this.data.savingsBreakdown,
|
||||
'#0ea5e9',
|
||||
['#cbd5e1'],
|
||||
this.data.activeSavingsHolders,
|
||||
'active savings'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createDoughnutChart(
|
||||
canvasId: string,
|
||||
breakdown: any[],
|
||||
accent: string,
|
||||
palette: string[],
|
||||
defaultVal: any,
|
||||
defaultSub: string,
|
||||
gauge: boolean = false
|
||||
) {
|
||||
const el = document.getElementById(canvasId) as HTMLCanvasElement;
|
||||
if (!el) return;
|
||||
|
||||
const labels = breakdown.map((item) => item.label);
|
||||
const data = breakdown.map((item) => item.percent);
|
||||
const colors = breakdown.map((item, idx) => (item.match ? accent : palette[idx % palette.length]));
|
||||
|
||||
const chart = new Chart(el, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data,
|
||||
backgroundColor: colors,
|
||||
borderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
borderRadius: 5, // Rounded corners on doughnut segments for clean graphic style
|
||||
hoverOffset: 6
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '70%',
|
||||
circumference: gauge ? 180 : 360,
|
||||
rotation: gauge ? -90 : 0,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false } // Disable native tooltip to prevent center overlay clipping
|
||||
},
|
||||
onHover: (_event: any, activeElements: any[]) => {
|
||||
const valEl = document.getElementById(canvasId + '-val');
|
||||
const subEl = document.getElementById(canvasId + '-sub');
|
||||
if (!valEl || !subEl) return;
|
||||
|
||||
if (activeElements && activeElements.length > 0) {
|
||||
const index = activeElements[0].index;
|
||||
const label = labels[index];
|
||||
const val = data[index];
|
||||
valEl.innerText = val + '%';
|
||||
subEl.innerText = label;
|
||||
subEl.style.color = colors[index];
|
||||
subEl.style.fontWeight = '700';
|
||||
} else {
|
||||
valEl.innerText = typeof defaultVal === 'number' ? defaultVal + (gauge ? '%' : '') : String(defaultVal);
|
||||
subEl.innerText = defaultSub;
|
||||
subEl.style.color = '';
|
||||
subEl.style.fontWeight = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.chartInstances.push(chart);
|
||||
}
|
||||
|
||||
barWidth(pct: number): string {
|
||||
return Math.min(pct, 100) + '%';
|
||||
}
|
||||
barHeight(pct: number): string {
|
||||
return Math.min(pct, 100) + '%';
|
||||
}
|
||||
|
||||
hasCriteria(d: any): boolean {
|
||||
return (d.postGenderType && d.postGenderType !== 'ANY') || d.postMinAge || d.postMaxAge || d.postMinShareHolding;
|
||||
}
|
||||
|
||||
eligiblePct(d: any): number {
|
||||
if (!d?.totalActiveShareholders) return 0;
|
||||
return Math.round((d.actualEligibleVoters / d.totalActiveShareholders) * 100);
|
||||
}
|
||||
|
||||
activeLoanPct(d: any): number {
|
||||
if (!d?.totalActiveShareholders) return 0;
|
||||
return Math.round((d.activeLoanHolders / d.totalActiveShareholders) * 100);
|
||||
}
|
||||
|
||||
activeSavingsPct(d: any): number {
|
||||
if (!d?.totalActiveShareholders) return 0;
|
||||
return Math.round((d.activeSavingsHolders / d.totalActiveShareholders) * 100);
|
||||
}
|
||||
|
||||
crossMatchPct(d: any): number {
|
||||
if (!d?.totalActiveShareholders) return 0;
|
||||
return Math.round((d.crossMatchCount / d.totalActiveShareholders) * 100);
|
||||
}
|
||||
|
||||
casteMatchPct(d: any): number {
|
||||
if (!d?.totalActiveShareholders) return 0;
|
||||
return Math.round((d.casteMatchCount / d.totalActiveShareholders) * 100);
|
||||
}
|
||||
|
||||
areaMatchPct(d: any): number {
|
||||
if (!d?.totalActiveShareholders) return 0;
|
||||
return Math.round((d.areaMatchCount / d.totalActiveShareholders) * 100);
|
||||
}
|
||||
|
||||
itemColor(item: any, idx: number, accent: string, palette: string[]): string {
|
||||
if (item.match) return accent;
|
||||
return palette[idx % palette.length];
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,9 @@
|
||||
</div>
|
||||
|
||||
<div class="candidate-actions" (click)="$event.stopPropagation()">
|
||||
<a [routerLink]="['/election', electionId, 'candidates', candidate.id, 'analysis']" class="btn-analyse">
|
||||
<mat-icon>analytics</mat-icon> Analyse
|
||||
</a>
|
||||
@if (!candidate.isActive) {
|
||||
<span class="removed-badge">Removed</span>
|
||||
} @else {
|
||||
|
||||
@@ -303,8 +303,33 @@
|
||||
.candidate-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
|
||||
.btn-analyse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -81,6 +81,14 @@ const routes: Routes = [
|
||||
(m) => m.ElectionAuditLogComponent
|
||||
),
|
||||
data: { title: 'Election Audit Log' }
|
||||
},
|
||||
{
|
||||
path: ':electionId/candidates/:candidateId/analysis',
|
||||
loadComponent: () =>
|
||||
import('./components/candidate-analysis/candidate-analysis.component').then(
|
||||
(m) => m.CandidateAnalysisComponent
|
||||
),
|
||||
data: { title: 'Candidate Voter Analysis' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -157,6 +157,12 @@ export class ElectionService {
|
||||
return this.http.get<any>(`/election/${electionId}/vote-summary`).pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
getCandidateVoterAnalysis(electionId: number, candidateId: number): Observable<any> {
|
||||
return this.http
|
||||
.get<any>(`/election/${electionId}/candidates/${candidateId}/voter-analysis`)
|
||||
.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
getAuditLog(
|
||||
electionId: number,
|
||||
params?: { entityType?: string; action?: string; search?: string; page?: number; size?: number }
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260623',
|
||||
'hash': '3d9b0480'
|
||||
'version': '260626',
|
||||
'hash': '9e33392b'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user