Загрузка данных
import {
ColorType,
createChart,
createSeriesMarkers,
CrosshairMode,
type IChartApi,
type ISeriesApi,
type ISeriesMarkersPluginApi,
LineSeries,
LineStyle,
type MouseEventParams,
PriceScaleMode,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject } from 'rxjs';
import { createInitialSeasonalityState } from '@src/constants';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { getThemeStore } from '@src/theme/store';
import { getLocale, t } from '@src/translations';
import { ChartViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { canvasToBlob, drawTextLayer, formatCompactNumber } from '@src/utils';
import {
buildSeasonality,
normalizeRange,
toSeriesData,
} from './seasonality.utils';
import type { DataSourceParams } from '@src/core/DataSource';
import type {
Candle,
ChartTypeOptions,
DailyClose,
SeasonalityRange,
SeasonalityScale,
SeasonalityYear,
} from '@src/types';
interface SeasonalityManagerParams {
container: HTMLElement;
getData: DataSourceParams['getData'];
precision?: number;
chartOptions?: ChartTypeOptions;
onOpen: () => void;
onBack: () => void;
}
interface SeasonalityPlot {
year: SeasonalityYear;
series: ISeriesApi<'Line'>;
legend: HTMLSpanElement;
legendValue: HTMLSpanElement;
markers?: ISeriesMarkersPluginApi<Time>;
}
const DEFAULT_YEARS = 5;
const MAX_HISTORY_PAGES = 1000;
export class SeasonalityManager {
private readonly currentYear = new Date().getUTCFullYear();
private readonly state = new BehaviorSubject(createInitialSeasonalityState());
private readonly chartContainer = document.createElement('div');
private readonly legend = document.createElement('div');
private readonly statusElement = document.createElement('div');
private readonly plots = new Map<number, SeasonalityPlot>();
private chart: IChartApi | null = null;
private allYears: SeasonalityYear[] = [];
private requestId = 0;
private renderFrame: number | null = null;
private destroyed = false;
constructor(private readonly params: SeasonalityManagerParams) {
Object.assign(this.chartContainer.style, {
position: 'absolute',
inset: '0',
});
Object.assign(this.legend.style, {
position: 'absolute',
top: 'var(--space-0500)',
left: 'var(--space-0500)',
width: 'max-content',
display: 'grid',
gridTemplateColumns: 'max-content max-content',
justifyContent: 'start',
alignItems: 'center',
gap: 'var(--space-0375) var(--space-1000)',
pointerEvents: 'none',
fontSize: 'var(--space-0750)',
fontVariantNumeric: 'tabular-nums',
zIndex: '2',
});
Object.assign(this.statusElement.style, {
position: 'absolute',
inset: '0',
display: 'none',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--space-1000)',
textAlign: 'center',
pointerEvents: 'none',
});
params.container.append(
this.chartContainer,
this.legend,
this.statusElement,
);
this.updateTheme();
}
public getState() {
return this.state.asObservable();
}
public isActive(): boolean {
return this.state.value.view === ChartViewType.Seasonality;
}
public async show(
symbolId: string,
range?: SeasonalityRange,
): Promise<void> {
if (this.destroyed || this.reuseHistory(symbolId, range)) {
return;
}
const requestId = ++this.requestId;
this.reset();
if (!this.isActive()) {
this.params.onOpen();
}
this.showStatus(t('Loading seasonality'));
this.state.next({
...this.state.value,
symbolId,
view: ChartViewType.Seasonality,
status: 'loading',
error: null,
});
try {
if (!symbolId) {
throw new Error('Select an instrument');
}
const rows = await this.loadHistory(symbolId, requestId);
if (!this.isCurrent(requestId)) {
return;
}
this.allYears = buildSeasonality(rows);
this.setInitialRange(range);
} catch (error) {
if (!this.isCurrent(requestId)) {
return;
}
this.reset();
this.showStatus(t('Unable to load seasonality'));
this.state.next({
...this.state.value,
status: 'error',
error: String(error),
});
console.error('[Seasonality] History loading failed:', error);
}
}
public setRange = (range: SeasonalityRange): void => {
if (
this.destroyed ||
!this.isActive() ||
this.allYears.length === 0
) {
return;
}
const state = this.state.value;
const next = normalizeRange(range, state.minYear, state.maxYear);
if (
next.fromYear === state.fromYear &&
next.toYear === state.toYear
) {
return;
}
this.state.next({ ...state, ...next });
if (this.renderFrame !== null) {
return;
}
this.renderFrame = requestAnimationFrame(() => {
this.renderFrame = null;
if (!this.destroyed && this.isActive()) {
this.renderRange();
}
});
};
public setScale = (scale: SeasonalityScale): void => {
if (
this.destroyed ||
!this.isActive() ||
this.state.value.scale === scale
) {
return;
}
this.state.next({ ...this.state.value, scale });
if (!this.chart) {
return;
}
this.plots.forEach((plot) => this.updateSeries(plot));
this.fitCalendar();
this.updateStatus();
this.updateLegend();
};
public back = (): void => {
if (this.destroyed || !this.isActive()) {
return;
}
this.requestId += 1;
this.reset();
this.state.next({
...this.state.value,
view: ChartViewType.Chart,
status: 'idle',
error: null,
});
this.params.onBack();
};
public updateTheme(): void {
const { colors } = getThemeStore();
Object.assign(this.params.container.style, {
backgroundColor: colors.chartBackground,
});
Object.assign(this.statusElement.style, {
color: colors.chartTextPrimary,
});
this.legend.style.color = colors.chartTextPrimary;
this.chart?.applyOptions(
getThemeOptions(this.params.chartOptions),
);
}
public async copyImage(): Promise<void> {
if (!this.chart || this.state.value.status !== 'ready') {
return;
}
const canvas = this.chart.takeScreenshot(true, false);
drawTextLayer(canvas, this.legend, this.chart.chartElement());
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
}
public destroy(): void {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.requestId += 1;
this.reset();
this.chartContainer.remove();
this.legend.remove();
this.statusElement.remove();
this.state.complete();
}
private isCurrent(requestId: number): boolean {
return !this.destroyed && requestId === this.requestId;
}
private reuseHistory(
symbolId: string,
range?: SeasonalityRange,
): boolean {
const state = this.state.value;
if (!this.isActive() || state.symbolId !== symbolId) {
return false;
}
if (state.status === 'loading') {
return true;
}
if (this.allYears.length === 0) {
return false;
}
if (range) {
this.setRange(range);
}
return true;
}
private async loadHistory(
symbolId: string,
requestId: number,
): Promise<DailyClose[]> {
const candles = new Map<number, Candle>();
let cursor: Candle | undefined;
for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
if (!this.isCurrent(requestId)) {
return [];
}
const loaded = await this.params.getData(
Timeframes['1d'],
symbolId,
cursor,
);
if (!this.isCurrent(requestId)) {
return [];
}
if (!loaded?.length) {
return Array.from(candles.values(), (candle) => ({
date: new Date(candle.time * 1000).toISOString().slice(0, 10),
close: candle.close,
}));
}
const oldest = mergeHistoryPage(loaded, candles);
if (cursor && oldest.time >= cursor.time) {
throw new Error('History pagination made no progress');
}
cursor = oldest;
}
throw new Error('History page limit exceeded');
}
private setInitialRange(range?: SeasonalityRange): void {
if (this.allYears.length === 0) {
this.showStatus(t('No seasonality data'));
this.state.next({
...this.state.value,
status: 'empty',
});
return;
}
const minYear = this.allYears[0].year;
const maxYear = this.allYears[this.allYears.length - 1].year;
const selected = normalizeRange(
range ?? {
fromYear: maxYear - DEFAULT_YEARS + 1,
toYear: maxYear,
},
minYear,
maxYear,
);
this.state.next({
...this.state.value,
...selected,
minYear,
maxYear,
});
this.renderRange();
}
private createChart(): IChartApi {
const chart = createChart(this.chartContainer, {
...getThemeOptions(this.params.chartOptions),
autoSize: true,
handleScroll: false,
handleScale: false,
kineticScroll: {
mouse: false,
touch: false,
},
rightPriceScale: {
mode: PriceScaleMode.Normal,
autoScale: true,
alignLabels: true,
visible: true,
borderVisible: false,
},
leftPriceScale: {
visible: false,
},
timeScale: {
timeVisible: false,
secondsVisible: false,
borderVisible: false,
allowBoldLabels: false,
rightOffset: 0,
tickMarkFormatter: formatSeasonDate,
lockVisibleTimeRangeOnResize: true,
},
localization: {
locale: getLocale(),
timeFormatter: formatSeasonDate,
},
});
chart.subscribeCrosshairMove(this.onCrosshairMove);
this.chart = chart;
return chart;
}
private renderRange(): void {
const chart = this.chart ?? this.createChart();
const { fromYear, toYear } = this.state.value;
const years = this.allYears.filter(
(year) => year.year >= fromYear && year.year <= toYear,
);
this.plots.forEach((plot, year) => {
if (year < fromYear || year > toYear) {
plot.markers?.detach();
chart.removeSeries(plot.series);
this.plots.delete(year);
}
});
const plots = years.map((year) => {
const existing = this.plots.get(year.year);
if (existing) {
return existing;
}
const plot = this.createPlot(chart, year);
this.plots.set(year.year, plot);
return plot;
});
this.legend.replaceChildren(
...plots.flatMap((plot) => [
plot.legend,
plot.legendValue,
]),
);
if (plots.length > 0) {
this.fitCalendar();
}
this.updateStatus();
this.updateLegend();
this.state.next({
...this.state.value,
status: plots.length > 0 ? 'ready' : 'empty',
});
}
private fitCalendar(): void {
this.chart?.timeScale().setVisibleRange({
from: '2000-01-01',
to: '2000-12-31',
});
}
private createPlot(
chart: IChartApi,
year: SeasonalityYear,
): SeasonalityPlot {
const color = getYearColor(year.year);
const legend = document.createElement('span');
const legendValue = document.createElement('span');
legend.textContent = String(year.year);
Object.assign(legendValue.style, {
color,
textAlign: 'right',
});
const plot: SeasonalityPlot = {
year,
legend,
legendValue,
series: chart.addSeries(LineSeries, {
title: String(year.year),
color,
priceLineVisible: false,
lastValueVisible: true,
lineWidth: year.year === this.currentYear ? 3 : 1,
pointMarkersVisible: false,
crosshairMarkerVisible: true,
}),
};
if (year.year === this.currentYear) {
plot.markers = createSeriesMarkers(plot.series, [], {
autoScale: false,
});
}
this.updateSeries(plot);
return plot;
}
private updateSeries({
series,
year,
markers,
}: SeasonalityPlot): void {
const { scale } = this.state.value;
const isPercentage = scale === PriceScaleMode.Percentage;
series.applyOptions({
priceFormat: {
type: 'custom',
minMove: isPercentage
? 0.01
: 10 ** -(this.params.precision ?? 2),
formatter: (value: number) =>
isPercentage
? `${value.toFixed(2)}%`
: formatCompactNumber(value),
},
});
series.setData(toSeriesData(year, scale));
if (markers) {
const lastPoint = year.points[year.points.length - 1];
const value = isPercentage
? lastPoint.percentage
: lastPoint.price;
markers.setMarkers(
value === null
? []
: [
{
time: lastPoint.chartDate,
position: 'inBar',
shape: 'circle',
color: getYearColor(year.year),
size: 1,
},
],
);
}
}
private updateStatus(): void {
if (this.plots.size === 0) {
this.showStatus(t('No seasonality data'));
return;
}
const isPercentage =
this.state.value.scale === PriceScaleMode.Percentage;
const hasBaseline = Array.from(this.plots.values()).some(
(plot) => plot.year.baselinePrice > 0,
);
if (isPercentage && !hasBaseline) {
this.showStatus(t('Percentage requires a positive baseline'));
return;
}
this.statusElement.style.display = 'none';
}
private onCrosshairMove = (event: MouseEventParams): void => {
this.updateLegend(event);
};
private updateLegend(event?: MouseEventParams): void {
const isPercentage =
this.state.value.scale === PriceScaleMode.Percentage;
this.plots.forEach(({ year, series, legendValue }) => {
const lastPoint = year.points[year.points.length - 1];
let value = isPercentage
? lastPoint.percentage
: lastPoint.price;
if (event?.time !== undefined) {
const point = event.seriesData.get(series);
value =
point && 'value' in point && typeof point.value === 'number'
? point.value
: null;
}
legendValue.textContent =
value === null
? '—'
: series.priceFormatter().format(value);
});
}
private showStatus(message: string): void {
this.statusElement.textContent = message;
this.statusElement.style.display = 'flex';
}
private reset(): void {
if (this.renderFrame !== null) {
cancelAnimationFrame(this.renderFrame);
this.renderFrame = null;
}
this.chart?.unsubscribeCrosshairMove(this.onCrosshairMove);
this.plots.forEach((plot) => plot.markers?.detach());
this.chart?.remove();
this.chart = null;
this.allYears = [];
this.plots.clear();
this.legend.replaceChildren();
this.statusElement.style.display = 'none';
}
}
function mergeHistoryPage(
loaded: Candle[],
candles: Map<number, Candle>,
): Candle {
let oldest = loaded[0];
let oldestTime = Infinity;
for (let index = loaded.length - 1; index >= 0; index -= 1) {
const candle = loaded[index];
if (
!Number.isFinite(candle.time) ||
!Number.isFinite(candle.close)
) {
throw new Error('Invalid daily candle');
}
const time = Math.floor(
candle.time > 1e10 ? candle.time / 1000 : candle.time,
);
const normalized =
time === candle.time ? candle : { ...candle, time };
if (time < oldestTime) {
oldest = normalized;
oldestTime = time;
}
if (!candles.has(time)) {
candles.set(time, normalized);
}
}
return oldest;
}
function getThemeOptions(options: ChartTypeOptions = {}) {
const { colors } = getThemeStore();
return {
layout: {
background: {
type: ColorType.Solid,
color: colors.chartBackground,
},
textColor: colors.chartTextPrimary,
panes: {
separatorColor: colors.chartPaneSeparator,
separatorHoverColor: colors.chartPaneSeparator,
enableResize: false,
},
},
grid: {
vertLines: {
color: colors.chartGridLine,
},
horzLines: {
color: colors.chartGridLine,
},
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color:
options.crosshairLineXColor ?? colors.chartCrosshairLine,
labelBackgroundColor:
options.crosshairLabelXBgColor ?? colors.chartCrosshairLabel,
style: LineStyle.Solid,
},
horzLine: {
color:
options.crosshairLineYColor ?? colors.chartCrosshairLine,
labelBackgroundColor:
options.crosshairLabelYBgColor ?? colors.chartCrosshairLabel,
style: LineStyle.Dashed,
},
},
};
}
function formatSeasonDate(time: Time): string {
if (typeof time === 'object') {
const day = String(time.day).padStart(2, '0');
const month = String(time.month).padStart(2, '0');
return `${day}.${month}`;
}
const date =
typeof time === 'number'
? new Date(time * 1000).toISOString()
: time;
return `${date.slice(8, 10)}.${date.slice(5, 7)}`;
}
function getYearColor(year: number): string {
return COMPARE_COLOR_PALETTE[
year % COMPARE_COLOR_PALETTE.length
];
}
export { buildSeasonality, normalizeRange, toSeriesData };
import {
PriceScaleMode,
type LineData,
type WhitespaceData,
} from 'lightweight-charts';
import type {
DailyClose,
SeasonalityRange,
SeasonalityScale,
SeasonalityYear,
} from '@src/types';
type SeriesDatum = LineData | WhitespaceData;
const CALENDAR = Array.from({ length: 366 }, (_, day) =>
new Date(Date.UTC(2000, 0, day + 1)).toISOString().slice(0, 10),
);
export function normalizeRange(
range: SeasonalityRange,
minYear: number,
maxYear: number,
): SeasonalityRange {
if (
!Number.isFinite(range.fromYear) ||
!Number.isFinite(range.toYear)
) {
throw new Error('Invalid year range');
}
const fromYear = Math.max(
minYear,
Math.min(maxYear, Math.round(range.fromYear)),
);
const toYear = Math.max(
fromYear,
Math.min(maxYear, Math.round(range.toYear)),
);
return { fromYear, toYear };
}
export function buildSeasonality(
rows: readonly DailyClose[],
): SeasonalityYear[] {
const years: SeasonalityYear[] = [];
const sorted = [...rows].sort(
(a, b) => a.date.localeCompare(b.date),
);
for (const row of sorted) {
const year = Number(row.date.slice(0, 4));
let current = years[years.length - 1];
if (!current || current.year !== year) {
current = {
year,
baselineDate: row.date,
baselinePrice: row.close,
points: [],
};
years.push(current);
}
if (current.points[current.points.length - 1]?.date === row.date) {
throw new Error(`Multiple daily candles on ${row.date}`);
}
const percentage =
current.baselinePrice > 0
? (row.close / current.baselinePrice - 1) * 100
: null;
if (percentage !== null && !Number.isFinite(percentage)) {
throw new Error('Invalid percentage');
}
current.points.push({
date: row.date,
chartDate: `2000-${row.date.slice(5)}`,
price: row.close,
percentage,
});
}
return years;
}
export function toSeriesData(
year: SeasonalityYear,
scale: SeasonalityScale,
): SeriesDatum[] {
const isPercentage = scale === PriceScaleMode.Percentage;
let index = 0;
return CALENDAR.map((time) => {
const point = year.points[index];
if (point?.chartDate !== time) {
return { time };
}
index += 1;
const value = isPercentage ? point.percentage : point.price;
return value === null ? { time } : { time, value };
});
}