feat(charts): implement lazy loading for Chart.js to reduce initial bundle size

- Create dedicated lazy-loading service and component for charts
- Remove Chart.js from main bundle, load on-demand instead
- Fix responsive scaling issues for smaller screens
- Improve TypeScript typing throughout chart components
- Ensure single module load with proper caching
- Fix chart overlap issue in simple counter dialog
This commit is contained in:
Johannes Millan 2025-08-10 14:05:53 +02:00
parent e8f1498f10
commit 5dfbbd80be
8 changed files with 257 additions and 58 deletions

View file

@ -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<void>;
private isRegistered = false;
async loadChartModule(): Promise<void> {
// 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<void> {
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;
}
}

View file

@ -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: `
<div class="chart-wrapper">
@if (!isLoaded) {
<div class="chart-loading">Loading chart...</div>
}
@if (isLoaded) {
<div
class="chart-container"
[style.height]="height"
>
<canvas #canvas></canvas>
</div>
}
</div>
`,
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<ChartClickEvent>();
@ViewChild('canvas', { static: false })
canvasRef?: ElementRef<HTMLCanvasElement>;
private readonly chartLoaderService = inject(ChartLazyLoaderService);
private readonly cdr = inject(ChangeDetectorRef);
isLoaded = false;
private chartInstance?: ChartJS;
async ngOnInit(): Promise<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
private async initChart(): Promise<void> {
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;
}
}
}

View file

@ -75,27 +75,29 @@
@if (metricService.improvementCountsPieChartData$ | async; as improvementCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.IMPROVEMENT_SELECTION_COUNT | translate }}</h3>
<canvas
<lazy-chart
[type]="pieChartType"
[data]="improvementCounts"
[datasets]="improvementCounts.datasets"
[labels]="improvementCounts.labels"
[legend]="improvementCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
baseChart
height="300px"
>
</canvas>
</lazy-chart>
</section>
}
@if (metricService.obstructionCountsPieChartData$ | async; as obstructionCounts) {
<section>
<h3>{{ T.F.METRIC.CMP.OBSTRUCTION_SELECTION_COUNT | translate }}</h3>
<canvas
<lazy-chart
[type]="pieChartType"
[data]="obstructionCounts"
[datasets]="obstructionCounts.datasets"
[labels]="obstructionCounts.labels"
[legend]="obstructionCounts?.datasets[0].data.length < 12"
[options]="pieChartOptions"
baseChart
height="300px"
>
</canvas>
</lazy-chart>
</section>
}
</section>
@ -103,17 +105,14 @@
@if (productivityHappiness$ | async; as productivityHappiness) {
<section>
<h3>{{ T.F.METRIC.CMP.MOOD_PRODUCTIVITY_OVER_TIME | translate }}</h3>
<div class="chart-wrapper">
<div class="inner-chart-wrapper">
<canvas
[type]="lineChartType"
[data]="productivityHappiness"
[legend]="true"
[options]="lineChartOptions"
baseChart
></canvas>
</div>
</div>
<lazy-chart
[type]="lineChartType"
[datasets]="productivityHappiness.datasets"
[labels]="productivityHappiness.labels"
[legend]="true"
[options]="lineChartOptions"
height="400px"
></lazy-chart>
</section>
}
</section>
@ -126,37 +125,29 @@
@if (simpleClickCounterData$ | async; as simpleCounterClickData) {
<section>
<h3>{{ T.F.METRIC.CMP.SIMPLE_CLICK_COUNTERS_OVER_TIME | translate }}</h3>
<div class="chart-wrapper">
<div class="inner-chart-wrapper">
<canvas
[type]="lineChartType"
[data]="simpleCounterClickData"
[labels]="simpleCounterClickData.labels"
[options]="lineChartOptions"
[legend]="true"
baseChart
>
</canvas>
</div>
</div>
<lazy-chart
[type]="lineChartType"
[datasets]="simpleCounterClickData.datasets"
[labels]="simpleCounterClickData.labels"
[options]="lineChartOptions"
[legend]="true"
height="400px"
>
</lazy-chart>
</section>
}
@if (simpleCounterStopWatchData$ | async; as simpleCounterStopWatchData) {
<section>
<h3>{{ T.F.METRIC.CMP.SIMPLE_STOPWATCH_COUNTERS_OVER_TIME | translate }}</h3>
<div class="chart-wrapper">
<div class="inner-chart-wrapper">
<canvas
[type]="lineChartType"
[data]="simpleCounterStopWatchData"
[labels]="simpleCounterStopWatchData.labels"
[options]="lineChartOptions"
[legend]="true"
baseChart
>
</canvas>
</div>
</div>
<lazy-chart
[type]="lineChartType"
[datasets]="simpleCounterStopWatchData.datasets"
[labels]="simpleCounterStopWatchData.labels"
[options]="lineChartOptions"
[legend]="true"
height="400px"
>
</lazy-chart>
</section>
}
</section>

View file

@ -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<LineChartData> =
this.metricService.getSimpleCounterStopwatchMetrics$();
pieChartOptions: ChartConfiguration<'pie', Array<number>, any>['options'] = {
pieChartOptions: ChartConfiguration<'pie', number[], string>['options'] = {
scales: {
x: {
ticks: {

View file

@ -34,15 +34,16 @@
@if (chartData()) {
<section class="chart-section">
<div class="chart-wrapper">
<canvas
baseChart
<lazy-chart
[type]="'line'"
[data]="chartData()"
[datasets]="chartData().datasets"
[labels]="chartData().labels"
[options]="chartOptions"
[legend]="false"
height="200px"
(chartClick)="onChartClick($event)"
>
</canvas>
</lazy-chart>
</div>
</section>
}

View file

@ -23,6 +23,7 @@
.chart-wrapper {
height: 200px;
position: relative;
overflow: hidden;
@include mq(xs) {
min-width: 500px;

View file

@ -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;

View file

@ -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,