@@ -14,7 +14,7 @@ import {
trafficBytesPerSecond ,
trafficScaleRatio ,
} from '../../utils/format.js' ;
import type { TrafficSample , TrafficScale } from './deviceSnapshot.js' ;
import type { OutboundTrafficSample , TrafficSample , TrafficScale } from './deviceSnapshot.js' ;
const TRAFFIC_CHART_HEADROOM = 10 ;
const trafficChartY = ( ratio : number ) = > 100 - ratio * ( 100 - TRAFFIC_CHART_HEADROOM ) ;
@@ -25,13 +25,21 @@ function chartTime(value: string) {
} ) ;
}
type ChartSample = TrafficSample | OutboundTrafficSample ;
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown' ;
type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY' ;
interface ChartPoint {
sample : TrafficSample ;
sample : ChartSample ;
x : number ;
gateway : bigint ;
proxy : bigint ;
directIpv4 : bigint ;
unknown : bigint ;
gatewayY : number ;
proxyY : number ;
directIpv4Y : number ;
unknownY : number ;
}
interface HoveredPoint extends ChartPoint {
@@ -39,7 +47,7 @@ interface HoveredPoint extends ChartPoint {
clientY : number ;
}
function smoothTrafficPath ( points : ChartPoint [ ] , valueKey : 'gatewayY' | 'proxyY' ) {
function smoothTrafficPath ( points : ChartPoint [ ] , valueKey : ChartYKey ) {
if ( ! points . length ) return '' ;
return points . slice ( 1 ) . reduce ( ( path , point , index ) = > {
const previous = points [ index ] ;
@@ -60,20 +68,22 @@ function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartP
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 < { gateway : bigint ; proxy : bigint } > ) {
return values . reduce ( ( largest , { gateway , proxy } ) = > {
return gateway > largest
? ( proxy > gateway ? proxy : gateway )
: ( proxy > largest ? proxy : largest ) ;
} , 0 n ) ;
function trafficSeriesMax ( values : Array < Record < ChartValueKey , bigint > > ) {
return values . reduce ( ( largest , value ) = > (
Object . values ( value ) . reduce ( ( current , item ) = > item > current ? item : current , largest )
) , 0 n ) ;
}
function formatRate ( value : bigint ) {
@@ -88,64 +98,101 @@ export function TrafficChart({
pinned = true ,
collapsing = false ,
onCollapseEnd ,
series = 'routes ' ,
series = 'inbound ' ,
} : {
samples : TrafficSample [ ] ;
samples : ChartSample [ ] ;
scale? : TrafficScale ;
capacity : number ;
routeLabel : string ;
pinned? : boolean ;
collapsing? : boolean ;
onCollapseEnd ? : ( ) = > void ;
series ? : 'routes ' | 'speed' ;
series ? : 'inbound' | 'outbound ' | 'speed' ;
} ) {
const [ hovered , setHovered ] = useState < HoveredPoint | null > ( null ) ;
const previousPoints = useRef < ChartPoint [ ] > ( [ ] ) ;
const previousSeries = useRef ( series ) ;
const speedAvailable = samples . length > 1 && samples . every ( ( sample ) = > (
typeof sample . downloadBytes === 'string' && typeof sample . uploadBytes === 'string'
'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 === 'routes ' ) {
if ( series === 'inbound ' ) {
const traffic = sample as TrafficSample ;
return {
sample ,
gateway : byteString ( sample . gatewayBytes ) ,
proxy : byteString ( sample . proxyBytes ) ,
gateway : byteString ( traffic . gatewayBytes ) ,
proxy : byteString ( traffic . proxyBytes ) ,
directIpv4 : 0n ,
unknown : 0 n ,
} ;
}
const previous = samples [ index ] ;
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 ( sample . downloadBytes , previous . observedAt , sample . observedAt ) ,
proxy : trafficBytesPerSecond ( sample . uploadBytes , previous . observedAt , sample . observedAt ) ,
gateway : trafficBytesPerSecond ( traffic . downloadBytes , previous . observedAt , traffic . observedAt ) ,
proxy : trafficBytesPerSecond ( traffic . uploadBytes , previous . observedAt , traffic . observedAt ) ,
directIpv4 : 0n ,
unknown : 0 n ,
} ;
} ) ;
const max = trafficSeriesMax ( values ) ;
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 } , index ) = > {
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 hasSecondary = points . some ( ( { proxy } ) = > proxy > 0 n ) ;
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 ] > 0 n ) ) ;
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 } ) => (
` $ { sample . observedAt } : $ { gateway } : $ { proxy } `
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 ) ;
@@ -182,6 +229,12 @@ export function TrafficChart({
< span className = "is-interval" > З а и н т е р в а л { formatByteString ( byteString ( hovered . sample . gatewayBytes ) + byteString ( hovered . sample . proxyBytes ) ) } < / span >
< span className = { routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct' } > { routeLabel } { formatByteString ( hovered . sample . gatewayBytes ) } < / span >
{ byteString ( hovered . sample . proxyBytes ) > 0 n && < span className = "is-proxy" > Proxy { formatByteString ( hovered . sample . proxyBytes ) } < / span > }
< / > : series === 'outbound' ? < >
{ hovered . gateway > 0 n && < strong className = "is-vpn" > VPN · sing - box { formatByteString ( hovered . gateway ) } < / strong > }
{ hovered . proxy > 0 n && < span className = "is-direct-tracked" > Direct · sing - box { formatByteString ( hovered . proxy ) } < / span > }
{ hovered . directIpv4 > 0 n && < span className = "is-direct-ipv4" > Direct · IPv4 { formatByteString ( hovered . directIpv4 ) } < / span > }
{ hovered . unknown > 0 n && < span className = "is-unknown" > Н е и з в е с т н о · sing - box { formatByteString ( hovered . unknown ) } < / span > }
< span className = "is-interval" > Р а з н ы е у р о в н и у ч ё т а н е с к л а д ы в а ю т с я < / span >
< / > : < >
< strong className = "is-total" > В с е г о { formatByteString ( hovered . gateway + hovered . proxy ) } < / strong >
< span className = { routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct' } > { routeLabel } { formatByteString ( hovered . gateway ) } < / span >
@@ -194,7 +247,7 @@ export function TrafficChart({
return < span
className = { ` client-device-traffic-chart ${ collapsing ? ' is-collapsing' : '' } ` }
role = "img"
aria-label = { ` ${ series === 'speed' ? 'История скорости' : 'История трафика'} , шкала ${ scale === 'log' ? 'логарифмическая' : 'линейная' } , максимум ${ series === 'speed' ? formatRate ( max ) : formatByteString ( max ) } ` }
aria-label = { ` ${ series === 'speed' ? 'История скорости' : series === 'outbound' ? 'Фактический выход трафика' : 'Источник входящего трафика'} , шкала ${ scale === 'log' ? 'логарифмическая' : 'линейная' } , максимум ${ series === 'speed' ? formatRate ( max ) : formatByteString ( max ) } ` }
style = { {
'--traffic-chart-top' : ` ${ TRAFFIC_CHART_HEADROOM } % ` ,
'--traffic-chart-mid' : ` ${ ( 100 + TRAFFIC_CHART_HEADROOM ) / 2 } % ` ,
@@ -216,32 +269,30 @@ export function TrafficChart({
< line x1 = "0" x2 = "100" y1 = "100" y2 = "100" / >
< / g > }
< g className = "client-device-traffic-lines" >
{ previous . length > 0 && < path className = { series === 'speed' ? 'is-download' : 'is-gateway '} d = { smoothTrafficPath ( previous , 'gatewayY' ) } >
{ animatePaths && < animate key = { ` gateway- ${ motionKey } ` } attributeName = "d" from = { smoothTrafficPath ( previousMotionFrom , 'gatewayY' ) } to = { smoothTrafficPath ( previous , 'gatewayY' ) } dur = "520ms" calcMode = "spline" keyTimes = "0;1" keySplines = "0.16 1 0.3 1" fill = "freeze" / > }
< / path > }
{ hasSecondary && previous . length > 0 && < path className = { series === 'speed' ? 'is-upload' : 'is-proxy' } d = { smoothTrafficPath ( previous , 'proxyY' ) } >
{ animatePaths && < animate key = { ` proxy- ${ motionKey } ` } attributeName = "d" from = { smoothTrafficPath ( previousMotionFrom , 'proxyY' ) } to = { smoothTrafficPath ( previous , 'proxyY' ) } dur = "520ms" calcMode = "spline" keyTimes = "0;1" keySplines = "0.16 1 0.3 1" fill = "freeze" / > }
< / path > }
{ penultimate && newest && < path className = { series === 'speed' ? 'is-download' : 'is-gateway' } d = { smoothTrafficPath ( [ penultimate , newest ] , 'gatewayY' ) } >
{ animatePaths && < animate key = { ` gateway-new- ${ motionKey } ` } attributeName = "d" from = { smoothTrafficPath ( newestMotionFrom , 'gatewayY' ) } to = { smoothTrafficPath ( [ penultimate , newest ] , 'gatewayY' ) } dur = "520ms " calcMode = "spline " keyTimes = "0; 1" keySplines = "0.16 1 0.3 1 " fill = "freeze" / > }
< / path > }
{ hasSecondary && penultimate && newest && < path className = { series === 'speed' ? 'is-upload' : 'is-proxy' } d = { smoothTrafficPath ( [ penultimate , newest ] , 'proxyY' ) } >
{ animatePaths && < animate key = { ` proxy-new- ${ motionKey } ` } attributeName = "d" from = { smoothTrafficPath ( newestMotionFrom , 'proxyY' ) } to = { smoothTrafficPath ( [ penultimate , newest ] , 'proxyY' ) } dur = "520ms" calcMode = "spline" keyTimes = "0;1" keySplines = "0.16 1 0.3 1" fill = "freeze" / > }
< / path > }
{ ! penultimate && newest && < line className = { ` ${ series === 'speed' ? 'is-download' : 'is-gateway' } is-point ` } x1 = { newest . x } x2 = { newest . x } y1 = { newest . gatewayY } y2 = { newest . gatewayY } >
{ animatePaths && < animate key = { ` point- ${ motionKey } ` } attributeName = "opacity" from = "0" to = "1" dur = "220ms" fill = "freeze" / > }
< / line > }
{ visibleLines . map ( ( line ) = > previous . length > 0 && < path key = { ` old- ${ line . valueKey } ` } className = { line . valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line . valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line . valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown '} d = { smoothTrafficPath ( previous , line . yKey ) } >
{ animatePaths && < animate key = { ` ${ line . valueKey } - ${ motionKey } ` } attributeName = "d" from = { smoothTrafficPath ( previousMotionFrom , line . yKey ) } to = { smoothTrafficPath ( previous , line . yKey ) } dur = "520ms" calcMode = "spline" keyTimes = "0;1" keySplines = "0.16 1 0.3 1" fill = "freeze" / > }
< / path > ) }
{ visibleLines . map ( ( line ) = > penultimate && newest && < path key = { ` new- ${ line . valueKey } ` } className = { line . valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line . valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line . valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown' } d = { smoothTrafficPath ( [ penultimate , newest ] , line . yKey ) } >
{ animatePaths && < animate key = { ` ${ line . valueKey } -new- ${ motionKey } ` } attributeName = "d" from = { smoothTrafficPath ( newestMotionFrom , line . yKey ) } to = { smoothTrafficPath ( [ penultimate , newest ] , line . yKey ) } dur = "520ms" calcMode = "spline" keyTimes = "0;1" keySplines = "0.16 1 0.3 1" fill = "freeze" / > }
< / path > ) }
{ visibleLines . map ( ( line ) = > ! penultimate && newest && < line key = { ` point- ${ line . valueKey } ` } className = { ` ${ line . valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line . valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line . valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown' } is-point ` } x1 = { newest . x } x2 = { newest . x } y1 = { newest [ line . yKey ] } y2 = { newest [ line . yKey ] } >
{ animatePaths && < animate key = { ` ${ line . valueKey } -point- ${ motionKey } ` } attributeName = "opacity " from = "0 " to = "1" dur = "220ms " fill = "freeze" / > }
< / line > ) }
< / g >
{ hovered && < g className = "client-device-traffic-cursor" >
< line className = "is-guide" x1 = { hovered . x } x2 = { hovered . x } y1 = { TRAFFIC_CHART_HEADROOM } y2 = "100" / >
< line className = { ` is-point ${ series === 'speed' ? 'is-download' : 'is-gateway '} ` } x1 = { hovered . x } x2 = { hovered . x } y1 = { hovered . gatewayY } y2 = { hovered . gatewayY } / >
{ hovered . proxy > 0 n && < line className = { ` is-point ${ series === 'speed' ? 'is-upload' : 'is-proxy' } ` } x1 = { hovered . x } x2 = { hovered . x } y1 = { hovered . proxyY } y2 = { hovered . proxyY } / > }
{ visibleLines . map ( ( line ) = > hovered [ line . valueKey ] > 0 n && < line key = { line . valueKey } className = { ` is-point ${ line . valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line . valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line . valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown '} ` } x1 = { hovered . x } x2 = { hovered . x } y1 = { hovered [ line . yKey ] } y2 = { hovered [ line . yKey ] } / > ) }
< / g > }
< / svg >
< / span >
{ visibleSamples . length > 0 && < span className = "client-device-traffic-time" aria-hidden = "true" >
< time dateTime = { visibleSamples [ 0 ] . observedAt } > { chartTime ( visibleSamples [ 0 ] . observedAt ) } < / time >
< span > { series === 'spee d' ? '↓ / ↑' : '15 с ' } < / span >
{ series === 'outboun d' ? < span className = "client-device-traffic-legend" >
< span className = "is-vpn" > VPN < / span >
< span className = "is-direct-tracked" > Direct · sing - box < / span >
< span className = "is-direct-ipv4" > Direct · IPv4 < / span >
< span className = "is-unknown" > ? < / span >
< / span > : < span > { series === 'speed' ? '↓ / ↑' : 'Вход' } < / span > }
< time dateTime = { visibleSamples [ visibleSamples . length - 1 ] . observedAt } > { chartTime ( visibleSamples [ visibleSamples . length - 1 ] . observedAt ) } < / time >
< / span > }
{ tooltip }