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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m18s
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user