Created
June 15, 2025 16:07
-
-
Save saicharanreddyk/cc9a03b5068c8f5dc60ec77607abf857 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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">×</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