From 4d63d5f4d63b1060b675aafd8d55d3c34c25511b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:44:14 +0200 Subject: [PATCH] Task/update chart colors on theme change (#7981) * Update chart colors on change of color scheme * Update changelog --- CHANGELOG.md | 1 + apps/client/src/app/app.component.ts | 34 ++++++++++--------- .../benchmark-comparator.component.ts | 22 ++++++++++-- .../interfaces/interfaces.ts | 2 +- .../investment-chart.component.ts | 20 +++++++++-- libs/common/src/lib/chart-helper.ts | 12 ++++--- libs/ui/src/lib/chart/chart.color-scheme.ts | 8 +++++ libs/ui/src/lib/chart/index.ts | 1 + .../fire-calculator.component.ts | 24 +++++++++++-- .../lib/line-chart/line-chart.component.ts | 20 +++++++++-- .../portfolio-proportion-chart.component.ts | 7 ++++ .../treemap-chart/treemap-chart.component.ts | 9 ++++- 12 files changed, 128 insertions(+), 32 deletions(-) create mode 100644 libs/ui/src/lib/chart/chart.color-scheme.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f30da1b2..17ad555200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the charts to update their colors if the appearance is set to auto and the operating system switches between light and dark mode - Extended the emergency fund dialog in the portfolio summary to include the currency suffix - Extended the user detail dialog in the users section of the admin control panel to include the last request - Migrated the client to the code generated by the new `prisma-client` generator of _Prisma_ diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index a1f8ebfe9c..fba00e1c47 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -1,4 +1,8 @@ -import { getCssVariable, isDarkColorScheme } from '@ghostfolio/common/helper'; +import { + getBackgroundColor, + getCssVariable, + isDarkColorScheme +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, InfoItem, @@ -227,7 +231,7 @@ export class GfAppComponent implements OnInit { : !!this.info?.subscriptionOffer?.coupon || !!this.info?.subscriptionOffer?.durationExtension; - this.initializeTheme(this.user?.settings.colorScheme); + this.toggleTheme(this.user?.settings.colorScheme); this.changeDetectorRef.markForCheck(); }); @@ -259,17 +263,19 @@ export class GfAppComponent implements OnInit { document.location.href = `/${document.documentElement.lang}`; } - private initializeTheme(userPreferredColorScheme?: ColorScheme) { - this.toggleTheme(isDarkColorScheme(userPreferredColorScheme)); + private initializeTheme() { + this.toggleTheme(); // Default chart styles Chart.defaults.font.family = getCssVariable('--font-family-sans-serif'); - window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => { - if (!this.user?.settings.colorScheme) { - this.toggleTheme(event.matches); - } - }); + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', () => { + if (!this.user?.settings.colorScheme) { + this.toggleTheme(); + } + }); } private openHoldingDetailDialog({ @@ -340,12 +346,8 @@ export class GfAppComponent implements OnInit { }); } - private toggleTheme(isDarkTheme: boolean) { - const themeColor = getCssVariable( - isDarkTheme ? '--dark-background' : '--light-background' - ); - - if (isDarkTheme) { + private toggleTheme(userPreferredColorScheme?: ColorScheme) { + if (isDarkColorScheme(userPreferredColorScheme)) { this.document.body.classList.add('theme-dark'); this.document.body.classList.remove('theme-light'); } else { @@ -355,6 +357,6 @@ export class GfAppComponent implements OnInit { this.document .querySelector('meta[name="theme-color"]') - ?.setAttribute('content', themeColor); + ?.setAttribute('content', getBackgroundColor(userPreferredColorScheme)); } } 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 ef14b404e4..72241f0454 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 @@ -15,6 +15,7 @@ import { ColorScheme } from '@ghostfolio/common/types'; import type { SymbolProfile } from '@ghostfolio/prisma/browser'; import { getTimeSeriesTooltipOptions, + onPrefersColorSchemeChange, registerChartConfiguration } from '@ghostfolio/ui/chart'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; @@ -27,6 +28,7 @@ import { OnChanges, OnDestroy, output, + SimpleChanges, viewChild } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -68,7 +70,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { public readonly benchmark = input>(); public readonly benchmarkDataItems = input([]); public readonly benchmarks = input[]>(); - public readonly colorScheme = input.required(); + public readonly colorScheme = input(); public readonly isLoading = input(); public readonly locale = input(getLocale()); public readonly performanceDataItems = input.required(); @@ -76,7 +78,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { public readonly benchmarkChanged = output(); - protected chart: Chart<'line'>; + protected chart?: Chart<'line'>; protected hasPermissionToAccessAdminControl: boolean; protected readonly routerLinkAdminControlMarketData = internalRoutes.adminControl.subRoutes.marketData.routerLink; @@ -96,10 +98,24 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { registerChartConfiguration(); + onPrefersColorSchemeChange(() => { + if (this.chart && !this.colorScheme()) { + this.chart.destroy(); + this.chart = undefined; + + this.initialize(); + } + }); + addIcons({ arrowForwardOutline }); } - public ngOnChanges() { + public ngOnChanges(changes: SimpleChanges) { + if (changes.colorScheme && this.chart) { + this.chart.destroy(); + this.chart = undefined; + } + this.hasPermissionToAccessAdminControl = hasPermission( this.user()?.permissions, permissions.accessAdminControl diff --git a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts index 5f33db42a3..a03fd93992 100644 --- a/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts @@ -3,7 +3,7 @@ import { DataSource } from '@ghostfolio/prisma/enums'; export interface HoldingDetailDialogParams { baseCurrency: string; - colorScheme: ColorScheme; + colorScheme?: ColorScheme; dataSource: DataSource; deviceType: string; hasPermissionToAccessAdminControl: boolean; 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 b3cc0d753e..a1dc6a28f9 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 @@ -14,6 +14,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in import { ColorScheme, GroupBy } from '@ghostfolio/common/types'; import { getTimeSeriesTooltipOptions, + onPrefersColorSchemeChange, registerChartConfiguration } from '@ghostfolio/ui/chart'; @@ -24,6 +25,7 @@ import { Input, OnChanges, OnDestroy, + SimpleChanges, viewChild } from '@angular/core'; import { @@ -67,7 +69,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { private readonly chartCanvas = viewChild.required>('chartCanvas'); - private chart: Chart<'bar' | 'line'>; + private chart?: Chart<'bar' | 'line'>; private investments: InvestmentItem[]; private values: LineChartItem[]; @@ -84,9 +86,23 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { ); registerChartConfiguration(); + + onPrefersColorSchemeChange(() => { + if (this.chart && !this.colorScheme) { + this.chart.destroy(); + this.chart = undefined; + + this.initialize(); + } + }); } - public ngOnChanges() { + public ngOnChanges(changes: SimpleChanges) { + if (changes.colorScheme && this.chart) { + this.chart.destroy(); + this.chart = undefined; + } + if (this.benchmarkDataItems && this.historicalDataItems) { this.initialize(); } diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index 58f370d06d..bad13d0bac 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -101,9 +101,11 @@ export function getTooltipOptions({ locale?: string; unit?: string; }): Partial> { + const textColor = getTextColor(colorScheme); + return { backgroundColor: getBackgroundColor(colorScheme), - bodyColor: `rgb(${getTextColor(colorScheme)})`, + bodyColor: `rgb(${textColor})`, borderWidth: 1, borderColor: getChartBorderColor(colorScheme), // @ts-expect-error: no need to set all attributes in callbacks @@ -144,12 +146,12 @@ export function getTooltipOptions({ }, caretSize: 0, cornerRadius: 2, - footerColor: `rgb(${getTextColor(colorScheme)})`, + footerColor: `rgb(${textColor})`, itemSort: (a, b) => { // Reverse order return b.datasetIndex - a.datasetIndex; }, - titleColor: `rgb(${getTextColor(colorScheme)})`, + titleColor: `rgb(${textColor})`, usePointStyle: true }; } @@ -183,6 +185,8 @@ export function getValueAxisOptions({ ticks: Tick[] ) => string; }): ScaleOptions<'linear'> { + const borderColor = getChartBorderColor(colorScheme); + return { display, border: { @@ -196,7 +200,7 @@ export function getValueAxisOptions({ tick.value === scale.min || highlightedValues.includes(tick.value) ) { - return getChartBorderColor(colorScheme); + return borderColor; } return 'transparent'; diff --git a/libs/ui/src/lib/chart/chart.color-scheme.ts b/libs/ui/src/lib/chart/chart.color-scheme.ts new file mode 100644 index 0000000000..684c9bda80 --- /dev/null +++ b/libs/ui/src/lib/chart/chart.color-scheme.ts @@ -0,0 +1,8 @@ +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { fromEvent } from 'rxjs'; + +export function onPrefersColorSchemeChange(callback: () => void) { + fromEvent(window.matchMedia('(prefers-color-scheme: dark)'), 'change') + .pipe(takeUntilDestroyed()) + .subscribe(callback); +} diff --git a/libs/ui/src/lib/chart/index.ts b/libs/ui/src/lib/chart/index.ts index 01597c515c..3ac6db992e 100644 --- a/libs/ui/src/lib/chart/index.ts +++ b/libs/ui/src/lib/chart/index.ts @@ -1,2 +1,3 @@ +export * from './chart.color-scheme'; export * from './chart.options'; 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 959e934553..bdd65d1668 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -18,6 +18,7 @@ import { OnChanges, OnDestroy, output, + SimpleChanges, viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -63,6 +64,7 @@ import { isNumber } from 'lodash-es'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { debounceTime } from 'rxjs'; +import { onPrefersColorSchemeChange } from '../chart'; import { FireCalculatorService } from './fire-calculator.service'; @Component({ @@ -85,7 +87,7 @@ import { FireCalculatorService } from './fire-calculator.service'; }) export class GfFireCalculatorComponent implements OnChanges, OnDestroy { @Input() annualInterestRate = 0; - @Input() colorScheme: ColorScheme; + @Input() colorScheme?: ColorScheme; @Input() currency: string; @Input() deviceType: string; @Input() fireWealth = 0; @@ -103,7 +105,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { retirementDate: new FormControl(null) }); - public chart: Chart<'bar'>; + public chart?: Chart<'bar'>; public isLoading = true; public minDate = addDays(new Date(), 1); public periodsToRetire = 0; @@ -142,6 +144,15 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { addIcons({ calendarClearOutline }); + onPrefersColorSchemeChange(() => { + if (this.chart && !this.colorScheme) { + this.chart.destroy(); + this.chart = undefined; + + this.initialize(); + } + }); + this.calculatorForm.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { @@ -221,7 +232,14 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { }); } - public ngOnChanges() { + public ngOnChanges(changes: SimpleChanges) { + if (changes.colorScheme && this.chart) { + this.chart.destroy(); + this.chart = undefined; + + this.initialize(); + } + if (isNumber(this.fireWealth) && this.fireWealth >= 0) { this.calculatorForm.setValue( { 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 49eae2f5df..ad399496e2 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -18,6 +18,7 @@ import { Input, OnChanges, OnDestroy, + SimpleChanges, ViewChild } from '@angular/core'; import { @@ -37,6 +38,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { getTimeSeriesTooltipOptions, + onPrefersColorSchemeChange, registerChartConfiguration } from '../chart'; @@ -71,7 +73,7 @@ export class GfLineChartComponent @ViewChild('chartCanvas') chartCanvas: ElementRef; - public chart: Chart<'line'>; + public chart?: Chart<'line'>; public isLoading = true; private readonly ANIMATION_DURATION = 1200; @@ -88,6 +90,15 @@ export class GfLineChartComponent ); registerChartConfiguration(); + + onPrefersColorSchemeChange(() => { + if (this.chart && !this.colorScheme) { + this.chart.destroy(); + this.chart = undefined; + + this.initialize(); + } + }); } public ngAfterViewInit() { @@ -101,7 +112,12 @@ export class GfLineChartComponent } } - public ngOnChanges() { + public ngOnChanges(changes: SimpleChanges) { + if (changes.colorScheme && this.chart) { + this.chart.destroy(); + this.chart = undefined; + } + if (this.historicalDataItems || this.historicalDataItems === null) { setTimeout(() => { // Wait for the chartCanvas 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 ff61830f04..e27097a701 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 @@ -33,6 +33,7 @@ import Color from 'color'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import OpenColor from 'open-color'; +import { onPrefersColorSchemeChange } from '../chart'; import { PortfolioProportionChartClickEvent } from './interfaces/interfaces'; const { @@ -94,6 +95,12 @@ export class GfPortfolioProportionChartComponent public constructor() { Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); + + onPrefersColorSchemeChange(() => { + if (this.chart && !this.colorScheme) { + this.initialize(); + } + }); } public ngAfterViewInit() { 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 a35d3deef5..83b9916c6e 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -36,6 +36,7 @@ import { orderBy, round } from 'lodash-es'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import OpenColor from 'open-color'; +import { onPrefersColorSchemeChange } from '../chart'; import type { GetColorParams, GfTreemapDataPoint, @@ -55,7 +56,7 @@ export class GfTreemapChartComponent implements AfterViewInit, OnChanges, OnDestroy { public readonly baseCurrency = input.required(); - public readonly colorScheme = input.required(); + public readonly colorScheme = input(); public readonly cursor = input.required(); public readonly dateRange = input.required(); public readonly holdings = input(); @@ -71,6 +72,12 @@ export class GfTreemapChartComponent public constructor() { Chart.register(LinearScale, Tooltip, TreemapController, TreemapElement); + + onPrefersColorSchemeChange(() => { + if (this.chart && !this.colorScheme()) { + this.initialize(); + } + }); } public ngAfterViewInit() {