-
Notifications
You must be signed in to change notification settings - Fork 889
WEB-887: Add Polar Area Chart support to report visualization. #3426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,10 +7,11 @@ | |
| */ | ||
|
|
||
| /** Angular Imports */ | ||
| import { Component, OnChanges, Input, inject } from '@angular/core'; | ||
| import { Component, OnChanges, OnInit, OnDestroy, Input, inject } from '@angular/core'; | ||
|
|
||
| /** Custom Services */ | ||
| import { ReportsService } from '../../reports.service'; | ||
| import { ThemeStorageService } from 'app/shared/theme-picker/theme-storage.service'; | ||
|
|
||
| /** Custom Models */ | ||
| import { ChartData } from '../../common-models/chart-data.model'; | ||
|
|
@@ -38,8 +39,11 @@ Chart.register(...registerables); | |
| NgStyle | ||
| ] | ||
| }) | ||
| export class ChartComponent implements OnChanges { | ||
| export class ChartComponent implements OnChanges, OnInit, OnDestroy { | ||
| private reportsService = inject(ReportsService); | ||
| private themeStorageService = inject(ThemeStorageService); | ||
| private resizeTimeoutId: ReturnType<typeof setTimeout> | undefined; | ||
| private initialRenderTimeoutId: ReturnType<typeof setTimeout> | undefined; | ||
|
|
||
| /** Run Report Data */ | ||
| @Input() dataObject: any; | ||
|
|
@@ -50,6 +54,52 @@ export class ChartComponent implements OnChanges { | |
| hideOutput = true; | ||
| /** Data object for witching charts in view. */ | ||
| inputData: ChartData; | ||
| /** Tracks the currently selected chart type */ | ||
| selectedChartType: string = 'Pie'; | ||
| /** Resize listener */ | ||
| private readonly resizeListener = () => this.resizeChart(); | ||
|
|
||
| /** | ||
| * Initialize component and add resize listener. | ||
| */ | ||
| ngOnInit() { | ||
| window.addEventListener('resize', this.resizeListener); | ||
| } | ||
|
|
||
| /** | ||
| * Clean up on component destroy. | ||
| */ | ||
| ngOnDestroy() { | ||
| window.removeEventListener('resize', this.resizeListener); | ||
| if (this.resizeTimeoutId !== undefined) { | ||
| clearTimeout(this.resizeTimeoutId); | ||
| this.resizeTimeoutId = undefined; | ||
| } | ||
| if (this.initialRenderTimeoutId !== undefined) { | ||
| clearTimeout(this.initialRenderTimeoutId); | ||
| this.initialRenderTimeoutId = undefined; | ||
| } | ||
| if (this.chart) { | ||
| this.chart.destroy(); | ||
| this.chart = undefined; | ||
| } | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Resize and redraw chart when window size changes. | ||
| */ | ||
| resizeChart() { | ||
| if (this.chart) { | ||
| if (this.resizeTimeoutId !== undefined) { | ||
| clearTimeout(this.resizeTimeoutId); | ||
| } | ||
| // Debounce resize calls to avoid queuing multiple chart operations. | ||
| this.resizeTimeoutId = setTimeout(() => { | ||
| this.chart?.resize(); | ||
| this.resizeTimeoutId = undefined; | ||
| }, 100); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Fetches run report data post changes in run report form. | ||
|
|
@@ -63,19 +113,59 @@ export class ChartComponent implements OnChanges { | |
| .getChartRunReportData(this.dataObject.report.name, this.dataObject.formData) | ||
| .subscribe((response: ChartData) => { | ||
| this.inputData = response; | ||
| this.setPieChart(this.inputData); | ||
| this.selectedChartType = 'Pie'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this always a Pie Chart? or is this just the default?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The API response does not provide a chart type field, so the component chooses Pie as the initial default for first render to keep behavior unchanged from the previous implementation. It is not always Pie: users can switch to Bar or Polar using the chart toggle after data loads. If we want backend-driven chart selection later, we can add a chartType attribute in the API contract and use that instead of a UI default. {
"columnHeaders": [
{
"columnName": "office_name",
"columnType": "VARCHAR",
"columnDisplayType": "STRING",
"isColumnNullable": false,
"isColumnPrimaryKey": false,
"isColumnUnique": false,
"isColumnIndexed": false,
"columnValues": []
},
{
"columnName": "total_loans",
"columnType": "BIGINT",
"columnDisplayType": "INTEGER",
"isColumnNullable": false,
"isColumnPrimaryKey": false,
"isColumnUnique": false,
"isColumnIndexed": false,
"columnValues": []
},
{
"columnName": "total_principal",
"columnType": "DECIMAL",
"columnDisplayType": "DECIMAL",
"isColumnNullable": false,
"isColumnPrimaryKey": false,
"isColumnUnique": false,
"isColumnIndexed": false,
"columnValues": []
},
{
"columnName": "active_loans",
"columnType": "BIGINT",
"columnDisplayType": "INTEGER",
"isColumnNullable": false,
"isColumnPrimaryKey": false,
"isColumnUnique": false,
"isColumnIndexed": false,
"columnValues": []
}
],
"data": [
{
"row": [
"New Branch Name",
2,
28000,
2
]
},
{
"row": [
"West Sub-Branch",
1,
12000,
1
]
},
{
"row": [
"East Sub-Branch",
1,
4000,
1
]
}
]
} |
||
| this.hideOutput = false; | ||
| if (this.initialRenderTimeoutId !== undefined) { | ||
| clearTimeout(this.initialRenderTimeoutId); | ||
| } | ||
| this.initialRenderTimeoutId = setTimeout(() => { | ||
| this.setPieChart(response); | ||
| this.initialRenderTimeoutId = undefined; | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Handles chart type selection and renders the selected chart. | ||
| * @param {string} chartType The type of chart to display | ||
| */ | ||
| selectChart(chartType: string) { | ||
| if (!this.inputData) { | ||
| return; | ||
| } | ||
| const chartColors = this.randomColorArray(this.inputData.values.length); | ||
| this.selectedChartType = chartType; | ||
| switch (chartType) { | ||
| case 'Bar': | ||
| this.setBarChart(this.inputData, chartColors); | ||
| break; | ||
| case 'Pie': | ||
| this.setPieChart(this.inputData, chartColors); | ||
| break; | ||
| case 'Polar': | ||
| this.setPolarAreaChart(this.inputData, chartColors); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Refreshes colors when user clicks the currently selected toggle. | ||
| */ | ||
| refreshChartIfSameType(chartType: string) { | ||
| if (chartType === this.selectedChartType) { | ||
| this.selectChart(chartType); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Creates instance of chart.js pie chart. | ||
| * Refer: https://www.chartjs.org/docs/latest/charts/doughnut.html for configuration details. | ||
| */ | ||
| setPieChart(inputData: ChartData) { | ||
| setPieChart(inputData: ChartData, chartColors?: string[]) { | ||
| if (this.chart) { | ||
| this.chart.destroy(); | ||
| } | ||
| const colors = chartColors ?? this.randomColorArray(inputData.values.length); | ||
| this.chart = new Chart('output', { | ||
| type: 'pie', | ||
| data: { | ||
|
|
@@ -84,11 +174,13 @@ export class ChartComponent implements OnChanges { | |
| { | ||
| label: inputData.valuesLabel, | ||
| data: inputData.values, | ||
| backgroundColor: this.randomColorArray(inputData.values.length) | ||
| backgroundColor: colors | ||
| } | ||
| ] | ||
| }, | ||
| options: { | ||
| responsive: true, | ||
| maintainAspectRatio: false, | ||
| plugins: { | ||
| title: { | ||
| display: true, | ||
|
|
@@ -103,10 +195,11 @@ export class ChartComponent implements OnChanges { | |
| * Creates instance of chart.js bar chart. | ||
| * Refer: https://www.chartjs.org/docs/latest/charts/bar.html for configuration details. | ||
| */ | ||
| setBarChart(inputData: ChartData) { | ||
| setBarChart(inputData: ChartData, chartColors?: string[]) { | ||
| if (this.chart) { | ||
| this.chart.destroy(); | ||
| } | ||
| const colors = chartColors ?? this.randomColorArray(inputData.values.length); | ||
| this.chart = new Chart('output', { | ||
| type: 'bar', | ||
| data: { | ||
|
|
@@ -115,11 +208,13 @@ export class ChartComponent implements OnChanges { | |
| { | ||
| label: inputData.valuesLabel, | ||
| data: inputData.values, | ||
| backgroundColor: this.randomColorArray(inputData.values.length) | ||
| backgroundColor: colors | ||
| } | ||
| ] | ||
| }, | ||
| options: { | ||
| responsive: true, | ||
| maintainAspectRatio: false, | ||
| plugins: { | ||
| legend: { display: false } | ||
| }, | ||
|
|
@@ -138,12 +233,53 @@ export class ChartComponent implements OnChanges { | |
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Creates instance of chart.js polar area chart. | ||
| * Refer: https://www.chartjs.org/docs/latest/charts/polar.html for configuration details. | ||
| */ | ||
| setPolarAreaChart(inputData: ChartData, chartColors?: string[]) { | ||
| if (this.chart) { | ||
| this.chart.destroy(); | ||
| } | ||
| const colors = chartColors ?? this.randomColorArray(inputData.values.length); | ||
| this.chart = new Chart('output', { | ||
| type: 'polarArea', | ||
| data: { | ||
| labels: inputData.keys, | ||
| datasets: [ | ||
| { | ||
| label: inputData.valuesLabel, | ||
| data: inputData.values, | ||
| backgroundColor: colors, | ||
| borderColor: colors | ||
| } | ||
Omar-Nabil2 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ] | ||
| }, | ||
| options: { | ||
| responsive: true, | ||
| maintainAspectRatio: false, | ||
| plugins: { | ||
| title: { | ||
| display: true, | ||
| text: inputData.keysLabel | ||
| }, | ||
| legend: { display: true } | ||
| }, | ||
| scales: { | ||
| r: { | ||
| min: 0 | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Generates bar/pie-slice colors array for dynamic charts. | ||
| * @param {number} length Length of dataset array. | ||
| */ | ||
| randomColorArray(length: number) { | ||
| const colorArray: any[] = []; | ||
| const colorArray: string[] = []; | ||
| while (length--) { | ||
| const color = this.randomColor(); | ||
| colorArray.push(color); | ||
|
|
@@ -152,12 +288,66 @@ export class ChartComponent implements OnChanges { | |
| } | ||
|
|
||
| /** | ||
| * Returns a random rgb color. | ||
| * Returns a semi-random color based on the active theme palette. | ||
| */ | ||
| randomColor() { | ||
| const r = Math.floor(Math.random() * 255); | ||
| const g = Math.floor(Math.random() * 255); | ||
| const b = Math.floor(Math.random() * 255); | ||
| return `rgb(${r},${g},${b},0.6)`; | ||
| const baseColors = this.getThemeBaseColors(); | ||
| const baseColor = baseColors[Math.floor(Math.random() * baseColors.length)]; | ||
| const variation = Math.floor(Math.random() * 61) - 30; | ||
| const [ | ||
| r, | ||
| g, | ||
| b | ||
| ] = this.hexToRgb(baseColor).map((channel) => this.clamp(channel + variation)); | ||
| return `rgba(${r},${g},${b},0.6)`; | ||
| } | ||
|
|
||
| /** | ||
| * Derives chart base colors from the user's selected theme and current dark mode. | ||
| */ | ||
| private getThemeBaseColors(): string[] { | ||
| const savedTheme = this.themeStorageService.getTheme(); | ||
| const primary = savedTheme?.primary || '#1074B9'; | ||
| const accent = savedTheme?.accent || '#B4D575'; | ||
| const isDark = document.body.classList.contains('dark-theme'); | ||
|
|
||
| if (isDark) { | ||
| return [ | ||
| primary, | ||
| accent, | ||
| '#5BA2EC', | ||
| '#83A447', | ||
| '#C4C6D0' | ||
| ]; | ||
| } | ||
|
|
||
| return [ | ||
| primary, | ||
| accent, | ||
| '#004989', | ||
| '#E7FFA5', | ||
| '#6E8A3B' | ||
| ]; | ||
| } | ||
|
|
||
| private hexToRgb(hexColor: string): number[] { | ||
| const hex = hexColor.replace('#', ''); | ||
| const normalized = | ||
| hex.length === 3 | ||
| ? hex | ||
| .split('') | ||
| .map((char) => char + char) | ||
| .join('') | ||
| : hex; | ||
| const numeric = parseInt(normalized, 16); | ||
| return [ | ||
| (numeric >> 16) & 255, | ||
| (numeric >> 8) & 255, | ||
| numeric & 255 | ||
| ]; | ||
| } | ||
|
|
||
| private clamp(value: number): number { | ||
| return Math.max(0, Math.min(255, value)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
window.addEventListener()returnsvoid, so assigning its return value toresizeListenermeansremoveEventListener()inngOnDestroy()will never remove the handler (leaking the listener and continuing to callresizeChart()after destroy). Store the actual callback function (or use anAbortController/RxJSfromEventwith teardown) and consider debouncing/throttling resize events to avoid queuing manysetTimeout()calls during continuous resize.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done