Загрузка данных
<SeasonalityControls
stateObs={this.seasonalityManager.getState()}
setDisplayMode={this.seasonalityManager.setDisplayMode}
setScale={this.seasonalityManager.setScale}
setRange={this.seasonalityManager.setRange}
setAverageVisible={this.seasonalityManager.setAverageVisible}
copyImage={
config.chartCollectionPreset.showSnapshotButton
? () => this.seasonalityManager.copyImage()
: undefined
}
/>
import { type LineData, PriceScaleMode, type WhitespaceData } from 'lightweight-charts';
import type {
DailyClose,
SeasonalityChange,
SeasonalityRange,
SeasonalityScale,
SeasonalityTableRow,
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),
);
const ISO_DATE_MONTH_START = 5;
const ISO_DATE_MONTH_END = 7;
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 };
});
}
export function buildSeasonalityAverage(
years: readonly SeasonalityYear[],
scale: SeasonalityScale,
): SeriesDatum[] {
const values = new Map<string, number[]>();
const isPercentage = scale === PriceScaleMode.Percentage;
for (const year of years) {
for (const point of year.points) {
const value = isPercentage ? point.percentage : point.price;
if (value === null) {
continue;
}
const dailyValues = values.get(point.chartDate);
if (dailyValues) {
dailyValues.push(value);
} else {
values.set(point.chartDate, [value]);
}
}
}
return CALENDAR.map((time) => {
const value = calculateAverage(values.get(time) ?? []);
return value === null ? { time } : { time, value };
});
}
export function buildSeasonalityTable(years: readonly SeasonalityYear[]): SeasonalityTableRow[] {
const closes = new Map<number, number>();
for (const year of years) {
for (const point of year.points) {
const month = Number(point.date.slice(ISO_DATE_MONTH_START, ISO_DATE_MONTH_END)) - 1;
closes.set(year.year * 12 + month, point.price);
}
}
return years
.map((year) => {
const firstMonth = year.year * 12;
const baselineMonth = Number(year.baselineDate.slice(ISO_DATE_MONTH_START, ISO_DATE_MONTH_END)) - 1;
const lastPoint = year.points[year.points.length - 1];
const baselinePrice = closes.get(firstMonth - 1) ?? year.baselinePrice;
return {
year: year.year,
months: Array.from({ length: 12 }, (_, month) => {
const key = firstMonth + month;
const fromPrice = closes.get(key - 1) ?? (month === baselineMonth ? year.baselinePrice : undefined);
return calculateChange(fromPrice, closes.get(key));
}),
total: calculateChange(baselinePrice, lastPoint.price),
};
})
.reverse();
}
function calculateChange(from: number | undefined, to: number | undefined): SeasonalityChange | null {
if (from === undefined || to === undefined) {
return null;
}
return {
absolute: to - from,
percentage: from > 0 ? (to / from - 1) * 100 : null,
};
}
export function buildSeasonalityTableAverage(
rows: readonly SeasonalityTableRow[],
): Pick<SeasonalityTableRow, 'months' | 'total'> | null {
if (rows.length === 0) {
return null;
}
return {
months: Array.from({ length: 12 }, (_, month) => averageChanges(rows.map((row) => row.months[month]))),
total: averageChanges(rows.map((row) => row.total)),
};
}
function averageChanges(
changes: readonly (SeasonalityChange | null | undefined)[],
): SeasonalityChange | null {
const available = changes.filter(
(change): change is SeasonalityChange => change !== null && change !== undefined,
);
if (available.length === 0) {
return null;
}
return {
absolute: available.reduce((sum, change) => sum + change.absolute, 0) / available.length,
percentage: calculateAverage(available.map((change) => change.percentage)),
};
}
function calculateAverage(values: readonly (number | null)[]): number | null {
let sum = 0;
let count = 0;
for (const value of values) {
if (value !== null) {
sum += value;
count += 1;
}
}
return count === 0 ? null : sum / count;
}
import dayjs from 'dayjs';
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, DEFAULT_SEASONALITY_YEARS } from '@src/constants';
import { ZIndex } from '@src/core/ContainerManager';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { getThemeStore } from '@src/theme/store';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { buildSeasonalityAverage } from '@src/utils/seasonality';
import {
buildSeasonality,
buildSeasonalityTable,
canvasToBlob,
drawTextLayer,
formatCompactNumber,
formatPercent,
getPercentPrecisionStep,
getPricePrecisionStep,
normalizeRange,
toSeriesData,
} from '@src/utils';
import type { DataSourceParams } from '@src/core/DataSource';
import type {
Candle,
DailyClose,
SeasonalityRange,
SeasonalityScale,
SeasonalityTableRow,
SeasonalityYear,
} from '@src/types';
interface SeasonalityManagerParams {
container: HTMLElement;
getData: DataSourceParams['getData'];
}
interface SeasonalityPlot {
year: SeasonalityYear;
series: ISeriesApi<'Line'>;
legend: HTMLSpanElement;
legendValue: HTMLSpanElement;
markers?: ISeriesMarkersPluginApi<Time>;
}
interface SeasonalityAveragePlot {
series: ISeriesApi<'Line'>;
legend: HTMLSpanElement;
legendValue: HTMLSpanElement;
data: ReturnType<typeof buildSeasonalityAverage>;
}
export class SeasonalityManager {
private readonly currentYear = dayjs().year();
private readonly state = new BehaviorSubject(createInitialSeasonalityState());
private readonly chartContainer: HTMLDivElement;
private readonly legend: HTMLDivElement;
private readonly statusElement: HTMLDivElement;
private readonly plots = new Map<number, SeasonalityPlot>();
private chart: IChartApi | null = null;
private averagePlot: SeasonalityAveragePlot | null = null;
private allYears: SeasonalityYear[] = [];
private tableRows: SeasonalityTableRow[] = [];
private requestId = 0;
private renderFrame: number | null = null;
private destroyed = false;
constructor(private readonly params: SeasonalityManagerParams) {
const { chartContainer, legend, statusElement } = createSeasonalityElements(params.container);
this.chartContainer = chartContainer;
this.legend = legend;
this.statusElement = statusElement;
this.updateTheme();
}
public getState() {
return this.state.asObservable();
}
public async show(symbolId: string, range?: SeasonalityRange): Promise<void> {
if (this.destroyed || this.reuseHistory(symbolId, range)) {
return;
}
const requestId = ++this.requestId;
this.reset();
this.showStatus(t('Loading seasonality'));
this.state.next({
...this.state.value,
symbolId,
status: 'loading',
tableRows: [],
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.tableRows = buildSeasonalityTable(this.allYears);
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',
tableRows: [],
error: String(error),
});
console.error('[Seasonality] History loading failed:', error);
}
}
public setRange = (range: SeasonalityRange): void => {
if (this.destroyed || 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.allYears.length > 0) {
this.renderRange();
}
});
};
public setDisplayMode = (displayMode: SeasonalityViewType): void => {
if (this.destroyed || this.state.value.displayMode === displayMode) {
return;
}
this.state.next({ ...this.state.value, displayMode });
};
public setAverageVisible = (showAverage: boolean): void => {
if (this.destroyed || this.state.value.showAverage === showAverage) {
return;
}
this.state.next({ ...this.state.value, showAverage });
this.updateAveragePlot(this.getSelectedYears());
this.renderLegend();
this.updateLegend();
};
public setScale = (scale: SeasonalityScale): void => {
if (this.destroyed || 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.updateAveragePlot(this.getSelectedYears());
this.fitCalendar();
this.updateStatus();
this.updateLegend();
};
public clear = (): void => {
if (this.destroyed) {
return;
}
this.requestId += 1;
this.reset();
this.state.next({
...this.state.value,
status: 'idle',
tableRows: [],
error: null,
});
};
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());
if (this.averagePlot) {
this.averagePlot.series.applyOptions({ color: colors.chartTextPrimary });
this.averagePlot.legendValue.style.color = colors.chartTextPrimary;
}
}
public async copyImage(): Promise<void> {
if (
!this.chart ||
this.state.value.status !== 'ready' ||
this.state.value.displayMode !== SeasonalityViewType.Chart
) {
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 (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>();
const loadPage = async (cursor?: Candle): Promise<void> => {
if (!this.isCurrent(requestId)) {
return;
}
const loaded = await this.params.getData(Timeframes['1d'], symbolId, cursor);
if (!this.isCurrent(requestId) || !loaded?.length) {
return;
}
const { oldest, newestTime } = mergeHistoryPage(loaded, candles);
if (cursor && oldest.time >= cursor.time) {
const isBoundaryOnly = oldest.time === cursor.time && newestTime === cursor.time;
if (isBoundaryOnly) {
return;
}
throw new Error(
`History pagination made no progress: cursor=${cursor.time}, ` +
`oldest=${oldest.time}, newest=${newestTime}, count=${loaded.length}`,
);
}
return loadPage(oldest);
};
await loadPage();
if (!this.isCurrent(requestId)) {
return [];
}
return Array.from(candles.values(), (candle) => ({
date: dayjs.unix(candle.time).format('YYYY-MM-DD'),
close: candle.close,
}));
}
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_SEASONALITY_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(),
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.getSelectedYears();
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.updateAveragePlot(years);
this.renderLegend();
if (plots.length > 0) {
this.fitCalendar();
}
this.updateStatus();
this.updateLegend();
this.state.next({
...this.state.value,
status: plots.length > 0 ? 'ready' : 'empty',
tableRows: this.tableRows.filter((row) => row.year >= fromYear && row.year <= toYear),
});
}
private getSelectedYears(): SeasonalityYear[] {
const { fromYear, toYear } = this.state.value;
return this.allYears.filter((year) => year.year >= fromYear && year.year <= toYear);
}
private renderLegend(): void {
const plots = [...this.plots.values()].sort((a, b) => a.year.year - b.year.year);
const elements = plots.flatMap((plot) => [plot.legend, plot.legendValue]);
if (this.averagePlot) {
elements.push(this.averagePlot.legend, this.averagePlot.legendValue);
}
this.legend.replaceChildren(...elements);
}
private updateAveragePlot(years: readonly SeasonalityYear[]): void {
if (!this.chart) {
return;
}
if (!this.state.value.showAverage || years.length === 0) {
if (this.averagePlot) {
this.chart.removeSeries(this.averagePlot.series);
this.averagePlot = null;
}
return;
}
if (!this.averagePlot) {
const legend = document.createElement('span');
const legendValue = document.createElement('span');
const color = getThemeStore().colors.chartTextPrimary;
legend.textContent = t('Average');
Object.assign(legendValue.style, { color, textAlign: 'right' });
this.averagePlot = {
legend,
legendValue,
data: [],
series: this.chart.addSeries(LineSeries, {
title: t('Average'),
color,
lineStyle: LineStyle.Dashed,
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: true,
pointMarkersVisible: false,
crosshairMarkerVisible: true,
}),
};
}
this.updatePriceFormat(this.averagePlot.series);
this.averagePlot.data = buildSeasonalityAverage(years, this.state.value.scale);
this.averagePlot.series.setData(this.averagePlot.data);
}
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;
this.updatePriceFormat(series);
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 updatePriceFormat(series: ISeriesApi<'Line'>): void {
const isPercentage = this.state.value.scale === PriceScaleMode.Percentage;
series.applyOptions({
priceFormat: {
type: 'custom',
minMove: isPercentage ? getPercentPrecisionStep() : getPricePrecisionStep(),
formatter: (value: number) => (isPercentage ? formatPercent(value) : formatCompactNumber(value)),
},
});
}
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);
});
const average = this.averagePlot;
if (average) {
let value: number | null = null;
if (event?.time !== undefined) {
const point = event.seriesData.get(average.series);
value = point && 'value' in point && typeof point.value === 'number' ? point.value : null;
} else {
for (const point of average.data) {
if ('value' in point) {
value = point.value;
}
}
}
average.legend.textContent = t('Average');
average.legendValue.textContent = value === null ? '—' : average.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.averagePlot = null;
this.allYears = [];
this.tableRows = [];
this.plots.clear();
this.legend.replaceChildren();
this.statusElement.style.display = 'none';
}
}
function mergeHistoryPage(
loaded: Candle[],
candles: Map<number, Candle>,
): { oldest: Candle; newestTime: number } {
let oldest = loaded[0];
let oldestTime = Infinity;
let newestTime = -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 };
newestTime = Math.max(newestTime, time);
if (time < oldestTime) {
oldest = normalized;
oldestTime = time;
}
if (!candles.has(time)) {
candles.set(time, normalized);
}
}
return { oldest, newestTime };
}
function getThemeOptions() {
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: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
style: LineStyle.Solid,
},
horzLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: 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];
}
function createSeasonalityElements(container: HTMLElement) {
const chartContainer = document.createElement('div');
const legend = document.createElement('div');
const statusElement = document.createElement('div');
Object.assign(chartContainer.style, {
position: 'absolute',
inset: 0,
});
Object.assign(legend.style, {
width: 'max-content',
position: 'absolute',
top: 0,
left: 0,
display: 'grid',
gridTemplateColumns: 'max-content max-content',
justifyContent: 'start',
alignItems: 'center',
gap: 'var(--space-0125) var(--space-0500)',
fontSize: 'var(--space-0750)',
lineHeight: 'var(--space-1000)',
fontVariantNumeric: 'tabular-nums',
pointerEvents: 'none',
zIndex: ZIndex.Legend,
});
Object.assign(statusElement.style, {
position: 'absolute',
inset: 0,
display: 'none',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
pointerEvents: 'none',
});
container.append(chartContainer, legend, statusElement);
return { chartContainer, legend, statusElement };
}
import { PriceScaleMode } from 'lightweight-charts';
import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { CameraIcon, LineIcon, TableIcon } from '@src/components/Icon';
import { AverageIcon } from '@src/components/Icon/AverageIcon';
import { SnapshotMenu } from '@src/components/Menu';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
import { Tooltip } from '@src/components/Tooltip';
import { t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { useObservable } from '@src/utils';
import styles from './index.module.scss';
import type { SeasonalityRange, SeasonalityScale, SeasonalityState } from '@src/types/seasonality';
import type { Observable } from 'rxjs';
interface SeasonalityControlsProps {
stateObs: Observable<SeasonalityState>;
setDisplayMode: (displayMode: SeasonalityViewType) => void;
setScale: (scale: SeasonalityScale) => void;
setRange: (range: SeasonalityRange) => void;
setAverageVisible: (visible: boolean) => void;
copyImage?: () => void | Promise<void>;
}
const MAX_SCALE_LABELS = 5;
function createYearMarks(minYear: number, maxYear: number): number[] {
const span = maxYear - minYear;
if (span <= 0) return [minYear];
const intervals = Math.min(MAX_SCALE_LABELS - 1, span);
return Array.from(
{ length: intervals + 1 },
(_, index) => minYear + Math.floor((span * index) / intervals),
);
}
export function SeasonalityControls({
stateObs,
setDisplayMode,
setScale,
setRange,
setAverageVisible,
copyImage,
}: SeasonalityControlsProps) {
const state = useObservable(stateObs);
if (!state || state.status === 'idle') {
return null;
}
const selectedScaleLabel = state.scale === PriceScaleMode.Normal ? t('Price') : t('Percent');
const isRangeDisabled = state.status === 'loading' || state.status === 'error';
const canCopyImage = state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart;
const marks = createYearMarks(state.minYear, state.maxYear);
const handleRangeChange = ([fromYear, toYear]: [number, number]): void => {
setRange({ fromYear, toYear });
};
return (
<div className={styles.controls}>
<div className={styles.controls_header}>
<div className={styles.controls_views}>
<Button
label={<LineIcon />}
pressed={state.displayMode === SeasonalityViewType.Chart}
onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
/>
<Button
label={<TableIcon />}
pressed={state.displayMode === SeasonalityViewType.Table}
onClick={() => setDisplayMode(SeasonalityViewType.Table)}
/>
</div>
<div className={styles.controls_actions}>
<Button
label={
<span className={styles.controls_buttonContent}>
<AverageIcon />
<span>{t('Average')}</span>
</span>
}
pressed={state.showAverage}
disabled={state.status !== 'ready'}
onClick={() => setAverageVisible(!state.showAverage)}
/>
<div className={styles.controls_scale}>
<Dropdown
selectedValue={selectedScaleLabel}
renderTrigger={({ isOpen, onToggle }) => (
<Button
pressed={isOpen}
onClick={onToggle}
label={
<span className={styles.controls_buttonContent}>
<span>{selectedScaleLabel}</span>
<span
className={`${styles.controls_chevron} ${isOpen ? styles.controls_chevronOpen : ''}`}
/>
</span>
}
/>
)}
>
<SeasonalityScaleMenu selectedScale={state.scale} onClick={setScale} />
</Dropdown>
</div>
{copyImage && canCopyImage && (
<Tooltip location="bottom" label={t('Snapshot')}>
<Dropdown selectedValue={<CameraIcon />}>
<SnapshotMenu onCopyImage={copyImage} />
</Dropdown>
</Tooltip>
)}
</div>
</div>
<div className={styles.controls_range}>
<RangeField
mode="range"
value={[state.fromYear, state.toYear]}
min={state.minYear}
max={state.maxYear}
marks={marks}
step={1}
suffix=""
size="sm"
disabled={isRangeDisabled}
onRangeChange={handleRangeChange}
/>
</div>
</div>
);
}
.controls {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--space-0500);
&_header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-0500);
min-width: 0;
}
&_views {
display: flex;
align-items: center;
gap: var(--space-0125);
border: var(--borderWidth-md) solid var(--neutral-7);
border-radius: var(--space-0250);
}
&_scale {
border: var(--borderWidth-md) solid var(--neutral-7);
border-radius: var(--space-0250);
}
&_actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-0250);
margin-left: auto;
}
&_buttonContent {
display: inline-flex;
align-items: center;
gap: var(--space-0250);
white-space: nowrap;
}
&_chevron {
display: inline-block;
width: var(--space-0375, 6px);
height: var(--space-0375, 6px);
border-right: var(--borderWidth-md) solid currentColor;
border-bottom: var(--borderWidth-md) solid currentColor;
transform: translateY(-1px) rotate(45deg);
}
&_chevronOpen {
transform: translateY(1px) rotate(225deg);
}
&_range {
min-width: 0;
}
}
import { PriceScaleMode } from 'lightweight-charts';
import { useMemo } from 'react';
import { Table, type TableColumn } from '@src/components/Table';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { getPercentPrecision, getPricePrecision, useObservable } from '@src/utils';
import { buildSeasonalityTableAverage } from '@src/utils/seasonality';
import styles from './index.module.scss';
import type { SeasonalityChange, SeasonalityState, SeasonalityTableRow } from '@src/types';
import type { ReactNode } from 'react';
import type { Observable } from 'rxjs';
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const;
interface SeasonalityTableProps {
stateObs: Observable<SeasonalityState>;
}
type SeasonalityDisplayRow = Pick<SeasonalityTableRow, 'months' | 'total'> & {
year: number | 'average';
};
export function SeasonalityTable({ stateObs }: SeasonalityTableProps) {
const state = useObservable(stateObs);
const locale = getLocale();
const fractionDigits =
state?.scale === PriceScaleMode.Percentage ? getPercentPrecision() : getPricePrecision();
const formatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}),
[locale, fractionDigits],
);
const rows = useMemo<SeasonalityDisplayRow[]>(() => {
const tableRows = state?.tableRows ?? [];
if (!state?.showAverage) {
return tableRows;
}
const average = buildSeasonalityTableAverage(tableRows);
return average ? [...tableRows, { year: 'average', ...average }] : tableRows;
}, [state?.tableRows, state?.showAverage]);
if (!state || state.status === 'idle' || state.displayMode !== SeasonalityViewType.Table) {
return null;
}
const valueKey = state.scale === PriceScaleMode.Percentage ? 'percentage' : 'absolute';
const suffix = state.scale === PriceScaleMode.Percentage ? '%' : '';
const formatChange = (change: SeasonalityChange | null): string => {
const value = change?.[valueKey] ?? null;
return value === null ? '—' : `${formatter.format(value)}${suffix}`;
};
const getChangeClassName = (change: SeasonalityChange | null): string | undefined => {
const value = change?.[valueKey] ?? null;
if (value !== null && value > 0) {
return styles.positive;
}
if (value !== null && value < 0) {
return styles.negative;
}
return undefined;
};
const createChangeColumn = (
id: string,
header: ReactNode,
getChange: (row: SeasonalityDisplayRow) => SeasonalityChange | null,
className?: string,
): TableColumn<SeasonalityDisplayRow> => ({
id,
header,
align: 'right',
className,
renderCell: (row) => formatChange(getChange(row)),
getCellClassName: (row) =>
[getChangeClassName(getChange(row)), row.year === 'average' && styles.average]
.filter(Boolean)
.join(' ') || undefined,
});
const columns: TableColumn<SeasonalityDisplayRow>[] = [
{
id: 'date',
header: t('Date'),
rowHeader: true,
renderCell: (row) => (row.year === 'average' ? t('Average') : row.year),
getCellClassName: (row) => (row.year === 'average' ? styles.average : undefined),
},
...MONTHS.map((month, index) => createChangeColumn(month, t(month), (row) => row.months[index])),
createChangeColumn('year', t('Year'), (row) => row.total, styles.total),
];
const emptyContent = getEmptyContent(state.status);
return (
<Table
className={styles.seasonality}
columns={columns}
rows={rows}
getRowKey={(row) => row.year}
emptyContent={emptyContent}
stickyFirstColumn
/>
);
}
function getEmptyContent(status: SeasonalityState['status']): string {
if (status === 'loading') {
return t('Loading seasonality');
}
if (status === 'error') {
return t('Unable to load seasonality');
}
return t('No seasonality data');
}
[data-theme='mxt'] {
--cell-background-positive: #24a249;
--cell-background-negative: #d71d38;
}
[data-theme='mb'] {
--cell-background-positive: #3cae0b;
--cell-background-negative: #d71d38;
}
[data-theme='tr'] {
--cell-background-positive: #27ef0085;
--cell-background-negative: #ff000085;
}
.seasonality {
font-variant-numeric: tabular-nums;
}
.positive {
--table-cell-background: color-mix(
in srgb,
var(--cell-background-positive) 35%,
var(--table-background, var(--neutral-0))
);
}
.negative {
--table-cell-background: color-mix(
in srgb,
var(--cell-background-negative) 35%,
var(--table-background, var(--neutral-0))
);
}
.total {
--table-cell-font-weight: 600;
}
.average {
--table-cell-font-weight: 600;
border-top: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
}
import classNames from 'classnames';
import { type ChangeEvent, type PointerEvent, useId, useRef } from 'react';
import styles from './index.module.scss';
interface CommonRangeFieldProps {
label?: string;
min?: number;
max?: number;
step?: number;
color?: string;
suffix?: string;
size?: 'sm' | 'md';
disabled?: boolean;
}
interface SingleRangeFieldProps extends CommonRangeFieldProps {
mode?: 'single';
value: number;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}
interface IntervalRangeFieldProps extends CommonRangeFieldProps {
mode: 'range';
value: [number, number];
marks?: readonly number[];
onRangeChange: (value: [number, number]) => void;
}
interface RangeDragState {
pointerId: number;
pointerX: number;
from: number;
to: number;
trackWidth: number;
}
type RangeFieldProps = SingleRangeFieldProps | IntervalRangeFieldProps;
export const RangeField = (props: RangeFieldProps) => {
const {
label,
min = 0,
max = 100,
step = 1,
color = 'var(--blue-1)',
suffix = '%',
size = 'md',
disabled = false,
} = props;
const inputId = useId();
const trackRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<RangeDragState | null>(null);
if (props.mode !== 'range') {
return (
<div
className={classNames(styles.range, {
[styles.range_sm]: size === 'sm',
})}
>
{label && (
<label htmlFor={inputId} className={styles.range_label}>
{label}
</label>
)}
<div className={styles.range_wrapper}>
<input
id={inputId}
type="range"
min={min}
max={max}
step={step}
value={props.value}
className={styles.range_input}
style={{ ['--range-color' as string]: color }}
onChange={props.onChange}
disabled={disabled}
/>
<span className={styles.range_value}>
{props.value}
{suffix}
</span>
</div>
</div>
);
}
const {
value: [from, to],
marks = [],
onRangeChange,
} = props;
const span = max - min;
const left = span > 0 ? ((from - min) / span) * 100 : 0;
const right = span > 0 ? ((to - min) / span) * 100 : 0;
const isDisabled = disabled || span <= 0;
const scaleMarks =
span > 0
? [
min,
...Array.from(new Set(marks))
.filter((value) => value > min && value < max)
.sort((a, b) => a - b),
max,
]
: [min];
const handleDragEnd = (event: PointerEvent<HTMLElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null;
}
};
const handleDragStart = (event: PointerEvent<HTMLElement>) => {
if (isDisabled || event.button !== 0 || dragRef.current || !trackRef.current) return;
const { width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
pointerX: event.clientX,
from,
to,
trackWidth: width,
};
};
const handleDragMove = (event: PointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId || isDisabled) return;
const pointerOffset = ((event.clientX - drag.pointerX) / drag.trackWidth) * span;
const steppedOffset = Math.round(pointerOffset / step) * step;
const offset = Math.max(min - drag.from, Math.min(max - drag.to, steppedOffset));
const nextFrom = drag.from + offset;
const nextTo = drag.to + offset;
if (nextFrom !== from || nextTo !== to) {
onRangeChange([nextFrom, nextTo]);
}
};
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
isDisabled ||
from !== to ||
event.button !== 0 ||
event.defaultPrevented ||
event.target instanceof HTMLInputElement ||
!trackRef.current
) {
return;
}
const { left: trackLeft, width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
const value = min + Math.round((((event.clientX - trackLeft) / width) * span) / step) * step;
const next = Math.max(min, Math.min(max, value));
event.preventDefault();
if (next !== from) {
onRangeChange([Math.min(from, next), Math.max(to, next)]);
}
};
const handleFromChange = (event: ChangeEvent<HTMLInputElement>) => {
if (dragRef.current) return;
onRangeChange([Math.min(event.currentTarget.valueAsNumber, to), to]);
};
const handleToChange = (event: ChangeEvent<HTMLInputElement>) => {
if (dragRef.current) return;
onRangeChange([from, Math.max(event.currentTarget.valueAsNumber, from)]);
};
const dragHandlers = {
onPointerMove: handleDragMove,
onPointerUp: handleDragEnd,
onPointerCancel: handleDragEnd,
onLostPointerCapture: handleDragEnd,
};
return (
<div
className={classNames(styles.range, styles.range_interval, {
[styles.range_sm]: size === 'sm',
[styles.range_disabled]: isDisabled,
})}
>
{label && <span className={styles.range_label}>{label}</span>}
<div
ref={trackRef}
className={styles.range_track}
style={{ ['--range-color' as string]: color }}
onPointerDown={handleTrackPointerDown}
>
<div className={styles.range_rail} />
<div
className={styles.range_selection}
style={{
left: `${left}%`,
width: `${right - left}%`,
}}
onPointerDown={handleDragStart}
{...dragHandlers}
/>
<input
id={`${inputId}-from`}
type="range"
min={min}
max={max}
step={step}
value={from}
disabled={isDisabled}
className={classNames(styles.range_handle, styles.range_handle_start, {
[styles.range_handle_behind]: from === to && from === min,
})}
onChange={handleFromChange}
onPointerDown={from === to ? handleDragStart : undefined}
{...dragHandlers}
/>
<input
id={`${inputId}-to`}
type="range"
min={min}
max={max}
step={step}
value={to}
disabled={isDisabled}
className={classNames(styles.range_handle, styles.range_handle_end)}
onChange={handleToChange}
onPointerDown={from === to ? handleDragStart : undefined}
{...dragHandlers}
/>
</div>
<div className={styles.range_scale}>
{scaleMarks.map((value) => (
<span
key={value}
className={styles.range_scale_label}
style={{
left: `${span > 0 ? ((value - min) / span) * 100 : 0}%`,
}}
>
{value}
{suffix}
</span>
))}
</div>
</div>
);
};
import styles from './index.module.scss';
import type { HeaderControlsProps } from './types';
export function SeasonalityHeader({ viewControl }: HeaderControlsProps) {
return <div className={styles.group}>{viewControl}</div>;
}