feat(network-map): implement advanced edge routing and geometry calculations for improved visual representation
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m18s

This commit is contained in:
2026-02-24 23:30:44 +07:00
parent 7029fa2897
commit d657b03abc
+159 -39
View File
@@ -105,6 +105,20 @@ function getTunnelColor(tunnelType) {
return UNIFI_GREEN_SOFT;
}
function sideVector(side) {
if (side === 'right') return { x: 1, y: 0 };
if (side === 'left') return { x: -1, y: 0 };
if (side === 'bottom') return { x: 0, y: 1 };
return { x: 0, y: -1 };
}
function anchorPoint(pos, side, slotOffset = 0) {
if (side === 'right') return { x: pos.x + NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'left') return { x: pos.x - NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'bottom') return { x: pos.x + slotOffset, y: pos.y + NODE_HEIGHT / 2 };
return { x: pos.x + slotOffset, y: pos.y - NODE_HEIGHT / 2 };
}
export default function NetworkMapUnifi({
servers = [],
connections = [],
@@ -271,55 +285,161 @@ export default function NetworkMapUnifi({
}).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
// Вычисляем старт/финиш и одну контрольную точку:
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
// Роутинг рёбер: раскладываем рёбра по "портам" на сторонах узла (эффект трезубца),
// а также даём параллельным связям разный изгиб.
const routedEdges = useMemo(() => {
const nodePortBuckets = new Map();
const edgeRoutes = new Map();
const pairBuckets = new Map();
const slotStep = 9;
const getEdgeSides = (e) => {
const dx = e.p2.x - e.p1.x;
const dy = e.p2.y - e.p1.y;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0
? { fromSide: 'right', toSide: 'left' }
: { fromSide: 'left', toSide: 'right' };
}
return dy >= 0
? { fromSide: 'bottom', toSide: 'top' }
: { fromSide: 'top', toSide: 'bottom' };
};
const getSortValue = (side, targetPos) =>
side === 'left' || side === 'right' ? targetPos.y : targetPos.x;
edgesWithPing.forEach((e, idx) => {
const { fromSide, toSide } = getEdgeSides(e);
if (!nodePortBuckets.has(e.from)) nodePortBuckets.set(e.from, {});
if (!nodePortBuckets.has(e.to)) nodePortBuckets.set(e.to, {});
const fromBuckets = nodePortBuckets.get(e.from);
const toBuckets = nodePortBuckets.get(e.to);
fromBuckets[fromSide] = fromBuckets[fromSide] || [];
toBuckets[toSide] = toBuckets[toSide] || [];
fromBuckets[fromSide].push({
idx,
role: 'from',
sortValue: getSortValue(fromSide, e.p2),
});
toBuckets[toSide].push({
idx,
role: 'to',
sortValue: getSortValue(toSide, e.p1),
});
const pairKey = [e.from, e.to].sort().join(':');
if (!pairBuckets.has(pairKey)) pairBuckets.set(pairKey, []);
pairBuckets.get(pairKey).push(idx);
edgeRoutes.set(idx, {
fromSide,
toSide,
fromSlot: 0,
toSlot: 0,
bundleOffset: 0,
});
});
nodePortBuckets.forEach((sides) => {
Object.keys(sides).forEach((side) => {
const list = sides[side];
list.sort((a, b) => a.sortValue - b.sortValue);
const center = (list.length - 1) / 2;
list.forEach((item, order) => {
const slotOffset = (order - center) * slotStep;
const route = edgeRoutes.get(item.idx);
if (!route) return;
if (item.role === 'from') route.fromSlot = slotOffset;
else route.toSlot = slotOffset;
});
});
});
pairBuckets.forEach((idxList) => {
idxList.sort((a, b) => {
const ea = edgesWithPing[a];
const eb = edgesWithPing[b];
return String(ea.tunnelType || '').localeCompare(String(eb.tunnelType || '')) || ea.edgeKey.localeCompare(eb.edgeKey);
});
const center = (idxList.length - 1) / 2;
idxList.forEach((edgeIdx, order) => {
const route = edgeRoutes.get(edgeIdx);
if (!route) return;
route.bundleOffset = (order - center) * 14;
});
});
return edgesWithPing.map((e, idx) => ({
...e,
route: edgeRoutes.get(idx),
}));
}, [edgesWithPing]);
// Вычисляем геометрию ребра: выход из "порта" узла + короткий stem (трезубец) + плавная дуга.
const computeCurvePoints = useCallback((e) => {
const { p1, p2 } = e;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
const route = e.route || { fromSide: 'right', toSide: 'left', fromSlot: 0, toSlot: 0, bundleOffset: 0 };
const stem = 14;
let sx = p1.x;
let sy = p1.y;
let ex = p2.x;
let ey = p2.y;
const startAnchor = anchorPoint(p1, route.fromSide, route.fromSlot);
const endAnchor = anchorPoint(p2, route.toSide, route.toSlot);
const fromVec = sideVector(route.fromSide);
const toVec = sideVector(route.toSide);
if (adx >= ady) {
const sign = dx >= 0 ? 1 : -1;
sx = p1.x + sign * (NODE_WIDTH / 2);
sy = p1.y;
ex = p2.x - sign * (NODE_WIDTH / 2);
ey = p2.y;
} else {
const sign = dy >= 0 ? 1 : -1;
sx = p1.x;
sy = p1.y + sign * (NODE_HEIGHT / 2);
ex = p2.x;
ey = p2.y - sign * (NODE_HEIGHT / 2);
}
const sx = startAnchor.x;
const sy = startAnchor.y;
const ex = endAnchor.x;
const ey = endAnchor.y;
const sOutX = sx + fromVec.x * stem;
const sOutY = sy + fromVec.y * stem;
const eOutX = ex + toVec.x * stem;
const eOutY = ey + toVec.y * stem;
const ddx = ex - sx;
const ddy = ey - sy;
const ddx = eOutX - sOutX;
const ddy = eOutY - sOutY;
const len = Math.hypot(ddx, ddy) || 1;
const mx = (sx + ex) / 2;
const my = (sy + ey) / 2;
const baseOffset = Math.min(40, len / 2);
const perpX = (-ddy / len) * baseOffset;
const perpY = (ddx / len) * baseOffset;
const cpx = mx + perpX;
const cpy = my + perpY;
const perpLen = Math.hypot(perpX, perpY) || 1;
const ux = perpX / perpLen;
const uy = perpY / perpLen;
const baseOffset = Math.min(44, len / 2);
const bendOffset = baseOffset + (route.bundleOffset || 0);
const bendX = (-ddy / len) * bendOffset;
const bendY = (ddx / len) * bendOffset;
return { sx, sy, ex, ey, cpx, cpy, mx, my, perpX: ux, perpY: uy };
const cpx = (sOutX + eOutX) / 2 + bendX;
const cpy = (sOutY + eOutY) / 2 + bendY;
// Середина квадратичной кривой при t=0.5
const mx = 0.25 * sOutX + 0.5 * cpx + 0.25 * eOutX;
const my = 0.25 * sOutY + 0.5 * cpy + 0.25 * eOutY;
const perpLen = Math.hypot(-ddy, ddx) || 1;
const ux = (-ddy) / perpLen;
const uy = (ddx) / perpLen;
return {
sx,
sy,
ex,
ey,
sOutX,
sOutY,
eOutX,
eOutY,
cpx,
cpy,
mx,
my,
perpX: ux,
perpY: uy,
};
}, []);
const pathD = useCallback((e) => {
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`;
const { sx, sy, ex, ey, sOutX, sOutY, eOutX, eOutY, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} L ${sOutX} ${sOutY} Q ${cpx} ${cpy} ${eOutX} ${eOutY} L ${ex} ${ey}`;
}, [computeCurvePoints]);
/** Точка для подписи: середина ребра + смещение по перпендикуляру (чтобы подпись была у ребра, а не уезжала) */
@@ -380,7 +500,7 @@ export default function NetworkMapUnifi({
const used = [];
const LABEL_W = 118;
const LABEL_H = 22;
return edgesWithPing.map((e, idx) => {
return routedEdges.map((e, idx) => {
const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3 : 2;