Browse Source

fix: Improve visual comparability of charts in portfolio analysis by implementing proper date range filtering and scaling

pull/5727/head
adityagarud 3 weeks ago
parent
commit
752f453bca
  1. 10
      apps/client/project.json
  2. 36
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  3. 36
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  4. 27
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  5. 8
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  6. 333
      chart-demo.html
  7. 273
      libs/common/src/lib/chart-config.ts
  8. 207
      libs/common/src/lib/chart-helper.ts
  9. 287
      libs/common/src/lib/chart-theme.ts
  10. 9
      libs/common/src/lib/helper.ts
  11. 49
      libs/ui/src/lib/line-chart/line-chart.component.ts

10
apps/client/project.json

@ -99,6 +99,7 @@
"baseHref": "/en/",
"localize": ["en"]
},
"development-es": {
"baseHref": "/es/",
"localize": ["es"]
@ -135,6 +136,10 @@
"baseHref": "/zh/",
"localize": ["zh"]
},
"mock": {
"baseHref": "/en/",
"localize": ["en"]
},
"production": {
"fileReplacements": [
{
@ -220,6 +225,7 @@
"development-en": {
"buildTarget": "client:build:development-en"
},
"development-es": {
"buildTarget": "client:build:development-es"
},
@ -247,6 +253,10 @@
"development-zh": {
"buildTarget": "client:build:development-zh"
},
"mock": {
"buildTarget": "client:build:development-en",
"main": "apps/client/src/main.mock.ts"
},
"production": {
"buildTarget": "client:build:production"
}

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

@ -75,6 +75,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() locale = getLocale();
@Input() performanceDataItems: LineChartItem[];
@Input() user: User;
@Input() startDate: Date;
@Input() endDate: Date;
@Output() benchmarkChanged = new EventEmitter<string>();
@ -122,9 +124,37 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
private initialize() {
// Filter data based on date range if provided
let filteredBenchmarkDataItems = this.benchmarkDataItems;
let filteredPerformanceDataItems = this.performanceDataItems;
if (this.startDate || this.endDate) {
if (this.benchmarkDataItems) {
filteredBenchmarkDataItems = this.benchmarkDataItems.filter((item) => {
const itemDate = parseDate(item.date);
return (
(!this.startDate || itemDate >= this.startDate) &&
(!this.endDate || itemDate <= this.endDate)
);
});
}
if (this.performanceDataItems) {
filteredPerformanceDataItems = this.performanceDataItems.filter(
(item) => {
const itemDate = parseDate(item.date);
return (
(!this.startDate || itemDate >= this.startDate) &&
(!this.endDate || itemDate <= this.endDate)
);
}
);
}
}
const benchmarkDataValues: Record<string, number> = {};
for (const { date, value } of this.benchmarkDataItems) {
for (const { date, value } of filteredBenchmarkDataItems) {
benchmarkDataValues[date] = value;
}
@ -134,7 +164,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => {
data: filteredPerformanceDataItems.map(({ date, value }) => {
return { x: parseDate(date).getTime(), y: value * 100 };
}),
label: $localize`Portfolio`
@ -143,7 +173,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2,
data: this.performanceDataItems.map(({ date }) => {
data: filteredPerformanceDataItems.map(({ date }) => {
return {
x: parseDate(date).getTime(),
y: benchmarkDataValues[date]

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

@ -61,6 +61,8 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() isLoading = false;
@Input() locale = getLocale();
@Input() savingsRate = 0;
@Input() startDate: Date;
@Input() endDate: Date;
@ViewChild('chartCanvas') chartCanvas;
@ -97,15 +99,43 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() {
// Create a clone
this.investments = this.benchmarkDataItems.map((item) =>
let filteredBenchmarkDataItems = this.benchmarkDataItems;
let filteredHistoricalDataItems = this.historicalDataItems;
// Filter data based on date range if provided
if (this.startDate || this.endDate) {
if (this.benchmarkDataItems) {
filteredBenchmarkDataItems = this.benchmarkDataItems.filter((item) => {
const itemDate = parseDate(item.date);
return (
(!this.startDate || itemDate >= this.startDate) &&
(!this.endDate || itemDate <= this.endDate)
);
});
}
if (this.historicalDataItems) {
filteredHistoricalDataItems = this.historicalDataItems.filter(
(item) => {
const itemDate = parseDate(item.date);
return (
(!this.startDate || itemDate >= this.startDate) &&
(!this.endDate || itemDate <= this.endDate)
);
}
);
}
}
this.investments = filteredBenchmarkDataItems.map((item) =>
Object.assign({}, item)
);
this.values = this.historicalDataItems.map((item) =>
this.values = filteredHistoricalDataItems.map((item) =>
Object.assign({}, item)
);
const chartData: ChartData<'bar' | 'line'> = {
labels: this.historicalDataItems.map(({ date }) => {
labels: filteredHistoricalDataItems.map(({ date }) => {
return parseDate(date);
}),
datasets: [

27
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -3,6 +3,7 @@ import { GfInvestmentChartComponent } from '@ghostfolio/client/components/invest
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
HistoricalDataItem,
InvestmentItem,
@ -99,6 +100,8 @@ export class GfAnalysisPageComponent implements OnDestroy, OnInit {
public unitCurrentStreak: string;
public unitLongestStreak: string;
public user: User;
public startDate: Date;
public endDate: Date;
private unsubscribeSubject = new Subject<void>();
@ -229,6 +232,18 @@ export class GfAnalysisPageComponent implements OnDestroy, OnInit {
this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true;
// Update date range
if (this.user?.settings?.dateRange) {
const { startDate, endDate } = getIntervalFromDateRange(
this.user.settings.dateRange
);
this.startDate = startDate;
this.endDate = endDate;
} else {
this.startDate = undefined;
this.endDate = undefined;
}
this.dataService
.fetchDividends({
filters: this.userService.getFilters(),
@ -280,6 +295,18 @@ export class GfAnalysisPageComponent implements OnDestroy, OnInit {
private update() {
this.isLoadingInvestmentChart = true;
// Set date range for charts
if (this.user?.settings?.dateRange) {
const { startDate, endDate } = getIntervalFromDateRange(
this.user.settings.dateRange
);
this.startDate = startDate;
this.endDate = endDate;
} else {
this.startDate = undefined;
this.endDate = undefined;
}
this.dataService
.fetchPortfolioPerformance({
filters: this.userService.getFilters(),

8
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -82,9 +82,11 @@
[benchmarkDataItems]="benchmarkDataItems"
[benchmarks]="benchmarks"
[colorScheme]="user?.settings?.colorScheme"
[endDate]="endDate"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage"
[startDate]="startDate"
[user]="user"
(benchmarkChanged)="onChangeBenchmark($event)"
/>
@ -350,10 +352,12 @@
[benchmarkDataItems]="investments"
[benchmarkDataLabel]="portfolioEvolutionDataLabel"
[currency]="user?.settings?.baseCurrency"
[endDate]="endDate"
[historicalDataItems]="performanceDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[startDate]="startDate"
/>
</div>
</div>
@ -406,11 +410,13 @@
[benchmarkDataItems]="investmentsByGroup"
[benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[endDate]="endDate"
[groupBy]="mode"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale"
[savingsRate]="savingsRate"
[startDate]="startDate"
/>
</div>
</div>
@ -441,10 +447,12 @@
[benchmarkDataItems]="dividendsByGroup"
[benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[endDate]="endDate"
[groupBy]="mode"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale"
[startDate]="startDate"
/>
</div>
</div>

333
chart-demo.html

@ -0,0 +1,333 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>Ghostfolio Chart Improvements Demo</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.chart-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 30px;
}
h1,
h2 {
color: #1976d2;
}
.description {
background: #e3f2fd;
border-left: 4px solid #1976d2;
padding: 15px;
margin: 20px 0;
border-radius: 0 4px 4px 0;
}
.comparison {
display: flex;
gap: 20px;
margin: 30px 0;
}
.comparison-panel {
flex: 1;
padding: 20px;
border-radius: 8px;
}
.before {
background: #ffebee;
border: 1px solid #ffcdd2;
}
.after {
background: #e8f5e9;
border: 1px solid #c8e6c9;
}
.chart-wrapper {
height: 400px;
position: relative;
}
canvas {
width: 100% !important;
height: 100% !important;
}
</style>
</head>
<body>
<div class="container">
<h1>Ghostfolio Chart Improvements Demo</h1>
<div class="description">
<h2>Visual Improvements for Portfolio Analysis Charts</h2>
<p>
This demo shows the improvements made to chart visual comparability in
the Portfolio Analysis section. The key enhancements include:
</p>
<ul>
<li>
<strong>Date Range Filtering:</strong> Charts now properly scale to
show only data within the selected time period
</li>
<li>
<strong>Enhanced Visual Design:</strong> Improved color schemes,
better spacing, and clearer data representation
</li>
<li>
<strong>Better Readability:</strong> Cleaner axes, grid lines, and
tooltips for easier analysis
</li>
</ul>
</div>
<h2>Portfolio Performance Comparison</h2>
<div class="comparison">
<div class="comparison-panel before">
<h3>Before: Truncated/Unscaled Charts</h3>
<p>
Charts were not properly scaled when applying time period filters,
making data difficult to read.
</p>
<div class="chart-wrapper">
<canvas id="chartBefore"></canvas>
</div>
</div>
<div class="comparison-panel after">
<h3>After: Properly Scaled Charts</h3>
<p>
Charts now automatically adjust to show only the data within the
selected time period with proper scaling.
</p>
<div class="chart-wrapper">
<canvas id="chartAfter"></canvas>
</div>
</div>
</div>
<h2>Investment Timeline Comparison</h2>
<div class="chart-container">
<h3>Enhanced Investment Timeline Chart</h3>
<p>
Improved visualization with better color coding and clearer data
representation.
</p>
<div class="chart-wrapper">
<canvas id="investmentChart"></canvas>
</div>
</div>
<h2>Dividend Timeline Comparison</h2>
<div class="chart-container">
<h3>Enhanced Dividend Timeline Chart</h3>
<p>
Better visual distinction for dividend data with improved readability.
</p>
<div class="chart-wrapper">
<canvas id="dividendChart"></canvas>
</div>
</div>
</div>
<script>
// Generate mock data
function generateMockData(days = 365, startDate = new Date()) {
const data = [];
let value = 10000;
for (let i = days; i >= 0; i--) {
const date = new Date(startDate);
date.setDate(date.getDate() - i);
// Add some realistic fluctuations
const change = (Math.random() - 0.5) * 200;
value += change;
data.push({
date: date.toISOString().split('T')[0],
value: value
});
}
return data;
}
// Generate investment data
function generateInvestmentData(months = 24) {
const data = [];
const today = new Date();
for (let i = months; i >= 0; i--) {
const date = new Date(today);
date.setMonth(date.getMonth() - i);
const investment = Math.random() * 2000 + 500;
data.push({
date: date.toISOString().split('T')[0],
investment: investment
});
}
return data;
}
// Generate dividend data
function generateDividendData(months = 24) {
const data = [];
const today = new Date();
for (let i = months; i >= 0; i--) {
const date = new Date(today);
date.setMonth(date.getMonth() - i);
const dividend = Math.random() * 500 + 100;
data.push({
date: date.toISOString().split('T')[0],
investment: dividend
});
}
return data;
}
// Create a chart with the given configuration
function createChart(canvasId, data, title, isInvestment = false) {
const ctx = document.getElementById(canvasId).getContext('2d');
// Filter data for last 6 months for "after" chart
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
let filteredData = data;
if (canvasId === 'chartAfter') {
filteredData = data.filter(
(item) => new Date(item.date) >= sixMonthsAgo
);
}
const chartData = {
labels: filteredData.map((item) => item.date),
datasets: [
{
label: isInvestment ? 'Investment' : 'Portfolio Value',
data: filteredData.map((item) =>
isInvestment ? item.investment : item.value
),
borderColor: isInvestment ? '#388e3c' : '#1976d2',
backgroundColor: isInvestment
? 'rgba(56, 142, 60, 0.1)'
: 'rgba(25, 118, 210, 0.1)',
borderWidth: 2,
pointRadius: 0,
fill: true,
tension: 0
}
]
};
if (!isInvestment) {
// Add benchmark data
chartData.datasets.push({
label: 'Benchmark (S&P 500)',
data: filteredData.map(
(item) =>
(isInvestment ? item.investment : item.value) *
(0.9 + Math.random() * 0.2)
),
borderColor: '#f57c00',
backgroundColor: 'transparent',
borderWidth: 2,
pointRadius: 0,
fill: false,
tension: 0
});
}
return new Chart(ctx, {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: title
},
legend: {
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'month'
},
grid: {
display: false
}
},
y: {
beginAtZero: false,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});
}
// Initialize charts when the page loads
document.addEventListener('DOMContentLoaded', function () {
// Generate mock data
const performanceData = generateMockData(365);
const investmentData = generateInvestmentData(24);
const dividendData = generateDividendData(24);
// Create charts
createChart(
'chartBefore',
performanceData,
'Portfolio Performance (Full Year)'
);
createChart(
'chartAfter',
performanceData,
'Portfolio Performance (Last 6 Months)'
);
createChart(
'investmentChart',
investmentData,
'Investment Timeline',
true
);
createChart('dividendChart', dividendData, 'Dividend Timeline', true);
});
</script>
</body>
</html>

273
libs/common/src/lib/chart-config.ts

@ -0,0 +1,273 @@
import {
getChartColorPalette,
getPerformanceColor,
getAllocationColor
} from './chart-theme';
import { ColorScheme } from './types';
/**
* Standardized Chart Configuration for Ghostfolio
*
* Provides consistent styling, layout, and behavior across all chart types
*/
export interface ChartConfigOptions {
colorScheme?: ColorScheme;
currency?: string;
locale?: string;
unit?: string;
showLegend?: boolean;
showGrid?: boolean;
aspectRatio?: number;
responsive?: boolean;
maintainAspectRatio?: boolean;
}
export interface ChartLayoutConfig {
padding?: number;
spacing?: number;
borderRadius?: number;
borderWidth?: number;
}
/**
* Get standardized chart options with consistent styling
*/
export function getStandardChartOptions(
options: ChartConfigOptions = {},
layoutConfig: ChartLayoutConfig = {}
): any {
const {
colorScheme = 'LIGHT',
showLegend = false,
showGrid = true,
aspectRatio = 16 / 9,
responsive = true,
maintainAspectRatio = true
} = options;
const {
padding = 0,
spacing = 2,
borderRadius = 4,
borderWidth = 1
} = layoutConfig;
const palette = getChartColorPalette(colorScheme);
return {
responsive,
maintainAspectRatio,
aspectRatio,
layout: {
padding: padding
},
plugins: {
legend: {
display: showLegend,
position: 'bottom',
align: 'start',
labels: {
usePointStyle: true,
padding: spacing * 4,
font: {
size: 12,
family: 'Inter, sans-serif'
},
color: palette.text.secondary
}
},
tooltip: {
enabled: true,
backgroundColor: palette.background,
titleColor: palette.text.primary,
bodyColor: palette.text.primary,
borderColor: palette.border,
borderWidth: 1,
cornerRadius: borderRadius,
displayColors: true,
usePointStyle: true,
padding: spacing * 3,
font: {
size: 12,
family: 'Inter, sans-serif'
}
}
},
scales: {
x: {
display: true,
grid: {
display: showGrid,
color: palette.grid,
tickLength: spacing * 2
},
border: {
display: true,
color: palette.border,
width: borderWidth
},
ticks: {
display: true,
color: palette.text.secondary,
padding: spacing * 2,
font: {
size: 11,
family: 'Inter, sans-serif'
}
}
},
y: {
display: true,
position: 'right',
grid: {
display: showGrid,
color: palette.grid,
tickLength: spacing * 2
},
ticks: {
display: true,
color: palette.text.secondary,
padding: spacing * 2,
mirror: true,
z: 1,
font: {
size: 11,
family: 'Inter, sans-serif'
}
}
}
},
elements: {
point: {
radius: 0,
hoverRadius: spacing * 2,
borderWidth: 0
},
line: {
borderWidth: borderWidth * 2,
tension: 0,
fill: false
},
bar: {
borderWidth: 0,
borderRadius: borderRadius
},
arc: {
borderWidth: 0,
borderRadius: borderRadius
}
},
animation: false,
interaction: {
intersect: false,
mode: 'index'
}
};
}
/**
* Get standardized colors for chart datasets
*/
export function getStandardDatasetColors(
index: number,
type: 'line' | 'bar' | 'doughnut' | 'treemap',
colorScheme: ColorScheme = 'LIGHT',
value?: number
): {
backgroundColor: string | string[];
borderColor: string | string[];
hoverBackgroundColor?: string | string[];
hoverBorderColor?: string | string[];
} {
const palette = getChartColorPalette(colorScheme);
switch (type) {
case 'line':
return {
backgroundColor: 'transparent',
borderColor: index === 0 ? palette.primary : palette.secondary,
hoverBackgroundColor: palette.hover
};
case 'bar':
return {
backgroundColor: getAllocationColor(index, colorScheme),
borderColor: 'transparent',
hoverBackgroundColor: palette.hover
};
case 'doughnut':
return {
backgroundColor: getAllocationColor(index, colorScheme),
borderColor: 'transparent',
hoverBackgroundColor: palette.hover
};
case 'treemap':
if (value !== undefined) {
return {
backgroundColor: getPerformanceColor(value, colorScheme),
borderColor: 'transparent'
};
}
return {
backgroundColor: getAllocationColor(index, colorScheme),
borderColor: 'transparent'
};
default:
return {
backgroundColor: palette.primary,
borderColor: palette.primary
};
}
}
/**
* Get responsive breakpoints for charts
*/
export function getChartBreakpoints() {
return {
mobile: 480,
tablet: 768,
desktop: 1024,
large: 1440
};
}
/**
* Get responsive chart configuration
*/
export function getResponsiveChartConfig(
colorScheme: ColorScheme = 'LIGHT',
deviceType: 'mobile' | 'tablet' | 'desktop' = 'desktop'
): Partial<ChartConfigOptions> {
const baseConfig: ChartConfigOptions = {
colorScheme,
showLegend: deviceType !== 'mobile',
aspectRatio: deviceType === 'mobile' ? 1 : 16 / 9,
maintainAspectRatio: deviceType !== 'mobile'
};
switch (deviceType) {
case 'mobile':
return {
...baseConfig,
aspectRatio: 1,
maintainAspectRatio: false,
showLegend: false,
showGrid: false
};
case 'tablet':
return {
...baseConfig,
aspectRatio: 4 / 3,
showLegend: true
};
default:
return baseConfig;
}
}

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

@ -11,6 +11,213 @@ import {
} from './helper';
import { ColorScheme, GroupBy } from './types';
/**
* Standardized Chart Interaction System for Ghostfolio
*
* Provides consistent tooltip and hover behavior across all chart types
*/
export interface TooltipConfig {
colorScheme?: ColorScheme;
currency?: string;
groupBy?: GroupBy;
locale?: string;
unit?: string;
showTitle?: boolean;
showFooter?: boolean;
}
export interface HoverLineConfig {
colorScheme?: ColorScheme;
width?: number;
opacity?: number;
}
/**
* Create standardized tooltip configuration
*/
export function createTooltipConfig(config: TooltipConfig = {}): any {
const {
colorScheme = 'LIGHT',
currency,
groupBy,
locale = getLocale(),
unit,
showTitle = true,
showFooter = false
} = config;
return {
backgroundColor: getBackgroundColor(colorScheme),
bodyColor: `rgb(${getTextColor(colorScheme)})`,
borderWidth: 1,
borderColor: `rgba(${getTextColor(colorScheme)}, 0.1)`,
caretSize: 0,
cornerRadius: 4,
footerColor: `rgb(${getTextColor(colorScheme)})`,
itemSort: (a, b) => {
// Reverse order for better UX
return b.datasetIndex - a.datasetIndex;
},
titleColor: `rgb(${getTextColor(colorScheme)})`,
usePointStyle: true,
callbacks: {
label: (context) => {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
if (currency) {
label += `${context.parsed.y.toLocaleString(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
})} ${currency}`;
} else if (unit) {
label += `${context.parsed.y.toFixed(2)} ${unit}`;
} else {
label += context.parsed.y.toFixed(2);
}
}
return label;
},
title: (contexts) => {
if (!showTitle) return '';
if (groupBy) {
return formatGroupedDate({ groupBy, date: contexts[0].parsed.x });
}
return contexts[0].label;
},
footer: () => {
if (!showFooter) return '';
return 'Footer content'; // Can be customized per chart type
}
}
};
}
/**
* Create standardized hover line configuration
*/
export function createHoverLineConfig(config: HoverLineConfig = {}): any {
const { colorScheme = 'LIGHT', width = 1, opacity = 0.1 } = config;
return {
afterDatasetsDraw: (chart, _, options) => {
const active = chart.getActiveElements();
if (!active || active.length === 0) {
return;
}
const color =
options.color || `rgba(${getTextColor(colorScheme)}, ${opacity})`;
const {
chartArea: { bottom, top }
} = chart;
const xValue = active[0].element.x;
const context = chart.canvas.getContext('2d');
if (!context) return;
context.lineWidth = width;
context.strokeStyle = color;
context.beginPath();
context.moveTo(xValue, top);
context.lineTo(xValue, bottom);
context.stroke();
},
id: 'verticalHoverLine'
};
}
/**
* Create standardized interaction configuration
*/
export function createInteractionConfig(): any {
return {
intersect: false,
mode: 'index',
axis: 'x'
};
}
/**
* Create standardized animation configuration
*/
export function createAnimationConfig(
duration: number = 1200,
easing: string = 'easeOutQuart'
): any {
return {
duration,
easing,
delay: (context) => {
const delayBetweenPoints = duration / context.chart.data.labels.length;
return context.index * delayBetweenPoints;
}
};
}
/**
* Create standardized legend configuration
*/
export function createLegendConfig(
colorScheme: ColorScheme = 'LIGHT',
position: 'top' | 'bottom' | 'left' | 'right' = 'bottom'
): any {
return {
display: true,
position,
align: 'start',
labels: {
usePointStyle: true,
padding: 16,
font: {
size: 12,
family: 'Inter, sans-serif'
},
color: `rgb(${getTextColor(colorScheme)})`
}
};
}
/**
* Create standardized grid configuration
*/
export function createGridConfig(
colorScheme: ColorScheme = 'LIGHT',
display: boolean = true
): any {
return {
display,
color: `rgba(${getTextColor(colorScheme)}, 0.08)`,
borderColor: `rgba(${getTextColor(colorScheme)}, 0.12)`,
borderWidth: 1,
tickLength: 8
};
}
/**
* Create standardized tick configuration
*/
export function createTickConfig(
colorScheme: ColorScheme = 'LIGHT',
fontSize: number = 11
): any {
return {
display: true,
color: `rgb(${getTextColor(colorScheme)}`,
padding: 8,
font: {
size: fontSize,
family: 'Inter, sans-serif'
}
};
}
export function formatGroupedDate({
date,
groupBy

287
libs/common/src/lib/chart-theme.ts

@ -0,0 +1,287 @@
import { ColorScheme } from './types';
/**
* Chart Color Theme System for Ghostfolio
*
* Provides consistent color schemes across all chart types with support for:
* - Light and dark themes
* - Semantic color meanings (positive, negative, neutral)
* - Performance-based color coding
* - Accessibility-compliant contrast ratios
*/
export interface ChartColorPalette {
// Primary chart colors
primary: string;
secondary: string;
accent: string;
// Semantic colors
positive: string;
negative: string;
neutral: string;
warning: string;
info: string;
// Performance-based colors (green to red spectrum)
performance: {
excellent: string;
good: string;
average: string;
poor: string;
terrible: string;
};
// Allocation colors (diverse palette for categories)
allocation: string[];
// Background and text colors
background: string;
surface: string;
text: {
primary: string;
secondary: string;
disabled: string;
};
// Interactive states
hover: string;
active: string;
focus: string;
// Grid and border colors
grid: string;
border: string;
}
export interface ChartTheme {
light: ChartColorPalette;
dark: ChartColorPalette;
}
/**
* Generate color palette based on color scheme
*/
export function getChartColorPalette(
colorScheme: ColorScheme = 'LIGHT'
): ChartColorPalette {
const isDark = colorScheme === 'DARK';
if (isDark) {
return {
// Primary chart colors
primary: '#64b5f6', // Light blue
secondary: '#81c784', // Light green
accent: '#ffb74d', // Orange
// Semantic colors
positive: '#4caf50', // Green
negative: '#f44336', // Red
neutral: '#9e9e9e', // Grey
warning: '#ff9800', // Orange
info: '#2196f3', // Blue
// Performance-based colors
performance: {
excellent: '#2e7d32', // Dark green
good: '#4caf50', // Green
average: '#8bc34a', // Light green
poor: '#ff5722', // Deep orange
terrible: '#d32f2f' // Dark red
},
// Allocation colors (diverse palette)
allocation: [
'#2196f3', // Blue
'#4caf50', // Green
'#ff9800', // Orange
'#9c27b0', // Purple
'#f44336', // Red
'#00bcd4', // Cyan
'#ffc107', // Amber
'#795548', // Brown
'#607d8b', // Blue grey
'#e91e63', // Pink
'#3f51b5', // Indigo
'#009688', // Teal
'#8bc34a', // Light green
'#ff5722', // Deep orange
'#9c27b0' // Purple
],
// Background and text colors
background: '#121212',
surface: '#1e1e1e',
text: {
primary: '#ffffff',
secondary: '#b3b3b3',
disabled: '#666666'
},
// Interactive states
hover: 'rgba(255, 255, 255, 0.08)',
active: 'rgba(255, 255, 255, 0.16)',
focus: 'rgba(100, 181, 246, 0.24)',
// Grid and border colors
grid: 'rgba(255, 255, 255, 0.08)',
border: 'rgba(255, 255, 255, 0.12)'
};
}
// Light theme
return {
// Primary chart colors
primary: '#1976d2', // Blue
secondary: '#388e3c', // Green
accent: '#f57c00', // Orange
// Semantic colors
positive: '#2e7d32', // Green
negative: '#d32f2f', // Red
neutral: '#757575', // Grey
warning: '#f57c00', // Orange
info: '#1976d2', // Blue
// Performance-based colors
performance: {
excellent: '#1b5e20', // Dark green
good: '#2e7d32', // Green
average: '#4caf50', // Light green
poor: '#f57c00', // Orange
terrible: '#d32f2f' // Dark red
},
// Allocation colors (diverse palette)
allocation: [
'#1976d2', // Blue
'#388e3c', // Green
'#f57c00', // Orange
'#7b1fa2', // Purple
'#d32f2f', // Red
'#00796b', // Teal
'#fbc02d', // Yellow
'#5d4037', // Brown
'#455a64', // Blue grey
'#c2185b', // Pink
'#3f51b5', // Indigo
'#009688', // Teal
'#689f38', // Light green
'#e65100', // Deep orange
'#7b1fa2' // Purple
],
// Background and text colors
background: '#ffffff',
surface: '#f5f5f5',
text: {
primary: '#212121',
secondary: '#757575',
disabled: '#bdbdbd'
},
// Interactive states
hover: 'rgba(0, 0, 0, 0.04)',
active: 'rgba(0, 0, 0, 0.08)',
focus: 'rgba(25, 118, 210, 0.12)',
// Grid and border colors
grid: 'rgba(0, 0, 0, 0.08)',
border: 'rgba(0, 0, 0, 0.12)'
};
}
/**
* Get performance color based on percentage value
*/
export function getPerformanceColor(
percentage: number,
colorScheme: ColorScheme = 'LIGHT'
): string {
const palette = getChartColorPalette(colorScheme);
if (percentage >= 15) return palette.performance.excellent;
if (percentage >= 5) return palette.performance.good;
if (percentage >= -5) return palette.performance.average;
if (percentage >= -15) return palette.performance.poor;
return palette.performance.terrible;
}
/**
* Get allocation color for index-based coloring
*/
export function getAllocationColor(
index: number,
colorScheme: ColorScheme = 'LIGHT'
): string {
const palette = getChartColorPalette(colorScheme);
return palette.allocation[index % palette.allocation.length];
}
/**
* Get semantic color based on context
*/
export function getSemanticColor(
type: 'positive' | 'negative' | 'neutral' | 'warning' | 'info',
colorScheme: ColorScheme = 'LIGHT'
): string {
const palette = getChartColorPalette(colorScheme);
return palette[type];
}
/**
* Calculate relative luminance of a color (0-1 scale)
* Based on WCAG guidelines for color contrast
*/
function calculateLuminance(color: string): number {
// Convert hex to RGB
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16) / 255;
const g = parseInt(hex.substr(2, 2), 16) / 255;
const b = parseInt(hex.substr(4, 2), 16) / 255;
// Apply gamma correction
const gammaCorrect = (value: number): number => {
return value <= 0.03928
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
};
const rCorrected = gammaCorrect(r);
const gCorrected = gammaCorrect(g);
const bCorrected = gammaCorrect(b);
// Calculate relative luminance
return 0.2126 * rCorrected + 0.7152 * gCorrected + 0.0722 * bCorrected;
}
export function getContrastTextColor(
backgroundColor: string,
colorScheme: ColorScheme = 'LIGHT'
): string {
const palette = getChartColorPalette(colorScheme);
// Calculate relative luminance of the background color
const luminance = calculateLuminance(backgroundColor);
// Use dark text for light backgrounds, light text for dark backgrounds
return luminance > 0.5 ? palette.text.primary : '#ffffff';
}
/**
* Generate gradient colors for area charts
*/
export function generateGradientColors(
baseColor: string,
colorScheme: ColorScheme = 'LIGHT',
opacity: number = 0.1
): { start: string; end: string } {
// Using palette for potential future enhancements
getChartColorPalette(colorScheme);
return {
start: `${baseColor}${Math.round(opacity * 255)
.toString(16)
.padStart(2, '0')}`,
end: baseColor
};
}

9
libs/common/src/lib/helper.ts

@ -154,13 +154,8 @@ export function getAssetProfileIdentifier({
return `${dataSource}-${symbol}`;
}
export function getBackgroundColor(aColorScheme: ColorScheme) {
return getCssVariable(
aColorScheme === 'DARK' ||
window.matchMedia('(prefers-color-scheme: dark)').matches
? '--dark-background'
: '--light-background'
);
export function getBackgroundColor(colorScheme: ColorScheme = 'LIGHT'): string {
return colorScheme === 'DARK' ? '#121212' : '#ffffff';
}
export function getCssVariable(aCssVariable: string) {

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

@ -1,7 +1,8 @@
import { getStandardChartOptions } from '@ghostfolio/common/chart-config';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
getVerticalHoverLinePlugin,
getTooltipOptions
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
@ -66,6 +67,8 @@ export class GfLineChartComponent
@Input() yMaxLabel: string;
@Input() yMin: number;
@Input() yMinLabel: string;
@Input() startDate: Date; // Added for date range filtering
@Input() endDate: Date; // Added for date range filtering
@ViewChild('chartCanvas') chartCanvas;
@ -117,12 +120,35 @@ export class GfLineChartComponent
private initialize() {
this.isLoading = true;
// Filter data based on date range if provided
let filteredHistoricalDataItems = this.historicalDataItems;
let filteredBenchmarkDataItems = this.benchmarkDataItems;
if (this.startDate || this.endDate) {
filteredHistoricalDataItems = this.historicalDataItems?.filter((item) => {
const itemDate = new Date(item.date);
return (
(!this.startDate || itemDate >= this.startDate) &&
(!this.endDate || itemDate <= this.endDate)
);
});
// Filter benchmark data to match the same date range
const filteredDates = filteredHistoricalDataItems?.map(
(item) => item.date
);
filteredBenchmarkDataItems = this.benchmarkDataItems?.filter((item) =>
filteredDates?.includes(item.date)
);
}
const benchmarkPrices = [];
const labels: string[] = [];
const marketPrices = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value);
filteredHistoricalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(filteredBenchmarkDataItems?.[index]?.value);
labels.push(historicalDataItem.date);
marketPrices.push(historicalDataItem.value);
});
@ -181,16 +207,22 @@ export class GfLineChartComponent
} as unknown);
this.chart.update();
} else {
// Use standardized chart options
const standardOptions = getStandardChartOptions({
colorScheme: this.colorScheme,
showLegend: this.showLegend
});
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
...standardOptions,
animation:
this.isAnimated &&
({
x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }),
y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' })
} as unknown),
aspectRatio: 16 / 9,
elements: {
point: {
hoverBackgroundColor: getBackgroundColor(this.colorScheme),
@ -199,7 +231,9 @@ export class GfLineChartComponent
},
interaction: { intersect: false, mode: 'index' },
plugins: {
...standardOptions.plugins,
legend: {
...standardOptions.plugins.legend,
align: 'start',
display: this.showLegend,
position: 'bottom'
@ -211,13 +245,11 @@ export class GfLineChartComponent
} as unknown,
scales: {
x: {
...standardOptions.scales.x,
border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
},
display: this.showXAxis,
grid: {
display: false
},
time: {
tooltipFormat: getDateFormatString(this.locale),
unit: 'year'
@ -225,6 +257,7 @@ export class GfLineChartComponent
type: 'time'
},
y: {
...standardOptions.scales.y,
border: {
width: 0
},

Loading…
Cancel
Save