feat(election): candidate analysis enhancements + overall shareholder analysis page

- candidate-analysis: add family voter influence donut chart (% of total shareholders)
- candidate-analysis: sub-caste breakdown by client lastname
- candidate-analysis: religion profile with candidate match (★) highlighting
- candidate-analysis: remove active savings holders metric, expand grid to 3 cols
- shareholder-analysis: new standalone page showing demographic analysis of all active shareholders (11 charts across gender, age, religion, caste, sub-caste, occupation, education, marital, share-holding, tenure, area)
- candidate-board: add 'Overall Analysis' button navigating to shareholder analysis page
- election-routing: lazy-load route for :electionId/shareholders/analysis
- election.service: add getOverallShareholderAnalysis() API call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-30 18:31:40 +05:30
co-authored by Claude Sonnet 4.6
parent 78b87fb6ce
commit a915259ba1
10 changed files with 2347 additions and 744 deletions
@@ -15,9 +15,25 @@
<!-- ── 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>
<div class="hero-actions">
<a [routerLink]="['/election', electionId, 'candidates']" class="hero-back">
<mat-icon>chevron_left</mat-icon> Back to Candidates
</a>
@if (!loading && data) {
<button
class="download-btn"
[class.downloading]="pdfGenerating"
[disabled]="pdfGenerating"
(click)="downloadPDF()"
>
@if (pdfGenerating) {
<mat-icon class="spin-icon">sync</mat-icon> Generating...
} @else {
<mat-icon>file_download</mat-icon> Download PDF
}
</button>
}
</div>
@if (data) {
<div class="hero-body">
<div class="hero-avatar-wrapper">
@@ -30,7 +46,6 @@
<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>
@@ -114,16 +129,6 @@
<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>
@@ -147,43 +152,48 @@
</div>
<div class="a-grid">
<!-- Gender — DONUT -->
<!-- Gender -->
<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>
<span class="ac-tag">{{ 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 class="cbs">
<div class="cr-outer" style="--cr-glow: #ec4899">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.genderBreakdown, '#ec4899', genderPalette)"
(mousemove)="onRingHover($event, data.genderBreakdown, '#ec4899', genderPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 }}
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#ec4899', genderPalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★ Match</span>
<span class="mbadge">★</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Age Groups — DONUT -->
<!-- Age Groups -->
<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>
@@ -192,162 +202,201 @@
<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 class="cbs">
<div class="cr-outer" style="--cr-glow: #3b82f6">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.ageBreakdown, '#3b82f6', agePalette)"
(mousemove)="onRingHover($event, data.ageBreakdown, '#3b82f6', agePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#3b82f6', agePalette)"></span>
<span class="crl-label"
>{{ item.label }} Group
@if (item.match) {
<span class="mbadge">★ Match</span>
<span class="mbadge">★</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Religion — DONUT -->
<!-- Religion -->
<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>
@if (data.religionBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #f97316">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.religionBreakdown, '#f97316', religionPalette)"
(mousemove)="onRingHover($event, data.religionBreakdown, '#f97316', religionPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 class="crl-item" [class.crl-match]="item.match">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#f97316', religionPalette)"
></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No religion data recorded yet.</p>
}
</div>
</div>
} @else {
<p class="no-data">No religion data recorded yet.</p>
}
</div>
<!-- Occupation — DONUT -->
<!-- Occupation -->
<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>
@if (data.occupationBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #0891b2">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.occupationBreakdown, '#0891b2', occupationPalette)"
(mousemove)="onRingHover($event, data.occupationBreakdown, '#0891b2', occupationPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-legend">
@for (item of data.occupationBreakdown; track item.label; let i = $index) {
<div class="cl-row">
<div class="crl-item">
<span
class="cl-dot"
[style.background]="itemColor(item, i, '#0891b2', ['#22d3ee', '#67e8f9', '#a5f3fc', '#cbd5e1'])"
class="crl-swatch"
[style.background]="itemColor(item, i, '#0891b2', occupationPalette)"
></span>
<span class="cl-label">{{ item.label }}</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No occupation data recorded yet.</p>
}
</div>
</div>
} @else {
<p class="no-data">No occupation data recorded yet.</p>
}
</div>
<!-- Education Level — DONUT -->
<!-- Education Level -->
<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>
@if (data.educationBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #6366f1">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.educationBreakdown, '#6366f1', educationPalette)"
(mousemove)="onRingHover($event, data.educationBreakdown, '#6366f1', educationPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 class="crl-item">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#6366f1', educationPalette)"
></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No education data recorded yet.</p>
}
</div>
</div>
} @else {
<p class="no-data">No education data recorded yet.</p>
}
</div>
<!-- Marital Status — DONUT -->
<!-- Marital Status -->
<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>
@if (data.maritalBreakdown?.length > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #f59e0b">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.maritalBreakdown, '#f59e0b', maritalPalette)"
(mousemove)="onRingHover($event, data.maritalBreakdown, '#f59e0b', maritalPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 class="crl-item">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#f59e0b', maritalPalette)"
></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
} @else {
<p class="no-data">No marital status data recorded yet.</p>
}
</div>
</div>
} @else {
<p class="no-data">No marital status data recorded yet.</p>
}
</div>
</div>
</div>
@@ -361,106 +410,128 @@
</div>
<div class="a-grid">
<!-- Area / City — BARS -->
<!-- Area / City -->
<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>
<span class="ac-tag">{{ 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 class="cbs">
<div class="cr-outer" style="--cr-glow: #10b981">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.areaBreakdown, '#10b981', areaPalette)"
(mousemove)="onRingHover($event, data.areaBreakdown, '#10b981', areaPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 }}
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#10b981', areaPalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★ Match</span>
<span class="mbadge">★</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Social Category — DONUT -->
<!-- Social Category (Caste) -->
<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>
<span class="ac-tag">{{ 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 class="cbs">
<div class="cr-outer" style="--cr-glow: #8b5cf6">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.casteBreakdown, '#8b5cf6', castePalette)"
(mousemove)="onRingHover($event, data.casteBreakdown, '#8b5cf6', castePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.casteMatchCount }}</span>
<span class="cr-sub">match</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 }}
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#8b5cf6', castePalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★ Match</span>
<span class="mbadge">★</span>
}
</span>
<span class="cl-pct-badge">{{ item.percent }}%</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Sub-Caste — FULL WIDTH BARS -->
<!-- Sub-Caste — FULL WIDTH -->
<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>
<span class="ac-tag">{{ 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 class="split-left">
<div class="cr-outer" style="--cr-glow: #d946ef">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.subCasteBreakdown, '#d946ef', subCastePalette)"
(mousemove)="onRingHover($event, data.subCasteBreakdown, '#d946ef', subCastePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</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="caste-detail-item"
[class.caste-match]="item.match"
[style.--item-color]="itemColor(item, i, '#d946ef', subCastePalette)"
>
<div class="cdi-header">
<span
class="cdi-dot"
[style.background]="itemColor(item, i, '#d946ef', subCastePalette)"
></span>
<span class="cdi-label">{{ item.label }}</span>
@if (item.match) {
<span class="mbadge">★ Match</span>
@@ -487,197 +558,122 @@
<div class="section-header">
<mat-icon>savings</mat-icon>
<h2>Financial & Family Engagement</h2>
<span class="section-badge">5 Analysis Dimensions</span>
<span class="section-badge">3 Analysis Dimensions</span>
</div>
<div class="a-grid">
<!-- Share Holdings — BARS -->
<!-- Share Holding Distribution -->
<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 class="cbs">
<div class="cr-outer" style="--cr-glow: #6366f1">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.shareHoldingBreakdown, '#6366f1', shareHoldingPalette)"
(mousemove)="onRingHover($event, data.shareHoldingBreakdown, '#6366f1', shareHoldingPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 class="crl-item">
<span
class="crl-swatch"
[style.background]="itemColor(item, i, '#6366f1', shareHoldingPalette)"
></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Member Tenure — BARS -->
<!-- Member Tenure -->
<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 class="cbs">
<div class="cr-outer" style="--cr-glow: #06b6d4">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.tenureBreakdown, '#06b6d4', tenurePalette)"
(mousemove)="onRingHover($event, data.tenureBreakdown, '#06b6d4', tenurePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span>
<span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="chart-legend">
<div class="cr-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 class="crl-item">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#06b6d4', tenurePalette)"></span>
<span class="crl-label">{{ item.label }}</span>
<span class="crl-pct">{{ 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)">
<!-- Family Voter Influence -->
<div class="a-card" style="--ac: #be185d; --ac-bg: rgba(190, 24, 93, 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 class="ac-icon"><mat-icon>family_restroom</mat-icon></div>
<span class="ac-title">Family Voter Influence</span>
<span class="ac-tag"
>{{ data.shareholdersWithFamily }} of {{ data.totalActiveShareholders }} shareholders</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>
<span class="ac-tag">{{ activeSavingsPct(data) }}% Penetration</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>
<div class="sav-stats">
<div class="sav-stat">
<div class="sav-num" style="color: #0ea5e9">{{ data.activeSavingsHolders }}</div>
<div class="sav-lbl">Active Savings</div>
</div>
<div class="sav-stat">
<div class="sav-num" style="color: #94a3b8">
{{ data.totalActiveShareholders - data.activeSavingsHolders }}
</div>
<div class="sav-lbl">No Savings</div>
</div>
<div class="sav-stat">
<div class="sav-num" style="color: #16a34a">{{ activeSavingsPct(data) }}%</div>
<div class="sav-lbl">Penetration Rate</div>
</div>
</div>
<div class="loan-note" style="margin-top: 10px; width: 100%">
<mat-icon>info_outline</mat-icon>
<span
>High savings penetration means members are financially active and have stronger ties to the
institution — a positive signal for candidate support.</span
@if (data.shareholdersWithFamily > 0) {
<div class="cbs">
<div class="cr-outer" style="--cr-glow: #be185d">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(getFamilyBreakdown(data), '#be185d', familyPalette)"
(mousemove)="onRingHover($event, getFamilyBreakdown(data), '#be185d', familyPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.shareholdersWithFamily }}</span>
<span class="cr-sub">family</span>
</div>
</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 class="cr-legend">
@for (item of getFamilyBreakdown(data); track item.label; let i = $index) {
<div class="crl-item" [class.crl-match]="item.match">
<span class="crl-swatch" [style.background]="itemColor(item, i, '#be185d', familyPalette)"></span>
<span class="crl-label"
>{{ item.label }}
@if (item.match) {
<span class="mbadge">★</span>
}
</span>
<span class="crl-pct" [class.crl-pct-match]="item.match">{{ item.percent }}%</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>
} @else {
<p class="no-data">No family member records linked to this candidate.</p>
}
</div>
</div>
</div>
@@ -691,7 +687,6 @@
</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>
@@ -705,7 +700,7 @@
</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 class="im-label">Core Base — Same Caste &amp; Same Area</div>
</div>
<div class="influence-metric">
<div class="im-icon" style="background: rgba(139, 92, 246, 0.08)">
@@ -723,16 +718,8 @@
<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">
<div class="insight-note" style="margin-top: 16px">
<mat-icon>lightbulb</mat-icon>
<span
>The <strong>Core Base</strong> — voters matching both the candidate's social category AND geographic
@@ -746,3 +733,13 @@
</div>
}
</div>
@if (tooltipState.visible) {
<div class="chart-tooltip" [style.left.px]="tooltipState.x + 14" [style.top.px]="tooltipState.y - 48">
<span class="ct-swatch" [style.background]="tooltipState.color"></span>
<span class="ct-label">{{ tooltipState.label }}</span>
<span class="ct-sep">·</span>
<span class="ct-pct">{{ tooltipState.percent }}%</span>
<span class="ct-count">({{ tooltipState.count }})</span>
</div>
}
@@ -126,6 +126,15 @@ $accent-green: #10b981;
pointer-events: none;
}
.hero-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
position: relative;
z-index: 2;
}
.hero-back {
display: inline-flex;
align-items: center;
@@ -134,10 +143,7 @@ $accent-green: #10b981;
text-decoration: none;
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 2;
padding: 6px 12px;
background: rgb(15 23 42 / 4%);
border: 1px solid rgb(15 23 42 / 8%);
@@ -157,6 +163,51 @@ $accent-green: #10b981;
}
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: white;
border: none;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgb(79 70 229 / 25%);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-family: $font;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
box-shadow: 0 6px 18px rgb(79 70 229 / 40%);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgb(79 70 229 / 20%);
}
&.downloading,
&:disabled {
opacity: 0.72;
cursor: not-allowed;
transform: none !important;
box-shadow: 0 2px 8px rgb(79 70 229 / 15%) !important;
}
}
.spin-icon {
animation: spin 0.9s linear infinite;
}
.hero-body {
display: flex;
align-items: center;
@@ -375,9 +426,9 @@ $accent-green: #10b981;
// ─── KPI Strip ────────────────────────────────────────────────────────────────
.kpi-row {
display: grid;
grid-template-columns: 1.4fr repeat(5, 1fr);
grid-template-columns: 1.4fr repeat(4, 1fr);
gap: 10px;
padding: 12px 24px;
padding: 12px 20px;
position: relative;
z-index: 2;
@@ -387,12 +438,14 @@ $accent-green: #10b981;
@media (width <= 768px) {
grid-template-columns: repeat(2, 1fr);
padding: 10px 16px;
padding: 10px 14px;
gap: 8px;
}
@media (width <= 480px) {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
padding: 8px 12px;
}
}
@@ -491,8 +544,13 @@ $accent-green: #10b981;
z-index: 2;
@media (width <= 768px) {
padding: 0 14px;
gap: 16px;
padding: 0 12px;
gap: 14px;
}
@media (width <= 480px) {
padding: 0 8px;
gap: 12px;
}
}
@@ -560,8 +618,9 @@ $accent-green: #10b981;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (width <= 1024px) {
@media (width <= 900px) {
grid-template-columns: 1fr;
gap: 10px;
}
}
@@ -637,67 +696,113 @@ $accent-green: #10b981;
flex-shrink: 0;
}
// ─── Flex helpers ─────────────────────────────────────────────────────────────
.flex-col {
// ─── Conic-Gradient Ring Charts ──────────────────────────────────────────────
.cbs {
display: flex;
flex-direction: column;
align-items: center;
align-items: flex-start;
gap: 64px;
width: 100%;
@media (width <= 480px) {
flex-direction: column;
align-items: center;
.cr-legend {
width: 100%;
}
}
}
// ─── Chart Canvas Wrappers ───────────────────────────────────────────────────
.chart-canvas-wrap {
.cr-outer {
position: relative;
width: 100%;
height: 100%;
}
.bar-chart-wrap {
position: relative;
width: 100%;
margin-bottom: 16px;
filter: drop-shadow(0 6px 10px rgb(0 0 0 / 3%));
}
.vertical-bar-wrap {
height: 160px;
}
.horizontal-bar-wrap {
height: 140px;
}
.horizontal-bar-wrap-large {
height: 200px;
}
// ─── Donut Center Overlay ────────────────────────────────────────────────────
.donut-wrap {
position: relative;
width: 120px;
height: 120px;
flex-shrink: 0;
margin-bottom: 12px;
filter: drop-shadow(0 4px 8px rgb(0 0 0 / 4%));
width: 118px;
height: 118px;
&.ring-hovered .cr-glow {
opacity: 0.45;
animation: glow-pulse 1.4s ease-in-out infinite;
}
}
.donut-center {
@keyframes glow-pulse {
0%,
100% {
opacity: 0.38;
filter: blur(14px);
}
50% {
opacity: 0.6;
filter: blur(22px);
}
}
.cr-glow {
position: absolute;
inset: 22%;
inset: -10px;
border-radius: 50%;
background: var(--cr-glow, #6366f1);
filter: blur(16px);
opacity: 0.28;
pointer-events: none;
transition:
opacity 0.3s,
background-color 0.25s;
}
// Segment-specific highlight overlay — extends 8px beyond the ring edge
// so the hovered arc visually grows thicker/larger. cr-hole (z-index:2)
// stays on top and keeps the white center intact.
.cr-highlight {
position: absolute;
inset: -8px; // 8px wider than cr-ring on all sides → arc sticks out
border-radius: 50%;
opacity: 0;
pointer-events: none;
z-index: 1;
&.active {
animation: segment-pulse 0.8s ease-in-out infinite;
}
}
@keyframes segment-pulse {
0%,
100% {
opacity: 0.72;
}
50% {
opacity: 1;
}
}
.cr-ring {
width: 118px;
height: 118px;
border-radius: 50%;
position: relative;
box-shadow: 0 4px 20px rgb(0 0 0 / 6%);
}
.cr-hole {
position: absolute;
inset: 21%;
background: white;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 4px 10px rgb(0 0 0 / 6%);
z-index: 10;
box-shadow: inset 0 2px 8px rgb(0 0 0 / 7%);
text-align: center;
padding: 8px;
padding: 4px;
box-sizing: border-box;
z-index: 2;
}
.dc-val {
.cr-val {
font-size: 18px;
font-weight: 800;
color: $text-main;
@@ -705,8 +810,8 @@ $accent-green: #10b981;
letter-spacing: -0.5px;
}
.dc-sub {
font-size: 10px;
.cr-sub {
font-size: 8px;
color: $text-sub;
font-weight: 700;
text-transform: uppercase;
@@ -714,82 +819,82 @@ $accent-green: #10b981;
margin-top: 2px;
}
// ─── Interactive HTML Legends ────────────────────────────────────────────────
.chart-legend {
width: 100%;
// ─── Ring Legend ─────────────────────────────────────────────────────────────
.cr-legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 5px;
gap: 4px;
min-width: 0;
justify-content: center;
}
.cl-row {
.crl-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #f1f5f9;
transition: all 0.2s ease;
gap: 7px;
padding: 5px 7px;
border-radius: 7px;
transition: background 0.2s;
&.cl-match {
&.crl-match {
background: var(--ac-bg, #eff6ff);
border-color: rgb(79 70 229 / 15%);
.cl-label {
.crl-label {
font-weight: 700;
color: $text-main;
}
.cl-pct-badge {
background: var(--ac, #3b82f6);
color: white;
}
}
&:hover {
background: #f1f5f9;
transform: translateX(2px);
}
}
.cl-dot {
width: 10px;
height: 10px;
.crl-swatch {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.cl-label {
font-size: 12.5px;
.crl-label {
font-size: 11.5px;
color: #475569;
font-weight: 500;
flex: 1;
display: flex;
align-items: center;
gap: 6px;
gap: 5px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.crl-pct {
font-size: 11px;
font-weight: 700;
color: $text-main;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 5px;
flex-shrink: 0;
&.crl-pct-match {
background: var(--ac, #2563eb);
color: white;
}
}
.mbadge {
background: var(--ac, #2563eb);
color: white;
border-radius: 99px;
padding: 2px 8px;
font-size: 9.5px;
padding: 2px 7px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.cl-pct-badge {
font-size: 12px;
font-weight: 700;
color: $text-main;
background: #e2e8f0;
padding: 2px 8px;
border-radius: 6px;
min-width: 38px;
text-align: center;
flex-shrink: 0;
}
// ─── Sub-Caste Layout ────────────────────────────────────────────────────────
@@ -827,6 +932,7 @@ $accent-green: #10b981;
.caste-detail-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 3px solid var(--item-color, #e2e8f0);
border-radius: 12px;
padding: 12px 14px;
transition: all 0.2s ease;
@@ -850,10 +956,18 @@ $accent-green: #10b981;
.cdi-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.cdi-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgb(0 0 0 / 6%);
}
.cdi-label {
font-size: 13px;
font-weight: 600;
@@ -896,30 +1010,36 @@ $accent-green: #10b981;
flex-shrink: 0;
}
.loan-donut-wrap {
.loan-g-outer {
position: relative;
width: 160px;
height: 95px;
height: 80px;
overflow: hidden;
}
.gauge-center {
.loan-g-ring {
width: 160px;
height: 160px;
border-radius: 50%;
box-shadow: 0 4px 20px rgb(0 0 0 / 6%);
}
.loan-g-hole {
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 0);
transform: translateX(-50%);
width: 108px;
height: 54px;
background: white;
border-radius: 50% 50% 0 0;
width: 110px;
height: 55px;
border-radius: 100px 100px 0 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 8px;
box-shadow: 0 -4px 10px rgb(0 0 0 / 5%);
box-shadow: 0 -3px 10px rgb(0 0 0 / 5%);
text-align: center;
z-index: 10;
box-sizing: border-box;
}
@@ -1136,7 +1256,7 @@ $accent-green: #10b981;
// ─── Voter Influence Insight Card ─────────────────────────────────────────────
.influence-insight {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 6px;
@@ -1246,6 +1366,69 @@ $accent-green: #10b981;
line-height: 1.3;
}
// ─── Chart Hover Tooltip ─────────────────────────────────────────────────────
.chart-tooltip {
position: fixed;
z-index: 9999;
background: rgb(15 23 42 / 90%);
color: white;
border-radius: 10px;
padding: 8px 14px;
font-size: 12.5px;
font-family: $font;
display: flex;
align-items: center;
gap: 8px;
pointer-events: none;
white-space: nowrap;
box-shadow:
0 8px 24px rgb(0 0 0 / 22%),
0 2px 8px rgb(0 0 0 / 12%);
backdrop-filter: blur(6px);
animation: tooltip-pop 0.12s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes tooltip-pop {
from {
opacity: 0;
transform: scale(0.88) translateY(4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.ct-swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgb(255 255 255 / 25%);
}
.ct-label {
font-weight: 600;
color: #f1f5f9;
}
.ct-sep {
color: #475569;
font-weight: 400;
}
.ct-pct {
font-weight: 800;
color: #fbbf24;
letter-spacing: -0.3px;
}
.ct-count {
color: #94a3b8;
font-size: 11px;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
.no-data {
font-size: 13.5px;
@@ -1255,3 +1438,151 @@ $accent-green: #10b981;
margin: 0;
font-style: italic;
}
// ─── Print / Download PDF ─────────────────────────────────────────────────────
@media print {
/* stylelint-disable unit-allowed-list */
@page {
size: a4 portrait;
margin: 8mm 10mm;
}
/* stylelint-enable unit-allowed-list */
// Force all elements to allow full content flow
/* stylelint-disable selector-max-universal */
*,
*::before,
*::after {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* stylelint-enable selector-max-universal */
html,
body {
height: auto !important;
overflow: visible !important;
width: 100% !important;
}
// Angular Material scroll containers clip print to one viewport height — reset them
/* stylelint-disable selector-class-pattern */
mat-sidenav-content,
mat-drawer-content,
mat-sidenav-container,
mat-drawer-container,
.mat-drawer-content,
.mat-sidenav-content {
overflow: visible !important;
height: auto !important;
max-height: none !important;
}
/* stylelint-enable selector-class-pattern */
// Reset page shell — overflow-x:hidden is the main culprit that clips to 1 page
.va-page {
position: static !important;
overflow: visible !important;
height: auto !important;
min-height: auto !important;
background: white !important;
padding-bottom: 0 !important;
}
// Hide decorative / navigation elements
.backdrop-blobs,
.hero-overlay,
.hero-avatar-glow,
.hero-actions,
.cr-glow {
display: none !important;
}
.hero {
background: linear-gradient(135deg, #eef2ff 0%, #e0f2fe 50%, #f0fdf4 100%) !important;
overflow: visible !important;
padding: 12px 16px 14px !important;
border-bottom: 2px solid #cbd5e1 !important;
&::before,
&::after {
display: none !important;
}
}
.kpi-row {
padding: 8px 14px !important;
break-inside: avoid;
page-break-inside: avoid;
}
.kpi {
box-shadow: none !important;
}
.dashboard-sections {
padding: 0 10px !important;
gap: 12px !important;
overflow: visible !important;
height: auto !important;
}
.dashboard-section {
overflow: visible !important;
height: auto !important;
// Each section starts on a new page (except the first)
& + .dashboard-section {
break-before: page;
page-break-before: always;
}
}
.section-header {
break-after: avoid;
page-break-after: avoid;
}
.a-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 8px !important;
height: auto !important;
overflow: visible !important;
}
.a-card {
break-inside: avoid;
page-break-inside: avoid;
box-shadow: none !important;
transform: none !important;
overflow: visible !important;
height: auto !important;
}
.a-full {
grid-column: 1 / -1 !important;
}
.cbs,
.cr-ring,
.cr-outer,
.cr-legend,
.loan-layout,
.loan-stats,
.influence-insight,
.sav-stats {
overflow: visible !important;
height: auto !important;
}
.cr-ring {
box-shadow: none !important;
}
.full-card-split {
break-inside: avoid;
page-break-inside: avoid;
}
}
@@ -5,14 +5,13 @@
* 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 { Component, OnInit, 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);
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
@Component({
selector: 'mifosx-candidate-analysis',
@@ -25,7 +24,7 @@ Chart.register(...registerables);
templateUrl: './candidate-analysis.component.html',
styleUrls: ['./candidate-analysis.component.scss']
})
export class CandidateAnalysisComponent implements OnInit, OnDestroy {
export class CandidateAnalysisComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
@@ -35,62 +34,205 @@ export class CandidateAnalysisComponent implements OnInit, OnDestroy {
loading = true;
error = false;
errorMessage = '';
pdfGenerating = false;
private chartInstances: any[] = [];
tooltipState: {
visible: boolean;
label: string;
count: number;
percent: number;
color: string;
x: number;
y: number;
} = { visible: false, label: '', count: 0, percent: 0, color: '', x: 0, y: 0 };
// ── Color palettes for donut charts ───────────────────────────────────────
private currentRingOuter: HTMLElement | null = null;
private currentRing: HTMLElement | null = null;
// ── Color palettes — IT-standard categorical colors (Tailwind 500 / Chart.js) ─
// Each non-match segment gets a distinct hue; grey (#94a3b8) for "unspecified"
// Gender accent #ec4899
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',
'#3b82f6',
'#a855f7',
'#94a3b8'
];
readonly incomePalette = [
'#4ade80',
'#86efac',
'#6ee7b7',
'#5eead4',
'#a5f3fc',
'#cbd5e1'
// Caste accent #8b5cf6
readonly castePalette = [
'#3b82f6',
'#06b6d4',
'#10b981',
'#f59e0b',
'#94a3b8'
];
// Religion accent #f97316
readonly religionPalette = [
'#3b82f6',
'#8b5cf6',
'#ef4444',
'#10b981',
'#f59e0b',
'#ec4899',
'#94a3b8'
];
// Age accent #3b82f6
readonly agePalette = [
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#f97316',
'#94a3b8'
];
// Education accent #6366f1
readonly educationPalette = [
'#10b981',
'#f59e0b',
'#ef4444',
'#ec4899',
'#94a3b8'
];
// Marital accent #f59e0b
readonly maritalPalette = [
'#3b82f6',
'#10b981',
'#ec4899',
'#8b5cf6',
'#94a3b8'
];
// Income accent #16a34a
readonly incomePalette = [
'#3b82f6',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#f97316',
'#94a3b8'
];
// Share Holding accent #6366f1
readonly shareHoldingPalette = [
'#f59e0b',
'#10b981',
'#ef4444',
'#94a3b8'
];
// Tenure accent #06b6d4
readonly tenurePalette = [
'#f59e0b',
'#8b5cf6',
'#10b981',
'#94a3b8'
];
// Area accent #10b981
readonly areaPalette = [
'#3b82f6',
'#f59e0b',
'#f97316',
'#94a3b8'
];
// Sub-Caste accent #d946ef
readonly subCastePalette = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#f97316',
'#94a3b8'
];
// Occupation accent #0891b2
readonly occupationPalette = [
'#f59e0b',
'#8b5cf6',
'#ef4444',
'#10b981',
'#94a3b8'
];
// Family accent #be185d
readonly familyPalette = [
'#be185d',
'#94a3b8'
];
onRingHover(event: MouseEvent, breakdown: any[], accent: string, palette: string[]): void {
if (!breakdown?.length) return;
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const outerR = rect.width / 2;
const holeR = outerR * 0.42;
// Track cr-outer for glow color; track ring element for highlight overlay
const outer = el.parentElement as HTMLElement;
if (outer && outer !== this.currentRingOuter) {
if (this.currentRingOuter) {
this.currentRingOuter.classList.remove('ring-hovered');
this.currentRingOuter.style.removeProperty('--cr-glow');
}
this.currentRingOuter = outer;
this.currentRing = el;
outer.classList.add('ring-hovered');
}
// Ensure highlight overlay element exists (created once per ring, reused)
let hl = el.querySelector<HTMLElement>('.cr-highlight');
if (!hl) {
hl = document.createElement('div');
hl.className = 'cr-highlight';
el.insertBefore(hl, el.querySelector('.cr-hole'));
}
if (dist < holeR || dist > outerR) {
hl.classList.remove('active');
this.tooltipState.visible = false;
return;
}
// CSS conic-gradient uses clockwise-from-top convention; "from -90deg" starts at left (9 o'clock).
// atan2(dx, -dy) gives the clockwise-from-top angle in degrees.
// Subtract the start offset (-90deg) to get the gradient percentage position.
const cssDeg = Math.atan2(dx, -dy) * (180 / Math.PI);
const pct = (((((cssDeg + 90) % 360) + 360) % 360) / 360) * 100;
let cum = 0;
for (let i = 0; i < breakdown.length; i++) {
cum += breakdown[i].percent;
if (pct <= cum + 0.01) {
const segColor = breakdown[i].match ? accent : palette[i % palette.length];
const startPct = cum - breakdown[i].percent;
// Highlight overlay: only the hovered segment is colored, rest transparent
hl.style.background = `conic-gradient(from -90deg, transparent 0% ${startPct}%, ${segColor} ${startPct}% ${cum}%, transparent ${cum}% 100%)`;
hl.classList.add('active');
if (outer) outer.style.setProperty('--cr-glow', segColor);
this.tooltipState = {
visible: true,
label: breakdown[i].label,
count: breakdown[i].count,
percent: breakdown[i].percent,
color: segColor,
x: event.clientX,
y: event.clientY
};
return;
}
}
hl.classList.remove('active');
this.tooltipState.visible = false;
}
onRingLeave(): void {
const hl = this.currentRing?.querySelector<HTMLElement>('.cr-highlight');
if (hl) hl.classList.remove('active');
if (this.currentRingOuter) {
this.currentRingOuter.classList.remove('ring-hovered');
this.currentRingOuter.style.removeProperty('--cr-glow');
this.currentRingOuter = null;
}
this.currentRing = null;
this.tooltipState.visible = false;
}
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
@@ -100,7 +242,6 @@ export class CandidateAnalysisComponent implements OnInit, OnDestroy {
next: (d) => {
this.data = d;
this.loading = false;
setTimeout(() => this.initCharts(), 0);
},
error: (err) => {
this.loading = false;
@@ -110,256 +251,27 @@ export class CandidateAnalysisComponent implements OnInit, OnDestroy {
});
}
ngOnDestroy(): void {
this.chartInstances.forEach((chart) => {
if (chart) {
chart.destroy();
}
/** Builds a CSS conic-gradient string from a breakdown array. */
getConicGradient(breakdown: any[], accent: string, palette: string[]): string {
if (!breakdown?.length) return '#e2e8f0';
const parts: string[] = [];
let cum = 0;
breakdown.forEach((item, i) => {
const color = item.match ? accent : palette[i % palette.length];
const segEnd = cum + item.percent;
parts.push(`${color} ${cum}% ${segEnd}%`);
cum = segEnd;
});
this.chartInstances = [];
if (cum < 99.5) parts.push(`#e2e8f0 ${cum}% 100%`);
return `conic-gradient(from -90deg, ${parts.join(', ')})`;
}
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: breakdown.length === 1 ? 0 : 3,
borderColor: '#ffffff',
borderRadius: breakdown.length === 1 ? 0 : 5,
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) + '%';
/** Semicircle gauge for loan chart. */
getLoanGauge(pct: number): string {
const fill = pct / 2;
return `conic-gradient(from 270deg, #f59e0b 0% ${fill}%, #e2e8f0 ${fill}% 50%, transparent 50% 100%)`;
}
hasCriteria(d: any): boolean {
@@ -400,4 +312,211 @@ export class CandidateAnalysisComponent implements OnInit, OnDestroy {
if (item.match) return accent;
return palette[idx % palette.length];
}
getFamilyBreakdown(d: any): any[] {
if (!d?.totalActiveShareholders) return [];
const total = d.totalActiveShareholders;
const familyShareholders = d.shareholdersWithFamily ?? 0;
const others = total - familyShareholders;
const result: any[] = [];
if (familyShareholders > 0) {
result.push({
label: 'Family Shareholders',
count: familyShareholders,
percent: Math.round((familyShareholders / total) * 1000) / 10,
match: true
});
}
if (others > 0) {
result.push({
label: 'Other Shareholders',
count: others,
percent: Math.round((others / total) * 1000) / 10,
match: false
});
}
return result;
}
async downloadPDF(): Promise<void> {
if (this.pdfGenerating) return;
this.pdfGenerating = true;
// Hide navigation/decorative elements that shouldn't appear in the PDF
const hideSelectors = [
'.hero-actions',
'.backdrop-blobs'
];
const hiddenEls: HTMLElement[] = [];
hideSelectors.forEach((sel) => {
document.querySelectorAll<HTMLElement>(sel).forEach((el) => {
hiddenEls.push(el);
el.style.setProperty('display', 'none', 'important');
});
});
// Expand Angular Material scroll containers so the full page height is rendered
const scrollSaved: Array<{ el: HTMLElement; overflow: string; height: string; maxHeight: string }> = [];
[
'mat-sidenav-content',
'mat-drawer-content',
'mat-sidenav-container',
'mat-drawer-container'
].forEach((sel) => {
document.querySelectorAll<HTMLElement>(sel).forEach((el) => {
scrollSaved.push({ el, overflow: el.style.overflow, height: el.style.height, maxHeight: el.style.maxHeight });
el.style.setProperty('overflow', 'visible', 'important');
el.style.setProperty('height', 'auto', 'important');
el.style.setProperty('max-height', 'none', 'important');
});
});
// Allow full reflow to complete after container expansion
await new Promise<void>((resolve) => setTimeout(resolve, 300));
// html2canvas cannot render conic-gradient CSS — pre-bake each ring to a canvas image
const gradientBackups = this.preprocessConicGradients();
try {
const element = document.querySelector<HTMLElement>('.va-page')!;
// Read full height AFTER reflow — not just the clipped viewport height
const fullHeight = element.scrollHeight || element.offsetHeight;
const canvas = await html2canvas(element, {
scale: 1.5,
useCORS: true,
allowTaint: true,
backgroundColor: '#f8fafc',
logging: false,
scrollX: 0,
scrollY: 0,
width: element.offsetWidth,
height: fullHeight,
windowWidth: 1280,
windowHeight: fullHeight
});
const imgData = canvas.toDataURL('image/jpeg', 0.97);
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageW = pdf.internal.pageSize.getWidth();
const pageH = pdf.internal.pageSize.getHeight();
const imgW = pageW;
const imgH = (canvas.height * imgW) / canvas.width;
let yOffset = 0;
let remaining = imgH;
pdf.addImage(imgData, 'JPEG', 0, yOffset, imgW, imgH);
remaining -= pageH;
while (remaining > 0) {
yOffset -= pageH;
pdf.addPage();
pdf.addImage(imgData, 'JPEG', 0, yOffset, imgW, imgH);
remaining -= pageH;
}
const safeName = (this.data?.candidateName ?? 'candidate').replace(/\s+/g, '-');
pdf.save(`Voter-Analysis-${safeName}.pdf`);
} finally {
scrollSaved.forEach(({ el, overflow, height, maxHeight }) => {
el.style.overflow = overflow;
el.style.height = height;
el.style.maxHeight = maxHeight;
});
hiddenEls.forEach((el) => el.style.removeProperty('display'));
gradientBackups.forEach(({ el, originalBg, originalBgSize }) => {
el.style.background = originalBg;
el.style.backgroundSize = originalBgSize;
});
this.pdfGenerating = false;
}
}
/** Replace every conic-gradient background with a canvas-drawn image so html2canvas can capture it. */
private preprocessConicGradients(): Array<{ el: HTMLElement; originalBg: string; originalBgSize: string }> {
const backups: Array<{ el: HTMLElement; originalBg: string; originalBgSize: string }> = [];
document.querySelectorAll<HTMLElement>('.cr-ring, .loan-g-ring').forEach((el) => {
const bg = el.style.background;
if (!bg?.includes('conic-gradient')) return;
backups.push({ el, originalBg: bg, originalBgSize: el.style.backgroundSize });
const w = (el.offsetWidth || 118) * 2;
const h = (el.offsetHeight || 118) * 2;
const dataUrl = this.conicGradToDataUrl(bg, w, h);
if (dataUrl) {
el.style.background = `url(${dataUrl})`;
el.style.backgroundSize = '100% 100%';
}
});
return backups;
}
/** Parse a conic-gradient() CSS string and rasterise it onto a canvas, returning a PNG data URL.
* Two-pass parser: Pass 1 handles original range format `color X% Y%`.
* Pass 2 handles Chrome-normalised single-stop format `color X%` by grouping consecutive
* same-colour stops into ranges — Chrome writes each range as two identical-colour stops. */
private conicGradToDataUrl(gradStr: string, w: number, h: number): string | null {
try {
const angleMatch = gradStr.match(/from\s+([\d.-]+)deg/);
const startRad = angleMatch ? (parseFloat(angleMatch[1]) * Math.PI) / 180 : 0;
const C = 'white|transparent|#[0-9a-fA-F]{3,8}|rgb[a]?\\([^)]+\\)';
let stops: Array<{ color: string; from: number; to: number }> = [];
// Pass 1 — original range format: `color X% Y%`
const rangeRe = new RegExp(`(${C})\\s+([\\d.]+)%\\s+([\\d.]+)%`, 'g');
let m: RegExpExecArray | null;
while ((m = rangeRe.exec(gradStr)) !== null) {
stops.push({ color: m[1], from: parseFloat(m[2]), to: parseFloat(m[3]) });
}
if (!stops.length) {
// Pass 2 — Chrome-normalised single stops: group consecutive same-colour stops into ranges
const singleRe = new RegExp(`(${C})\\s+([\\d.]+)%`, 'g');
const raw: Array<{ color: string; pct: number }> = [];
while ((m = singleRe.exec(gradStr)) !== null) {
raw.push({ color: m[1], pct: parseFloat(m[2]) });
}
let i = 0;
while (i < raw.length) {
const { color } = raw[i];
const start = raw[i].pct;
let j = i + 1;
while (j < raw.length && raw[j].color === color) j++;
const end = raw[j - 1].pct;
if (start < end) stops.push({ color, from: start, to: end });
i = j;
}
}
if (!stops.length) return null;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy);
stops.forEach(({ color, from, to }) => {
if (color === 'transparent' || from >= to) return;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, startRad + (from / 100) * 2 * Math.PI, startRad + (to / 100) * 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
});
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
}
@@ -15,6 +15,9 @@
<p class="sub-title">{{ election.title }}</p>
}
</div>
<a [routerLink]="['/election', electionId, 'shareholders', 'analysis']" class="btn-outline btn-analysis">
<mat-icon>analytics</mat-icon> Overall Analysis
</a>
<a [routerLink]="['/election', electionId, 'nominations']" class="btn-outline">
<mat-icon>list</mat-icon> Nominations
</a>
@@ -52,6 +52,21 @@
mat-icon {
font-size: 18px;
}
&.btn-analysis {
margin-left: auto;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #fff;
border-color: transparent;
&:hover {
opacity: 0.9;
}
}
& + .btn-outline {
margin-left: 8px;
}
}
}
@@ -0,0 +1,469 @@
<!--
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="sa-page">
<!-- Header -->
<div class="sa-header">
<a [routerLink]="['/election', electionId, 'candidates']" class="sa-back">
<mat-icon>chevron_left</mat-icon> Back to Candidates
</a>
<div class="sa-title-block">
<mat-icon class="sa-title-icon">analytics</mat-icon>
<div>
<h1 class="sa-title">Overall Shareholder Analysis</h1>
<p class="sa-sub">Complete demographic profile of all active shareholders</p>
</div>
</div>
@if (!loading && data) {
<div class="sa-kpi">
<span class="sa-kpi-val">{{ data.totalActiveShareholders }}</span>
<span class="sa-kpi-lbl">Total Active Shareholders</span>
</div>
}
</div>
@if (loading) {
<div class="sa-state">
<div class="ring-spinner"></div>
<p>Loading shareholder analysis...</p>
</div>
} @else if (error || !data) {
<div class="sa-state">
<mat-icon class="sa-err-icon">error_outline</mat-icon>
<p>Analysis not available.</p>
</div>
} @else {
<!-- ── SECTION 1: DEMOGRAPHICS ── -->
<div class="sa-section">
<div class="sa-section-header">
<mat-icon>people_outline</mat-icon>
<h2>Demographic Profile</h2>
<span class="sa-badge">6 Dimensions</span>
</div>
<div class="sa-grid">
<!-- Gender -->
<div class="sa-card" style="--ac: #ec4899; --ac-bg: rgba(236, 72, 153, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>people</mat-icon></div>
<span>Gender Distribution</span>
</div>
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #ec4899">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.genderBreakdown, '#ec4899', genderPalette)"
(mousemove)="onRingHover($event, data.genderBreakdown, '#ec4899', genderPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.genderBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="genderPalette[i % genderPalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Age -->
<div class="sa-card" style="--ac: #3b82f6; --ac-bg: rgba(59, 130, 246, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>cake</mat-icon></div>
<span>Age Groups</span>
</div>
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #3b82f6">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.ageBreakdown, '#3b82f6', agePalette)"
(mousemove)="onRingHover($event, data.ageBreakdown, '#3b82f6', agePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.ageBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="agePalette[i % agePalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Religion -->
<div class="sa-card" style="--ac: #f97316; --ac-bg: rgba(249, 115, 22, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>temple_hindu</mat-icon></div>
<span>Religion Profile</span>
</div>
@if (data.religionBreakdown?.length > 0) {
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #f97316">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.religionBreakdown, '#f97316', religionPalette)"
(mousemove)="onRingHover($event, data.religionBreakdown, '#f97316', religionPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.religionBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="religionPalette[i % religionPalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="sa-no-data">No religion data.</p>
}
</div>
<!-- Occupation -->
<div class="sa-card" style="--ac: #0891b2; --ac-bg: rgba(8, 145, 178, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>work</mat-icon></div>
<span>Occupation Profile</span>
</div>
@if (data.occupationBreakdown?.length > 0) {
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #0891b2">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.occupationBreakdown, '#0891b2', occupationPalette)"
(mousemove)="onRingHover($event, data.occupationBreakdown, '#0891b2', occupationPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.occupationBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span
class="sa-leg-dot"
[style.background]="occupationPalette[i % occupationPalette.length]"
></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="sa-no-data">No occupation data.</p>
}
</div>
<!-- Education -->
<div class="sa-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>school</mat-icon></div>
<span>Education Level</span>
</div>
@if (data.educationBreakdown?.length > 0) {
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #6366f1">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.educationBreakdown, '#6366f1', educationPalette)"
(mousemove)="onRingHover($event, data.educationBreakdown, '#6366f1', educationPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.educationBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="educationPalette[i % educationPalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="sa-no-data">No education data.</p>
}
</div>
<!-- Marital -->
<div class="sa-card" style="--ac: #f59e0b; --ac-bg: rgba(245, 158, 11, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>favorite</mat-icon></div>
<span>Marital Status</span>
</div>
@if (data.maritalBreakdown?.length > 0) {
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #f59e0b">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.maritalBreakdown, '#f59e0b', maritalPalette)"
(mousemove)="onRingHover($event, data.maritalBreakdown, '#f59e0b', maritalPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.maritalBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="maritalPalette[i % maritalPalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="sa-no-data">No marital data.</p>
}
</div>
</div>
</div>
<!-- ── SECTION 2: SOCIAL & GEOGRAPHIC ── -->
<div class="sa-section">
<div class="sa-section-header">
<mat-icon>public</mat-icon>
<h2>Social & Geographic Profile</h2>
<span class="sa-badge">3 Dimensions</span>
</div>
<div class="sa-grid">
<!-- Area -->
<div class="sa-card" style="--ac: #10b981; --ac-bg: rgba(16, 185, 129, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>location_on</mat-icon></div>
<span>Area / City</span>
</div>
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #10b981">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.areaBreakdown, '#10b981', areaPalette)"
(mousemove)="onRingHover($event, data.areaBreakdown, '#10b981', areaPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.areaBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="areaPalette[i % areaPalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Caste -->
<div class="sa-card" style="--ac: #8b5cf6; --ac-bg: rgba(139, 92, 246, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>diversity_3</mat-icon></div>
<span>Social Category</span>
</div>
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #8b5cf6">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.casteBreakdown, '#8b5cf6', castePalette)"
(mousemove)="onRingHover($event, data.casteBreakdown, '#8b5cf6', castePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.casteBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="castePalette[i % castePalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Sub-Caste (by lastname) -->
<div class="sa-card" style="--ac: #d946ef; --ac-bg: rgba(217, 70, 239, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>account_tree</mat-icon></div>
<span>Sub-Caste (Surname)</span>
</div>
@if (data.subCasteBreakdown?.length > 0) {
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #d946ef">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.subCasteBreakdown, '#d946ef', subCastePalette)"
(mousemove)="onRingHover($event, data.subCasteBreakdown, '#d946ef', subCastePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.subCasteBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="subCastePalette[i % subCastePalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
} @else {
<p class="sa-no-data">No sub-caste data.</p>
}
</div>
</div>
</div>
<!-- ── SECTION 3: FINANCIAL ── -->
<div class="sa-section">
<div class="sa-section-header">
<mat-icon>savings</mat-icon>
<h2>Financial Profile</h2>
<span class="sa-badge">2 Dimensions</span>
</div>
<div class="sa-grid">
<!-- Share Holding -->
<div class="sa-card" style="--ac: #6366f1; --ac-bg: rgba(99, 102, 241, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>account_balance_wallet</mat-icon></div>
<span>Share Holding</span>
</div>
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #6366f1">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.shareHoldingBreakdown, '#6366f1', shareHoldingPalette)"
(mousemove)="onRingHover($event, data.shareHoldingBreakdown, '#6366f1', shareHoldingPalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.shareHoldingBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span
class="sa-leg-dot"
[style.background]="shareHoldingPalette[i % shareHoldingPalette.length]"
></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
<!-- Tenure -->
<div class="sa-card" style="--ac: #06b6d4; --ac-bg: rgba(6, 182, 212, 0.07)">
<div class="sa-card-head">
<div class="sa-icon"><mat-icon>history</mat-icon></div>
<span>Member Tenure</span>
</div>
<div class="sa-chart-block">
<div class="cr-outer" style="--cr-glow: #06b6d4">
<div class="cr-glow"></div>
<div
class="cr-ring"
[style.background]="getConicGradient(data.tenureBreakdown, '#06b6d4', tenurePalette)"
(mousemove)="onRingHover($event, data.tenureBreakdown, '#06b6d4', tenurePalette)"
(mouseleave)="onRingLeave()"
>
<div class="cr-hole">
<span class="cr-val">{{ data.totalActiveShareholders }}</span
><span class="cr-sub">total</span>
</div>
</div>
</div>
<div class="sa-legend">
@for (item of data.tenureBreakdown; track item.label; let i = $index) {
<div class="sa-leg-item">
<span class="sa-leg-dot" [style.background]="tenurePalette[i % tenurePalette.length]"></span>
<span class="sa-leg-lbl">{{ item.label }}</span>
<span class="sa-leg-pct">{{ item.percent }}%</span>
</div>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
@if (tooltipState.visible) {
<div class="sa-tooltip" [style.left.px]="tooltipState.x + 14" [style.top.px]="tooltipState.y - 48">
<span class="stt-dot" [style.background]="tooltipState.color"></span>
<span class="stt-lbl">{{ tooltipState.label }}</span>
<span class="stt-sep">·</span>
<span class="stt-pct">{{ tooltipState.percent }}%</span>
<span class="stt-cnt">({{ tooltipState.count }})</span>
</div>
}
@@ -0,0 +1,437 @@
/**
* 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/.
*/
@use 'sass:color';
:host {
display: block;
min-height: 100vh;
background: #f1f5f9;
font-family:
Inter,
system-ui,
-apple-system,
sans-serif;
}
/* ── Page Layout ── */
.sa-page {
max-width: 1400px;
margin: 0 auto;
padding: 24px 32px 48px;
position: relative;
}
/* ── Header ── */
.sa-header {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 36px;
flex-wrap: wrap;
}
.sa-back {
display: flex;
align-items: center;
gap: 2px;
color: #64748b;
text-decoration: none;
font-size: 13px;
padding: 6px 10px 6px 4px;
border-radius: 8px;
transition:
background 0.2s,
color 0.2s;
white-space: nowrap;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
background: rgb(0 0 0 / 5%);
color: #1e293b;
}
}
.sa-title-block {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
min-width: 0;
}
.sa-title-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: #6366f1;
}
.sa-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.4px;
white-space: nowrap;
}
.sa-sub {
margin: 2px 0 0;
font-size: 13px;
color: #64748b;
}
.sa-kpi {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: auto;
}
.sa-kpi-val {
font-size: 28px;
font-weight: 800;
color: #1e293b;
line-height: 1;
}
.sa-kpi-lbl {
font-size: 11px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-top: 4px;
}
/* ── Loading / Error states ── */
.sa-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
color: #94a3b8;
font-size: 14px;
}
.sa-err-icon {
font-size: 40px;
width: 40px;
height: 40px;
color: #ef4444;
}
.ring-spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(99 102 241 / 15%);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── Section ── */
.sa-section {
margin-bottom: 40px;
}
.sa-section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #64748b;
}
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #334155;
}
}
.sa-badge {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: #64748b;
background: #e2e8f0;
border: 1px solid #cbd5e1;
padding: 2px 8px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* ── Grid ── */
.sa-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
/* ── Cards ── */
.sa-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 20px;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 8px 24px rgb(0 0 0 / 8%);
}
}
.sa-card-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
font-size: 13px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sa-icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--ac-bg, #f1f5f9);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--ac, #64748b);
}
}
.sa-no-data {
color: #94a3b8;
font-size: 12px;
text-align: center;
padding: 24px 0;
margin: 0;
}
/* ── Chart block ── */
.sa-chart-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
/* ── Donut ring ── */
.cr-outer {
position: relative;
width: 130px;
height: 130px;
flex-shrink: 0;
&.ring-hovered .cr-glow {
opacity: 1;
transform: scale(1.15);
}
}
.cr-glow {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle, var(--cr-glow, #6366f1) 0%, transparent 70%);
opacity: 0;
filter: blur(12px);
transition:
opacity 0.3s,
transform 0.3s;
pointer-events: none;
}
.cr-ring {
position: absolute;
inset: 0;
border-radius: 50%;
cursor: crosshair;
transition: transform 0.2s;
&:hover {
transform: scale(1.03);
}
}
.cr-hole {
position: absolute;
inset: 15%;
background: #fff;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
}
.cr-val {
font-size: 16px;
font-weight: 800;
color: #1e293b;
line-height: 1;
}
.cr-sub {
font-size: 9px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.cr-highlight {
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
z-index: 3;
filter: brightness(1.15);
&.active {
opacity: 0.55;
}
}
/* ── Legend ── */
.sa-legend {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.sa-leg-item {
display: grid;
grid-template-columns: 10px 1fr auto;
align-items: center;
gap: 8px;
font-size: 12px;
color: #64748b;
}
.sa-leg-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.sa-leg-lbl {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sa-leg-pct {
font-size: 11px;
font-weight: 700;
color: #334155;
}
/* ── Tooltip ── */
.sa-tooltip {
position: fixed;
z-index: 9999;
pointer-events: none;
background: rgb(255 255 255 / 97%);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #1e293b;
box-shadow: 0 4px 20px rgb(0 0 0 / 12%);
white-space: nowrap;
backdrop-filter: blur(12px);
}
.stt-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.stt-lbl {
font-weight: 600;
}
.stt-sep {
color: #cbd5e1;
}
.stt-pct {
color: #6366f1;
font-weight: 700;
}
.stt-cnt {
color: #94a3b8;
}
/* ── Responsive ── */
@media (width <= 768px) {
.sa-page {
padding: 16px;
}
.sa-grid {
grid-template-columns: 1fr;
}
.sa-title {
font-size: 18px;
}
.sa-kpi {
display: none;
}
}
@@ -0,0 +1,220 @@
/**
* 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, 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';
@Component({
selector: 'mifosx-shareholder-analysis',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule
],
templateUrl: './shareholder-analysis.component.html',
styleUrls: ['./shareholder-analysis.component.scss']
})
export class ShareholderAnalysisComponent implements OnInit {
private service = inject(ElectionService);
private route = inject(ActivatedRoute);
electionId!: number;
data: any = null;
loading = true;
error = false;
readonly genderPalette = [
'#3b82f6',
'#a855f7',
'#94a3b8'
];
readonly agePalette = [
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#f97316',
'#94a3b8'
];
readonly religionPalette = [
'#3b82f6',
'#8b5cf6',
'#ef4444',
'#10b981',
'#f59e0b',
'#ec4899',
'#94a3b8'
];
readonly castePalette = [
'#3b82f6',
'#06b6d4',
'#10b981',
'#f59e0b',
'#94a3b8'
];
readonly subCastePalette = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#f97316',
'#94a3b8'
];
readonly occupationPalette = [
'#f59e0b',
'#8b5cf6',
'#ef4444',
'#10b981',
'#94a3b8'
];
readonly educationPalette = [
'#10b981',
'#f59e0b',
'#ef4444',
'#ec4899',
'#94a3b8'
];
readonly maritalPalette = [
'#3b82f6',
'#10b981',
'#ec4899',
'#8b5cf6',
'#94a3b8'
];
readonly shareHoldingPalette = [
'#f59e0b',
'#10b981',
'#ef4444',
'#94a3b8'
];
readonly tenurePalette = [
'#f59e0b',
'#8b5cf6',
'#10b981',
'#94a3b8'
];
readonly areaPalette = [
'#3b82f6',
'#f59e0b',
'#f97316',
'#94a3b8'
];
tooltipState: {
visible: boolean;
label: string;
count: number;
percent: number;
color: string;
x: number;
y: number;
} = { visible: false, label: '', count: 0, percent: 0, color: '', x: 0, y: 0 };
private currentRingOuter: HTMLElement | null = null;
private currentRing: HTMLElement | null = null;
ngOnInit(): void {
this.electionId = +this.route.snapshot.params['electionId'];
this.service.getOverallShareholderAnalysis(this.electionId).subscribe({
next: (d) => {
this.data = d;
this.loading = false;
},
error: () => {
this.loading = false;
this.error = true;
}
});
}
getConicGradient(breakdown: any[], accent: string, palette: string[]): string {
if (!breakdown?.length) return '#e2e8f0';
const parts: string[] = [];
let cum = 0;
breakdown.forEach((item, i) => {
const color = palette[i % palette.length];
const segEnd = cum + item.percent;
parts.push(`${color} ${cum}% ${segEnd}%`);
cum = segEnd;
});
if (cum < 99.5) parts.push(`#e2e8f0 ${cum}% 100%`);
return `conic-gradient(from -90deg, ${parts.join(', ')})`;
}
onRingHover(event: MouseEvent, breakdown: any[], accent: string, palette: string[]): void {
if (!breakdown?.length) return;
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const outerR = rect.width / 2;
const holeR = outerR * 0.42;
const outer = el.parentElement as HTMLElement;
if (outer && outer !== this.currentRingOuter) {
if (this.currentRingOuter) {
this.currentRingOuter.classList.remove('ring-hovered');
this.currentRingOuter.style.removeProperty('--cr-glow');
}
this.currentRingOuter = outer;
this.currentRing = el;
outer.classList.add('ring-hovered');
}
let hl = el.querySelector<HTMLElement>('.cr-highlight');
if (!hl) {
hl = document.createElement('div');
hl.className = 'cr-highlight';
el.insertBefore(hl, el.querySelector('.cr-hole'));
}
if (dist < holeR || dist > outerR) {
hl.classList.remove('active');
this.tooltipState.visible = false;
return;
}
const cssDeg = Math.atan2(dx, -dy) * (180 / Math.PI);
const pct = (((((cssDeg + 90) % 360) + 360) % 360) / 360) * 100;
let cum = 0;
for (let i = 0; i < breakdown.length; i++) {
cum += breakdown[i].percent;
if (pct <= cum + 0.01) {
const segColor = palette[i % palette.length];
const startPct = cum - breakdown[i].percent;
hl.style.background = `conic-gradient(from -90deg, transparent 0% ${startPct}%, ${segColor} ${startPct}% ${cum}%, transparent ${cum}% 100%)`;
hl.classList.add('active');
if (outer) outer.style.setProperty('--cr-glow', segColor);
this.tooltipState = {
visible: true,
label: breakdown[i].label,
count: breakdown[i].count,
percent: breakdown[i].percent,
color: segColor,
x: event.clientX,
y: event.clientY
};
return;
}
}
hl.classList.remove('active');
this.tooltipState.visible = false;
}
onRingLeave(): void {
const hl = this.currentRing?.querySelector<HTMLElement>('.cr-highlight');
if (hl) hl.classList.remove('active');
if (this.currentRingOuter) {
this.currentRingOuter.classList.remove('ring-hovered');
this.currentRingOuter.style.removeProperty('--cr-glow');
this.currentRingOuter = null;
}
this.currentRing = null;
this.tooltipState.visible = false;
}
}
@@ -89,6 +89,14 @@ const routes: Routes = [
(m) => m.CandidateAnalysisComponent
),
data: { title: 'Candidate Voter Analysis' }
},
{
path: ':electionId/shareholders/analysis',
loadComponent: () =>
import('./components/shareholder-analysis/shareholder-analysis.component').then(
(m) => m.ShareholderAnalysisComponent
),
data: { title: 'Overall Shareholder Analysis' }
}
]
}
+4
View File
@@ -163,6 +163,10 @@ export class ElectionService {
.pipe(catchError(() => of(null)));
}
getOverallShareholderAnalysis(electionId: number): Observable<any> {
return this.http.get<any>(`/election/${electionId}/shareholders/analysis`).pipe(catchError(() => of(null)));
}
getAuditLog(
electionId: number,
params?: { entityType?: string; action?: string; search?: string; page?: number; size?: number }