refactor(NetworkMapUnifi): improve layout computation by grouping server types and enhancing curve point calculations for better visualization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m5s

This commit is contained in:
2026-02-17 17:13:56 +07:00
parent 965eb7467b
commit c475762fde
+86 -38
View File
@@ -9,23 +9,47 @@ const UNIFI_TEXT = '#f1f5f9';
const UNIFI_TEXT_MUTED = '#94a3b8';
const NODE_WIDTH = 88;
const NODE_HEIGHT = 52;
const CIRCLE_RADIUS = 240;
const STORAGE_KEY = 'network-map-unifi-positions';
function computeCircleLayout(servers, size) {
const n = servers.length;
if (n === 0) return {};
const cx = size.w / 2;
const cy = size.h / 2;
const r = Math.min(CIRCLE_RADIUS, Math.min(size.w, size.h) * 0.4);
function computeInitialLayout(servers, size) {
const positions = {};
servers.forEach((s, i) => {
const angle = (2 * Math.PI * i) / n - Math.PI / 2;
positions[String(s.ip)] = {
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle),
};
if (!servers.length) return positions;
// Группируем: сначала home, затем jumphost, затем exit, затем остальные
const homes = servers.filter((s) => String(s.type || '').toLowerCase() === 'home');
const jumphosts = servers.filter((s) => String(s.type || '').toLowerCase() === 'jumphost');
const exits = servers.filter((s) => String(s.type || '').toLowerCase() === 'exit');
const others = servers.filter((s) => {
const t = String(s.type || '').toLowerCase();
return t !== 'home' && t !== 'jumphost' && t !== 'exit';
});
const columns = [homes, jumphosts, exits, others].filter((col) => col.length > 0);
const colCount = columns.length || 1;
const paddingX = 80;
const usableWidth = Math.max(200, size.w - paddingX * 2);
const colStep = usableWidth / Math.max(1, colCount - 1);
const baseX = paddingX;
columns.forEach((col, colIdx) => {
// В пределах колонки сортируем по dns/ip для стабильности
const sorted = [...col].sort((a, b) => {
const la = (a.dns || a.ip || '').toString();
const lb = (b.dns || b.ip || '').toString();
return la.localeCompare(lb);
});
const n = sorted.length;
const x = baseX + colStep * colIdx;
const topPadding = 60;
const bottomPadding = 60;
const usableHeight = Math.max(120, size.h - topPadding - bottomPadding);
sorted.forEach((s, i) => {
const t = n > 1 ? i / (n - 1) : 0.5;
const y = topPadding + usableHeight * t;
positions[String(s.ip)] = { x, y };
});
});
return positions;
}
@@ -62,14 +86,14 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) saved = JSON.parse(raw) || {};
} catch {}
const circle = computeCircleLayout(servers, size);
const initial = computeInitialLayout(servers, size);
const merged = {};
servers.forEach((s, i) => {
const ip = String(s.ip);
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
} else {
merged[ip] = circle[ip] ?? { x: size.w / 2, y: size.h / 2 };
merged[ip] = initial[ip] ?? { x: size.w / 2, y: size.h / 2 };
}
});
setPositions(merged);
@@ -117,10 +141,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}, [drag, getSvgCoords]);
const resetLayout = useCallback(() => {
const circle = computeCircleLayout(servers, size);
setPositions(circle);
const initial = computeInitialLayout(servers, size);
setPositions(initial);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(circle));
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
} catch {}
}, [servers, size]);
@@ -158,36 +182,60 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
}).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap]);
const pathD = useCallback((e) => {
// Вычисляем старт/финиш и контрольную точку так, чтобы рёбра выходили
// с «боков» карточек и красиво изгибались (как в UniFi).
const computeCurvePoints = useCallback((e) => {
const { p1, p2 } = e;
const mx = (p1.x + p2.x) / 2;
const my = (p1.y + p2.y) / 2;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy) || 1;
const perpX = (-dy / len) * 35;
const perpY = (dx / len) * 35;
const adx = Math.abs(dx);
const ady = Math.abs(dy);
let sx = p1.x;
let sy = p1.y;
let ex = p2.x;
let ey = p2.y;
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 mx = (sx + ex) / 2;
const my = (sy + ey) / 2;
const ddx = ex - sx;
const ddy = ey - sy;
const len = Math.hypot(ddx, ddy) || 1;
const baseOffset = 40;
const perpX = (-ddy / len) * baseOffset;
const perpY = (ddx / len) * baseOffset;
const cpx = mx + perpX;
const cpy = my + perpY;
return `M ${p1.x} ${p1.y} Q ${cpx} ${cpy} ${p2.x} ${p2.y}`;
return { sx, sy, ex, ey, cpx, cpy };
}, []);
const pathD = useCallback((e) => {
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`;
}, [computeCurvePoints]);
const labelPoint = useCallback((e) => {
const { p1, p2 } = e;
const mx = (p1.x + p2.x) / 2;
const my = (p1.y + p2.y) / 2;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy) || 1;
const perpX = (-dy / len) * 35;
const perpY = (dx / len) * 35;
const cpx = mx + perpX;
const cpy = my + perpY;
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e);
const t = 0.5;
const lx = (1 - t) * (1 - t) * p1.x + 2 * (1 - t) * t * cpx + t * t * p2.x;
const ly = (1 - t) * (1 - t) * p1.y + 2 * (1 - t) * t * cpy + t * t * p2.y;
const lx = (1 - t) * (1 - t) * sx + 2 * (1 - t) * t * cpx + t * t * ex;
const ly = (1 - t) * (1 - t) * sy + 2 * (1 - t) * t * cpy + t * t * ey;
return { x: lx, y: ly };
}, []);
}, [computeCurvePoints]);
if (servers.length === 0) return null;