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] =?UTF-8?q?BUR-67=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D1=80=D0=B8=D1=81=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BB=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=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; -- 2.49.1