Загрузка данных
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 {
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 = () => {
dragRef.current = null;
};
const handleDragStart = (event: PointerEvent<HTMLDivElement>) => {
if (isDisabled || event.button !== 0 || !trackRef.current) return;
const { width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerX: event.clientX,
from,
to,
trackWidth: width,
};
};
const handleDragMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || 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));
onRangeChange([drag.from + offset, drag.to + offset]);
};
const handleFromChange = (event: ChangeEvent<HTMLInputElement>) => {
onRangeChange([Math.min(event.currentTarget.valueAsNumber, to), to]);
};
const handleToChange = (event: ChangeEvent<HTMLInputElement>) => {
onRangeChange([from, Math.max(event.currentTarget.valueAsNumber, from)]);
};
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 }}
>
<div className={styles.range_rail} />
<div
className={styles.range_selection}
style={{
left: `${left}%`,
width: `${right - left}%`,
}}
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onLostPointerCapture={handleDragEnd}
/>
<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}
/>
<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}
/>
</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 { PriceScaleMode } from 'lightweight-charts';
import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { LineIcon, TableIcon } from '@src/components/Icon';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
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;
}
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 }: 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 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_scale}>
<Dropdown selectedValue={selectedScaleLabel}>
<SeasonalityScaleMenu
selectedScale={state.scale}
onClick={setScale}
/>
</Dropdown>
</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);
}
&_range {
min-width: 0;
}
}
import classNames from 'classnames';
import { Button as ExchangeButton } from 'exchange-elements/v2';
import { type ComponentProps } from 'react';
import styles from './index.module.scss';
export type ExchangeButtonProps = ComponentProps<typeof ExchangeButton>;
export interface ButtonProps extends ExchangeButtonProps {
pressed?: boolean;
selected?: boolean;
}
export function Button({ size = 'sm', pressed = false, selected = false, className, ...props }: ButtonProps) {
return (
<ExchangeButton
{...props}
size={size}
className={classNames(styles.button, className, {
[styles.pressed]: pressed,
[styles.selected]: selected,
})}
/>
);
}
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 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>;
}
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],
);
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: SeasonalityTableRow) => SeasonalityChange | null,
className?: string,
): TableColumn<SeasonalityTableRow> => ({
id,
header,
align: 'right',
className,
renderCell: (row) => formatChange(getChange(row)),
getCellClassName: (row) => getChangeClassName(getChange(row)),
});
const columns: TableColumn<SeasonalityTableRow>[] = [
{
id: 'date',
header: t('Date'),
rowHeader: true,
renderCell: (row) => row.year,
},
...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={state.tableRows}
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;
}
import styles from './index.module.scss';
import type { Key, ReactNode } from 'react';
export interface TableColumn<Row> {
id: string;
header: ReactNode;
renderCell: (row: Row) => ReactNode;
align?: 'left' | 'center' | 'right';
rowHeader?: boolean;
className?: string;
getCellClassName?: (row: Row) => string | undefined;
}
export interface TableProps<Row> {
columns: readonly TableColumn<Row>[];
rows: readonly Row[];
getRowKey: (row: Row) => Key;
className?: string;
stickyHeader?: boolean;
stickyFirstColumn?: boolean;
emptyContent?: ReactNode;
}
export function Table<Row>({
columns,
rows,
getRowKey,
className,
stickyHeader = true,
stickyFirstColumn = false,
emptyContent = '—',
}: TableProps<Row>) {
const wrapperClassName = [
styles.table,
stickyHeader && styles.table_stickyHeader,
stickyFirstColumn && styles.table_stickyFirstColumn,
className,
]
.filter(Boolean)
.join(' ');
const columnClassNames = columns.map((column) =>
[styles.table_cell, styles[`table_cell_${column.align ?? 'left'}`], column.className].filter(Boolean).join(' '),
);
return (
<div className={wrapperClassName}>
<table className={styles.table_content}>
<thead>
<tr>
{columns.map((column, index) => (
<th
key={column.id}
scope="col"
className={columnClassNames[index]}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={getRowKey(row)}>
{columns.map((column, index) => {
const Cell = column.rowHeader ? 'th' : 'td';
const cellClassName = [columnClassNames[index], column.getCellClassName?.(row)]
.filter(Boolean)
.join(' ');
return (
<Cell
key={column.id}
scope={column.rowHeader ? 'row' : undefined}
className={cellClassName}
>
{column.renderCell(row)}
</Cell>
);
})}
</tr>
))}
{rows.length === 0 && (
<tr>
<td
colSpan={columns.length}
className={styles.table_empty}
>
{emptyContent}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
@use '../../theme/mixins' as m;
.table {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
max-width: 100%;
max-height: 100%;
overflow: auto;
color: var(--table-text, var(--neutral-12));
background-color: var(--table-background, var(--neutral-0));
@include m.scrollbar;
&_content {
width: 100%;
min-width: max-content;
border-collapse: separate;
border-spacing: 0;
}
&_cell {
font-size: var(--space-0750);
padding: var(--space-0500);
border-right: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
border-bottom: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
background-color: var(--table-cell-background, var(--table-background, var(--neutral-0)));
white-space: nowrap;
font-weight: var(--table-cell-font-weight, var(--fontWeight-regular));
&:last-child {
border-right: 0;
}
&_left {
text-align: left;
}
&_center {
text-align: center;
}
&_right {
text-align: right;
}
}
&_content thead &_cell {
border-top: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
}
&_stickyHeader thead .table_cell {
position: sticky;
top: 0;
z-index: 2;
}
&_stickyFirstColumn .table_cell:first-child {
position: sticky;
left: 0;
z-index: 1;
}
&_stickyHeader.table_stickyFirstColumn thead .table_cell:first-child {
z-index: 3;
}
&_empty {
padding: var(--space-0500);
text-align: center;
}
}