import React, { useLayoutEffect, useRef, useState, type AnimationEvent, type CSSProperties, type PointerEvent, } from 'react'; import { createPortal } from 'react-dom'; import { byteString, formatByteString, trafficAxisMid, trafficBytesPerSecond, trafficScaleRatio, } from '../../utils/format.js'; import type { OutboundTrafficSample, TrafficSample, TrafficScale } from './deviceSnapshot.js'; const TRAFFIC_CHART_HEADROOM = 10; const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM); function chartTime(value: string) { return new Date(value).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } type ChartSample = TrafficSample | OutboundTrafficSample; type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown'; type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY'; interface ChartPoint { sample: ChartSample; x: number; gateway: bigint; proxy: bigint; directIpv4: bigint; unknown: bigint; gatewayY: number; proxyY: number; directIpv4Y: number; unknownY: number; } interface HoveredPoint extends ChartPoint { clientX: number; clientY: number; } function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) { if (!points.length) return ''; return points.slice(1).reduce((path, point, index) => { const previous = points[index]; const midX = (previous.x + point.x) / 2; return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`; }, `M ${points[0].x},${points[0][valueKey]}`); } function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartPoint[]) { const previousByTime = new Map(previousPoints.map((point) => [point.sample.observedAt, point])); let anchor: ChartPoint | undefined; return points.map((point) => { const exact = previousByTime.get(point.sample.observedAt); if (exact) anchor = exact; const source = exact || anchor; return source ? { ...point, x: source.x, gatewayY: source.gatewayY, proxyY: source.proxyY, directIpv4Y: source.directIpv4Y, unknownY: source.unknownY, } : { ...point, gatewayY: 100, proxyY: 100, directIpv4Y: 100, unknownY: 100, }; }); } function trafficSeriesMax(values: Array>) { return values.reduce((largest, value) => ( Object.values(value).reduce((current, item) => item > current ? item : current, largest) ), 0n); } function formatRate(value: bigint) { return `${formatByteString(value)}/с`; } export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel, pinned = true, collapsing = false, onCollapseEnd, series = 'inbound', }: { samples: ChartSample[]; scale?: TrafficScale; capacity: number; routeLabel: string; pinned?: boolean; collapsing?: boolean; onCollapseEnd?: () => void; series?: 'inbound' | 'outbound' | 'speed'; }) { const [hovered, setHovered] = useState(null); const previousPoints = useRef([]); const previousSeries = useRef(series); const speedAvailable = samples.length > 1 && samples.every((sample) => ( 'downloadBytes' in sample && typeof sample.downloadBytes === 'string' && 'uploadBytes' in sample && typeof sample.uploadBytes === 'string' )); const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples; const values = visibleSamples.map((sample, index) => { if (series === 'inbound') { const traffic = sample as TrafficSample; return { sample, gateway: byteString(traffic.gatewayBytes), proxy: byteString(traffic.proxyBytes), directIpv4: 0n, unknown: 0n, }; } if (series === 'outbound') { const outbound = sample as OutboundTrafficSample; return { sample, gateway: byteString(outbound.vpnBytes), proxy: byteString(outbound.directTrackedBytes), directIpv4: byteString(outbound.directIpv4Bytes), unknown: byteString(outbound.unknownBytes), }; } const traffic = sample as TrafficSample; const previous = samples[index] as TrafficSample; return { sample, gateway: trafficBytesPerSecond(traffic.downloadBytes, previous.observedAt, traffic.observedAt), proxy: trafficBytesPerSecond(traffic.uploadBytes, previous.observedAt, traffic.observedAt), directIpv4: 0n, unknown: 0n, }; }); const max = trafficSeriesMax(values.map(({ gateway, proxy, directIpv4, unknown }) => ({ gateway, proxy, directIpv4, unknown, }))); const mid = trafficAxisMid(max, scale); const firstSlot = capacity - visibleSamples.length; const points = values.map(({ sample, gateway, proxy, directIpv4, unknown }, index) => { return { sample, x: (firstSlot + index) * 100 / Math.max(1, capacity - 1), gateway, proxy, directIpv4, unknown, gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)), proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)), directIpv4Y: trafficChartY(trafficScaleRatio(directIpv4, max, scale)), unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)), }; }); const previous = points.slice(0, -1); const penultimate = points.at(-2); const newest = points.at(-1); const lineDefinitions: Array<{ valueKey: ChartValueKey; yKey: ChartYKey; }> = series === 'outbound' ? [ { valueKey: 'gateway', yKey: 'gatewayY' }, { valueKey: 'proxy', yKey: 'proxyY' }, { valueKey: 'directIpv4', yKey: 'directIpv4Y' }, { valueKey: 'unknown', yKey: 'unknownY' }, ] : [ { valueKey: 'gateway', yKey: 'gatewayY' }, { valueKey: 'proxy', yKey: 'proxyY' }, ]; const visibleLines = lineDefinitions.filter(({ valueKey }) => points.some((point) => point[valueKey] > 0n)); const motionFrom = previousSeries.current === series ? previousPoints.current : []; const previousMotionFrom = trafficPathAnimationSource(previous, motionFrom); const newestMotionFrom = trafficPathAnimationSource( penultimate && newest ? [penultimate, newest] : [], motionFrom, ); const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy, directIpv4, unknown }) => ( `${sample.observedAt}:${gateway}:${proxy}:${directIpv4}:${unknown}` )).join('|')}`; const animatePaths = points.length > 0 && !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); useLayoutEffect(() => { previousPoints.current = points; previousSeries.current = series; }, [points, series]); function trackPointer(event: PointerEvent) { const bounds = event.currentTarget.getBoundingClientRect(); const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1)); const index = slot - firstSlot; if (index < 0 || index >= points.length) { setHovered(null); return; } setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY }); } const tooltip = hovered && typeof document !== 'undefined' && createPortal( {series === 'speed' ? <> ↓ Download {formatRate(hovered.gateway)} ↑ Upload {formatRate(hovered.proxy)} За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))} {routeLabel} {formatByteString(hovered.sample.gatewayBytes)} {byteString(hovered.sample.proxyBytes) > 0n && Proxy {formatByteString(hovered.sample.proxyBytes)}} : series === 'outbound' ? <> {hovered.gateway > 0n && VPN · sing-box {formatByteString(hovered.gateway)}} {hovered.proxy > 0n && Direct · sing-box {formatByteString(hovered.proxy)}} {hovered.directIpv4 > 0n && Direct · IPv4 {formatByteString(hovered.directIpv4)}} {hovered.unknown > 0n && Неизвестно · sing-box {formatByteString(hovered.unknown)}} Разные уровни учёта не складываются : <> Всего {formatByteString(hovered.gateway + hovered.proxy)} {routeLabel} {formatByteString(hovered.gateway)} {hovered.proxy > 0n && Proxy {formatByteString(hovered.proxy)}} } , document.body, ); return ) => { if (collapsing && event.animationName === 'client-device-traffic-plot-collapse') onCollapseEnd?.(); }} > {pinned && max > 0n && } setHovered(null)}> {visibleSamples.length > 0 && } {tooltip} ; }