diff --git a/src/app/features/metric/chart-lazy-loader.service.ts b/src/app/features/metric/chart-lazy-loader.service.ts new file mode 100644 index 0000000000..e82016b409 --- /dev/null +++ b/src/app/features/metric/chart-lazy-loader.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; + +type ChartJSModule = typeof import('chart.js'); + +@Injectable({ + providedIn: 'root', +}) +export class ChartLazyLoaderService { + private chartJSModule?: ChartJSModule; + private loadingPromise?: Promise; + private isRegistered = false; + + async loadChartModule(): Promise { + // If already loading, return the existing promise + if (this.loadingPromise) { + return this.loadingPromise; + } + + // If already loaded, return immediately + if (this.chartJSModule && this.isRegistered) { + return Promise.resolve(); + } + + // Start loading and cache the promise + this.loadingPromise = this._loadModules(); + return this.loadingPromise; + } + + private async _loadModules(): Promise { + const chartJs = await import('chart.js'); + + // Register Chart.js components only once + if (!this.isRegistered) { + const { Chart, registerables } = chartJs; + Chart.register(...registerables); + this.isRegistered = true; + } + + // Cache the module + this.chartJSModule = chartJs; + } + + getChartModule(): ChartJSModule | undefined { + return this.chartJSModule; + } +} diff --git a/src/app/features/metric/lazy-chart/lazy-chart.component.ts b/src/app/features/metric/lazy-chart/lazy-chart.component.ts new file mode 100644 index 0000000000..107e42349d --- /dev/null +++ b/src/app/features/metric/lazy-chart/lazy-chart.component.ts @@ -0,0 +1,163 @@ +import { + Component, + Input, + ViewChild, + ElementRef, + OnInit, + OnDestroy, + inject, + ChangeDetectorRef, + Output, + EventEmitter, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { + ChartType, + ChartData, + ChartOptions, + ActiveElement, + ChartEvent, + Chart as ChartJS, + ChartConfiguration, +} from 'chart.js'; +import { CommonModule } from '@angular/common'; +import { ChartLazyLoaderService } from '../chart-lazy-loader.service'; + +interface ChartClickEvent { + active: ActiveElement[]; +} + +@Component({ + selector: 'lazy-chart', + template: ` +
+ @if (!isLoaded) { +
Loading chart...
+ } + @if (isLoaded) { +
+ +
+ } +
+ `, + styles: [ + ` + :host { + display: block; + width: 100%; + } + .chart-wrapper { + width: 100%; + position: relative; + } + .chart-container { + position: relative; + width: 100%; + } + .chart-loading { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: #666; + } + `, + ], + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LazyChartComponent implements OnInit, OnDestroy { + @Input({ required: true }) type!: ChartType; + @Input() datasets: ChartData['datasets'] = []; + @Input() labels?: ChartData['labels']; + @Input() options?: ChartOptions; + @Input() legend = true; + @Input() height = '400px'; + + @Output() chartClick = new EventEmitter(); + + @ViewChild('canvas', { static: false }) + canvasRef?: ElementRef; + + private readonly chartLoaderService = inject(ChartLazyLoaderService); + private readonly cdr = inject(ChangeDetectorRef); + + isLoaded = false; + private chartInstance?: ChartJS; + + async ngOnInit(): Promise { + try { + await this.chartLoaderService.loadChartModule(); + this.isLoaded = true; + this.cdr.markForCheck(); + + // Wait for next tick to ensure canvas is rendered + await this.waitForNextTick(); + await this.initChart(); + } catch (error) { + console.error('Failed to load chart module:', error); + } + } + + private async waitForNextTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + private async initChart(): Promise { + if (!this.canvasRef?.nativeElement) { + console.warn('Canvas element not available'); + return; + } + + const chartModule = this.chartLoaderService.getChartModule(); + if (!chartModule) { + console.error('Chart module not loaded'); + return; + } + + const { Chart } = chartModule; + + const chartOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + ...this.options, + plugins: { + ...this.options?.plugins, + legend: { + display: this.legend, + ...this.options?.plugins?.legend, + }, + }, + onClick: (event: ChartEvent, activeElements: ActiveElement[]) => { + this.chartClick.emit({ active: activeElements }); + }, + }; + + const chartConfig: ChartConfiguration = { + type: this.type, + data: { + labels: this.labels, + datasets: this.datasets, + }, + options: chartOptions, + }; + + try { + this.chartInstance = new Chart(this.canvasRef.nativeElement, chartConfig); + } catch (error) { + console.error('Failed to initialize chart:', error); + } + } + + ngOnDestroy(): void { + if (this.chartInstance) { + this.chartInstance.destroy(); + this.chartInstance = undefined; + } + } +} diff --git a/src/app/features/metric/metric.component.html b/src/app/features/metric/metric.component.html index cf3962f4cf..818984ec8f 100644 --- a/src/app/features/metric/metric.component.html +++ b/src/app/features/metric/metric.component.html @@ -75,27 +75,29 @@ @if (metricService.improvementCountsPieChartData$ | async; as improvementCounts) {

{{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}

- - +
} @if (metricService.obstructionCountsPieChartData$ | async; as obstructionCounts) {

{{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}

- - +
} @@ -103,17 +105,14 @@ @if (productivityHappiness$ | async; as productivityHappiness) {

{{ T.F.METRIC.CMP.MOOD_PRODUCTIVITY_OVER_TIME | translate }}

-
-
- -
-
+
} @@ -126,37 +125,29 @@ @if (simpleClickCounterData$ | async; as simpleCounterClickData) {

{{ T.F.METRIC.CMP.SIMPLE_CLICK_COUNTERS_OVER_TIME | translate }}

-
-
- - -
-
+ +
} @if (simpleCounterStopWatchData$ | async; as simpleCounterStopWatchData) {

{{ T.F.METRIC.CMP.SIMPLE_STOPWATCH_COUNTERS_OVER_TIME | translate }}

-
-
- - -
-
+ +
} diff --git a/src/app/features/metric/metric.component.ts b/src/app/features/metric/metric.component.ts index b7c7722199..466d321653 100644 --- a/src/app/features/metric/metric.component.ts +++ b/src/app/features/metric/metric.component.ts @@ -1,14 +1,13 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChartConfiguration, ChartType } from 'chart.js'; import { MetricService } from './metric.service'; -// import { Color } from 'ng2-charts'; import { Observable } from 'rxjs'; import { LineChartData } from './metric.model'; import { fadeAnimation } from '../../ui/animations/fade.ani'; import { T } from '../../t.const'; import { ProjectMetricsService } from './project-metrics.service'; import { WorkContextService } from '../work-context/work-context.service'; -import { BaseChartDirective } from 'ng2-charts'; +import { LazyChartComponent } from './lazy-chart/lazy-chart.component'; import { AsyncPipe, DecimalPipe } from '@angular/common'; import { MsToStringPipe } from '../../ui/duration/ms-to-string.pipe'; import { TranslatePipe } from '@ngx-translate/core'; @@ -19,7 +18,7 @@ import { TranslatePipe } from '@ngx-translate/core'; styleUrls: ['./metric.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeAnimation], - imports: [BaseChartDirective, AsyncPipe, DecimalPipe, MsToStringPipe, TranslatePipe], + imports: [LazyChartComponent, AsyncPipe, DecimalPipe, MsToStringPipe, TranslatePipe], }) export class MetricComponent { workContextService = inject(WorkContextService); @@ -37,7 +36,7 @@ export class MetricComponent { simpleCounterStopWatchData$: Observable = this.metricService.getSimpleCounterStopwatchMetrics$(); - pieChartOptions: ChartConfiguration<'pie', Array, any>['options'] = { + pieChartOptions: ChartConfiguration<'pie', number[], string>['options'] = { scales: { x: { ticks: { diff --git a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html index d4006f5a45..ea736b74c0 100644 --- a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html +++ b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html @@ -34,15 +34,16 @@ @if (chartData()) {
- - +
} diff --git a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.scss b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.scss index f0e131211a..c63c9f71ff 100644 --- a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.scss +++ b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.scss @@ -23,6 +23,7 @@ .chart-wrapper { height: 200px; position: relative; + overflow: hidden; @include mq(xs) { min-width: 500px; diff --git a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.ts b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.ts index 5515fc2da9..87eb45c2c1 100644 --- a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.ts +++ b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.ts @@ -24,11 +24,11 @@ import { InputDurationDirective } from '../../../ui/duration/input-duration.dire import { MatIcon } from '@angular/material/icon'; import { MatButton } from '@angular/material/button'; import { TranslatePipe } from '@ngx-translate/core'; -import { BaseChartDirective } from 'ng2-charts'; import { ChartConfiguration } from 'chart.js'; import { LineChartData } from '../../metric/metric.model'; import { getSimpleCounterStreakDuration } from '../get-simple-counter-streak-duration'; import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe'; +import { LazyChartComponent } from '../../metric/lazy-chart/lazy-chart.component'; const CHART_DAYS = 28; const CHART_COLOR = '#4bc0c0'; @@ -49,7 +49,7 @@ const CHART_COLOR = '#4bc0c0'; MatDialogActions, MatButton, TranslatePipe, - BaseChartDirective, + LazyChartComponent, MsToStringPipe, ], }) @@ -126,7 +126,7 @@ export class DialogSimpleCounterEditComponent { }, }; - onChartClick(event: { active?: any[] }): void { + onChartClick(event: { active: { index: number }[] }): void { if (!event.active?.length) return; const index = event.active[0].index; diff --git a/src/main.ts b/src/main.ts index a4b8459776..70fce08529 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,7 +61,6 @@ import { AppComponent } from './app/app.component'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; import { ShortTimeHtmlPipe } from './app/ui/pipes/short-time-html.pipe'; import { ShortTimePipe } from './app/ui/pipes/short-time.pipe'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { BackgroundTask } from '@capawesome/capacitor-background-task'; import { promiseTimeout } from './app/util/promise-timeout'; import { PLUGIN_INITIALIZER_PROVIDER } from './app/plugins/plugin-initializer'; @@ -165,7 +164,6 @@ bootstrapApplication(AppComponent, { DatePipe, ShortTimeHtmlPipe, ShortTimePipe, - provideCharts(withDefaultRegisterables()), provideMarkdown(), { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,