diff --git a/package.json b/package.json index d2305453..8b86fe78 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,9 @@ "@jsverse/transloco": "^7.5.1", "ag-grid-angular": "^33.1.1", "ag-grid-community": "^33.1.1", + "chart.js": "^4.5.0", + "chartjs-plugin-annotation": "^3.1.0", + "chartjs-plugin-zoom": "^2.2.0", "crypto-es": "^2.1.0", "cspell": "^8.17.3", "file-saver": "^2.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 058627f2..caa3861a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,15 @@ importers: ag-grid-community: specifier: ^33.1.1 version: 33.1.1 + chart.js: + specifier: ^4.5.0 + version: 4.5.0 + chartjs-plugin-annotation: + specifier: ^3.1.0 + version: 3.1.0(chart.js@4.5.0) + chartjs-plugin-zoom: + specifier: ^2.2.0 + version: 2.2.0(chart.js@4.5.0) crypto-es: specifier: ^2.1.0 version: 2.1.0 @@ -1953,6 +1962,9 @@ packages: '@keyv/serialize@1.0.3': resolution: {integrity: sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -2500,6 +2512,9 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -3054,6 +3069,20 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chart.js@4.5.0: + resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==} + engines: {pnpm: '>=8'} + + chartjs-plugin-annotation@3.1.0: + resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==} + peerDependencies: + chart.js: '>=4.0.0' + + chartjs-plugin-zoom@2.2.0: + resolution: {integrity: sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==} + peerDependencies: + chart.js: '>=3.2.0' + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4174,6 +4203,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + hammerjs@2.0.8: + resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} + engines: {node: '>=0.8.0'} + handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} @@ -8686,6 +8719,8 @@ snapshots: dependencies: buffer: 6.0.3 + '@kurkle/color@0.3.4': {} + '@leichtgewicht/ip-codec@2.0.5': {} '@listr2/prompt-adapter-inquirer@2.0.22(@inquirer/prompts@7.5.1(@types/node@22.13.9))': @@ -9152,6 +9187,8 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/hammerjs@2.0.46': {} + '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.16': @@ -9815,6 +9852,20 @@ snapshots: chardet@0.7.0: {} + chart.js@4.5.0: + dependencies: + '@kurkle/color': 0.3.4 + + chartjs-plugin-annotation@3.1.0(chart.js@4.5.0): + dependencies: + chart.js: 4.5.0 + + chartjs-plugin-zoom@2.2.0(chart.js@4.5.0): + dependencies: + '@types/hammerjs': 2.0.46 + chart.js: 4.5.0 + hammerjs: 2.0.8 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -11192,6 +11243,8 @@ snapshots: graphemer@1.4.0: {} + hammerjs@2.0.8: {} + handle-thing@2.0.1: {} has-bigints@1.1.0: {} diff --git a/src/@seed/api/dataset/dataset.types.ts b/src/@seed/api/dataset/dataset.types.ts index 4dc2d397..66229af4 100644 --- a/src/@seed/api/dataset/dataset.types.ts +++ b/src/@seed/api/dataset/dataset.types.ts @@ -67,6 +67,7 @@ export type DataMappingRow = { omit?: boolean; // optional, used for omitting columns isExtraData?: boolean; // used internally, not part of the API isNewColumn?: boolean; // used internally, not part of the API + hasDuplicate?: boolean; // used internally, not part of the API } export type MappedData = { diff --git a/src/@seed/api/index.ts b/src/@seed/api/index.ts index e6d01803..3e883e3d 100644 --- a/src/@seed/api/index.ts +++ b/src/@seed/api/index.ts @@ -18,6 +18,7 @@ export * from './notes' export * from './organization' export * from './pairing' export * from './postoffice' +export * from './program' export * from './progress' export * from './salesforce' export * from './scenario' diff --git a/src/@seed/api/program/index.ts b/src/@seed/api/program/index.ts new file mode 100644 index 00000000..ac3caa6e --- /dev/null +++ b/src/@seed/api/program/index.ts @@ -0,0 +1,2 @@ +export * from './program.service' +export * from './program.types' diff --git a/src/@seed/api/program/program.service.ts b/src/@seed/api/program/program.service.ts new file mode 100644 index 00000000..9ec7745d --- /dev/null +++ b/src/@seed/api/program/program.service.ts @@ -0,0 +1,93 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import type { Observable } from 'rxjs' +import { BehaviorSubject, catchError, map, tap } from 'rxjs' +import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { UserService } from '../user' +import type { Program, ProgramData, ProgramResponse, ProgramsResponse } from './program.types' + +@Injectable({ providedIn: 'root' }) +export class ProgramService { + private _httpClient = inject(HttpClient) + private _programs = new BehaviorSubject([]) + // private _programs = new ReplaySubject(1) + private _errorService = inject(ErrorService) + private _snackBar = inject(SnackBarService) + private _userService = inject(UserService) + programs$ = this._programs + orgId: number + + constructor() { + this._userService.currentOrganizationId$ + .pipe( + tap((orgId) => { this.list(orgId) }), + ) + .subscribe() + } + + list(orgId: number) { + const url = `/api/v3/compliance_metrics/?organization_id=${orgId}` + this._httpClient.get(url).pipe( + map(({ compliance_metrics }) => { + this.programs$.next(compliance_metrics) + return compliance_metrics + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching Programs') + }), + ).subscribe() + } + + create(orgId: number, data: Program): Observable { + const url = `/api/v3/compliance_metrics/?organization_id=${orgId}` + return this._httpClient.post(url, data).pipe( + tap(() => { + this.list(orgId) + this._snackBar.success('Successfully created Program') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error creating Program') + }), + ) + } + + update(orgId: number, programId: number, data: Program): Observable { + const url = `/api/v3/compliance_metrics/${programId}/?organization_id=${orgId}` + return this._httpClient.put(url, data).pipe( + tap(() => { + this.list(orgId) + this._snackBar.success('Successfully updated Program') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating Program') + }), + ) + } + + delete(orgId: number, programId: number): Observable { + const url = `/api/v3/compliance_metrics/${programId}/?organization_id=${orgId}` + return this._httpClient.delete(url).pipe( + tap(() => { + this.list(orgId) + this._snackBar.success('Successfully deleted Program') + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting Program') + }), + ) + } + + evaluate(orgId: number, programId: number, aliId: number = null): Observable { + let url = `/api/v3/compliance_metrics/${programId}/evaluate/?organization_id=${orgId}` + if (aliId) url += `&access_level_instance_id=${aliId}` + + return this._httpClient.get<{ data: ProgramData }>(url).pipe( + map(({ data }) => data), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error evaluating Program') + }), + ) + } +} diff --git a/src/@seed/api/program/program.types.ts b/src/@seed/api/program/program.types.ts new file mode 100644 index 00000000..cc2acb62 --- /dev/null +++ b/src/@seed/api/program/program.types.ts @@ -0,0 +1,79 @@ +import type { ChartDataset } from 'chart.js' + +export type Program = { + actual_emission_column: number; + actual_energy_column: number; + cycles: number[]; + emission_metric_type: string; + energy_metric_type: string; + filter_group: null; + id: number; + name: string; + organization_id: number; + target_emission_column: number; + target_energy_column: number; + x_axis_columns: number[]; + energy_bool?: boolean; + emission_bool?: boolean; +} + +export type EvaluatedProgram = { + actual_emission_column: number; + actual_emission_column_name: string; + actual_energy_column: number; + actual_energy_column_name: string; + cycles: { id: number; name: string }[]; + emission_bool: boolean; + emission_metric: boolean; + emission_metric_type: number; + energy_bool: boolean; + energy_metric: boolean; + energy_metric_type: number; + filter_group: number; + target_emission_column: number; + target_energy_column: number; +} + +export type ProgramsResponse = { + status: string; + compliance_metrics: Program[]; +} + +export type ProgramResponse = { + status: string; + compliance_metric: Program; +} + +export type ProgramData = { + cycles: { id: number; name: string }[]; + graph_data: GraphData; + meta: { organization: number; compliance_metric: number }; + metric: EvaluatedProgram; + name: string; + properties_by_cycles: Record[]>; + results_by_cycles: ResultsByCycles; +} + +// compliant -> n: No, u: Unknown, y: Yes +export type ResultsByCycles = { n: number[]; u: number[]; y: number[] } + +type GraphData = { + datasets: { data: number[]; label: string; backgroundColor?: string }[]; + labels: string[]; +} + +export type PropertyInsightDataset = ChartDataset<'scatter', PropertyInsightPoint[]> + +export type PropertyInsightPoint = { + id: number; + name: string; + x: number; + y: number; + target: number; + distance?: number; +} + +export type SimpleCartesianScale = { + type: 'linear' | 'category'; + title: { display: boolean; text: string }; +} diff --git a/src/@seed/materials/material.module.ts b/src/@seed/materials/material.module.ts index 789cb785..9e989d81 100644 --- a/src/@seed/materials/material.module.ts +++ b/src/@seed/materials/material.module.ts @@ -16,7 +16,7 @@ import { MatMenuModule } from '@angular/material/menu' import { MatPaginatorModule } from '@angular/material/paginator' import { MatProgressBarModule } from '@angular/material/progress-bar' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' -import { MatSelectModule } from '@angular/material/select' +import { MatSelectModule, MatSelectTrigger } from '@angular/material/select' import { MatSidenavModule } from '@angular/material/sidenav' import { MatSlideToggleModule } from '@angular/material/slide-toggle' import { MatSortModule } from '@angular/material/sort' @@ -47,6 +47,7 @@ export const MaterialImports = [ MatOptionModule, MatSelectModule, MatStepperModule, + MatSelectTrigger, MatSidenavModule, MatSlideToggleModule, MatSortModule, diff --git a/src/app/ag-grid-modules.ts b/src/app/ag-grid-modules.ts index da988e0f..bbf8e22f 100644 --- a/src/app/ag-grid-modules.ts +++ b/src/app/ag-grid-modules.ts @@ -9,6 +9,7 @@ import { PaginationModule, RenderApiModule, RowApiModule, + RowStyleModule, SelectEditorModule, TextEditorModule, ValidationModule, @@ -24,6 +25,7 @@ ModuleRegistry.registerModules([ PaginationModule, RenderApiModule, RowApiModule, + RowStyleModule, SelectEditorModule, TextEditorModule, ValidationModule, diff --git a/src/app/chartjs-setup.ts b/src/app/chartjs-setup.ts new file mode 100644 index 00000000..db3861b5 --- /dev/null +++ b/src/app/chartjs-setup.ts @@ -0,0 +1,7 @@ +import { Chart, registerables } from 'chart.js' +import annotationPlugin from 'chartjs-plugin-annotation' +import zoomPlugin from 'chartjs-plugin-zoom' + +Chart.register(...registerables) +Chart.register(annotationPlugin) +Chart.register(zoomPlugin) diff --git a/src/app/core/navigation/navigation.service.ts b/src/app/core/navigation/navigation.service.ts index af286576..bcf5e2b4 100644 --- a/src/app/core/navigation/navigation.service.ts +++ b/src/app/core/navigation/navigation.service.ts @@ -198,7 +198,7 @@ export class NavigationService { id: 'insights/program-overview', link: '/insights/program-overview', title: 'Program Overview', - icon: 'fa-solid:chart-simple', + icon: 'fa-solid:chart-column', type: 'basic', }, { diff --git a/src/app/modules/datasets/data-mappings/step1/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts index 3fcba898..6e7f6e97 100644 --- a/src/app/modules/datasets/data-mappings/step1/column-defs.ts +++ b/src/app/modules/datasets/data-mappings/step1/column-defs.ts @@ -3,12 +3,6 @@ import { EditHeaderComponent } from '@seed/components' import { AutocompleteCellComponent } from '@seed/components/ag-grid/autocomplete.component' import { dataTypeOptions, unitMap } from './constants' -export const gridOptions = { - singleClickEdit: true, - suppressMovableColumns: true, - // defaultColDef: { cellClass: (params: CellClassParams) => params.colDef.editable ? 'bg-primary bg-opacity-25' : '' }, -} - // Special cases const canEdit = (to_data_type: string, field: string, isNewColumn: boolean): boolean => { const editMap: Record = { diff --git a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts index 44f6bb41..239a3f69 100644 --- a/src/app/modules/datasets/data-mappings/step1/map-data.component.ts +++ b/src/app/modules/datasets/data-mappings/step1/map-data.component.ts @@ -6,7 +6,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { MatDialog } from '@angular/material/dialog' import { ActivatedRoute } from '@angular/router' import { AgGridAngular } from 'ag-grid-angular' -import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowNode } from 'ag-grid-community' +import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent, RowClassParams, RowNode } from 'ag-grid-community' import { Subject, switchMap, take } from 'rxjs' import type { Column, ColumnMapping, ColumnMappingProfile, ColumnMappingProfileType, Cycle, DataMappingRow, ImportFile, MappingSuggestionsResponse } from '@seed/api' import { ColumnMappingProfileService } from '@seed/api' @@ -16,7 +16,7 @@ import { ConfigService } from '@seed/services' import type { ProgressBarObj } from '@seed/services/uploader' import type { InventoryDisplayType } from 'app/modules/inventory' import { HelpComponent } from '../help.component' -import { buildColumnDefs, gridOptions } from './column-defs' +import { buildColumnDefs } from './column-defs' import { dataTypeMap, displayToDataTypeMap } from './constants' import { CreateProfileComponent } from './modal/create-profile.component' @@ -56,11 +56,11 @@ export class MapDataComponent implements OnChanges, OnDestroy { dataValid = false defaultInventoryType: InventoryDisplayType = 'Property' defaultRow: Record + duplicateCounts: Record = {} errorMessages: string[] = [] fileId = this._router.snapshot.params.id as number gridApi: GridApi gridHeight = 0 - gridOptions = gridOptions gridTheme$ = this._configService.gridTheme$ mappedData: { mappings: DataMappingRow[] } = { mappings: [] } profile: ColumnMappingProfile @@ -72,6 +72,12 @@ export class MapDataComponent implements OnChanges, OnDestroy { taxlotColumnMap: Record taxlotColumnNames: string[] + gridOptions = { + singleClickEdit: true, + suppressMovableColumns: true, + getRowClass: (params: RowClassParams) => params.data?.hasDuplicate ? 'bg-red-700/50' : '', + } + progressBarObj: ProgressBarObj = { message: [], progress: 0, @@ -327,12 +333,31 @@ export class MapDataComponent implements OnChanges, OnDestroy { // no duplicates toFields = toFields.filter((f) => f) + this.duplicateCounts = {} if (toFields.length !== new Set(toFields).size) { - this.dataValid = false - this.errorMessages.push('Duplicate headers found. Each SEED Header must be unique.') + this.setDuplicateCounts() } + this.markDuplicates() this.dataValid = this.errorMessages.length === 0 + this.gridApi.redrawRows() + } + + setDuplicateCounts() { + this.gridApi.forEachNode((node: RowNode) => { + const v = node.data?.to_field_display_name + if (v) this.duplicateCounts[v] = (this.duplicateCounts[v] ?? 0) + 1 + }) + this.dataValid = false + this.errorMessages.push('Duplicate headers found. Each SEED Header must be unique.') + } + + markDuplicates() { + // mark duplicate rows with 'hasDuplicate' for styling + this.gridApi.forEachNode((node: RowNode) => { + const v = node.data?.to_field_display_name + node.data.hasDuplicate = v ? this.duplicateCounts[v] > 1 : false + }) } ngOnDestroy(): void { diff --git a/src/app/modules/insights/config/index.ts b/src/app/modules/insights/config/index.ts new file mode 100644 index 00000000..6549518f --- /dev/null +++ b/src/app/modules/insights/config/index.ts @@ -0,0 +1 @@ +export * from './program-config.component' diff --git a/src/app/modules/insights/config/program-config.component.html b/src/app/modules/insights/config/program-config.component.html new file mode 100644 index 00000000..4fb269c8 --- /dev/null +++ b/src/app/modules/insights/config/program-config.component.html @@ -0,0 +1,199 @@ + + +
+
+
+ + Select a program to edit or create new + + @for (program of data.programs; track $index) { + {{ program.name }} + } + + + @if (program) { + + +
+ + } +
+ + + +
+
+
+ + General Settings +
+
+ Configure your program metric to enable visualizations on the + program overview + page. +
+
+ + + + Name + + + + + +
+
+ + Cycles + + Cycles + + @for (cycle of data.cycles; track $index) { + {{ cycle.name }} + } + + +
+ + + @for (cycle of form.value.cycles; track $index) { + + {{ getCycle(cycle) }} + cancel + + } + +
+ +
+
+ + Metric Settings +
+
+ The overall metric can be made up of an energy metric, an emission metric, or both. At least one type of metric is required and if + two are defined, then both metrics must be met for compliance. +
+
+ + +
+
Energy Metric
+ + + Actual Field + + + @for (column of metricColumns; track $index) { + {{ column.display_name }} + } + + + + Target Field + + + @for (column of metricColumns; track $index) { + {{ column.display_name }} + } + + + + Compliance Type + + + @for (metricType of metricTypes; track $index) { + {{ metricType.key }} + } + + +
+ + +
+
Emission Metric
+ + Actual Field + + + @for (column of metricColumns; track $index) { + {{ column.display_name }} + } + + + + Target Field + + + @for (column of metricColumns; track $index) { + {{ column.display_name }} + } + + + + Compliance Type + + + @for (metricType of metricTypes; track $index) { + {{ metricType.key }} + } + + +
+ + + +
+
+ + Visualization Settings +
+
+ Select at least one field which will serve as the x-axis for visualizations on the + property insights + page. Multiple fields can be selected. +
+
+ +
+
+ + X-Axis Field Options + + Columns + @for (column of data.xAxisColumns; track $index) { + {{ column.display_name }} + } + + +
+ + + @for (column of form.value.x_axis_columns; track $index) { + + {{ getColumn(column) }} + cancel + + } + +
+
+
+
+
+
+ +
+ +
diff --git a/src/app/modules/insights/config/program-config.component.ts b/src/app/modules/insights/config/program-config.component.ts new file mode 100644 index 00000000..d1bd4290 --- /dev/null +++ b/src/app/modules/insights/config/program-config.component.ts @@ -0,0 +1,146 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog' +import { RouterModule } from '@angular/router' +import type { Observable } from 'rxjs' +import { finalize, Subject, take, tap } from 'rxjs' +import type { Program, ProgramResponse } from '@seed/api' +import { ProgramService } from '@seed/api' +import type { Column } from '@seed/api/column/column.types' +import type { Cycle } from '@seed/api/cycle/cycle.types' +import { AlertComponent, ModalHeaderComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { Organization } from 'app/modules/organizations/organizations.types' + +@Component({ + selector: 'seed-program-config', + templateUrl: './program-config.component.html', + imports: [ + AlertComponent, + CommonModule, + MaterialImports, + ModalHeaderComponent, + FormsModule, + ReactiveFormsModule, + RouterModule, + ], +}) +export class ProgramConfigComponent implements OnInit, OnDestroy { + private _dialogRef = inject(MatDialogRef) + private _programService = inject(ProgramService) + private _snackBar = inject(SnackBarService) + private _unsubscribeAll$ = new Subject() + + metricTypes = [ + { key: 'Target Greater Than Actual', value: 'Target > Actual for Compliance' }, + { key: 'Target Less Than Actual', value: 'Target < Actual for Compliance' }, + ] + metricColumns: Column[] + metricDataTypes = ['number', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] + maxHeight = window.innerHeight - 200 + program: Program | null = null + + data = inject(MAT_DIALOG_DATA) as { + programs: Program[]; + cycles: Cycle[]; + filterGroups: unknown[]; + program: Program; + org: Organization; + propertyColumns: Column[]; + xAxisColumns: Column[]; + } + + form = new FormGroup({ + actual_emission_column: new FormControl(null), + actual_energy_column: new FormControl(null), + cycles: new FormControl([], Validators.required), + emission_metric_type: new FormControl(''), + energy_metric_type: new FormControl(''), + filter_group: new FormControl(null), + name: new FormControl('', Validators.required), + organization_id: new FormControl(this.data.org?.id), + target_emission_column: new FormControl(null), + target_energy_column: new FormControl(null), + x_axis_columns: new FormControl([], Validators.required), + }) + + ngOnInit(): void { + this.metricColumns = this.data.propertyColumns.filter((c) => this.validColumn(c, this.metricDataTypes)) + } + + validColumn(column: Column, validTypes: string[]) { + const isAllowedType = validTypes.includes(column.data_type) + const notRelated = !column.related + const notDerived = !column.derived_column + return isAllowedType && notRelated && notDerived + } + + selectProgram(program: Program) { + this.program = program + this.form.patchValue(program) + } + + newProgram() { + this.program = null + this.form.reset() + } + + removeProgram() { + this._programService.delete(this.data.org.id, this.program.id) + .pipe( + take(1), + finalize(() => { this.close() }), + ).subscribe() + } + + removeItem(item: number, key: 'cycles' | 'x_axis_columns') { + const items = this.form.value[key].filter((i) => i !== item) + this.form.patchValue({ [key]: items }) + } + + getCycle(id: number) { + return this.data.cycles.find((cycle) => cycle.id === id).name + } + + getColumn(id: number) { + return this.data.xAxisColumns.find((column) => column.id === id).display_name + } + + hasMetric() { + const values = this.form.value + const energy = values.actual_energy_column && values.target_energy_column && values.energy_metric_type + const emission = values.actual_emission_column && values.target_emission_column && values.emission_metric_type + return !!(energy || emission) + } + + onSubmit() { + if (!this.hasMetric()) { + this._snackBar.alert('At least one Metric is required') + } + const data = this.form.value as Program + + const request$: Observable = this.program + ? this._programService.update(this.data.org.id, this.program.id, data) + : this._programService.create(this.data.org.id, data) + + request$.pipe( + take(1), + tap(({ compliance_metric }) => { + const programId = compliance_metric.id + this.close(programId) + }), + ).subscribe() + } + + close(programId = null) { + this._dialogRef.close(programId) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/insights/index.ts b/src/app/modules/insights/index.ts index ae5c132a..c66c8570 100644 --- a/src/app/modules/insights/index.ts +++ b/src/app/modules/insights/index.ts @@ -1,5 +1,6 @@ export * from './custom-reports' +export * from './config' export * from './default-reports' export * from './portfolio-summary' -export * from './program-overview' export * from './property-insights' +export * from './program-overview' diff --git a/src/app/modules/insights/insights.routes.ts b/src/app/modules/insights/insights.routes.ts index f9c9208d..cdd63e4d 100644 --- a/src/app/modules/insights/insights.routes.ts +++ b/src/app/modules/insights/insights.routes.ts @@ -23,11 +23,21 @@ export default [ title: 'Program Overview', component: ProgramOverviewComponent, }, + { + path: 'program-overview/:id', + title: 'Program Overview', + component: ProgramOverviewComponent, + }, { path: 'property-insights', title: 'Property Insights', component: PropertyInsightsComponent, }, + { + path: 'property-insights/:id', + title: 'Property Insights', + component: PropertyInsightsComponent, + }, { path: 'portfolio-summary', title: 'Portfolio Summary', diff --git a/src/app/modules/insights/program-overview/program-overview.component.html b/src/app/modules/insights/program-overview/program-overview.component.html index 7d182487..1897f2db 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.html +++ b/src/app/modules/insights/program-overview/program-overview.component.html @@ -1,3 +1,82 @@ - -
Program Overview Content
+ +
+
+ +
+ + Select Program + + @for (program of programs; track $index) { + + {{ program.name }} + + } + + +
+ + +
+
+ @for (entry of colors | keyvalue; track $index) { +
+
+ {{ entry.key | titlecase }} +
+ } +
+
+ +
+ + +
+
+ + + + +
+ @if (program) { +
+
+ {{ chartName | titlecase }} +
+
+ } +
+ +
+
+
+ + @if (loading) { +
+ +
+ } + + @if (!program) { +
+ +
+ }
diff --git a/src/app/modules/insights/program-overview/program-overview.component.ts b/src/app/modules/insights/program-overview/program-overview.component.ts index 592fc8d4..6c60e97e 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.ts +++ b/src/app/modules/insights/program-overview/program-overview.component.ts @@ -1,14 +1,251 @@ -import type { OnInit } from '@angular/core' -import { Component } from '@angular/core' -import { PageComponent } from '@seed/components' +import { CommonModule } from '@angular/common' +import type { ElementRef, OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import type { ParamMap } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' +import type { ActiveElement, TooltipItem } from 'chart.js' +import { Chart } from 'chart.js' +import { combineLatest, filter, Subject, switchMap, takeUntil, tap } from 'rxjs' +import type { Column, Cycle, Organization, Program, ProgramData } from '@seed/api' +import { ColumnService, CycleService, OrganizationService, ProgramService } from '@seed/api' +import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import { naturalSort } from '@seed/utils' +import { ProgramConfigComponent } from '../config' @Component({ selector: 'seed-program-overview', templateUrl: './program-overview.component.html', - imports: [PageComponent], + imports: [ + CommonModule, + MaterialImports, + PageComponent, + ProgressBarComponent, + NotFoundComponent, + ], }) -export class ProgramOverviewComponent implements OnInit { +export class ProgramOverviewComponent implements OnDestroy, OnInit { + @ViewChild('programOverviewChart', { static: true }) canvas!: ElementRef + private _columnService = inject(ColumnService) + private _configService = inject(ConfigService) + private _cycleService = inject(CycleService) + private _programService = inject(ProgramService) + private _dialog = inject(MatDialog) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private _unsubscribeAll$ = new Subject() + chart: Chart + chartName: string + colors: Record = { compliant: '#77CCCB', 'non-compliant': '#A94455', unknown: '#DDDDDD' } + cycles: Cycle[] + data: ProgramData + filterGroups: unknown[] + loading = true + org: Organization + programId = 0 + program: Program + programs: Program[] + propertyColumns: Column[] + scheme: 'dark' | 'light' = 'light' + xAxisColumns: Column[] + xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] + ngOnInit(): void { - console.log('Program Overview') + this._route.paramMap.subscribe((params: ParamMap) => { + this.programId = parseInt(params.get('id')) + this.initProgram() + }) + } + + initProgram() { + this.getDependencies() + this.initChart() + this.setScheme() + } + + getDependencies() { + combineLatest({ + org: this._organizationService.currentOrganization$, + cycles: this._cycleService.cycles$, + propertyColumns: this._columnService.propertyColumns$, + programs: this._programService.programs$, + scheme: this._configService.scheme$, + }).pipe( + tap(({ org, cycles, propertyColumns, programs, scheme }) => { + this.org = org + this.cycles = cycles + this.propertyColumns = propertyColumns + this.xAxisColumns = this.propertyColumns.filter((c) => this.validColumn(c, this.xAxisDataTypes)) + this.scheme = scheme + this.programs = programs.filter((p) => p.organization_id === org.id).sort((a, b) => naturalSort(a.name, b.name)) + this.program = programs.find((p) => p.id === this.programId) + if (!this.program) { + this.loading = false + this.programChange(this.programs[0]) + } + }), + filter(() => this.program?.organization_id === this.org.id), + switchMap(() => this.evaluateProgram()), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + programChange(program: Program) { + const segments = ['/insights/program-overview'] + if (program?.id) segments.push(program.id.toString()) + void this._router.navigate(segments) + } + + evaluateProgram() { + return this._programService.evaluate(this.org.id, this.program.id).pipe( + tap((data) => { + this.data = data + this.setDatasets() + this.loading = false + this.setChartName(this.program) + }), + ) + } + + setDatasets() { + if (!this.data.graph_data) return + const { labels, datasets } = this.data.graph_data + for (const ds of datasets) { + ds.backgroundColor = this.colors[ds.label] + } + + this.chart.data.labels = labels + this.chart.data.datasets = datasets + this.chart.update() + console.log('ALL DATA', { + chart: this.chart, + }) + } + + initChart() { + this.chart?.destroy() + this.chart = new Chart(this.canvas.nativeElement, { + type: 'bar', + data: { + labels: [], + datasets: [], + }, + options: { + onClick: (_, elements: ActiveElement[], chart: Chart<'bar'>) => { + const { datasetIndex, index } = elements[0] + const label = chart.data.datasets[datasetIndex]?.label + const cycleName = chart.data.labels[index] + const cycleId = this.cycles.find((c) => c.name === cycleName)?.id + + return void this._router.navigate(['/insights/property-insights', this.program.id], { + state: { cycleId, label }, + }) + }, + plugins: { + title: { display: true, align: 'start' }, + legend: { display: false }, + tooltip: { + callbacks: { label: (ctx) => this.tooltipFooter(ctx) }, + }, + }, + scales: { + x: { + stacked: true, + }, + y: { + beginAtZero: true, + stacked: true, + position: 'left', + display: true, + title: { text: 'Number of Buildings', display: true }, + }, + }, + responsive: true, + maintainAspectRatio: false, + }, + }) + this.setScheme() + } + + setScheme() { + this._configService.scheme$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((scheme) => { + const color = scheme === 'light' ? '#0000001a' : '#ffffff2b' + this.chart.options.scales.x.grid = { color } + this.chart.options.scales.y.grid = { color } + this.chart.update() + }) + } + + tooltipFooter(tooltipItem: TooltipItem<'bar'>): string[] { + if (!tooltipItem) return [] + + const { dataIndex, raw, dataset } = tooltipItem + const label = `${dataset.label}: ${raw as number}` + + const barValues = this.chart.data.datasets.map((ds) => ds.data[dataIndex]) as number[] + const barTotal = barValues.reduce((acc, cur) => acc + cur, 0) + const percentage = `${((raw as number / barTotal) * 100).toPrecision(4)}%` + + return [label, percentage] + } + + validColumn(column: Column, validTypes: string[]) { + const isAllowedType = validTypes.includes(column.data_type) + const notRelated = !column.related + const notDerived = !column.derived_column + return isAllowedType && notRelated && notDerived + } + + setChartName(program: Program) { + if (!program) return + const cycles = this.cycles.filter((c) => program.cycles.includes(c.id)) + const cycleFirst = cycles.reduce((prev, curr) => (prev.start < curr.start ? prev : curr)) + const cycleLast = cycles.reduce((prev, curr) => (prev.end > curr.end ? prev : curr)) + const cycleRange = cycleFirst === cycleLast ? cycleFirst.name : `${cycleFirst.name} - ${cycleLast.name}` + this.chartName = `${program.name}: ${cycleRange}` + } + + refreshChart() { + if (!this.program) return + this.initChart() + this.setDatasets() + } + + downloadChart() { + const a = document.createElement('a') + a.href = this.chart.toBase64Image() + a.download = `Program-${this.chartName}.png` + a.click() + } + + openProgramConfig = () => { + const dialogRef = this._dialog.open(ProgramConfigComponent, { + width: '50rem', + data: { + cycles: this.cycles, + filterGroups: this.filterGroups, + programs: this.programs, + selectedProgram: this.program, + org: this.org, + propertyColumns: this.propertyColumns?.sort((a, b) => naturalSort(a.display_name, b.display_name)), + xAxisColumns: this.xAxisColumns, + }, + }) + + dialogRef + .afterClosed() + .pipe( + filter(Boolean), + tap((programId: number) => { this.program = this.programs.find((p) => p.id == programId) }), + ) + .subscribe() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() } } diff --git a/src/app/modules/insights/property-insights/property-insights.component.html b/src/app/modules/insights/property-insights/property-insights.component.html index e8afd4b6..30b0f6ad 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -1,3 +1,210 @@ - -
Property Insights Content
+ +
+
+ +
+ + Select Program + + @for (p of programs; track $index) { + + {{ p.name }} + + } + + +
+ + + @if (program) { +
+
+ + @for (entry of colors | keyvalue; track $index; let i = $index) { + +
+
+ {{ entry.key | titlecase }} +
+
+ } + + + Distance from Target + +
+
+
+
+ + +
+ } +
+ + + +
+ + @if (program) { +
+
+ + Cycle + + @for (cycle of programCycles; track $index) { + {{ cycle.name }} + } + + + + + Metric Type + + @for (metricType of programMetricTypes; track $index) { + {{ metricType.value }} + } + + + + + X-Axis + + @for (column of programXAxisColumns; track $index) { + {{ column.display_name }} + } + + + + + Access Level + + @for (level of accessLevelNames; track $index) { + {{ level }} + } + + + + + Access Level Instance + + @for (instance of accessLevelInstances; track $index) { + {{ instance.name }} + } + + +
+ + +
+ } + +
+
+ +
+
+
+ + +
+ +
+
+ + Data Table +
+ Click to expand +
+ + + + + Compliant: + {{ results.y }} + + + + + + Non-Compliant: + {{ results.n }} + + + + + + Unknown: + {{ results.u }} + + + + +
+ + @if (loading) { +
+ +
+ } + + @if (!program) { +
+ +
+ } +
diff --git a/src/app/modules/insights/property-insights/property-insights.component.ts b/src/app/modules/insights/property-insights/property-insights.component.ts index fbadf946..3eb14125 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -1,14 +1,738 @@ -import type { OnInit } from '@angular/core' -import { Component } from '@angular/core' -import { PageComponent } from '@seed/components' +import { CommonModule, Location } from '@angular/common' +import type { ElementRef, OnDestroy, OnInit } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { MatDialog } from '@angular/material/dialog' +import type { ParamMap } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' +import { AgGridAngular } from 'ag-grid-angular' +import type { ColDef, RowClickedEvent } from 'ag-grid-community' +import type { ActiveElement, ScatterDataPoint, TooltipItem } from 'chart.js' +import { Chart } from 'chart.js' +import type { AnnotationOptions } from 'chartjs-plugin-annotation' +import { combineLatest, debounceTime, EMPTY, filter, map, merge, Subject, switchMap, take, takeUntil, tap, zip } from 'rxjs' +import type { AccessLevelInstancesByDepth, AccessLevelsByDepth, Column, Cycle, Organization, Program, ProgramData, PropertyInsightDataset, PropertyInsightPoint, ResultsByCycles, SimpleCartesianScale } from '@seed/api' +import { ColumnService, CycleService, OrganizationService, ProgramService } from '@seed/api' +import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ConfigService } from '@seed/services' +import { naturalSort } from '@seed/utils' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { LabelsModalComponent } from 'app/modules/inventory/actions' +import { ProgramConfigComponent } from '../config' @Component({ selector: 'seed-property-insights', templateUrl: './property-insights.component.html', - imports: [PageComponent], + imports: [ + AgGridAngular, + CommonModule, + FormsModule, + PageComponent, + MaterialImports, + NotFoundComponent, + ProgressBarComponent, + ReactiveFormsModule, + ], }) -export class PropertyInsightsComponent implements OnInit { - ngOnInit(): void { - console.log('Property Insights') +export class PropertyInsightsComponent implements OnDestroy, OnInit { + @ViewChild('propertyInsightsChart', { static: true }) canvas!: ElementRef + private _location = inject(Location) + private _columnService = inject(ColumnService) + private _configService = inject(ConfigService) + private _cycleService = inject(CycleService) + private _programService = inject(ProgramService) + private _dialog = inject(MatDialog) + private _organizationService = inject(OrganizationService) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private _snackBar = inject(SnackBarService) + private _unsubscribeAll$ = new Subject() + + accessLevelNames: AccessLevelInstancesByDepth['accessLevelNames'] = [] + accessLevelInstancesByDepth: AccessLevelsByDepth = {} + accessLevelInstances: AccessLevelsByDepth[keyof AccessLevelsByDepth] = [] + annotations: Record + chart: Chart + colDefs: ColDef[] = [] + colors: Record = { compliant: '#77CCCB', 'non-compliant': '#A94455', unknown: '#DDDDDD' } + cycles: Cycle[] + data: ProgramData + datasets: PropertyInsightDataset[] = [] + datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] + filterGroups: unknown[] = [] + gridOptions = { rowClass: 'cursor-pointer' } + gridTheme$ = this._configService.gridTheme$ + loading = true + metricTypes = [ + { key: 0, value: 'Energy Metric' }, + { key: 1, value: 'Emission Metric' }, + ] + org: Organization + programId: number + program: Program + programs: Program[] + propertyColumns: Column[] + programCycles: Cycle[] = [] + programMetricTypes: { key: number; value: string }[] = [] + programXAxisColumns: Column[] = [] + rankedCol = { display_name: 'Ranked Distance to Compliance', id: 0 } as Column + results = { y: 0, n: 0, u: 0 } + rowData: Record = {} + scheme: 'dark' | 'light' = 'light' + xCategorical = false + xAxisColumns: Column[] + xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] + + form = new FormGroup({ + cycleId: new FormControl(null), + metricType: new FormControl<0 | 1>(0), + xAxisColumnId: new FormControl(null), + accessLevel: new FormControl(null), + accessLevelInstanceId: new FormControl(null), + program: new FormControl(null), + annotationVisibility: new FormControl(true), + }) + + get cycleId() { + return this.form.value.cycleId + } + + get metricType() { + return this.form.value.metricType + } + + get xAxisColumnId() { + return this.form.value.xAxisColumnId + } + + get accessLevelInstanceId() { + return this.form.value.accessLevelInstanceId + } + + ngOnInit() { + this.watchForm() + this._route.paramMap.subscribe((params: ParamMap) => { + this.programId = parseInt(params.get('id')) + this.initChart() + this.setScheme() + this.initProgram() + }) + } + + initProgram(): void { + this.getDependencies() + .pipe( + debounceTime(300), + tap((dependencies) => { + this.setDependencies(dependencies) + this.getPrograms() + }), + takeUntil(this._unsubscribeAll$), + ) + .subscribe() + + this.getAliTree() + } + + getDependencies() { + return combineLatest({ + org: this._organizationService.currentOrganization$, + cycles: this._cycleService.cycles$, + propertyColumns: this._columnService.propertyColumns$, + }) + } + + setDependencies( + { org, cycles, propertyColumns }: + { org: Organization; cycles: Cycle[]; propertyColumns: Column[] }, + ) { + this.org = org + this.cycles = cycles + this.propertyColumns = propertyColumns + this.xAxisColumns = this.propertyColumns.filter((c) => this.isValidColumn(c, this.xAxisDataTypes)) + } + + getPrograms() { + this._programService.programs$.pipe( + filter(() => !!this.org), + tap((programs) => { + this.programs = programs.filter((p) => p.organization_id === this.org.id).sort((a, b) => naturalSort(a.name, b.name)) + this.program = programs.find((p) => p.id === this.programId) + if (!this.program) { + this.programChange(this.programs[0]) + } + }), + filter(() => !!(this.program)), + switchMap(() => this.evaluateProgram(this.accessLevelInstanceId)), + tap(() => { this.setForm() }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + setForm() { + if (!this.program) return + if (!this.accessLevelInstances) { + this.getPossibleAccessLevelInstances(this.accessLevelNames?.at(-1)) + } + + this.setFormOptions() + const cycleId = this.getStateCycle() + const metricType = this.program.actual_energy_column ? 0 : 1 + const data: Record = { + cycleId, + xAxisColumnId: this.program.x_axis_columns[0], + metricType, + accessLevel: this.accessLevelNames.at(-1), + accessLevelInstanceId: this.accessLevelInstances[0]?.id ?? null, + } + // wait for DOM to update before patching to avoid blank selections + setTimeout(() => { + this.form.patchValue(data) + }) + } + + watchForm() { + // Developer Note: use map to track which value changes + merge( + this.form.get('cycleId')?.valueChanges.pipe(map((value) => ({ field: 'cycleId', value }))), + this.form.get('xAxisColumnId')?.valueChanges.pipe(map((value) => ({ field: 'xAxisColumnId', value }))), + this.form.get('metricType')?.valueChanges.pipe(map((value) => ({ field: 'metricType', value }))), + ).pipe( + tap(() => { this.loading = true }), + debounceTime(300), + tap(() => { + this.setChart() + if (this.cycleId) { + this.setResults() + this.loading = false + } + }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + + this.form.get('accessLevel')?.valueChanges.pipe( + tap((accessLevel) => { this.getPossibleAccessLevelInstances(accessLevel) }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + + this.form.get('accessLevelInstanceId')?.valueChanges.pipe( + filter((id) => !!(id && this.org && this.cycleId)), + switchMap((id) => this.evaluateProgram(id)), + tap(() => { this.setChart() }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + setChart() { + this.clearLabels() + this.setChartSettings() + this.loadDatasets() + this.chart.update() + this.sortLabels() + this.chart.resetZoom() + } + + getAliTree() { + zip( + this._organizationService.accessLevelTree$, + this._organizationService.accessLevelInstancesByDepth$, + ).pipe( + tap(([accessLevelTree, accessLevelsByDepth]) => { + this.accessLevelNames = accessLevelTree.accessLevelNames + this.accessLevelInstancesByDepth = accessLevelsByDepth + this.getPossibleAccessLevelInstances(this.accessLevelNames?.at(-1)) + + // suggest access level instance if null + this.form.get('accessLevelInstanceId')?.setValue(this.accessLevelInstances[0]?.id) + }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + getPossibleAccessLevelInstances(accessLevelName: string): void { + const depth = this.accessLevelNames.findIndex((name) => name === accessLevelName) + this.accessLevelInstances = this.accessLevelInstancesByDepth[depth] + } + + programChange(program: Program) { + this.form.reset() + const segments = ['/insights/property-insights'] + if (program?.id) segments.push(program.id.toString()) + void this._router.navigate(segments) + } + + compareSelection = (a: { id: number }, b: { id: number }) => a && b && a.id === b.id + + evaluateProgram(aliId: number = null) { + if (this.program?.organization_id !== this.org.id) { + this.loading = false + this.clearChart() + return EMPTY + } + + return this._programService.evaluate(this.org.id, this.program.id, aliId).pipe( + tap((data) => { + this.data = data + console.log('evaluate program', this.cycleId) + this.setResults() + }), + take(1), + ) + } + + getStateCycle() { + // use incoming state cycle if coming from program overview, but clear state after initial load + const { cycles } = this.program + const state = this._location.getState() as { cycleId?: number; label?: string } + const stateCycleId = state.cycleId + const dataset = state.label + this.handleLegendVisibility(dataset) + history.replaceState({}, document.title) + return cycles.find((c) => c === stateCycleId) ?? cycles[0] + } + + handleLegendVisibility(dataset: string) { + if (!dataset) this.datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] + if (dataset) this.datasetVisibility = [dataset] + if (dataset === 'non-compliant') this.datasetVisibility.push('whisker') + } + + setFormOptions() { + const { cycles, x_axis_columns, actual_emission_column, actual_energy_column } = this.program + this.programMetricTypes = [] + if (actual_emission_column) this.programMetricTypes.push({ key: 1, value: 'Emission Metric' }) + if (actual_energy_column) this.programMetricTypes.push({ key: 0, value: 'Energy Metric' }) + this.programCycles = this.cycles.filter((c) => cycles.includes(c.id)) + this.programXAxisColumns = [...this.xAxisColumns.filter((c) => x_axis_columns.includes(c.id)), this.rankedCol] + } + + validateProgram() { + const { actual_emission_column, actual_energy_column } = this.program + if (!this.data) { + this.clearChart() + return false + } + const validEnergy = this.metricType === 0 && !!actual_energy_column + const validEmission = this.metricType === 1 && !!actual_emission_column + return validEnergy || validEmission + } + + isValidColumn(column: Column, validTypes: string[]) { + const isAllowedType = validTypes.includes(column.data_type) + const notRelated = !column.related + const notDerived = !column.derived_column + return isAllowedType && notRelated && notDerived + } + + setResults() { + if (!this.data || !this.cycleId) return + + const { y, n, u } = this.data.results_by_cycles[this.cycleId] as { y: number[]; n: number[]; u: number[] } + this.results = { y: y.length, n: n.length, u: u.length } + + const { scales } = this.chart.options as { scales: { x: { title: { text: string } }; y: { title: { text: string } } } } + this.colDefs = [ + { field: 'x', headerName: `X: ${scales.x.title.text}`, flex: 1 }, + { field: 'y', headerName: `Y: ${scales.y.title.text}`, flex: 1 }, + { field: 'distance', headerName: 'Distance to Target', flex: 1 }, + ] + + this.rowData = this.chart.data.datasets.reduce((acc, { label, data }) => { + acc[label] = data + return acc + }, { compliant: [], 'non-compliant': [], unknown: [] }) + } + + setScheme() { + this._configService.scheme$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((scheme) => { + this.scheme = scheme + const color = scheme === 'light' ? '#0000001a' : '#ffffff2b' + this.chart.options.scales.x.grid = { color } + this.chart.options.scales.y.grid = { color } + this.chart.update() + }) + } + + /* + * Step 2, set chart settings (axes name, background, labels...) + */ + setChartSettings() { + if (!this.program) return + const [xAxisName, yAxisName] = this.getXYAxisName() + const xScale = this.chart.options.scales.x as SimpleCartesianScale + xScale.type = this.xCategorical ? 'category' : 'linear' + xScale.title = { display: true, text: xAxisName } + + const yScale = this.chart.options.scales.y as SimpleCartesianScale + yScale.title = { display: true, text: yAxisName } + this.chart.options.plugins.annotation.annotations = this.annotations + this.chart.options.scales.x.ticks = { + callback(value) { + const label = this.getLabelForValue(value as number) + if (xAxisName?.toLowerCase().includes('year')) { + return label.replace(',', '') + } + return label + }, + } + } + + getXYAxisName(): string[] { + const xAxisCol = this.programXAxisColumns.find((col) => col.id === this.xAxisColumnId) + if (!xAxisCol || !this.program) return [null, null] + + const xAxisName = xAxisCol.display_name + this.xCategorical = ['string', 'boolean'].includes(xAxisCol.data_type) + const energyCol = this.propertyColumns.find((col) => col.id === this.program.actual_energy_column) + const emissionCol = this.propertyColumns.find((col) => col.id === this.program.actual_emission_column) + const yAxisName = this.metricType === 0 + ? energyCol?.display_name + : emissionCol?.display_name + + return [xAxisName, yAxisName] + } + + setDatasetColor() { + for (const ds of this.chart.data.datasets) { + ds.backgroundColor = this.colors[ds.label] + } + } + + /* + * Step 3: Loads datasets into the chart. + */ + loadDatasets() { + if (!this.program || !this.data) return + + this.resetDatasets() + + const numProperties = Object.values(this.data.properties_by_cycles).reduce((acc, curr) => acc + curr.length, 0) + if (numProperties > 3000) { + this._snackBar.alert('Too many properties to chart. Update program and try again.') + return + } + + this.formatDataPoints() + this.formatNonCompliantPoints() + this.chart.data.datasets = this.datasets + const flatData = this.chart.data.datasets?.flatMap((ds) => ds.data as ScatterDataPoint[]) + const yMax = Math.max(...flatData.map((p) => p.y)) + const xMax = Math.max(...flatData.map((p) => p.x)) + this.chart.options.scales.y.suggestedMax = yMax * 1.1 + this.chart.options.scales.x.suggestedMax = xMax * 1.1 + this.setDatasetColor() + this.chart.options.plugins.annotation.annotations = this.annotations + + // console.log('ALL DATA', { + // form: this.form.value, + // data: this.data, + // datasets: this.datasets, + // chart: this.chart, + // }) + } + + formatDataPoints() { + const { metric, results_by_cycles } = this.data + + const properties = this.data.properties_by_cycles[this.cycleId] ?? [] + const cycleResult = results_by_cycles[this.cycleId] as ResultsByCycles + + for (const prop of properties) { + const id = prop.id as number + const nonCompliant = cycleResult.n.includes(id) + const name = this.getValue(prop, 'startsWith', this.org.property_display_field) as string + const x = this.getValue(prop, 'endsWith', `_${this.xAxisColumnId}`) as number + let target: number + let distance: number = null + + const actualCol = this.metricType === 0 ? metric.actual_energy_column : metric.actual_emission_column + const targetCol = this.metricType === 0 ? metric.target_energy_column : metric.target_emission_column + const hasTarget = this.metricType === 0 ? !metric.energy_bool : !metric.emission_bool + + const y = this.getValue(prop, 'endsWith', `_${actualCol}`) as number + if (hasTarget) { + target = this.getValue(prop, 'endsWith', `_${targetCol}`) as number + distance = nonCompliant ? Math.abs(target - y) : null + } + + const item: PropertyInsightPoint = { id, name, x, y, target, distance } + + // place in appropriate dataset + if (cycleResult.y.includes(id)) { + this.datasets[0].data.push(item) + } else if (nonCompliant) { + this.datasets[1].data.push(item) + } else { + this.datasets[2].data.push(item) + } + } + } + + formatNonCompliantPoints() { + this.annotations = {} + const program = this.data.metric + const nonCompliant = this.datasets.find((ds) => ds.label === 'non-compliant') + const targetType = this.metricType === 0 ? program.energy_metric_type : program.emission_metric_type + + // Ranked distance from target (col id = 0) + if (this.xAxisColumnId === 0) { + nonCompliant.data.sort((a, b) => (b.distance) - (a.distance)) + for (const [i, item] of nonCompliant.data.entries()) { + item.x = i + 1 + } + } + + for (const item of nonCompliant.data) { + const annotation = this.blankAnnotation() + + item.distance = null + // if (!(item.x && item.y && item.target)) return + const belowTarget = targetType === 1 && item?.target < item?.y + const aboveTarget = targetType === 0 && item?.target > item?.y + const addWhisker = belowTarget || aboveTarget + + if (!addWhisker) return + + item.distance = Math.abs(item.target - item.y) + annotation.xMin = item.x + annotation.xMax = item.x + annotation.yMin = item.y + annotation.yMax = item.target + this.annotations[`prop${item.id}`] = annotation + } + } + + getValue(property: Record, fn: 'startsWith' | 'endsWith', key: string) { + const entry = Object.entries(property).find(([k]) => k[fn](key)) + return entry?.[1] + } + + resetDatasets() { + const isHidden = (label: string) => !this.datasetVisibility.includes(label) + this.datasets = [ + { data: [], label: 'compliant', pointStyle: 'circle', pointRadius: 7, hidden: isHidden('compliant') }, + { data: [], label: 'non-compliant', pointStyle: 'triangle', pointRadius: 7, hidden: isHidden('non-compliant') }, + { data: [], label: 'unknown', pointStyle: 'rect', hidden: isHidden('unknown') }, + ] + } + + blankAnnotation(): AnnotationOptions { + return { + type: 'line', + xMin: 0, + xMax: 0, + yMin: 0, + yMax: 0, + borderColor: () => this.scheme === 'dark' ? '#ffffffff' : '#333333', + borderWidth: 1, + display: this.datasetVisibility.includes('whisker'), + arrowHeads: { + end: { + display: true, + width: 9, + length: 0, + }, + }, + } + } + + toggleVisibility(idx: number, show: boolean) { + if (idx === 3) { + this.toggleWhiskers(show) + } else { + this.chart.setDatasetVisibility(idx, show) + } + this.chart.update() + } + + toggleWhiskers(show: boolean) { + for (const key of Object.keys(this.annotations)) { + if (key.startsWith('prop')) { + this.annotations[key].display = show + } + } + } + + downloadChart() { + const a = document.createElement('a') + a.href = this.chart.toBase64Image() + a.download = `Program-${this.program.name}.png` + a.click() + } + + refreshChart() { + if (!this.program) return + this.initChart() + this.setChart() + this.setScheme() + } + + clearChart() { + this.programCycles = [] + this.programXAxisColumns = [this.rankedCol] + this.loading = false + this.initChart() + } + + clearLabels() { + this.chart.data.labels = [] + this.chart.update() + } + + sortLabels() { + const labels = this.chart.data.labels ?? [] + const isNumeric = labels.every((l) => !isNaN(Number(l))) + if (isNumeric) { + labels.sort((a, b) => Number(a) - Number(b)) + } else { + labels.sort((a, b) => naturalSort(a as string, b as string)) + } + } + + onRowClicked({ data }: RowClickedEvent<{ id: number }>) { + if (data.id) { + void this._router.navigate(['/properties', data.id]) + } + } + + openLabelModal = () => { + const visibleData = this.chart.data.datasets.filter((_, i) => this.chart.isDatasetVisible(i)).map((ds) => ds.data) + if (!visibleData.length) return + + const ids = visibleData.flatMap((d: PropertyInsightPoint[]) => d).map((d) => d.id) + this._dialog.open(LabelsModalComponent, { + width: '50rem', + data: { + orgId: this.org.id, + type: 'properties', + viewIds: ids, + }, + }) + } + + openProgramConfig = () => { + const dialogRef = this._dialog.open(ProgramConfigComponent, { + width: '50rem', + data: { + filterGroups: this.filterGroups, + cycles: this.cycles, + programs: this.programs, + program: this.program, + org: this.org, + propertyColumns: this.propertyColumns?.sort((a, b) => naturalSort(a.display_name, b.display_name)), + xAxisColumns: this.xAxisColumns, + }, + }) + + dialogRef + .afterClosed() + .pipe( + filter(Boolean), + tap((programId: number) => { + this.program = this.programs.find((p) => p.id == programId) + this.programChange(this.program) + }), + takeUntil(this._unsubscribeAll$), + ) + .subscribe() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + initChart() { + this.chart?.destroy() + this.chart = new Chart(this.canvas.nativeElement, { + type: 'scatter', + data: { + labels: [], + datasets: [], + }, + options: { + onClick: (_, elements: ActiveElement[], chart: Chart<'scatter'>) => { + if (!elements.length) return + const { datasetIndex, index } = elements[0] + const raw = chart.data.datasets[datasetIndex].data[index] as PropertyInsightPoint + const viewId = raw.id + return void this._router.navigate(['/properties', viewId]) + }, + elements: { + point: { + radius: 5, + }, + }, + plugins: { + title: { + display: true, + align: 'start', + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + label: (context: TooltipItem<'scatter'> & { raw: { name: string; id: number } }) => { + const text: string[] = [] + // property ID / default display field + if (context.raw.name) { + text.push(`Property: ${context.raw.name}`) + } else { + text.push(`Property ID: ${context.raw.id}`) + } + + // x and y axis names and values + const [xAxisName, yAxisName] = this.getXYAxisName() + text.push(`${xAxisName}: ${context.parsed.x}`) + text.push(`${yAxisName}: ${context.parsed.y}`) + return text + }, + }, + }, + zoom: { + limits: { + x: { min: 'original', max: 'original', minRange: 50 }, + y: { min: 'original', max: 'original', minRange: 50 }, + }, + pan: { + enabled: true, + mode: 'xy', + }, + zoom: { + wheel: { + enabled: true, + }, + mode: 'xy', + }, + }, + }, + scales: { + x: { + title: { + text: 'X', + display: true, + }, + ticks: { + callback(value) { + return this.getLabelForValue(value as number) + }, + }, + type: 'linear', + }, + y: { + type: 'linear', + beginAtZero: true, + position: 'left', + display: true, + title: { + text: 'Y', + display: true, + }, + }, + }, + }, + }) } } diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 81d1e512..19f9ef89 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -207,7 +207,11 @@ export class InventoryComponent implements OnDestroy, OnInit { * returns a null observable to track completion */ loadInventory(): Observable { - if (!this.cycleId) return of(null) + // org change can lead to a mismatch + if (!this.cycleId || this.orgId !== this.cycle.organization) { + return of(null) + } + const inventory_type = this.type === 'properties' ? 'property' : 'taxlot' const params = new URLSearchParams({ cycle: this.cycleId.toString(), diff --git a/src/main.ts b/src/main.ts index 2166129e..216f86d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser' import { AppComponent } from 'app/app.component' import { appConfig } from 'app/app.config' import 'app/ag-grid-modules' +import 'app/chartjs-setup' bootstrapApplication(AppComponent, appConfig).catch((err: unknown) => { console.error(err) diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 7d8f6a06..4b3a386f 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -258,7 +258,6 @@ // } } - .compact-form { label, span { @apply text-sm !important; @@ -282,4 +281,34 @@ .mat-mdc-text-field-wrapper .mdc-notched-outline__notch { border-left: none !important; -} \ No newline at end of file +} + +.cdk-overlay-pane:has(.wide-select-lg) { + width: 700px !important; +} + +.cdk-overlay-pane:has(.wide-select-md) { + width: 500px !important; +} + +.cdk-overlay-pane:has(.wide-select-sm) { + width: 400px !important; +} + +.strike-through { + mat-button-toggle:not(.mat-button-toggle-checked) { + span { + @apply line-through; + } + } +} + +.accordion-sub-tables { + .mat-expansion-panel { + @apply border m-0 !rounded-none; + } + + .mat-expansion-panel-body { + @apply p-0; + } +}