G6绘制机柜 以及机柜设备的demo

javascript 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>G6 机柜 · 右键菜单 (上架/下架/更换)</title>
    <!-- 引入 G6 -->
    <script src="https://gw.alipayobjects.com/os/lib/antv/g6/4.8.24/dist/g6.min.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            user-select: none;
        }
        body {
            background: #2d3a4a;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            font-family: 'Segoe UI', Roboto, sans-serif;
        }
        .cabinet-wrapper {
            background: #1e2a36;
            border-radius: 24px;
            padding: 24px 30px 24px 30px;
            box-shadow: 0 20px 30px rgba(0,0,0,0.7), inset 0 1px 4px rgba(255,255,255,0.1);
            border: 1px solid #3f4f5e;
        }
        #container {
            width: 400px;
            height: 700px;
            background: #17212b;
            border-radius: 8px;
            box-shadow: inset 0 0 0 2px #2b3a47, 0 10px 15px -5px #0f151c;
            position: relative;
            border: 1px solid #5e6f7e;
        }
        .info-panel {
            margin-top: 20px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            color: #bcd3f0;
        }
        .device-selector {
            display: flex;
            gap: 20px;
            background: #1f2c38;
            padding: 8px 16px;
            border-radius: 40px;
            border: 1px solid #3f5568;
        }
        .device-selector label {
            display: flex;
            align-items: center;
            gap: 8px;
            cursor: pointer;
            font-weight: 600;
            font-size: 15px;
            color: #ccdeff;
        }
        .device-selector input[type="radio"] {
            accent-color: #3f9eff;
            width: 16px;
            height: 16px;
        }
        .reset-btn {
            background: #2f4053;
            border: none;
            color: white;
            padding: 8px 24px;
            border-radius: 40px;
            font-weight: bold;
            font-size: 14px;
            cursor: pointer;
            border: 1px solid #5b748b;
            transition: all 0.2s;
            letter-spacing: 0.5px;
        }
        .reset-btn:hover {
            background: #3e536a;
            border-color: #7f9ec7;
        }
        .legend {
            display: flex;
            gap: 16px;
            font-size: 13px;
            align-items: center;
        }
        .legend-item {
            display: flex;
            align-items: center;
            gap: 6px;
        }
        .color-dot {
            width: 18px;
            height: 18px;
            border-radius: 4px;
            background: gray;
        }
        .dot-blue {
            background: #2b6c9e;
            border: 1px solid #7ab0e6;
        }
        .dot-gray {
            background: #464f5a;
            border: 1px solid #6f7d8c;
        }
        .dot-green {
            background: #3f9142;
            border: 1px solid #8fdd92;
        }

        /* 自定义右键菜单样式 */
        .context-menu {
            position: fixed;
            background: #253544;
            border: 1px solid #4f6b84;
            border-radius: 12px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.6);
            padding: 6px 0;
            min-width: 160px;
            z-index: 1000;
            backdrop-filter: blur(4px);
            color: #eaf2ff;
            font-size: 14px;
        }
        .context-menu .menu-item {
            padding: 8px 18px;
            cursor: pointer;
            transition: 0.1s;
            display: flex;
            align-items: center;
            gap: 10px;
            border-bottom: 1px solid #3e5568;
        }
        .context-menu .menu-item:last-child {
            border-bottom: none;
        }
        .context-menu .menu-item:hover {
            background: #3f5e7a;
        }
        .context-menu .menu-item.disabled {
            opacity: 0.4;
            pointer-events: none;
        }
        .context-menu .divider {
            height: 1px;
            background: #3e5568;
            margin: 4px 0;
        }
    </style>
</head>
<body>
<div class="cabinet-wrapper">
    <div id="container"></div>
    <div class="info-panel">
        <div class="device-selector">
            <label><input type="radio" name="deviceSize" value="2" checked> 2U 设备</label>
            <label><input type="radio" name="deviceSize" value="4"> 4U 设备</label>
        </div>
        <div class="legend">
            <span class="legend-item"><span class="color-dot dot-gray"></span>空闲</span>
            <span class="legend-item"><span class="color-dot dot-blue"></span>已上架</span>
            <span class="legend-item"><span class="color-dot dot-green"></span>悬停</span>
        </div>
        <button class="reset-btn" id="resetBtn">清空机柜</button>
    </div>
    <div style="color:#9bb7d4; text-align:center; margin-top: 12px; font-size: 14px; font-weight: 400;">
        ⬆️ 右键单击空白U位显示「上架设备」菜单;右键单击已上建设备显示「下架/更换」菜单
    </div>
</div>

<!-- 自定义右键菜单容器 (默认隐藏) -->
<div id="contextMenu" class="context-menu" style="display: none;"></div>

<script>
    (function() {
        // 容器
        const container = document.getElementById('container');
        // 机柜总U数
        const TOTAL_U = 44;
        // 每个U占的高度 (px)
        const U_HEIGHT = 16; 
        // 左右刻度区域宽度
        const SCALE_WIDTH = 24;
        // 中间设备区域宽度
        const SLOT_WIDTH = 352;  // 400 - 2*24

        // 存储当前上架设备: 键为起始U (1-based), 值为U数
        const mountedDevices = new Map(); // 例如 { 20: 2, 30: 4 }

        // 当前选择的设备U数 (用于上架)
        let selectedSize = 2;

        // 右键菜单DOM
        const menuEl = document.getElementById('contextMenu');

        // 当前右键点击的节点信息 (用于菜单操作)
        let currentContextNode = null; // 存储 { type, uIdx, startU, sizeU, model }

        // 监听radio变化
        document.querySelectorAll('input[name="deviceSize"]').forEach(radio => {
            radio.addEventListener('change', (e) => {
                selectedSize = parseInt(e.target.value, 10);
            });
        });

        // 清空按钮
        document.getElementById('resetBtn').addEventListener('click', () => {
            mountedDevices.clear();
            hideContextMenu();
            renderG6Graph();
        });

        // 隐藏右键菜单
        function hideContextMenu() {
            menuEl.style.display = 'none';
            currentContextNode = null;
        }

        // 显示菜单并设置位置
        function showContextMenu(x, y, items) {
            // 构建菜单HTML
            let html = '';
            items.forEach(item => {
                if (item.type === 'divider') {
                    html += '<div class="divider"></div>';
                } else {
                    const disabledClass = item.disabled ? 'disabled' : '';
                    html += `<div class="menu-item ${disabledClass}" data-action="${item.action}">${item.icon || ''} ${item.label}</div>`;
                }
            });
            menuEl.innerHTML = html;
            menuEl.style.display = 'block';
            menuEl.style.left = x + 'px';
            menuEl.style.top = y + 'px';

            // 绑定菜单项点击事件 (委托)
            const onMenuItemClick = (e) => {
                const target = e.target.closest('.menu-item');
                if (!target || target.classList.contains('disabled')) return;
                const action = target.dataset.action;
                if (action && currentContextNode) {
                    handleMenuAction(action, currentContextNode);
                }
                hideContextMenu();
                e.stopPropagation();
            };
            menuEl.addEventListener('click', onMenuItemClick, { once: true });
        }

        // 处理菜单动作
        function handleMenuAction(action, nodeInfo) {
            if (action === 'mount') {
                // 上架设备 (使用当前selectedSize)
                const u = nodeInfo.uIdx;
                if (canPlace(u, selectedSize)) {
                    mountedDevices.set(u, selectedSize);
                    renderG6Graph();
                } else {
                    alert(`无法在U${u}放置${selectedSize}U设备,位置冲突或超出边界`);
                }
            }
            else if (action === 'mount2') {
                // 上架2U (强制)
                if (canPlace(nodeInfo.uIdx, 2)) {
                    mountedDevices.set(nodeInfo.uIdx, 2);
                    renderG6Graph();
                } else alert('无法放置2U设备');
            }
            else if (action === 'mount4') {
                // 上架4U (强制)
                if (canPlace(nodeInfo.uIdx, 4)) {
                    mountedDevices.set(nodeInfo.uIdx, 4);
                    renderG6Graph();
                } else alert('无法放置4U设备');
            }
            else if (action === 'unmount') {
                // 下架设备
                const startU = nodeInfo.startU;
                if (mountedDevices.has(startU)) {
                    mountedDevices.delete(startU);
                    renderG6Graph();
                }
            }
            else if (action === 'replace') {
                // 更换位置 -> 简单起见,先下架再提示用户点击新U位 (这里我们打开一个"待更换"状态)
                // 为了简化交互,这里先移除原设备,然后弹提示让用户选择新位置
                const oldStart = nodeInfo.startU;
                const oldSize = nodeInfo.sizeU;
                mountedDevices.delete(oldStart);
                renderG6Graph();
                alert(`请右键单击新的起始U位 (需连续${oldSize}U空闲) 并选择「上架设备」`);
            }
        }

        // 检查放置可行性
        function canPlace(startU, sizeU) {
            if (startU < 1 || startU + sizeU - 1 > TOTAL_U) return false;
            for (let u = startU; u < startU + sizeU; u++) {
                for (let [occStart, occSize] of mountedDevices.entries()) {
                    if (u >= occStart && u < occStart + occSize) return false;
                }
            }
            return true;
        }

        // 构建G6数据
        function buildGraphData() {
            const nodes = [];
            const edges = [];

            // 左右刻度
            for (let i = 1; i <= TOTAL_U; i++) {
                // 左侧刻度
                nodes.push({
                    id: `left-${i}`,
                    type: 'rect',
                    label: `${i}`,
                    size: [SCALE_WIDTH - 6, U_HEIGHT - 2],
                    style: {
                        fill: '#3e4f5e',
                        stroke: '#1b2a36',
                        lineWidth: 1,
                        radius: 2,
                        fillOpacity: 0.9,
                        shadowBlur: 2,
                        shadowColor: '#00000040',
                    },
                    labelCfg: {
                        style: { fill: '#b9cfec', fontSize: 9, fontWeight: 'bold' },
                        position: 'center',
                    },
                    x: SCALE_WIDTH / 2,
                    y: (TOTAL_U - i + 0.5) * U_HEIGHT,
                });
                // 右侧刻度
                nodes.push({
                    id: `right-${i}`,
                    type: 'rect',
                    label: `${i}`,
                    size: [SCALE_WIDTH - 6, U_HEIGHT - 2],
                    style: {
                        fill: '#3e4f5e',
                        stroke: '#1b2a36',
                        lineWidth: 1,
                        radius: 2,
                        fillOpacity: 0.9,
                    },
                    labelCfg: {
                        style: { fill: '#b9cfec', fontSize: 9, fontWeight: 'bold' },
                        position: 'center',
                    },
                    x: 400 - SCALE_WIDTH / 2,
                    y: (TOTAL_U - i + 0.5) * U_HEIGHT,
                });
            }

            // 中间槽位灰色节点(每个U)
            for (let i = 1; i <= TOTAL_U; i++) {
                nodes.push({
                    id: `slot-${i}`,
                    type: 'rect',
                    label: '',
                    size: [SLOT_WIDTH - 8, U_HEIGHT - 2],
                    style: {
                        fill: '#5d6f82',
                        stroke: '#2e3f4f',
                        lineWidth: 1,
                        radius: 2,
                        fillOpacity: 0.9,
                        cursor: 'pointer',
                    },
                    data: {
                        uIndex: i,
                        type: 'slot',
                    },
                    x: SCALE_WIDTH + SLOT_WIDTH / 2,
                    y: (TOTAL_U - i + 0.5) * U_HEIGHT,
                });
            }

            // 已上架的设备节点 (蓝色)
            for (let [startU, sizeU] of mountedDevices.entries()) {
                const centerY = (TOTAL_U - startU + 0.5 - (sizeU - 1) / 2) * U_HEIGHT;
                nodes.push({
                    id: `dev-${startU}`,
                    type: 'rect',
                    label: `${sizeU}U`,
                    size: [SLOT_WIDTH - 8, sizeU * U_HEIGHT - 2],
                    style: {
                        fill: '#1e5a9b',
                        stroke: '#7ab8f0',
                        lineWidth: 2,
                        radius: 4,
                        fillOpacity: 0.95,
                        shadowBlur: 6,
                        shadowColor: '#1e3a5f',
                        cursor: 'pointer',
                    },
                    labelCfg: {
                        style: { fill: 'white', fontSize: 14, fontWeight: 'bold', shadowBlur: 4, shadowColor: 'black' },
                        position: 'center',
                    },
                    data: {
                        type: 'device',
                        startU: startU,
                        sizeU: sizeU,
                    },
                    x: SCALE_WIDTH + SLOT_WIDTH / 2,
                    y: centerY,
                });
            }

            return { nodes, edges };
        }

        let graph = null;

        function renderG6Graph() {
            const data = buildGraphData();

            if (graph) {
                graph.changeData(data);
                return;
            }

            graph = new G6.Graph({
                container: 'container',
                width: 400,
                height: 700,
                modes: {
                    default: ['drag-canvas', 'click-select'], // 允许拖拽,但右键会阻止默认
                },
                defaultNode: {
                    type: 'rect',
                    anchorPoints: [[0, 0.5], [1, 0.5]],
                },
                nodeStateStyles: {
                    hover: {
                        fill: '#3f9e4d',
                        stroke: '#b0f7b5',
                        lineWidth: 2,
                        shadowBlur: 10,
                        shadowColor: '#73d97b',
                    },
                },
                enabledStack: false,
            });

            graph.data(data);
            graph.render();

            // 监听节点右键事件 (contextmenu)
            graph.on('node:contextmenu', (evt) => {
                // 阻止浏览器默认右键菜单
                evt.originalEvent.preventDefault();
                
                const node = evt.item;
                const model = node.getModel();
                const nodeData = model.data || {};
                
                // 获取鼠标坐标用于定位菜单
                const canvasPoint = evt.canvasX + 10, canvasY = evt.canvasY + 10; // 偏移一点
                // 转换为页面坐标 (需要简单估算,因为G6画布可能偏移)
                const containerRect = document.getElementById('container').getBoundingClientRect();
                const pageX = containerRect.left + canvasPoint;
                const pageY = containerRect.top + canvasY;

                // 根据节点类型构造菜单项
                let menuItems = [];

                if (nodeData.type === 'slot') {
                    const u = nodeData.uIndex;
                    // 检查这个U是否已被设备占用 (理论上slot不会被占用,但如果有蓝色覆盖但slot还在下层,右键可能点到slot? 由于设备节点在上层,右键设备会触发device,所以这里一定是空闲slot)
                    // 但为了安全,二次确认是否空闲
                    let occupied = false;
                    for (let [start, size] of mountedDevices.entries()) {
                        if (u >= start && u < start + size) { occupied = true; break; }
                    }
                    if (!occupied) {
                        // 空闲U位: 上架菜单
                        menuItems = [
                            { label: '📦 上架 2U 设备', action: 'mount2', icon: '🟦' },
                            { label: '📦 上架 4U 设备', action: 'mount4', icon: '🟦' },
                            { type: 'divider' },
                            { label: `使用当前选择 (${selectedSize}U)`, action: 'mount', icon: '⚡' },
                        ];
                        currentContextNode = { type: 'slot', uIdx: u };
                    } else {
                        // 极少数情况被占用
                        menuItems = [ { label: '⚠️ 该U位已被占用', action: '', disabled: true } ];
                        currentContextNode = null;
                    }
                }
                else if (nodeData.type === 'device') {
                    const startU = nodeData.startU;
                    const sizeU = nodeData.sizeU;
                    // 设备节点: 下架、更换菜单
                    menuItems = [
                        { label: '❌ 下架设备', action: 'unmount', icon: '⏏️' },
                        { label: '🔄 更换U位置', action: 'replace', icon: '📌' },
                    ];
                    currentContextNode = { type: 'device', startU, sizeU };
                }

                if (menuItems.length > 0) {
                    showContextMenu(pageX, pageY, menuItems);
                }
            });

            // 点击空白区域隐藏菜单
            graph.on('canvas:click', () => {
                hideContextMenu();
            });

            // 鼠标移入移出悬停效果
            graph.on('node:mouseenter', (evt) => {
                const node = evt.item;
                graph.setItemState(node, 'hover', true);
            });
            graph.on('node:mouseleave', (evt) => {
                const node = evt.item;
                graph.setItemState(node, 'hover', false);
            });

            graph.fitView();
        }

        // 全局点击隐藏菜单 (点击其他区域)
        document.addEventListener('click', (e) => {
            if (!menuEl.contains(e.target)) {
                hideContextMenu();
            }
        });
        // 阻止浏览器默认右键菜单在整个页面
        document.addEventListener('contextmenu', (e) => {
            if (!e.target.closest('#container')) return; // 仅阻止画布区域的默认右键,也可以全局阻止
            e.preventDefault();
        });

        // 初始化渲染
        renderG6Graph();

        setTimeout(() => {
            if (graph) graph.fitView();
        }, 100);
    })();
</script>
</body>
</html>
相关推荐
C澒2 小时前
供应链产研交付提效:前端多业务线新增样板间页面统计方案
前端·mr
可视之道2 小时前
低代码可视化平台的前端架构设计:从渲染引擎到插件系统
前端
南城书生2 小时前
Android Kotlin 协程原理分析
前端
Lee川2 小时前
🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”
前端·javascript·面试
唐叔在学习2 小时前
TodoList应用:SPA应用首屏性能优化实践
前端·javascript·性能优化
恋猫de小郭2 小时前
AI 时代的工程师需要具备什么能力?Augment Code 给出了他们的招聘标准
前端·人工智能·ai编程
kyriewen2 小时前
别再滥用 iframe 了!这些场景下它其实是最优解
前端·javascript·html
Nile2 小时前
解密openclaw底层pi-mono架构系列一:5. pi-web-ui
前端·ui·架构
郝学胜-神的一滴2 小时前
系统设计与面向对象设计:两大设计思想的深度剖析
java·前端·c++·ue5·软件工程