Animate device pin collapse and traffic chart updates
This commit is contained in:
@@ -29,6 +29,11 @@ interface TrafficDelta {
|
||||
total?: string;
|
||||
}
|
||||
|
||||
interface PinCollapse {
|
||||
animationDone: boolean;
|
||||
result: 'pending' | 'saved' | 'failed';
|
||||
}
|
||||
|
||||
function requestMessage(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const message: unknown = Reflect.get(value, 'message');
|
||||
@@ -79,9 +84,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
|
||||
const [pencilAnimationId, setPencilAnimationId] = useState('');
|
||||
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
|
||||
const [pinCollapses, setPinCollapses] = useState<Record<string, PinCollapse>>({});
|
||||
const deviceNodes = useRef(new Map<string, HTMLElement>());
|
||||
const previousPositions = useRef(new Map<string, DOMRect>());
|
||||
const previousOrder = useRef<string[]>([]);
|
||||
const previousScrollTop = useRef(0);
|
||||
const movementAnimations = useRef(new Map<string, Animation>());
|
||||
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
|
||||
@@ -144,7 +149,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
previousPositions.current.clear();
|
||||
previousOrder.current = [];
|
||||
previousScrollTop.current = 0;
|
||||
for (const animation of movementAnimations.current.values()) animation.cancel();
|
||||
movementAnimations.current.clear();
|
||||
@@ -155,12 +159,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
movementAnimations.current.get(id)?.cancel();
|
||||
positions.set(id, node.getBoundingClientRect());
|
||||
}
|
||||
const order = devices.map(({ id }) => id);
|
||||
const orderChanged = previousOrder.current.length > 0
|
||||
&& (order.length !== previousOrder.current.length
|
||||
|| order.some((id, index) => id !== previousOrder.current[index]));
|
||||
const currentScrollTop = panelRef.current?.scrollTop || 0;
|
||||
if (orderChanged && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
if (previousPositions.current.size > 0
|
||||
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
for (const [id, after] of positions) {
|
||||
const before = previousPositions.current.get(id);
|
||||
const deltaY = before
|
||||
@@ -178,9 +179,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
}
|
||||
}
|
||||
previousPositions.current = positions;
|
||||
previousOrder.current = order;
|
||||
previousScrollTop.current = currentScrollTop;
|
||||
}, [devices, open, panelRef]);
|
||||
}, [devices, open, panelRef, pinCollapses]);
|
||||
|
||||
async function saveAlias(device: Device) {
|
||||
const nextAlias = alias.trim();
|
||||
@@ -233,6 +233,39 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
setAlias(value);
|
||||
}
|
||||
|
||||
async function togglePin(device: Device) {
|
||||
if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
await updateDevice(device, { pinned: !device.pinned });
|
||||
return;
|
||||
}
|
||||
setPinCollapses((current) => ({
|
||||
...current,
|
||||
[device.id]: { animationDone: false, result: 'pending' },
|
||||
}));
|
||||
const saved = await updateDevice(device, { pinned: false });
|
||||
setPinCollapses((current) => {
|
||||
const phase = current[device.id];
|
||||
if (!phase) return current;
|
||||
if (!phase.animationDone) {
|
||||
return { ...current, [device.id]: { ...phase, result: saved ? 'saved' : 'failed' } };
|
||||
}
|
||||
const next = { ...current };
|
||||
delete next[device.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function finishPinCollapse(deviceId: string) {
|
||||
setPinCollapses((current) => {
|
||||
const phase = current[deviceId];
|
||||
if (!phase) return current;
|
||||
if (phase.result === 'pending') return { ...current, [deviceId]: { ...phase, animationDone: true } };
|
||||
const next = { ...current };
|
||||
delete next[deviceId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
@@ -333,6 +366,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
</div>
|
||||
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
|
||||
{devices.map((device) => {
|
||||
const collapsing = Boolean(pinCollapses[device.id]);
|
||||
const displayPinned = device.pinned || collapsing;
|
||||
const hasName = Boolean(device.alias || device.hostname);
|
||||
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
|
||||
const editing = editingId === device.id;
|
||||
@@ -374,7 +409,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
if (node) deviceNodes.current.set(device.id, node);
|
||||
else deviceNodes.current.delete(device.id);
|
||||
}}
|
||||
className={`client-device is-${device.status}${device.pinned ? ' is-pinned' : ''}`}
|
||||
className={`client-device is-${device.status}${displayPinned ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}`}
|
||||
key={device.id}
|
||||
>
|
||||
<span className="client-device-pin-wrap client-tooltip-anchor">
|
||||
@@ -384,7 +419,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
aria-pressed={device.pinned}
|
||||
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
|
||||
disabled={saving}
|
||||
onClick={() => updateDevice(device, { pinned: !device.pinned })}
|
||||
onClick={() => togglePin(device)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
|
||||
@@ -493,7 +528,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||
scale={trafficScale}
|
||||
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
||||
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
|
||||
pinned={device.pinned}
|
||||
pinned={displayPinned}
|
||||
collapsing={collapsing}
|
||||
onCollapseEnd={() => finishPinCollapse(device.id)}
|
||||
/>
|
||||
</article>;
|
||||
})}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user