decompose structure
This commit is contained in:
92
src/features/history-chart/HistoryChart.tsx
Normal file
92
src/features/history-chart/HistoryChart.tsx
Normal 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 />;
|
||||
}
|
||||
18
src/features/history-chart/chartTypes.ts
Normal file
18
src/features/history-chart/chartTypes.ts
Normal 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[];
|
||||
};
|
||||
102
src/features/history-chart/historyChartFormat.ts
Normal file
102
src/features/history-chart/historyChartFormat.ts
Normal 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('');
|
||||
}
|
||||
110
src/features/history-chart/historyChartOptions.ts
Normal file
110
src/features/history-chart/historyChartOptions.ts
Normal 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),
|
||||
) ?? [],
|
||||
};
|
||||
}
|
||||
101
src/features/history-chart/historyChartSeries.ts
Normal file
101
src/features/history-chart/historyChartSeries.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user