Browse Source

Feature/draw the performance chart of the overview page on the trading days and remove the samples of the day

pull/8267/head
mister-ideas 1 day ago
parent
commit
bccbca05d2
  1. 8
      CHANGELOG.md
  2. 58
      apps/client/src/app/components/home-overview/home-overview.component.ts
  3. 50
      apps/client/src/app/components/home-overview/intraday-samples.spec.ts
  4. 83
      apps/client/src/app/components/home-overview/intraday-samples.ts
  5. 143
      libs/common/src/lib/chart-helper.spec.ts
  6. 295
      libs/common/src/lib/chart-helper.ts
  7. 15
      libs/ui/src/lib/line-chart/line-chart.component.ts

8
CHANGELOG.md

@ -9,15 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a time axis to the performance chart on the overview page, drawing the current day from samples recorded on the device, which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range - Added a time axis to the performance chart on the overview page which only contains the trading days, spans the whole current year and uses hours, days, months or years as unit according to the selected date range
### Changed
- Hid the performance chart on the overview page if it has less than three data points
- Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental) - Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental)
### Changed ### Changed
- Hid the performance chart on the overview page if it has less than three data points
- Reduced the performance chart on the overview page to one point per week above 400 trading days
- Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile - Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile
- Improved the style of the close button in the dialog footer component - Improved the style of the close button in the dialog footer component
- Improved the style of the close button in the dialog header component - Improved the style of the close button in the dialog header component

58
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -2,6 +2,7 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/p
import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getTradingDayItems } from '@ghostfolio/common/chart-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
@ -12,7 +13,7 @@ import { isCashPosition } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
HistoricalDataItem, HistoricalDataItem,
LineChartItem, NullableLineChartItem,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPosition,
User User
@ -43,11 +44,6 @@ import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons'; import { arrowForwardOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import {
getIntradayChartItems,
recordIntradaySample
} from './intraday-samples';
const MINIMUM_CHART_ITEMS = 3; const MINIMUM_CHART_ITEMS = 3;
@Component({ @Component({
@ -69,15 +65,18 @@ export class GfHomeOverviewComponent implements OnInit {
protected readonly errors = signal<AssetProfileIdentifier[]>([]); protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false); protected readonly hasImpersonationId = signal(false);
protected readonly defaultDateRange = DEFAULT_DATE_RANGE; protected readonly defaultDateRange = DEFAULT_DATE_RANGE;
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null); protected readonly historicalDataItems = signal<
NullableLineChartItem[] | null
>(null);
// A line through only two points (e.g. the close of the previous day and // A line through only two points (e.g. the close of the previous day and
// now) is drawn as a misleading slope across the whole width // now) is drawn as a misleading slope across the whole width
protected readonly hasEnoughChartData = computed(() => { protected readonly hasEnoughChartData = computed(() => {
const dateRange = this.user()?.settings?.dateRange; return (
const minimum = dateRange === '1d' ? 1 : MINIMUM_CHART_ITEMS; (this.historicalDataItems() ?? []).filter(({ value }) => {
return value !== null;
return (this.historicalDataItems()?.length ?? 0) >= minimum; }).length >= MINIMUM_CHART_ITEMS
);
}); });
protected readonly holdings = signal<PortfolioPosition[] | undefined>( protected readonly holdings = signal<PortfolioPosition[] | undefined>(
undefined undefined
@ -197,28 +196,21 @@ export class GfHomeOverviewComponent implements OnInit {
chart?: HistoricalDataItem[]; chart?: HistoricalDataItem[];
dateRange?: DateRange; dateRange?: DateRange;
}) { }) {
const items = // Only the trading days, and the remaining ones of the current year
chart?.map(({ date, netPerformanceInPercentageWithCurrencyEffect }) => { this.historicalDataItems.set(
return { getTradingDayItems({
date, dateRange,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100 items:
}; chart?.map(
}) ?? null; ({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
const latest = items?.at(-1); date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
if ((dateRange ?? DEFAULT_DATE_RANGE) === '1d' && latest) { };
// There is only one value per day on the server: draw the day from the }
// close of the previous day to the samples recorded on this device ) ?? []
this.historicalDataItems.set( })
getIntradayChartItems({ );
baseline: items?.[0]?.value ?? 0,
samples: recordIntradaySample({ value: latest.value })
})
);
} else {
this.historicalDataItems.set(items);
}
} }
private update() { private update() {

50
apps/client/src/app/components/home-overview/intraday-samples.spec.ts

@ -1,50 +0,0 @@
import {
getIntradayChartItems,
INTRADAY_SAMPLE_INTERVAL_MS,
loadIntradaySamples,
recordIntradaySample
} from './intraday-samples';
describe('intraday samples', () => {
const now = new Date(2026, 9, 21, 10, 0, 0);
beforeEach(() => {
localStorage.clear();
});
it('records at most one sample per minute', () => {
recordIntradaySample({ now, value: 1 });
recordIntradaySample({ now: new Date(now.getTime() + 5_000), value: 2 });
expect(loadIntradaySamples(now)).toEqual([
{ timestamp: now.getTime(), value: 2 }
]);
recordIntradaySample({
now: new Date(now.getTime() + INTRADAY_SAMPLE_INTERVAL_MS),
value: 3
});
expect(loadIntradaySamples(now)).toHaveLength(2);
});
it('removes the samples of previous days', () => {
recordIntradaySample({ now: new Date(2026, 9, 20, 15, 0), value: 1 });
recordIntradaySample({ now, value: 2 });
expect(loadIntradaySamples(new Date(2026, 9, 20))).toEqual([]);
expect(loadIntradaySamples(now)).toHaveLength(1);
});
it('starts the chart of the day with the baseline at midnight', () => {
const items = getIntradayChartItems({
now,
baseline: 0,
samples: [{ timestamp: now.getTime(), value: 0.5 }]
});
expect(items).toHaveLength(2);
expect(new Date(items[0].date).getHours()).toBe(0);
expect(items[1].value).toBe(0.5);
});
});

83
apps/client/src/app/components/home-overview/intraday-samples.ts

@ -1,83 +0,0 @@
import { LineChartItem } from '@ghostfolio/common/interfaces';
import { format, startOfDay } from 'date-fns';
export const INTRADAY_SAMPLES_KEY_PREFIX = 'gf-intraday-';
export const INTRADAY_SAMPLE_INTERVAL_MS = 60_000;
export interface IntradaySample {
timestamp: number;
value: number;
}
function getKey(aDate: Date) {
return `${INTRADAY_SAMPLES_KEY_PREFIX}${format(aDate, 'yyyy-MM-dd')}`;
}
export function loadIntradaySamples(aDate = new Date()): IntradaySample[] {
try {
const samples = JSON.parse(localStorage.getItem(getKey(aDate)) ?? '[]');
return Array.isArray(samples) ? samples : [];
} catch {
return [];
}
}
/**
* Stores the current value of the day (at most one sample per minute) in the
* local storage of the device, because only a single value per day is stored
* on the server. Samples of previous days are removed.
*/
export function recordIntradaySample({
now = new Date(),
value
}: {
now?: Date;
value: number;
}): IntradaySample[] {
const samples = loadIntradaySamples(now);
const last = samples.at(-1);
if (!last || now.getTime() - last.timestamp >= INTRADAY_SAMPLE_INTERVAL_MS) {
samples.push({ value, timestamp: now.getTime() });
} else {
// Keep the latest value of the current minute
last.value = value;
}
try {
for (let index = localStorage.length - 1; index >= 0; index--) {
const key = localStorage.key(index);
if (key?.startsWith(INTRADAY_SAMPLES_KEY_PREFIX) && key !== getKey(now)) {
localStorage.removeItem(key);
}
}
localStorage.setItem(getKey(now), JSON.stringify(samples));
} catch {}
return samples;
}
/**
* Builds the chart of the current day: the baseline (the close of the previous
* day) at the start of the day followed by the recorded samples of the day.
*/
export function getIntradayChartItems({
baseline,
now = new Date(),
samples
}: {
baseline: number;
now?: Date;
samples: IntradaySample[];
}): LineChartItem[] {
return [
{ date: startOfDay(now).toISOString(), value: baseline },
...samples.map(({ timestamp, value }) => {
return { value, date: new Date(timestamp).toISOString() };
})
];
}

143
libs/common/src/lib/chart-helper.spec.ts

@ -1,4 +1,11 @@
import { getTimeAxisBounds, getTimeAxisUnit } from './chart-helper'; import { addDays, format } from 'date-fns';
import {
getTimeAxisUnit,
getTimeSeriesTickCallback,
getTradingDayItems
} from './chart-helper';
import { DATE_FORMAT } from './helper';
describe('getTimeAxisUnit', () => { describe('getTimeAxisUnit', () => {
it.each([ it.each([
@ -6,61 +13,129 @@ describe('getTimeAxisUnit', () => {
['wtd', 'day'], ['wtd', 'day'],
['mtd', 'day'], ['mtd', 'day'],
['ytd', 'month'], ['ytd', 'month'],
['1y', 'month'], ['1y', false],
['2025', 'month'], ['2025', 'month'],
['5y', 'year'], ['5y', false],
['max', 'year'], ['max', false],
[undefined, 'year'] [undefined, 'year']
])('uses the unit of %s', (dateRange, unit) => { ])('uses the unit of %s', (dateRange, unit) => {
expect(getTimeAxisUnit(dateRange)).toBe(unit); expect(getTimeAxisUnit(dateRange)).toBe(unit);
}); });
}); });
describe('getTimeAxisBounds', () => { describe('getTradingDayItems', () => {
const reference = new Date(2026, 9, 21, 15, 30); // Thursday
const now = new Date(2026, 11, 24);
it('spans the whole current year for ytd', () => { it('skips the weekends', () => {
const { max, min } = getTimeAxisBounds('ytd', reference); const items = getTradingDayItems({
dateRange: '1y',
now,
items: [
{ date: '2026-12-18', value: 1 },
{ date: '2026-12-19', value: 1 },
{ date: '2026-12-20', value: 1 },
{ date: '2026-12-21', value: 2 }
]
});
expect(new Date(min).toDateString()).toBe( expect(items.map(({ date }) => date)).toEqual(['2026-12-18', '2026-12-21']);
new Date(2026, 0, 1).toDateString()
);
expect(new Date(max).toDateString()).toBe(
new Date(2026, 11, 31).toDateString()
);
}); });
it('spans the whole current month for mtd', () => { it('adds the remaining trading days of the year', () => {
const { max, min } = getTimeAxisBounds('mtd', reference); const items = getTradingDayItems({
dateRange: 'ytd',
now,
items: [{ date: '2026-12-24', value: 1 }]
});
expect(new Date(min).getDate()).toBe(1); expect(items).toEqual([
expect(new Date(max).getDate()).toBe(31); { date: '2026-12-24', value: 1 },
{ date: '2026-12-25', value: null },
{ date: '2026-12-28', value: null },
{ date: '2026-12-29', value: null },
{ date: '2026-12-30', value: null },
{ date: '2026-12-31', value: null }
]);
}); });
});
it('spans the whole current week for wtd', () => { describe('getTimeSeriesTickCallback', () => {
const { max, min } = getTimeAxisBounds('wtd', reference); it('labels the first point of each hour', () => {
const ticks = [
new Date(2026, 9, 22, 9, 0),
new Date(2026, 9, 22, 9, 30),
new Date(2026, 9, 22, 10, 0),
new Date(2026, 9, 22, 10, 30)
].map((date) => {
return { value: date.getTime() };
});
expect(new Date(min).getDay()).toBe(1); const callback = getTimeSeriesTickCallback('hour');
expect(new Date(max).getDay()).toBe(0);
expect(
ticks.map(({ value }, index) => {
return callback(value, index, ticks);
})
).toEqual(['09:00', '', '10:00', '']);
}); });
it('spans the whole current day for 1d', () => { it('labels the start of each trading day, also after a post-market past midnight', () => {
const { max, min } = getTimeAxisBounds('1d', reference); const ticks = [
new Date(2026, 9, 22, 21, 0),
new Date(2026, 9, 22, 23, 0),
new Date(2026, 9, 23, 1, 0),
new Date(2026, 9, 23, 9, 0)
].map((date) => {
return { value: date.getTime() };
});
const callback = getTimeSeriesTickCallback('day');
expect(new Date(min).getHours()).toBe(0); expect(
expect(new Date(max).getHours()).toBe(23); ticks.map(({ value }, index) => {
return callback(value, index, ticks);
})
).toEqual(['Thu 22', '', '', 'Fri 23']);
}); });
it('spans a past year', () => { it('thins out the labels', () => {
const { max, min } = getTimeAxisBounds('2024', reference); const ticks = Array.from({ length: 20 }, (_, index) => {
return { value: new Date(2026, 0, 1 + index).getTime() };
});
expect(new Date(min).getFullYear()).toBe(2024); const callback = getTimeSeriesTickCallback('day', 5);
expect(new Date(max).getMonth()).toBe(11); const labels = ticks
.map(({ value }, index) => {
return callback(value, index, ticks);
})
.filter(Boolean);
expect(labels.length).toBeLessThanOrEqual(5);
}); });
});
describe('getTradingDayItems with many items', () => {
it('keeps one point per week (the last trading day)', () => {
const items = Array.from({ length: 28 }, (_, index) => {
return {
date: format(addDays(new Date(2026, 0, 5), index), DATE_FORMAT),
value: index
};
});
const result = getTradingDayItems({ dateRange: '1y', items, maxItems: 10 });
it('does not fix the bounds of rolling ranges', () => { expect(
expect(getTimeAxisBounds('1y', reference)).toEqual({}); result.map(({ date }) => {
expect(getTimeAxisBounds('5y', reference)).toEqual({}); return date;
expect(getTimeAxisBounds('max', reference)).toEqual({}); })
).toEqual([
'2026-01-05',
'2026-01-09',
'2026-01-16',
'2026-01-23',
'2026-01-30'
]);
}); });
}); });

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

@ -12,18 +12,9 @@ import type {
TooltipPosition TooltipPosition
} from 'chart.js'; } from 'chart.js';
import type { AnnotationOptions } from 'chartjs-plugin-annotation'; import type { AnnotationOptions } from 'chartjs-plugin-annotation';
import { import { addDays, format, startOfWeek } from 'date-fns';
endOfDay,
endOfMonth,
endOfWeek,
endOfYear,
format,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear
} from 'date-fns';
import { secondaryColorRgb } from './config';
import { import {
DATE_FORMAT, DATE_FORMAT,
DATE_FORMAT_MONTHLY, DATE_FORMAT_MONTHLY,
@ -31,7 +22,8 @@ import {
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
getLocale, getLocale,
getTextColor getTextColor,
parseDate
} from './helper'; } from './helper';
import { ColorScheme, DateRange, GroupBy } from './types'; import { ColorScheme, DateRange, GroupBy } from './types';
@ -51,6 +43,21 @@ export function formatGroupedDate({
return format(date, DATE_FORMAT); return format(date, DATE_FORMAT);
} }
// Colors for the lines of the compared benchmarks, the portfolio uses the
// primary color
const BENCHMARK_COLORS = [
`rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
'rgb(255, 159, 64)',
'rgb(75, 192, 112)',
'rgb(153, 102, 255)',
'rgb(255, 99, 132)',
'rgb(201, 203, 207)'
];
export function getBenchmarkColor(index: number) {
return BENCHMARK_COLORS[index % BENCHMARK_COLORS.length];
}
export function getChartBorderColor(colorScheme?: ColorScheme) { export function getChartBorderColor(colorScheme?: ColorScheme) {
return `rgba(${getTextColor(colorScheme)}, 0.1)`; return `rgba(${getTextColor(colorScheme)}, 0.1)`;
} }
@ -75,69 +82,196 @@ export function getChartElementsOptions(
* length of the date range: days up to a month, months up to a year and years * length of the date range: days up to a month, months up to a year and years
* above * above
*/ */
/**
* Returns the daily items of the trading days (Monday to Friday) and, for a
* date range with a fixed end (the current year), the remaining trading days
* without a value, so that the chart shows the whole period
*/
function getWeekKey(aDate?: Date) {
return aDate
? format(startOfWeek(aDate, { weekStartsOn: 1 }), DATE_FORMAT)
: '';
}
export function getTradingDayItems<T extends { date: string }>({
dateRange,
items,
maxItems = 400,
now = new Date()
}: {
dateRange?: DateRange;
items: T[];
/** Above, one point per week (the last trading day) is kept */
maxItems?: number;
now?: Date;
}): (T | { date: string; value: null })[] {
const isWeekend = (aDate: Date) => {
return aDate.getDay() === 0 || aDate.getDay() === 6;
};
const result: (T | { date: string; value: null })[] = items.filter(
({ date }) => {
const parsedDate = parseDate(date);
return !parsedDate || !isWeekend(parsedDate);
}
);
if (result.length > maxItems) {
const weekly = result.filter(({ date }, index) => {
const next = result[index + 1];
return (
index === 0 ||
!next ||
getWeekKey(parseDate(date)) !== getWeekKey(parseDate(next.date))
);
});
result.splice(0, result.length, ...weekly);
}
let end: Date | undefined;
let start: Date | undefined;
if (dateRange === 'ytd') {
end = new Date(now.getFullYear(), 11, 31);
start = new Date(now.getFullYear(), 0, 1);
} else if (/^\d{4}$/.test(dateRange ?? '')) {
end = new Date(Number(dateRange), 11, 31);
start = new Date(Number(dateRange), 0, 1);
}
const firstDate = result[0] ? parseDate(result[0].date) : undefined;
if (start && firstDate && firstDate < start) {
// The baseline (the close of the previous year) is shown at the start
result[0] = { ...result[0], date: format(start, DATE_FORMAT) };
}
const lastDate = items.at(-1)?.date;
const last = lastDate ? parseDate(lastDate) : undefined;
if (end && last) {
for (
let date = addDays(last, 1);
date.getTime() <= end.getTime();
date = addDays(date, 1)
) {
if (!isWeekend(date)) {
result.push({ date: format(date, DATE_FORMAT), value: null });
}
}
}
return result;
}
export function getTimeAxisUnit( export function getTimeAxisUnit(
aDateRange?: DateRange aDateRange?: DateRange
): 'day' | 'hour' | 'month' | 'year' { ): 'day' | 'hour' | 'month' | 'year' | false {
switch (aDateRange) { switch (aDateRange) {
case undefined:
return 'year';
case '1d': case '1d':
return 'hour'; return 'hour';
case 'mtd': case 'mtd':
case 'wtd': case 'wtd':
return 'day'; return 'day';
case '1y':
case '5y': case '5y':
case 'max': case 'max':
case undefined: // Chosen by the chart according to the length of the period
return 'year'; return false;
default: default:
// 'ytd', '1y' and single years // 'ytd' and single years
return 'month'; return 'month';
} }
} }
const TIME_SERIES_LABEL_FORMATS = {
day: 'EEE d',
hour: 'HH:mm',
month: 'MMM',
year: 'yyyy'
} as const;
/** /**
* Returns the bounds of the time axis for the date ranges which have a fixed * Returns the labels of an axis with equidistant points (without the closed
* end (the current week, month or year), so that the chart shows the whole * markets): a label at the first point of a period (hour, day, month or
* period with the part still to come left empty. Other date ranges (rolling * year), thinned out to at most a few labels
* periods and `max`) end at the latest data point.
*/ */
export function getTimeAxisBounds( export function getTimeSeriesTickCallback(
aDateRange?: DateRange, aUnit: 'day' | 'hour' | 'month' | 'year' | false,
aReferenceDate = new Date() aMaxLabels = 7
): { max?: number; min?: number } { ) {
switch (aDateRange) { let cache: { key: string; labels: Map<number, string> } | undefined;
case '1d':
return { return (
max: endOfDay(aReferenceDate).getTime(), _value: number | string,
min: startOfDay(aReferenceDate).getTime() index: number,
}; ticks: { value: number }[]
case 'wtd': ) => {
return { const key = `${ticks.length}-${ticks[0]?.value}-${ticks.at(-1)?.value}`;
max: endOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime(),
min: startOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime() if (cache?.key !== key) {
}; const span = (ticks.at(-1)?.value ?? 0) - (ticks[0]?.value ?? 0);
case 'mtd': const unit =
return { aUnit ||
max: endOfMonth(aReferenceDate).getTime(), (span > 3 * 365 * 24 * 60 * 60 * 1000
min: startOfMonth(aReferenceDate).getTime() ? 'year'
}; : span > 45 * 24 * 60 * 60 * 1000
case 'ytd': ? 'month'
return { : 'day');
max: endOfYear(aReferenceDate).getTime(),
min: startOfYear(aReferenceDate).getTime() const getPeriod = (aTime: number) => {
const date = new Date(aTime);
switch (unit) {
case 'hour':
return `${date.toDateString()} ${date.getHours()}`;
case 'month':
return `${date.getFullYear()}-${date.getMonth()}`;
default:
return `${date.getFullYear()}`;
}
}; };
default: {
if (typeof aDateRange === 'string' && /^\d{4}$/.test(aDateRange)) {
const year = new Date(Number(aDateRange), 0, 1);
return {
max: endOfYear(year).getTime(),
min: startOfYear(year).getTime()
};
}
return {}; // The first point of each period
const starts: number[] = [];
ticks.forEach(({ value }, tickIndex) => {
const previous = ticks[tickIndex - 1]?.value;
if (
tickIndex === 0 ||
(unit === 'day'
? // A trading day starts after the closed market (the post-market
// can last after midnight)
value - previous > 2 * 60 * 60 * 1000
: getPeriod(value) !== getPeriod(previous))
) {
starts.push(tickIndex);
}
});
const step = Math.max(1, Math.ceil(starts.length / aMaxLabels));
const labels = new Map<number, string>();
starts.forEach((tickIndex, startIndex) => {
if (startIndex % step === 0) {
labels.set(
tickIndex,
format(ticks[tickIndex].value, TIME_SERIES_LABEL_FORMATS[unit])
);
}
});
cache = { key, labels };
} }
}
return cache.labels.get(index) ?? '';
};
} }
export function getTimeAxisOptions({ export function getTimeAxisOptions({
@ -145,18 +279,19 @@ export function getTimeAxisOptions({
colorScheme, colorScheme,
display = true, display = true,
locale = getLocale(), locale = getLocale(),
max, type = 'time',
min, unit = 'year',
unit = 'year' withTime = false
}: { }: {
borderWidth?: number; borderWidth?: number;
colorScheme?: ColorScheme; colorScheme?: ColorScheme;
display?: boolean; display?: boolean;
locale?: string; locale?: string;
max?: number; type?: 'time' | 'timeseries';
min?: number; unit?: 'day' | 'hour' | 'month' | 'year' | false;
unit?: 'day' | 'hour' | 'month' | 'year'; /** Whether the points are intraday (the tooltip shows the time) */
}): ScaleOptions<'time'> { withTime?: boolean;
}): ScaleOptions<'time' | 'timeseries'> {
return { return {
display, display,
border: { border: {
@ -166,18 +301,36 @@ export function getTimeAxisOptions({
grid: { grid: {
display: false display: false
}, },
max, ticks:
min, type === 'timeseries'
ticks: { ? {
autoSkip: true, // A label at the first point of an hour, day, month or year
maxRotation: 0, autoSkip: false,
maxTicksLimit: 6 callback: getTimeSeriesTickCallback(unit),
}, maxRotation: 0,
source: 'data'
}
: {
autoSkip: true,
maxRotation: 0,
maxTicksLimit: 6
},
time: { time: {
tooltipFormat: unit === 'hour' ? 'HH:mm' : getDateFormatString(locale), tooltipFormat: withTime
? unit === 'hour'
? 'HH:mm'
: `${getDateFormatString(locale)} HH:mm`
: getDateFormatString(locale),
displayFormats: {
day: 'EEE d',
hour: 'HH:mm',
month: unit === 'month' ? 'MMM' : 'MMM yy',
week: 'd MMM',
year: 'yyyy'
},
unit unit
}, },
type: 'time' type
}; };
} }

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

@ -1,6 +1,5 @@
import { import {
getChartBorderColor, getChartBorderColor,
getTimeAxisBounds,
getTimeAxisOptions, getTimeAxisOptions,
getTimeAxisUnit, getTimeAxisUnit,
getValueAxisOptions, getValueAxisOptions,
@ -8,10 +7,7 @@ import {
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper'; import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
import { import { NullableLineChartItem } from '@ghostfolio/common/interfaces';
LineChartItem,
NullableLineChartItem
} from '@ghostfolio/common/interfaces';
import { ColorScheme, DateRange } from '@ghostfolio/common/types'; import { ColorScheme, DateRange } from '@ghostfolio/common/types';
import { import {
@ -36,6 +32,7 @@ import {
LineElement, LineElement,
PointElement, PointElement,
TimeScale, TimeScale,
TimeSeriesScale,
Tooltip, Tooltip,
type TooltipOptions type TooltipOptions
} from 'chart.js'; } from 'chart.js';
@ -63,7 +60,7 @@ export class GfLineChartComponent
@Input() colorScheme?: ColorScheme; @Input() colorScheme?: ColorScheme;
@Input() currency?: string; @Input() currency?: string;
@Input() dateRange?: DateRange; @Input() dateRange?: DateRange;
@Input() historicalDataItems: LineChartItem[] | null; @Input() historicalDataItems: NullableLineChartItem[] | null;
@Input() isAnimated = false; @Input() isAnimated = false;
@Input() label: string; @Input() label: string;
@Input() locale = getLocale(); @Input() locale = getLocale();
@ -93,6 +90,7 @@ export class GfLineChartComponent
PointElement, PointElement,
LinearScale, LinearScale,
TimeScale, TimeScale,
TimeSeriesScale,
Tooltip Tooltip
); );
@ -143,7 +141,7 @@ export class GfLineChartComponent
this.isLoading = true; this.isLoading = true;
const benchmarkPrices: (number | null)[] = []; const benchmarkPrices: (number | null)[] = [];
const labels: string[] = []; const labels: string[] = [];
const marketPrices: number[] = []; const marketPrices: (number | null)[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => { this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null); benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null);
@ -295,7 +293,8 @@ export class GfLineChartComponent
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
display: this.showXAxis, display: this.showXAxis,
locale: this.locale, locale: this.locale,
...getTimeAxisBounds(this.dateRange), // Equidistant points: the closed markets (nights, weekends) are skipped
type: this.dateRange ? 'timeseries' : 'time',
unit: getTimeAxisUnit(this.dateRange) unit: getTimeAxisUnit(this.dateRange)
}); });
} }

Loading…
Cancel
Save