You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

394 lines
12 KiB

import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from '@ghostfolio/common/calculation-helper';
import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { getLocale } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { ColorScheme, DateRange } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
} from '@angular/core';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { ChartConfiguration } from 'chart.js';
import { LinearScale } from 'chart.js';
import { Chart, Tooltip } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
import { isUUID } from 'class-validator';
import { differenceInDays, max } from 'date-fns';
import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import OpenColor from 'open-color';
import { GetColorParams } from './interfaces/interfaces';
const { gray, green, red } = OpenColor;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule],
selector: 'gf-treemap-chart',
styleUrls: ['./treemap-chart.component.scss'],
templateUrl: './treemap-chart.component.html'
})
export class GfTreemapChartComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() baseCurrency: string;
@Input() colorScheme: ColorScheme;
@Input() cursor: string;
@Input() dateRange: DateRange;
@Input() holdings: PortfolioPosition[];
@Input() locale = getLocale();
@Output() treemapChartClicked = new EventEmitter<AssetProfileIdentifier>();
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'treemap'>;
public isLoading = true;
public constructor() {
Chart.register(LinearScale, Tooltip, TreemapController, TreemapElement);
}
public ngAfterViewInit() {
if (this.holdings) {
this.initialize();
}
}
public ngOnChanges() {
if (this.holdings) {
this.initialize();
}
}
public ngOnDestroy() {
this.chart?.destroy();
}
private getColor({
annualizedNetPerformancePercent,
negativeNetPerformancePercentsRange,
positiveNetPerformancePercentsRange
}: GetColorParams) {
if (Math.abs(annualizedNetPerformancePercent) === 0) {
return {
backgroundColor: gray[3],
fontColor: gray[9]
};
}
if (annualizedNetPerformancePercent > 0) {
let backgroundIndex: number;
const range =
positiveNetPerformancePercentsRange.max -
positiveNetPerformancePercentsRange.min;
if (
annualizedNetPerformancePercent >=
positiveNetPerformancePercentsRange.max - range * 0.25
) {
backgroundIndex = 9;
} else if (
annualizedNetPerformancePercent >=
positiveNetPerformancePercentsRange.max - range * 0.5
) {
backgroundIndex = 7;
} else if (
annualizedNetPerformancePercent >=
positiveNetPerformancePercentsRange.max - range * 0.75
) {
backgroundIndex = 5;
} else {
backgroundIndex = 3;
}
return {
backgroundColor: green[backgroundIndex],
fontColor: green[backgroundIndex <= 4 ? 9 : 0]
};
} else {
let backgroundIndex: number;
const range =
negativeNetPerformancePercentsRange.min -
negativeNetPerformancePercentsRange.max;
if (
annualizedNetPerformancePercent <=
negativeNetPerformancePercentsRange.min + range * 0.25
) {
backgroundIndex = 9;
} else if (
annualizedNetPerformancePercent <=
negativeNetPerformancePercentsRange.min + range * 0.5
) {
backgroundIndex = 7;
} else if (
annualizedNetPerformancePercent <=
negativeNetPerformancePercentsRange.min + range * 0.75
) {
backgroundIndex = 5;
} else {
backgroundIndex = 3;
}
return {
backgroundColor: red[backgroundIndex],
fontColor: red[backgroundIndex <= 4 ? 9 : 0]
};
}
}
private initialize() {
this.isLoading = true;
const { endDate, startDate } = getIntervalFromDateRange(this.dateRange);
const netPerformancePercentsWithCurrencyEffect = this.holdings.map(
({ dateOfFirstActivity, netPerformancePercentWithCurrencyEffect }) => {
return getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
endDate,
max([dateOfFirstActivity ?? new Date(0), startDate])
),
netPerformancePercentage: new Big(
netPerformancePercentWithCurrencyEffect
)
}).toNumber();
}
);
const positiveNetPerformancePercents =
netPerformancePercentsWithCurrencyEffect.filter(
(annualizedNetPerformancePercent) => {
return annualizedNetPerformancePercent > 0;
}
);
const positiveNetPerformancePercentsRange = {
max: Math.max(...positiveNetPerformancePercents),
min: Math.min(...positiveNetPerformancePercents)
};
const negativeNetPerformancePercents =
netPerformancePercentsWithCurrencyEffect.filter(
(annualizedNetPerformancePercent) => {
return annualizedNetPerformancePercent < 0;
}
);
const negativeNetPerformancePercentsRange = {
max: Math.max(...negativeNetPerformancePercents),
min: Math.min(...negativeNetPerformancePercents)
};
const data: ChartConfiguration<'treemap'>['data'] = {
datasets: [
{
backgroundColor: (context) => {
let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
endDate,
max([
context.raw._data.dateOfFirstActivity ?? new Date(0),
startDate
])
),
netPerformancePercentage: new Big(
context.raw._data.netPerformancePercentWithCurrencyEffect
)
}).toNumber();
// Round to 2 decimal places
annualizedNetPerformancePercent =
Math.round(annualizedNetPerformancePercent * 100) / 100;
const { backgroundColor } = this.getColor({
annualizedNetPerformancePercent,
negativeNetPerformancePercentsRange,
positiveNetPerformancePercentsRange
});
return backgroundColor;
},
borderRadius: 4,
key: 'allocationInPercentage',
labels: {
align: 'left',
color: (context) => {
let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
endDate,
max([
context.raw._data.dateOfFirstActivity ?? new Date(0),
startDate
])
),
netPerformancePercentage: new Big(
context.raw._data.netPerformancePercentWithCurrencyEffect
)
}).toNumber();
// Round to 2 decimal places
annualizedNetPerformancePercent =
Math.round(annualizedNetPerformancePercent * 100) / 100;
const { fontColor } = this.getColor({
annualizedNetPerformancePercent,
negativeNetPerformancePercentsRange,
positiveNetPerformancePercentsRange
});
return fontColor;
},
display: true,
font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }],
formatter: ({ raw }) => {
// Round to 4 decimal places
let netPerformancePercentWithCurrencyEffect =
Math.round(
raw._data.netPerformancePercentWithCurrencyEffect * 10000
) / 10000;
if (Math.abs(netPerformancePercentWithCurrencyEffect) === 0) {
netPerformancePercentWithCurrencyEffect = Math.abs(
netPerformancePercentWithCurrencyEffect
);
}
const name = raw._data.name;
const symbol = raw._data.symbol;
return [
isUUID(symbol) ? (name ?? symbol) : symbol,
`${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
];
},
hoverColor: undefined,
position: 'top'
},
spacing: 1,
tree: this.holdings
}
]
} as any;
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown;
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
onClick: (event, activeElements) => {
try {
const dataIndex = activeElements[0].index;
const datasetIndex = activeElements[0].datasetIndex;
const dataset = orderBy(
event.chart.data.datasets[datasetIndex].tree,
['allocationInPercentage'],
['desc']
);
const dataSource: DataSource = dataset[dataIndex].dataSource;
const symbol: string = dataset[dataIndex].symbol;
this.treemapChartClicked.emit({ dataSource, symbol });
} catch {}
},
onHover: (event, chartElement) => {
if (this.cursor) {
event.native.target.style.cursor = chartElement[0]
? this.cursor
: 'default';
}
},
plugins: {
tooltip: this.getTooltipPluginConfiguration()
}
} as unknown,
type: 'treemap'
});
}
}
this.isLoading = false;
}
private getTooltipPluginConfiguration() {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
currency: this.baseCurrency,
locale: this.locale
}),
callbacks: {
label: ({ raw }) => {
const allocationInPercentage = `${((raw._data.allocationInPercentage as number) * 100).toFixed(2)}%`;
const name = raw._data.name;
const sign =
raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : '';
const symbol = raw._data.symbol;
const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`;
if (raw._data.valueInBaseCurrency !== null) {
const value = raw._data.valueInBaseCurrency as number;
return [
`${name ?? symbol} (${allocationInPercentage})`,
`${value.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${this.baseCurrency}`,
'',
$localize`Change` + ' (' + $localize`Performance` + ')',
`${sign}${raw._data.netPerformanceWithCurrencyEffect.toLocaleString(
this.locale,
{
maximumFractionDigits: 2,
minimumFractionDigits: 2
}
)} ${this.baseCurrency} (${netPerformanceInPercentageWithSign})`
];
} else {
return [
`${name ?? symbol} (${allocationInPercentage})`,
'',
$localize`Performance`,
netPerformanceInPercentageWithSign
];
}
},
title: () => {
return '';
}
},
xAlign: 'center',
yAlign: 'center'
};
}
}