From af034e87c991bf9bfa9701e128a6a3718975481b Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:25:20 +0700 Subject: [PATCH] Task/improve chart type safety (#6277) * Improve chart type safety --- .../benchmark-comparator.component.ts | 20 ++++--- .../investment-chart.component.ts | 50 ++++++++-------- libs/common/src/lib/chart-helper.ts | 55 +++++++++++------- libs/ui/src/lib/chart/chart.registry.ts | 29 ++++++++++ libs/ui/src/lib/chart/index.ts | 1 + .../fire-calculator.component.ts | 18 +++--- .../lib/line-chart/line-chart.component.ts | 58 ++++++++++--------- .../portfolio-proportion-chart.component.ts | 56 +++++++++++------- .../treemap-chart/interfaces/interfaces.ts | 16 +++++ .../treemap-chart/treemap-chart.component.ts | 49 +++++++++------- 10 files changed, 221 insertions(+), 131 deletions(-) create mode 100644 libs/ui/src/lib/chart/chart.registry.ts create mode 100644 libs/ui/src/lib/chart/index.ts diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index 7f03ea57f..2ecefc311 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/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(); - @ViewChild('chartCanvas') chartCanvas; + @ViewChild('chartCanvas') chartCanvas: ElementRef; 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> { 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' }; diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 5492ddd4c..53d4f5693 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/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; 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>; + 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(aContext: any, aValue: T) { + private isInFuture(aContext: ScriptableLineSegmentContext, aValue: T) { return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) ? aValue : undefined; diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index da6473645..1f385e901 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/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({ colorScheme, currency = '', groupBy, @@ -45,35 +48,43 @@ export function getTooltipOptions({ groupBy?: GroupBy; locale?: string; unit?: string; -}) { +}): Partial> { 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( - chartCanvas: ElementRef, +export function getVerticalHoverLinePlugin( + chartCanvas: ElementRef, colorScheme: ColorScheme -): Plugin { +): Plugin { return { afterDatasetsDraw: (chart, _, options) => { const active = chart.getActiveElements(); @@ -125,13 +137,16 @@ export function getVerticalHoverLinePlugin( 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' }; diff --git a/libs/ui/src/lib/chart/chart.registry.ts b/libs/ui/src/lib/chart/chart.registry.ts new file mode 100644 index 000000000..465d6e716 --- /dev/null +++ b/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 { + verticalHoverLine: TType extends 'line' | 'bar' + ? VerticalHoverLinePluginOptions + : never; + } + interface TooltipPositionerMap { + top: TooltipPositionerFunction; + } +} + +export function registerChartConfiguration() { + if (Tooltip.positioners['top']) { + return; + } + + Tooltip.positioners.top = function (_elements, eventPosition) { + return getTooltipPositionerMapTop(this.chart, eventPosition); + }; +} diff --git a/libs/ui/src/lib/chart/index.ts b/libs/ui/src/lib/chart/index.ts new file mode 100644 index 000000000..2a3d3b358 --- /dev/null +++ b/libs/ui/src/lib/chart/index.ts @@ -0,0 +1 @@ +export * from './chart.registry'; diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index 6b0bc8dcb..7461f6729 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/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})` ) diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index 0afef5959..dd972bc5a 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/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; 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[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> { 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' }; diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index fb11897eb..7d0203e9c 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/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> { 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, { diff --git a/libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts b/libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts index bb673ed64..e8d182adb 100644 --- a/libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts +++ b/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; +} diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 6ae958b83..ce85c300e 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/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> { 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}`,