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

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

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

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

@ -1,8 +1,11 @@
import type { ElementRef } from '@angular/core';
import type {
Chart,
ChartTypeRegistry,
ChartType,
ControllerDatasetOptions,
Plugin,
Point,
TooltipOptions,
TooltipPosition
} from 'chart.js';
import { format } from 'date-fns';
@ -21,7 +24,7 @@ export function formatGroupedDate({
date,
groupBy
}: {
date: Date;
date: number;
groupBy: GroupBy;
}) {
if (groupBy === 'month') {
@ -33,7 +36,7 @@ export function formatGroupedDate({
return format(date, DATE_FORMAT);
}
export function getTooltipOptions({
export function getTooltipOptions<T extends ChartType>({
colorScheme,
currency = '',
groupBy,
@ -45,35 +48,43 @@ export function getTooltipOptions({
groupBy?: GroupBy;
locale?: string;
unit?: string;
}) {
}): Partial<TooltipOptions<T>> {
return {
backgroundColor: getBackgroundColor(colorScheme),
bodyColor: `rgb(${getTextColor(colorScheme)})`,
borderWidth: 1,
borderColor: `rgba(${getTextColor(colorScheme)}, 0.1)`,
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: {
label: (context) => {
let label = context.dataset.label ?? '';
let label = (context.dataset as ControllerDatasetOptions).label ?? '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
const yPoint = (context.parsed as Point).y;
if (yPoint !== null) {
if (currency) {
label += `${context.parsed.y.toLocaleString(locale, {
label += `${yPoint.toLocaleString(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${currency}`;
} else if (unit) {
label += `${context.parsed.y.toFixed(2)} ${unit}`;
label += `${yPoint.toFixed(2)} ${unit}`;
} else {
label += context.parsed.y.toFixed(2);
label += yPoint.toFixed(2);
}
}
return label;
},
title: (contexts) => {
if (groupBy) {
return formatGroupedDate({ groupBy, date: contexts[0].parsed.x });
const xPoint = (contexts[0].parsed as Point).x;
if (groupBy && xPoint !== null) {
return formatGroupedDate({ groupBy, date: xPoint });
}
return contexts[0].label;
@ -98,16 +109,17 @@ export function getTooltipPositionerMapTop(
if (!position || !chart?.chartArea) {
return false;
}
return {
x: position.x,
y: chart.chartArea.top
};
}
export function getVerticalHoverLinePlugin<T extends keyof ChartTypeRegistry>(
chartCanvas: ElementRef,
export function getVerticalHoverLinePlugin<T extends 'line' | 'bar'>(
chartCanvas: ElementRef<HTMLCanvasElement>,
colorScheme: ColorScheme
): Plugin<T> {
): Plugin<T, { color: string; width: number }> {
return {
afterDatasetsDraw: (chart, _, options) => {
const active = chart.getActiveElements();
@ -125,13 +137,16 @@ export function getVerticalHoverLinePlugin<T extends keyof ChartTypeRegistry>(
const xValue = active[0].element.x;
const context = chartCanvas.nativeElement.getContext('2d');
context.lineWidth = width;
context.strokeStyle = color;
context.beginPath();
context.moveTo(xValue, top);
context.lineTo(xValue, bottom);
context.stroke();
if (context) {
context.lineWidth = width;
context.strokeStyle = color;
context.beginPath();
context.moveTo(xValue, top);
context.lineTo(xValue, bottom);
context.stroke();
}
},
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,
CategoryScale,
Chart,
type ChartData,
type ChartDataset,
LinearScale,
Tooltip
} from 'chart.js';
@ -270,7 +272,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
this.chart = new Chart<'bar'>(this.chartCanvas.nativeElement, {
data: chartData,
options: {
plugins: {
@ -280,7 +282,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
callbacks: {
footer: (items) => {
const totalAmount = items.reduce(
(a, b) => a + b.parsed.y,
(a, b) => a + (b.parsed.y ?? 0),
0
);
@ -302,8 +304,6 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
if (context.parsed.y !== null) {
label += new Intl.NumberFormat(this.locale, {
currency: this.currency,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Only supported from ES2020 or later
currencyDisplay: 'code',
style: 'currency'
}).format(context.parsed.y);
@ -345,9 +345,9 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.isLoading = false;
}
private getChartData() {
private getChartData(): ChartData<'bar'> {
const currentYear = new Date().getFullYear();
const labels = [];
const labels: number[] = [];
// Principal investment amount
const P: number = this.getP();
@ -371,13 +371,13 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
labels.push(year);
}
const datasetDeposit = {
const datasetDeposit: ChartDataset<'bar'> = {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
data: [],
label: $localize`Deposit`
};
const datasetInterest = {
const datasetInterest: ChartDataset<'bar'> = {
backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
)
@ -387,7 +387,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
label: $localize`Interest`
};
const datasetSavings = {
const datasetSavings: ChartDataset<'bar'> = {
backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
)

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

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

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

@ -22,11 +22,16 @@ import {
} from '@angular/core';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { ChartConfiguration, Tooltip } from 'chart.js';
import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js';
import { DoughnutController } from 'chart.js';
import { Chart } from 'chart.js';
import {
ArcElement,
Chart,
type ChartData,
type ChartDataset,
DoughnutController,
LinearScale,
Tooltip,
type TooltipOptions
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { isUUID } from 'class-validator';
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]) => {
return item.color;
@ -324,7 +329,7 @@ export class GfPortfolioProportionChartComponent
datasets[1].data[1] = Number.MAX_SAFE_INTEGER;
}
const data: ChartConfiguration<'doughnut'>['data'] = {
const data: ChartData<'doughnut'> = {
datasets,
labels
};
@ -332,9 +337,10 @@ export class GfPortfolioProportionChartComponent
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins.tooltip = this.getTooltipPluginConfiguration(
data
) as unknown;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration(data);
this.chart.update();
} else {
this.chart = new Chart<'doughnut'>(this.chartCanvas.nativeElement, {
@ -345,21 +351,22 @@ export class GfPortfolioProportionChartComponent
layout: {
padding: this.showLabels === true ? 100 : 0
},
onClick: (event, activeElements) => {
onClick: (_, activeElements, chart) => {
try {
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 {}
},
onHover: (event, chartElement) => {
if (this.cursor) {
event.native.target.style.cursor = chartElement[0]
? this.cursor
: 'default';
(event.native?.target as HTMLElement).style.cursor =
chartElement[0] ? this.cursor : 'default';
}
},
plugins: {
@ -392,7 +399,7 @@ export class GfPortfolioProportionChartComponent
legend: { display: false },
tooltip: this.getTooltipPluginConfiguration(data)
}
} as unknown,
},
plugins: [ChartDataLabels],
type: 'doughnut'
});
@ -419,19 +426,24 @@ export class GfPortfolioProportionChartComponent
];
}
private getTooltipPluginConfiguration(data: ChartConfiguration['data']) {
private getTooltipPluginConfiguration(
data: ChartData<'doughnut'>
): Partial<TooltipOptions<'doughnut'>> {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
currency: this.baseCurrency,
locale: this.locale
}),
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: {
label: (context) => {
const labelIndex =
(data.datasets[context.datasetIndex - 1]?.data?.length ?? 0) +
context.dataIndex;
let symbol = context.chart.data.labels?.[labelIndex] ?? '';
let symbol =
(context.chart.data.labels?.[labelIndex] as string) ?? '';
if (symbol === this.OTHER_KEY) {
symbol = $localize`Other`;
@ -439,9 +451,10 @@ export class GfPortfolioProportionChartComponent
symbol = $localize`No data available`;
}
const name = translate(this.data[symbol as string]?.name);
const name = translate(this.data[symbol]?.name);
let sum = 0;
for (const item of context.dataset.data) {
sum += item;
}
@ -454,6 +467,7 @@ export class GfPortfolioProportionChartComponent
return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`];
} else {
const value = context.raw as number;
return [
`${name ?? symbol}`,
`${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 {
annualizedNetPerformancePercent: number;
negativeNetPerformancePercentsRange: { 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';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { ChartConfiguration } from 'chart.js';
import type { TooltipOptions, ChartData } from 'chart.js';
import { LinearScale } from 'chart.js';
import { Chart, Tooltip } from 'chart.js';
import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
@ -35,7 +35,11 @@ import { orderBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import OpenColor from 'open-color';
import { GetColorParams } from './interfaces/interfaces';
import type {
GetColorParams,
GfTreemapScriptableContext,
GfTreemapTooltipItem
} from './interfaces/interfaces';
const { gray, green, red } = OpenColor;
@ -198,10 +202,10 @@ export class GfTreemapChartComponent
min: Math.min(...negativeNetPerformancePercents)
};
const data: ChartConfiguration<'treemap'>['data'] = {
const data: ChartData<'treemap'> = {
datasets: [
{
backgroundColor: (context) => {
backgroundColor: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
@ -232,7 +236,7 @@ export class GfTreemapChartComponent
key: 'allocationInPercentage',
labels: {
align: 'left',
color: (context) => {
color: (context: GfTreemapScriptableContext) => {
let annualizedNetPerformancePercent =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
@ -261,7 +265,7 @@ export class GfTreemapChartComponent
},
display: true,
font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }],
formatter: ({ raw }) => {
formatter: ({ raw }: GfTreemapScriptableContext) => {
// Round to 4 decimal places
let netPerformancePercentWithCurrencyEffect =
Math.round(
@ -286,32 +290,35 @@ export class GfTreemapChartComponent
position: 'top'
},
spacing: 1,
// @ts-expect-error: should be PortfolioPosition[]
tree: this.holdings
}
]
} as any;
};
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown;
this.getTooltipPluginConfiguration();
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
this.chart = new Chart<'treemap'>(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
onClick: (event, activeElements) => {
onClick: (_, activeElements, chart: Chart<'treemap'>) => {
try {
const dataIndex = activeElements[0].index;
const datasetIndex = activeElements[0].datasetIndex;
const dataset = orderBy(
event.chart.data.datasets[datasetIndex].tree,
chart.data.datasets[datasetIndex].tree,
['allocationInPercentage'],
['desc']
);
) as PortfolioPosition[];
const dataSource: DataSource = dataset[dataIndex].dataSource;
const symbol: string = dataset[dataIndex].symbol;
@ -321,15 +328,14 @@ export class GfTreemapChartComponent
},
onHover: (event, chartElement) => {
if (this.cursor) {
event.native.target.style.cursor = chartElement[0]
? this.cursor
: 'default';
(event.native?.target as HTMLElement).style.cursor =
chartElement[0] ? this.cursor : 'default';
}
},
plugins: {
tooltip: this.getTooltipPluginConfiguration()
}
} as unknown,
},
type: 'treemap'
});
}
@ -338,16 +344,17 @@ export class GfTreemapChartComponent
this.isLoading = false;
}
private getTooltipPluginConfiguration() {
private getTooltipPluginConfiguration(): Partial<TooltipOptions<'treemap'>> {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
currency: this.baseCurrency,
locale: this.locale
}),
// @ts-expect-error: no need to set all attributes in callbacks
callbacks: {
label: ({ raw }) => {
const allocationInPercentage = `${((raw._data.allocationInPercentage as number) * 100).toFixed(2)}%`;
label: ({ raw }: GfTreemapTooltipItem) => {
const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`;
const name = raw._data.name;
const sign =
raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : '';
@ -356,11 +363,11 @@ export class GfTreemapChartComponent
const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`;
if (raw._data.valueInBaseCurrency !== null) {
const value = raw._data.valueInBaseCurrency as number;
const value = raw._data.valueInBaseCurrency;
return [
`${name ?? symbol} (${allocationInPercentage})`,
`${value.toLocaleString(this.locale, {
`${value?.toLocaleString(this.locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${this.baseCurrency}`,

Loading…
Cancel
Save