Browse Source

Task/improve chart type safety (#6277)

* Improve chart type safety
pull/6280/head
Kenrick Tandrian 4 days ago
committed by GitHub
parent
commit
af034e87c9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  2. 50
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  3. 55
      libs/common/src/lib/chart-helper.ts
  4. 29
      libs/ui/src/lib/chart/chart.registry.ts
  5. 1
      libs/ui/src/lib/chart/index.ts
  6. 18
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  7. 58
      libs/ui/src/lib/line-chart/line-chart.component.ts
  8. 56
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  9. 16
      libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts
  10. 49
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

20
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -1,6 +1,5 @@
import { import {
getTooltipOptions, getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
@ -15,12 +14,14 @@ import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
type ElementRef,
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
@ -42,7 +43,7 @@ import {
PointElement, PointElement,
TimeScale, TimeScale,
Tooltip, Tooltip,
TooltipPosition type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
@ -78,7 +79,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Output() benchmarkChanged = new EventEmitter<string>(); @Output() benchmarkChanged = new EventEmitter<string>();
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'line'>; public chart: Chart<'line'>;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
@ -96,8 +97,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) => registerChartConfiguration();
getTooltipPositionerMapTop(this.chart, position);
addIcons({ arrowForwardOutline }); addIcons({ arrowForwardOutline });
} }
@ -157,8 +157,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -196,7 +198,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
} as unknown, },
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
@ -253,7 +255,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
@ -261,7 +263,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
unit: '%' unit: '%'
}), }),
mode: 'index', mode: 'index',
position: 'top' as unknown, position: 'top',
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };

50
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -1,6 +1,5 @@
import { import {
getTooltipOptions, getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin, getVerticalHoverLinePlugin,
transformTickToAbbreviation transformTickToAbbreviation
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
@ -15,11 +14,13 @@ import {
import { LineChartItem } from '@ghostfolio/common/interfaces'; import { LineChartItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { ColorScheme, GroupBy } from '@ghostfolio/common/types'; import { ColorScheme, GroupBy } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
type ElementRef,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
@ -34,12 +35,15 @@ import {
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
type ScriptableLineSegmentContext,
TimeScale, TimeScale,
Tooltip, Tooltip,
TooltipPosition type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin, {
type AnnotationOptions
} from 'chartjs-plugin-annotation';
import { isAfter } from 'date-fns'; import { isAfter } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -62,7 +66,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'bar' | 'line'>; public chart: Chart<'bar' | 'line'>;
private investments: InvestmentItem[]; private investments: InvestmentItem[];
@ -81,8 +85,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) => registerChartConfiguration();
getTooltipPositionerMapTop(this.chart, position);
} }
public ngOnChanges() { public ngOnChanges() {
@ -121,12 +124,12 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}), }),
label: this.benchmarkDataLabel, label: this.benchmarkDataLabel,
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context) =>
this.isInFuture( this.isInFuture(
context, context,
`rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)` `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)`
), ),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2]) borderDash: (context) => this.isInFuture(context, [2, 2])
}, },
stepped: true stepped: true
}, },
@ -143,12 +146,12 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
label: $localize`Total Amount`, label: $localize`Total Amount`,
pointRadius: 0, pointRadius: 0,
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context) =>
this.isInFuture( this.isInFuture(
context, context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)` `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
), ),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2]) borderDash: (context) => this.isInFuture(context, [2, 2])
} }
} }
] ]
@ -157,17 +160,14 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = chartData; this.chart.data = chartData;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
if ( const annotations = this.chart.options.plugins.annotation
this.savingsRate && .annotations as Record<string, AnnotationOptions<'line'>>;
// @ts-ignore if (this.savingsRate && annotations.savingsRate) {
this.chart.options.plugins.annotation.annotations.savingsRate annotations.savingsRate.value = this.savingsRate;
) {
// @ts-ignore
this.chart.options.plugins.annotation.annotations.savingsRate.value =
this.savingsRate;
} }
this.chart.update(); this.chart.update();
@ -201,7 +201,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
color: 'white', color: 'white',
content: $localize`Savings Rate`, content: $localize`Savings Rate`,
display: true, display: true,
font: { size: '10px', weight: 'normal' }, font: { size: 10, weight: 'normal' },
padding: { padding: {
x: 4, x: 4,
y: 2 y: 2
@ -229,7 +229,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
} as unknown, },
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
@ -286,7 +286,9 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
} }
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<
TooltipOptions<'bar' | 'line'>
> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
@ -296,13 +298,13 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
unit: this.isInPercent ? '%' : undefined unit: this.isInPercent ? '%' : undefined
}), }),
mode: 'index', mode: 'index',
position: 'top' as unknown, position: 'top',
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };
} }
private isInFuture<T>(aContext: any, aValue: T) { private isInFuture<T>(aContext: ScriptableLineSegmentContext, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue ? aValue
: undefined; : undefined;

55
libs/common/src/lib/chart-helper.ts

@ -1,8 +1,11 @@
import type { ElementRef } from '@angular/core'; import type { ElementRef } from '@angular/core';
import type { import type {
Chart, Chart,
ChartTypeRegistry, ChartType,
ControllerDatasetOptions,
Plugin, Plugin,
Point,
TooltipOptions,
TooltipPosition TooltipPosition
} from 'chart.js'; } from 'chart.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -21,7 +24,7 @@ export function formatGroupedDate({
date, date,
groupBy groupBy
}: { }: {
date: Date; date: number;
groupBy: GroupBy; groupBy: GroupBy;
}) { }) {
if (groupBy === 'month') { if (groupBy === 'month') {
@ -33,7 +36,7 @@ export function formatGroupedDate({
return format(date, DATE_FORMAT); return format(date, DATE_FORMAT);
} }
export function getTooltipOptions({ export function getTooltipOptions<T extends ChartType>({
colorScheme, colorScheme,
currency = '', currency = '',
groupBy, groupBy,
@ -45,35 +48,43 @@ export function getTooltipOptions({
groupBy?: GroupBy; groupBy?: GroupBy;
locale?: string; locale?: string;
unit?: string; unit?: string;
}) { }): Partial<TooltipOptions<T>> {
return { return {
backgroundColor: getBackgroundColor(colorScheme), backgroundColor: getBackgroundColor(colorScheme),
bodyColor: `rgb(${getTextColor(colorScheme)})`, bodyColor: `rgb(${getTextColor(colorScheme)})`,
borderWidth: 1, borderWidth: 1,
borderColor: `rgba(${getTextColor(colorScheme)}, 0.1)`, borderColor: `rgba(${getTextColor(colorScheme)}, 0.1)`,
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: (context) => { label: (context) => {
let label = context.dataset.label ?? ''; let label = (context.dataset as ControllerDatasetOptions).label ?? '';
if (label) { if (label) {
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) {
const yPoint = (context.parsed as Point).y;
if (yPoint !== null) {
if (currency) { if (currency) {
label += `${context.parsed.y.toLocaleString(locale, { label += `${yPoint.toLocaleString(locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2
})} ${currency}`; })} ${currency}`;
} else if (unit) { } else if (unit) {
label += `${context.parsed.y.toFixed(2)} ${unit}`; label += `${yPoint.toFixed(2)} ${unit}`;
} else { } else {
label += context.parsed.y.toFixed(2); label += yPoint.toFixed(2);
} }
} }
return label; return label;
}, },
title: (contexts) => { title: (contexts) => {
if (groupBy) { const xPoint = (contexts[0].parsed as Point).x;
return formatGroupedDate({ groupBy, date: contexts[0].parsed.x });
if (groupBy && xPoint !== null) {
return formatGroupedDate({ groupBy, date: xPoint });
} }
return contexts[0].label; return contexts[0].label;
@ -98,16 +109,17 @@ export function getTooltipPositionerMapTop(
if (!position || !chart?.chartArea) { if (!position || !chart?.chartArea) {
return false; return false;
} }
return { return {
x: position.x, x: position.x,
y: chart.chartArea.top y: chart.chartArea.top
}; };
} }
export function getVerticalHoverLinePlugin<T extends keyof ChartTypeRegistry>( export function getVerticalHoverLinePlugin<T extends 'line' | 'bar'>(
chartCanvas: ElementRef, chartCanvas: ElementRef<HTMLCanvasElement>,
colorScheme: ColorScheme colorScheme: ColorScheme
): Plugin<T> { ): Plugin<T, { color: string; width: number }> {
return { return {
afterDatasetsDraw: (chart, _, options) => { afterDatasetsDraw: (chart, _, options) => {
const active = chart.getActiveElements(); const active = chart.getActiveElements();
@ -125,13 +137,16 @@ export function getVerticalHoverLinePlugin<T extends keyof ChartTypeRegistry>(
const xValue = active[0].element.x; const xValue = active[0].element.x;
const context = chartCanvas.nativeElement.getContext('2d'); const context = chartCanvas.nativeElement.getContext('2d');
context.lineWidth = width;
context.strokeStyle = color;
context.beginPath(); if (context) {
context.moveTo(xValue, top); context.lineWidth = width;
context.lineTo(xValue, bottom); context.strokeStyle = color;
context.stroke();
context.beginPath();
context.moveTo(xValue, top);
context.lineTo(xValue, bottom);
context.stroke();
}
}, },
id: 'verticalHoverLine' id: 'verticalHoverLine'
}; };

29
libs/ui/src/lib/chart/chart.registry.ts

@ -0,0 +1,29 @@
import { getTooltipPositionerMapTop } from '@ghostfolio/common/chart-helper';
import { Tooltip, TooltipPositionerFunction, ChartType } from 'chart.js';
interface VerticalHoverLinePluginOptions {
color?: string;
width?: number;
}
declare module 'chart.js' {
interface PluginOptionsByType<TType extends ChartType> {
verticalHoverLine: TType extends 'line' | 'bar'
? VerticalHoverLinePluginOptions
: never;
}
interface TooltipPositionerMap {
top: TooltipPositionerFunction<ChartType>;
}
}
export function registerChartConfiguration() {
if (Tooltip.positioners['top']) {
return;
}
Tooltip.positioners.top = function (_elements, eventPosition) {
return getTooltipPositionerMapTop(this.chart, eventPosition);
};
}

1
libs/ui/src/lib/chart/index.ts

@ -0,0 +1 @@
export * from './chart.registry';

18
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

@ -38,6 +38,8 @@ import {
BarElement, BarElement,
CategoryScale, CategoryScale,
Chart, Chart,
type ChartData,
type ChartDataset,
LinearScale, LinearScale,
Tooltip Tooltip
} from 'chart.js'; } from 'chart.js';
@ -270,7 +272,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart<'bar'>(this.chartCanvas.nativeElement, {
data: chartData, data: chartData,
options: { options: {
plugins: { plugins: {
@ -280,7 +282,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
callbacks: { callbacks: {
footer: (items) => { footer: (items) => {
const totalAmount = items.reduce( const totalAmount = items.reduce(
(a, b) => a + b.parsed.y, (a, b) => a + (b.parsed.y ?? 0),
0 0
); );
@ -302,8 +304,6 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += new Intl.NumberFormat(this.locale, { label += new Intl.NumberFormat(this.locale, {
currency: this.currency, currency: this.currency,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Only supported from ES2020 or later
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency'
}).format(context.parsed.y); }).format(context.parsed.y);
@ -345,9 +345,9 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.isLoading = false; this.isLoading = false;
} }
private getChartData() { private getChartData(): ChartData<'bar'> {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const labels = []; const labels: number[] = [];
// Principal investment amount // Principal investment amount
const P: number = this.getP(); const P: number = this.getP();
@ -371,13 +371,13 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
labels.push(year); labels.push(year);
} }
const datasetDeposit = { const datasetDeposit: ChartDataset<'bar'> = {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
data: [], data: [],
label: $localize`Deposit` label: $localize`Deposit`
}; };
const datasetInterest = { const datasetInterest: ChartDataset<'bar'> = {
backgroundColor: Color( backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
) )
@ -387,7 +387,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
label: $localize`Interest` label: $localize`Interest`
}; };
const datasetSavings = { const datasetSavings: ChartDataset<'bar'> = {
backgroundColor: Color( backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
) )

58
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -1,6 +1,5 @@
import { import {
getTooltipOptions, getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
@ -19,12 +18,14 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
type ElementRef,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { import {
type AnimationsSpec,
Chart, Chart,
Filler, Filler,
LinearScale, LinearScale,
@ -33,11 +34,13 @@ import {
PointElement, PointElement,
TimeScale, TimeScale,
Tooltip, Tooltip,
TooltipPosition type TooltipOptions
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { registerChartConfiguration } from '../chart';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [CommonModule, NgxSkeletonLoaderModule],
@ -67,7 +70,7 @@ export class GfLineChartComponent
@Input() yMin: number; @Input() yMin: number;
@Input() yMinLabel: string; @Input() yMinLabel: string;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'line'>; public chart: Chart<'line'>;
public isLoading = true; public isLoading = true;
@ -85,8 +88,7 @@ export class GfLineChartComponent
Tooltip Tooltip
); );
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) => registerChartConfiguration();
getTooltipPositionerMapTop(this.chart, position);
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -117,9 +119,9 @@ export class GfLineChartComponent
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const benchmarkPrices = []; const benchmarkPrices: number[] = [];
const labels: string[] = []; const labels: string[] = [];
const marketPrices = []; const marketPrices: number[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => { this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value); benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value);
@ -129,11 +131,14 @@ export class GfLineChartComponent
const gradient = this.chartCanvas?.nativeElement const gradient = this.chartCanvas?.nativeElement
?.getContext('2d') ?.getContext('2d')
.createLinearGradient( ?.createLinearGradient(
0, 0,
0, 0,
0, 0,
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5 ((this.chartCanvas.nativeElement.parentNode as HTMLElement)
.offsetHeight *
4) /
5
); );
if (gradient && this.showGradient) { if (gradient && this.showGradient) {
@ -169,27 +174,26 @@ export class GfLineChartComponent
}; };
if (this.chartCanvas) { if (this.chartCanvas) {
const animations = {
x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }),
y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' })
};
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
this.chart.options.animation = this.chart.options.animations = this.isAnimated
this.isAnimated && ? animations
({ : undefined;
x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }),
y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' })
} as unknown);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
animation: animations: this.isAnimated ? animations : undefined,
this.isAnimated &&
({
x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }),
y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' })
} as unknown),
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
elements: { elements: {
point: { point: {
@ -208,7 +212,7 @@ export class GfLineChartComponent
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
} }
} as unknown, },
scales: { scales: {
x: { x: {
border: { border: {
@ -298,7 +302,7 @@ export class GfLineChartComponent
}: { }: {
axis: 'x' | 'y'; axis: 'x' | 'y';
labels: string[]; labels: string[];
}) { }): Partial<AnimationsSpec<'line'>[string]> {
const delayBetweenPoints = this.ANIMATION_DURATION / labels.length; const delayBetweenPoints = this.ANIMATION_DURATION / labels.length;
return { return {
@ -308,7 +312,7 @@ export class GfLineChartComponent
} }
context[`${axis}Started`] = true; context[`${axis}Started`] = true;
return context.index * delayBetweenPoints; return context.dataIndex * delayBetweenPoints;
}, },
duration: delayBetweenPoints, duration: delayBetweenPoints,
easing: 'linear', easing: 'linear',
@ -317,7 +321,7 @@ export class GfLineChartComponent
}; };
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
@ -326,7 +330,7 @@ export class GfLineChartComponent
unit: this.unit unit: this.unit
}), }),
mode: 'index', mode: 'index',
position: 'top' as unknown, position: 'top',
xAlign: 'center', xAlign: 'center',
yAlign: 'bottom' yAlign: 'bottom'
}; };

56
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -22,11 +22,16 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { ChartConfiguration, Tooltip } from 'chart.js'; import {
import { LinearScale } from 'chart.js'; ArcElement,
import { ArcElement } from 'chart.js'; Chart,
import { DoughnutController } from 'chart.js'; type ChartData,
import { Chart } from 'chart.js'; type ChartDataset,
DoughnutController,
LinearScale,
Tooltip,
type TooltipOptions
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels'; import ChartDataLabels from 'chartjs-plugin-datalabels';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import Color from 'color'; import Color from 'color';
@ -286,7 +291,7 @@ export class GfPortfolioProportionChartComponent
}); });
}); });
const datasets: ChartConfiguration<'doughnut'>['data']['datasets'] = [ const datasets: ChartDataset<'doughnut'>[] = [
{ {
backgroundColor: chartDataSorted.map(([, item]) => { backgroundColor: chartDataSorted.map(([, item]) => {
return item.color; return item.color;
@ -324,7 +329,7 @@ export class GfPortfolioProportionChartComponent
datasets[1].data[1] = Number.MAX_SAFE_INTEGER; datasets[1].data[1] = Number.MAX_SAFE_INTEGER;
} }
const data: ChartConfiguration<'doughnut'>['data'] = { const data: ChartData<'doughnut'> = {
datasets, datasets,
labels labels
}; };
@ -332,9 +337,10 @@ export class GfPortfolioProportionChartComponent
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins.tooltip = this.getTooltipPluginConfiguration( this.chart.options.plugins ??= {};
data this.chart.options.plugins.tooltip =
) as unknown; this.getTooltipPluginConfiguration(data);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart<'doughnut'>(this.chartCanvas.nativeElement, { this.chart = new Chart<'doughnut'>(this.chartCanvas.nativeElement, {
@ -345,21 +351,22 @@ export class GfPortfolioProportionChartComponent
layout: { layout: {
padding: this.showLabels === true ? 100 : 0 padding: this.showLabels === true ? 100 : 0
}, },
onClick: (event, activeElements) => { onClick: (_, activeElements, chart) => {
try { try {
const dataIndex = activeElements[0].index; const dataIndex = activeElements[0].index;
const symbol: string = event.chart.data.labels[dataIndex]; const symbol = chart.data.labels?.[dataIndex] as string;
const dataSource = this.data[symbol]?.dataSource; const dataSource = this.data[symbol].dataSource;
this.proportionChartClicked.emit({ dataSource, symbol }); if (dataSource) {
this.proportionChartClicked.emit({ dataSource, symbol });
}
} catch {} } catch {}
}, },
onHover: (event, chartElement) => { onHover: (event, chartElement) => {
if (this.cursor) { if (this.cursor) {
event.native.target.style.cursor = chartElement[0] (event.native?.target as HTMLElement).style.cursor =
? this.cursor chartElement[0] ? this.cursor : 'default';
: 'default';
} }
}, },
plugins: { plugins: {
@ -392,7 +399,7 @@ export class GfPortfolioProportionChartComponent
legend: { display: false }, legend: { display: false },
tooltip: this.getTooltipPluginConfiguration(data) tooltip: this.getTooltipPluginConfiguration(data)
} }
} as unknown, },
plugins: [ChartDataLabels], plugins: [ChartDataLabels],
type: 'doughnut' type: 'doughnut'
}); });
@ -419,19 +426,24 @@ export class GfPortfolioProportionChartComponent
]; ];
} }
private getTooltipPluginConfiguration(data: ChartConfiguration['data']) { private getTooltipPluginConfiguration(
data: ChartData<'doughnut'>
): Partial<TooltipOptions<'doughnut'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
currency: this.baseCurrency, currency: this.baseCurrency,
locale: this.locale locale: this.locale
}), }),
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const labelIndex = const labelIndex =
(data.datasets[context.datasetIndex - 1]?.data?.length ?? 0) + (data.datasets[context.datasetIndex - 1]?.data?.length ?? 0) +
context.dataIndex; context.dataIndex;
let symbol = context.chart.data.labels?.[labelIndex] ?? '';
let symbol =
(context.chart.data.labels?.[labelIndex] as string) ?? '';
if (symbol === this.OTHER_KEY) { if (symbol === this.OTHER_KEY) {
symbol = $localize`Other`; symbol = $localize`Other`;
@ -439,9 +451,10 @@ export class GfPortfolioProportionChartComponent
symbol = $localize`No data available`; symbol = $localize`No data available`;
} }
const name = translate(this.data[symbol as string]?.name); const name = translate(this.data[symbol]?.name);
let sum = 0; let sum = 0;
for (const item of context.dataset.data) { for (const item of context.dataset.data) {
sum += item; sum += item;
} }
@ -454,6 +467,7 @@ export class GfPortfolioProportionChartComponent
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`]; return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
} else { } else {
const value = context.raw as number; const value = context.raw as number;
return [ return [
`${name ?? symbol}`, `${name ?? symbol}`,
`${value.toLocaleString(this.locale, { `${value.toLocaleString(this.locale, {

16
libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts

@ -1,5 +1,21 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ScriptableContext, TooltipItem } from 'chart.js';
import { TreemapDataPoint } from 'chartjs-chart-treemap';
export interface GetColorParams { export interface GetColorParams {
annualizedNetPerformancePercent: number; annualizedNetPerformancePercent: number;
negativeNetPerformancePercentsRange: { max: number; min: number }; negativeNetPerformancePercentsRange: { max: number; min: number };
positiveNetPerformancePercentsRange: { max: number; min: number }; positiveNetPerformancePercentsRange: { max: number; min: number };
} }
interface GfTreemapDataPoint extends TreemapDataPoint {
_data: PortfolioPosition;
}
export interface GfTreemapScriptableContext extends ScriptableContext<'treemap'> {
raw: GfTreemapDataPoint;
}
export interface GfTreemapTooltipItem extends TooltipItem<'treemap'> {
raw: GfTreemapDataPoint;
}

49
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -25,7 +25,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { ChartConfiguration } from 'chart.js'; import type { TooltipOptions, ChartData } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
import { Chart, Tooltip } from 'chart.js'; import { Chart, Tooltip } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
@ -35,7 +35,11 @@ import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import OpenColor from 'open-color'; import OpenColor from 'open-color';
import { GetColorParams } from './interfaces/interfaces'; import type {
GetColorParams,
GfTreemapScriptableContext,
GfTreemapTooltipItem
} from './interfaces/interfaces';
const { gray, green, red } = OpenColor; const { gray, green, red } = OpenColor;
@ -198,10 +202,10 @@ export class GfTreemapChartComponent
min: Math.min(...negativeNetPerformancePercents) min: Math.min(...negativeNetPerformancePercents)
}; };
const data: ChartConfiguration<'treemap'>['data'] = { const data: ChartData<'treemap'> = {
datasets: [ datasets: [
{ {
backgroundColor: (context) => { backgroundColor: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent = let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
@ -232,7 +236,7 @@ export class GfTreemapChartComponent
key: 'allocationInPercentage', key: 'allocationInPercentage',
labels: { labels: {
align: 'left', align: 'left',
color: (context) => { color: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent = let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({ getAnnualizedPerformancePercent({
daysInMarket: differenceInDays( daysInMarket: differenceInDays(
@ -261,7 +265,7 @@ export class GfTreemapChartComponent
}, },
display: true, display: true,
font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }], font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }],
formatter: ({ raw }) => { formatter: ({ raw }: GfTreemapScriptableContext) => {
// Round to 4 decimal places // Round to 4 decimal places
let netPerformancePercentWithCurrencyEffect = let netPerformancePercentWithCurrencyEffect =
Math.round( Math.round(
@ -286,32 +290,35 @@ export class GfTreemapChartComponent
position: 'top' position: 'top'
}, },
spacing: 1, spacing: 1,
// @ts-expect-error: should be PortfolioPosition[]
tree: this.holdings tree: this.holdings
} }
] ]
} as any; };
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown; this.getTooltipPluginConfiguration();
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart<'treemap'>(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
animation: false, animation: false,
onClick: (event, activeElements) => { onClick: (_, activeElements, chart: Chart<'treemap'>) => {
try { try {
const dataIndex = activeElements[0].index; const dataIndex = activeElements[0].index;
const datasetIndex = activeElements[0].datasetIndex; const datasetIndex = activeElements[0].datasetIndex;
const dataset = orderBy( const dataset = orderBy(
event.chart.data.datasets[datasetIndex].tree, chart.data.datasets[datasetIndex].tree,
['allocationInPercentage'], ['allocationInPercentage'],
['desc'] ['desc']
); ) as PortfolioPosition[];
const dataSource: DataSource = dataset[dataIndex].dataSource; const dataSource: DataSource = dataset[dataIndex].dataSource;
const symbol: string = dataset[dataIndex].symbol; const symbol: string = dataset[dataIndex].symbol;
@ -321,15 +328,14 @@ export class GfTreemapChartComponent
}, },
onHover: (event, chartElement) => { onHover: (event, chartElement) => {
if (this.cursor) { if (this.cursor) {
event.native.target.style.cursor = chartElement[0] (event.native?.target as HTMLElement).style.cursor =
? this.cursor chartElement[0] ? this.cursor : 'default';
: 'default';
} }
}, },
plugins: { plugins: {
tooltip: this.getTooltipPluginConfiguration() tooltip: this.getTooltipPluginConfiguration()
} }
} as unknown, },
type: 'treemap' type: 'treemap'
}); });
} }
@ -338,16 +344,17 @@ export class GfTreemapChartComponent
this.isLoading = false; this.isLoading = false;
} }
private getTooltipPluginConfiguration() { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'treemap'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
currency: this.baseCurrency, currency: this.baseCurrency,
locale: this.locale locale: this.locale
}), }),
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: { callbacks: {
label: ({ raw }) => { label: ({ raw }: GfTreemapTooltipItem) => {
const allocationInPercentage = `${((raw._data.allocationInPercentage as number) * 100).toFixed(2)}%`; const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`;
const name = raw._data.name; const name = raw._data.name;
const sign = const sign =
raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : '';
@ -356,11 +363,11 @@ export class GfTreemapChartComponent
const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`;
if (raw._data.valueInBaseCurrency !== null) { if (raw._data.valueInBaseCurrency !== null) {
const value = raw._data.valueInBaseCurrency as number; const value = raw._data.valueInBaseCurrency;
return [ return [
`${name ?? symbol} (${allocationInPercentage})`, `${name ?? symbol} (${allocationInPercentage})`,
`${value.toLocaleString(this.locale, { `${value?.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2
})} ${this.baseCurrency}`, })} ${this.baseCurrency}`,

Loading…
Cancel
Save