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:
co-authored by
Claude Sonnet 4.6
parent
78b87fb6ce
commit
a915259ba1
+332
-335
@@ -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 & 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>
|
||||
}
|
||||
|
||||
+437
-106
@@ -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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user