parent
d5ba624403
commit
23f2ac472e
@ -0,0 +1,65 @@
|
||||
<div class="container p-0">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<form class="" [formGroup]="calculatorForm">
|
||||
<!--<mat-form-field appearance="outline">
|
||||
<input formControlName="principalInvestmentAmount" matInput />
|
||||
</mat-form-field>-->
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Savings Rate</mat-label>
|
||||
<input
|
||||
formControlName="paymentPerPeriod"
|
||||
matInput
|
||||
step="100"
|
||||
type="number"
|
||||
/>
|
||||
<span class="ml-2" i18n matSuffix>{{ currency }} per month</span>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Investment Horizon</mat-label>
|
||||
<input formControlName="time" matInput type="number" />
|
||||
<span class="ml-2" i18n matSuffix>years</span>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Annual Interest Rate</mat-label>
|
||||
<input
|
||||
formControlName="annualInterestRate"
|
||||
matInput
|
||||
step="0.25"
|
||||
type="number"
|
||||
/>
|
||||
<span class="ml-2" i18n matSuffix>%</span>
|
||||
</mat-form-field>
|
||||
|
||||
<gf-value
|
||||
label="Projected Total Amount"
|
||||
size="large"
|
||||
[currency]="currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="projectedTotalAmount"
|
||||
></gf-value>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-9 text-center">
|
||||
<div class="chart-container mb-4">
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
[theme]="{
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
<canvas
|
||||
#chartCanvas
|
||||
class="h-100"
|
||||
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.chart-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
ngx-skeleton-loader {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
||||
import { Meta, Story, moduleMetadata } from '@storybook/angular';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { GfValueModule } from '../value';
|
||||
|
||||
import { FireCalculatorComponent } from './fire-calculator.component';
|
||||
import { FireCalculatorService } from './fire-calculator.service';
|
||||
|
||||
export default {
|
||||
title: 'FIRE Calculator',
|
||||
component: FireCalculatorComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [FireCalculatorComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
NoopAnimationsModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: [FireCalculatorService]
|
||||
})
|
||||
]
|
||||
} as Meta<FireCalculatorComponent>;
|
||||
|
||||
const Template: Story<FireCalculatorComponent> = (
|
||||
args: FireCalculatorComponent
|
||||
) => ({
|
||||
props: args
|
||||
});
|
||||
|
||||
export const Simple = Template.bind({});
|
||||
Simple.args = {
|
||||
currency: baseCurrency,
|
||||
fireWealth: 0,
|
||||
locale: locale
|
||||
};
|
@ -0,0 +1,247 @@
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormControl } from '@angular/forms';
|
||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
LinearScale,
|
||||
Tooltip
|
||||
} from 'chart.js';
|
||||
|
||||
import { FireCalculatorService } from './fire-calculator.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-fire-calculator',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './fire-calculator.component.html',
|
||||
styleUrls: ['./fire-calculator.component.scss']
|
||||
})
|
||||
export class FireCalculatorComponent
|
||||
implements AfterViewInit, OnChanges, OnDestroy
|
||||
{
|
||||
@Input() currency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() fireWealth: number;
|
||||
@Input() locale: string;
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public calculatorForm = this.formBuilder.group({
|
||||
annualInterestRate: new FormControl(),
|
||||
paymentPerPeriod: new FormControl(),
|
||||
principalInvestmentAmount: new FormControl(),
|
||||
time: new FormControl()
|
||||
});
|
||||
public chart: Chart;
|
||||
public isLoading = true;
|
||||
public projectedTotalAmount: number;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private fireCalculatorService: FireCalculatorService,
|
||||
private formBuilder: FormBuilder
|
||||
) {
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip
|
||||
);
|
||||
|
||||
this.calculatorForm.setValue({
|
||||
annualInterestRate: 5,
|
||||
paymentPerPeriod: 500,
|
||||
principalInvestmentAmount: 0,
|
||||
time: 10
|
||||
});
|
||||
|
||||
this.calculatorForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.initialize();
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterViewInit() {
|
||||
if (this.fireWealth >= 0) {
|
||||
setTimeout(() => {
|
||||
// Wait for the chartCanvas
|
||||
this.calculatorForm.patchValue({
|
||||
principalInvestmentAmount: this.fireWealth
|
||||
});
|
||||
this.calculatorForm.get('principalInvestmentAmount').disable();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnChanges() {
|
||||
if (this.fireWealth >= 0) {
|
||||
setTimeout(() => {
|
||||
// Wait for the chartCanvas
|
||||
this.calculatorForm.patchValue({
|
||||
principalInvestmentAmount: this.fireWealth
|
||||
});
|
||||
this.calculatorForm.get('principalInvestmentAmount').disable();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.chart?.destroy();
|
||||
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.isLoading = true;
|
||||
|
||||
const chartData = this.getChartData();
|
||||
|
||||
if (this.chartCanvas) {
|
||||
if (this.chart) {
|
||||
this.chart.data.labels = chartData.labels;
|
||||
this.chart.data.datasets[0].data = chartData.datasets[0].data;
|
||||
this.chart.data.datasets[1].data = chartData.datasets[1].data;
|
||||
|
||||
this.chart.update();
|
||||
} else {
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
data: chartData,
|
||||
options: {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat(this.locale, {
|
||||
currency: this.currency,
|
||||
currencyDisplay: 'code',
|
||||
style: 'currency'
|
||||
}).format(context.parsed.y);
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
stacked: true
|
||||
},
|
||||
y: {
|
||||
display: this.deviceType !== 'mobile',
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
stacked: true,
|
||||
ticks: {
|
||||
callback: (value: number) => {
|
||||
return transformTickToAbbreviation(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'bar'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private getChartData() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const labels = [];
|
||||
|
||||
// Principal investment amount
|
||||
const P: number =
|
||||
this.calculatorForm.get('principalInvestmentAmount').value || 0;
|
||||
|
||||
// Payment per period
|
||||
const PMT: number = parseFloat(
|
||||
this.calculatorForm.get('paymentPerPeriod').value
|
||||
);
|
||||
|
||||
// Annual interest rate
|
||||
const r: number = this.calculatorForm.get('annualInterestRate').value / 100;
|
||||
|
||||
// Time
|
||||
const t: number = parseFloat(this.calculatorForm.get('time').value);
|
||||
|
||||
for (let year = currentYear; year < currentYear + t; year++) {
|
||||
labels.push(year);
|
||||
}
|
||||
|
||||
const datasetInterest = {
|
||||
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
data: [],
|
||||
label: 'Interest'
|
||||
};
|
||||
|
||||
const datasetPrincipal = {
|
||||
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
data: [],
|
||||
label: 'Principal'
|
||||
};
|
||||
|
||||
for (let period = 1; period <= t; period++) {
|
||||
const { interest, principal, totalAmount } =
|
||||
this.fireCalculatorService.calculateCompoundInterest({
|
||||
P,
|
||||
period,
|
||||
PMT,
|
||||
r
|
||||
});
|
||||
|
||||
datasetPrincipal.data.push(principal.toNumber());
|
||||
datasetInterest.data.push(interest.toNumber());
|
||||
|
||||
if (period === t - 1) {
|
||||
this.projectedTotalAmount = totalAmount.toNumber();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [datasetPrincipal, datasetInterest]
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfValueModule } from '../value';
|
||||
import { FireCalculatorComponent } from './fire-calculator.component';
|
||||
import { FireCalculatorService } from './fire-calculator.service';
|
||||
|
||||
@NgModule({
|
||||
declarations: [FireCalculatorComponent],
|
||||
exports: [FireCalculatorComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: [FireCalculatorService]
|
||||
})
|
||||
export class GfFireCalculatorModule {}
|
@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import Big from 'big.js';
|
||||
|
||||
@Injectable()
|
||||
export class FireCalculatorService {
|
||||
private readonly COMPOUND_PERIOD = 12;
|
||||
private readonly CONTRIBUTION_PERIOD = 12;
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor() {}
|
||||
|
||||
public calculateCompoundInterest({
|
||||
P,
|
||||
period,
|
||||
PMT,
|
||||
r
|
||||
}: {
|
||||
P: number;
|
||||
period: number;
|
||||
PMT: number;
|
||||
r: number;
|
||||
}) {
|
||||
let interest = new Big(0);
|
||||
const principal = new Big(P).plus(
|
||||
new Big(PMT).mul(this.CONTRIBUTION_PERIOD).mul(period)
|
||||
);
|
||||
let totalAmount = principal;
|
||||
|
||||
if (r) {
|
||||
const compoundInterestForPrincipal = new Big(1)
|
||||
.plus(new Big(r).div(this.COMPOUND_PERIOD))
|
||||
.pow(new Big(this.COMPOUND_PERIOD).mul(period).toNumber());
|
||||
const compoundInterest = new Big(P).mul(compoundInterestForPrincipal);
|
||||
const contributionInterest = new Big(
|
||||
new Big(PMT).mul(compoundInterestForPrincipal.minus(1))
|
||||
).div(new Big(r).div(this.CONTRIBUTION_PERIOD));
|
||||
interest = compoundInterest.plus(contributionInterest).minus(principal);
|
||||
totalAmount = compoundInterest.plus(contributionInterest);
|
||||
}
|
||||
|
||||
return {
|
||||
interest,
|
||||
principal,
|
||||
totalAmount
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './fire-calculator.module';
|
Loading…
Reference in new issue