Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>NodeFlow Navigator</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/vue@3.2.47/dist/vue.global.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/cytoscape@3.23.0/dist/cytoscape.min.js"></script> | |
| <style> | |
| #cy { | |
| width: 100%; | |
| height: 80vh; | |
| background-color: #f8fafc; | |
| border-radius: 1rem; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| } | |
| .node-label { | |
| font-size: 12px; | |
| text-align: center; | |
| color: #1e293b; | |
| font-weight: 600; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 min-h-screen"> | |
| <div id="app" class="container mx-auto px-4 py-8"> | |
| <header class="mb-8 text-center"> | |
| <h1 class="text-4xl font-bold text-blue-600 mb-2">NodeFlow Navigator ๐</h1> | |
| <p class="text-gray-600">Visualize command relationships with interactive nodes</p> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-4 gap-6"> | |
| <div class="lg:col-span-3"> | |
| <div id="cy"></div> | |
| </div> | |
| <div class="bg-white p-6 rounded-lg shadow-md"> | |
| <h2 class="text-xl font-semibold mb-4 text-blue-700">Controls</h2> | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Node Label</label> | |
| <input v-model="newNodeLabel" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Connections</label> | |
| <div class="flex space-x-2"> | |
| <select v-model="sourceNode" class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <option value="">Select source</option> | |
| <option v-for="node in nodes" :value="node.data.id">{{ node.data.label }}</option> | |
| </select> | |
| <select v-model="targetNode" class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <option value="">Select target</option> | |
| <option v-for="node in nodes" :value="node.data.id">{{ node.data.label }}</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="flex space-x-2"> | |
| <button @click="addNode" class="flex-1 bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition"> | |
| <i data-feather="plus-circle" class="inline mr-2"></i> Add Node | |
| </button> | |
| <button @click="addEdge" class="flex-1 bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md transition"> | |
| <i data-feather="link" class="inline mr-2"></i> Connect | |
| </button> | |
| <button @click="createGroup('left')" class="flex-1 bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-md transition"> | |
| <i data-feather="box" class="inline mr-2"></i> Group Left | |
| </button> | |
| <button @click="createGroup('right')" class="flex-1 bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded-md transition"> | |
| <i data-feather="box" class="inline mr-2"></i> Group Right | |
| </button> | |
| </div> | |
| <div class="pt-4 border-t border-gray-200"> | |
| <button @click="resetGraph" class="w-full bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md transition"> | |
| <i data-feather="trash-2" class="inline mr-2"></i> Reset | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, onMounted } = Vue; | |
| createApp({ | |
| setup() { | |
| const newNodeLabel = ref(''); | |
| const sourceNode = ref(''); | |
| const targetNode = ref(''); | |
| const nodes = ref([ | |
| { data: { id: 'cmd1', label: 'git init' } }, | |
| { data: { id: 'cmd2', label: 'git add' } }, | |
| { data: { id: 'cmd3', label: 'git commit' } }, | |
| { data: { id: 'cmd4', label: 'git push' } } | |
| ]); | |
| const edges = ref([ | |
| { data: { id: 'e1', source: 'cmd1', target: 'cmd2' } }, | |
| { data: { id: 'e2', source: 'cmd2', target: 'cmd3' } }, | |
| { data: { id: 'e3', source: 'cmd3', target: 'cmd4' } } | |
| ]); | |
| let cy = null; | |
| onMounted(() => { | |
| cy = cytoscape({ | |
| container: document.getElementById('cy'), | |
| elements: { | |
| nodes: nodes.value, | |
| edges: edges.value | |
| }, | |
| style: [ | |
| { | |
| selector: 'node', | |
| style: { | |
| 'label': 'data(label)', | |
| 'text-valign': 'center', | |
| 'text-halign': 'center', | |
| 'background-color': '#bfdbfe', | |
| 'border-width': 2, | |
| 'border-color': '#3b82f6', | |
| 'width': '100px', | |
| 'height': '60px', | |
| 'shape': 'round-rectangle', | |
| 'font-size': '12px' | |
| } | |
| }, | |
| { | |
| selector: 'edge', | |
| style: { | |
| 'width': 2, | |
| 'line-color': '#64748b', | |
| 'curve-style': 'bezier', | |
| 'target-arrow-color': '#64748b', | |
| 'target-arrow-shape': 'triangle' | |
| } | |
| }, | |
| { | |
| selector: '.selected', | |
| style: { | |
| 'border-width': 3, | |
| 'border-color': '#ef4444', | |
| 'border-style': 'solid' | |
| } | |
| } | |
| ], | |
| style: [ | |
| { | |
| selector: '.parent', | |
| style: { | |
| 'background-color': '#c4b5fd', | |
| 'border-color': '#8b5cf6', | |
| 'width': '160px', | |
| 'height': '100px', | |
| 'font-size': '14px', | |
| 'shape': 'hexagon', | |
| 'border-width': '3px' | |
| } | |
| }, | |
| { | |
| selector: 'edge[isGroupEdge]', | |
| style: { | |
| 'line-color': '#8b5cf6', | |
| 'line-style': 'dashed', | |
| 'target-arrow-color': '#8b5cf6' | |
| } | |
| }, | |
| { | |
| selector: 'node', | |
| style: { | |
| 'label': 'data(label)', | |
| 'text-valign': 'center', | |
| 'text-halign': 'center', | |
| 'background-color': '#bfdbfe', | |
| 'border-width': 2, | |
| 'border-color': '#3b82f6', | |
| 'width': '100px', | |
| 'height': '60px', | |
| 'shape': 'round-rectangle', | |
| 'font-size': '12px' | |
| } | |
| }, | |
| { | |
| selector: 'edge', | |
| style: { | |
| 'width': 2, | |
| 'line-color': '#64748b', | |
| 'curve-style': 'bezier', | |
| 'target-arrow-color': '#64748b', | |
| 'target-arrow-shape': 'triangle' | |
| } | |
| }, | |
| { | |
| selector: '.selected', | |
| style: { | |
| 'border-width': 3, | |
| 'border-color': '#ef4444', | |
| 'border-style': 'solid' | |
| } | |
| } | |
| ], | |
| layout: { | |
| name: 'cose', | |
| animate: true, | |
| animationDuration: 1000, | |
| fit: true, | |
| padding: 50 | |
| } | |
| }); | |
| // Make nodes draggable | |
| cy.on('drag', 'node', function(event) { | |
| event.target.style('background-color', '#93c5fd'); | |
| }); | |
| cy.on('free', 'node', function(event) { | |
| event.target.style('background-color', '#bfdbfe'); | |
| }); | |
| // Click handler for nodes | |
| cy.on('click', 'node', function(event) { | |
| const node = event.target; | |
| node.animate({ | |
| style: { 'background-color': '#60a5fa' } | |
| }, { | |
| duration: 300, | |
| complete: function() { | |
| node.animate({ | |
| style: { 'background-color': '#bfdbfe' } | |
| }, { | |
| duration: 300 | |
| }); | |
| } | |
| }); | |
| alert(`Command executed: ${node.data('label')}`); | |
| }); | |
| // Right click to select node with red border | |
| cy.on('cxttap', 'node', function(event) { | |
| const node = event.target; | |
| // Remove previous selections | |
| cy.elements().removeClass('selected'); | |
| // Add red border to selected node | |
| node.addClass('selected'); | |
| }); | |
| }); | |
| function addNode() { | |
| if (!newNodeLabel.value) return; | |
| const id = 'cmd' + (nodes.value.length + 1); | |
| const newNode = { | |
| data: { id, label: newNodeLabel.value } | |
| }; | |
| nodes.value.push(newNode); | |
| cy.add(newNode); | |
| cy.layout({ name: 'cose', animate: true }).run(); | |
| newNodeLabel.value = ''; | |
| } | |
| function addEdge() { | |
| if (!sourceNode.value || !targetNode.value) return; | |
| const id = 'e' + (edges.value.length + 1); | |
| const newEdge = { | |
| data: { id, source: sourceNode.value, target: targetNode.value } | |
| }; | |
| edges.value.push(newEdge); | |
| cy.add(newEdge); | |
| cy.layout({ name: 'cose', animate: true }).run(); | |
| // Reset select inputs | |
| sourceNode.value = ''; | |
| targetNode.value = ''; | |
| } | |
| function createGroup(direction) { | |
| const selectedNodes = cy.elements('.selected'); | |
| if (selectedNodes.length < 2) { | |
| alert('Select at least 2 nodes to group (right-click to select)'); | |
| return; | |
| } | |
| // Get positions for new group node | |
| const positions = selectedNodes.map(node => node.position()); | |
| const avgX = positions.reduce((sum, pos) => sum + pos.x, 0) / positions.length; | |
| const avgY = positions.reduce((sum, pos) => sum + pos.y, 0) / positions.length; | |
| // Position new group node based on direction | |
| const offset = direction === 'left' ? -200 : 200; | |
| const groupX = avgX + offset; | |
| // Get connected nodes' labels for group name | |
| const labels = selectedNodes.map(node => node.data('label')); | |
| const groupName = labels.join(' + '); | |
| // Create parent node with position | |
| const parentId = 'group-' + Date.now(); | |
| const parentNode = { | |
| data: { | |
| id: parentId, | |
| label: groupName, | |
| isParent: true | |
| }, | |
| position: { x: groupX, y: avgY }, | |
| classes: 'parent' | |
| }; | |
| // Add parent node | |
| nodes.value.push(parentNode); | |
| const parent = cy.add(parentNode); | |
| // Animate children moving to parent | |
| selectedNodes.forEach(node => { | |
| const newX = groupX + (direction === 'left' ? -50 : 50) * Math.random(); | |
| const newY = avgY + 100 * (Math.random() - 0.5); | |
| node.animate({ | |
| position: { x: newX, y: newY }, | |
| complete: () => { | |
| node.move({ parent: parentId }); | |
| } | |
| }); | |
| }); | |
| // Connect parent to children after animation | |
| setTimeout(() => { | |
| selectedNodes.forEach(node => { | |
| const edgeId = 'edge-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); | |
| const newEdge = { | |
| data: { | |
| id: edgeId, | |
| source: parentId, | |
| target: node.id(), | |
| isGroupEdge: true | |
| } | |
| }; | |
| edges.value.push(newEdge); | |
| cy.add(newEdge); | |
| }); | |
| cy.layout({ name: 'cose', animate: true }).run(); | |
| }, 800); | |
| } | |
| function resetGraph() { | |
| nodes.value = [ | |
| { data: { id: 'cmd1', label: 'git init' } }, | |
| { data: { id: 'cmd2', label: 'git add' } }, | |
| { data: { id: 'cmd3', label: 'git commit' } }, | |
| { data: { id: 'cmd4', label: 'git push' } } | |
| ]; | |
| edges.value = [ | |
| { data: { id: 'e1', source: 'cmd1', target: 'cmd2' } }, | |
| { data: { id: 'e2', source: 'cmd2', target: 'cmd3' } }, | |
| { data: { id: 'e3', source: 'cmd3', target: 'cmd4' } } | |
| ]; | |
| cy.elements().remove(); | |
| cy.add(nodes.value); | |
| cy.add(edges.value); | |
| cy.layout({ name: 'cose', animate: true }).run(); | |
| } | |
| return { | |
| newNodeLabel, | |
| sourceNode, | |
| targetNode, | |
| nodes, | |
| addNode, | |
| addEdge, | |
| resetGraph | |
| }; | |
| } | |
| }).mount('#app'); | |
| feather.replace(); | |
| </script> | |
| </body> | |
| </html> | |