From e796dced6c6af6a5d37245a213085d5795e967a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Tue, 7 Jul 2026 19:55:16 +0300 Subject: [PATCH 1/7] =?UTF-8?q?BUR-67=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=80=D0=B8=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/history-chart/HistoryChart.tsx | 121 ++------- .../history-chart/HistoryChartArea.tsx | 29 ++- src/features/history-chart/chartTypes.ts | 13 +- .../history-chart/historyChartSeries.ts | 238 +++++++++++++----- .../history-chart/historyChartZoom.ts | 97 +++++++ .../history-chart/useHistoryChartSeries.ts | 10 +- src/styles/charts.css | 32 +++ 7 files changed, 375 insertions(+), 165 deletions(-) create mode 100644 src/features/history-chart/historyChartZoom.ts diff --git a/src/features/history-chart/HistoryChart.tsx b/src/features/history-chart/HistoryChart.tsx index 0bc6795..befc54c 100644 --- a/src/features/history-chart/HistoryChart.tsx +++ b/src/features/history-chart/HistoryChart.tsx @@ -1,9 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { EChartsOption } from 'echarts'; -import { getHistoryGranularity, type HistoryAxisLabelFormat } from '../../utils/historyGranularity'; -import type { DataZoomEventBatch, DataZoomState, HistoryZoomRange } from './chartTypes'; +import { parseGranulateMs, type HistoryAxisLabelFormat } from '../../utils/historyGranularity'; +import type { AvgLineMode, DataZoomEventBatch, HistoryZoomRange } from './chartTypes'; import { HistoryChartArea } from './HistoryChartArea'; import { createHistoryChartOptions } from './historyChartOptions'; +import { + createDataZoomState, + createInitialZoomRange, + getZoomRangeFromEvent, + isSameZoomRange, + isXAxisDataZoom, + ZOOM_REQUEST_DELAY_MS, +} from './historyChartZoom'; import { useHistoryChartQueries } from './useHistoryChartQueries'; import { useHistoryChartSeries } from './useHistoryChartSeries'; @@ -18,106 +26,19 @@ type HistoryChartProps = { tagLabels?: Record; }; -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-линии с учетом ручного режима и текущей грануляции. */ +function shouldShowAvgLine(mode: AvgLineMode, granulate: string): 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 parseGranulateMs(granulate) < DAY_MS; } export function HistoryChart({ @@ -141,6 +62,7 @@ export function HistoryChart({ const zoomRangeRef = useRef(zoomRange); const zoomTimerRef = useRef | null>(null); const lastReadyOptionRef = useRef(null); + const [avgLineMode, setAvgLineMode] = useState('auto'); const lines = useHistoryChartQueries({ edge, from: zoomRange.from, @@ -149,7 +71,8 @@ export function HistoryChart({ to: zoomRange.to, tagLabels, }); - const series = useHistoryChartSeries(lines, zoomRange.granulate); + const showAvgLine = shouldShowAvgLine(avgLineMode, zoomRange.granulate); + const series = useHistoryChartSeries(lines, zoomRange.granulate, showAvgLine); const legendData = useMemo(() => lines.map((line) => line.label), [lines]); const loading = lines.some((line) => line.loading); const hasData = series.length > 0; @@ -229,8 +152,10 @@ export function HistoryChart({ hasData={displayHasData} hasSelection={tags.length > 0} loading={loading} + avgLineMode={avgLineMode} option={displayOption} onDataZoom={handleDataZoom} + onAvgLineModeChange={setAvgLineMode} /> ); } diff --git a/src/features/history-chart/HistoryChartArea.tsx b/src/features/history-chart/HistoryChartArea.tsx index 1130542..1849971 100644 --- a/src/features/history-chart/HistoryChartArea.tsx +++ b/src/features/history-chart/HistoryChartArea.tsx @@ -2,17 +2,26 @@ import type { EChartsOption } from 'echarts'; import ReactEChartsCore from 'echarts-for-react/esm/core.js'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import type { WheelEvent } from 'react'; -import type { DataZoomEventBatch } from './chartTypes'; +import type { AvgLineMode, DataZoomEventBatch } from './chartTypes'; import { echarts } from './historyChartEcharts'; type HistoryChartAreaProps = { + avgLineMode: AvgLineMode; hasData: boolean; hasSelection: boolean; loading: boolean; option: EChartsOption; + onAvgLineModeChange: (mode: AvgLineMode) => void; onDataZoom: (event: DataZoomEventBatch) => void; }; +// Порядок режимов соответствует требованию: авто, принудительно рисовать, принудительно скрыть. +const AVG_LINE_MODES: Array<{ value: AvgLineMode; label: string }> = [ + { value: 'auto', label: 'Авто' }, + { value: 'show', label: 'Рисовать' }, + { value: 'hide', label: 'Не рисовать' }, +]; + /** Включает или выключает нативный X-зум колесом, чтобы Shift + колесо работал только по Y. */ function setXAxisWheelZoom(chart: ReturnType, enabled: boolean): void { chart.setOption( @@ -30,10 +39,12 @@ function setXAxisWheelZoom(chart: ReturnType | null>(null); @@ -100,6 +111,22 @@ export function HistoryChartArea({ return (
+
+ Линия avg +
+ {AVG_LINE_MODES.map((mode) => ( + + ))} +
+
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 и предыдущую avg-точку в 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, index) => { + const previousPoint = finitePoints[index - 1]; + + return [ + new Date(point.time).getTime(), + point.avg_value, + point.min_value, + point.max_value, + point.point_count, + slotMs, + previousPoint ? new Date(previousPoint.time).getTime() : null, + previousPoint?.avg_value ?? null, + ]; + }); } -/** Рисует один bucket как временной слот: затухающий min..max и яркий маркер avg. */ -function renderBucket(color: string) { +/** Читает tuple bucket-а из ECharts API и возвращает значения с понятными именами. */ +function readBucketValues(api: CustomSeriesRenderItemAPI): BucketValues { + const previousTimeValue = api.value(BUCKET.previousTime); + const previousAvgValue = api.value(BUCKET.previousAvg); + + 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)), + previousTime: Number(previousTimeValue), + previousAvg: Number(previousAvgValue), + previousTimeValue, + previousAvgValue, + }; +} + +/** Проверяет, можно ли рисовать соединительный отрезок к предыдущей avg-точке. */ +function hasPreviousAvg(values: BucketValues): boolean { + return ( + values.previousTimeValue !== null && + values.previousTimeValue !== undefined && + values.previousAvgValue !== null && + values.previousAvgValue !== undefined && + Number.isFinite(values.previousTime) && + Number.isFinite(values.previousAvg) + ); +} + +/** Считает пиксельные фигуры одного 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, + }; +} + +/** Создает отрезок к предыдущей avg-точке, если режим соединительной линии включен. */ +function createAvgLineShape( + api: CustomSeriesRenderItemAPI, + values: BucketValues, + avgPoint: number[], + avgY: number, + chartArea: ChartArea, +) { + if (!hasPreviousAvg(values)) { + return null; + } + + const previousPoint = api.coord([values.previousTime, values.previousAvg]); + + return { + x1: clamp(previousPoint[0], chartArea.x, chartArea.x + chartArea.width), + y1: clamp(previousPoint[1], chartArea.y, chartArea.y + chartArea.height), + x2: clamp(avgPoint[0], chartArea.x, chartArea.x + chartArea.width), + y2: clamp(avgY, chartArea.y, chartArea.y + chartArea.height), + }; +} + +/** Рисует один bucket: min..max слот, avg-маркер и опциональный отрезок к предыдущей avg-точке. */ +function renderBucket(color: string, showAvgLine: boolean) { 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); + // Avg-линия рисуется внутри той же custom-серии, чтобы не удваивать число ECharts-серий при зуме. + const lineShape = showAvgLine + ? createAvgLineShape(api, values, bucketShapes.avgPoint, bucketShapes.avgY, chartArea) + : null; + 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, - }, - }] : []), + ...(lineShape + ? [{ + type: 'line' as const, + shape: lineShape, + style: { + stroke: color, + lineWidth: 1, + opacity: 0.72, + }, + }] + : []), + ...(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[] { +/** Создает одну custom-серию на тег: bucket-слоты и avg-линия живут в одном renderItem. */ +export function createSeriesOptions( + points: HistoryPoint[], + index: number, + label: string, + granulate: string, + showAvgLine: boolean, +): SeriesOption[] { const color = SERIES_COLORS[index % SERIES_COLORS.length]; - const data = createBucketData(points, parseGranulateMs(granulate)); return [ { name: label, type: 'custom', - data, - renderItem: renderBucket(color), + data: createBucketData(points, parseGranulateMs(granulate)), + renderItem: renderBucket(color, showAvgLine), 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..7f23c98 100644 --- a/src/features/history-chart/useHistoryChartSeries.ts +++ b/src/features/history-chart/useHistoryChartSeries.ts @@ -3,12 +3,16 @@ import type { SeriesOption } from 'echarts'; import { createSeriesOptions } from './historyChartSeries'; import type { HistoryChartLineData } from './useHistoryChartQueries'; -export function useHistoryChartSeries(lines: HistoryChartLineData[], granulate: string): SeriesOption[] { +export function useHistoryChartSeries( + lines: HistoryChartLineData[], + granulate: string, + 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, showAvgLine) : [], ), - [granulate, lines], + [granulate, lines, showAvgLine], ); } diff --git a/src/styles/charts.css b/src/styles/charts.css index 6b8d3b2..8bba975 100644 --- a/src/styles/charts.css +++ b/src/styles/charts.css @@ -8,6 +8,38 @@ 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; +} + +.history-chart-line-mode button { + min-height: 24px; + padding: 0 8px; + font-size: 12px; +} + +.history-chart-line-mode__button--active { + color: #f8fafc; + background: rgba(212, 165, 116, 0.16); + border-color: rgba(212, 165, 116, 0.38); +} + .chart-section--archive .history-chart-shell, .chart-section--archive .history-chart { flex: 1; From 486221b9803fad1dd6f95b1d73b2528e145fb4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Wed, 8 Jul 2026 00:21:15 +0300 Subject: [PATCH 2/7] =?UTF-8?q?BUR-67=20=D1=81=D0=BA=D0=BE=D1=80=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=90=D0=B2=D1=82=D0=BE,=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B5=D0=B4.=20=D0=BB=D0=B8=D0=BD=D0=B8=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B8=D1=81=D1=83=D0=B5=D1=82=D1=81=D1=8F=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/history-chart/HistoryChart.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/history-chart/HistoryChart.tsx b/src/features/history-chart/HistoryChart.tsx index befc54c..5b3647f 100644 --- a/src/features/history-chart/HistoryChart.tsx +++ b/src/features/history-chart/HistoryChart.tsx @@ -28,8 +28,8 @@ type HistoryChartProps = { const DAY_MS = 24 * 60 * 60 * 1000; -/** Определяет видимость соединительной avg-линии с учетом ручного режима и текущей грануляции. */ -function shouldShowAvgLine(mode: AvgLineMode, granulate: string): boolean { +/** Определяет видимость соединительной avg-линии с учетом ручного режима и масштаба X-шкалы. */ +function shouldShowAvgLine(mode: AvgLineMode, range: HistoryZoomRange): boolean { if (mode === 'show') { return true; } @@ -38,7 +38,7 @@ function shouldShowAvgLine(mode: AvgLineMode, granulate: string): boolean { return false; } - return parseGranulateMs(granulate) < DAY_MS; + return (range.tickIntervalMs ?? parseGranulateMs(range.granulate)) < DAY_MS; } export function HistoryChart({ @@ -71,7 +71,7 @@ export function HistoryChart({ to: zoomRange.to, tagLabels, }); - const showAvgLine = shouldShowAvgLine(avgLineMode, zoomRange.granulate); + const showAvgLine = shouldShowAvgLine(avgLineMode, zoomRange); const series = useHistoryChartSeries(lines, zoomRange.granulate, showAvgLine); const legendData = useMemo(() => lines.map((line) => line.label), [lines]); const loading = lines.some((line) => line.loading); From 5ffe9192c10c6ba85666ba246a60dbfca683d99e Mon Sep 17 00:00:00 2001 From: Nikita Biziaev Date: Wed, 8 Jul 2026 13:22:18 +0500 Subject: [PATCH 3/7] correct gev proxy --- .env.example | 5 +++-- vite.config.ts | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) 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/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/, ''), }, }, }, From 9a542ce33e5f27f03aeef8825055dde88c416f25 Mon Sep 17 00:00:00 2001 From: Nikita Biziaev Date: Wed, 8 Jul 2026 14:17:10 +0500 Subject: [PATCH 4/7] avg line serie and mode --- src/features/history-chart/HistoryChart.tsx | 2 +- .../history-chart/HistoryChartArea.tsx | 1 + src/features/history-chart/chartTypes.ts | 2 - .../historyChartAvgLineSeries.ts | 92 +++++++++++++++++++ .../history-chart/historyChartEcharts.ts | 3 +- .../history-chart/historyChartSeries.ts | 83 ++--------------- .../history-chart/useHistoryChartSeries.ts | 12 ++- 7 files changed, 113 insertions(+), 82 deletions(-) create mode 100644 src/features/history-chart/historyChartAvgLineSeries.ts diff --git a/src/features/history-chart/HistoryChart.tsx b/src/features/history-chart/HistoryChart.tsx index 5b3647f..e32a5f9 100644 --- a/src/features/history-chart/HistoryChart.tsx +++ b/src/features/history-chart/HistoryChart.tsx @@ -72,7 +72,7 @@ export function HistoryChart({ tagLabels, }); const showAvgLine = shouldShowAvgLine(avgLineMode, zoomRange); - const series = useHistoryChartSeries(lines, zoomRange.granulate, showAvgLine); + 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; diff --git a/src/features/history-chart/HistoryChartArea.tsx b/src/features/history-chart/HistoryChartArea.tsx index 1849971..dea99a6 100644 --- a/src/features/history-chart/HistoryChartArea.tsx +++ b/src/features/history-chart/HistoryChartArea.tsx @@ -128,6 +128,7 @@ export function HistoryChartArea({
[ + 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 * 100; + + 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/historyChartSeries.ts b/src/features/history-chart/historyChartSeries.ts index 8000176..86e1520 100644 --- a/src/features/history-chart/historyChartSeries.ts +++ b/src/features/history-chart/historyChartSeries.ts @@ -27,8 +27,6 @@ const BUCKET = { max: 3, count: 4, slotMs: 5, - previousTime: 6, - previousAvg: 7, } as const; type CartesianCoordSys = { @@ -46,10 +44,6 @@ type BucketValues = { min: number; max: number; slotMs: number; - previousTime: number; - previousAvg: number; - previousTimeValue: unknown; - previousAvgValue: unknown; }; /** Ограничивает число заданными границами, чтобы фигуры не выходили за canvas графика. */ @@ -75,56 +69,31 @@ function isFinitePoint(point: HistoryPoint): boolean { return [point.avg_value, point.min_value, point.max_value].every(Number.isFinite); } -/** Упаковывает bucket и предыдущую avg-точку в tuple для custom series ECharts. */ +/** Упаковывает bucket в tuple для custom series ECharts. */ function createBucketData(points: HistoryPoint[], slotMs: number): HistoryBucketValue[] { const finitePoints = points.filter(isFinitePoint); - return finitePoints.map((point, index) => { - const previousPoint = finitePoints[index - 1]; - - return [ + return finitePoints.map((point) => [ new Date(point.time).getTime(), point.avg_value, point.min_value, point.max_value, point.point_count, slotMs, - previousPoint ? new Date(previousPoint.time).getTime() : null, - previousPoint?.avg_value ?? null, - ]; - }); + ]); } /** Читает tuple bucket-а из ECharts API и возвращает значения с понятными именами. */ function readBucketValues(api: CustomSeriesRenderItemAPI): BucketValues { - const previousTimeValue = api.value(BUCKET.previousTime); - const previousAvgValue = api.value(BUCKET.previousAvg); - 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)), - previousTime: Number(previousTimeValue), - previousAvg: Number(previousAvgValue), - previousTimeValue, - previousAvgValue, }; } -/** Проверяет, можно ли рисовать соединительный отрезок к предыдущей avg-точке. */ -function hasPreviousAvg(values: BucketValues): boolean { - return ( - values.previousTimeValue !== null && - values.previousTimeValue !== undefined && - values.previousAvgValue !== null && - values.previousAvgValue !== undefined && - Number.isFinite(values.previousTime) && - Number.isFinite(values.previousAvg) - ); -} - /** Считает пиксельные фигуры одного bucket-а и обрезает их по области графика. */ function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues, chartArea: ChartArea) { const avgPoint = api.coord([values.time, values.avg]); @@ -155,30 +124,8 @@ function createBucketShapes(api: CustomSeriesRenderItemAPI, values: BucketValues }; } -/** Создает отрезок к предыдущей avg-точке, если режим соединительной линии включен. */ -function createAvgLineShape( - api: CustomSeriesRenderItemAPI, - values: BucketValues, - avgPoint: number[], - avgY: number, - chartArea: ChartArea, -) { - if (!hasPreviousAvg(values)) { - return null; - } - - const previousPoint = api.coord([values.previousTime, values.previousAvg]); - - return { - x1: clamp(previousPoint[0], chartArea.x, chartArea.x + chartArea.width), - y1: clamp(previousPoint[1], chartArea.y, chartArea.y + chartArea.height), - x2: clamp(avgPoint[0], chartArea.x, chartArea.x + chartArea.width), - y2: clamp(avgY, chartArea.y, chartArea.y + chartArea.height), - }; -} - -/** Рисует один bucket: min..max слот, avg-маркер и опциональный отрезок к предыдущей avg-точке. */ -function renderBucket(color: string, showAvgLine: boolean) { +/** Рисует один bucket: min..max слот и горизонтальный avg-маркер. */ +function renderBucket(color: string) { return (params: CustomSeriesRenderItemParams, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn => { const values = readBucketValues(api); @@ -190,10 +137,6 @@ function renderBucket(color: string, showAvgLine: boolean) { return null; } - // Avg-линия рисуется внутри той же custom-серии, чтобы не удваивать число ECharts-серий при зуме. - const lineShape = showAvgLine - ? createAvgLineShape(api, values, bucketShapes.avgPoint, bucketShapes.avgY, chartArea) - : null; const avgOffset = clamp( (bucketShapes.avgY - bucketShapes.rangeShape.y) / bucketShapes.rangeShape.height, 0, @@ -210,17 +153,6 @@ function renderBucket(color: string, showAvgLine: boolean) { fill: new graphic.LinearGradient(0, 0, 0, 1, createAvgGradientStops(color, avgOffset)), }, }, - ...(lineShape - ? [{ - type: 'line' as const, - shape: lineShape, - style: { - stroke: color, - lineWidth: 1, - opacity: 0.72, - }, - }] - : []), ...(bucketShapes.avgShape ? [{ type: 'rect' as const, @@ -235,13 +167,12 @@ function renderBucket(color: string, showAvgLine: boolean) { }; } -/** Создает одну custom-серию на тег: bucket-слоты и avg-линия живут в одном renderItem. */ +/** Создает bucket-серию на тег: min..max слот и горизонтальный avg-маркер. */ export function createSeriesOptions( points: HistoryPoint[], index: number, label: string, granulate: string, - showAvgLine: boolean, ): SeriesOption[] { const color = SERIES_COLORS[index % SERIES_COLORS.length]; @@ -250,7 +181,7 @@ export function createSeriesOptions( name: label, type: 'custom', data: createBucketData(points, parseGranulateMs(granulate)), - renderItem: renderBucket(color, showAvgLine), + renderItem: renderBucket(color), encode: { x: BUCKET.time, y: [BUCKET.avg, BUCKET.min, BUCKET.max], diff --git a/src/features/history-chart/useHistoryChartSeries.ts b/src/features/history-chart/useHistoryChartSeries.ts index 7f23c98..7cef921 100644 --- a/src/features/history-chart/useHistoryChartSeries.ts +++ b/src/features/history-chart/useHistoryChartSeries.ts @@ -1,18 +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, + avgLineMode: AvgLineMode, showAvgLine: boolean, ): SeriesOption[] { return useMemo( () => lines.flatMap((line) => - line.rows.length ? createSeriesOptions(line.rows, line.index, line.label, granulate, showAvgLine) : [], + line.rows.length + ? [ + ...createSeriesOptions(line.rows, line.index, line.label, granulate), + ...createAvgLineSeries(line.rows, line.index, line.label, granulate, showAvgLine, avgLineMode === 'auto'), + ] + : [], ), - [granulate, lines, showAvgLine], + [granulate, lines, showAvgLine, avgLineMode], ); } From 1127b14946ac4bc26bf9501b4e193d38188268e0 Mon Sep 17 00:00:00 2001 From: Nikita Biziaev Date: Wed, 8 Jul 2026 14:48:50 +0500 Subject: [PATCH 5/7] segmented buttons style --- .../edge-detail/components/ArchiveView.tsx | 9 ++++- src/features/history-chart/HistoryChart.tsx | 6 +-- .../history-chart/HistoryChartArea.tsx | 28 +------------- .../HistoryChartAvgLineModeControl.tsx | 33 +++++++++++++++++ .../historyChartAvgLineSeries.ts | 2 +- src/styles/charts.css | 37 ++++++++++++++++--- src/styles/detail/archive.css | 8 ++++ 7 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 src/features/history-chart/HistoryChartAvgLineModeControl.tsx diff --git a/src/features/edge-detail/components/ArchiveView.tsx b/src/features/edge-detail/components/ArchiveView.tsx index ce4dbfd..06ce2b7 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,7 @@ export function ArchiveView({ onSelectVisibleTags, onToggleTag, }: ArchiveViewProps) { + const [avgLineMode, setAvgLineMode] = useState('auto'); const [selectorOpen, setSelectorOpen] = useState(false); const selectedPreview = selectedTags.slice(0, 10); const hiddenSelectedCount = Math.max(0, selectedTags.length - selectedPreview.length); @@ -209,7 +212,7 @@ export function ArchiveView({ onPointerEnter={() => setSelectorOpen(false)} >
-
+
{RANGE_PRESETS.map((preset) => (
+
{selectedTags.length ? ( | null>(null); const lastReadyOptionRef = useRef(null); - const [avgLineMode, setAvgLineMode] = useState('auto'); const lines = useHistoryChartQueries({ edge, from: zoomRange.from, @@ -149,13 +150,12 @@ export function HistoryChart({ return ( 0} loading={loading} - avgLineMode={avgLineMode} option={displayOption} onDataZoom={handleDataZoom} - onAvgLineModeChange={setAvgLineMode} /> ); } diff --git a/src/features/history-chart/HistoryChartArea.tsx b/src/features/history-chart/HistoryChartArea.tsx index dea99a6..8937689 100644 --- a/src/features/history-chart/HistoryChartArea.tsx +++ b/src/features/history-chart/HistoryChartArea.tsx @@ -2,8 +2,9 @@ import type { EChartsOption } from 'echarts'; import ReactEChartsCore from 'echarts-for-react/esm/core.js'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import type { WheelEvent } from 'react'; -import type { AvgLineMode, DataZoomEventBatch } from './chartTypes'; +import type { DataZoomEventBatch } from './chartTypes'; import { echarts } from './historyChartEcharts'; +import type { AvgLineMode } from './chartTypes'; type HistoryChartAreaProps = { avgLineMode: AvgLineMode; @@ -11,17 +12,9 @@ type HistoryChartAreaProps = { hasSelection: boolean; loading: boolean; option: EChartsOption; - onAvgLineModeChange: (mode: AvgLineMode) => void; onDataZoom: (event: DataZoomEventBatch) => void; }; -// Порядок режимов соответствует требованию: авто, принудительно рисовать, принудительно скрыть. -const AVG_LINE_MODES: Array<{ value: AvgLineMode; label: string }> = [ - { value: 'auto', label: 'Авто' }, - { value: 'show', label: 'Рисовать' }, - { value: 'hide', label: 'Не рисовать' }, -]; - /** Включает или выключает нативный X-зум колесом, чтобы Shift + колесо работал только по Y. */ function setXAxisWheelZoom(chart: ReturnType, enabled: boolean): void { chart.setOption( @@ -44,7 +37,6 @@ export function HistoryChartArea({ hasSelection, loading, option, - onAvgLineModeChange, onDataZoom, }: HistoryChartAreaProps) { const chartRef = useRef | null>(null); @@ -111,22 +103,6 @@ export function HistoryChartArea({ return (
-
- Линия avg -
- {AVG_LINE_MODES.map((mode) => ( - - ))} -
-
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 +
+ {AVG_LINE_MODES.map((mode) => ( + + ))} +
+
+ ); +} diff --git a/src/features/history-chart/historyChartAvgLineSeries.ts b/src/features/history-chart/historyChartAvgLineSeries.ts index ae5ef7c..9dd2080 100644 --- a/src/features/history-chart/historyChartAvgLineSeries.ts +++ b/src/features/history-chart/historyChartAvgLineSeries.ts @@ -41,7 +41,7 @@ function createAvgLineData(points: HistoryPoint[], granulate: string, breakOnGap } const previousTime = new Date(finitePoints[index - 1].time).getTime(); - const hasAcceptableGap = currentTime - previousTime <= slotMs * 100; + const hasAcceptableGap = currentTime - previousTime <= slotMs * 24; return hasAcceptableGap ? [currentPoint] : [null, currentPoint]; }); diff --git a/src/styles/charts.css b/src/styles/charts.css index 8bba975..bf69a2a 100644 --- a/src/styles/charts.css +++ b/src/styles/charts.css @@ -28,16 +28,41 @@ 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 { - min-height: 24px; - padding: 0 8px; - font-size: 12px; + font-size: inherit; } .history-chart-line-mode__button--active { - color: #f8fafc; - background: rgba(212, 165, 116, 0.16); - border-color: rgba(212, 165, 116, 0.38); + border-color: var(--line-strong); + background: rgba(201, 122, 61, 0.18); } .chart-section--archive .history-chart-shell, diff --git a/src/styles/detail/archive.css b/src/styles/detail/archive.css index 48afed0..baa830e 100644 --- a/src/styles/detail/archive.css +++ b/src/styles/detail/archive.css @@ -216,6 +216,14 @@ font-size: 0.84rem; } +.archive-avgline-mode-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; From 5d0408c533b25b0b36376a124620936e616630bc Mon Sep 17 00:00:00 2001 From: Nikita Biziaev Date: Wed, 8 Jul 2026 15:19:22 +0500 Subject: [PATCH 6/7] segmented buttons states and styles --- .../edge-detail/components/ArchiveView.tsx | 32 +++++++++++++------ .../HistoryChartAvgLineModeControl.tsx | 8 ++--- src/styles/charts.css | 5 --- src/styles/components/controls.css | 6 ++++ src/styles/detail/archive.css | 2 +- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/features/edge-detail/components/ArchiveView.tsx b/src/features/edge-detail/components/ArchiveView.tsx index 06ce2b7..6f762e7 100644 --- a/src/features/edge-detail/components/ArchiveView.tsx +++ b/src/features/edge-detail/components/ArchiveView.tsx @@ -96,6 +96,7 @@ export function ArchiveView({ 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); @@ -106,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), @@ -152,14 +154,14 @@ export function ArchiveView({ onSearchChange(event.target.value)} placeholder="Поиск" />
- + */} - ))} +
+
+ {RANGE_PRESETS.map((preset) => ( + + ))} +
С diff --git a/src/features/history-chart/HistoryChartAvgLineModeControl.tsx b/src/features/history-chart/HistoryChartAvgLineModeControl.tsx index 8d895ab..e41fde6 100644 --- a/src/features/history-chart/HistoryChartAvgLineModeControl.tsx +++ b/src/features/history-chart/HistoryChartAvgLineModeControl.tsx @@ -8,19 +8,19 @@ type HistoryChartAvgLineModeControlProps = { const AVG_LINE_MODES: Array<{ value: AvgLineMode; label: string }> = [ { value: 'auto', label: 'Авто' }, { value: 'show', label: 'Рисовать' }, - { value: 'hide', label: 'Не рисовать' }, + { value: 'hide', label: 'Скрывать' }, ]; export function HistoryChartAvgLineModeControl({ value, onChange }: HistoryChartAvgLineModeControlProps) { return ( -
- Линия avg +
+ Соединительная линия
{AVG_LINE_MODES.map((mode) => (