// Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. import { useEffect, useMemo, useRef, useState } from 'react'; // Линейный график для вкладки «Монитор». Одна ось, линия 2 px, конечная точка с кольцом цвета поверхности, // перекрестие с подсказкой по всем рядам, легенда при ≥ 2 рядах, табличный вид и управление стрелками. // Цвета рядов — токены темы (--series-N в monitor.css), подписи и значения — всегда цветом текста. export interface ChartSeries { id: string; name: string; color: string; points: [number, number][] } const M = { left: 46, right: 14, top: 10, bottom: 24 }; // Шкала оси: круглый шаг (1, 2, 2.5, 5 × 10ⁿ) и 3–5 интервалов, чтобы подписи были 0 / 50 / 100 / 150, а не 0 / 63 / 125. function niceScale(max: number): { top: number; ticks: number[] } { if (!(max > 0)) return { top: 1, ticks: [0, 1] }; const raw = max / 4; const p = Math.pow(10, Math.floor(Math.log10(raw))); const step = ([1, 2, 2.5, 5, 10].map(f => f * p).find(s => s >= raw)) ?? 10 * p; const n = Math.max(1, Math.ceil(max / step - 1e-9)); return { top: step * n, ticks: Array.from({ length: n + 1 }, (_, i) => step * i) }; } const timeLabel = (t: number, span: number) => { const d = new Date(t * 1000); const hm = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); return span > 86400 * 1.5 ? `${d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} ${hm}` : hm; }; export default function LineChart({ title, series, from, to, step, format, formatAxis, max, dim = false }: { title: string; series: ChartSeries[]; from: number; to: number; step: number; // шаг данных, с: разрыв длиннее трёх шагов рвёт линию, а не рисует мост format: (v: number) => string; // значение в подсказке и таблице formatAxis?: (v: number) => string; // компактная подпись оси max?: number; // фиксированный верх оси (проценты — 100); без него подбирается по данным dim?: boolean; // идёт перезагрузка: держим прежний кадр бледнее }) { const wrap = useRef(null); // содержимое карточки без её padding: от него зависит ширина svg const [w, setW] = useState(640); const [hover, setHover] = useState(null); // индекс по оси времени const [table, setTable] = useState(false); const H = 200; useEffect(() => { const el = wrap.current; if (!el) return; const ro = new ResizeObserver(() => setW(Math.max(260, el.clientWidth))); ro.observe(el); setW(Math.max(260, el.clientWidth)); return () => ro.disconnect(); }, []); // Общая ось времени: метки первого непустого ряда (Prometheus выравнивает точки по шагу). const times = useMemo(() => { const set = new Set(); for (const s of series) for (const p of s.points) set.add(p[0]); return [...set].sort((a, b) => a - b); }, [series]); const byTime = useMemo(() => series.map(s => new Map(s.points)), [series]); const empty = times.length === 0; const { top: yMax, ticks: yTicks } = max !== undefined ? { top: max, ticks: [0, 1, 2, 3, 4].map(i => (max / 4) * i) } : niceScale(Math.max(0, ...series.flatMap(s => s.points.map(p => p[1]))) * 1.04); const x = (t: number) => M.left + ((t - from) / Math.max(1, to - from)) * (w - M.left - M.right); const y = (v: number) => H - M.bottom - (v / yMax) * (H - M.top - M.bottom); const path = (pts: [number, number][]) => { let d = '', prev: number | null = null; for (const [t, v] of pts) { d += `${prev !== null && t - prev <= step * 3 ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`; prev = t; } return d; }; const span = to - from; const xTicks = [0, 1, 2, 3, 4].map(i => from + (span / 4) * i); const axis = formatAxis ?? format; const onMove = (e: React.PointerEvent) => { if (!times.length) return; const r = e.currentTarget.getBoundingClientRect(); const t = from + ((e.clientX - r.left) / Math.max(1, r.width)) * span; let best = 0; for (let i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; setHover(best); }; const onKey = (e: React.KeyboardEvent) => { if (!times.length) return; if (e.key === 'ArrowLeft') { e.preventDefault(); setHover(h => Math.max(0, (h ?? times.length) - 1)); } if (e.key === 'ArrowRight') { e.preventDefault(); setHover(h => Math.min(times.length - 1, (h ?? -1) + 1)); } if (e.key === 'Escape') setHover(null); }; const ht = hover !== null ? times[hover] : null; // Подсказка не вылезает за правый край: правее середины открывается влево от перекрестия const flip = ht !== null && x(ht) > w * 0.55; // Табличный вид — те же числа без наведения: ~12 равномерно выбранных строк, последняя — самая свежая const rows = useMemo(() => { if (!times.length) return [] as number[]; const n = Math.min(12, times.length); return Array.from({ length: n }, (_, i) => times[Math.round(((times.length - 1) * (i + 1)) / n)]).reverse(); }, [times]); return (
{title} {series.length >= 2 && ( {series.map(s => (