decompose structure

This commit is contained in:
Первов Артем
2026-06-20 00:51:18 +03:00
parent dbda8ee613
commit 31add10e56
77 changed files with 4112 additions and 3974 deletions

View File

@@ -0,0 +1,92 @@
import { LineChart, ScatterChart } from 'echarts/charts';
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import ReactEChartsCore from 'echarts-for-react/esm/core.js';
import { useMemo, useRef } from 'react';
import type { HistoryResponse } from '../../entities/history/types';
import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity';
import type { DataZoomEventBatch, DataZoomState } from './chartTypes';
import { createHistoryChartOptions } from './historyChartOptions';
echarts.use([
LineChart,
ScatterChart,
GridComponent,
LegendComponent,
TooltipComponent,
DataZoomComponent,
CanvasRenderer,
]);
type HistoryChartProps = {
data?: HistoryResponse;
loading: boolean;
from?: string;
to?: string;
tickIntervalMs?: number;
labelFormat?: HistoryAxisLabelFormat;
tagLabels?: Record<string, string>;
};
/** Рисует avg-линию, min/max-точки и вертикальный диапазон для агрегированной истории. */
export function HistoryChart({
data,
loading,
from,
to,
tickIntervalMs,
labelFormat,
tagLabels = {},
}: HistoryChartProps) {
const dataZoomRef = useRef<DataZoomState | null>(null);
const legendData = useMemo(
() => data?.series.map((series) => tagLabels[series.tag] ?? series.tag) ?? [],
[data?.series, tagLabels],
);
const option = useMemo(
() =>
createHistoryChartOptions({
data,
dataZoomState: dataZoomRef.current ?? {},
from,
to,
labelFormat,
legendData,
tagLabels,
tickIntervalMs,
}),
[data, from, labelFormat, legendData, tagLabels, tickIntervalMs, to],
);
const onChartEvents = useMemo(
() => ({
datazoom: (event: DataZoomEventBatch) => {
const state = event.batch?.[0] ?? event;
dataZoomRef.current = {
start: state.start,
end: state.end,
startValue: state.startValue,
endValue: state.endValue,
};
},
}),
[],
);
if (loading) {
return <div className="chart-placeholder">Загрузка графика...</div>;
}
if (!data?.series.length) {
return <div className="chart-placeholder">Нет данных для выбранного диапазона</div>;
}
return <ReactEChartsCore echarts={echarts} option={option} className="history-chart" onEvents={onChartEvents} lazyUpdate />;
}

View File

@@ -0,0 +1,18 @@
export type AvgPointValue = [time: number, avg: number, min: number, max: number, count: number];
export type TooltipParam = {
marker?: string;
seriesName?: string;
value?: unknown;
};
export type DataZoomState = {
start?: number;
end?: number;
startValue?: number;
endValue?: number;
};
export type DataZoomEventBatch = DataZoomState & {
batch?: DataZoomState[];
};

View File

@@ -0,0 +1,102 @@
import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity';
import type { AvgPointValue, TooltipParam } from './chartTypes';
export function formatAxisDate(value: number, labelFormat: HistoryAxisLabelFormat = 'time-minutes'): string {
const date = new Date(value);
if (labelFormat === 'time-seconds') {
return new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
}
if (labelFormat === 'time-minutes') {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(date);
}
if (labelFormat === 'hour-zero') {
return `${new Intl.DateTimeFormat('ru-RU', { hour: '2-digit' }).format(date)}:00`;
}
if (labelFormat === 'day-hour') {
const day = new Intl.DateTimeFormat('ru-RU', { day: '2-digit' }).format(date);
const hour = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit' }).format(date);
return `${day} ${hour}:00`;
}
if (labelFormat === 'weekday-day') {
return new Intl.DateTimeFormat('ru-RU', { weekday: 'short', day: '2-digit' }).format(date);
}
if (labelFormat === 'day-month') {
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(date);
}
if (labelFormat === 'day-month-name') {
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'short' }).format(date);
}
if (labelFormat === 'month-name') {
return new Intl.DateTimeFormat('ru-RU', { month: 'short' }).format(date);
}
if (labelFormat === 'month-year') {
return new Intl.DateTimeFormat('ru-RU', { month: 'short', year: '2-digit' }).format(date);
}
if (labelFormat === 'quarter-year') {
const quarter = Math.floor(date.getMonth() / 3) + 1;
return `Кв${quarter} ${String(date.getFullYear()).slice(-2)}`;
}
return new Intl.DateTimeFormat('ru-RU', { year: 'numeric' }).format(date);
}
function formatChartValue(value: number): string {
return new Intl.NumberFormat('ru-RU', {
maximumFractionDigits: Math.abs(value) >= 100 ? 2 : 3,
}).format(value);
}
function isAvgPointValue(value: unknown): value is AvgPointValue {
return Array.isArray(value) && value.length >= 5 && value.every((item) => typeof item === 'number');
}
/** Собирает tooltip с min/avg/max для основной avg-серии. */
export function formatTooltip(params: unknown): string {
const items = (Array.isArray(params) ? params : [params]).filter(
(item): item is TooltipParam => typeof item === 'object' && item !== null,
);
const avgItems = items.filter((item) => isAvgPointValue(item.value));
const firstValue = avgItems[0]?.value as AvgPointValue | undefined;
if (!firstValue) {
return '';
}
const header = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(firstValue[0]));
const rows = avgItems.map((item) => {
const value = item.value as AvgPointValue;
return [
'<div class="chart-tooltip-row">',
`<span>${item.marker ?? ''}${item.seriesName ?? ''}</span>`,
'<strong>',
`min ${formatChartValue(value[2])} · avg ${formatChartValue(value[1])} · max ${formatChartValue(value[3])}`,
value[4] > 1 ? ` · ${value[4]} точек` : '',
'</strong>',
'</div>',
].join('');
});
return [`<div class="chart-tooltip"><div class="chart-tooltip-title">${header}</div>`, ...rows, '</div>'].join('');
}

View File

@@ -0,0 +1,110 @@
import type { EChartsOption } from 'echarts';
import type { HistoryResponse } from '../../entities/history/types';
import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity';
import type { DataZoomState } from './chartTypes';
import { formatAxisDate, formatTooltip } from './historyChartFormat';
import { createSeriesOptions, SERIES_COLORS } from './historyChartSeries';
export type HistoryChartOptionsInput = {
data?: HistoryResponse;
dataZoomState: DataZoomState;
from?: string;
to?: string;
labelFormat?: HistoryAxisLabelFormat;
legendData: string[];
tagLabels: Record<string, string>;
tickIntervalMs?: number;
};
export function createHistoryChartOptions({
data,
dataZoomState,
from,
to,
labelFormat,
legendData,
tagLabels,
tickIntervalMs,
}: HistoryChartOptionsInput): EChartsOption {
const xMin = from ? new Date(from).getTime() : undefined;
const xMax = to ? new Date(to).getTime() : undefined;
return {
color: SERIES_COLORS,
animation: false,
backgroundColor: 'transparent',
textStyle: {
color: '#cbd5e1',
fontFamily: 'Inter, Segoe UI, sans-serif',
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(10, 13, 18, 0.96)',
borderColor: 'rgba(212, 165, 116, 0.32)',
textStyle: { color: '#f8fafc' },
formatter: formatTooltip,
},
legend: {
type: 'scroll',
top: 0,
data: legendData,
textStyle: { color: '#cbd5e1' },
},
grid: {
top: 54,
left: 48,
right: 24,
bottom: 72,
},
xAxis: {
type: 'time',
min: Number.isFinite(xMin) ? xMin : undefined,
max: Number.isFinite(xMax) ? xMax : undefined,
minInterval: tickIntervalMs,
maxInterval: tickIntervalMs,
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } },
axisLabel: {
color: '#94a3b8',
formatter: (value: number) => formatAxisDate(value, labelFormat),
},
splitLine: { show: false },
},
yAxis: {
type: 'value',
scale: true,
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.35)' } },
axisLabel: { color: '#94a3b8' },
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.1)' } },
},
dataZoom: [
{
type: 'inside',
throttle: 80,
start: dataZoomState.start,
end: dataZoomState.end,
startValue: dataZoomState.startValue,
endValue: dataZoomState.endValue,
},
{
type: 'slider',
bottom: 18,
height: 28,
borderColor: 'rgba(148, 163, 184, 0.24)',
fillerColor: 'rgba(91, 143, 249, 0.18)',
handleStyle: {
color: '#d4a574',
borderColor: '#e8c9a0',
},
textStyle: { color: '#94a3b8' },
start: dataZoomState.start,
end: dataZoomState.end,
startValue: dataZoomState.startValue,
endValue: dataZoomState.endValue,
},
],
series:
data?.series.flatMap((series, index) =>
createSeriesOptions(series, index, tagLabels[series.tag] ?? series.tag),
) ?? [],
};
}

View File

@@ -0,0 +1,101 @@
import type { SeriesOption } from 'echarts';
import type { HistorySeries } from '../../entities/history/types';
import type { AvgPointValue } from './chartTypes';
export const SERIES_COLORS = [
'#5B8FF9',
'#5AD8A6',
'#F6BD16',
'#E8684A',
'#6DC8EC',
'#FF9D4D',
'#73D13D',
'#A7B3FF',
];
/** Создает avg-линию, вертикальные min/max-отрезки и точки экстремумов для одной серии. */
export function createSeriesOptions(series: HistorySeries, index: number, label: string): SeriesOption[] {
const color = SERIES_COLORS[index % SERIES_COLORS.length];
const avgData: AvgPointValue[] = series.points.map((point) => [
point.t,
point.avg,
point.min,
point.max,
point.count,
]);
const spreadData = series.points.flatMap((point) => [
[point.t, point.min],
[point.t, point.max],
[point.t, null],
]);
return [
{
name: `${label} min/max`,
type: 'line',
data: spreadData,
showSymbol: false,
connectNulls: false,
silent: true,
tooltip: { show: false },
legendHoverLink: false,
lineStyle: {
width: 1,
opacity: 0.42,
color,
},
emphasis: {
disabled: true,
},
z: 1,
},
{
name: `${label} min`,
type: 'scatter',
data: series.points.map((point) => [point.t, point.min]),
symbolSize: 4,
silent: true,
tooltip: { show: false },
itemStyle: {
color,
opacity: 0.72,
},
emphasis: {
disabled: true,
},
z: 2,
},
{
name: `${label} max`,
type: 'scatter',
data: series.points.map((point) => [point.t, point.max]),
symbolSize: 4,
silent: true,
tooltip: { show: false },
itemStyle: {
color,
opacity: 0.72,
},
emphasis: {
disabled: true,
},
z: 2,
},
{
name: label,
type: 'line',
showSymbol: false,
smooth: false,
sampling: 'lttb',
data: avgData,
lineStyle: {
width: 1.8,
color,
},
emphasis: {
focus: 'series',
},
z: 3,
},
];
}