Skip to content

Instantly share code, notes, and snippets.

@saicharanreddyk
Created June 15, 2025 16:07
Show Gist options
  • Save saicharanreddyk/cc9a03b5068c8f5dc60ec77607abf857 to your computer and use it in GitHub Desktop.
Save saicharanreddyk/cc9a03b5068c8f5dc60ec77607abf857 to your computer and use it in GitHub Desktop.
/**
* RecipeJsonViewer - A utility for visualizing JSON recipe data as interactive graphs
*
* Usage:
* const viewer = new RecipeJsonViewer(containerElement, options);
* viewer.loadJson(jsonData);
*/
class RecipeJsonViewer {
constructor(container, options = {}) {
this.container = container;
this.options = {
width: options.width || '100%',
height: options.height || '600px',
showJsonInput: options.showJsonInput !== false,
enableEditing: options.enableEditing !== false,
...options
};
// State management
this.jsonData = null;
this.pan = { x: 50, y: 200 };
this.zoom = 0.5;
this.isPanning = false;
this.isDraggingNode = false;
this.draggedNode = null;
this.hasDragged = false;
this.debounceTimeout = null;
this.dragRenderTimeout = null;
this.dragOffset = { x: 0, y: 0 };
this.startPoint = { x: 0, y: 0 };
this.isInitialRender = true;
this.init();
}
init() {
this.createHTML();
this.setupEventListeners();
this.setupIcons();
}
createHTML() {
this.container.innerHTML = `
<div class="recipe-json-viewer" style="width: ${this.options.width}; height: ${this.options.height}; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; display: flex; flex-direction: column; overflow: hidden; position: relative;">
<style>
.recipe-json-viewer * { box-sizing: border-box; }
.recipe-json-viewer ::-webkit-scrollbar { width: 8px; height: 8px; }
.recipe-json-viewer ::-webkit-scrollbar-track { background: #f1f1f1; }
.recipe-json-viewer ::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }
.recipe-json-viewer ::-webkit-scrollbar-thumb:hover { background: #555; }
.recipe-graph-container {
cursor: grab;
overflow: hidden;
background-color: #f9fafb;
background-image: linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
flex: 1;
position: relative;
}
.recipe-graph-container.loading {
opacity: 0;
pointer-events: none;
}
.recipe-graph-container.loading::before {
content: 'Loading graph...';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 16px;
color: #6b7280;
z-index: 100;
opacity: 1;
}
.recipe-graph-container.ready {
opacity: 1;
pointer-events: auto;
transition: opacity 0.3s ease;
}
.recipe-graph-canvas {
position: relative;
transform-origin: 0 0;
width: 100%;
height: 100%;
min-width: 2500px;
min-height: 1500px;
}
.recipe-node {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 160px;
height: 80px;
background-color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
cursor: move;
transition: box-shadow 0.2s;
padding: 10px;
text-align: center;
user-select: none;
border-radius: 6px;
}
.recipe-node.dragging {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
z-index: 10;
}
.recipe-node-icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
pointer-events: none;
}
.recipe-node-label {
font-weight: 500;
font-size: 13px;
line-height: 1.3;
color: #374151;
pointer-events: none;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
max-width: 100%;
}
/* Node Types */
.recipe-node.LOAD_DATASET {
background-color: #ecfeff;
border-color: #06b6d4;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
width: 140px; height: 110px;
}
.recipe-node.LOAD_DATASET .recipe-node-icon { stroke: #0891b2; }
.recipe-node.TRANSFORM {
background-color: #eff6ff;
border-color: #3b82f6;
border-radius: 12px;
width: 140px; height: 80px;
}
.recipe-node.TRANSFORM .recipe-node-icon { stroke: #2563eb; }
.recipe-node.APPEND, .recipe-node.JOIN {
background-color: #fff7ed;
border-color: #f97316;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
width: 140px; height: 110px;
}
.recipe-node.APPEND .recipe-node-icon, .recipe-node.JOIN .recipe-node-icon { stroke: #ea580c; }
.recipe-node.OUTPUT {
background-color: #f5f3ff;
border-color: #8b5cf6;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
width: 140px; height: 110px;
}
.recipe-node.OUTPUT .recipe-node-icon { stroke: #7c3aed; }
.recipe-connector {
fill: none;
stroke: #9ca3af;
stroke-width: 1.5;
marker-end: url(#arrow);
}
.recipe-view-controls {
position: absolute;
bottom: 1rem;
right: 1rem;
z-index: 20;
display: flex;
gap: 0.5rem;
background-color: white;
padding: 0.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.recipe-control-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
cursor: pointer;
font-weight: 600;
color: #4b5563;
font-size: 16px;
}
.recipe-control-btn:hover {
background-color: #e5e7eb;
}
.recipe-json-input {
width: 100%;
height: 200px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 12px;
resize: vertical;
background-color: white;
}
.recipe-json-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.recipe-error-message {
color: #dc2626;
font-size: 14px;
font-weight: 500;
margin-top: 8px;
}
.recipe-modal {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
transition: opacity 0.3s ease;
}
.recipe-modal-content {
background-color: white;
border-radius: 8px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
max-height: 80vh;
transition: transform 0.3s ease;
}
.recipe-modal-header {
border-bottom: 1px solid #e5e7eb;
padding: 1.5rem 1.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.recipe-modal-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.recipe-modal-close {
color: #6b7280;
font-size: 24px;
cursor: pointer;
line-height: 1;
padding: 0;
background: none;
border: none;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.recipe-modal-close:hover {
color: #1f2937;
background-color: #f3f4f6;
border-radius: 4px;
}
.recipe-modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: 60vh;
}
.recipe-modal-pre {
background-color: #f9fafb;
padding: 1rem;
border-radius: 6px;
font-size: 12px;
overflow: auto;
white-space: pre-wrap;
font-family: 'Courier New', monospace;
margin: 0;
}
.recipe-input-section {
padding: 1rem;
background-color: white;
border-bottom: 1px solid #e5e7eb;
}
</style>
${this.options.showJsonInput ? `
<div class="recipe-input-section">
<textarea class="recipe-json-input" placeholder="Paste your JSON recipe here..."></textarea>
<div class="recipe-error-message"></div>
</div>
` : ''}
<div class="recipe-graph-container loading">
<div class="recipe-graph-canvas">
<svg style="position:absolute; top:0; left:0; width:100%; height:100%; z-index: 0; pointer-events:none; overflow: visible;"></svg>
</div>
<div class="recipe-view-controls">
<button class="recipe-control-btn recipe-zoom-in" title="Zoom In">+</button>
<button class="recipe-control-btn recipe-zoom-out" title="Zoom Out">−</button>
<button class="recipe-control-btn recipe-center" title="Center View">⌂</button>
</div>
</div>
<div class="recipe-modal" style="display: none; opacity: 0;">
<div class="recipe-modal-content" style="transform: translateY(-2.5rem);">
<div class="recipe-modal-header">
<h3 class="recipe-modal-title">Node Details</h3>
<button class="recipe-modal-close">&times;</button>
</div>
<div class="recipe-modal-body">
<pre class="recipe-modal-pre"></pre>
</div>
</div>
</div>
</div>
`;
// Cache DOM elements
this.elements = {
jsonInput: this.container.querySelector('.recipe-json-input'),
errorMessage: this.container.querySelector('.recipe-error-message'),
graphContainer: this.container.querySelector('.recipe-graph-container'),
graphCanvas: this.container.querySelector('.recipe-graph-canvas'),
svgLayer: this.container.querySelector('.recipe-graph-canvas svg'),
modal: this.container.querySelector('.recipe-modal'),
modalTitle: this.container.querySelector('.recipe-modal-title'),
modalBody: this.container.querySelector('.recipe-modal-pre'),
modalCloseBtn: this.container.querySelector('.recipe-modal-close'),
zoomInBtn: this.container.querySelector('.recipe-zoom-in'),
zoomOutBtn: this.container.querySelector('.recipe-zoom-out'),
centerBtn: this.container.querySelector('.recipe-center')
};
}
setupIcons() {
this.icons = {
LOAD_DATASET: `<svg class="recipe-node-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
TRANSFORM: `<svg class="recipe-node-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>`,
APPEND: `<svg class="recipe-node-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14m-7-7h14"/></svg>`,
JOIN: `<svg class="recipe-node-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 21h4a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2Z"/><path d="M18 16h-4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2Z"/></svg>`,
OUTPUT: `<svg class="recipe-node-icon" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>`,
DEFAULT: `<svg class="recipe-node-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>`
};
}
setupEventListeners() {
// JSON input change handler
if (this.elements.jsonInput && this.options.enableEditing) {
this.elements.jsonInput.addEventListener('input', () => this.handleJsonInputChange());
}
// Zoom controls
this.elements.zoomInBtn.addEventListener('click', () => this.zoomAt(1.2, this.elements.graphContainer.clientWidth / 2, this.elements.graphContainer.clientHeight / 2));
this.elements.zoomOutBtn.addEventListener('click', () => this.zoomAt(1 / 1.2, this.elements.graphContainer.clientWidth / 2, this.elements.graphContainer.clientHeight / 2));
this.elements.centerBtn.addEventListener('click', () => this.centerView());
// Modal handlers
this.elements.modalCloseBtn.addEventListener('click', () => this.closeModal());
this.elements.modal.addEventListener('click', (e) => {
if (e.target === this.elements.modal) this.closeModal();
});
// Pan and zoom
this.elements.graphContainer.addEventListener('mousedown', (e) => this.onContainerMouseDown(e));
this.elements.graphContainer.addEventListener('wheel', (e) => this.onWheel(e));
// Bind methods
this.onWindowMouseMove = this.onWindowMouseMove.bind(this);
this.onWindowMouseUp = this.onWindowMouseUp.bind(this);
}
loadJson(jsonData) {
this.jsonData = jsonData;
if (this.elements.jsonInput) {
this.elements.jsonInput.value = JSON.stringify(jsonData, null, 2);
}
this.renderWithManualLayout();
}
handleJsonInputChange() {
clearTimeout(this.debounceTimeout);
this.debounceTimeout = setTimeout(() => {
try {
this.jsonData = JSON.parse(this.elements.jsonInput.value);
if (this.elements.errorMessage) {
this.elements.errorMessage.textContent = '';
}
if (!this.isInitialRender) {
this.autoLayoutAndRender();
this.centerView();
} else {
this.renderGraph(this.jsonData);
}
} catch (e) {
if (this.elements.errorMessage) {
this.elements.errorMessage.textContent = "Invalid JSON. Please check the syntax.";
}
}
}, 500);
}
renderWithManualLayout() {
if (!this.jsonData) return;
this.isInitialRender = true;
// Use the new hierarchical algorithm for all layouts
if (this.jsonData && this.jsonData.ui && this.jsonData.ui.nodes) {
console.log('🎨 Using hierarchical layout algorithm');
this.calculateHierarchicalLayout();
}
// Now render with proper positions
this.renderGraph(this.jsonData);
// Wait for canvas to be ready, then center and finalize
const waitForReady = () => {
if (this.elements.graphContainer.classList.contains('ready')) {
this.centerView();
// CRITICAL: Re-render connectors AFTER centerView transform is applied
setTimeout(() => {
console.log('Re-rendering connectors after centerView transform');
this.renderConnectors(this.jsonData.ui.connectors);
this.isInitialRender = false;
// Single final validation
setTimeout(() => {
const connectorCount = this.elements.svgLayer.querySelectorAll('.recipe-connector').length;
const expectedCount = this.jsonData?.ui?.connectors?.length || 0;
if (connectorCount < expectedCount) {
console.log(`Final check: Re-rendering connectors - found ${connectorCount}, expected ${expectedCount}`);
this.renderConnectors(this.jsonData.ui.connectors);
}
}, 50);
}, 100);
} else {
setTimeout(waitForReady, 10);
}
};
waitForReady();
}
calculateHierarchicalLayout() {
console.log('🎨 Starting hierarchical layout calculation');
const connectors = this.jsonData.ui.connectors;
const nodeIds = Object.keys(this.jsonData.ui.nodes);
const nodes = this.jsonData.ui.nodes;
// 1. Analyze node types and semantic groups
const nodesByType = {
LOAD_DATASET: [],
TRANSFORM: [],
JOIN: [],
APPEND: [],
OUTPUT: [],
FILTER: []
};
nodeIds.forEach(id => {
const type = nodes[id].type;
if (nodesByType[type]) {
nodesByType[type].push(id);
}
});
console.log('📊 Node analysis:', nodesByType);
// 2. Build dependency graph for flow analysis
const adj = new Map(nodeIds.map(id => [id, []]));
const inDegree = new Map(nodeIds.map(id => [id, 0]));
const outDegree = new Map(nodeIds.map(id => [id, 0]));
connectors.forEach(({ source, target }) => {
adj.get(source)?.push(target);
inDegree.set(target, (inDegree.get(target) || 0) + 1);
outDegree.set(source, (outDegree.get(source) || 0) + 1);
});
// 3. Identify main flow vs supporting data
const mainFlowNodes = [];
const supportingNodes = [];
// Main flow: nodes with both inputs and outputs (processing nodes)
// Supporting: pure sources (LOAD_DATASET with no inputs) or leaves
nodeIds.forEach(id => {
const inDeg = inDegree.get(id) || 0;
const outDeg = outDegree.get(id) || 0;
const type = nodes[id].type;
if (type === 'LOAD_DATASET' && inDeg === 0) {
supportingNodes.push(id);
} else if (type === 'OUTPUT' && outDeg === 0) {
mainFlowNodes.push(id); // Outputs are end of main flow
} else if (inDeg > 0 && outDeg > 0) {
mainFlowNodes.push(id); // Processing nodes
} else if (type === 'TRANSFORM' || type === 'JOIN' || type === 'APPEND') {
mainFlowNodes.push(id); // Operations are typically main flow
} else {
supportingNodes.push(id);
}
});
console.log('🔄 Flow analysis:', { mainFlowNodes, supportingNodes });
// 4. Create hierarchical positioning
this.layoutHierarchical(mainFlowNodes, supportingNodes, adj, inDegree, connectors);
}
renderGraph(data) {
// Keep canvas hidden while rendering
this.elements.graphContainer.classList.add('loading');
this.elements.graphContainer.classList.remove('ready');
const nodes = this.elements.graphCanvas.querySelectorAll('.recipe-node');
nodes.forEach(node => node.remove());
if (!data?.ui?.nodes) {
this.elements.graphContainer.classList.remove('loading');
this.elements.graphContainer.classList.add('ready');
return;
}
Object.entries(data.ui.nodes).forEach(([nodeId, nodeData]) => {
const nodeEl = document.createElement('div');
nodeEl.id = nodeId;
nodeEl.className = `recipe-node ${nodeData.type}`;
nodeEl.style.left = `${nodeData.left}px`;
nodeEl.style.top = `${nodeData.top}px`;
nodeEl.innerHTML = `
<div class="recipe-node-icon">${this.icons[nodeData.type] || this.icons['DEFAULT']}</div>
<span class="recipe-node-label">${nodeData.label}</span>
`;
nodeEl.addEventListener('mousedown', (e) => this.onNodeMouseDown(e));
this.elements.graphCanvas.insertBefore(nodeEl, this.elements.svgLayer);
});
// Ensure DOM is fully updated before initial rendering
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Show canvas first - connectors will be rendered after centerView
this.elements.graphContainer.classList.remove('loading');
this.elements.graphContainer.classList.add('ready');
});
});
}
renderConnectors(connectors) {
// Log connector rendering (can be disabled for production)
if (this.options.debugLogging !== false) {
console.log('🔗 renderConnectors called', {
connectorCount: connectors?.length || 0,
currentTransform: this.elements.graphCanvas.style.transform,
pan: this.pan,
zoom: this.zoom
});
}
this.elements.svgLayer.innerHTML = '';
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const markerId = `arrow-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
defs.innerHTML = `<marker id="${markerId}" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#9ca3af" /></marker>`;
this.elements.svgLayer.appendChild(defs);
if (!connectors) return;
let renderedCount = 0;
connectors.forEach(conn => {
const sourceNode = this.elements.graphCanvas.querySelector(`#${conn.source}`);
const targetNode = this.elements.graphCanvas.querySelector(`#${conn.target}`);
if (sourceNode && targetNode) {
// Force reflow to ensure accurate positioning
sourceNode.offsetHeight;
targetNode.offsetHeight;
const startX = sourceNode.offsetLeft + sourceNode.offsetWidth;
const startY = sourceNode.offsetTop + sourceNode.offsetHeight / 2;
const endX = targetNode.offsetLeft;
const endY = targetNode.offsetTop + targetNode.offsetHeight / 2;
if (this.options.debugLogging !== false) {
console.log(`📍 Connector ${conn.source} -> ${conn.target}:`, {
source: { left: sourceNode.offsetLeft, top: sourceNode.offsetTop, width: sourceNode.offsetWidth, height: sourceNode.offsetHeight },
target: { left: targetNode.offsetLeft, top: targetNode.offsetTop, width: targetNode.offsetWidth, height: targetNode.offsetHeight },
calculated: { startX, startY, endX, endY }
});
}
// More lenient validation - allow endX to be 0 for left-positioned nodes
if (startX > sourceNode.offsetWidth && startY > 0 && endX >= 0 && endY > 0) {
const d = `M ${startX} ${startY} C ${startX + 60} ${startY}, ${endX - 60} ${endY}, ${endX} ${endY}`;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
path.setAttribute('class', 'recipe-connector');
path.setAttribute('marker-end', `url(#${markerId})`);
this.elements.svgLayer.appendChild(path);
renderedCount++;
console.log(`✅ Rendered connector with path: ${d}`);
} else {
console.warn(`❌ Invalid connector coordinates: ${conn.source} -> ${conn.target}`, {
source: { left: sourceNode.offsetLeft, top: sourceNode.offsetTop, width: sourceNode.offsetWidth, height: sourceNode.offsetHeight },
target: { left: targetNode.offsetLeft, top: targetNode.offsetTop, width: targetNode.offsetWidth, height: targetNode.offsetHeight },
calculated: { startX, startY, endX, endY }
});
}
} else {
console.warn(`❌ Missing nodes for connector: ${conn.source} -> ${conn.target}`, {
sourceExists: !!sourceNode,
targetExists: !!targetNode
});
}
});
console.log(`🎯 Final result: Rendered ${renderedCount} of ${connectors.length} connectors`);
}
// Event handlers
onNodeMouseDown(e) {
if (e.button !== 0) return;
this.hasDragged = false;
this.isDraggingNode = true;
this.draggedNode = e.currentTarget;
const rect = this.draggedNode.getBoundingClientRect();
this.dragOffset.x = (e.clientX - rect.left) / this.zoom;
this.dragOffset.y = (e.clientY - rect.top) / this.zoom;
window.addEventListener('mousemove', this.onWindowMouseMove);
window.addEventListener('mouseup', this.onWindowMouseUp);
e.preventDefault();
}
onContainerMouseDown(e) {
if (e.target !== this.elements.graphContainer && e.target !== this.elements.graphCanvas) return;
this.isPanning = true;
this.hasDragged = false;
this.elements.graphContainer.style.cursor = 'grabbing';
this.startPoint = { x: e.clientX - this.pan.x, y: e.clientY - this.pan.y };
window.addEventListener('mousemove', this.onWindowMouseMove);
window.addEventListener('mouseup', this.onWindowMouseUp);
}
onWindowMouseMove(e) {
if (this.isDraggingNode || this.isPanning) {
if (Math.abs(e.clientX - this.startPoint.x) > 3 || Math.abs(e.clientY - this.startPoint.y) > 3) {
this.hasDragged = true;
}
}
if (this.isPanning) {
this.pan.x = e.clientX - this.startPoint.x;
this.pan.y = e.clientY - this.startPoint.y;
this.updateTransform();
} else if (this.isDraggingNode && this.draggedNode && this.hasDragged) {
this.draggedNode.classList.add('dragging');
const containerRect = this.elements.graphContainer.getBoundingClientRect();
const newLeft = (e.clientX - containerRect.left - this.pan.x) / this.zoom - this.dragOffset.x;
const newTop = (e.clientY - containerRect.top - this.pan.y) / this.zoom - this.dragOffset.y;
this.draggedNode.style.left = `${newLeft}px`;
this.draggedNode.style.top = `${newTop}px`;
if (this.jsonData && this.jsonData.ui && this.jsonData.ui.nodes[this.draggedNode.id]) {
this.jsonData.ui.nodes[this.draggedNode.id].left = newLeft;
this.jsonData.ui.nodes[this.draggedNode.id].top = newTop;
}
// Debounce connector re-rendering during drag
clearTimeout(this.dragRenderTimeout);
this.dragRenderTimeout = setTimeout(() => {
this.renderConnectors(this.jsonData.ui.connectors);
}, 16); // ~60fps
}
}
onWindowMouseUp(e) {
if (this.isDraggingNode && !this.hasDragged) {
this.showNodeDetails(this.draggedNode.id);
}
if (this.draggedNode) {
this.draggedNode.classList.remove('dragging');
}
this.isPanning = false;
this.isDraggingNode = false;
this.draggedNode = null;
this.elements.graphContainer.style.cursor = 'grab';
window.removeEventListener('mousemove', this.onWindowMouseMove);
window.removeEventListener('mouseup', this.onWindowMouseUp);
}
onWheel(e) {
e.preventDefault();
const scale = (e.deltaY < 0) ? 1.1 : 1 / 1.1;
this.zoomAt(scale, e.clientX, e.clientY);
}
// Utility methods
updateTransform() {
this.elements.graphCanvas.style.transform = `translate(${this.pan.x}px, ${this.pan.y}px) scale(${this.zoom})`;
}
zoomAt(scale, x, y) {
const rect = this.elements.graphContainer.getBoundingClientRect();
const mouseX = x - rect.left;
const mouseY = y - rect.top;
const newZoom = Math.max(0.2, Math.min(2, this.zoom * scale));
this.pan.x = mouseX - (mouseX - this.pan.x) * (newZoom / this.zoom);
this.pan.y = mouseY - (mouseY - this.pan.y) * (newZoom / this.zoom);
this.zoom = newZoom;
this.updateTransform();
}
centerView() {
console.log('🎯 centerView called');
const nodes = this.elements.graphCanvas.querySelectorAll('.recipe-node');
if (nodes.length === 0) {
console.log('❌ No nodes found for centerView');
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach(node => {
minX = Math.min(minX, node.offsetLeft);
minY = Math.min(minY, node.offsetTop);
maxX = Math.max(maxX, node.offsetLeft + node.offsetWidth);
maxY = Math.max(maxY, node.offsetTop + node.offsetHeight);
});
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
const containerWidth = this.elements.graphContainer.clientWidth;
const containerHeight = this.elements.graphContainer.clientHeight;
console.log('📏 CenterView calculations:', {
bounds: { minX, minY, maxX, maxY },
graphSize: { graphWidth, graphHeight },
containerSize: { containerWidth, containerHeight },
currentZoom: this.zoom
});
// Prevent division by zero and invalid calculations
if (graphWidth <= 0 || graphHeight <= 0 || containerWidth <= 0 || containerHeight <= 0) {
console.warn('❌ Invalid dimensions for centerView, keeping current zoom:', {
graphWidth, graphHeight, containerWidth, containerHeight
});
// Keep current zoom but update transform
this.updateTransform();
return;
}
const scaleX = containerWidth / graphWidth;
const scaleY = containerHeight / graphHeight;
let newZoom = Math.min(scaleX, scaleY) * 0.8;
// Ensure zoom is valid and within bounds
if (!isFinite(newZoom) || newZoom <= 0) {
console.warn('❌ Invalid zoom calculated, using default:', newZoom);
newZoom = 0.8; // Fallback zoom
}
newZoom = Math.max(0.2, Math.min(2, newZoom)); // Clamp zoom
const newPanX = (containerWidth - (graphWidth * newZoom)) / 2 - (minX * newZoom);
const newPanY = (containerHeight - (graphHeight * newZoom)) / 2 - (minY * newZoom);
console.log('✅ CenterView result:', {
newZoom,
newPan: { x: newPanX, y: newPanY }
});
this.zoom = newZoom;
this.pan.x = newPanX;
this.pan.y = newPanY;
this.updateTransform();
}
showNodeDetails(nodeId) {
const uiNodeData = this.jsonData.ui.nodes[nodeId];
let detailsToShow = {};
let title = `${uiNodeData.label} (${nodeId})`;
if (uiNodeData.type === 'TRANSFORM' && uiNodeData.graph) {
title = `Transform Details: ${uiNodeData.label}`;
Object.keys(uiNodeData.graph).forEach(subNodeId => {
if (this.jsonData.nodes[subNodeId]) detailsToShow[subNodeId] = this.jsonData.nodes[subNodeId];
});
} else if (this.jsonData.nodes[nodeId]) {
detailsToShow = this.jsonData.nodes[nodeId];
} else {
detailsToShow = { "message": "No detailed data available for this UI node." };
}
this.elements.modalTitle.textContent = title;
this.elements.modalBody.textContent = JSON.stringify(detailsToShow, null, 2);
this.elements.modal.style.display = 'flex';
setTimeout(() => {
this.elements.modal.style.opacity = '1';
this.elements.modal.querySelector('.recipe-modal-content').style.transform = 'translateY(0)';
}, 10);
}
closeModal() {
this.elements.modal.style.opacity = '0';
this.elements.modal.querySelector('.recipe-modal-content').style.transform = 'translateY(-2.5rem)';
setTimeout(() => {
this.elements.modal.style.display = 'none';
}, 300);
}
autoLayoutAndRender() {
if (!this.jsonData || !this.jsonData.nodes) return;
console.log('🎨 Starting advanced hierarchical layout algorithm');
const connectors = this.jsonData.ui.connectors;
const nodeIds = Object.keys(this.jsonData.ui.nodes);
const nodes = this.jsonData.ui.nodes;
// 1. Analyze node types and semantic groups
const nodesByType = {
LOAD_DATASET: [],
TRANSFORM: [],
JOIN: [],
APPEND: [],
OUTPUT: [],
FILTER: []
};
nodeIds.forEach(id => {
const type = nodes[id].type;
if (nodesByType[type]) {
nodesByType[type].push(id);
}
});
console.log('📊 Node analysis:', nodesByType);
// 2. Build dependency graph for flow analysis
const adj = new Map(nodeIds.map(id => [id, []]));
const inDegree = new Map(nodeIds.map(id => [id, 0]));
const outDegree = new Map(nodeIds.map(id => [id, 0]));
connectors.forEach(({ source, target }) => {
adj.get(source)?.push(target);
inDegree.set(target, (inDegree.get(target) || 0) + 1);
outDegree.set(source, (outDegree.get(source) || 0) + 1);
});
// 3. Identify main flow vs supporting data
const mainFlowNodes = [];
const supportingNodes = [];
// Main flow: nodes with both inputs and outputs (processing nodes)
// Supporting: pure sources (LOAD_DATASET with no inputs) or leaves
nodeIds.forEach(id => {
const inDeg = inDegree.get(id) || 0;
const outDeg = outDegree.get(id) || 0;
const type = nodes[id].type;
if (type === 'LOAD_DATASET' && inDeg === 0) {
supportingNodes.push(id);
} else if (type === 'OUTPUT' && outDeg === 0) {
mainFlowNodes.push(id); // Outputs are end of main flow
} else if (inDeg > 0 && outDeg > 0) {
mainFlowNodes.push(id); // Processing nodes
} else if (type === 'TRANSFORM' || type === 'JOIN' || type === 'APPEND') {
mainFlowNodes.push(id); // Operations are typically main flow
} else {
supportingNodes.push(id);
}
});
console.log('🔄 Flow analysis:', { mainFlowNodes, supportingNodes });
// 4. Create hierarchical positioning
this.layoutHierarchical(mainFlowNodes, supportingNodes, adj, inDegree, connectors);
this.renderGraph(this.jsonData);
}
layoutHierarchical(mainFlowNodes, supportingNodes, adj, inDegree, connectors) {
console.log('🏗️ Creating hierarchical layout');
const totalNodes = mainFlowNodes.length + supportingNodes.length;
// Handle simple cases with basic left-to-right layout
if (totalNodes <= 3) {
console.log('📝 Using simple layout for', totalNodes, 'nodes');
const allNodes = [...supportingNodes, ...mainFlowNodes];
allNodes.forEach((nodeId, index) => {
this.jsonData.ui.nodes[nodeId].left = 200 + (index * 300);
this.jsonData.ui.nodes[nodeId].top = 250;
});
return;
}
// Layout parameters for complex layouts
const layouts = {
mainFlow: { startX: 400, startY: 300, xGap: 280, yGap: 150 },
supporting: { startX: 100, startY: 150, xGap: 200, yGap: 180 },
output: { startX: 800, startY: 300, xGap: 250, yGap: 120 }
};
// 1. Position main flow nodes using topological order
const mainFlowQueue = mainFlowNodes.filter(id => inDegree.get(id) === 0);
const processedMain = new Set();
const mainPositions = new Map();
let mainLevel = 0;
while (mainFlowQueue.length > 0 || processedMain.size < mainFlowNodes.length) {
const levelNodes = [];
const currentQueueSize = mainFlowQueue.length;
// Process current level
for (let i = 0; i < currentQueueSize; i++) {
const nodeId = mainFlowQueue.shift();
if (nodeId && mainFlowNodes.includes(nodeId)) {
levelNodes.push(nodeId);
processedMain.add(nodeId);
// Add children to queue if all dependencies are met
adj.get(nodeId)?.forEach(child => {
if (mainFlowNodes.includes(child)) {
const childDeps = inDegree.get(child) || 0;
const processedDeps = connectors
.filter(c => c.target === child)
.filter(c => processedMain.has(c.source)).length;
if (processedDeps >= childDeps && !processedMain.has(child)) {
mainFlowQueue.push(child);
}
}
});
}
}
// Position nodes in this level
levelNodes.forEach((nodeId, index) => {
const type = this.jsonData.ui.nodes[nodeId].type;
const x = layouts.mainFlow.startX + (mainLevel * layouts.mainFlow.xGap);
let y = layouts.mainFlow.startY;
// Adjust Y based on type and position in level
if (type === 'OUTPUT') {
y = layouts.output.startY + (index * layouts.output.yGap);
} else {
y = layouts.mainFlow.startY + (index * layouts.mainFlow.yGap);
}
mainPositions.set(nodeId, { x, y });
this.jsonData.ui.nodes[nodeId].left = x;
this.jsonData.ui.nodes[nodeId].top = y;
});
mainLevel++;
// Safety check to prevent infinite loops
if (mainLevel > 10) break;
}
// 2. Position supporting nodes (data sources) on the left
supportingNodes.forEach((nodeId, index) => {
const type = this.jsonData.ui.nodes[nodeId].type;
let x, y;
if (type === 'LOAD_DATASET') {
// Group data sources by what they connect to
const connectedTo = connectors
.filter(c => c.source === nodeId)
.map(c => c.target);
if (connectedTo.length > 0) {
// Position near the first main flow node it connects to
const targetNode = connectedTo[0];
const targetPos = mainPositions.get(targetNode);
if (targetPos) {
x = targetPos.x - layouts.supporting.xGap;
y = targetPos.y + (index % 3 - 1) * layouts.supporting.yGap;
} else {
x = layouts.supporting.startX;
y = layouts.supporting.startY + (index * layouts.supporting.yGap);
}
} else {
x = layouts.supporting.startX;
y = layouts.supporting.startY + (index * layouts.supporting.yGap);
}
} else {
x = layouts.supporting.startX + (index % 2) * layouts.supporting.xGap;
y = layouts.supporting.startY + Math.floor(index / 2) * layouts.supporting.yGap;
}
this.jsonData.ui.nodes[nodeId].left = x;
this.jsonData.ui.nodes[nodeId].top = y;
});
console.log('✅ Hierarchical layout complete');
}
// Public API
getJsonData() {
return this.jsonData;
}
destroy() {
clearTimeout(this.debounceTimeout);
clearTimeout(this.dragRenderTimeout);
window.removeEventListener('mousemove', this.onWindowMouseMove);
window.removeEventListener('mouseup', this.onWindowMouseUp);
this.container.innerHTML = '';
}
}
// Export for different module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = RecipeJsonViewer;
} else if (typeof define === 'function' && define.amd) {
define([], function() { return RecipeJsonViewer; });
} else if (typeof window !== 'undefined') {
window.RecipeJsonViewer = RecipeJsonViewer;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment