301 lines
15 KiB
TypeScript
301 lines
15 KiB
TypeScript
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<Record<ChartValueKey, bigint>>) {
|
||
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<HoveredPoint | null>(null);
|
||
const previousPoints = useRef<ChartPoint[]>([]);
|
||
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<HTMLSpanElement>) {
|
||
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(
|
||
<span
|
||
className="client-device-traffic-point-tooltip"
|
||
style={{
|
||
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
|
||
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
|
||
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
|
||
}}
|
||
>
|
||
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
|
||
{series === 'speed' ? <>
|
||
<strong className="is-download">↓ Download {formatRate(hovered.gateway)}</strong>
|
||
<span className="is-upload">↑ Upload {formatRate(hovered.proxy)}</span>
|
||
<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) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>}
|
||
</> : series === 'outbound' ? <>
|
||
{hovered.gateway > 0n && <strong className="is-vpn">VPN · sing-box {formatByteString(hovered.gateway)}</strong>}
|
||
{hovered.proxy > 0n && <span className="is-direct-tracked">Direct · sing-box {formatByteString(hovered.proxy)}</span>}
|
||
{hovered.directIpv4 > 0n && <span className="is-direct-ipv4">Direct · IPv4 {formatByteString(hovered.directIpv4)}</span>}
|
||
{hovered.unknown > 0n && <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>
|
||
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
|
||
</>}
|
||
</span>,
|
||
document.body,
|
||
);
|
||
|
||
return <span
|
||
className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
|
||
role="img"
|
||
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}%`,
|
||
} as CSSProperties}
|
||
onAnimationEnd={(event: AnimationEvent<HTMLSpanElement>) => {
|
||
if (collapsing && event.animationName === 'client-device-traffic-plot-collapse') onCollapseEnd?.();
|
||
}}
|
||
>
|
||
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
||
<span className="is-max">{series === 'speed' ? formatRate(max) : formatByteString(max)}</span>
|
||
<span className="is-mid">{series === 'speed' ? formatRate(mid) : formatByteString(mid)}</span>
|
||
<span className="is-zero">0</span>
|
||
</span>}
|
||
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||
{pinned && <g className="client-device-traffic-grid">
|
||
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
|
||
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
||
<line x1="0" x2="100" y1="100" y2="100" />
|
||
</g>}
|
||
<g className="client-device-traffic-lines">
|
||
{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" />
|
||
{visibleLines.map((line) => hovered[line.valueKey] > 0n && <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>
|
||
{series === 'outbound' ? <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}
|
||
</span>;
|
||
}
|