Загрузка данных
import type { SingleValueData, Time } from 'lightweight-charts';
export function isSingleValueData(data: unknown): data is SingleValueData<Time> {
if (typeof data !== 'object' || data === null) {
return false;
}
return 'time' in data && 'value' in data && typeof data.value === 'number';
}
export function calcCandleChange(
prev: BarData | LineData | HistogramData | CustomData | null,
current: BarData | LineData | HistogramData | CustomData | null,
):
| (Ohlc & {
customValues?: Record<string, unknown>;
})
| null {
if (!current) {
return null;
}
if (!prev) {
return current;
}
if (isBarData(prev) && isBarData(current)) {
const absoluteChange = current.close - prev.close;
const percentageChange = ((current.close - prev.close) / prev.close) * 100;
return {
...current,
absoluteChange,
percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
};
}
if (isSingleValueData(prev) && isSingleValueData(current)) {
const absoluteChange = current.value - prev.value;
const percentageChange = ((current.value - prev.value) / prev.value) * 100;
const high = current.customValues?.high;
const low = current.customValues?.low;
return {
time: current.time,
value: current.value,
high: typeof high === 'number' ? high : current.value,
low: typeof low === 'number' ? low : current.value,
absoluteChange,
percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
customValues: current.customValues,
};
}
return null;
}
import type {
BarData,
CustomData,
HistogramData,
LineData,
SeriesDataItemTypeMap,
SingleValueData,
Time,
} from 'lightweight-charts';
import type { Ohlc } from '@core/Legend';
import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';
import type { ISeries } from '@src/modules/series-strategies';
import { t } from '@src/translations';
import type { Candle, LineCandle } from '@src/types';
import { formatCompactNumber } from '@src/utils';
import { isSingleValueData } from '@src/utils/isSingleValueData';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
type SingleValueSeriesType = 'Line' | 'Histogram' | 'Baseline';
type LegendData = BarData | LineData | HistogramData | CustomData | null;
type LegendValues = Partial<
Record<
keyof Ohlc,
{
value: number | string | Time;
color: string;
name: string;
}
>
>;
export abstract class SingleValueSeriesStrategy<TSeries extends SingleValueSeriesType>
extends BaseSeries<TSeries>
implements ISeries<TSeries>
{
constructor(params: BaseSeriesParams<TSeries>) {
super(params);
this.subscribeDataSource(params.dataSource);
}
public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
return (
Array.isArray(data) &&
data.every((point) => typeof point.time === 'number' && typeof point.close === 'number')
);
}
protected dataSourceSubscription = (dataToSet: Candle[]): void => {
if (!this.validateData(dataToSet)) {
console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
return;
}
this.setData(this.formatData(dataToSet));
};
protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
if (!this.validateData([dataToSet])) {
console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
return;
}
const formattedData = this.formatData([dataToSet]);
this.update(formattedData[0], true);
};
protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
return inputData.map((point) => ({
time: point.time as Time,
value: point.close,
customValues: point as unknown as Record<string, unknown>,
})) as SeriesDataItemTypeMap<Time>[TSeries][];
}
protected formatLegendValues(currentBar: LegendData, prevBar: LegendData): LegendValues {
if (!isSingleValueData(currentBar)) {
return {};
}
const change = calcCandleChange(prevBar, currentBar);
if (!change) {
return {};
}
const color = this.getLegendColor(currentBar);
return {
value: {
value: formatCompactNumber(currentBar.value) ?? '',
name: '',
color,
},
absoluteChange: {
value: formatCompactNumber(change.absoluteChange ?? 0),
name: t('Change'),
color,
},
percentageChange: {
value:
change.percentageChange !== undefined
? `${formatCompactNumber(change.percentageChange)}%`
: '',
name: t('Change'),
color,
},
time: {
value: change.time,
name: t('Time'),
color,
},
};
}
protected getLegendColor(currentBar: SingleValueData<Time>): string {
if ('color' in currentBar && typeof currentBar.color === 'string') {
return removeAlphaFromHex(currentBar.color);
}
const options = this.options();
if ('color' in options && typeof options.color === 'string') {
return options.color;
}
return '';
}
public abstract getTypeName(): string;
}
import {
LineSeries,
LineStyle,
SeriesDefinition,
SeriesPartialOptionsMap,
} from 'lightweight-charts';
import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';
import { getThemeStore } from '@src/theme/store';
export class LineSeriesStrategy extends SingleValueSeriesStrategy<'Line'> {
protected seriesDefinition(): SeriesDefinition<'Line'> {
return LineSeries;
}
protected getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
const { colors } = getThemeStore();
return {
color: colors.chartLineColor,
lineWidth: 2,
lineStyle: LineStyle.Solid,
};
}
public getTypeName(): string {
return 'Line';
}
}
import {
HistogramSeries,
SeriesDefinition,
SeriesPartialOptionsMap,
} from 'lightweight-charts';
import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';
export class HistogramSeriesStrategy extends SingleValueSeriesStrategy<'Histogram'> {
protected seriesDefinition(): SeriesDefinition<'Histogram'> {
return HistogramSeries;
}
protected getDefaultOptions(): SeriesPartialOptionsMap['Histogram'] {
return {};
}
public getTypeName(): string {
return 'Histogram';
}
}
import {
BaselineSeries,
SeriesDefinition,
SeriesPartialOptionsMap,
SingleValueData,
Time,
} from 'lightweight-charts';
import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';
export class BaselineSeriesStrategy extends SingleValueSeriesStrategy<'Baseline'> {
protected seriesDefinition(): SeriesDefinition<'Baseline'> {
return BaselineSeries;
}
protected getDefaultOptions(): SeriesPartialOptionsMap['Baseline'] {
return {};
}
protected getLegendColor(currentBar: SingleValueData<Time>): string {
const options = this.options();
if (currentBar.value >= options.baseValue.price) {
if ('topLineColor' in currentBar && typeof currentBar.topLineColor === 'string') {
return currentBar.topLineColor;
}
return options.topLineColor;
}
if ('bottomLineColor' in currentBar && typeof currentBar.bottomLineColor === 'string') {
return currentBar.bottomLineColor;
}
return options.bottomLineColor;
}
public getTypeName(): string {
return 'Baseline';
}
}
import { BarSeriesStrategy } from '@core/Series/BarSeriesStrategy';
import { BaselineSeriesStrategy } from '@core/Series/BaselineSeriesStrategy';
import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { CandlestickSeriesStrategy } from '@core/Series/CandlestickSeriesStrategy';
import { HistogramSeriesStrategy } from '@core/Series/HistogramSeriesStrategy';
import { LineSeriesStrategy } from '@core/Series/LineSeriesStrategy';
import { ChartSeriesType } from '@src/types';
export type SeriesStrategies =
| CandlestickSeriesStrategy
| LineSeriesStrategy
| HistogramSeriesStrategy
| BarSeriesStrategy
| BaselineSeriesStrategy;
type SeriesStrategyFactory =
| ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
| ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
| ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
| ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy)
| ((params: BaseSeriesParams<'Baseline'>) => BaselineSeriesStrategy);
export class SeriesFactory {
static create(type: ChartSeriesType): SeriesStrategyFactory {
switch (type) {
case 'Candlestick':
return (params: BaseSeriesParams<'Candlestick'>) => new CandlestickSeriesStrategy(params);
case 'Histogram':
return (params: BaseSeriesParams<'Histogram'>) => new HistogramSeriesStrategy(params);
case 'Line':
return (params: BaseSeriesParams<'Line'>) => new LineSeriesStrategy(params);
case 'Bar':
return (params: BaseSeriesParams<'Bar'>) => new BarSeriesStrategy(params);
case 'Baseline':
return (params: BaseSeriesParams<'Baseline'>) => new BaselineSeriesStrategy(params);
default:
throw new Error(`Unsupported chart type: ${type}`);
}
}
}
import {
BarIcon,
CandleStickIcon,
LineIcon,
} from '@src/components/Icon';
import { ChartSeriesType } from '@src/types';
interface SeriesTypeIconProps {
type?: ChartSeriesType;
}
export function SeriesTypeIcon({ type }: SeriesTypeIconProps) {
switch (type) {
case 'Line':
case 'Baseline':
return <LineIcon />;
case 'Bar':
return <BarIcon />;
case 'Candlestick':
default:
return <CandleStickIcon />;
}
}
import { MenuList, MenuOption } from '@src/components/Menu/MenuList';
import { SeriesTypeIcon } from '@src/components/SeriesTypeIcon';
import { t } from '@src/translations';
import { ChartSeriesType } from '@src/types';
interface SeriesMenuProps {
selectedType?: ChartSeriesType;
seriesTypes: ChartSeriesType[];
onClick: (next: ChartSeriesType) => void;
}
const seriesOptions = (): Partial<Record<ChartSeriesType, MenuOption<ChartSeriesType>>> => ({
Candlestick: {
icon: <SeriesTypeIcon type="Candlestick" />,
value: 'Candlestick',
label: t('Candles'),
},
Line: {
icon: <SeriesTypeIcon type="Line" />,
value: 'Line',
label: t('Line'),
},
Bar: {
icon: <SeriesTypeIcon type="Bar" />,
value: 'Bar',
label: t('Bars'),
},
Baseline: {
icon: <SeriesTypeIcon type="Baseline" />,
value: 'Baseline',
label: t('Baseline'),
},
});
export function SeriesMenu({ selectedType, seriesTypes, onClick }: SeriesMenuProps) {
const options: MenuOption<ChartSeriesType>[] = seriesTypes.map((type) => ({
value: type,
icon: seriesOptions()[type]?.icon,
label: seriesOptions()[type]?.label,
}));
return (
<MenuList
mode="single"
value={selectedType}
options={options}
onClick={onClick}
/>
);
}
import { Divider } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';
import { type Observable, of } from 'rxjs';
import { IndicatorsSelect } from '@components/IndicatorsSelect';
import { Button } from '@src/components/Button';
import { SeriesTypeIcon } from '@src/components/SeriesTypeIcon';
import { Tooltip } from '@src/components/Tooltip';
import { IndicatorsIds } from '@src/constants';
import { FullscreenController } from '@src/core/Fullscreen';
import { UndoRedo, type UndoRedoState } from '@src/core/UndoRedo';
import { t } from '@src/translations';
import { ChartSeriesType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { useObservable } from '@src/utils';
import { Dropdown } from '../Dropdown';
import {
BurgerMenuIcon,
CameraIcon,
FullscreenIcon,
PlusCircleIcon,
RedoIcon,
SearchIcon,
UndoIcon,
} from '../Icon';
import { SeriesMenu, SnapshotMenu, TimeframesMenu } from '../Menu';
import styles from './index.module.scss';
const defaultUndoRedoState: UndoRedoState = {
canUndo: false,
canRedo: false,
};
const defaultUndoRedoState$ = of(defaultUndoRedoState);
interface HeaderProps {
timeframes: Timeframes[];
selectedTimeframeObs: Observable<Timeframes>;
setTimeframe: (value: Timeframes) => void;
seriesTypes: ChartSeriesType[];
setSelectedSeries: (next: ChartSeriesType) => void;
selectedSeriesObs: Observable<ChartSeriesType>;
showSettingsModal: (() => void) | undefined;
addIndicatorToChart: (id: IndicatorsIds) => void;
toggleToolbarVisible: () => boolean;
showCompareButton: boolean;
showSymbolSearchButton: boolean;
undoRedo: UndoRedo | undefined;
showMenuButton?: boolean;
showFullscreenButton?: boolean;
fullscreen: FullscreenController;
openCompareModal?: () => void;
openSymbolSearchModal?: () => void;
copyImage?: () => Promise<void>;
isMXT: boolean;
}
export function Header({
setTimeframe,
selectedTimeframeObs,
setSelectedSeries,
selectedSeriesObs,
timeframes,
seriesTypes,
showSettingsModal,
addIndicatorToChart,
toggleToolbarVisible,
showCompareButton,
showSymbolSearchButton,
fullscreen,
undoRedo,
showFullscreenButton,
showMenuButton,
openCompareModal,
openSymbolSearchModal,
copyImage,
isMXT,
}: HeaderProps) {
const [isToolbarOpen, setIsToolbarOpen] = useState(showMenuButton ?? false);
const [isFullscreen, setIsFullscreen] = useState(fullscreen.isFullscreen);
const undoRedoState = useObservable(
undoRedo?.getState() ?? defaultUndoRedoState$,
defaultUndoRedoState,
);
useEffect(() => {
const unsubscribe = fullscreen.onChange(() => {
setIsFullscreen(fullscreen.isFullscreen);
});
return unsubscribe;
}, [fullscreen]);
const selectedTimeframe = useObservable(selectedTimeframeObs);
const selectedSeries = useObservable(selectedSeriesObs);
const handleOpenToolbar = (): void => {
setIsToolbarOpen(toggleToolbarVisible());
};
return (
<header className={styles.header}>
<div className={styles.group}>
{showMenuButton && (
<Tooltip
location="bottom"
label={t(isToolbarOpen ? 'Hide drawing toolbar' : 'Show drawing toolbar')}
>
<Button
pressed={isToolbarOpen}
onClick={handleOpenToolbar}
label={<BurgerMenuIcon />}
/>
</Tooltip>
)}
{showSymbolSearchButton && (
<Tooltip
location="bottom"
label={t('Symbol search')}
>
<Button
onClick={openSymbolSearchModal}
label={<SearchIcon />}
/>
</Tooltip>
)}
{showCompareButton && (
<Tooltip
location="bottom"
label={t('Compare symbols')}
>
<Button
onClick={openCompareModal}
label={<PlusCircleIcon />}
/>
</Tooltip>
)}
</div>
{(showMenuButton || showCompareButton || showSymbolSearchButton) && (
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
)}
<Tooltip
location="bottom"
label={t('Timeframe')}
>
<Dropdown
menuClassName={styles.menu}
selectedValue={selectedTimeframe ? t(selectedTimeframe) : selectedTimeframe}
>
<TimeframesMenu
onClick={setTimeframe}
{...{ selectedTimeframe, timeframes }}
/>
</Dropdown>
</Tooltip>
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
<div className={styles.group}>
<IndicatorsSelect
addIndicatorToChart={addIndicatorToChart}
isMXT={isMXT}
/>
{showSettingsModal && (
<Button
onClick={showSettingsModal}
label={t('Settings')}
/>
)}
<Tooltip
location="bottom"
label={t('Chart type')}
>
<Dropdown
menuClassName={styles.menu}
selectedValue={<SeriesTypeIcon type={selectedSeries} />}
>
<SeriesMenu
selectedType={selectedSeries}
seriesTypes={seriesTypes}
onClick={setSelectedSeries}
/>
</Dropdown>
</Tooltip>
</div>
{undoRedo && (
<>
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
<div className={styles.group}>
<Tooltip
location="bottom"
label={t('Undo')}
>
<Button
onClick={undoRedo.undo}
disabled={!undoRedoState?.canUndo}
label={<UndoIcon />}
/>
</Tooltip>
<Tooltip
location="bottom"
label={t('Redo')}
>
<Button
onClick={undoRedo.redo}
disabled={!undoRedoState?.canRedo}
label={<RedoIcon />}
/>
</Tooltip>
</div>
</>
)}
{(copyImage || showFullscreenButton) && (
<>
<Divider
direction="vertical"
pt={{ divider: { className: styles.divider } }}
/>
<div className={styles.group}>
{copyImage && (
<Tooltip
location="bottom"
label={t('Snapshot')}
>
<Dropdown
menuClassName={styles.menu}
selectedValue={<CameraIcon />}
>
<SnapshotMenu onCopyImage={copyImage} />
</Dropdown>
</Tooltip>
)}
{showFullscreenButton && (
<Tooltip
location="bottom"
label={t(isFullscreen ? 'Exit fullscreen' : 'Fullscreen')}
>
<Button
pressed={isFullscreen}
onClick={() => fullscreen.toggle()}
label={<FullscreenIcon />}
/>
</Tooltip>
)}
</div>
</>
)}
</header>
);
}