From a915259ba1477beaeb3b50b59c0dc46c9036f52e Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Tue, 30 Jun 2026 18:31:40 +0530 Subject: [PATCH] feat(election): candidate analysis enhancements + overall shareholder analysis page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../candidate-analysis.component.html | 667 ++++++++-------- .../candidate-analysis.component.scss | 543 ++++++++++--- .../candidate-analysis.component.ts | 725 ++++++++++-------- .../candidate-board.component.html | 3 + .../candidate-board.component.scss | 15 + .../shareholder-analysis.component.html | 469 +++++++++++ .../shareholder-analysis.component.scss | 437 +++++++++++ .../shareholder-analysis.component.ts | 220 ++++++ src/app/election/election-routing.module.ts | 8 + src/app/election/election.service.ts | 4 + 10 files changed, 2347 insertions(+), 744 deletions(-) create mode 100644 src/app/election/components/shareholder-analysis/shareholder-analysis.component.html create mode 100644 src/app/election/components/shareholder-analysis/shareholder-analysis.component.scss create mode 100644 src/app/election/components/shareholder-analysis/shareholder-analysis.component.ts diff --git a/src/app/election/components/candidate-analysis/candidate-analysis.component.html b/src/app/election/components/candidate-analysis/candidate-analysis.component.html index 2f3de9232..d23ce37e4 100644 --- a/src/app/election/components/candidate-analysis/candidate-analysis.component.html +++ b/src/app/election/components/candidate-analysis/candidate-analysis.component.html @@ -15,9 +15,25 @@
- - chevron_left Back to Candidates - +
+ + chevron_left Back to Candidates + + @if (!loading && data) { + + } +
@if (data) {
@@ -30,7 +46,6 @@ Voter Influence Analysis

{{ data.candidateName }}

- @if (hasCriteria(data)) {
Target Criteria: @@ -114,16 +129,6 @@
Matches candidate
-
-
- account_balance -
-
-
{{ data.activeLoanHolders }}
-
Active Loan Holders
-
With active accounts
-
-
family_restroom @@ -147,43 +152,48 @@
- +
people
Gender Distribution @if (data.candidateGender) { - Candidate: {{ data.candidateGender }} + {{ data.candidateGender }} }
-
-
-
- -
-
- {{ data.totalActiveShareholders }} - total +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.genderBreakdown; track item.label; let i = $index) { -
- - - {{ item.label }} +
+ + {{ item.label }} @if (item.match) { - ★ Match + ★ } - {{ item.percent }}% + {{ item.percent }}%
}
- +
cake
@@ -192,162 +202,201 @@ Age: {{ data.candidateAge }} yrs }
-
-
-
- -
-
- {{ data.totalActiveShareholders }} - total +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.ageBreakdown; track item.label; let i = $index) { -
- - - {{ item.label }} Group +
+ + {{ item.label }} Group @if (item.match) { - ★ Match + ★ } - {{ item.percent }}% + {{ item.percent }}%
}
- +
temple_hindu
Religion Profile
-
- @if (data.religionBreakdown?.length > 0) { -
-
- -
-
- {{ data.totalActiveShareholders }} - total + @if (data.religionBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.religionBreakdown; track item.label; let i = $index) { -
- - {{ item.label }} - {{ item.percent }}% +
+ + {{ item.label }} + @if (item.match) { + ★ + } + + {{ item.percent }}%
}
- } @else { -

No religion data recorded yet.

- } -
+
+ } @else { +

No religion data recorded yet.

+ }
- +
work
Occupation Profile
-
- @if (data.occupationBreakdown?.length > 0) { -
-
- -
-
- {{ data.totalActiveShareholders }} - total + @if (data.occupationBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.occupationBreakdown; track item.label; let i = $index) { -
+
- {{ item.label }} - {{ item.percent }}% + {{ item.label }} + {{ item.percent }}%
}
- } @else { -

No occupation data recorded yet.

- } -
+
+ } @else { +

No occupation data recorded yet.

+ }
- +
school
Education Level
-
- @if (data.educationBreakdown?.length > 0) { -
-
- -
-
- {{ data.totalActiveShareholders }} - total + @if (data.educationBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.educationBreakdown; track item.label; let i = $index) { -
- - {{ item.label }} - {{ item.percent }}% +
+ + {{ item.label }} + {{ item.percent }}%
}
- } @else { -

No education data recorded yet.

- } -
+
+ } @else { +

No education data recorded yet.

+ }
- +
favorite
Marital Status Profile
-
- @if (data.maritalBreakdown?.length > 0) { -
-
- -
-
- {{ data.totalActiveShareholders }} - total + @if (data.maritalBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.maritalBreakdown; track item.label; let i = $index) { -
- - {{ item.label }} - {{ item.percent }}% +
+ + {{ item.label }} + {{ item.percent }}%
}
- } @else { -

No marital status data recorded yet.

- } -
+
+ } @else { +

No marital status data recorded yet.

+ }
@@ -361,106 +410,128 @@
- +
location_on
Area / City Distribution @if (data.candidateArea) { - Candidate: {{ data.candidateArea }} + {{ data.candidateArea }} }
-
-
-
- -
-
- {{ data.totalActiveShareholders }} - total +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.areaBreakdown; track item.label; let i = $index) { -
- - - {{ item.label }} +
+ + {{ item.label }} @if (item.match) { - ★ Match + ★ } - {{ item.percent }}% + {{ item.percent }}%
}
- +
diversity_3
Social Category (Caste) @if (data.candidateCaste) { - Candidate: {{ data.candidateCaste }} + {{ data.candidateCaste }} }
-
-
-
- -
-
- {{ data.casteMatchCount }} - match +
+
+
+
+
+ {{ data.casteMatchCount }} + match +
-
+
@for (item of data.casteBreakdown; track item.label; let i = $index) { -
- - - {{ item.label }} +
+ + {{ item.label }} @if (item.match) { - ★ Match + ★ } - {{ item.percent }}% + {{ item.percent }}%
}
- +
account_tree
Sub-Caste Breakdown @if (data.candidateSubCaste) { - Candidate: {{ data.candidateSubCaste }} + {{ data.candidateSubCaste }} }
- @if (data.subCasteBreakdown?.length > 0) {
-
-
-
- -
-
- {{ data.totalActiveShareholders }} - total +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
@for (item of data.subCasteBreakdown; track item.label; let i = $index) { -
+
+ {{ item.label }} @if (item.match) { ★ Match @@ -487,197 +558,122 @@
savings

Financial & Family Engagement

- 5 Analysis Dimensions + 3 Analysis Dimensions
- +
account_balance_wallet
Share Holding Distribution
-
-
-
- -
-
- {{ data.totalActiveShareholders }} - total +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.shareHoldingBreakdown; track item.label; let i = $index) { -
- - {{ item.label }} - {{ item.percent }}% +
+ + {{ item.label }} + {{ item.percent }}%
}
- +
history
Member Tenure
-
-
-
- -
-
- {{ data.totalActiveShareholders }} - total +
+
+
+
+
+ {{ data.totalActiveShareholders }} + total +
-
+
@for (item of data.tenureBreakdown; track item.label; let i = $index) { -
- - {{ item.label }} - {{ item.percent }}% +
+ + {{ item.label }} + {{ item.percent }}%
}
- -
+ +
-
currency_rupee
- Monthly Income Distribution +
family_restroom
+ Family Voter Influence + {{ data.shareholdersWithFamily }} of {{ data.totalActiveShareholders }} shareholders
-
- @if (data.monthlyIncomeBreakdown?.length > 0) { -
-
- -
-
- {{ data.totalActiveShareholders }} - total -
-
-
- @for (item of data.monthlyIncomeBreakdown; track item.label; let i = $index) { -
- - {{ item.label }} - {{ item.percent }}% -
- } -
- } @else { -

No monthly income data recorded yet.

- } -
-
- - -
-
-
savings
- Savings Account Depth - {{ activeSavingsPct(data) }}% Penetration -
-
- @if (data.savingsBreakdown?.length > 0) { -
-
- -
-
- {{ data.activeSavingsHolders }} - active savings -
-
-
- @for (item of data.savingsBreakdown; track item.label) { -
- - {{ item.label }} - {{ item.percent }}% -
- } -
-
-
-
{{ data.activeSavingsHolders }}
-
Active Savings
-
-
-
- {{ data.totalActiveShareholders - data.activeSavingsHolders }} -
-
No Savings
-
-
-
{{ activeSavingsPct(data) }}%
-
Penetration Rate
-
-
-
- info_outline - High savings penetration means members are financially active and have stronger ties to the - institution — a positive signal for candidate support. 0) { +
+
+
+
+
+ {{ data.shareholdersWithFamily }} + family +
+
- } @else { -

No savings data available.

- } -
-
- - -
-
-
account_balance
- Financial Engagement — Active Loans -
-
-
-
-
- -
-
- {{ activeLoanPct(data) }}% - active -
+
+ @for (item of getFamilyBreakdown(data); track item.label; let i = $index) { +
+ + {{ item.label }} + @if (item.match) { + ★ + } + + {{ item.percent }}% +
+ }
-
-
- @for (item of data.loanHolderBreakdown; track item.label) { -
-
- - {{ item.label }} -
-
{{ item.count }}
-
{{ item.percent }}% of shareholders
-
- } -
-
- info_outline - Members with active loans have an ongoing financial relationship, indicating higher engagement and - interest in institutional decisions. -
-
-
+ } @else { +

No family member records linked to this candidate.

+ }
@@ -691,7 +687,6 @@
-
hub
@@ -705,7 +700,7 @@
{{ data.crossMatchCount }}
{{ crossMatchPct(data) }}%
-
Core Base — Same Caste & Same Area
+
Core Base — Same Caste & Same Area
@@ -723,16 +718,8 @@
{{ areaMatchPct(data) }}%
Same Area / City (Geographic Proximity)
-
-
- savings -
-
{{ data.activeSavingsHolders }}
-
{{ activeSavingsPct(data) }}%
-
Active Savings Holders (Financially Engaged)
-
-
+
lightbulb The Core Base — voters matching both the candidate's social category AND geographic @@ -746,3 +733,13 @@
}
+ +@if (tooltipState.visible) { +
+ + {{ tooltipState.label }} + · + {{ tooltipState.percent }}% + ({{ tooltipState.count }}) +
+} diff --git a/src/app/election/components/candidate-analysis/candidate-analysis.component.scss b/src/app/election/components/candidate-analysis/candidate-analysis.component.scss index d55c66342..30e4a3055 100644 --- a/src/app/election/components/candidate-analysis/candidate-analysis.component.scss +++ b/src/app/election/components/candidate-analysis/candidate-analysis.component.scss @@ -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; + } +} diff --git a/src/app/election/components/candidate-analysis/candidate-analysis.component.ts b/src/app/election/components/candidate-analysis/candidate-analysis.component.ts index 2a20e7626..ceb71d462 100644 --- a/src/app/election/components/candidate-analysis/candidate-analysis.component.ts +++ b/src/app/election/components/candidate-analysis/candidate-analysis.component.ts @@ -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('.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('.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 { + 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(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(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((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('.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('.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; + } + } } diff --git a/src/app/election/components/candidate-board/candidate-board.component.html b/src/app/election/components/candidate-board/candidate-board.component.html index dc3894679..88d4a6142 100644 --- a/src/app/election/components/candidate-board/candidate-board.component.html +++ b/src/app/election/components/candidate-board/candidate-board.component.html @@ -15,6 +15,9 @@

{{ election.title }}

}
+ + analytics Overall Analysis + list Nominations diff --git a/src/app/election/components/candidate-board/candidate-board.component.scss b/src/app/election/components/candidate-board/candidate-board.component.scss index 9d74b694b..9c8c7db47 100644 --- a/src/app/election/components/candidate-board/candidate-board.component.scss +++ b/src/app/election/components/candidate-board/candidate-board.component.scss @@ -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; + } } } diff --git a/src/app/election/components/shareholder-analysis/shareholder-analysis.component.html b/src/app/election/components/shareholder-analysis/shareholder-analysis.component.html new file mode 100644 index 000000000..aafef3c24 --- /dev/null +++ b/src/app/election/components/shareholder-analysis/shareholder-analysis.component.html @@ -0,0 +1,469 @@ + +
+ +
+ + chevron_left Back to Candidates + +
+ analytics +
+

Overall Shareholder Analysis

+

Complete demographic profile of all active shareholders

+
+
+ @if (!loading && data) { +
+ {{ data.totalActiveShareholders }} + Total Active Shareholders +
+ } +
+ + @if (loading) { +
+
+

Loading shareholder analysis...

+
+ } @else if (error || !data) { +
+ error_outline +

Analysis not available.

+
+ } @else { + +
+
+ people_outline +

Demographic Profile

+ 6 Dimensions +
+
+ +
+
+
people
+ Gender Distribution +
+
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.genderBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+
+ + +
+
+
cake
+ Age Groups +
+
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.ageBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+
+ + +
+
+
temple_hindu
+ Religion Profile +
+ @if (data.religionBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.religionBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+ } @else { +

No religion data.

+ } +
+ + +
+
+
work
+ Occupation Profile +
+ @if (data.occupationBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.occupationBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+ } @else { +

No occupation data.

+ } +
+ + +
+
+
school
+ Education Level +
+ @if (data.educationBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.educationBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+ } @else { +

No education data.

+ } +
+ + +
+
+
favorite
+ Marital Status +
+ @if (data.maritalBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.maritalBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+ } @else { +

No marital data.

+ } +
+
+
+ + +
+
+ public +

Social & Geographic Profile

+ 3 Dimensions +
+
+ +
+
+
location_on
+ Area / City +
+
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.areaBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+
+ + +
+
+
diversity_3
+ Social Category +
+
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.casteBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+
+ + +
+
+
account_tree
+ Sub-Caste (Surname) +
+ @if (data.subCasteBreakdown?.length > 0) { +
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.subCasteBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+ } @else { +

No sub-caste data.

+ } +
+
+
+ + +
+
+ savings +

Financial Profile

+ 2 Dimensions +
+
+ +
+
+
account_balance_wallet
+ Share Holding +
+
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.shareHoldingBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+
+ + +
+
+
history
+ Member Tenure +
+
+
+
+
+
+ {{ data.totalActiveShareholders }}total +
+
+
+
+ @for (item of data.tenureBreakdown; track item.label; let i = $index) { +
+ + {{ item.label }} + {{ item.percent }}% +
+ } +
+
+
+
+
+ } +
+ +@if (tooltipState.visible) { +
+ + {{ tooltipState.label }} + · + {{ tooltipState.percent }}% + ({{ tooltipState.count }}) +
+} diff --git a/src/app/election/components/shareholder-analysis/shareholder-analysis.component.scss b/src/app/election/components/shareholder-analysis/shareholder-analysis.component.scss new file mode 100644 index 000000000..b875263cd --- /dev/null +++ b/src/app/election/components/shareholder-analysis/shareholder-analysis.component.scss @@ -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; + } +} diff --git a/src/app/election/components/shareholder-analysis/shareholder-analysis.component.ts b/src/app/election/components/shareholder-analysis/shareholder-analysis.component.ts new file mode 100644 index 000000000..b71a7a05d --- /dev/null +++ b/src/app/election/components/shareholder-analysis/shareholder-analysis.component.ts @@ -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('.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('.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; + } +} diff --git a/src/app/election/election-routing.module.ts b/src/app/election/election-routing.module.ts index af5faf3a5..df877bb9d 100644 --- a/src/app/election/election-routing.module.ts +++ b/src/app/election/election-routing.module.ts @@ -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' } } ] } diff --git a/src/app/election/election.service.ts b/src/app/election/election.service.ts index fa15b3da7..ed1ed365d 100644 --- a/src/app/election/election.service.ts +++ b/src/app/election/election.service.ts @@ -163,6 +163,10 @@ export class ElectionService { .pipe(catchError(() => of(null))); } + getOverallShareholderAnalysis(electionId: number): Observable { + return this.http.get(`/election/${electionId}/shareholders/analysis`).pipe(catchError(() => of(null))); + } + getAuditLog( electionId: number, params?: { entityType?: string; action?: string; search?: string; page?: number; size?: number }