diff --git a/frontend/src/NetworkMapUnifi.jsx b/frontend/src/NetworkMapUnifi.jsx index f30e8ec..e659ecb 100644 --- a/frontend/src/NetworkMapUnifi.jsx +++ b/frontend/src/NetworkMapUnifi.jsx @@ -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;