From 85bddfca873ac8b8d2d4ef1e1c60fcb03a42bc0f Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 19 Aug 2025 21:42:23 +0000 Subject: [PATCH 01/22] program config --- src/@seed/api/index.ts | 1 + src/@seed/api/program/index.ts | 2 + src/@seed/api/program/program.service.ts | 80 +++++++ src/@seed/api/program/program.types.ts | 19 ++ src/@seed/materials/material.module.ts | 3 +- src/app/modules/insights/config/index.ts | 1 + .../program-config-compact.component.html | 165 ++++++++++++++ .../config/program-config.component.html | 211 ++++++++++++++++++ .../config/program-config.component.ts | 145 ++++++++++++ src/app/modules/insights/index.ts | 3 +- .../program-overview.component.html | 33 ++- .../program-overview.component.ts | 75 ++++++- .../property-insights.component.html | 6 +- src/styles/styles.scss | 6 +- 14 files changed, 741 insertions(+), 9 deletions(-) create mode 100644 src/@seed/api/program/index.ts create mode 100644 src/@seed/api/program/program.service.ts create mode 100644 src/@seed/api/program/program.types.ts create mode 100644 src/app/modules/insights/config/index.ts create mode 100644 src/app/modules/insights/config/program-config-compact.component.html create mode 100644 src/app/modules/insights/config/program-config.component.html create mode 100644 src/app/modules/insights/config/program-config.component.ts diff --git a/src/@seed/api/index.ts b/src/@seed/api/index.ts index a7a17037..bd61ba62 100644 --- a/src/@seed/api/index.ts +++ b/src/@seed/api/index.ts @@ -17,6 +17,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..a4985d8b --- /dev/null +++ b/src/@seed/api/program/program.service.ts @@ -0,0 +1,80 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { Observable } from 'rxjs' +import { catchError, map, ReplaySubject, tap } from 'rxjs' +import { UserService } from '../user' +import type { Program, ProgramResponse } from './program.types' + +@Injectable({ providedIn: 'root' }) +export class ProgramService { + private _httpClient = inject(HttpClient) + 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') + }), + ) + } +} diff --git a/src/@seed/api/program/program.types.ts b/src/@seed/api/program/program.types.ts new file mode 100644 index 00000000..9039fffc --- /dev/null +++ b/src/@seed/api/program/program.types.ts @@ -0,0 +1,19 @@ +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[]; +} + +export type ProgramResponse = { + status: string; + compliance_metrics: Program[]; +} 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/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-compact.component.html b/src/app/modules/insights/config/program-config-compact.component.html new file mode 100644 index 00000000..108c98c2 --- /dev/null +++ b/src/app/modules/insights/config/program-config-compact.component.html @@ -0,0 +1,165 @@ + + +
+
+
+ + Select Program + + @for (metric of data.complianceMetrics; track $index) { + {{ metric.name }} + } + + + + +
+ + + +
+
+
+ + General Settings +
+
+ Configure your program metric to enable visualizations on the program overview page. +
+
+ + +
+ + Name + + + + + + Cycles + Select cycles to be included in the compliance period + + @for (cycle of data.cycles; track $index) { + {{ cycle.name }} + } + + + +
+ + +
+
+ + 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
+ + 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
+ + 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 + + @for (column of xAxisColumns; track $index) { + {{ column.display_name }} + } + + +
+ +
+
+
+
+ +
+ +
+ + +
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..c0b9ded1 --- /dev/null +++ b/src/app/modules/insights/config/program-config.component.html @@ -0,0 +1,211 @@ + + +
+
+
+ + Select Program + + @for (program of data.programs; track $index) { + {{ program.name }} + } + + + @if (selectedProgram) { + + + } +
+ +
+ + + +
+
+
+ + 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 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..757c79b2 --- /dev/null +++ b/src/app/modules/insights/config/program-config.component.ts @@ -0,0 +1,145 @@ +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 { 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' +import type { Observable } from 'rxjs' +import { finalize, Subject, take } from 'rxjs' + +@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'] + xAxisColumns: Column[] + xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] + maxHeight = window.innerHeight - 200 + selectedProgram: Program | null = null + + data = inject(MAT_DIALOG_DATA) as { + program: Program[]; + cycles: Cycle[]; + filterGroups: unknown[]; + selectedProgram: Program; + org: Organization; + propertyColumns: 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)) + this.xAxisColumns = this.data.propertyColumns.filter((c) => this.validColumn(c, this.xAxisDataTypes)) + } + + 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.selectedProgram = program + this.form.patchValue(program) + } + + newProgram() { + this.selectedProgram = null + this.form.reset() + } + + removeProgram() { + this._programService.delete(this.data.org.id, this.selectedProgram.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.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.selectedProgram + ? this._programService.update(this.data.org.id, this.selectedProgram.id, data) + : this._programService.create(this.data.org.id, data) + + request$.pipe( + take(1), + finalize(() => { this.close(true) }), + ).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/program-overview/program-overview.component.html b/src/app/modules/insights/program-overview/program-overview.component.html index 7d182487..abd3f9c9 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,32 @@ - -
Program Overview Content
+ +
+ +
+ + Programs + + @for (program of programs; track $index) { + + {{ program.name }} + + } + + +
+ + + +
+ {{ selectedProgram | json}} +
+ +
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..0091bf39 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,83 @@ +import { CommonModule } from '@angular/common' import type { OnInit } from '@angular/core' -import { Component } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { combineLatest, filter, switchMap, tap } from 'rxjs' +import type { Column, Cycle, Organization, Program } from '@seed/api' +import { ColumnService, CycleService, OrganizationService } from '@seed/api' +import { ProgramService } from '@seed/api/program' import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +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, + ], }) export class ProgramOverviewComponent implements OnInit { + private _columnService = inject(ColumnService) + private _cycleService = inject(CycleService) + private _programService = inject(ProgramService) + private _dialog = inject(MatDialog) + private _organizationService = inject(OrganizationService) + + programs: Program[] + cycles: Cycle[] + selectedProgram: Program + org: Organization + orgId: number + propertyColumns: Column[] + filterGroups: unknown[] + ngOnInit(): void { - console.log('Program Overview') + this.getDependencies() + } + + getDependencies() { + this._organizationService.currentOrganization$ + .pipe( + tap((org) => { this.org = org }), + switchMap(() => combineLatest({ + cycles: this._cycleService.cycles$, + programs: this._programService.programs$, + propertyColumns: this._columnService.propertyColumns$, + })), + tap(({ cycles, programs, propertyColumns }) => { + this.orgId = this.org.id + this.programs = programs.sort((a, b) => naturalSort(a.name, b.name)) + this.cycles = cycles + this.propertyColumns = propertyColumns + this.selectedProgram = programs?.[0] + }), + ) + .subscribe() + } + + openProgramConfig = () => { + const dialogRef = this._dialog.open(ProgramConfigComponent, { + width: '50rem', + data: { + cycles: this.cycles, + filterGroups: this.filterGroups, + programs: this.programs, + selectedProgram: this.selectedProgram, + org: this.org, + propertyColumns: this.propertyColumns?.sort((a, b) => naturalSort(a.display_name, b.display_name)), + }, + }) + + dialogRef + .afterClosed() + .pipe( + filter(Boolean), + tap((program: Program) => { this.selectedProgram = program }), + ) + .subscribe() } } 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..faefdf75 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,7 @@ - +
Property Insights Content
diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 7d8f6a06..38190e64 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -282,4 +282,8 @@ .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) { + width: 700px !important; +} From bcc1be4fb5833ecf9df8ff2a77430a278b3ba14b Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 20 Aug 2025 21:11:23 +0000 Subject: [PATCH 02/22] guard org mismatch on load inventory --- src/app/modules/inventory-list/list/inventory.component.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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(), From 0e25adfac525eb593d19c5f8d9197677823f38be Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 21 Aug 2025 17:44:24 +0000 Subject: [PATCH 03/22] program overview fxnal sans click events --- package.json | 1 + pnpm-lock.yaml | 16 ++ src/@seed/api/program/program.service.ts | 14 +- src/@seed/api/program/program.types.ts | 23 ++- src/app/chartjs-setup.ts | 2 + src/app/core/navigation/navigation.service.ts | 2 +- .../config/program-config.component.ts | 15 +- .../program-overview.component.html | 68 ++++++-- .../program-overview.component.ts | 111 ++++++------ .../modules/insights/program-wrapper/index.ts | 1 + .../program-wrapper.directive.ts | 165 ++++++++++++++++++ src/main.ts | 1 + 12 files changed, 332 insertions(+), 87 deletions(-) create mode 100644 src/app/chartjs-setup.ts create mode 100644 src/app/modules/insights/program-wrapper/index.ts create mode 100644 src/app/modules/insights/program-wrapper/program-wrapper.directive.ts diff --git a/package.json b/package.json index d2305453..c4825780 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@jsverse/transloco": "^7.5.1", "ag-grid-angular": "^33.1.1", "ag-grid-community": "^33.1.1", + "chart.js": "^4.5.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..25a96926 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: ag-grid-community: specifier: ^33.1.1 version: 33.1.1 + chart.js: + specifier: ^4.5.0 + version: 4.5.0 crypto-es: specifier: ^2.1.0 version: 2.1.0 @@ -1953,6 +1956,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==} @@ -3054,6 +3060,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chart.js@4.5.0: + resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==} + engines: {pnpm: '>=8'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -8686,6 +8696,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))': @@ -9815,6 +9827,10 @@ snapshots: chardet@0.7.0: {} + chart.js@4.5.0: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 diff --git a/src/@seed/api/program/program.service.ts b/src/@seed/api/program/program.service.ts index a4985d8b..228dc817 100644 --- a/src/@seed/api/program/program.service.ts +++ b/src/@seed/api/program/program.service.ts @@ -6,7 +6,7 @@ import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { Observable } from 'rxjs' import { catchError, map, ReplaySubject, tap } from 'rxjs' import { UserService } from '../user' -import type { Program, ProgramResponse } from './program.types' +import type { Program, ProgramData, ProgramResponse, ProgramsResponse } from './program.types' @Injectable({ providedIn: 'root' }) export class ProgramService { @@ -28,7 +28,7 @@ export class ProgramService { list(orgId: number) { const url = `/api/v3/compliance_metrics/?organization_id=${orgId}` - this._httpClient.get(url).pipe( + this._httpClient.get(url).pipe( map(({ compliance_metrics }) => { this.programs$.next(compliance_metrics) return compliance_metrics @@ -77,4 +77,14 @@ export class ProgramService { }), ) } + + evaluate(orgId: number, programId: number): Observable { + const url = `/api/v3/compliance_metrics/${programId}/evaluate/?organization_id=${orgId}` + 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 index 9039fffc..7faaefb2 100644 --- a/src/@seed/api/program/program.types.ts +++ b/src/@seed/api/program/program.types.ts @@ -13,7 +13,28 @@ export type Program = { x_axis_columns: number[]; } -export type ProgramResponse = { +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: Program; + name: string; + properties_by_cycles: Record[]>; + // compliant -> n: No, u: Unknown, y: Yes + results_by_cycle: { n: number[]; u: number[]; y: number[] }; +} + +type GraphData = { + datasets: { data: number[]; label: string; backgroundColor?: string }[]; + labels: string[]; +} diff --git a/src/app/chartjs-setup.ts b/src/app/chartjs-setup.ts new file mode 100644 index 00000000..20b2e203 --- /dev/null +++ b/src/app/chartjs-setup.ts @@ -0,0 +1,2 @@ +import { Chart, registerables } from 'chart.js' +Chart.register(...registerables) 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/insights/config/program-config.component.ts b/src/app/modules/insights/config/program-config.component.ts index 757c79b2..086c62e9 100644 --- a/src/app/modules/insights/config/program-config.component.ts +++ b/src/app/modules/insights/config/program-config.component.ts @@ -13,7 +13,7 @@ import { MaterialImports } from '@seed/materials' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { Organization } from 'app/modules/organizations/organizations.types' import type { Observable } from 'rxjs' -import { finalize, Subject, take } from 'rxjs' +import { finalize, Subject, take, tap } from 'rxjs' @Component({ selector: 'seed-program-config', @@ -32,7 +32,7 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { private _dialogRef = inject(MatDialogRef) private _programService = inject(ProgramService) private _snackBar = inject(SnackBarService) - private _unsubscribeAll = new Subject() + private _unsubscribeAll$ = new Subject() metricTypes = [ { key: 'Target Greater Than Actual', value: 'Target > Actual for Compliance' }, @@ -46,7 +46,7 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { selectedProgram: Program | null = null data = inject(MAT_DIALOG_DATA) as { - program: Program[]; + programs: Program[]; cycles: Cycle[]; filterGroups: unknown[]; selectedProgram: Program; @@ -130,7 +130,10 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { request$.pipe( take(1), - finalize(() => { this.close(true) }), + tap(({ compliance_metric }) => { + const programId = compliance_metric.id + this.close(programId) + }), ).subscribe() } @@ -139,7 +142,7 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this._unsubscribeAll.next() - this._unsubscribeAll.complete() + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() } } 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 abd3f9c9..5056a355 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.html +++ b/src/app/modules/insights/program-overview/program-overview.component.html @@ -1,32 +1,68 @@ -
+
-
- - Programs - - @for (program of programs; track $index) { - - {{ program.name }} - - } - - +
+ +
+ + Select Program + + @for (program of programs; track $index) { + + {{ program.name }} + + } + + +
+ + +
+
+ @for (entry of colors | keyvalue; track $index) { +
+
+ {{ entry.key | titlecase }} +
+ } +
+
-
- {{ selectedProgram | json}} + +
+ @if (selectedProgram) { +
+ +
{{ chartName | titlecase }}
+ +
+ } +
+ +
-
+ + @if (loading) { +
+ +
+ } + + @if (!selectedProgram) { +
+ +
+ } 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 0091bf39..35a4b811 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.ts +++ b/src/app/modules/insights/program-overview/program-overview.component.ts @@ -1,15 +1,11 @@ import { CommonModule } from '@angular/common' -import type { OnInit } from '@angular/core' -import { Component, inject } from '@angular/core' -import { MatDialog } from '@angular/material/dialog' -import { combineLatest, filter, switchMap, tap } from 'rxjs' -import type { Column, Cycle, Organization, Program } from '@seed/api' -import { ColumnService, CycleService, OrganizationService } from '@seed/api' -import { ProgramService } from '@seed/api/program' -import { PageComponent } from '@seed/components' +import type { ElementRef, OnInit } from '@angular/core' +import { Component, ViewChild } from '@angular/core' +import type { TooltipItem } from 'chart.js' +import { Chart } from 'chart.js' +import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' -import { naturalSort } from '@seed/utils' -import { ProgramConfigComponent } from '../config' +import { ProgramWrapperDirective } from '../program-wrapper' @Component({ selector: 'seed-program-overview', @@ -18,66 +14,59 @@ import { ProgramConfigComponent } from '../config' CommonModule, MaterialImports, PageComponent, + ProgressBarComponent, + NotFoundComponent, ], }) -export class ProgramOverviewComponent implements OnInit { - private _columnService = inject(ColumnService) - private _cycleService = inject(CycleService) - private _programService = inject(ProgramService) - private _dialog = inject(MatDialog) - private _organizationService = inject(OrganizationService) - - programs: Program[] - cycles: Cycle[] - selectedProgram: Program - org: Organization - orgId: number - propertyColumns: Column[] - filterGroups: unknown[] +export class ProgramOverviewComponent extends ProgramWrapperDirective implements OnInit { + @ViewChild('chart', { static: true }) canvas!: ElementRef + chart: Chart ngOnInit(): void { - this.getDependencies() - } - - getDependencies() { - this._organizationService.currentOrganization$ - .pipe( - tap((org) => { this.org = org }), - switchMap(() => combineLatest({ - cycles: this._cycleService.cycles$, - programs: this._programService.programs$, - propertyColumns: this._columnService.propertyColumns$, - })), - tap(({ cycles, programs, propertyColumns }) => { - this.orgId = this.org.id - this.programs = programs.sort((a, b) => naturalSort(a.name, b.name)) - this.cycles = cycles - this.propertyColumns = propertyColumns - this.selectedProgram = programs?.[0] - }), - ) - .subscribe() + super.ngOnInit() } - openProgramConfig = () => { - const dialogRef = this._dialog.open(ProgramConfigComponent, { - width: '50rem', + initChart() { + this.chart?.destroy() + this.chart = new Chart(this.canvas.nativeElement, { + type: 'bar', data: { - cycles: this.cycles, - filterGroups: this.filterGroups, - programs: this.programs, - selectedProgram: this.selectedProgram, - org: this.org, - propertyColumns: this.propertyColumns?.sort((a, b) => naturalSort(a.display_name, b.display_name)), + labels: [], + datasets: [], + }, + options: { + plugins: { + title: { display: true, align: 'start' }, + legend: { display: false }, + tooltip: { + callbacks: { footer: (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, }, }) + } + + tooltipFooter(tooltipItems: TooltipItem<'bar'>[]) { + const tooltipItem = tooltipItems[0] + if (!tooltipItem) return '' - dialogRef - .afterClosed() - .pipe( - filter(Boolean), - tap((program: Program) => { this.selectedProgram = program }), - ) - .subscribe() + const { dataIndex } = tooltipItem + const barValues = this.chart.data.datasets.map((ds) => ds.data[dataIndex]) as number[] + const barTotal = barValues.reduce((acc, cur) => acc + cur, 0) + return `${((tooltipItem.raw as number / barTotal) * 100).toPrecision(4)}%` } } diff --git a/src/app/modules/insights/program-wrapper/index.ts b/src/app/modules/insights/program-wrapper/index.ts new file mode 100644 index 00000000..449f6478 --- /dev/null +++ b/src/app/modules/insights/program-wrapper/index.ts @@ -0,0 +1 @@ +export * from './program-wrapper.directive' diff --git a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts new file mode 100644 index 00000000..49d9fa40 --- /dev/null +++ b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts @@ -0,0 +1,165 @@ +import type { ElementRef, OnDestroy, OnInit } from '@angular/core' +import { Directive, inject, ViewChild } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { Chart } from 'chart.js' +import { combineLatest, filter, Subject, take, takeUntil, tap } from 'rxjs' +import type { Column, Cycle, Organization, Program, ProgramData } from '@seed/api' +import { ColumnService, CycleService, OrganizationService, UserService } from '@seed/api' +import { ProgramService } from '@seed/api/program' +import { ConfigService } from '@seed/services' +import { naturalSort } from '@seed/utils' +import { ProgramConfigComponent } from '../config' + +@Directive() +export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { + @ViewChild('chart', { 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 _userService = inject(UserService) + 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 + orgId: number + programs: Program[] + propertyColumns: Column[] + selectedProgram: Program + + ngOnInit(): void { + console.log('init') + this.initChart() + this.getDependencies() + this.setScheme() + } + + getDependencies() { + combineLatest({ + org: this._organizationService.currentOrganization$, + cycles: this._cycleService.cycles$, + propertyColumns: this._columnService.propertyColumns$, + programs: this._programService.programs$, + }).pipe( + tap(({ org, cycles, propertyColumns, programs }) => { + this.org = org + this.cycles = cycles + this.propertyColumns = propertyColumns + + this.setProgram(programs, org) + }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + 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() + }) + } + + setProgram(programs: Program[], org: Organization) { + if (!programs.length) this.programChange(null) + + // if org mismatch set selectedProgram to null + this.programs = programs.filter((p) => p.organization_id === org.id).sort((a, b) => naturalSort(a.name, b.name)) + const program = this.selectedProgram?.organization_id === org.id + ? this.selectedProgram + : this.programs?.[0] + this.programChange(program) + } + + programChange(program: Program) { + this.selectedProgram = program + if (!program) { + this.initChart() + this.loading = false + return + } + + this._programService.evaluate(this.org.id, program.id) + .pipe( + tap((data) => { + this.data = data + this.loading = false + this.setChartName(program) + this.loadDatasets() + }), + take(1), + ).subscribe() + } + + 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}` + } + + loadDatasets() { + 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() + } + + refreshChart() { + if (!this.selectedProgram) return + this.initChart() + this.programChange(this.selectedProgram) + } + + 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.selectedProgram, + org: this.org, + propertyColumns: this.propertyColumns?.sort((a, b) => naturalSort(a.display_name, b.display_name)), + }, + }) + + dialogRef + .afterClosed() + .pipe( + filter(Boolean), + tap((programId: number) => { this.selectedProgram = this.programs.find((p) => p.id == programId) }), + ) + .subscribe() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + protected abstract initChart(): void +} 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) From dac55ab16cc9319ebf2b5e6f2ac1c424730c56d5 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 22 Aug 2025 17:33:33 +0000 Subject: [PATCH 04/22] property insights dev --- src/@seed/api/program/program.types.ts | 2 +- .../program-config-compact.component.html | 4 +- .../config/program-config.component.html | 6 +- .../config/program-config.component.ts | 6 +- .../program-overview.component.html | 2 +- .../program-overview.component.ts | 3 +- .../program-wrapper.directive.ts | 27 ++++- .../property-insights.component.html | 111 +++++++++++++++++- .../property-insights.component.ts | 109 ++++++++++++++++- src/styles/styles.scss | 8 +- 10 files changed, 254 insertions(+), 24 deletions(-) diff --git a/src/@seed/api/program/program.types.ts b/src/@seed/api/program/program.types.ts index 7faaefb2..40e869ea 100644 --- a/src/@seed/api/program/program.types.ts +++ b/src/@seed/api/program/program.types.ts @@ -31,7 +31,7 @@ export type ProgramData = { name: string; properties_by_cycles: Record[]>; // compliant -> n: No, u: Unknown, y: Yes - results_by_cycle: { n: number[]; u: number[]; y: number[] }; + results_by_cycles: { n: number[]; u: number[]; y: number[] }; } type GraphData = { diff --git a/src/app/modules/insights/config/program-config-compact.component.html b/src/app/modules/insights/config/program-config-compact.component.html index 108c98c2..4f3491e9 100644 --- a/src/app/modules/insights/config/program-config-compact.component.html +++ b/src/app/modules/insights/config/program-config-compact.component.html @@ -45,7 +45,7 @@ Cycles Select cycles to be included in the compliance period - + @for (cycle of data.cycles; track $index) { {{ cycle.name }} } @@ -73,7 +73,7 @@
Energy
Actual Field - + @for (column of metricColumns; track $index) { {{ column.display_name }} } diff --git a/src/app/modules/insights/config/program-config.component.html b/src/app/modules/insights/config/program-config.component.html index c0b9ded1..b8146b41 100644 --- a/src/app/modules/insights/config/program-config.component.html +++ b/src/app/modules/insights/config/program-config.component.html @@ -94,7 +94,7 @@ Actual Field - + @for (column of metricColumns; track $index) { {{ column.display_name }} @@ -176,9 +176,9 @@
X-Axis Field Options - + Columns - @for (column of xAxisColumns; track $index) { + @for (column of data.xAxisColumns; track $index) { {{ column.display_name }} } diff --git a/src/app/modules/insights/config/program-config.component.ts b/src/app/modules/insights/config/program-config.component.ts index 086c62e9..a43ba4d0 100644 --- a/src/app/modules/insights/config/program-config.component.ts +++ b/src/app/modules/insights/config/program-config.component.ts @@ -40,8 +40,6 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { ] metricColumns: Column[] metricDataTypes = ['number', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] - xAxisColumns: Column[] - xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] maxHeight = window.innerHeight - 200 selectedProgram: Program | null = null @@ -52,6 +50,7 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { selectedProgram: Program; org: Organization; propertyColumns: Column[]; + xAxisColumns: Column[]; } form = new FormGroup({ @@ -70,7 +69,6 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { ngOnInit(): void { this.metricColumns = this.data.propertyColumns.filter((c) => this.validColumn(c, this.metricDataTypes)) - this.xAxisColumns = this.data.propertyColumns.filter((c) => this.validColumn(c, this.xAxisDataTypes)) } validColumn(column: Column, validTypes: string[]) { @@ -108,7 +106,7 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { } getColumn(id: number) { - return this.xAxisColumns.find((column) => column.id === id).display_name + return this.data.xAxisColumns.find((column) => column.id === id).display_name } hasMetric() { 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 5056a355..5ccf608a 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.html +++ b/src/app/modules/insights/program-overview/program-overview.component.html @@ -49,7 +49,7 @@
}
- +
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 35a4b811..dc75febb 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.ts +++ b/src/app/modules/insights/program-overview/program-overview.component.ts @@ -19,10 +19,11 @@ import { ProgramWrapperDirective } from '../program-wrapper' ], }) export class ProgramOverviewComponent extends ProgramWrapperDirective implements OnInit { - @ViewChild('chart', { static: true }) canvas!: ElementRef + @ViewChild('programOverviewChart', { static: true }) canvas!: ElementRef chart: Chart ngOnInit(): void { + // ProgramWrapperDirective init super.ngOnInit() } diff --git a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts index 49d9fa40..581e3b79 100644 --- a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts +++ b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts @@ -19,9 +19,10 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { private _programService = inject(ProgramService) private _dialog = inject(MatDialog) private _organizationService = inject(OrganizationService) - private _userService = inject(UserService) private _unsubscribeAll$ = new Subject() + accessLevelInstances = ['temp instance1', 'temp instance2'] + accessLevels = ['temp level1', 'temp level2'] chart: Chart chartName: string colors: Record = { compliant: '#77CCCB', 'non-compliant': '#A94455', unknown: '#DDDDDD' } @@ -32,11 +33,15 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { org: Organization orgId: number programs: Program[] + programChange$ = new Subject() + programCycles: Cycle[] = [] + programXAxisColumns: Column[] = [] propertyColumns: Column[] selectedProgram: Program + xAxisColumns: Column[] + xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] ngOnInit(): void { - console.log('init') this.initChart() this.getDependencies() this.setScheme() @@ -53,6 +58,7 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { this.org = org this.cycles = cycles this.propertyColumns = propertyColumns + this.xAxisColumns = this.propertyColumns.filter((c) => this.validColumn(c, this.xAxisDataTypes)) this.setProgram(programs, org) }), @@ -82,9 +88,11 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { programChange(program: Program) { this.selectedProgram = program + this.setProgramModels() if (!program) { this.initChart() this.loading = false + this.programChange$.next() return } @@ -95,11 +103,18 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { this.loading = false this.setChartName(program) this.loadDatasets() + this.programChange$.next() }), take(1), ).subscribe() } + setProgramModels() { + const { cycles, x_axis_columns } = this.selectedProgram + this.programCycles = this.cycles.filter((c) => cycles.includes(c.id)) + this.programXAxisColumns = this.xAxisColumns.filter((c) => x_axis_columns.includes(c.id)) + } + setChartName(program: Program) { if (!program) return const cycles = this.cycles.filter((c) => program.cycles.includes(c.id)) @@ -134,6 +149,13 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { a.click() } + 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 + } + openProgramConfig = () => { const dialogRef = this._dialog.open(ProgramConfigComponent, { width: '50rem', @@ -144,6 +166,7 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { selectedProgram: this.selectedProgram, org: this.org, propertyColumns: this.propertyColumns?.sort((a, b) => naturalSort(a.display_name, b.display_name)), + xAxisColumns: this.xAxisColumns, }, }) 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 faefdf75..9ff3fb6d 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -1,7 +1,110 @@ - -
Property Insights Content
+
+ +
+ +
+ + Select Program + + @for (program of programs; track $index) { + + {{ program.name }} + + } + + +
+ + +
+
+ @for (entry of colors | keyvalue; track $index) { +
+
+ {{ entry.key | titlecase }} +
+ } +
+
+
+ + + +
+ +
+ +
+ + Cycle + + @for (cycle of programCycles; track $index) { + {{ cycle.name }} + } + + + + + Metric Type + + @for (metricType of metricTypes; track $index) { + {{ metricType.value }} + } + + + + + X-Axis + + @for (column of programXAxisColumns; track $index) { + {{ column.display_name }} + } + + + + + Access Level + + @for (level of accessLevels; track $index) { + {{ level }} + } + + + + + Access Level Instance + + @for (instance of accessLevelInstances; track $index) { + {{ instance }} + } + + + +
+ +
+
Compliant:{{ results.y }}
+
Non-Compliant:{{ results.n }}
+
Unknown:{{ results.u }}
+
+ + +
+ +
+
+ +
+ + +
+
+
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..5f54d385 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,113 @@ -import type { OnInit } from '@angular/core' -import { Component } from '@angular/core' +import { CommonModule } from '@angular/common' +import type { ElementRef, OnInit } from '@angular/core' +import { Component, ViewChild } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { Chart, TooltipItem } from 'chart.js' import { PageComponent } from '@seed/components' +import { MaterialImports } from '@seed/materials' +import { ProgramWrapperDirective } from '../program-wrapper' @Component({ selector: 'seed-property-insights', templateUrl: './property-insights.component.html', - imports: [PageComponent], + imports: [ + CommonModule, + FormsModule, + PageComponent, + MaterialImports, + ReactiveFormsModule, + ], }) -export class PropertyInsightsComponent implements OnInit { +export class PropertyInsightsComponent extends ProgramWrapperDirective implements OnInit { + @ViewChild('propertyInsightsChart', { static: true }) canvas!: ElementRef + + results = { y: 0, n: 0, u: 0 } + metricTypes = [ + { key: 'energy', value: 'Energy Metric' }, + { key: 'emission', value: 'Emission Metric' }, + ] + + form = new FormGroup({ + cycleId: new FormControl(null), + metricType: new FormControl<'Emission Metric' | 'Energy Metric'>(null), + xAxisColumnId: new FormControl(null), + accessLevel: new FormControl(null), + accessLevelInstance: new FormControl(null), + }) + ngOnInit(): void { - console.log('Property Insights') + // ProgramWrapperDirective init + super.ngOnInit() + + this.programChange$.subscribe(() => { + if (!this.selectedProgram) return + this.patchForm() + this.setResults() + }) + } + + patchForm() { + const { cycles, x_axis_columns } = this.selectedProgram + const data: Record = { + cycleId: cycles[0], + xAxisColumnId: x_axis_columns[0], + metricType: 'energy', + accessLevel: this.accessLevels[0], + accessLevelInstance: this.accessLevelInstances[0], + } + this.form.patchValue(data) + } + + setResults() { + console.log('data', !!this.data) + const cycleId = this.form.value.cycleId + const { y, n, u } = this.data.results_by_cycles[cycleId] as { y: number[]; n: number[]; u: number[] } + this.results = { y: y.length, n: n.length, u: u.length } + } + + initChart() { + this.chart?.destroy() + this.chart = new Chart(this.canvas.nativeElement, { + type: 'scatter', + data: { + labels: [], + datasets: [], + }, + options: { + onClick: () => { console.log('setup click events') }, + elements: { + point: { + radius: 5, + }, + }, + plugins: { + title: { + display: true, + align: 'start', + }, + legend: { + display: false, + }, + tooltip: { + // callbacks: { + // label: (context: TooltipItem<'scatter'>) => { + // const text = []; + // if (context.raw.name) { + // text.push(`Property: ${context.raw.name}`) + // } else { + // text.push(`Property ID: ${context.raw.id}`) + // } + // return text + // } + // } + }, + }, + + scales: { + x: {}, + y: {}, + }, + }, + }) } } diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 38190e64..cde1c268 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -284,6 +284,12 @@ border-left: none !important; } -.cdk-overlay-pane:has(.wide-select) { +.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; +} From 4b49acc923b437f4bcb6a718913dae5ccc4fbe93 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 26 Aug 2025 16:40:51 +0000 Subject: [PATCH 05/22] property inisghts base --- package.json | 2 + pnpm-lock.yaml | 37 +++ src/@seed/api/program/program.types.ts | 20 +- src/app/chartjs-setup.ts | 5 + .../program-overview.component.ts | 19 ++ .../program-wrapper.directive.ts | 35 +-- .../property-insights.component.ts | 271 ++++++++++++++++-- 7 files changed, 348 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index c4825780..8b86fe78 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "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 25a96926..caa3861a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,12 @@ importers: 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 @@ -2506,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==} @@ -3064,6 +3073,16 @@ packages: 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'} @@ -4184,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==} @@ -9164,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': @@ -9831,6 +9856,16 @@ snapshots: 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 @@ -11208,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/program/program.types.ts b/src/@seed/api/program/program.types.ts index 40e869ea..91e094b9 100644 --- a/src/@seed/api/program/program.types.ts +++ b/src/@seed/api/program/program.types.ts @@ -1,3 +1,5 @@ +import type { ChartDataset } from 'chart.js' + export type Program = { actual_emission_column: number; actual_energy_column: number; @@ -11,6 +13,8 @@ export type Program = { target_emission_column: number; target_energy_column: number; x_axis_columns: number[]; + energy_bool?: boolean; + emission_bool?: boolean; } export type ProgramsResponse = { @@ -30,11 +34,23 @@ export type ProgramData = { metric: Program; name: string; properties_by_cycles: Record[]>; - // compliant -> n: No, u: Unknown, y: Yes - results_by_cycles: { n: number[]; u: number[]; y: number[] }; + 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; +} diff --git a/src/app/chartjs-setup.ts b/src/app/chartjs-setup.ts index 20b2e203..db3861b5 100644 --- a/src/app/chartjs-setup.ts +++ b/src/app/chartjs-setup.ts @@ -1,2 +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/modules/insights/program-overview/program-overview.component.ts b/src/app/modules/insights/program-overview/program-overview.component.ts index dc75febb..c122b8bb 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.ts +++ b/src/app/modules/insights/program-overview/program-overview.component.ts @@ -24,7 +24,26 @@ export class ProgramOverviewComponent extends ProgramWrapperDirective implements ngOnInit(): void { // ProgramWrapperDirective init + this.initChart() super.ngOnInit() + this.setScheme() + + this.programChange$.subscribe(() => { + this.loadDatasets() + }) + } + + loadDatasets() { + 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(this.chart) } initChart() { diff --git a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts index 581e3b79..9b4646f8 100644 --- a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts +++ b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts @@ -1,7 +1,7 @@ import type { ElementRef, OnDestroy, OnInit } from '@angular/core' import { Directive, inject, ViewChild } from '@angular/core' import { MatDialog } from '@angular/material/dialog' -import { Chart } from 'chart.js' +import type { Chart } from 'chart.js' import { combineLatest, filter, Subject, take, takeUntil, tap } from 'rxjs' import type { Column, Cycle, Organization, Program, ProgramData } from '@seed/api' import { ColumnService, CycleService, OrganizationService, UserService } from '@seed/api' @@ -19,7 +19,7 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { private _programService = inject(ProgramService) private _dialog = inject(MatDialog) private _organizationService = inject(OrganizationService) - private _unsubscribeAll$ = new Subject() + protected _unsubscribeAll$ = new Subject() accessLevelInstances = ['temp instance1', 'temp instance2'] accessLevels = ['temp level1', 'temp level2'] @@ -42,9 +42,7 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] ngOnInit(): void { - this.initChart() this.getDependencies() - this.setScheme() } getDependencies() { @@ -88,27 +86,32 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { programChange(program: Program) { this.selectedProgram = program - this.setProgramModels() if (!program) { - this.initChart() - this.loading = false - this.programChange$.next() + this.clearChart() return } + this.setProgramModels() this._programService.evaluate(this.org.id, program.id) .pipe( tap((data) => { this.data = data + this.programChange$.next() this.loading = false this.setChartName(program) - this.loadDatasets() - this.programChange$.next() }), take(1), ).subscribe() } + clearChart() { + this.programCycles = [] + this.programXAxisColumns = [] + this.loading = false + // this.programChange$.next() // necessary? + this.initChart() + } + setProgramModels() { const { cycles, x_axis_columns } = this.selectedProgram this.programCycles = this.cycles.filter((c) => cycles.includes(c.id)) @@ -124,18 +127,6 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { this.chartName = `${program.name}: ${cycleRange}` } - loadDatasets() { - 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() - } - refreshChart() { if (!this.selectedProgram) return this.initChart() 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 5f54d385..3e77d955 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -2,7 +2,11 @@ import { CommonModule } from '@angular/common' import type { ElementRef, OnInit } from '@angular/core' import { Component, ViewChild } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import { Chart, TooltipItem } from 'chart.js' +import type { TooltipItem } from 'chart.js' +import { Chart } from 'chart.js' +import type { AnnotationOptions } from 'chartjs-plugin-annotation' +import { takeUntil, tap } from 'rxjs' +import type { Program, PropertyInsightDataset, PropertyInsightPoint, ResultsByCycles } from '@seed/api' import { PageComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ProgramWrapperDirective } from '../program-wrapper' @@ -21,23 +25,32 @@ import { ProgramWrapperDirective } from '../program-wrapper' export class PropertyInsightsComponent extends ProgramWrapperDirective implements OnInit { @ViewChild('propertyInsightsChart', { static: true }) canvas!: ElementRef - results = { y: 0, n: 0, u: 0 } + annotations: Record + datasets: PropertyInsightDataset[] = [] + displayAnnotation = true metricTypes = [ { key: 'energy', value: 'Energy Metric' }, { key: 'emission', value: 'Emission Metric' }, ] + results = { y: 0, n: 0, u: 0 } + xCategorical = false form = new FormGroup({ cycleId: new FormControl(null), - metricType: new FormControl<'Emission Metric' | 'Energy Metric'>(null), + metricType: new FormControl<'energy' | 'emission'>('energy'), xAxisColumnId: new FormControl(null), accessLevel: new FormControl(null), accessLevelInstance: new FormControl(null), + program: new FormControl(this.selectedProgram), + datasetVisibility: new FormControl([true, true, true]), + annotationVisibility: new FormControl(true), }) ngOnInit(): void { + this.initChart() // ProgramWrapperDirective init super.ngOnInit() + this.watchChart() this.programChange$.subscribe(() => { if (!this.selectedProgram) return @@ -58,6 +71,31 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement this.form.patchValue(data) } + watchChart() { + // update chart if anything changes + this.form.valueChanges.pipe( + tap(() => { + if (!this.validateProgram()) { + this.clearChart() + // this.initChart() + return + } + this.setChartSettings() + this.loadDatasets() + }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + validateProgram() { + const { actual_emission_column, actual_energy_column } = this.selectedProgram + const { metricType } = this.form.value + if (metricType === 'energy') { + return !!actual_energy_column + } + return !!actual_emission_column + } + setResults() { console.log('data', !!this.data) const cycleId = this.form.value.cycleId @@ -65,6 +103,9 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement this.results = { y: y.length, n: n.length, u: u.length } } + /* + * Step 1: Builds an empty chart + */ initChart() { this.chart?.destroy() this.chart = new Chart(this.canvas.nativeElement, { @@ -89,25 +130,221 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement display: false, }, tooltip: { - // callbacks: { - // label: (context: TooltipItem<'scatter'>) => { - // const text = []; - // if (context.raw.name) { - // text.push(`Property: ${context.raw.name}`) - // } else { - // text.push(`Property ID: ${context.raw.id}`) - // } - // return text - // } - // } + 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 + console.log('callback') + 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: {}, - y: {}, + 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, + }, + }, }, }, }) + this.setScheme() + } + + /* + * Step 2, set chart settings (axes name, background, labels...) + */ + setChartSettings() { + console.log('update chart') + if (!this.selectedProgram) return + const [xAxisName, yAxisName] = this.getXYAxisName() + const xScale = this.chart.options.scales.x + if (xScale?.type === 'linear' || xScale?.type === 'category') { + xScale.title = { display: true, text: xAxisName } + } + const yScale = this.chart.options.scales.y + if (yScale?.type === 'linear' || yScale?.type === 'category') { + yScale.title = { display: true, text: yAxisName } + } + this.chart.options.scales.x.type = this.xCategorical ? 'category' : 'linear' + this.chart.options.plugins.annotation.annotations = this.annotations + this.chart.data.datasets = this.datasets + for (const ds of this.chart.data.datasets) { + ds.backgroundColor = this.colors[ds.label] + } + 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 + }, + } + // labels for categorical + // RP - ADDRESS LABELS + // this.chart.data.labels = [] + // if (this.xCategorical) { + // let labels = [] + // for (const ds of this.datasets) { + // labels = ... + // } + // } + + for (const [idx, isVisible] of this.form.value.datasetVisibility.entries()) { + this.chart.setDatasetVisibility(idx, isVisible) + } + // this.displayAnnotation = savedConfig?.annotationVisibility ?? true + this.displayAnnotation = true + + this.chart.update() + } + + getXYAxisName(): string[] { + if (!this.selectedProgram) return [null, null] + const xAxisCol = this.programXAxisColumns.find((col) => col.id === this.form.value.xAxisColumnId) + const xAxisName = xAxisCol.display_name + const energyCol = this.propertyColumns.find((col) => col.id === this.selectedProgram.actual_energy_column) + const emissionCol = this.propertyColumns.find((col) => col.id === this.selectedProgram.actual_emission_column) + const yAxisName = this.form.value.metricType === 'emission' + ? emissionCol?.display_name + : energyCol?.display_name + + return [xAxisName, yAxisName] + } + + /* + * Step 3: Loads datasets into the chart. + */ + loadDatasets() { + if (!this.selectedProgram) return + this.xCategorical = false + this.displayAnnotation = true + + this.resetDatasets() + const annotation = this.blankAnnotation() + this.annotations = {} + + this.formatDataPoints() + // const nonCompliant = this.datasets.find(ds => ds.label === 'non-compliant') + console.log(this.datasets) + this.chart.data.datasets = this.datasets + this.chart.update() + console.log(this.chart) + } + + formatDataPoints() { + console.log('formatDataPoints') + const { metric, results_by_cycles } = this.data + const { cycleId, metricType, xAxisColumnId } = this.form.value + for (const prop of this.data.properties_by_cycles[this.form.value.cycleId]) { + const id = prop.id as number + const name = this.getValue(prop, 'startsWith', this.org.property_display_field) as string + const x = this.getValue(prop, 'endsWith', `_${xAxisColumnId}`) as number + let target: number + + if (this.xCategorical && Number.isNaN(Number(x))) { + this.xCategorical = true + } + + const actualCol = metricType === 'energy' ? metric.actual_energy_column : metric.actual_emission_column + const targetCol = metricType === 'energy' ? metric.target_energy_column : metric.target_emission_column + const hasTarget = 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 + } + + const item: PropertyInsightPoint = { id, name, x, y, target } + + // place in appropriate dataset + const cycleResult = results_by_cycles[cycleId] as ResultsByCycles + if (cycleResult.y.includes(id)) { + this.datasets[0].data.push(item) + } else if (cycleResult.n.includes(id)) { + this.datasets[1].data.push(item) + } else { + this.datasets[2].data.push(item) + } + } + } + + getValue(property: Record, fn: 'startsWith' | 'endsWith', key: string ) { + const entry = Object.entries(property).find(([k]) => k[fn](key)) + return entry?.[1] + } + + resetDatasets() { + console.log('resetDatasets') + this.datasets = [ + { data: [], label: 'compliant', pointStyle: 'circle' }, + { data: [], label: 'non-compliant', pointStyle: 'triangle', pointRadius: 7 }, + { data: [], label: 'unknown', pointStyle: 'rect' }, + ] + } + + blankAnnotation() { + return { + type: 'line', + xMin: 0, + xMax: 0, + yMin: 0, + yMax: 0, + backgroundColor: '#333', + borderWidth: 1, + display: () => this.displayAnnotation, + arrowHeads: { + end: { + display: true, + width: 9, + length: 0, + }, + }, + } } } From 8034d6f1d67fbe642f6568625bc0261c9685eea8 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 27 Aug 2025 16:04:10 +0000 Subject: [PATCH 06/22] mark duplicates in data mapping --- src/@seed/api/dataset/dataset.types.ts | 1 + src/app/ag-grid-modules.ts | 2 ++ .../data-mappings/step1/column-defs.ts | 5 --- .../data-mappings/step1/map-data.component.ts | 35 ++++++++++++++++--- 4 files changed, 33 insertions(+), 10 deletions(-) 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/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/modules/datasets/data-mappings/step1/column-defs.ts b/src/app/modules/datasets/data-mappings/step1/column-defs.ts index 3fcba898..6bc3e066 100644 --- a/src/app/modules/datasets/data-mappings/step1/column-defs.ts +++ b/src/app/modules/datasets/data-mappings/step1/column-defs.ts @@ -3,11 +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 => { 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..7215c79c 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 { From 8da7172e6c7d4adf488ce6f457653da75654a1e6 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 27 Aug 2025 21:45:40 +0000 Subject: [PATCH 07/22] legend toggle visibility --- src/@seed/api/program/program.types.ts | 20 +++- .../program-overview.component.html | 2 +- .../program-wrapper.directive.ts | 6 +- .../property-insights.component.html | 36 ++++-- .../property-insights.component.ts | 110 +++++++++++++----- src/styles/styles.scss | 3 +- 6 files changed, 139 insertions(+), 38 deletions(-) diff --git a/src/@seed/api/program/program.types.ts b/src/@seed/api/program/program.types.ts index 91e094b9..355c35aa 100644 --- a/src/@seed/api/program/program.types.ts +++ b/src/@seed/api/program/program.types.ts @@ -17,6 +17,23 @@ export type Program = { 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[]; @@ -31,7 +48,7 @@ export type ProgramData = { cycles: { id: number; name: string }[]; graph_data: GraphData; meta: { organization: number; compliance_metric: number }; - metric: Program; + metric: EvaluatedProgram; name: string; properties_by_cycles: Record[]>; results_by_cycles: ResultsByCycles; @@ -53,4 +70,5 @@ export type PropertyInsightPoint = { x: number; y: number; target: number; + distance?: number; } 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 5ccf608a..4159e63d 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.html +++ b/src/app/modules/insights/program-overview/program-overview.component.html @@ -44,8 +44,8 @@ @if (selectedProgram) {
-
{{ chartName | titlecase }}
+
{{ chartName | titlecase }}
}
diff --git a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts index 9b4646f8..56cd9f15 100644 --- a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts +++ b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts @@ -38,6 +38,7 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { programXAxisColumns: Column[] = [] propertyColumns: Column[] selectedProgram: Program + scheme: 'dark' | 'light' = 'light' xAxisColumns: Column[] xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] @@ -51,13 +52,14 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { cycles: this._cycleService.cycles$, propertyColumns: this._columnService.propertyColumns$, programs: this._programService.programs$, + scheme: this._configService.scheme$ }).pipe( - tap(({ org, cycles, propertyColumns, programs }) => { + 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.setProgram(programs, org) }), takeUntil(this._unsubscribeAll$), 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 9ff3fb6d..95f97f5b 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -24,13 +24,30 @@
-
- @for (entry of colors | keyvalue; track $index) { -
-
- {{ entry.key | titlecase }} -
- } +
+ + + + + @for (entry of colors | keyvalue; track $index; let i = $index) { + +
+
+ {{ entry.key | titlecase }} +
+
+ } + + + Distance from Target + +
@@ -40,6 +57,11 @@
+
+ + +
+
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 3e77d955..7e18da7b 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common' import type { ElementRef, OnInit } from '@angular/core' -import { Component, ViewChild } from '@angular/core' +import { Component, inject, ViewChild } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import type { TooltipItem } from 'chart.js' import { Chart } from 'chart.js' @@ -9,6 +9,7 @@ import { takeUntil, tap } from 'rxjs' import type { Program, PropertyInsightDataset, PropertyInsightPoint, ResultsByCycles } from '@seed/api' import { PageComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import { ProgramWrapperDirective } from '../program-wrapper' @Component({ @@ -24,20 +25,21 @@ import { ProgramWrapperDirective } from '../program-wrapper' }) export class PropertyInsightsComponent extends ProgramWrapperDirective implements OnInit { @ViewChild('propertyInsightsChart', { static: true }) canvas!: ElementRef + private _snackBar = inject(SnackBarService) annotations: Record datasets: PropertyInsightDataset[] = [] displayAnnotation = true metricTypes = [ - { key: 'energy', value: 'Energy Metric' }, - { key: 'emission', value: 'Emission Metric' }, + { key: 0, value: 'Energy Metric' }, + { key: 1, value: 'Emission Metric' }, ] results = { y: 0, n: 0, u: 0 } xCategorical = false form = new FormGroup({ cycleId: new FormControl(null), - metricType: new FormControl<'energy' | 'emission'>('energy'), + metricType: new FormControl<0 | 1>(0), xAxisColumnId: new FormControl(null), accessLevel: new FormControl(null), accessLevelInstance: new FormControl(null), @@ -64,7 +66,7 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement const data: Record = { cycleId: cycles[0], xAxisColumnId: x_axis_columns[0], - metricType: 'energy', + metricType: 0, accessLevel: this.accessLevels[0], accessLevelInstance: this.accessLevelInstances[0], } @@ -90,14 +92,13 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement validateProgram() { const { actual_emission_column, actual_energy_column } = this.selectedProgram const { metricType } = this.form.value - if (metricType === 'energy') { + if (metricType === 0) { return !!actual_energy_column } return !!actual_emission_column } setResults() { - console.log('data', !!this.data) const cycleId = this.form.value.cycleId const { y, n, u } = this.data.results_by_cycles[cycleId] as { y: number[]; n: number[]; u: number[] } this.results = { y: y.length, n: n.length, u: u.length } @@ -212,10 +213,6 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement } this.chart.options.scales.x.type = this.xCategorical ? 'category' : 'linear' this.chart.options.plugins.annotation.annotations = this.annotations - this.chart.data.datasets = this.datasets - for (const ds of this.chart.data.datasets) { - ds.backgroundColor = this.colors[ds.label] - } this.chart.options.scales.x.ticks = { callback(value) { const label = this.getLabelForValue(value as number) @@ -250,13 +247,19 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement const xAxisName = xAxisCol.display_name const energyCol = this.propertyColumns.find((col) => col.id === this.selectedProgram.actual_energy_column) const emissionCol = this.propertyColumns.find((col) => col.id === this.selectedProgram.actual_emission_column) - const yAxisName = this.form.value.metricType === 'emission' - ? emissionCol?.display_name - : energyCol?.display_name + const yAxisName = this.form.value.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. */ @@ -266,15 +269,24 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement this.displayAnnotation = true this.resetDatasets() - const annotation = this.blankAnnotation() - this.annotations = {} + + 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.') + this.initChart() + return + } this.formatDataPoints() - // const nonCompliant = this.datasets.find(ds => ds.label === 'non-compliant') - console.log(this.datasets) + this.formatNonCompliantPoints() this.chart.data.datasets = this.datasets + this.setDatasetColor() + this.chart.options.plugins.annotation.annotations = this.annotations this.chart.update() - console.log(this.chart) + console.log('config', this.form.value) + console.log('data', this.data) + console.log('datasets', this.datasets) + console.log('chart', this.chart) } formatDataPoints() { @@ -291,9 +303,9 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement this.xCategorical = true } - const actualCol = metricType === 'energy' ? metric.actual_energy_column : metric.actual_emission_column - const targetCol = metricType === 'energy' ? metric.target_energy_column : metric.target_emission_column - const hasTarget = metric.energy_bool || metric.emission_bool + const actualCol = metricType === 0 ? metric.actual_energy_column : metric.actual_emission_column + const targetCol = metricType === 0 ? metric.target_energy_column : metric.target_emission_column + const hasTarget = metricType === 0 ? !metric.energy_bool : !metric.emission_bool const y = this.getValue(prop, 'endsWith', `_${actualCol}`) as number if (hasTarget) { @@ -314,7 +326,40 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement } } - getValue(property: Record, fn: 'startsWith' | 'endsWith', key: string ) { + formatNonCompliantPoints() { + this.annotations = {} + const configs = this.form.value + const program = this.data.metric + const nonCompliant = this.datasets.find((ds) => ds.label === 'non-compliant') + const targetType = configs.metricType === 0 ? program.energy_metric_type : program.emission_metric_type + + // RP - need to figure out + // rank + // if (this.form.value.xAxisColumnId === 'Ranked') {} + // ... + + 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 + + console.log('addWhisker', addWhisker) + 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] } @@ -322,20 +367,20 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement resetDatasets() { console.log('resetDatasets') this.datasets = [ - { data: [], label: 'compliant', pointStyle: 'circle' }, + { data: [], label: 'compliant', pointStyle: 'circle', pointRadius: 7 }, { data: [], label: 'non-compliant', pointStyle: 'triangle', pointRadius: 7 }, { data: [], label: 'unknown', pointStyle: 'rect' }, ] } - blankAnnotation() { + blankAnnotation(): AnnotationOptions { return { type: 'line', xMin: 0, xMax: 0, yMin: 0, yMax: 0, - backgroundColor: '#333', + borderColor: () => this.scheme === 'dark' ? '#ffffffff' : '#333333', borderWidth: 1, display: () => this.displayAnnotation, arrowHeads: { @@ -347,4 +392,17 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement }, } } + + toggleVisibility(idx: number, show: boolean) { + if (idx === 3) { + for (const key of Object.keys(this.annotations)) { + if (key.startsWith('prop')) { + this.annotations[key].display = show + } + } + } else { + this.chart.setDatasetVisibility(idx, show) + } + this.chart.update() + } } diff --git a/src/styles/styles.scss b/src/styles/styles.scss index cde1c268..ddcdda67 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -258,7 +258,6 @@ // } } - .compact-form { label, span { @apply text-sm !important; @@ -287,9 +286,11 @@ .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; } From f115e01d7cd6c4f7aae3c0e8b21425f245fdfdcc Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 27 Aug 2025 22:30:24 +0000 Subject: [PATCH 08/22] click to detail --- .../program-overview.component.html | 12 ++++++--- .../property-insights.component.html | 25 ++++++++----------- .../property-insights.component.ts | 12 +++++++-- 3 files changed, 28 insertions(+), 21 deletions(-) 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 4159e63d..94207ae4 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.html +++ b/src/app/modules/insights/program-overview/program-overview.component.html @@ -26,7 +26,7 @@
-
+
@for (entry of colors | keyvalue; track $index) {
@@ -35,7 +35,12 @@ }
-
+ +
+ + +
+
@@ -43,8 +48,7 @@
@if (selectedProgram) {
- - +
{{ chartName | titlecase }}
} 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 95f97f5b..e7137754 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -50,19 +50,17 @@
+
+ + +
-
+
-
- - -
- - Cycle @@ -117,16 +115,13 @@
Unknown:{{ results.u }}
-
-
-
- -
- - -
+
+
+ +
+
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 7e18da7b..44371951 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -2,7 +2,8 @@ import { CommonModule } from '@angular/common' import type { ElementRef, OnInit } from '@angular/core' import { Component, inject, ViewChild } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import type { TooltipItem } from 'chart.js' +import { Router } from '@angular/router' +import type { ActiveElement, ChartEvent, PointElement, TooltipItem } from 'chart.js' import { Chart } from 'chart.js' import type { AnnotationOptions } from 'chartjs-plugin-annotation' import { takeUntil, tap } from 'rxjs' @@ -25,6 +26,7 @@ import { ProgramWrapperDirective } from '../program-wrapper' }) export class PropertyInsightsComponent extends ProgramWrapperDirective implements OnInit { @ViewChild('propertyInsightsChart', { static: true }) canvas!: ElementRef + private _router = inject(Router) private _snackBar = inject(SnackBarService) annotations: Record @@ -116,7 +118,13 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement datasets: [], }, options: { - onClick: () => { console.log('setup click events') }, + 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, From c3a2c933fa576d9b4d9c07f372b8b9da44f5c6fc Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 29 Aug 2025 17:02:25 +0000 Subject: [PATCH 09/22] overview and insights unique components --- .../config/program-config.component.html | 4 +- .../config/program-config.component.ts | 14 +- src/app/modules/insights/insights.routes.ts | 10 + .../program-overview.component.html | 6 +- .../program-overview.component.ts | 195 ++++++++++-- .../program-wrapper.directive.ts | 51 ++-- .../property-insights.component.html | 77 +++-- .../property-insights.component.ts | 285 ++++++++++++++---- src/styles/styles.scss | 8 + 9 files changed, 510 insertions(+), 140 deletions(-) diff --git a/src/app/modules/insights/config/program-config.component.html b/src/app/modules/insights/config/program-config.component.html index b8146b41..911bc63c 100644 --- a/src/app/modules/insights/config/program-config.component.html +++ b/src/app/modules/insights/config/program-config.component.html @@ -12,8 +12,8 @@ } - @if (selectedProgram) { - diff --git a/src/app/modules/insights/config/program-config.component.ts b/src/app/modules/insights/config/program-config.component.ts index a43ba4d0..57ec65d6 100644 --- a/src/app/modules/insights/config/program-config.component.ts +++ b/src/app/modules/insights/config/program-config.component.ts @@ -41,13 +41,13 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { metricColumns: Column[] metricDataTypes = ['number', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] maxHeight = window.innerHeight - 200 - selectedProgram: Program | null = null + program: Program | null = null data = inject(MAT_DIALOG_DATA) as { programs: Program[]; cycles: Cycle[]; filterGroups: unknown[]; - selectedProgram: Program; + program: Program; org: Organization; propertyColumns: Column[]; xAxisColumns: Column[]; @@ -79,17 +79,17 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { } selectProgram(program: Program) { - this.selectedProgram = program + this.program = program this.form.patchValue(program) } newProgram() { - this.selectedProgram = null + this.program = null this.form.reset() } removeProgram() { - this._programService.delete(this.data.org.id, this.selectedProgram.id) + this._programService.delete(this.data.org.id, this.program.id) .pipe( take(1), finalize(() => { this.close() }), @@ -122,8 +122,8 @@ export class ProgramConfigComponent implements OnInit, OnDestroy { } const data = this.form.value as Program - const request$: Observable = this.selectedProgram - ? this._programService.update(this.data.org.id, this.selectedProgram.id, data) + 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( 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 94207ae4..cf76b3a2 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.html +++ b/src/app/modules/insights/program-overview/program-overview.component.html @@ -14,7 +14,7 @@
Select Program - + @for (program of programs; track $index) { {{ program.name }} @@ -46,7 +46,7 @@
- @if (selectedProgram) { + @if (program) {
{{ chartName | titlecase }}
@@ -64,7 +64,7 @@
} - @if (!selectedProgram) { + @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 c122b8bb..297b5e79 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.ts +++ b/src/app/modules/insights/program-overview/program-overview.component.ts @@ -1,11 +1,19 @@ import { CommonModule } from '@angular/common' -import type { ElementRef, OnInit } from '@angular/core' -import { Component, ViewChild } from '@angular/core' -import type { TooltipItem } from 'chart.js' +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 { ProgramWrapperDirective } from '../program-wrapper' +import { ConfigService } from '@seed/services' +import { naturalSort } from '@seed/utils' +import { ProgramConfigComponent } from '../config' @Component({ selector: 'seed-program-overview', @@ -18,22 +26,91 @@ import { ProgramWrapperDirective } from '../program-wrapper' NotFoundComponent, ], }) -export class ProgramOverviewComponent extends ProgramWrapperDirective 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 { - // ProgramWrapperDirective init + this._route.paramMap.subscribe((params: ParamMap) => { + this.programId = parseInt(params.get('id')) + this.initProgram() + }) + } + + initProgram() { + this.getDependencies() this.initChart() - super.ngOnInit() this.setScheme() + } - this.programChange$.subscribe(() => { - this.loadDatasets() - }) + 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) + }), + ) } - loadDatasets() { + setDatasets() { if (!this.data.graph_data) return const { labels, datasets } = this.data.graph_data for (const ds of datasets) { @@ -43,7 +120,9 @@ export class ProgramOverviewComponent extends ProgramWrapperDirective implements this.chart.data.labels = labels this.chart.data.datasets = datasets this.chart.update() - console.log(this.chart) + console.log('ALL DATA', { + chart: this.chart, + }) } initChart() { @@ -55,11 +134,21 @@ export class ProgramOverviewComponent extends ProgramWrapperDirective implements 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: { footer: (ctx) => { this.tooltipFooter(ctx) } }, + callbacks: { label: (ctx) => this.tooltipFooter(ctx) }, }, }, scales: { @@ -78,15 +167,85 @@ export class ProgramOverviewComponent extends ProgramWrapperDirective implements maintainAspectRatio: false, }, }) + this.setScheme() } - tooltipFooter(tooltipItems: TooltipItem<'bar'>[]) { - const tooltipItem = tooltipItems[0] - if (!tooltipItem) return '' + 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 { dataIndex } = tooltipItem const barValues = this.chart.data.datasets.map((ds) => ds.data[dataIndex]) as number[] const barTotal = barValues.reduce((acc, cur) => acc + cur, 0) - return `${((tooltipItem.raw as number / barTotal) * 100).toPrecision(4)}%` + 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/program-wrapper/program-wrapper.directive.ts b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts index 56cd9f15..34490396 100644 --- a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts +++ b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts @@ -2,13 +2,15 @@ import type { ElementRef, OnDestroy, OnInit } from '@angular/core' import { Directive, inject, ViewChild } from '@angular/core' import { MatDialog } from '@angular/material/dialog' import type { Chart } from 'chart.js' -import { combineLatest, filter, Subject, take, takeUntil, tap } from 'rxjs' +import { combineLatest, filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' import type { Column, Cycle, Organization, Program, ProgramData } from '@seed/api' -import { ColumnService, CycleService, OrganizationService, UserService } from '@seed/api' +import { ColumnService, CycleService, OrganizationService } from '@seed/api' import { ProgramService } from '@seed/api/program' import { ConfigService } from '@seed/services' import { naturalSort } from '@seed/utils' import { ProgramConfigComponent } from '../config' +import { ActivatedRoute, ParamMap, Router } from '@angular/router' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' @Directive() export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { @@ -19,6 +21,9 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { private _programService = inject(ProgramService) private _dialog = inject(MatDialog) private _organizationService = inject(OrganizationService) + protected _snackBar = inject(SnackBarService) + protected _route = inject(ActivatedRoute) + protected _router = inject(Router) protected _unsubscribeAll$ = new Subject() accessLevelInstances = ['temp instance1', 'temp instance2'] @@ -52,7 +57,7 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { cycles: this._cycleService.cycles$, propertyColumns: this._columnService.propertyColumns$, programs: this._programService.programs$, - scheme: this._configService.scheme$ + scheme: this._configService.scheme$, }).pipe( tap(({ org, cycles, propertyColumns, programs, scheme }) => { this.org = org @@ -60,12 +65,36 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { this.propertyColumns = propertyColumns this.xAxisColumns = this.propertyColumns.filter((c) => this.validColumn(c, this.xAxisDataTypes)) this.scheme = scheme - this.setProgram(programs, org) + this.programs = programs.filter((p) => p.organization_id === org.id).sort((a, b) => naturalSort(a.name, b.name)) }), + switchMap(() => this._route.paramMap), + tap((params: ParamMap) => { this.handleRoute(params) }), takeUntil(this._unsubscribeAll$), ).subscribe() } + /* + * assigns the program based on the route parameters + * assigns the first program if the route parameter is invalid + */ + handleRoute(params: ParamMap) { + if (!this.programs.length) { + this.programChange(null) + return + } + + const id = parseInt(params.get('id')) + const urlProgram = this.programs.find((p) => p.id === id && p.organization_id === this.org.id) + const program = urlProgram ?? this.programs?.[0] + if (id !== program?.id) { + console.log(this._route.url) + const path = this._router.url.split('/')[2] + + void this._router.navigate([`/insights/${path}`, program.id]) + } + this.programChange(program) + } + setScheme() { this._configService.scheme$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((scheme) => { const color = scheme === 'light' ? '#0000001a' : '#ffffff2b' @@ -75,19 +104,8 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { }) } - setProgram(programs: Program[], org: Organization) { - if (!programs.length) this.programChange(null) - - // if org mismatch set selectedProgram to null - this.programs = programs.filter((p) => p.organization_id === org.id).sort((a, b) => naturalSort(a.name, b.name)) - const program = this.selectedProgram?.organization_id === org.id - ? this.selectedProgram - : this.programs?.[0] - this.programChange(program) - } - programChange(program: Program) { - this.selectedProgram = program + this.selectedProgram = program?.organization_id === this.org.id ? program : null if (!program) { this.clearChart() return @@ -110,7 +128,6 @@ export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { this.programCycles = [] this.programXAxisColumns = [] this.loading = false - // this.programChange$.next() // necessary? this.initChart() } 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 e7137754..15780dda 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -12,7 +12,7 @@
Select Program - + @for (program of programs; track $index) { {{ program.name }} @@ -23,44 +23,45 @@
-
-
- - - - +
+ - @for (entry of colors | keyvalue; track $index; let i = $index) { - -
-
- {{ entry.key | titlecase }} -
-
- } - - - Distance from Target - -
+ > + @for (entry of colors | keyvalue; track $index; let i = $index) { + +
+
+ {{ entry.key | titlecase }} +
+
+ } + + + Distance from Target + + +
-
-
- - -
+
+ + +
+ }
-
+ @if (program) { +
Cycle @@ -110,12 +111,14 @@
+
Stats
Compliant:{{ results.y }}
Non-Compliant:{{ results.n }}
Unknown:{{ results.u }}
+ }
@@ -123,5 +126,17 @@
+ + @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 44371951..dac1e101 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -1,17 +1,21 @@ import { CommonModule } from '@angular/common' -import type { ElementRef, OnInit } from '@angular/core' +import { 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 { Router } from '@angular/router' -import type { ActiveElement, ChartEvent, PointElement, TooltipItem } from 'chart.js' +import type { ActiveElement, TooltipItem } from 'chart.js' import { Chart } from 'chart.js' import type { AnnotationOptions } from 'chartjs-plugin-annotation' -import { takeUntil, tap } from 'rxjs' -import type { Program, PropertyInsightDataset, PropertyInsightPoint, ResultsByCycles } from '@seed/api' -import { PageComponent } from '@seed/components' +import { combineLatest, filter, Subject, switchMap, takeUntil, tap } from 'rxjs' +import { Column, ColumnService, Cycle, CycleService, Organization, OrganizationService, ProgramData, ProgramService, type Program, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' +import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' +import { ActivatedRoute, ParamMap, Router } from '@angular/router' +import { ConfigService } from '@seed/services' +import { MatDialog } from '@angular/material/dialog' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' -import { ProgramWrapperDirective } from '../program-wrapper' +import { ProgramConfigComponent } from '../config' +import { naturalSort } from '@seed/utils' @Component({ selector: 'seed-property-insights', @@ -21,23 +25,53 @@ import { ProgramWrapperDirective } from '../program-wrapper' FormsModule, PageComponent, MaterialImports, + NotFoundComponent, + ProgressBarComponent, ReactiveFormsModule, ], }) -export class PropertyInsightsComponent extends ProgramWrapperDirective implements OnInit { +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() + accessLevelInstances = ['temp instance1', 'temp instance2'] + accessLevels = ['temp level1', 'temp level2'] annotations: Record + chart: Chart + chartName: string + colors: Record = { compliant: '#77CCCB', 'non-compliant': '#A94455', unknown: '#DDDDDD' } + cycles: Cycle[] + data: ProgramData datasets: PropertyInsightDataset[] = [] - displayAnnotation = true + datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] + filterGroups: unknown[] = [] + 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[] = [] + programXAxisColumns: Column[] = [] results = { y: 0, n: 0, u: 0 } + 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), @@ -45,28 +79,74 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement xAxisColumnId: new FormControl(null), accessLevel: new FormControl(null), accessLevelInstance: new FormControl(null), - program: new FormControl(this.selectedProgram), - datasetVisibility: new FormControl([true, true, true]), + program: new FormControl(null), annotationVisibility: new FormControl(true), }) - ngOnInit(): void { + ngOnInit() { + this._route.paramMap.subscribe((params: ParamMap) => { + this.programId = parseInt(params.get('id')) + this.initProgram() + }) + } + + initProgram(): void { + this.getDependencies() this.initChart() - // ProgramWrapperDirective init - super.ngOnInit() this.watchChart() + } - this.programChange$.subscribe(() => { - if (!this.selectedProgram) return - this.patchForm() - this.setResults() - }) + 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()), + tap(() => { + this.setProgramModels() + this.patchForm() + }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + } + + programChange(program: Program) { + const segments = ['/insights/property-insights'] + 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.loading = false + }), + ) } patchForm() { - const { cycles, x_axis_columns } = this.selectedProgram +c const cycleId = this.getStateCycle() + const { x_axis_columns } = this.program const data: Record = { - cycleId: cycles[0], + cycleId, xAxisColumnId: x_axis_columns[0], metricType: 0, accessLevel: this.accessLevels[0], @@ -75,15 +155,28 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement this.form.patchValue(data) } + getStateCycle() { + // use incoming state cycle, 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 stateLabel = state.label + this.handleLabel(stateLabel) + history.replaceState({}, document.title) + return cycles.find((c) => c === stateCycleId) ?? cycles[0] + } + + handleLabel(label: string) { + if (!label) this.datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] + if (label) this.datasetVisibility = [label] + if (label === 'non-compliant') this.datasetVisibility.push('whisker') + } + watchChart() { // update chart if anything changes this.form.valueChanges.pipe( tap(() => { - if (!this.validateProgram()) { - this.clearChart() - // this.initChart() - return - } + if (!this.validateProgram()) return this.setChartSettings() this.loadDatasets() }), @@ -91,19 +184,36 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement ).subscribe() } + setProgramModels() { + const { cycles, x_axis_columns } = this.program + this.programCycles = this.cycles.filter((c) => cycles.includes(c.id)) + this.programXAxisColumns = this.xAxisColumns.filter((c) => x_axis_columns.includes(c.id)) + } + validateProgram() { - const { actual_emission_column, actual_energy_column } = this.selectedProgram + const { actual_emission_column, actual_energy_column } = this.program const { metricType } = this.form.value - if (metricType === 0) { - return !!actual_energy_column + if (!this.data) { + this.clearChart() + return false } - return !!actual_emission_column + const validEnergy = metricType === 0 && !!actual_energy_column + const validEmission = metricType === 1 && !!actual_emission_column + return validEnergy || validEmission + } + + 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 } setResults() { const cycleId = this.form.value.cycleId const { y, n, u } = this.data.results_by_cycles[cycleId] as { y: number[]; n: number[]; u: number[] } this.results = { y: y.length, n: n.length, u: u.length } + this.datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] } /* @@ -150,7 +260,6 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement } // x and y axis names and values - console.log('callback') const [xAxisName, yAxisName] = this.getXYAxisName() text.push(`${xAxisName}: ${context.parsed.x}`) text.push(`${yAxisName}: ${context.parsed.y}`) @@ -204,12 +313,20 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement 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() + }) + } + /* * Step 2, set chart settings (axes name, background, labels...) */ setChartSettings() { - console.log('update chart') - if (!this.selectedProgram) return + if (!this.program) return const [xAxisName, yAxisName] = this.getXYAxisName() const xScale = this.chart.options.scales.x if (xScale?.type === 'linear' || xScale?.type === 'category') { @@ -240,21 +357,15 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement // } // } - for (const [idx, isVisible] of this.form.value.datasetVisibility.entries()) { - this.chart.setDatasetVisibility(idx, isVisible) - } - // this.displayAnnotation = savedConfig?.annotationVisibility ?? true - this.displayAnnotation = true - this.chart.update() } getXYAxisName(): string[] { - if (!this.selectedProgram) return [null, null] + if (!this.program) return [null, null] const xAxisCol = this.programXAxisColumns.find((col) => col.id === this.form.value.xAxisColumnId) const xAxisName = xAxisCol.display_name - const energyCol = this.propertyColumns.find((col) => col.id === this.selectedProgram.actual_energy_column) - const emissionCol = this.propertyColumns.find((col) => col.id === this.selectedProgram.actual_emission_column) + 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.form.value.metricType === 0 ? energyCol?.display_name : emissionCol?.display_name @@ -272,11 +383,9 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement * Step 3: Loads datasets into the chart. */ loadDatasets() { - if (!this.selectedProgram) return - this.xCategorical = false - this.displayAnnotation = true - + if (!this.program) return this.resetDatasets() + this.xCategorical = false const numProperties = Object.values(this.data.properties_by_cycles).reduce((acc, curr) => acc + curr.length, 0) if (numProperties > 3000) { @@ -291,14 +400,15 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement this.setDatasetColor() this.chart.options.plugins.annotation.annotations = this.annotations this.chart.update() - console.log('config', this.form.value) - console.log('data', this.data) - console.log('datasets', this.datasets) - console.log('chart', this.chart) + console.log('ALL DATA', { + form: this.form.value, + data: this.data, + datasets: this.datasets, + chart: this.chart, + }) } formatDataPoints() { - console.log('formatDataPoints') const { metric, results_by_cycles } = this.data const { cycleId, metricType, xAxisColumnId } = this.form.value for (const prop of this.data.properties_by_cycles[this.form.value.cycleId]) { @@ -355,7 +465,6 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement const aboveTarget = targetType === 0 && item?.target > item?.y const addWhisker = belowTarget || aboveTarget - console.log('addWhisker', addWhisker) if (!addWhisker) return item.distance = Math.abs(item.target - item.y) @@ -373,11 +482,11 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement } resetDatasets() { - console.log('resetDatasets') + const isHidden = (label: string) => !this.datasetVisibility.includes(label) this.datasets = [ - { data: [], label: 'compliant', pointStyle: 'circle', pointRadius: 7 }, - { data: [], label: 'non-compliant', pointStyle: 'triangle', pointRadius: 7 }, - { data: [], label: 'unknown', pointStyle: 'rect' }, + { 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') }, ] } @@ -390,7 +499,7 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement yMax: 0, borderColor: () => this.scheme === 'dark' ? '#ffffffff' : '#333333', borderWidth: 1, - display: () => this.displayAnnotation, + display: this.datasetVisibility.includes('whisker'), arrowHeads: { end: { display: true, @@ -403,14 +512,66 @@ export class PropertyInsightsComponent extends ProgramWrapperDirective implement toggleVisibility(idx: number, show: boolean) { if (idx === 3) { - for (const key of Object.keys(this.annotations)) { - if (key.startsWith('prop')) { - this.annotations[key].display = show - } - } + 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.chartName}.png` + a.click() + } + + refreshChart() { + if (!this.program) return + this.initChart() + this.programChange(this.program) + } + + clearChart() { + this.programCycles = [] + this.programXAxisColumns = [] + this.loading = false + this.initChart() + } + + 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) }), + ) + .subscribe() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } } diff --git a/src/styles/styles.scss b/src/styles/styles.scss index ddcdda67..b0afbd9e 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -294,3 +294,11 @@ .cdk-overlay-pane:has(.wide-select-sm) { width: 400px !important; } + +.strike-through { + mat-button-toggle:not(.mat-button-toggle-checked) { + span { + @apply line-through; + } + } +} From 873baa77e088c4883b06400b93e7a099ab6f3f75 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 29 Aug 2025 17:03:52 +0000 Subject: [PATCH 10/22] remove program wrapper --- .../modules/insights/program-wrapper/index.ts | 1 - .../program-wrapper.directive.ts | 198 ------------------ .../property-insights.component.ts | 2 +- 3 files changed, 1 insertion(+), 200 deletions(-) delete mode 100644 src/app/modules/insights/program-wrapper/index.ts delete mode 100644 src/app/modules/insights/program-wrapper/program-wrapper.directive.ts diff --git a/src/app/modules/insights/program-wrapper/index.ts b/src/app/modules/insights/program-wrapper/index.ts deleted file mode 100644 index 449f6478..00000000 --- a/src/app/modules/insights/program-wrapper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './program-wrapper.directive' diff --git a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts b/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts deleted file mode 100644 index 34490396..00000000 --- a/src/app/modules/insights/program-wrapper/program-wrapper.directive.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { ElementRef, OnDestroy, OnInit } from '@angular/core' -import { Directive, inject, ViewChild } from '@angular/core' -import { MatDialog } from '@angular/material/dialog' -import type { Chart } from 'chart.js' -import { combineLatest, filter, Subject, switchMap, take, takeUntil, tap } from 'rxjs' -import type { Column, Cycle, Organization, Program, ProgramData } from '@seed/api' -import { ColumnService, CycleService, OrganizationService } from '@seed/api' -import { ProgramService } from '@seed/api/program' -import { ConfigService } from '@seed/services' -import { naturalSort } from '@seed/utils' -import { ProgramConfigComponent } from '../config' -import { ActivatedRoute, ParamMap, Router } from '@angular/router' -import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' - -@Directive() -export abstract class ProgramWrapperDirective implements OnDestroy, OnInit { - @ViewChild('chart', { 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) - protected _snackBar = inject(SnackBarService) - protected _route = inject(ActivatedRoute) - protected _router = inject(Router) - protected _unsubscribeAll$ = new Subject() - - accessLevelInstances = ['temp instance1', 'temp instance2'] - accessLevels = ['temp level1', 'temp level2'] - chart: Chart - chartName: string - colors: Record = { compliant: '#77CCCB', 'non-compliant': '#A94455', unknown: '#DDDDDD' } - cycles: Cycle[] - data: ProgramData - filterGroups: unknown[] - loading = true - org: Organization - orgId: number - programs: Program[] - programChange$ = new Subject() - programCycles: Cycle[] = [] - programXAxisColumns: Column[] = [] - propertyColumns: Column[] - selectedProgram: Program - scheme: 'dark' | 'light' = 'light' - xAxisColumns: Column[] - xAxisDataTypes = ['number', 'string', 'float', 'integer', 'ghg', 'ghg_intensity', 'area', 'eui', 'boolean'] - - ngOnInit(): void { - this.getDependencies() - } - - 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)) - }), - switchMap(() => this._route.paramMap), - tap((params: ParamMap) => { this.handleRoute(params) }), - takeUntil(this._unsubscribeAll$), - ).subscribe() - } - - /* - * assigns the program based on the route parameters - * assigns the first program if the route parameter is invalid - */ - handleRoute(params: ParamMap) { - if (!this.programs.length) { - this.programChange(null) - return - } - - const id = parseInt(params.get('id')) - const urlProgram = this.programs.find((p) => p.id === id && p.organization_id === this.org.id) - const program = urlProgram ?? this.programs?.[0] - if (id !== program?.id) { - console.log(this._route.url) - const path = this._router.url.split('/')[2] - - void this._router.navigate([`/insights/${path}`, program.id]) - } - this.programChange(program) - } - - 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() - }) - } - - programChange(program: Program) { - this.selectedProgram = program?.organization_id === this.org.id ? program : null - if (!program) { - this.clearChart() - return - } - this.setProgramModels() - - this._programService.evaluate(this.org.id, program.id) - .pipe( - tap((data) => { - this.data = data - this.programChange$.next() - this.loading = false - this.setChartName(program) - }), - take(1), - ).subscribe() - } - - clearChart() { - this.programCycles = [] - this.programXAxisColumns = [] - this.loading = false - this.initChart() - } - - setProgramModels() { - const { cycles, x_axis_columns } = this.selectedProgram - this.programCycles = this.cycles.filter((c) => cycles.includes(c.id)) - this.programXAxisColumns = this.xAxisColumns.filter((c) => x_axis_columns.includes(c.id)) - } - - 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.selectedProgram) return - this.initChart() - this.programChange(this.selectedProgram) - } - - downloadChart() { - const a = document.createElement('a') - a.href = this.chart.toBase64Image() - a.download = `Program-${this.chartName}.png` - a.click() - } - - 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 - } - - openProgramConfig = () => { - const dialogRef = this._dialog.open(ProgramConfigComponent, { - width: '50rem', - data: { - cycles: this.cycles, - filterGroups: this.filterGroups, - programs: this.programs, - selectedProgram: this.selectedProgram, - 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.selectedProgram = this.programs.find((p) => p.id == programId) }), - ) - .subscribe() - } - - ngOnDestroy(): void { - this._unsubscribeAll$.next() - this._unsubscribeAll$.complete() - } - - protected abstract initChart(): void -} 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 dac1e101..b09139fd 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -143,7 +143,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } patchForm() { -c const cycleId = this.getStateCycle() + const cycleId = this.getStateCycle() const { x_axis_columns } = this.program const data: Record = { cycleId, From 0b740e79e7016ca06749c169268adcf1f4c22ce4 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 29 Aug 2025 22:01:55 +0000 Subject: [PATCH 11/22] insight ali fxnal --- src/@seed/api/program/program.service.ts | 6 +- .../property-insights.component.html | 6 +- .../property-insights.component.ts | 175 +++++++++++------- 3 files changed, 119 insertions(+), 68 deletions(-) diff --git a/src/@seed/api/program/program.service.ts b/src/@seed/api/program/program.service.ts index 228dc817..209b1733 100644 --- a/src/@seed/api/program/program.service.ts +++ b/src/@seed/api/program/program.service.ts @@ -78,8 +78,10 @@ export class ProgramService { ) } - evaluate(orgId: number, programId: number): Observable { - const url = `/api/v3/compliance_metrics/${programId}/evaluate/?organization_id=${orgId}` + 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) => { 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 15780dda..39d0909e 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -93,7 +93,7 @@ Access Level - @for (level of accessLevels; track $index) { + @for (level of accessLevelNames; track $index) { {{ level }} } @@ -101,9 +101,9 @@ Access Level Instance - + @for (instance of accessLevelInstances; track $index) { - {{ instance }} + {{ instance.name }} } 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 b09139fd..94850fde 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -6,8 +6,8 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul import type { ActiveElement, TooltipItem } from 'chart.js' import { Chart } from 'chart.js' import type { AnnotationOptions } from 'chartjs-plugin-annotation' -import { combineLatest, filter, Subject, switchMap, takeUntil, tap } from 'rxjs' -import { Column, ColumnService, Cycle, CycleService, Organization, OrganizationService, ProgramData, ProgramService, type Program, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' +import { combineLatest, EMPTY, filter, Subject, switchMap, take, takeUntil, tap, zip } from 'rxjs' +import { AccessLevelInstancesByDepth, AccessLevelsByDepth, Column, ColumnService, Cycle, CycleService, Organization, OrganizationService, ProgramData, ProgramService, type Program, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' import { ActivatedRoute, ParamMap, Router } from '@angular/router' @@ -44,8 +44,9 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { private _snackBar = inject(SnackBarService) private _unsubscribeAll$ = new Subject() - accessLevelInstances = ['temp instance1', 'temp instance2'] - accessLevels = ['temp level1', 'temp level2'] + accessLevelNames: AccessLevelInstancesByDepth['accessLevelNames'] = [] + accessLevelInstancesByDepth: AccessLevelsByDepth = {} + accessLevelInstances: AccessLevelsByDepth[keyof AccessLevelsByDepth] = [] annotations: Record chart: Chart chartName: string @@ -77,8 +78,8 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { cycleId: new FormControl(null), metricType: new FormControl<0 | 1>(0), xAxisColumnId: new FormControl(null), - accessLevel: new FormControl(null), - accessLevelInstance: new FormControl(null), + accessLevel: new FormControl(null), + accessLevelInstanceId: new FormControl(null), program: new FormControl(null), annotationVisibility: new FormControl(true), }) @@ -86,75 +87,135 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { ngOnInit() { this._route.paramMap.subscribe((params: ParamMap) => { this.programId = parseInt(params.get('id')) + this.initChart() this.initProgram() }) } initProgram(): void { this.getDependencies() - this.initChart() - this.watchChart() + .pipe( + tap((dependencies) => { this.setDependencies(dependencies) }), + switchMap(() => this.evaluateProgram(this.form.value.accessLevelInstanceId)), + tap(() => { + this.setForm() + this.initChart() + this.setChart() + }), + ) + .subscribe() + + this.getAliTree() } getDependencies() { - combineLatest({ + // SHOULD THIS BE A ZIP? + return combineLatest({ org: this._organizationService.currentOrganization$, cycles: this._cycleService.cycles$, propertyColumns: this._columnService.propertyColumns$, programs: this._programService.programs$, scheme: this._configService.scheme$, + }) + } + + setDependencies( + { org, cycles, propertyColumns, programs, scheme }: + { org: Organization; cycles: Cycle[]; propertyColumns: Column[]; programs: Program[]; scheme: 'dark' | 'light' } + ) { + this.org = org + this.cycles = cycles + this.propertyColumns = propertyColumns + this.scheme = scheme + this.xAxisColumns = this.propertyColumns.filter((c) => this.isValidColumn(c, this.xAxisDataTypes)) + 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) ?? this.programs[0] + } + + setForm() { + this.setFormOptions() + const cycleId = this.getStateCycle() + const data: Record = { + cycleId, + xAxisColumnId: this.program.x_axis_columns[0], + metricType: 0, + accessLevel: this.accessLevelNames.at(-1), + accessLevelInstance: this.accessLevelInstances[0], + } + this.form.patchValue(data) + this.watchForm() + } + + watchForm() { + combineLatest({ + cycleId: this.form.get('cycleId')?.valueChanges, + xAxisColumnId: this.form.get('xAxisColumnId')?.valueChanges, + metricType: this.form.get('metricType')?.valueChanges, }).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()), - tap(() => { - this.setProgramModels() - this.patchForm() - }), + tap(() => { this.setChart() }), + takeUntil(this._unsubscribeAll$), + ).subscribe() + + this.form.get('accessLevel')?.valueChanges.pipe( + tap((accessLevel) => { this.getPossibleAccessLevelInstances(accessLevel) }), + ).subscribe() + + this.form.get('accessLevelInstanceId')?.valueChanges.pipe( + filter(Boolean), + switchMap((aliId) => this.evaluateProgram(aliId)), + tap(() => { this.setChart() }), takeUntil(this._unsubscribeAll$), ).subscribe() } + setChart() { + this.setChartSettings() + this.loadDatasets() + } + + 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) + }), + ).subscribe() + } + + getPossibleAccessLevelInstances(accessLevelName: string): void { + const depth = this.accessLevelNames.findIndex((name) => name === accessLevelName) + this.accessLevelInstances = this.accessLevelInstancesByDepth[depth] + } + programChange(program: Program) { const segments = ['/insights/property-insights'] 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( + 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 this.loading = false }), + take(1), ) } - patchForm() { - const cycleId = this.getStateCycle() - const { x_axis_columns } = this.program - const data: Record = { - cycleId, - xAxisColumnId: x_axis_columns[0], - metricType: 0, - accessLevel: this.accessLevels[0], - accessLevelInstance: this.accessLevelInstances[0], - } - this.form.patchValue(data) - } - getStateCycle() { // use incoming state cycle, but clear state after initial load const { cycles } = this.program @@ -172,19 +233,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { if (label === 'non-compliant') this.datasetVisibility.push('whisker') } - watchChart() { - // update chart if anything changes - this.form.valueChanges.pipe( - tap(() => { - if (!this.validateProgram()) return - this.setChartSettings() - this.loadDatasets() - }), - takeUntil(this._unsubscribeAll$), - ).subscribe() - } - - setProgramModels() { + setFormOptions() { const { cycles, x_axis_columns } = this.program this.programCycles = this.cycles.filter((c) => cycles.includes(c.id)) this.programXAxisColumns = this.xAxisColumns.filter((c) => x_axis_columns.includes(c.id)) @@ -202,7 +251,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { return validEnergy || validEmission } - validColumn(column: Column, validTypes: string[]) { + isValidColumn(column: Column, validTypes: string[]) { const isAllowedType = validTypes.includes(column.data_type) const notRelated = !column.related const notDerived = !column.derived_column @@ -400,12 +449,12 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this.setDatasetColor() this.chart.options.plugins.annotation.annotations = this.annotations this.chart.update() - console.log('ALL DATA', { - form: this.form.value, - data: this.data, - datasets: this.datasets, - chart: this.chart, - }) + // console.log('ALL DATA', { + // form: this.form.value, + // data: this.data, + // datasets: this.datasets, + // chart: this.chart, + // }) } formatDataPoints() { From 952744b87ac165e79c1d991da24ef30769e2b13d Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Mon, 8 Sep 2025 20:51:06 +0000 Subject: [PATCH 12/22] dev --- src/@seed/api/program/program.service.ts | 5 ++- .../property-insights.component.html | 12 ++--- .../property-insights.component.ts | 44 ++++++++++++++----- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/@seed/api/program/program.service.ts b/src/@seed/api/program/program.service.ts index 209b1733..662f11b1 100644 --- a/src/@seed/api/program/program.service.ts +++ b/src/@seed/api/program/program.service.ts @@ -4,14 +4,15 @@ import { inject, Injectable } from '@angular/core' import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { Observable } from 'rxjs' -import { catchError, map, ReplaySubject, tap } from 'rxjs' +import { BehaviorSubject, catchError, map, ReplaySubject, tap } from 'rxjs' 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 ReplaySubject(1) + private _programs = new BehaviorSubject([]) + // private _programs = new ReplaySubject(1) private _errorService = inject(ErrorService) private _snackBar = inject(SnackBarService) private _userService = inject(UserService) 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 39d0909e..58a877a7 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -12,10 +12,10 @@
Select Program - - @for (program of programs; track $index) { - - {{ program.name }} + + @for (p of programs; track $index) { + + {{ p.name }} } @@ -75,7 +75,7 @@ Metric Type - @for (metricType of metricTypes; track $index) { + @for (metricType of programMetricTypes; track $index) { {{ metricType.value }} } @@ -121,7 +121,7 @@ }
-
+
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 94850fde..0972b342 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -6,7 +6,7 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul import type { ActiveElement, TooltipItem } from 'chart.js' import { Chart } from 'chart.js' import type { AnnotationOptions } from 'chartjs-plugin-annotation' -import { combineLatest, EMPTY, filter, Subject, switchMap, take, takeUntil, tap, zip } from 'rxjs' +import { combineLatest, EMPTY, filter, merge, shareReplay, Subject, switchMap, take, takeUntil, tap, zip } from 'rxjs' import { AccessLevelInstancesByDepth, AccessLevelsByDepth, Column, ColumnService, Cycle, CycleService, Organization, OrganizationService, ProgramData, ProgramService, type Program, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' @@ -67,6 +67,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { programs: Program[] propertyColumns: Column[] programCycles: Cycle[] = [] + programMetricTypes: { key: number; value: string }[] = [] programXAxisColumns: Column[] = [] results = { y: 0, n: 0, u: 0 } scheme: 'dark' | 'light' = 'light' @@ -121,8 +122,9 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { setDependencies( { org, cycles, propertyColumns, programs, scheme }: - { org: Organization; cycles: Cycle[]; propertyColumns: Column[]; programs: Program[]; scheme: 'dark' | 'light' } + { org: Organization; cycles: Cycle[]; propertyColumns: Column[]; programs: Program[]; scheme: 'dark' | 'light' }, ) { + console.log('set dependencies') this.org = org this.cycles = cycles this.propertyColumns = propertyColumns @@ -147,11 +149,11 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } watchForm() { - combineLatest({ - cycleId: this.form.get('cycleId')?.valueChanges, - xAxisColumnId: this.form.get('xAxisColumnId')?.valueChanges, - metricType: this.form.get('metricType')?.valueChanges, - }).pipe( + merge( + this.form.get('cycleId')?.valueChanges, + this.form.get('xAxisColumnId')?.valueChanges, + this.form.get('metricType')?.valueChanges, + ).pipe( tap(() => { this.setChart() }), takeUntil(this._unsubscribeAll$), ).subscribe() @@ -171,6 +173,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { setChart() { this.setChartSettings() this.loadDatasets() + this.chart.resetZoom() } getAliTree() { @@ -200,6 +203,8 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { void this._router.navigate(segments) } + compareProgram = (a: Program, b: Program) => a && b && a.id === b.id + evaluateProgram(aliId: number = null) { if (this.program?.organization_id !== this.org.id) { this.loading = false @@ -234,7 +239,10 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } setFormOptions() { - const { cycles, x_axis_columns } = this.program + const { cycles, x_axis_columns, actual_emission_column, actual_energy_column } = this.program + + 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)) } @@ -410,8 +418,9 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } getXYAxisName(): string[] { - if (!this.program) return [null, null] const xAxisCol = this.programXAxisColumns.find((col) => col.id === this.form.value.xAxisColumnId) + if (!xAxisCol || !this.program) return [null, null] + const xAxisName = xAxisCol.display_name 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) @@ -460,7 +469,10 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { formatDataPoints() { const { metric, results_by_cycles } = this.data const { cycleId, metricType, xAxisColumnId } = this.form.value - for (const prop of this.data.properties_by_cycles[this.form.value.cycleId]) { + + const properties = this.data.properties_by_cycles[cycleId] ?? [] + + for (const prop of properties) { const id = prop.id as number const name = this.getValue(prop, 'startsWith', this.org.property_display_field) as string const x = this.getValue(prop, 'endsWith', `_${xAxisColumnId}`) as number @@ -614,11 +626,21 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { .afterClosed() .pipe( filter(Boolean), - tap((programId: number) => { this.program = this.programs.find((p) => p.id == programId) }), + tap((programId: number) => { + // this.resetSubscriptions() + this.program = this.programs.find((p) => p.id == programId) + this.programChange(this.program) + }), ) .subscribe() } + // resetSubscriptions() { + // this._unsubscribeAll$.next() + // this._unsubscribeAll$.complete() + // console.log('resetSubscriptions called, all subscriptions unsubscribed') + // } + ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() From 10f11d3b6a6b0c92e1d4abeab18c36fd09279a5c Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 9 Sep 2025 16:20:36 +0000 Subject: [PATCH 13/22] limit duplicate network calls and timing --- .../property-insights.component.html | 6 +- .../property-insights.component.ts | 94 +++++++++---------- 2 files changed, 50 insertions(+), 50 deletions(-) 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 58a877a7..5d48f16d 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -12,7 +12,7 @@
Select Program - + @for (p of programs; track $index) { {{ p.name }} @@ -57,7 +57,7 @@
- +
@if (program) { @@ -65,7 +65,7 @@
Cycle - + @for (cycle of programCycles; track $index) { {{ cycle.name }} } 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 0972b342..b9b33666 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -3,19 +3,22 @@ import { 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 type { ActiveElement, TooltipItem } from 'chart.js' import { Chart } from 'chart.js' import type { AnnotationOptions } from 'chartjs-plugin-annotation' -import { combineLatest, EMPTY, filter, merge, shareReplay, Subject, switchMap, take, takeUntil, tap, zip } from 'rxjs' -import { AccessLevelInstancesByDepth, AccessLevelsByDepth, Column, ColumnService, Cycle, CycleService, Organization, OrganizationService, ProgramData, ProgramService, type Program, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' +import { combineLatest, debounceTime, EMPTY, filter, map, merge, Subject, switchMap, take, takeUntil, tap, zip } from 'rxjs' +import type { AccessLevelInstancesByDepth, AccessLevelsByDepth, Column, Cycle, Organization, ProgramData } from '@seed/api' +import { ColumnService, CycleService, OrganizationService, type Program, ProgramService, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' -import { ActivatedRoute, ParamMap, Router } from '@angular/router' import { ConfigService } from '@seed/services' -import { MatDialog } from '@angular/material/dialog' +import { naturalSort } from '@seed/utils' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import { ProgramConfigComponent } from '../config' -import { naturalSort } from '@seed/utils' +import { returnOrUpdate } from 'ol/extent' @Component({ selector: 'seed-property-insights', @@ -86,9 +89,11 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { }) ngOnInit() { + this.watchForm() this._route.paramMap.subscribe((params: ParamMap) => { this.programId = parseInt(params.get('id')) this.initChart() + this.setScheme() this.initProgram() }) } @@ -96,13 +101,11 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { initProgram(): void { this.getDependencies() .pipe( - tap((dependencies) => { this.setDependencies(dependencies) }), - switchMap(() => this.evaluateProgram(this.form.value.accessLevelInstanceId)), - tap(() => { - this.setForm() - this.initChart() - this.setChart() + tap((dependencies) => { + this.getPrograms() + this.setDependencies(dependencies) }), + takeUntil(this._unsubscribeAll$), ) .subscribe() @@ -110,28 +113,35 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } getDependencies() { - // SHOULD THIS BE A ZIP? return combineLatest({ org: this._organizationService.currentOrganization$, cycles: this._cycleService.cycles$, propertyColumns: this._columnService.propertyColumns$, - programs: this._programService.programs$, - scheme: this._configService.scheme$, }) } setDependencies( - { org, cycles, propertyColumns, programs, scheme }: - { org: Organization; cycles: Cycle[]; propertyColumns: Column[]; programs: Program[]; scheme: 'dark' | 'light' }, + { org, cycles, propertyColumns }: + { org: Organization; cycles: Cycle[]; propertyColumns: Column[] }, ) { - console.log('set dependencies') this.org = org this.cycles = cycles this.propertyColumns = propertyColumns - this.scheme = scheme this.xAxisColumns = this.propertyColumns.filter((c) => this.isValidColumn(c, this.xAxisDataTypes)) - 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) ?? this.programs[0] + } + + 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) ?? this.programs[0] + }), + filter(() => !!this.program), + switchMap(() => this.evaluateProgram(this.form.value.accessLevelInstanceId)), + take(1), + tap(() => { this.setForm() }), + ).subscribe() } setForm() { @@ -145,34 +155,34 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { accessLevelInstance: this.accessLevelInstances[0], } this.form.patchValue(data) - this.watchForm() } watchForm() { + // Developer Note: use map to track which value changes merge( - this.form.get('cycleId')?.valueChanges, - this.form.get('xAxisColumnId')?.valueChanges, - this.form.get('metricType')?.valueChanges, + 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 }))), + this.form.get('accessLevelInstanceId')?.valueChanges.pipe(map((value) => ({ field: 'accessLevelInstanceId', value }))), ).pipe( - tap(() => { this.setChart() }), + tap(() => { this.loading = true }), + debounceTime(500), + tap(() => { + this.setChart() + this.loading = false + }), takeUntil(this._unsubscribeAll$), ).subscribe() this.form.get('accessLevel')?.valueChanges.pipe( tap((accessLevel) => { this.getPossibleAccessLevelInstances(accessLevel) }), ).subscribe() - - this.form.get('accessLevelInstanceId')?.valueChanges.pipe( - filter(Boolean), - switchMap((aliId) => this.evaluateProgram(aliId)), - tap(() => { this.setChart() }), - takeUntil(this._unsubscribeAll$), - ).subscribe() } setChart() { this.setChartSettings() this.loadDatasets() + this.chart.update() this.chart.resetZoom() } @@ -203,7 +213,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { void this._router.navigate(segments) } - compareProgram = (a: Program, b: Program) => a && b && a.id === b.id + 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) { @@ -367,11 +377,10 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { }, }, }) - this.setScheme() } - 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 } @@ -413,8 +422,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { // labels = ... // } // } - - this.chart.update() } getXYAxisName(): string[] { @@ -441,14 +448,14 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { * Step 3: Loads datasets into the chart. */ loadDatasets() { - if (!this.program) return + if (!this.program || !this.data) return + this.resetDatasets() this.xCategorical = false 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.') - this.initChart() return } @@ -457,7 +464,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this.chart.data.datasets = this.datasets this.setDatasetColor() this.chart.options.plugins.annotation.annotations = this.annotations - this.chart.update() + // console.log('ALL DATA', { // form: this.form.value, // data: this.data, @@ -627,7 +634,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { .pipe( filter(Boolean), tap((programId: number) => { - // this.resetSubscriptions() this.program = this.programs.find((p) => p.id == programId) this.programChange(this.program) }), @@ -635,12 +641,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { .subscribe() } - // resetSubscriptions() { - // this._unsubscribeAll$.next() - // this._unsubscribeAll$.complete() - // console.log('resetSubscriptions called, all subscriptions unsubscribed') - // } - ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() From 51cbdc079f9e738eec54c22ef79a7d65d497fd8f Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 9 Sep 2025 16:50:28 +0000 Subject: [PATCH 14/22] results and missing url id --- .../property-insights.component.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 b9b33666..a83aba62 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -102,8 +102,8 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this.getDependencies() .pipe( tap((dependencies) => { - this.getPrograms() this.setDependencies(dependencies) + this.getPrograms() }), takeUntil(this._unsubscribeAll$), ) @@ -135,7 +135,10 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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) ?? this.programs[0] + this.program = programs.find((p) => p.id === this.programId) + if (!this.program) { + this.programChange(this.programs[0]) + } }), filter(() => !!this.program), switchMap(() => this.evaluateProgram(this.form.value.accessLevelInstanceId)), @@ -145,6 +148,8 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } setForm() { + if (!this.program) return + this.setFormOptions() const cycleId = this.getStateCycle() const data: Record = { @@ -169,6 +174,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { debounceTime(500), tap(() => { this.setChart() + this.setResults() this.loading = false }), takeUntil(this._unsubscribeAll$), @@ -250,7 +256,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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)) @@ -277,6 +283,8 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } setResults() { + if (!this.data) return + const cycleId = this.form.value.cycleId const { y, n, u } = this.data.results_by_cycles[cycleId] as { y: number[]; n: number[]; u: number[] } this.results = { y: y.length, n: n.length, u: u.length } From e13a27b89c4b24ce6e7c4f97d3dce52024b1ff80 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Tue, 9 Sep 2025 19:09:56 +0000 Subject: [PATCH 15/22] form options update after program update --- .../config/program-config.component.html | 14 +++++++------- .../property-insights.component.ts | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/app/modules/insights/config/program-config.component.html b/src/app/modules/insights/config/program-config.component.html index 911bc63c..3ec007c6 100644 --- a/src/app/modules/insights/config/program-config.component.html +++ b/src/app/modules/insights/config/program-config.component.html @@ -5,8 +5,8 @@
- Select Program - + Select a program to edit or create new + @for (program of data.programs; track $index) { {{ program.name }} } @@ -18,12 +18,12 @@ Delete +
+ } -
-
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 a83aba62..fabafd6b 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -4,9 +4,9 @@ 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 type { ParamMap } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router' -import type { ActiveElement, TooltipItem } from 'chart.js' +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' @@ -18,7 +18,6 @@ import { ConfigService } from '@seed/services' import { naturalSort } from '@seed/utils' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import { ProgramConfigComponent } from '../config' -import { returnOrUpdate } from 'ol/extent' @Component({ selector: 'seed-property-insights', @@ -101,6 +100,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { initProgram(): void { this.getDependencies() .pipe( + debounceTime(300), tap((dependencies) => { this.setDependencies(dependencies) this.getPrograms() @@ -134,6 +134,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this._programService.programs$.pipe( filter(() => !!this.org), tap((programs) => { + console.log('get 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) { @@ -142,7 +143,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { }), filter(() => !!this.program), switchMap(() => this.evaluateProgram(this.form.value.accessLevelInstanceId)), - take(1), tap(() => { this.setForm() }), ).subscribe() } @@ -214,6 +214,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } programChange(program: Program) { + console.log('program change') const segments = ['/insights/property-insights'] if (program?.id) segments.push(program.id.toString()) void this._router.navigate(segments) @@ -470,6 +471,11 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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 @@ -613,7 +619,8 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { refreshChart() { if (!this.program) return this.initChart() - this.programChange(this.program) + this.setChart() + this.setScheme() } clearChart() { From 2a823737e0d5c758513c28b5467db830bdb7c590 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 11 Sep 2025 18:50:55 +0000 Subject: [PATCH 16/22] expandable data table --- .../property-insights.component.html | 73 +++++++++++++++++-- .../property-insights.component.ts | 40 ++++++++-- src/styles/styles.scss | 9 +++ 3 files changed, 106 insertions(+), 16 deletions(-) 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 5d48f16d..dce53561 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -110,21 +110,78 @@ -
-
Stats
-
Compliant:{{ results.y }}
-
Non-Compliant:{{ results.n }}
-
Unknown:{{ results.u }}
-
-
} -
+
+ +
+ + +
+ +
+
+ + Data Table +
+ Click to expand +
+ + + + + Compliant: + {{ results.y }} + + + + + + Non-Compliant: + {{ results.n }} + + + + + + Unknown: + {{ results.u }} + + + +
@if (loading) { 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 fabafd6b..8e61a6b1 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -6,6 +6,8 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul 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' @@ -23,6 +25,7 @@ import { ProgramConfigComponent } from '../config' selector: 'seed-property-insights', templateUrl: './property-insights.component.html', imports: [ + AgGridAngular, CommonModule, FormsModule, PageComponent, @@ -52,12 +55,15 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { annotations: Record chart: Chart chartName: string + 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' }, @@ -72,6 +78,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { programMetricTypes: { key: number; value: string }[] = [] programXAxisColumns: Column[] = [] results = { y: 0, n: 0, u: 0 } + rowData: Record = {} scheme: 'dark' | 'light' = 'light' xCategorical = false xAxisColumns: Column[] @@ -214,7 +221,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } programChange(program: Program) { - console.log('program change') const segments = ['/insights/property-insights'] if (program?.id) segments.push(program.id.toString()) void this._router.navigate(segments) @@ -290,6 +296,18 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { const { y, n, u } = this.data.results_by_cycles[cycleId] as { y: number[]; n: number[]; u: number[] } this.results = { y: y.length, n: n.length, u: u.length } this.datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] + + 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: [] }) } /* @@ -438,6 +456,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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.form.value.metricType === 0 @@ -492,16 +511,15 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { const { cycleId, metricType, xAxisColumnId } = this.form.value const properties = this.data.properties_by_cycles[cycleId] ?? [] + const cycleResult = results_by_cycles[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', `_${xAxisColumnId}`) as number let target: number - - if (this.xCategorical && Number.isNaN(Number(x))) { - this.xCategorical = true - } + let distance: number = null const actualCol = metricType === 0 ? metric.actual_energy_column : metric.actual_emission_column const targetCol = metricType === 0 ? metric.target_energy_column : metric.target_emission_column @@ -510,15 +528,15 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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 } + const item: PropertyInsightPoint = { id, name, x, y, target, distance } // place in appropriate dataset - const cycleResult = results_by_cycles[cycleId] as ResultsByCycles if (cycleResult.y.includes(id)) { this.datasets[0].data.push(item) - } else if (cycleResult.n.includes(id)) { + } else if (nonCompliant) { this.datasets[1].data.push(item) } else { this.datasets[2].data.push(item) @@ -656,6 +674,12 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { .subscribe() } + onRowClicked({ data }: RowClickedEvent<{ id: number }>) { + if (data.id) { + void this._router.navigate(['/properties', data.id]) + } + } + ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() diff --git a/src/styles/styles.scss b/src/styles/styles.scss index b0afbd9e..087c245c 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -302,3 +302,12 @@ } } } + +.accordion-sub-tables { + .mat-expansion-panel { + @apply border m-0 !rounded-none; + } + .mat-expansion-panel-body { + @apply p-0; + } +} From ddb8cf3e68c21073b04765dc8244b9982bbf19b2 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 11 Sep 2025 19:18:51 +0000 Subject: [PATCH 17/22] labels --- .../property-insights.component.html | 6 ++-- .../property-insights.component.ts | 28 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) 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 dce53561..b8ba5e0b 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -59,7 +59,7 @@
- + @if (program) {
@@ -110,6 +110,8 @@
+ +
} @@ -121,7 +123,7 @@
- +
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 8e61a6b1..04cdd304 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -19,6 +19,7 @@ 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({ @@ -648,6 +649,27 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this.initChart() } + 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', @@ -674,12 +696,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { .subscribe() } - onRowClicked({ data }: RowClickedEvent<{ id: number }>) { - if (data.id) { - void this._router.navigate(['/properties', data.id]) - } - } - ngOnDestroy(): void { this._unsubscribeAll$.next() this._unsubscribeAll$.complete() From 8ba54cfa9f6284ad2ef29c2a225bd3800a54dc98 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 11 Sep 2025 22:12:15 +0000 Subject: [PATCH 18/22] ranked and sorted labels --- src/@seed/api/program/program.types.ts | 5 + .../property-insights.component.html | 2 +- .../property-insights.component.ts | 262 ++++++++++-------- 3 files changed, 147 insertions(+), 122 deletions(-) diff --git a/src/@seed/api/program/program.types.ts b/src/@seed/api/program/program.types.ts index 355c35aa..cc2acb62 100644 --- a/src/@seed/api/program/program.types.ts +++ b/src/@seed/api/program/program.types.ts @@ -72,3 +72,8 @@ export type PropertyInsightPoint = { target: number; distance?: number; } + +export type SimpleCartesianScale = { + type: 'linear' | 'category'; + title: { display: boolean; text: string }; +} 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 b8ba5e0b..1323a292 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.html +++ b/src/app/modules/insights/property-insights/property-insights.component.html @@ -65,7 +65,7 @@
Cycle - + @for (cycle of programCycles; track $index) { {{ cycle.name }} } 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 04cdd304..279ce494 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -8,11 +8,11 @@ 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 type { ActiveElement, CartesianScaleOptions, ScaleOptionsByType, 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, ProgramData } from '@seed/api' +import type { AccessLevelInstancesByDepth, AccessLevelsByDepth, Column, Cycle, Organization, ProgramData, SimpleCartesianScale } from '@seed/api' import { ColumnService, CycleService, OrganizationService, type Program, ProgramService, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' import { NotFoundComponent, PageComponent, ProgressBarComponent } from '@seed/components' import { MaterialImports } from '@seed/materials' @@ -78,6 +78,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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' @@ -142,7 +143,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this._programService.programs$.pipe( filter(() => !!this.org), tap((programs) => { - console.log('get 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) { @@ -160,14 +160,18 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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: 0, + metricType, accessLevel: this.accessLevelNames.at(-1), accessLevelInstance: this.accessLevelInstances[0], } - this.form.patchValue(data) + // wait for DOM to update before patching to avoid blank selections + setTimeout(() => { + this.form.patchValue(data) + }) } watchForm() { @@ -179,11 +183,13 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this.form.get('accessLevelInstanceId')?.valueChanges.pipe(map((value) => ({ field: 'accessLevelInstanceId', value }))), ).pipe( tap(() => { this.loading = true }), - debounceTime(500), + debounceTime(300), tap(() => { this.setChart() - this.setResults() - this.loading = false + if (this.form.value.cycleId) { + this.setResults() + this.loading = false + } }), takeUntil(this._unsubscribeAll$), ).subscribe() @@ -194,9 +200,11 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } setChart() { + this.clearLabels() this.setChartSettings() this.loadDatasets() this.chart.update() + this.sortLabels() this.chart.resetZoom() } @@ -222,6 +230,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } programChange(program: Program) { + this.form.reset() const segments = ['/insights/property-insights'] if (program?.id) segments.push(program.id.toString()) void this._router.navigate(segments) @@ -239,14 +248,13 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { return this._programService.evaluate(this.org.id, this.program.id, aliId).pipe( tap((data) => { this.data = data - this.loading = false }), take(1), ) } getStateCycle() { - // use incoming state cycle, but clear state after initial load + // 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 @@ -268,7 +276,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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.programXAxisColumns = [...this.xAxisColumns.filter((c) => x_axis_columns.includes(c.id)), this.rankedCol] } validateProgram() { @@ -311,101 +319,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { }, { compliant: [], 'non-compliant': [], unknown: [] }) } - /* - * Step 1: Builds an empty chart - */ - 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, - }, - }, - }, - }, - }) - } setScheme() { this._configService.scheme$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((scheme) => { this.scheme = scheme @@ -422,15 +335,12 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { setChartSettings() { if (!this.program) return const [xAxisName, yAxisName] = this.getXYAxisName() - const xScale = this.chart.options.scales.x - if (xScale?.type === 'linear' || xScale?.type === 'category') { - xScale.title = { display: true, text: xAxisName } - } - const yScale = this.chart.options.scales.y - if (yScale?.type === 'linear' || yScale?.type === 'category') { - yScale.title = { display: true, text: yAxisName } - } - this.chart.options.scales.x.type = this.xCategorical ? 'category' : 'linear' + 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) { @@ -480,7 +390,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { if (!this.program || !this.data) return this.resetDatasets() - this.xCategorical = false const numProperties = Object.values(this.data.properties_by_cycles).reduce((acc, curr) => acc + curr.length, 0) if (numProperties > 3000) { @@ -552,10 +461,13 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { const nonCompliant = this.datasets.find((ds) => ds.label === 'non-compliant') const targetType = configs.metricType === 0 ? program.energy_metric_type : program.emission_metric_type - // RP - need to figure out - // rank - // if (this.form.value.xAxisColumnId === 'Ranked') {} - // ... + // Ranked distance from target (col id = 0) + if (this.form.value.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() @@ -644,11 +556,26 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { clearChart() { this.programCycles = [] - this.programXAxisColumns = [] + 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]) @@ -700,4 +627,97 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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, + }, + }, + }, + }, + }) + } } From a9487287fd0498abf2cd7a2978e797eec6c1c5fe Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 12 Sep 2025 16:04:53 +0000 Subject: [PATCH 19/22] form populate ali --- .../property-insights.component.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) 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 279ce494..7fc74edb 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -55,7 +55,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { accessLevelInstances: AccessLevelsByDepth[keyof AccessLevelsByDepth] = [] annotations: Record chart: Chart - chartName: string colDefs: ColDef[] = [] colors: Record = { compliant: '#77CCCB', 'non-compliant': '#A94455', unknown: '#DDDDDD' } cycles: Cycle[] @@ -157,6 +156,9 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { setForm() { if (!this.program) return + if (!this.accessLevelInstances) { + this.getPossibleAccessLevelInstances(this.accessLevelNames?.at(-1)) + } this.setFormOptions() const cycleId = this.getStateCycle() @@ -166,7 +168,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { xAxisColumnId: this.program.x_axis_columns[0], metricType, accessLevel: this.accessLevelNames.at(-1), - accessLevelInstance: this.accessLevelInstances[0], + accessLevelInstanceId: this.accessLevelInstances[0]?.id ?? null, } // wait for DOM to update before patching to avoid blank selections setTimeout(() => { @@ -351,15 +353,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { return label }, } - // labels for categorical - // RP - ADDRESS LABELS - // this.chart.data.labels = [] - // if (this.xCategorical) { - // let labels = [] - // for (const ds of this.datasets) { - // labels = ... - // } - // } } getXYAxisName(): string[] { @@ -543,7 +536,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { downloadChart() { const a = document.createElement('a') a.href = this.chart.toBase64Image() - a.download = `Program-${this.chartName}.png` + a.download = `Program-${this.program.name}.png` a.click() } From 4feae4656dc5ba32009d67be91c6968ea04d834e Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 12 Sep 2025 17:06:43 +0000 Subject: [PATCH 20/22] set results, use getters --- .../property-insights.component.ts | 68 +++++++++++++------ 1 file changed, 46 insertions(+), 22 deletions(-) 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 7fc74edb..c64b9e6b 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -95,6 +95,22 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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) => { @@ -148,9 +164,10 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this.programChange(this.programs[0]) } }), - filter(() => !!this.program), - switchMap(() => this.evaluateProgram(this.form.value.accessLevelInstanceId)), + filter(() => !!(this.program)), + switchMap(() => this.evaluateProgram(this.accessLevelInstanceId)), tap(() => { this.setForm() }), + takeUntil(this._unsubscribeAll$), ).subscribe() } @@ -182,13 +199,12 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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 }))), - this.form.get('accessLevelInstanceId')?.valueChanges.pipe(map((value) => ({ field: 'accessLevelInstanceId', value }))), ).pipe( tap(() => { this.loading = true }), debounceTime(300), tap(() => { this.setChart() - if (this.form.value.cycleId) { + if (this.cycleId) { this.setResults() this.loading = false } @@ -198,6 +214,14 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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() } @@ -223,6 +247,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { // suggest access level instance if null this.form.get('accessLevelInstanceId')?.setValue(this.accessLevelInstances[0]?.id) }), + takeUntil(this._unsubscribeAll$), ).subscribe() } @@ -250,6 +275,8 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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), ) @@ -283,13 +310,12 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { validateProgram() { const { actual_emission_column, actual_energy_column } = this.program - const { metricType } = this.form.value if (!this.data) { this.clearChart() return false } - const validEnergy = metricType === 0 && !!actual_energy_column - const validEmission = metricType === 1 && !!actual_emission_column + const validEnergy = this.metricType === 0 && !!actual_energy_column + const validEmission = this.metricType === 1 && !!actual_emission_column return validEnergy || validEmission } @@ -301,10 +327,9 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } setResults() { - if (!this.data) return + if (!this.data || !this.cycleId) return - const cycleId = this.form.value.cycleId - const { y, n, u } = this.data.results_by_cycles[cycleId] as { y: number[]; n: number[]; u: number[] } + 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 } this.datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] @@ -356,14 +381,14 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { } getXYAxisName(): string[] { - const xAxisCol = this.programXAxisColumns.find((col) => col.id === this.form.value.xAxisColumnId) + 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.form.value.metricType === 0 + const yAxisName = this.metricType === 0 ? energyCol?.display_name : emissionCol?.display_name @@ -411,22 +436,21 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { formatDataPoints() { const { metric, results_by_cycles } = this.data - const { cycleId, metricType, xAxisColumnId } = this.form.value - const properties = this.data.properties_by_cycles[cycleId] ?? [] - const cycleResult = results_by_cycles[cycleId] as ResultsByCycles + 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', `_${xAxisColumnId}`) as number + const x = this.getValue(prop, 'endsWith', `_${this.xAxisColumnId}`) as number let target: number let distance: number = null - const actualCol = metricType === 0 ? metric.actual_energy_column : metric.actual_emission_column - const targetCol = metricType === 0 ? metric.target_energy_column : metric.target_emission_column - const hasTarget = metricType === 0 ? !metric.energy_bool : !metric.emission_bool + 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) { @@ -449,13 +473,12 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { formatNonCompliantPoints() { this.annotations = {} - const configs = this.form.value const program = this.data.metric const nonCompliant = this.datasets.find((ds) => ds.label === 'non-compliant') - const targetType = configs.metricType === 0 ? program.energy_metric_type : program.emission_metric_type + const targetType = this.metricType === 0 ? program.energy_metric_type : program.emission_metric_type // Ranked distance from target (col id = 0) - if (this.form.value.xAxisColumnId === 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 @@ -612,6 +635,7 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { this.program = this.programs.find((p) => p.id == programId) this.programChange(this.program) }), + takeUntil(this._unsubscribeAll$), ) .subscribe() } From 56d9faa94766d91c2f5763ce1decdcab16af1510 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 12 Sep 2025 17:19:02 +0000 Subject: [PATCH 21/22] lint --- src/@seed/api/program/program.service.ts | 4 +- .../data-mappings/step1/column-defs.ts | 1 - .../data-mappings/step1/map-data.component.ts | 2 +- .../program-config-compact.component.html | 165 --------- .../config/program-config.component.html | 152 ++++---- .../config/program-config.component.ts | 4 +- .../program-overview.component.html | 58 +-- .../program-overview.component.ts | 2 +- .../property-insights.component.html | 343 +++++++++--------- .../property-insights.component.ts | 9 +- src/styles/styles.scss | 1 + 11 files changed, 291 insertions(+), 450 deletions(-) delete mode 100644 src/app/modules/insights/config/program-config-compact.component.html diff --git a/src/@seed/api/program/program.service.ts b/src/@seed/api/program/program.service.ts index 662f11b1..9ec7745d 100644 --- a/src/@seed/api/program/program.service.ts +++ b/src/@seed/api/program/program.service.ts @@ -1,10 +1,10 @@ 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 type { Observable } from 'rxjs' -import { BehaviorSubject, catchError, map, ReplaySubject, tap } from 'rxjs' import { UserService } from '../user' import type { Program, ProgramData, ProgramResponse, ProgramsResponse } from './program.types' 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 6bc3e066..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,7 +3,6 @@ import { EditHeaderComponent } from '@seed/components' import { AutocompleteCellComponent } from '@seed/components/ag-grid/autocomplete.component' import { dataTypeOptions, unitMap } from './constants' - // 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 7215c79c..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, RowClassParams, 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' diff --git a/src/app/modules/insights/config/program-config-compact.component.html b/src/app/modules/insights/config/program-config-compact.component.html deleted file mode 100644 index 4f3491e9..00000000 --- a/src/app/modules/insights/config/program-config-compact.component.html +++ /dev/null @@ -1,165 +0,0 @@ - - -
-
-
- - Select Program - - @for (metric of data.complianceMetrics; track $index) { - {{ metric.name }} - } - - - - -
- - - - -
-
- - General Settings -
-
- Configure your program metric to enable visualizations on the program overview page. -
-
- - -
- - Name - - - - - - Cycles - Select cycles to be included in the compliance period - - @for (cycle of data.cycles; track $index) { - {{ cycle.name }} - } - - - -
- - -
-
- - 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
- - 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
- - 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 - - @for (column of xAxisColumns; track $index) { - {{ column.display_name }} - } - - - - -
-
-
-
- -
- -
- - -
diff --git a/src/app/modules/insights/config/program-config.component.html b/src/app/modules/insights/config/program-config.component.html index 3ec007c6..4fb269c8 100644 --- a/src/app/modules/insights/config/program-config.component.html +++ b/src/app/modules/insights/config/program-config.component.html @@ -1,25 +1,24 @@ - +
-
+
Select a program to edit or create new @for (program of data.programs; track $index) { - {{ program.name }} + {{ program.name }} } @if (program) { -
- @@ -28,15 +27,18 @@ -
+
-
+
General Settings
-
- Configure your program metric to enable visualizations on the program overview page. +
+ Configure your program metric to enable visualizations on the + program overview + page.
@@ -48,91 +50,83 @@ -
+
Cycles Cycles - + @for (cycle of data.cycles; track $index) { {{ cycle.name }} } -
@for (cycle of form.value.cycles; track $index) { - - {{ getCycle(cycle) }} - cancel - + + {{ 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. +
+ 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) { +
+
Energy Metric
+ + + Actual Field + + + @for (column of metricColumns; track $index) { {{ column.display_name }} - } - - - - Target Field - - - @for (column of metricColumns; track $index) { + } + + + + Target Field + + + @for (column of metricColumns; track $index) { {{ column.display_name }} - } - - - - Compliance Type - - - @for (metricType of metricTypes; track $index) { - {{ metricType.key }} - } - - - -
- + } +
+
+ + Compliance Type + + + @for (metricType of metricTypes; track $index) { + {{ metricType.key }} + } + + +
-
- +
Emission Metric
Actual Field @for (column of metricColumns; track $index) { - {{ column.display_name }} + {{ column.display_name }} } @@ -141,7 +135,7 @@ @for (column of metricColumns; track $index) { - {{ column.display_name }} + {{ column.display_name }} } @@ -150,29 +144,29 @@ @for (metricType of metricTypes; track $index) { - {{ metricType.key }} + {{ 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. +
+ 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 @@ -187,23 +181,17 @@ @for (column of form.value.x_axis_columns; track $index) { - - {{ getColumn(column) }} - cancel - + + {{ 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 index 57ec65d6..d1bd4290 100644 --- a/src/app/modules/insights/config/program-config.component.ts +++ b/src/app/modules/insights/config/program-config.component.ts @@ -4,6 +4,8 @@ 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' @@ -12,8 +14,6 @@ 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' -import type { Observable } from 'rxjs' -import { finalize, Subject, take, tap } from 'rxjs' @Component({ selector: 'seed-program-config', 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 cf76b3a2..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,5 +1,5 @@ -
- -
+
Select Program @for (program of programs; track $index) { - + {{ program.name }} } @@ -25,22 +24,32 @@
-
-
- @for (entry of colors | keyvalue; track $index) { -
-
- {{ entry.key | titlecase }} -
- } -
-
+
+
+ @for (entry of colors | keyvalue; track $index) { +
+
+ {{ entry.key | titlecase }} +
+ } +
+
-
- - -
+
+ +
+
@@ -48,8 +57,9 @@
@if (program) {
- -
{{ chartName | titlecase }}
+
+ {{ chartName | titlecase }} +
}
@@ -59,13 +69,13 @@
@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 297b5e79..6c60e97e 100644 --- a/src/app/modules/insights/program-overview/program-overview.component.ts +++ b/src/app/modules/insights/program-overview/program-overview.component.ts @@ -87,7 +87,7 @@ export class ProgramOverviewComponent implements OnDestroy, OnInit { this.programChange(this.programs[0]) } }), - filter(() => this.program?.organization_id === this.org.id ), + filter(() => this.program?.organization_id === this.org.id), switchMap(() => this.evaluateProgram()), takeUntil(this._unsubscribeAll$), ).subscribe() 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 1323a292..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,201 +1,210 @@ - -
- -
- -
- - Select Program - - @for (p of programs; track $index) { + }" +> +
+
+ +
+ + Select Program + + @for (p of programs; track $index) { {{ p.name }} - } - - -
+ } + + +
- - @if (program) { -
-
- + @if (program) { +
+
+ + multiple + hideMultipleSelectionIndicator="false" + > @for (entry of colors | keyvalue; track $index; let i = $index) {
-
+
{{ entry.key | titlecase }}
- } - - - Distance from Target - -
-
-
-
- - + } + + + 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 }} - } - - - -
+
+ + @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
- -
- -
-
- - Data Table -
- Click to expand -
+ + + + Compliant: + {{ results.y }} + + + + + + Non-Compliant: + {{ results.n }} + + + + + + Unknown: + {{ results.u }} + + + + +
- - - - Compliant: - {{ results.y }} - - - - - - Non-Compliant: - {{ results.n }} - - - - - - Unknown: - {{ results.u }} - - - - + @if (loading) { +
+
+ } - @if (loading) { -
- -
- } - - @if (!program) { -
+ @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 c64b9e6b..6ea7bfad 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -1,5 +1,4 @@ -import { CommonModule } from '@angular/common' -import { Location } from '@angular/common' +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' @@ -8,12 +7,12 @@ 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, CartesianScaleOptions, ScaleOptionsByType, ScatterDataPoint, TooltipItem } from 'chart.js' +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, ProgramData, SimpleCartesianScale } from '@seed/api' -import { ColumnService, CycleService, OrganizationService, type Program, ProgramService, type PropertyInsightDataset, type PropertyInsightPoint, type ResultsByCycles } from '@seed/api' +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' diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 087c245c..4b3a386f 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -307,6 +307,7 @@ .mat-expansion-panel { @apply border m-0 !rounded-none; } + .mat-expansion-panel-body { @apply p-0; } From d0ed64ec70502e5c2951f336952c4cdf934f8cd8 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Fri, 12 Sep 2025 17:38:17 +0000 Subject: [PATCH 22/22] visibility logic --- .../property-insights.component.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 6ea7bfad..3eb14125 100644 --- a/src/app/modules/insights/property-insights/property-insights.component.ts +++ b/src/app/modules/insights/property-insights/property-insights.component.ts @@ -286,16 +286,16 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { const { cycles } = this.program const state = this._location.getState() as { cycleId?: number; label?: string } const stateCycleId = state.cycleId - const stateLabel = state.label - this.handleLabel(stateLabel) + const dataset = state.label + this.handleLegendVisibility(dataset) history.replaceState({}, document.title) return cycles.find((c) => c === stateCycleId) ?? cycles[0] } - handleLabel(label: string) { - if (!label) this.datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] - if (label) this.datasetVisibility = [label] - if (label === 'non-compliant') this.datasetVisibility.push('whisker') + 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() { @@ -330,7 +330,6 @@ export class PropertyInsightsComponent implements OnDestroy, OnInit { 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 } - this.datasetVisibility = ['compliant', 'non-compliant', 'unknown', 'whisker'] const { scales } = this.chart.options as { scales: { x: { title: { text: string } }; y: { title: { text: string } } } } this.colDefs = [