Skip to content

Commit 7bb376e

Browse files
authored
feat: #862 analytics dashboard district enhancement (#876)
1 parent 6be1ba5 commit 7bb376e

File tree

9 files changed

+310
-46
lines changed

9 files changed

+310
-46
lines changed

admin/src/app/analytics-dashboard/analytics-dashboard-chart-config.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ApexAxisChartSeries, ApexChart, ApexDataLabels, ApexFill, ApexGrid, ApexPlotOptions, ApexTheme, ApexTitleSubtitle, ApexXAxis, ApexYAxis, ChartType } from 'ng-apexcharts';
1+
import { ApexAxisChartSeries, ApexChart, ApexDataLabels, ApexFill, ApexGrid, ApexLegend, ApexPlotOptions, ApexTheme, ApexTitleSubtitle, ApexXAxis, ApexYAxis, ChartType } from 'ng-apexcharts';
22

33
export type ChartOptions = {
44
series: ApexAxisChartSeries;
@@ -11,14 +11,40 @@ export type ChartOptions = {
1111
title: ApexTitleSubtitle;
1212
theme: ApexTheme;
1313
grid: ApexGrid;
14+
legend: ApexLegend;
1415
};
1516

1617
const COLOR_LABEL = "#304758";
1718
const COLOR_FILL_1 = "#123B64";
1819
const COLOR_FILL_2 = "#52AE1E";
1920
const COLOR_FILL_3 = "#2576C8";
2021
const COLOR_FILL_4 = "#775DD0";
22+
const COLOR_FILL_5 = "#005F73";
23+
const COLOR_FILL_6 = "#E9D8A6";
24+
const COLOR_FILL_7 = "#EE9B00";
25+
const COLOR_FILL_8 = "#CA6702";
26+
const COLOR_FILL_9 = "#3A0CA3";
27+
2128
const COLOR_GRID_ROW_1 = "#f3f3f3";
29+
const COLOR_FILL_DST_CMMT_TOTAL = COLOR_FILL_9;
30+
31+
// Map response codes to display labels
32+
export const RESPONSE_CODE_LABELS = {
33+
'CONSIDERED': 'Considered',
34+
'ADDRESSED': 'Addressed',
35+
'IRRELEVANT': 'Not Applicable',
36+
'NOT_CATEGORIZED': 'Not Categorized',
37+
'TOTAL': 'Total'
38+
};
39+
40+
// Map response codes to colors
41+
export const RESPONSE_CODE_COLORS = {
42+
'CONSIDERED': COLOR_FILL_8,
43+
'ADDRESSED': COLOR_FILL_7,
44+
'IRRELEVANT': COLOR_FILL_6,
45+
'NOT_CATEGORIZED': COLOR_FILL_5,
46+
'TOTAL': COLOR_FILL_DST_CMMT_TOTAL
47+
};
2248

2349
/* *** Some utility functions to help with chart configuration and display *** */
2450
export const maxAxis = (series) => {
@@ -189,6 +215,80 @@ export const topCommentedProjectsChartOptions = {
189215
},
190216
};
191217

218+
// Chart options for: Comments by district and category
219+
export const commentsByDistrictChartOptions = {
220+
title: {
221+
text: "Public comments by district and category"
222+
},
223+
series: [
224+
// Multiple series will be added dynamically - one for each category + total
225+
],
226+
chart: {
227+
type: 'bar' as ChartType,
228+
stacked: false, // Grouped bars, not stacked
229+
} as ApexChart,
230+
theme: {
231+
mode: 'light',
232+
} as ApexTheme,
233+
plotOptions: {
234+
bar: {
235+
horizontal: true,
236+
barHeight: '70%',
237+
dataLabels: {
238+
position: 'right',
239+
}
240+
},
241+
},
242+
dataLabels: {
243+
enabled: true,
244+
position: 'right',
245+
formatter: function(val: number) {
246+
return '\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0' + val;
247+
},
248+
style: {
249+
fontSize: "12px",
250+
colors: [COLOR_LABEL]
251+
}
252+
} as ApexDataLabels,
253+
xaxis: {
254+
categories: [],
255+
title: {
256+
text: "Number of comments",
257+
style: {
258+
cssClass: "chart-title-label"
259+
}
260+
},
261+
min: 0,
262+
} as ApexXAxis,
263+
yaxis: {
264+
title: {
265+
text: "District",
266+
style: {
267+
cssClass: "chart-title-label"
268+
}
269+
},
270+
labels: {
271+
show: true,
272+
align: 'left',
273+
maxWidth: 400,
274+
}
275+
} as ApexYAxis,
276+
fill: {
277+
opacity: 1,
278+
},
279+
grid: {
280+
row: {
281+
colors: [COLOR_GRID_ROW_1, "transparent"],
282+
opacity: 0.5
283+
},
284+
},
285+
legend: {
286+
show: true,
287+
position: 'top' as const,
288+
horizontalAlign: 'left' as const,
289+
}
290+
};
291+
192292
// Chart options for: FOMs published for public comments in each district
193293
export const fomsCountByDistrictChartOptions = {
194294
title: {

admin/src/app/analytics-dashboard/analytics-dashboard-data.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ANALYTICS_DATA_DEFAULT_SIZE } from '@admin-core/utils/constants';
22
import { Injectable } from '@angular/core';
3-
import { AnalyticsDashboardService, ProjectCountByDistrictResponse, ProjectCountByForestClientResponse, ProjectPlanCodeFilterEnum, PublicCommentCountByProjectResponse } from '@api-client';
3+
import { AnalyticsDashboardService, ProjectCountByDistrictResponse, ProjectCountByForestClientResponse, ProjectPlanCodeFilterEnum, PublicCommentCountByDistrictResponse, PublicCommentCountByProjectResponse } from '@api-client';
44
import { forkJoin, of } from 'rxjs';
55
import { catchError, map } from 'rxjs/operators';
66

@@ -12,6 +12,7 @@ export type AnalyticsDashboardData = {
1212
nonInitialPublishedProjectCount: number | ApiError // Total FOM count
1313
commentCountByResponseCode: Record<string, number> | ApiError
1414
topCommentedProjects: Array<PublicCommentCountByProjectResponse> | ApiError
15+
commentCountByDistrict: Array<PublicCommentCountByDistrictResponse> | ApiError
1516
nonInitialPublishedProjectCountByDistrict: Array<ProjectCountByDistrictResponse> | ApiError
1617
uniqueForestClientCount: number | ApiError
1718
nonInitialPublishedProjectCountByForestClient: Array<ProjectCountByForestClientResponse> | ApiError
@@ -40,6 +41,9 @@ export class AnalyticsDashboardDataService {
4041
topCommentedProjects: this.api.analyticsDashboardControllerGetTopCommentedProjects(startDate, endDate, projectPlanCode, limit).pipe(
4142
catchError(err => this.handleApiError('topCommentedProjects', err))
4243
),
44+
commentCountByDistrict: this.api.analyticsDashboardControllerGetCommentCountByDistrict(startDate, endDate, projectPlanCode).pipe(
45+
catchError(err => this.handleApiError('commentCountByDistrict', err))
46+
),
4347
nonInitialPublishedProjectCountByDistrict: this.api.analyticsDashboardControllerGetNonInitialPublishedProjectCountByDistrict(startDate, endDate, projectPlanCode).pipe(
4448
catchError(err => this.handleApiError('nonInitialPublishedProjectCountByDistrict', err))
4549
),

admin/src/app/analytics-dashboard/analytics-dashboard.component.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,38 @@ <h1 class="text-muted mb-0">Dashboard</h1>
151151
</div>
152152
</div>
153153
</div>
154+
<div class="row">
155+
<div>
156+
<div id="comments-by-district" class="mt-3">
157+
<!-- options: first option is 'All districts' then individual districts collected from backend -->
158+
<!-- No show if there is only one district currently: ('All districts', 'district 1') -->
159+
@if (districtFilterOptions.length > 2) {
160+
<div class="mb-2" style="width: 20%;">
161+
<label for="district-select">Show</label>
162+
<select id="district-select" class="form-control" name="district-select"
163+
[(ngModel)]="selectedDistrict"
164+
(ngModelChange)="onDistrictFilterChange($event)">
165+
<option *ngFor="let opt of districtFilterOptions" [ngValue]="opt.value">{{ opt.label }}</option>
166+
</select>
167+
</div>
168+
}
169+
<apx-chart
170+
#commentsByDistrictChart
171+
[series]="commentsByDistrictChartOptions.series"
172+
[chart]="commentsByDistrictChartOptions.chart"
173+
[dataLabels]="commentsByDistrictChartOptions.dataLabels"
174+
[plotOptions]="commentsByDistrictChartOptions.plotOptions"
175+
[xaxis]="commentsByDistrictChartOptions.xaxis"
176+
[yaxis]="commentsByDistrictChartOptions.yaxis"
177+
[theme]="commentsByDistrictChartOptions.theme"
178+
[fill]="commentsByDistrictChartOptions.fill"
179+
[title]="commentsByDistrictChartOptions.title"
180+
[grid]="commentsByDistrictChartOptions.grid"
181+
[legend]="commentsByDistrictChartOptions.legend"
182+
></apx-chart>
183+
</div>
184+
</div>
185+
</div>
154186
</div>
155187

156188
<!-- Forest client activity -->

admin/src/app/analytics-dashboard/analytics-dashboard.component.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { MatFormFieldModule } from '@angular/material/form-field';
77
import { MatSelectModule } from '@angular/material/select';
88
import { ActivatedRoute } from '@angular/router';
99
import { ProjectPlanCodeFilterEnum, ResponseCodeEnum } from '@api-client';
10-
import { ChartOptions, commentsByResponseCodeChartOptions, fomsCountByDistrictChartOptions, fomsCountByForestClientChartOptions, maxAxis, maxAxis as maxxAxis, topCommentedProjectsChartOptions } from 'app/analytics-dashboard/analytics-dashboard-chart-config';
10+
import { ChartOptions, commentsByDistrictChartOptions, commentsByResponseCodeChartOptions, fomsCountByDistrictChartOptions, fomsCountByForestClientChartOptions, maxAxis, maxAxis as maxxAxis, RESPONSE_CODE_COLORS, RESPONSE_CODE_LABELS, topCommentedProjectsChartOptions } from 'app/analytics-dashboard/analytics-dashboard-chart-config';
1111
import { AnalyticsDashboardData, AnalyticsDashboardDataService, ApiError } from 'app/analytics-dashboard/analytics-dashboard-data.service';
1212
import { DateTime } from 'luxon';
1313
import {
14-
ChartComponent,
15-
NgApexchartsModule
14+
ChartComponent,
15+
NgApexchartsModule
1616
} from 'ng-apexcharts';
1717
import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
1818

@@ -50,19 +50,23 @@ export class AnalyticsDashboardComponent implements OnInit, AfterViewInit {
5050
];
5151
selectedPlan: ProjectPlanCodeFilterEnum = this.planFilterOptions[0]?.value;
5252
selectedFcLimit: number = this.fcLimitOptions[0].value; // default
53+
districtFilterOptions: Array<{ value: number | null, label: string }> = [];
54+
selectedDistrict: number | null = null; // null means 'All districts'
5355
minDate: Date = DateTime.fromISO(FOM_GO_LIVE_DATE).startOf('day').toJSDate();
5456
maxDate: Date = new Date(); // today
5557

5658
// chart Angular views
5759
@ViewChild("commentsByResponseCodeChart") commentsByResponseCodeChart!: ChartComponent;
5860
@ViewChild("topCommentedProjectsChart") topCommentedProjectsChart!: ChartComponent;
5961
@ViewChild("fomsCountByDistrictChart") fomsCountByDistrictChart!: ChartComponent;
62+
@ViewChild("commentsByDistrictChart") commentsByDistrictChart!: ChartComponent;
6063
@ViewChild("fomsCountByForestClientChart") fomsCountByForestClientChart!: ChartComponent;
6164

6265
// chart options
6366
commentsByResponseCodeChartOptions: Partial<ChartOptions>;
6467
topCommentedProjectsChartOptions: Partial<ChartOptions>;
6568
fomsCountByDistrictChartOptions: Partial<ChartOptions>;
69+
commentsByDistrictChartOptions: Partial<ChartOptions>;
6670
fomsCountByForestClientChartOptions: Partial<ChartOptions>;
6771

6872
constructor(
@@ -73,6 +77,7 @@ export class AnalyticsDashboardComponent implements OnInit, AfterViewInit {
7377
this.commentsByResponseCodeChartOptions = commentsByResponseCodeChartOptions;
7478
this.topCommentedProjectsChartOptions = topCommentedProjectsChartOptions;
7579
this.fomsCountByDistrictChartOptions = fomsCountByDistrictChartOptions;
80+
this.commentsByDistrictChartOptions = commentsByDistrictChartOptions;
7681
this.fomsCountByForestClientChartOptions = fomsCountByForestClientChartOptions;
7782
}
7883

@@ -125,6 +130,11 @@ export class AnalyticsDashboardComponent implements OnInit, AfterViewInit {
125130
this.applyFomsCountByForestClientChartOptions();
126131
}
127132

133+
onDistrictFilterChange(value: number | null) {
134+
this.selectedDistrict = value;
135+
this.applyCommentsByDistrictChartOptions();
136+
}
137+
128138
/**
129139
* Fetch analytics data based on the current filters from backend and apply to chart options.
130140
*/
@@ -146,6 +156,7 @@ export class AnalyticsDashboardComponent implements OnInit, AfterViewInit {
146156
this.applyCommentsByResponseCodeChartOptions();
147157
this.applyTopCommentedProjectsChartOptions();
148158
this.applyFomsCountByDistrictChartOptions();
159+
this.applyCommentsByDistrictChartOptions();
149160
this.applyFomsCountByForestClientChartOptions();
150161
}
151162

@@ -215,6 +226,75 @@ export class AnalyticsDashboardComponent implements OnInit, AfterViewInit {
215226
}
216227
}
217228

229+
applyCommentsByDistrictChartOptions() {
230+
const apiData = this.analyticsData().commentCountByDistrict;
231+
if (apiData && !(apiData instanceof ApiError)) {
232+
// Update district filter options
233+
this.districtFilterOptions = [
234+
{ value: null, label: 'All districts' },
235+
...apiData.map(item => ({ value: item.districtId, label: item.districtName }))
236+
];
237+
238+
// Filter data based on selected district
239+
const filteredData = this.selectedDistrict === null
240+
? apiData
241+
: apiData.filter(item => item.districtId === this.selectedDistrict);
242+
243+
if (filteredData.length === 0) return;
244+
245+
// Collect all unique response codes across all districts
246+
const allResponseCodes = new Set<string>();
247+
filteredData.forEach(district => {
248+
district.commentCountByCategory.forEach(cat => {
249+
allResponseCodes.add(cat.responseCode);
250+
});
251+
});
252+
253+
// Build series - one for each category + total
254+
const series: any[] = [];
255+
const seriesColors: string[] = [];
256+
257+
// Add series for each category
258+
allResponseCodes.forEach(responseCode => {
259+
const data = filteredData.map(district => {
260+
const category = district.commentCountByCategory.find(c => c.responseCode === responseCode);
261+
return category ? category.publicCommentCount : 0;
262+
});
263+
series.push({
264+
name: RESPONSE_CODE_LABELS[responseCode] || responseCode,
265+
data: data
266+
});
267+
seriesColors.push(RESPONSE_CODE_COLORS[responseCode] || '#999999');
268+
});
269+
270+
// Add Total series
271+
const totalData = filteredData.map(district => district.totalPublicCommentCount);
272+
series.push({
273+
name: 'Total',
274+
data: totalData
275+
});
276+
seriesColors.push(RESPONSE_CODE_COLORS['TOTAL']);
277+
278+
// Calculate max value for axis
279+
const allValues = series.flatMap(s => s.data);
280+
const maxValue = maxxAxis(allValues);
281+
282+
// Update chart
283+
this.commentsByDistrictChart.updateOptions({
284+
series: series,
285+
xaxis: {
286+
categories: filteredData.map(item => item.districtName + "\u00A0\u00A0"),
287+
min: 0,
288+
max: maxValue
289+
},
290+
colors: seriesColors,
291+
chart: {
292+
height: Math.max(300, filteredData.length * 80)
293+
}
294+
});
295+
}
296+
}
297+
218298
applyFomsCountByForestClientChartOptions() {
219299
const apiData = this.analyticsData().nonInitialPublishedProjectCountByForestClient;
220300
if (apiData && !(apiData instanceof ApiError)) {

0 commit comments

Comments
 (0)