nodeflow-navigator / index.html
peter649's picture
where is your "group" node which does the grouping?? if in doupt place from left to right
826416a verified
<!DOCTYPE html>
<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>