Animate device pin collapse and traffic chart updates
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-09 23:58:02 +03:00
parent d349ca5e29
commit 3a4173db43
7 changed files with 192 additions and 102 deletions
+62 -22
View File
@@ -1,4 +1,11 @@
import React, { useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
import React, {
useLayoutEffect,
useRef,
useState,
type AnimationEvent,
type CSSProperties,
type PointerEvent,
} from 'react';
import { createPortal } from 'react-dom';
import {
byteString,
@@ -41,6 +48,26 @@ function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY'
}, `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,
} : {
...point,
gatewayY: 100,
proxyY: 100,
};
});
}
function trafficSeriesMax(values: Array<{ gateway: bigint; proxy: bigint }>) {
return values.reduce((largest, { gateway, proxy }) => {
return gateway > largest
@@ -59,6 +86,8 @@ export function TrafficChart({
capacity,
routeLabel,
pinned = true,
collapsing = false,
onCollapseEnd,
series = 'routes',
}: {
samples: TrafficSample[];
@@ -66,11 +95,12 @@ export function TrafficChart({
capacity: number;
routeLabel: string;
pinned?: boolean;
collapsing?: boolean;
onCollapseEnd?: () => void;
series?: 'routes' | 'speed';
}) {
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
const previousPoints = useRef<ChartPoint[]>([]);
const previousScale = useRef<TrafficScale>(scale);
const previousSeries = useRef(series);
const speedAvailable = samples.length > 1 && samples.every((sample) => (
typeof sample.downloadBytes === 'string' && typeof sample.uploadBytes === 'string'
@@ -108,17 +138,22 @@ export function TrafficChart({
const penultimate = points.at(-2);
const newest = points.at(-1);
const hasSecondary = points.some(({ proxy }) => proxy > 0n);
const scaleFrom = previousPoints.current;
const animateScale = previousScale.current !== scale
&& previousSeries.current === series
&& scaleFrom.length === points.length
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}`
)).join('|')}`;
const animatePaths = points.length > 0
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
useLayoutEffect(() => {
previousPoints.current = points;
previousScale.current = scale;
previousSeries.current = series;
}, [points, scale, series]);
}, [points, series]);
function trackPointer(event: PointerEvent<HTMLSpanElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
@@ -142,14 +177,14 @@ export function TrafficChart({
>
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
{series === 'speed' ? <>
<strong> Download {formatRate(hovered.gateway)}</strong>
<strong className="is-download"> Download {formatRate(hovered.gateway)}</strong>
<span className="is-upload"> Upload {formatRate(hovered.proxy)}</span>
<span>За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))}</span>
<span>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</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>}
</> : <>
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span>{routeLabel} {formatByteString(hovered.gateway)}</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>,
@@ -157,13 +192,16 @@ export function TrafficChart({
);
return <span
className="client-device-traffic-chart"
className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
role="img"
aria-label={`${series === 'speed' ? 'История скорости' : 'История трафика'}, шкала ${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-chart-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>
@@ -177,20 +215,22 @@ export function TrafficChart({
<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" style={{ '--sample-count': Math.max(1, capacity) } as CSSProperties}>
<g className="client-device-traffic-lines">
{previous.length > 0 && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath(previous, 'gatewayY')}>
{animateScale && <animate key={`gateway-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
{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')}>
{animateScale && <animate key={`proxy-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
{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'} is-new`} pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
{animateScale && <animate key={`gateway-new-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
{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'} is-new`} pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
{animateScale && <animate key={`proxy-new-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
{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} />}
{!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>}
</g>
{hovered && <g className="client-device-traffic-cursor">
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />