diff --git a/package-lock.json b/package-lock.json index e41388a8..9c423f45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@auth0/auth0-angular": "^2.2.2", "@ng-select/ng-select": "^12.0.4", "@popperjs/core": "^2.11.8", + "@sgratzl/chartjs-chart-boxplot": "^4.2.8", "angular-auth-oidc-client": "^16.0.1", "bootstrap": "^5.3.2", "bootstrap-icons": "^1.11.2", @@ -3539,6 +3540,22 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@sgratzl/boxplots": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@sgratzl/boxplots/-/boxplots-1.3.0.tgz", + "integrity": "sha512-2BRWv+WOH58pwzSgP50buoXgxQic+4auz3BF0wiIUXS8D3QGkdBNgsNdQO1754Tm/0uEwly0R3WaCiGnoYWcmA==" + }, + "node_modules/@sgratzl/chartjs-chart-boxplot": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@sgratzl/chartjs-chart-boxplot/-/chartjs-chart-boxplot-4.2.8.tgz", + "integrity": "sha512-D4GNB4H1WVqJuvVgATj6GZzDbAKLNIaRGzUNa16+a0QjhiATBm05OfaQM1m9kRUuXs2Hmufi2v8IbHKhAQFALg==", + "dependencies": { + "@sgratzl/boxplots": "^1.3.0" + }, + "peerDependencies": { + "chart.js": "^4.1.1" + } + }, "node_modules/@sigstore/bundle": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz", @@ -18961,6 +18978,19 @@ "jsonc-parser": "3.2.0" } }, + "@sgratzl/boxplots": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@sgratzl/boxplots/-/boxplots-1.3.0.tgz", + "integrity": "sha512-2BRWv+WOH58pwzSgP50buoXgxQic+4auz3BF0wiIUXS8D3QGkdBNgsNdQO1754Tm/0uEwly0R3WaCiGnoYWcmA==" + }, + "@sgratzl/chartjs-chart-boxplot": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@sgratzl/chartjs-chart-boxplot/-/chartjs-chart-boxplot-4.2.8.tgz", + "integrity": "sha512-D4GNB4H1WVqJuvVgATj6GZzDbAKLNIaRGzUNa16+a0QjhiATBm05OfaQM1m9kRUuXs2Hmufi2v8IbHKhAQFALg==", + "requires": { + "@sgratzl/boxplots": "^1.3.0" + } + }, "@sigstore/bundle": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.0.tgz", diff --git a/package.json b/package.json index 0a9faecb..0bf79f8f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@auth0/auth0-angular": "^2.2.2", "@ng-select/ng-select": "^12.0.4", "@popperjs/core": "^2.11.8", + "@sgratzl/chartjs-chart-boxplot": "^4.2.8", "angular-auth-oidc-client": "^16.0.1", "bootstrap": "^5.3.2", "bootstrap-icons": "^1.11.2", diff --git a/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart-object.ts b/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart-object.ts index e5acdf50..40305404 100644 --- a/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart-object.ts +++ b/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart-object.ts @@ -48,7 +48,7 @@ export class CitiesDoughnutChartObject extends Chart { }, options: { aspectRatio: 1, - responsive: false, + responsive: true, plugins: { legend: { position: 'bottom', diff --git a/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart.component.scss b/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart.component.scss index d636884d..b5b84b83 100644 --- a/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart.component.scss +++ b/src/app/modules/salaries/components/cities-doughnut-chart/cities-doughnut-chart.component.scss @@ -6,7 +6,8 @@ canvas { position: relative; width: 100%; height: 100%; - min-height: 300px; + min-height: 250px; + max-height: 500px; } .list-group-item { diff --git a/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart-object.ts b/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart-object.ts index 3defa92b..e84078cd 100644 --- a/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart-object.ts +++ b/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart-object.ts @@ -3,8 +3,9 @@ import { RandomRgbColor } from '../random-rgb-color'; import { UserSalary } from '@models/salaries/salary.model'; import { DeveloperGrade } from '@models/enums'; import { CompanyType } from '@models/salaries/company-type'; +import { BoxPlotChart } from '@sgratzl/chartjs-chart-boxplot'; -export class GradesMinMaxSalariesChartObject extends Chart { +export class GradesMinMaxSalariesChartObject extends BoxPlotChart { static readonly grades: Array<{grade: DeveloperGrade, label: string}> = [ { grade: DeveloperGrade.Junior, label: DeveloperGrade[DeveloperGrade.Junior] }, @@ -37,7 +38,7 @@ export class GradesMinMaxSalariesChartObject extends Chart { }); if (salariesLocal.length > 0) { - datasets.push(new ChartDataset(salariesLocal, 'Казахстанские компании')); + datasets.push(new ChartDataset(salariesLocal, 'Казахстанская компания')); } if (salariesRemote.length > 0) { @@ -47,13 +48,19 @@ export class GradesMinMaxSalariesChartObject extends Chart { super( canvasId, { - type: 'bar', data: { labels: GradesMinMaxSalariesChartObject.grades.map(x => x.label), datasets: datasets, }, options: { + indexAxis: 'y', + maintainAspectRatio: false, responsive: true, + elements: { + boxplot: { + itemRadius: 2, + }, + }, plugins: { legend: { position: 'bottom', @@ -70,27 +77,48 @@ export class GradesMinMaxSalariesChartObject extends Chart { } class ChartDataset { - readonly data: Array<[number, number]>; + readonly data: Array; readonly borderColor: string; readonly backgroundColor: string; readonly borderRadius = 5; readonly borderWidth = 2; + readonly itemRadius = 1; + readonly itemStyle = 'circle'; + readonly itemBackgroundColor = '#000'; constructor(salariesSource: Array, readonly label: string) { const color = new RandomRgbColor(); this.borderColor = color.toString(1); this.backgroundColor = color.toString(0.7); - this.data = GradesMinMaxSalariesChartObject.grades.map(g => { - const salaries = salariesSource.filter(s => s.grade == g.grade); - if (salaries.length == 0) { - return [0, 0]; - } - const min = Math.min(...salaries.map(s => s.value)); - const max = Math.max(...salaries.map(s => s.value)); + this.data = GradesMinMaxSalariesChartObject.grades.map(g => { + const salaries = salariesSource + .filter(s => s.grade == g.grade) + .sort((a, b) => a.value - b.value); - return [min, max]; + return new ChartDatasetItem(salaries); }) } -} \ No newline at end of file +} + +class ChartDatasetItem { + readonly min: number; + readonly q1: number; + readonly median: number; + readonly q3: number; + readonly max: number; + readonly items: Array; + readonly mean: number; + + constructor(salaries: Array) { + this.min = salaries[0].value; + this.max = salaries[salaries.length - 1].value; + this.median = salaries[Math.floor(salaries.length / 2)].value; + this.q1 = salaries[Math.floor(salaries.length / 4)].value; + this.q3 = salaries[Math.floor(salaries.length * 3 / 4)].value; + this.mean = salaries.reduce((a, b) => a + b.value, 0) / salaries.length; + + this.items = salaries.map(s => s.value); + } +} diff --git a/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.html b/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.html index 9b6186e5..cd3051e8 100644 --- a/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.html +++ b/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.html @@ -1,7 +1,9 @@
{{ title }}
-
+
+ Черные точки - это значения зарплаты, которые встречаются в выборке. +
diff --git a/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.scss b/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.scss index d636884d..54db67a6 100644 --- a/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.scss +++ b/src/app/modules/salaries/components/grades-min-max-salaries-chart/grades-min-max-chart.component.scss @@ -1,12 +1,13 @@ canvas { - min-height: 250px; + min-height: 350px; } #canvas-container { position: relative; width: 100%; height: 100%; - min-height: 300px; + min-height: 350px; + max-height: 550px; } .list-group-item { diff --git a/src/app/modules/salaries/components/salaries-chart/salaries-activated-route.ts b/src/app/modules/salaries/components/salaries-chart/salaries-activated-route.ts index a8841019..a2b0b040 100644 --- a/src/app/modules/salaries/components/salaries-chart/salaries-activated-route.ts +++ b/src/app/modules/salaries/components/salaries-chart/salaries-activated-route.ts @@ -1,15 +1,16 @@ import { DeveloperGrade } from "@models/enums"; import { UserProfession } from "@models/salaries/user-profession"; -import { ActivatedRouteExtended, QueryParameter } from "@shared/routes/activated-route-extended"; +import { ActivatedRouteExtended } from "@shared/routes/activated-route-extended"; import { Observable, map } from "rxjs"; import { SalaryChartGlobalFiltersData } from "./salary-chart-global-filters/global-filters-form-group"; import { ActivatedRoute } from "@angular/router"; +import { KazakhstanCity } from "@models/salaries/kazakhstan-city"; export class SalariesChartActivatedRoute { static readonly gradeRouteParamName = 'grade'; static readonly profsIncludeRouteParamName = 'profsInclude'; - static readonly profsExcludeRouteParamName = 'profsExclude'; + static readonly ciiesParamName = 'cities'; private readonly activatedRoute: ActivatedRouteExtended @@ -29,9 +30,9 @@ export class SalariesChartActivatedRoute { queryParams += `${queryParams.length > 1 ? '&' : ''}${profsValue}`; } - if (data.profsToExclude.length > 0) { - const profsExcludeValue = `${SalariesChartActivatedRoute.profsExcludeRouteParamName}=${data.profsToExclude.map(x => x.toString()).join(',')}`; - queryParams += `${queryParams.length > 1 ? '&' : ''}${profsExcludeValue}`; + if (data.cities.length > 0) { + const citiesValue = `${SalariesChartActivatedRoute.ciiesParamName}=${data.cities.map(x => x.toString()).join(',')}`; + queryParams += `${queryParams.length > 1 ? '&' : ''}${citiesValue}`; } return queryParams; @@ -42,7 +43,7 @@ export class SalariesChartActivatedRoute { .getQueryParams([ SalariesChartActivatedRoute.gradeRouteParamName, SalariesChartActivatedRoute.profsIncludeRouteParamName, - SalariesChartActivatedRoute.profsExcludeRouteParamName, + SalariesChartActivatedRoute.ciiesParamName, ]) .pipe(map(queryParams => { const gradeString = queryParams @@ -64,16 +65,16 @@ export class SalariesChartActivatedRoute { profsInclude = profsToIncludeValue.split(',').map(x => Number(x) as UserProfession); } - let profsExclude: Array = []; - const profsToExcludeValue = queryParams - .find(x => x.key === SalariesChartActivatedRoute.profsExcludeRouteParamName) + let cities: Array = []; + const citiesValue = queryParams + .find(x => x.key === SalariesChartActivatedRoute.ciiesParamName) ?.value ?? null; - if (profsToExcludeValue && profsToExcludeValue !== '') { - profsExclude = profsToExcludeValue.split(',').map(x => Number(x) as UserProfession); + if (citiesValue && citiesValue !== '') { + cities = citiesValue.split(',').map(x => Number(x) as KazakhstanCity); } - return new SalaryChartGlobalFiltersData(grade, profsInclude, profsExclude); + return new SalaryChartGlobalFiltersData(grade, profsInclude, cities); })); } } \ No newline at end of file diff --git a/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.html b/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.html index 8c0c0028..47909254 100644 --- a/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.html +++ b/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.html @@ -16,7 +16,7 @@
diff --git a/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.ts b/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.ts index 096ca514..2974a555 100644 --- a/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.ts +++ b/src/app/modules/salaries/components/salaries-chart/salaries-chart.component.ts @@ -71,7 +71,7 @@ export class SalariesChartComponent implements OnInit, OnDestroy { this.service.charts({ grade: data?.grade ?? null, profsInclude: data?.profsToInclude ?? null, - profsExclude: data?.profsToExclude ?? null, + cities: data?.cities ?? null, }) .pipe(untilDestroyed(this)) .subscribe((x) => { diff --git a/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/global-filters-form-group.ts b/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/global-filters-form-group.ts index a2ffee3b..f8e167a2 100644 --- a/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/global-filters-form-group.ts +++ b/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/global-filters-form-group.ts @@ -1,30 +1,31 @@ import { FormControl, FormGroup } from "@angular/forms"; import { DeveloperGrade } from "@models/enums"; +import { KazakhstanCity } from "@models/salaries/kazakhstan-city"; import { UserProfession } from "@models/salaries/user-profession"; import { DeveloperGradeSelectItem } from "@shared/select-boxes/developer-grade-select-item"; export class SalaryChartGlobalFiltersData { grade: DeveloperGrade | null = null; profsToInclude: Array = []; - profsToExclude: Array = []; + cities: Array = []; constructor( grade: DeveloperGrade | null = null, profsToInclude: Array = [], - profsToExclude: Array = []) { + cities: Array = []) { if (grade === DeveloperGrade.Unknown) { grade = null; } this.grade = grade; this.profsToInclude = profsToInclude; - this.profsToExclude = profsToExclude; + this.cities = cities; } equals(other: SalaryChartGlobalFiltersData): boolean { // TODO mgorbatyuk: do more smart check that two arrays are not same return this.grade === other.grade && - this.profsToExclude.length === other.profsToExclude.length && + this.cities.length === other.cities.length && this.profsToInclude.length === other.profsToInclude.length; } } @@ -37,7 +38,7 @@ export class GlobalFiltersFormGroup extends FormGroup { super({ grade: new FormControl(filterData?.grade, []), profsToInclude: new FormControl(filterData?.profsToInclude, []), - profsToExclude: new FormControl(filterData?.profsToExclude, []), + cities: new FormControl(filterData?.cities, []), }); } @@ -52,8 +53,8 @@ export class GlobalFiltersFormGroup extends FormGroup { : null; const profsToInclude = this.value.profsToInclude as Array ?? []; - const profsToExclude = this.value.profsToExclude as Array ?? []; + const cities = this.value.cities as Array ?? []; - return new SalaryChartGlobalFiltersData(grade, profsToInclude, profsToExclude); + return new SalaryChartGlobalFiltersData(grade, profsToInclude, cities); } } diff --git a/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.html b/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.html index bca2d9fb..da759c58 100644 --- a/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.html +++ b/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.html @@ -29,16 +29,16 @@
- + diff --git a/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.ts b/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.ts index 39c8be84..24860d3c 100644 --- a/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.ts +++ b/src/app/modules/salaries/components/salaries-chart/salary-chart-global-filters/salary-chart-global-filters.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { GlobalFiltersFormGroup, SalaryChartGlobalFiltersData } from './global-filters-form-group'; import { UserProfession, UserProfessionEnum } from '@models/salaries/user-profession'; import { SelectItem } from '@shared/select-boxes/select-item'; +import { KazakhstanCity, KazakhstanCityEnum } from '@models/salaries/kazakhstan-city'; @Component({ selector: 'app-salary-chart-global-filters', @@ -10,7 +11,8 @@ import { SelectItem } from '@shared/select-boxes/select-item'; }) export class SalaryChartGlobalFiltersComponent implements OnInit { - readonly allProfessions: Array> = UserProfessionEnum.options() + readonly allProfessions: Array> = UserProfessionEnum.options(); + readonly allCities: Array> = KazakhstanCityEnum.options(); @Input() filterData: SalaryChartGlobalFiltersData | null = null; diff --git a/src/app/services/user-salaries.service.ts b/src/app/services/user-salaries.service.ts index 4952f66c..4f4ef7c5 100644 --- a/src/app/services/user-salaries.service.ts +++ b/src/app/services/user-salaries.service.ts @@ -79,7 +79,7 @@ export interface AdminAllSalariesQueryParams extends PageParams { export interface SalariesChartFilterData { grade: DeveloperGrade | null; profsInclude: Array | null; - profsExclude: Array | null; + cities: Array | null; } export interface SalariesAddingTrendAdminChart {