mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
e8f1498f10
commit
5dfbbd80be
8 changed files with 257 additions and 58 deletions
46
src/app/features/metric/chart-lazy-loader.service.ts
Normal file
46
src/app/features/metric/chart-lazy-loader.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
163
src/app/features/metric/lazy-chart/lazy-chart.component.ts
Normal file
163
src/app/features/metric/lazy-chart/lazy-chart.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
.chart-wrapper {
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@include mq(xs) {
|
||||
min-width: 500px;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue