diff --git a/.env.example b/.env.example index 761539f..3dba985 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -branch= -DEV_API_URL=http://localhost:3101 +BRANCH= +#DEV_API_URL=http://localhost:3101 +DEV_API_URL=https://beta.drill.greact.ru VITE_DIAGRAM_API_URL=http://localhost:3002 VITE_TOIR_LIGHT_ORIGIN=https://toir-light.greact.ru VITE_KEYCLOAK_URL=https://sso.greact.ru diff --git a/src/features/edge-detail/components/ArchiveView.tsx b/src/features/edge-detail/components/ArchiveView.tsx index ce4dbfd..6f762e7 100644 --- a/src/features/edge-detail/components/ArchiveView.tsx +++ b/src/features/edge-detail/components/ArchiveView.tsx @@ -4,6 +4,8 @@ import { useRef } from 'react'; import { CalendarDays, CalendarClock, Check, ChevronDown, Clock3, DatabaseZap, Search } from 'lucide-react'; import type { CurrentItem } from '../../../entities/current/types'; import { HistoryChart } from '../../../features/history-chart/HistoryChart'; +import { HistoryChartAvgLineModeControl } from '../../../features/history-chart/HistoryChartAvgLineModeControl'; +import type { AvgLineMode } from '../../../features/history-chart/chartTypes'; import { RANGE_PRESETS, createRange, type DateRangeState } from '../../../features/history/dateRange'; import { formatNumber, toIsoFromInput } from '../../../utils/format'; import type { HistoryGranularity } from '../../../utils/historyGranularity'; @@ -93,6 +95,8 @@ export function ArchiveView({ onSelectVisibleTags, onToggleTag, }: ArchiveViewProps) { + const [avgLineMode, setAvgLineMode] = useState('auto'); + const [selectedRangePresetId, setSelectedRangePresetId] = useState('24h'); const [selectorOpen, setSelectorOpen] = useState(false); const selectedPreview = selectedTags.slice(0, 10); const hiddenSelectedCount = Math.max(0, selectedTags.length - selectedPreview.length); @@ -103,6 +107,7 @@ export function ArchiveView({ const fromIso = toIsoFromInput(range.from) as string; const toIso = toIsoFromInput(range.to) as string; const updateRangePart = (rangePart: RangePart, inputPart: RangeInputPart, value: string) => { + setSelectedRangePresetId(''); onRangeChange({ ...range, [rangePart]: updateRangeInputPart(range[rangePart], inputPart, value), @@ -149,14 +154,14 @@ export function ArchiveView({ onSearchChange(event.target.value)} placeholder="Поиск" />
- + */} - ))} +
+
+ {RANGE_PRESETS.map((preset) => ( + + ))} +
С @@ -250,11 +265,13 @@ export function ArchiveView({ onChange={(value) => updateRangePart('to', 'time', value)} />
+
{selectedTags.length ? ( ; }; -const ZOOM_REQUEST_DELAY_MS = 350; +const DAY_MS = 24 * 60 * 60 * 1000; -function isXAxisDataZoom(state: DataZoomState): boolean { - return state.dataZoomId?.startsWith('history-x-') || state.dataZoomIndex === 0 || state.dataZoomIndex === 1; -} - -/** Собирает начальный zoom-диапазон из внешних props графика. */ -function createInitialZoomRange({ - from, - granulate, - labelFormat, - tickIntervalMs, - to, -}: Pick): HistoryZoomRange { - return { from, to, granulate, labelFormat, tickIntervalMs }; -} - -/** Создает zoom-диапазон из миллисекунд и подбирает для него грануляцию. */ -function createZoomRange(fromMs: number, toMs: number): HistoryZoomRange { - const from = new Date(fromMs).toISOString(); - const to = new Date(toMs).toISOString(); - - return { - from, - to, - ...getHistoryGranularity(from, to), - }; -} - -/** Достает подинтервал X-зума из события ECharts. */ -function getZoomRangeFromEvent(state: DataZoomState, baseRange: HistoryZoomRange): HistoryZoomRange | null { - const baseFromMs = new Date(baseRange.from).getTime(); - const baseToMs = new Date(baseRange.to).getTime(); - const baseSpanMs = baseToMs - baseFromMs; - - if (!Number.isFinite(baseFromMs) || !Number.isFinite(baseToMs) || baseSpanMs <= 0) { - return null; +/** Определяет видимость соединительной avg-линии с учетом ручного режима и масштаба X-шкалы. */ +function shouldShowAvgLine(mode: AvgLineMode, range: HistoryZoomRange): boolean { + if (mode === 'show') { + return true; } - const startValue = Number(state.startValue); - const endValue = Number(state.endValue); - let nextFromMs = Number.isFinite(startValue) ? startValue : undefined; - let nextToMs = Number.isFinite(endValue) ? endValue : undefined; - - // ECharts иногда не передает startValue/endValue, поэтому используем start/end. - if (nextFromMs === undefined || nextToMs === undefined) { - const start = Number(state.start); - const end = Number(state.end); - - if (!Number.isFinite(start) || !Number.isFinite(end)) { - return null; - } - - nextFromMs = baseFromMs + (baseSpanMs * start) / 100; - nextToMs = baseFromMs + (baseSpanMs * end) / 100; + if (mode === 'hide') { + return false; } - // Проверка на выход из диапазона - const fromMs = Math.max(baseFromMs, Math.min(nextFromMs, nextToMs)); - const toMs = Math.min(baseToMs, Math.max(nextFromMs, nextToMs)); - - return toMs > fromMs ? createZoomRange(fromMs, toMs) : null; -} - -/** Проверяет, что lazy zoom не пытается повторно применить тот же диапазон. */ -function isSameZoomRange(left: HistoryZoomRange, right: HistoryZoomRange): boolean { - return left.from === right.from && left.to === right.to && left.granulate === right.granulate; -} - -/** Вычисляет, какую часть верхнеуровневого периода занимает текущий пользовательский zoom. */ -function createDataZoomState(baseRange: HistoryZoomRange, zoomRange: HistoryZoomRange): DataZoomState { - // Верхнеуровневый период берём из инпутов "С/По" и считаем его полными 0..100%. - const baseFromMs = new Date(baseRange.from).getTime(); - const baseToMs = new Date(baseRange.to).getTime(); - - // Пользовательский zoom живёт внутри верхнеуровневого периода и должен быть выделен на нижней шкале. - const zoomFromMs = new Date(zoomRange.from).getTime(); - const zoomToMs = new Date(zoomRange.to).getTime(); - const baseSpanMs = baseToMs - baseFromMs; - - // Если даты некорректны, показываем весь период, чтобы не ломать управление графиком. - if ( - !Number.isFinite(baseFromMs) || - !Number.isFinite(baseToMs) || - !Number.isFinite(zoomFromMs) || - !Number.isFinite(zoomToMs) || - baseSpanMs <= 0 - ) { - return { start: 0, end: 100 }; - } - - // Переводим абсолютные даты zoom-а в проценты относительно верхнеуровневого периода. - const start = ((zoomFromMs - baseFromMs) / baseSpanMs) * 100; - const end = ((zoomToMs - baseFromMs) / baseSpanMs) * 100; - - // Ограничиваем значения диапазоном 0..100, чтобы ECharts не получил выход за шкалу. - return { - start: Math.max(0, Math.min(100, start)), - end: Math.max(0, Math.min(100, end)), - }; + return (range.tickIntervalMs ?? parseGranulateMs(range.granulate)) < DAY_MS; } export function HistoryChart({ + avgLineMode, edge, from, granulate, @@ -149,7 +72,8 @@ export function HistoryChart({ to: zoomRange.to, tagLabels, }); - const series = useHistoryChartSeries(lines, zoomRange.granulate); + const showAvgLine = shouldShowAvgLine(avgLineMode, zoomRange); + const series = useHistoryChartSeries(lines, zoomRange.granulate, avgLineMode, showAvgLine); const legendData = useMemo(() => lines.map((line) => line.label), [lines]); const loading = lines.some((line) => line.loading); const hasData = series.length > 0; @@ -226,6 +150,7 @@ export function HistoryChart({ return ( 0} loading={loading} diff --git a/src/features/history-chart/HistoryChartArea.tsx b/src/features/history-chart/HistoryChartArea.tsx index 1130542..8937689 100644 --- a/src/features/history-chart/HistoryChartArea.tsx +++ b/src/features/history-chart/HistoryChartArea.tsx @@ -4,8 +4,10 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import type { WheelEvent } from 'react'; import type { DataZoomEventBatch } from './chartTypes'; import { echarts } from './historyChartEcharts'; +import type { AvgLineMode } from './chartTypes'; type HistoryChartAreaProps = { + avgLineMode: AvgLineMode; hasData: boolean; hasSelection: boolean; loading: boolean; @@ -30,6 +32,7 @@ function setXAxisWheelZoom(chart: ReturnType void; +}; + +const AVG_LINE_MODES: Array<{ value: AvgLineMode; label: string }> = [ + { value: 'auto', label: 'Авто' }, + { value: 'show', label: 'Рисовать' }, + { value: 'hide', label: 'Скрывать' }, +]; + +export function HistoryChartAvgLineModeControl({ value, onChange }: HistoryChartAvgLineModeControlProps) { + return ( +
+ Соединительная линия +
+ {AVG_LINE_MODES.map((mode) => ( + + ))} +
+
+ ); +} diff --git a/src/features/history-chart/chartTypes.ts b/src/features/history-chart/chartTypes.ts index f190479..e4ee2f1 100644 --- a/src/features/history-chart/chartTypes.ts +++ b/src/features/history-chart/chartTypes.ts @@ -2,7 +2,16 @@ import type { HistoryAxisLabelFormat } from '../../utils/historyGranularity'; export type AvgPointValue = [time: number, avg: number, min: number, max: number, count: number, slotMs?: number]; -export type HistoryBucketValue = [time: number, avg: number, min: number, max: number, count: number, slotMs: number]; +export type HistoryBucketValue = [ + time: number, + avg: number, + min: number, + max: number, + count: number, + slotMs: number, +]; + +export type AvgLineMode = 'auto' | 'show' | 'hide'; export type HistoryZoomRange = { from: string; diff --git a/src/features/history-chart/historyChartAvgLineSeries.ts b/src/features/history-chart/historyChartAvgLineSeries.ts new file mode 100644 index 0000000..a22e220 --- /dev/null +++ b/src/features/history-chart/historyChartAvgLineSeries.ts @@ -0,0 +1,92 @@ +import type { LineSeriesOption, SeriesOption } from 'echarts'; +import type { HistoryPoint } from '../../entities/history/types'; +import { parseGranulateMs } from '../../utils/historyGranularity'; +import { SERIES_COLORS } from './historyChartSeries'; + +type AvgLineSeriesValue = [time: number, avg: number, min: number, max: number, count: number, slotMs: number]; +type AvgLineSeriesPoint = AvgLineSeriesValue | null; + +function isFinitePoint(point: HistoryPoint): boolean { + return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite); +} + +function createAvgLineData(points: HistoryPoint[], granulate: string, breakOnGaps: boolean): AvgLineSeriesPoint[] { + const slotMs = parseGranulateMs(granulate); + const finitePoints = points.filter(isFinitePoint); + + if (!breakOnGaps) { + return finitePoints.map((point) => [ + new Date(point.time).getTime(), + point.avg_value, + point.min_value, + point.max_value, + point.point_count, + slotMs, + ]); + } + + return finitePoints.flatMap((point, index) => { + const currentTime = new Date(point.time).getTime(); + const currentPoint: AvgLineSeriesValue = [ + currentTime, + point.avg_value, + point.min_value, + point.max_value, + point.point_count, + slotMs, + ]; + + if (index === 0) { + return [currentPoint]; + } + + const previousTime = new Date(finitePoints[index - 1].time).getTime(); + const hasAcceptableGap = currentTime - previousTime <= slotMs * 50; + + return hasAcceptableGap ? [currentPoint] : [null, currentPoint]; + }); +} + +export function createAvgLineSeries( + points: HistoryPoint[], + index: number, + label: string, + granulate: string, + showAvgLine: boolean, + breakOnGaps: boolean, +): SeriesOption[] { + if (!showAvgLine) { + return []; + } + + const color = SERIES_COLORS[index % SERIES_COLORS.length]; + + return [ + { + name: `${label} avg-line`, + type: 'line', + data: createAvgLineData(points, granulate, breakOnGaps), + encode: { + x: 0, + y: 1, + }, + smooth: true, + connectNulls: false, + showSymbol: false, + symbol: 'none', + silent: true, + tooltip: { + show: false, + }, + lineStyle: { + color, + width: 1.5, + opacity: 0.78, + }, + emphasis: { + disabled: true, + }, + z: 4, + } as LineSeriesOption, + ]; +} diff --git a/src/features/history-chart/historyChartEcharts.ts b/src/features/history-chart/historyChartEcharts.ts index 8cf309e..537c4ef 100644 --- a/src/features/history-chart/historyChartEcharts.ts +++ b/src/features/history-chart/historyChartEcharts.ts @@ -1,4 +1,4 @@ -import { CustomChart } from 'echarts/charts'; +import { CustomChart, LineChart } from 'echarts/charts'; import { DataZoomComponent, GridComponent, @@ -10,6 +10,7 @@ import { CanvasRenderer } from 'echarts/renderers'; echarts.use([ CustomChart, + LineChart, GridComponent, LegendComponent, TooltipComponent, diff --git a/src/features/history-chart/historyChartFormat.ts b/src/features/history-chart/historyChartFormat.ts index c7dd9ce..7dc2407 100644 --- a/src/features/history-chart/historyChartFormat.ts +++ b/src/features/history-chart/historyChartFormat.ts @@ -56,7 +56,12 @@ export function formatAxisDate(value: number, labelFormat: HistoryAxisLabelForma function formatChartValue(value: number): string { return new Intl.NumberFormat('ru-RU', { - maximumFractionDigits: Math.abs(value) >= 100 ? 2 : 3, + maximumFractionDigits: + Math.abs(value) >= 1000 ? 0 + : Math.abs(value) >= 100 ? 1 + : Math.abs(value) >= 10 ? 2 + : Math.abs(value) >= 1 ? 3 + : 4, }).format(value); } @@ -141,7 +146,8 @@ export function formatTooltip(params: unknown): string { return ''; } - const header = formatTooltipInterval(firstValue); + const time = firstValue[0]; + const header = formatTooltipDateTime(time); const rows = avgItems.map((item) => { const value = item.value as AvgPointValue; @@ -149,8 +155,7 @@ export function formatTooltip(params: unknown): string { '
', `${item.seriesName ?? ''}`, '', - formatTooltipValue(value), - value[4] > 1 ? ` · ${value[4]} точек` : '', + formatChartValue(value[1]), '', '
', ].join(''); diff --git a/src/features/history-chart/historyChartSeries.ts b/src/features/history-chart/historyChartSeries.ts index d4daccc..86e1520 100644 --- a/src/features/history-chart/historyChartSeries.ts +++ b/src/features/history-chart/historyChartSeries.ts @@ -20,6 +20,15 @@ export const SERIES_COLORS = [ '#A7B3FF', ]; +const BUCKET = { + time: 0, + avg: 1, + min: 2, + max: 3, + count: 4, + slotMs: 5, +} as const; + type CartesianCoordSys = { x: number; y: number; @@ -27,10 +36,22 @@ type CartesianCoordSys = { height: number; }; +type ChartArea = CartesianCoordSys; + +type BucketValues = { + time: number; + avg: number; + min: number; + max: number; + slotMs: number; +}; + +/** Ограничивает число заданными границами, чтобы фигуры не выходили за canvas графика. */ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +/** Создает вертикальный градиент min..max с максимальной яркостью в позиции avg. */ function createAvgGradientStops(color: string, avgOffset: number) { const softZone = 0.18; @@ -43,103 +64,127 @@ function createAvgGradientStops(color: string, avgOffset: number) { ].sort((first, second) => first.offset - second.offset); } +/** Проверяет, что точку можно безопасно отрисовать на графике. */ function isFinitePoint(point: HistoryPoint): boolean { return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite); } -/** Упаковывает один bucket API в компактный tuple для custom series ECharts. */ +/** Упаковывает bucket в tuple для custom series ECharts. */ function createBucketData(points: HistoryPoint[], slotMs: number): HistoryBucketValue[] { - return points.filter(isFinitePoint).map((point) => [ - new Date(point.time).getTime(), - point.avg_value, - point.min_value, - point.max_value, - point.point_count, - slotMs, - ]); + const finitePoints = points.filter(isFinitePoint); + + return finitePoints.map((point) => [ + new Date(point.time).getTime(), + point.avg_value, + point.min_value, + point.max_value, + point.point_count, + slotMs, + ]); } -/** Рисует один bucket как временной слот: затухающий min..max и яркий маркер avg. */ +/** Читает tuple bucket-а из ECharts API и возвращает значения с понятными именами. */ +function readBucketValues(api: CustomSeriesRenderItemAPI): BucketValues { + return { + time: Number(api.value(BUCKET.time)), + avg: Number(api.value(BUCKET.avg)), + min: Number(api.value(BUCKET.min)), + max: Number(api.value(BUCKET.max)), + slotMs: Number(api.value(BUCKET.slotMs)), + }; +} + +/** Считает пиксельные фигуры одного bucket-а и обрезает их по области графика. */ +function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues, chartArea: ChartArea) { + const avgPoint = api.coord([values.time, values.avg]); + const minPoint = api.coord([values.time, values.min]); + const maxPoint = api.coord([values.time, values.max]); + const slotStart = api.coord([values.time - values.slotMs / 2, values.avg]); + const slotEnd = api.coord([values.time + values.slotMs / 2, values.avg]); + + const x = Math.min(slotStart[0], slotEnd[0]); + const rawHeight = Math.abs(maxPoint[1] - minPoint[1]); + const width = Math.max(2, Math.abs(slotEnd[0] - slotStart[0])); + const height = Math.max(2, rawHeight); + const y = Math.min(minPoint[1], maxPoint[1]) - (height - rawHeight) / 2; + const avgY = avgPoint[1]; + + const rangeShape = graphic.clipRectByRect({ x, y, width, height }, chartArea); + const avgShape = graphic.clipRectByRect({ x, y: avgY - 1, width, height: 2 }, chartArea); + + if (!rangeShape) { + return null; + } + + return { + avgPoint, + avgShape, + avgY, + rangeShape, + }; +} + +/** Рисует один bucket: min..max слот и горизонтальный avg-маркер. */ function renderBucket(color: string) { return (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn => { - const time = Number(api.value(0)); - const avg = Number(api.value(1)); - const min = Number(api.value(2)); - const max = Number(api.value(3)); - const slotMs = Number(api.value(5)); + const values = readBucketValues(api); - // ECharts отдает размеры области графика в runtime, но в публичных типах - // они не описаны. Поэтому держим cast локально рядом с clipping-логикой. - const coordSys = params.coordSys as unknown as CartesianCoordSys; + // ECharts отдает размеры области графика в runtime, но в публичных типах они не описаны. + const chartArea = params.coordSys as unknown as ChartArea; + const bucketShapes = createBucketShapes(api, values, chartArea); - // Переводим координаты данных (time/value) в пиксели canvas. - const avgPoint = api.coord([time, avg]); - const minPoint = api.coord([time, min]); - const maxPoint = api.coord([time, max]); - const slotStart = api.coord([time - slotMs / 2, avg]); - const slotEnd = api.coord([time + slotMs / 2, avg]); - - // Слот занимает один интервал грануляции по X и диапазон min..max по Y. - const x = Math.min(slotStart[0], slotEnd[0]); - const rawHeight = Math.abs(maxPoint[1] - minPoint[1]); - const width = Math.max(2, Math.abs(slotEnd[0] - slotStart[0])); - const height = Math.max(2, rawHeight); - const y = Math.min(minPoint[1], maxPoint[1]) - (height - rawHeight) / 2; - const avgY = avgPoint[1]; - const chartArea = { x: coordSys.x, y: coordSys.y, width: coordSys.width, height: coordSys.height }; - - // Обрезаем фигуры, чтобы при Y-зуме они не выходили за область графика. - const rangeShape = graphic.clipRectByRect( - { x, y, width, height }, - chartArea, - ); - const avgShape = graphic.clipRectByRect( - { x, y: avgY - 1, width, height: 2 }, - chartArea, - ); - - if (!rangeShape) { + if (!bucketShapes) { return null; } - const avgOffset = clamp((avgY - rangeShape.y) / rangeShape.height, 0, 1); + const avgOffset = clamp( + (bucketShapes.avgY - bucketShapes.rangeShape.y) / bucketShapes.rangeShape.height, + 0, + 1, + ); return { type: 'group', children: [ { type: 'rect', - shape: rangeShape, + shape: bucketShapes.rangeShape, style: { fill: new graphic.LinearGradient(0, 0, 0, 1, createAvgGradientStops(color, avgOffset)), }, }, - ...(avgShape ? [{ - type: 'rect' as const, - shape: avgShape, - style: { - fill: color, - }, - }] : []), + ...(bucketShapes.avgShape + ? [{ + type: 'rect' as const, + shape: bucketShapes.avgShape, + style: { + fill: color, + }, + }] + : []), ], }; }; } -/** Создает отдельный временной слот min..max с акцентом на avg для каждой точки history. */ -export function createSeriesOptions(points: HistoryPoint[], index: number, label: string, granulate: string): SeriesOption[] { +/** Создает bucket-серию на тег: min..max слот и горизонтальный avg-маркер. */ +export function createSeriesOptions( + points: HistoryPoint[], + index: number, + label: string, + granulate: string, +): SeriesOption[] { const color = SERIES_COLORS[index % SERIES_COLORS.length]; - const data = createBucketData(points, parseGranulateMs(granulate)); return [ { name: label, type: 'custom', - data, + data: createBucketData(points, parseGranulateMs(granulate)), renderItem: renderBucket(color), encode: { - x: 0, - y: [1, 2, 3], + x: BUCKET.time, + y: [BUCKET.avg, BUCKET.min, BUCKET.max], }, itemStyle: { color, diff --git a/src/features/history-chart/historyChartZoom.ts b/src/features/history-chart/historyChartZoom.ts new file mode 100644 index 0000000..e89ae69 --- /dev/null +++ b/src/features/history-chart/historyChartZoom.ts @@ -0,0 +1,97 @@ +import { getHistoryGranularity } from '../../utils/historyGranularity'; +import type { DataZoomState, HistoryZoomRange } from './chartTypes'; + +export const ZOOM_REQUEST_DELAY_MS = 180; + +export function isXAxisDataZoom(state: DataZoomState): boolean { + return state.dataZoomId?.startsWith('history-x-') || state.dataZoomIndex === 0 || state.dataZoomIndex === 1; +} + +/** Собирает начальный zoom-диапазон из внешних props графика. */ +export function createInitialZoomRange(range: HistoryZoomRange): HistoryZoomRange { + return range; +} + +/** Создает zoom-диапазон из миллисекунд и подбирает для него грануляцию. */ +function createZoomRange(fromMs: number, toMs: number): HistoryZoomRange { + const from = new Date(fromMs).toISOString(); + const to = new Date(toMs).toISOString(); + + return { + from, + to, + ...getHistoryGranularity(from, to), + }; +} + +/** Достает подинтервал X-зума из события ECharts. */ +export function getZoomRangeFromEvent(state: DataZoomState, baseRange: HistoryZoomRange): HistoryZoomRange | null { + const baseFromMs = new Date(baseRange.from).getTime(); + const baseToMs = new Date(baseRange.to).getTime(); + const baseSpanMs = baseToMs - baseFromMs; + + if (!Number.isFinite(baseFromMs) || !Number.isFinite(baseToMs) || baseSpanMs <= 0) { + return null; + } + + const startValue = Number(state.startValue); + const endValue = Number(state.endValue); + let nextFromMs = Number.isFinite(startValue) ? startValue : undefined; + let nextToMs = Number.isFinite(endValue) ? endValue : undefined; + + // ECharts иногда не передает startValue/endValue, поэтому используем проценты start/end. + if (nextFromMs === undefined || nextToMs === undefined) { + const start = Number(state.start); + const end = Number(state.end); + + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + + nextFromMs = baseFromMs + (baseSpanMs * start) / 100; + nextToMs = baseFromMs + (baseSpanMs * end) / 100; + } + + const fromMs = Math.max(baseFromMs, Math.min(nextFromMs, nextToMs)); + const toMs = Math.min(baseToMs, Math.max(nextFromMs, nextToMs)); + + return toMs > fromMs ? createZoomRange(fromMs, toMs) : null; +} + +/** Проверяет, что lazy zoom не пытается повторно применить тот же диапазон. */ +export function isSameZoomRange(left: HistoryZoomRange, right: HistoryZoomRange): boolean { + return left.from === right.from && left.to === right.to && left.granulate === right.granulate; +} + +/** Вычисляет, какую часть верхнеуровневого периода занимает текущий пользовательский zoom. */ +export function createDataZoomState(baseRange: HistoryZoomRange, zoomRange: HistoryZoomRange): DataZoomState { + // Верхнеуровневый период берем из инпутов "С/По" и считаем его полными 0..100%. + const baseFromMs = new Date(baseRange.from).getTime(); + const baseToMs = new Date(baseRange.to).getTime(); + + // Пользовательский zoom живет внутри верхнеуровневого периода и выделяется на нижней шкале. + const zoomFromMs = new Date(zoomRange.from).getTime(); + const zoomToMs = new Date(zoomRange.to).getTime(); + const baseSpanMs = baseToMs - baseFromMs; + + // Если даты некорректны, показываем весь период, чтобы не ломать управление графиком. + if ( + !Number.isFinite(baseFromMs) || + !Number.isFinite(baseToMs) || + !Number.isFinite(zoomFromMs) || + !Number.isFinite(zoomToMs) || + baseSpanMs <= 0 + ) { + return { start: 0, end: 100 }; + } + + // Переводим абсолютные даты zoom-а в проценты относительно верхнеуровневого периода. + const start = ((zoomFromMs - baseFromMs) / baseSpanMs) * 100; + const end = ((zoomToMs - baseFromMs) / baseSpanMs) * 100; + + // Ограничиваем значения диапазоном 0..100, чтобы ECharts не получил выход за шкалу. + return { + start: Math.max(0, Math.min(100, start)), + end: Math.max(0, Math.min(100, end)), + }; +} diff --git a/src/features/history-chart/useHistoryChartSeries.ts b/src/features/history-chart/useHistoryChartSeries.ts index 32234e5..7cef921 100644 --- a/src/features/history-chart/useHistoryChartSeries.ts +++ b/src/features/history-chart/useHistoryChartSeries.ts @@ -1,14 +1,26 @@ import { useMemo } from 'react'; import type { SeriesOption } from 'echarts'; +import { createAvgLineSeries } from './historyChartAvgLineSeries'; import { createSeriesOptions } from './historyChartSeries'; import type { HistoryChartLineData } from './useHistoryChartQueries'; +import type { AvgLineMode } from './chartTypes'; -export function useHistoryChartSeries(lines: HistoryChartLineData[], granulate: string): SeriesOption[] { +export function useHistoryChartSeries( + lines: HistoryChartLineData[], + granulate: string, + avgLineMode: AvgLineMode, + showAvgLine: boolean, +): SeriesOption[] { return useMemo( () => lines.flatMap((line) => - line.rows.length ? createSeriesOptions(line.rows, line.index, line.label, granulate) : [], + line.rows.length + ? [ + ...createSeriesOptions(line.rows, line.index, line.label, granulate), + ...createAvgLineSeries(line.rows, line.index, line.label, granulate, showAvgLine, avgLineMode === 'auto'), + ] + : [], ), - [granulate, lines], + [granulate, lines, showAvgLine, avgLineMode], ); } diff --git a/src/styles/charts.css b/src/styles/charts.css index 6b8d3b2..10d26dc 100644 --- a/src/styles/charts.css +++ b/src/styles/charts.css @@ -8,6 +8,58 @@ height: 100%; } +.history-chart-shell { + position: relative; +} + +.history-chart-controls { + position: absolute; + top: 8px; + right: 12px; + z-index: 2; + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.history-chart-line-mode { + padding: 3px; +} + +.archive-avgline-mode-control .history-chart-line-mode button { + border-radius: 0; +} + +.archive-avgline-mode-control .history-chart-line-mode button:first-child { + border-top-left-radius: var(--radius); + border-bottom-left-radius: var(--radius); +} + +.archive-avgline-mode-control .history-chart-line-mode button:last-child { + border-top-right-radius: var(--radius); + border-bottom-right-radius: var(--radius); +} + +.segmented--range-preset button { + border-radius: 0; +} + +.segmented--range-preset button:first-child { + border-top-left-radius: var(--radius); + border-bottom-left-radius: var(--radius); +} + +.segmented--range-preset button:last-child { + border-top-right-radius: var(--radius); + border-bottom-right-radius: var(--radius); +} + +.history-chart-line-mode button { + font-size: inherit; +} + .chart-section--archive .history-chart-shell, .chart-section--archive .history-chart { flex: 1; @@ -24,10 +76,6 @@ border-radius: var(--radius); } -.chart-tooltip { - min-width: 240px; -} - .chart-tooltip-title { margin-bottom: 8px; color: #e5e7eb; @@ -36,7 +84,7 @@ .chart-tooltip-row { display: grid; - grid-template-columns: minmax(90px, 1fr) auto; + grid-template-columns: 1fr auto; align-items: center; gap: 14px; margin-top: 5px; diff --git a/src/styles/components/controls.css b/src/styles/components/controls.css index a797e61..3e6cb6f 100644 --- a/src/styles/components/controls.css +++ b/src/styles/components/controls.css @@ -73,6 +73,12 @@ background: rgba(201, 122, 61, 0.18); } +.segmented__button--active { + border-color: rgba(212, 165, 116, 0.34); + background: rgba(201, 122, 61, 0.14); + color: #f8fafc; +} + .source-chip, .status-pill, .metric-card__state { diff --git a/src/styles/detail/archive.css b/src/styles/detail/archive.css index 48afed0..2bf48ef 100644 --- a/src/styles/detail/archive.css +++ b/src/styles/detail/archive.css @@ -216,6 +216,14 @@ font-size: 0.84rem; } +.archive-toolbar-segmented-control { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.84rem; +} + .archive-date-input { color-scheme: dark; min-height: 36px; diff --git a/vite.config.ts b/vite.config.ts index 24a24f1..a32e4cd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,7 +15,6 @@ export default defineConfig(({ mode }) => { '/api': { target: DEV_API_URL, changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, ''), }, }, },