diff --git a/Makefile b/Makefile index 7ac6903dd..80d73d0cc 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ docker-build: docker-run: docker run --rm -v $(PROJECT_DIR):/app --network="host" $(DOCKER_IMAGE) examples/function_minimization/initial_program.py examples/function_minimization/evaluator.py --config examples/function_minimization/config.yaml --iterations 1000 -# Run the lm-eval benchmark -.PHONY: lm-eval -lm-eval: - $(PYTHON) scripts/lm_eval/lm-eval.py +# Run the visualization script +.PHONY: visualizer +visualizer: + $(PYTHON) scripts/visualizer.py --path examples/ diff --git a/README.md b/README.md index daa70e0ca..406b5c8b6 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,21 @@ The script in `scripts/visualize.py` allows you to visualize the evolution tree # Install requirements pip install -r scripts/requirements.txt -# Start the visualization web server +# Start the visualization web server and have it watch the examples/ folder python scripts/visualizer.py + +# Start the visualization web server with a specific checkpoint +python scripts/visualizer.py --path examples/function_minimization/openevolve_output/checkpoints/checkpoint_100/ ``` + +In the visualization UI, you can +- see the branching of your program evolution in a network visualization, with node radius chosen by the program fitness (= the currently selected metric), +- see the parent-child relationship of nodes and click through them in the sidebar (use the yellow locator icon in the sidebar to center the node in the graph), +- select the metric of interest (with the available metric choices depending on your data set), +- highlight nodes, for example the top score (for the chosen metric) or the MAP-elites members, +- click nodes to see their code and prompts (if available from the checkpoint data) in a sidebar, +- in the "Performance" tab, see their selected metric score vs generation in a graph + ![OpenEvolve Visualizer](openevolve-visualizer.png) ### Docker diff --git a/openevolve-visualizer.png b/openevolve-visualizer.png index 5ff88edcf..e73f9f53d 100644 Binary files a/openevolve-visualizer.png and b/openevolve-visualizer.png differ diff --git a/pyproject.toml b/pyproject.toml index f02ff90e5..7f09ea376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pyyaml>=6.0", "numpy>=1.22.0", "tqdm>=4.64.0", + "flask", ] [project.optional-dependencies] diff --git a/scripts/static/js/graph.js b/scripts/static/js/graph.js index 8042fc3e6..116ab09ba 100644 --- a/scripts/static/js/graph.js +++ b/scripts/static/js/graph.js @@ -52,6 +52,7 @@ export function updateGraphNodeSelection() { .attr('stroke', d => selectedProgramId === d.id ? 'red' : '#333') .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) .classed('node-selected', d => selectedProgramId === d.id); + updateGraphEdgeSelection(); // update edge highlight when node selection changes } export function getNodeColor(d) { @@ -104,6 +105,9 @@ export function selectProgram(programId) { } nodeElem.classed("node-hovered", false); }); + // Dispatch event for list view sync + window.dispatchEvent(new CustomEvent('node-selected', { detail: { id: programId } })); + updateGraphEdgeSelection(); // update edge highlight on selection } let svg = null; @@ -252,6 +256,7 @@ function renderGraph(data, options = {}) { node .attr("cx", d => d.x) .attr("cy", d => d.y); + updateGraphEdgeSelection(); // update edge highlight on tick }); // Intelligent zoom/pan @@ -309,6 +314,7 @@ function renderGraph(data, options = {}) { } selectProgram(selectedProgramId); + updateGraphEdgeSelection(); // update edge highlight after render applyDragHandlersToAllNodes(); svg.on("click", function(event) { @@ -385,6 +391,14 @@ export function centerAndHighlightNodeInGraph(nodeId) { } } +export function updateGraphEdgeSelection() { + if (!g) return; + g.selectAll('line') + .attr('stroke', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 'red' : '#999') + .attr('stroke-width', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 4 : 2) + .attr('stroke-opacity', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 0.95 : 0.6); +} + function dragstarted(event, d) { if (!event.active && simulation) simulation.alphaTarget(0.3).restart(); // Keep simulation alive d.fx = d.x; @@ -400,4 +414,9 @@ function dragended(event, d) { d.fy = null; } +window.addEventListener('node-selected', function(e) { + // When node selection changes (e.g., from list view), update graph node selection + updateGraphNodeSelection(); +}); + export { renderGraph, g }; diff --git a/scripts/static/js/list.js b/scripts/static/js/list.js index b08184866..b491e5113 100644 --- a/scripts/static/js/list.js +++ b/scripts/static/js/list.js @@ -55,6 +55,9 @@ export function renderNodeList(nodes) { Average ${avgScore.toFixed(4)} ${renderMetricBar(avgScore, minScore, maxScore)} + + 📦 Total: ${nodes.length} programs, ${new Set(nodes.map(n => n.generation)).size} generations, ${new Set(nodes.map(n => n.island)).size} islands + `; container.innerHTML = ''; @@ -151,6 +154,12 @@ export function renderNodeList(nodes) { }, 0); container.appendChild(row); }); + container.focus(); + // Scroll to selected node if present + const selected = container.querySelector('.node-list-item.selected'); + if (selected) { + selected.scrollIntoView({behavior: 'smooth', block: 'center'}); + } } export function selectListNodeById(id) { setSelectedProgramId(id); @@ -200,4 +209,53 @@ function showSidebarListView() { } else { showSidebar(); } -} \ No newline at end of file +} + +// Sync selection when switching to list tab +const tabListBtn = document.getElementById('tab-list'); +if (tabListBtn) { + tabListBtn.addEventListener('click', () => { + renderNodeList(allNodeData); + }); +} + +// Keyboard navigation for up/down in list view +const nodeListContainer = document.getElementById('node-list-container'); +if (nodeListContainer) { + nodeListContainer.tabIndex = 0; + nodeListContainer.addEventListener('keydown', function(e) { + if (!['ArrowUp', 'ArrowDown'].includes(e.key)) return; + e.preventDefault(); // Always prevent default to avoid browser scroll + const items = Array.from(nodeListContainer.querySelectorAll('.node-list-item')); + if (!items.length) return; + let idx = items.findIndex(item => item.classList.contains('selected')); + if (idx === -1) idx = 0; + if (e.key === 'ArrowUp' && idx > 0) idx--; + if (e.key === 'ArrowDown' && idx < items.length - 1) idx++; + const nextItem = items[idx]; + if (nextItem) { + const nodeId = nextItem.getAttribute('data-node-id'); + selectListNodeById(nodeId); + nextItem.focus(); + nextItem.scrollIntoView({behavior: 'smooth', block: 'center'}); + // Also scroll the page if needed + const rect = nextItem.getBoundingClientRect(); + if (rect.top < 0 || rect.bottom > window.innerHeight) { + window.scrollTo({top: window.scrollY + rect.top - 100, behavior: 'smooth'}); + } + } + }); + // Focus container on click to enable keyboard nav + nodeListContainer.addEventListener('click', function() { + nodeListContainer.focus(); + }); +} + +// Listen for node selection events from other views and sync selection in the list view +window.addEventListener('node-selected', function(e) { + // e.detail should contain the selected node id + if (e.detail && e.detail.id) { + setSelectedProgramId(e.detail.id); + renderNodeList(allNodeData); + } +}); \ No newline at end of file diff --git a/scripts/static/js/main.js b/scripts/static/js/main.js index 81075036c..b06961e2b 100644 --- a/scripts/static/js/main.js +++ b/scripts/static/js/main.js @@ -5,6 +5,7 @@ import { updateListSidebarLayout, renderNodeList } from './list.js'; import { renderGraph, g, getNodeRadius, animateGraphNodeAttributes } from './graph.js'; export let allNodeData = []; +let metricMinMax = {}; let archiveProgramIds = []; @@ -13,15 +14,55 @@ const sidebarEl = document.getElementById('sidebar'); let lastDataStr = null; let selectedProgramId = null; +function computeMetricMinMax(nodes) { + metricMinMax = {}; + if (!nodes) return; + nodes.forEach(n => { + if (n.metrics && typeof n.metrics === 'object') { + for (const [k, v] of Object.entries(n.metrics)) { + if (typeof v === 'number' && isFinite(v)) { + if (!(k in metricMinMax)) { + metricMinMax[k] = {min: v, max: v}; + } else { + metricMinMax[k].min = Math.min(metricMinMax[k].min, v); + metricMinMax[k].max = Math.max(metricMinMax[k].max, v); + } + } + } + } + }); + // Avoid min==max + for (const k in metricMinMax) { + if (metricMinMax[k].min === metricMinMax[k].max) { + metricMinMax[k].min = 0; + metricMinMax[k].max = 1; + } + } +} + function formatMetrics(metrics) { - return Object.entries(metrics).map(([k, v]) => `${k}: ${v}`).join('
'); + if (!metrics || typeof metrics !== 'object') return ''; + let rows = Object.entries(metrics).map(([k, v]) => { + let min = 0, max = 1; + if (metricMinMax[k]) { + min = metricMinMax[k].min; + max = metricMinMax[k].max; + } + let valStr = (typeof v === 'number' && isFinite(v)) ? v.toFixed(4) : v; + return `${k}${valStr}${typeof v === 'number' ? renderMetricBar(v, min, max) : ''}`; + }).join(''); + return `${rows}
`; } function renderMetricBar(value, min, max, opts={}) { let percent = 0; - if (typeof value === 'number' && isFinite(value) && max > min) { - percent = (value - min) / (max - min); - percent = Math.max(0, Math.min(1, percent)); + if (typeof value === 'number' && isFinite(value)) { + if (max > min) { + percent = (value - min) / (max - min); + percent = Math.max(0, Math.min(1, percent)); + } else if (max === min) { + percent = 1; // Show as filled if min==max + } } let minLabel = `${min.toFixed(2)}`; let maxLabel = `${max.toFixed(2)}`; @@ -201,10 +242,11 @@ document.getElementById('tab-branching').addEventListener('click', function() { // Export all shared state and helpers for use in other modules export function setAllNodeData(nodes) { allNodeData = nodes; + computeMetricMinMax(nodes); } export function setSelectedProgramId(id) { selectedProgramId = id; } -export { archiveProgramIds, lastDataStr, selectedProgramId, formatMetrics, renderMetricBar, getHighlightNodes, getSelectedMetric }; +export { archiveProgramIds, lastDataStr, selectedProgramId, formatMetrics, renderMetricBar, getHighlightNodes, getSelectedMetric, metricMinMax }; diff --git a/scripts/static/js/performance.js b/scripts/static/js/performance.js index 9fd235a64..a2f302946 100644 --- a/scripts/static/js/performance.js +++ b/scripts/static/js/performance.js @@ -82,7 +82,7 @@ import { selectListNodeById } from './list.js'; g.selectAll('circle') .filter(function(d) { return undefinedNodes.includes(d); }) .transition().duration(400) - .attr('cx', margin.left + undefinedBoxWidth/2) + .attr('cx', d => d._nanX || (margin.left + undefinedBoxWidth/2)) .attr('cy', d => yScales[showIslands ? d.island : null](d.generation)) .attr('r', d => getNodeRadius(d)) .attr('fill', d => getNodeColor(d)) @@ -132,21 +132,24 @@ import { selectListNodeById } from './list.js'; const island = showIslands ? d.target.island : null; return yScales[island](d.target.generation); }) - .attr('stroke', '#888') - .attr('stroke-width', 1.5) + .attr('stroke', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 'red' : '#888') + .attr('stroke-width', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 3 : 1.5) .attr('opacity', 0.5); } const metricSelect = document.getElementById('metric-select'); metricSelect.addEventListener('change', function() { updatePerformanceGraph(allNodeData); + setTimeout(updateEdgeHighlighting, 0); // ensure edges update after node positions change }); const highlightSelect = document.getElementById('highlight-select'); highlightSelect.addEventListener('change', function() { animatePerformanceGraphAttributes(); + setTimeout(updateEdgeHighlighting, 0); // ensure edges update after animation }); document.getElementById('tab-performance').addEventListener('click', function() { if (typeof allNodeData !== 'undefined' && allNodeData.length) { - updatePerformanceGraph(allNodeData); + updatePerformanceGraph(allNodeData, {autoZoom: true}); + setTimeout(() => { zoomPerformanceGraphToFit(); }, 0); } }); // Show islands yes/no toggle event @@ -163,7 +166,11 @@ import { selectListNodeById } from './list.js'; // Initial render if (typeof allNodeData !== 'undefined' && allNodeData.length) { - updatePerformanceGraph(allNodeData); + updatePerformanceGraph(allNodeData, {autoZoom: true}); + // --- Zoom to fit after initial render --- + setTimeout(() => { + zoomPerformanceGraphToFit(); + }, 0); } }); })(); @@ -172,6 +179,8 @@ import { selectListNodeById } from './list.js'; export function selectPerformanceNodeById(id, opts = {}) { setSelectedProgramId(id); setSidebarSticky(true); + // Dispatch event for list view sync + window.dispatchEvent(new CustomEvent('node-selected', { detail: { id } })); if (typeof allNodeData !== 'undefined' && allNodeData.length) { updatePerformanceGraph(allNodeData, opts); const node = allNodeData.find(n => n.id == id); @@ -224,6 +233,40 @@ let g = null; let zoomBehavior = null; let lastTransform = null; +function autoZoomPerformanceGraph(nodes, x, yScales, islands, graphHeight, margin, undefinedBoxWidth, width, svg, g) { + // Compute bounding box for all nodes (including NaN box) + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + // Valid nodes + nodes.forEach(n => { + let cx, cy; + if (n.metrics && typeof n.metrics[getSelectedMetric()] === 'number') { + cx = x(n.metrics[getSelectedMetric()]); + cy = yScales[document.getElementById('show-islands-toggle')?.checked ? n.island : null](n.generation); + } else if (typeof n._nanX === 'number') { + cx = n._nanX; + cy = yScales[document.getElementById('show-islands-toggle')?.checked ? n.island : null](n.generation); + } + if (typeof cx === 'number' && typeof cy === 'number') { + minX = Math.min(minX, cx); + maxX = Math.max(maxX, cx); + minY = Math.min(minY, cy); + maxY = Math.max(maxY, cy); + } + }); + // Include NaN box + minX = Math.min(minX, margin.left); + // Add some padding + const padX = 60, padY = 60; + minX -= padX; maxX += padX; minY -= padY; maxY += padY; + const svgW = +svg.attr('width'); + const svgH = +svg.attr('height'); + const scale = Math.min(svgW / (maxX - minX), svgH / (maxY - minY), 1.5); + const tx = svgW/2 - scale * (minX + (maxX-minX)/2); + const ty = svgH/2 - scale * (minY + (maxY-minY)/2); + const t = d3.zoomIdentity.translate(tx, ty).scale(scale); + svg.transition().duration(500).call(zoomBehavior.transform, t); +} + function updatePerformanceGraph(nodes, options = {}) { // Get or create SVG if (!svg) { @@ -272,6 +315,7 @@ function updatePerformanceGraph(nodes, options = {}) { }) .attr('stroke-width', 1.5); selectListNodeById(null); + setTimeout(updateEdgeHighlighting, 0); // ensure edges update after selectedProgramId is null } }); // Sizing @@ -317,11 +361,11 @@ function updatePerformanceGraph(nodes, options = {}) { .attr('class', 'axis') .attr('transform', `translate(${margin.left+graphXOffset},0)`) .call(d3.axisLeft(yScales[island]).ticks(Math.min(12, genCount))); - // Y axis label + // Y axis label (always at start of main graph) g.append('text') .attr('class', 'axis-label') .attr('transform', `rotate(-90)`) // vertical - .attr('y', margin.left + 8) + .attr('y', margin.left + graphXOffset + 8) .attr('x', -(margin.top + i*graphHeight + (graphHeight - margin.top - margin.bottom)/2)) .attr('dy', '-2.2em') .attr('text-anchor', 'middle') @@ -365,11 +409,27 @@ function updatePerformanceGraph(nodes, options = {}) { .text(metric); // NaN box if (undefinedNodes.length) { + // Group NaN nodes by (generation, island) + const nanGroups = {}; + undefinedNodes.forEach(n => { + const key = `${n.generation}|${showIslands ? n.island : ''}`; + if (!nanGroups[key]) nanGroups[key] = []; + nanGroups[key].push(n); + }); + // Find max group size + const maxGroupSize = Math.max(...Object.values(nanGroups).map(g => g.length)); + // Box width should be based on the full intended spread, not the reduced spread + const spreadWidth = Math.max(38, 24 * maxGroupSize); + undefinedBoxWidth = spreadWidth/2 + 32; // 16px padding on each side + // Add a fixed offset so the NaN box is further left of the main graph + const nanBoxGap = 64; // px gap between NaN box and main graph + const nanBoxRight = margin.left + graphXOffset - nanBoxGap; + const nanBoxLeft = nanBoxRight - undefinedBoxWidth; const boxTop = margin.top; const boxBottom = showIslands ? (margin.top + islands.length*graphHeight - margin.bottom) : (margin.top + graphHeight - margin.bottom); g.append('text') .attr('class', 'nan-label') - .attr('x', margin.left + undefinedBoxWidth/2) + .attr('x', nanBoxLeft + undefinedBoxWidth/2) .attr('y', boxTop - 10) .attr('text-anchor', 'middle') .attr('font-size', '0.92em') @@ -377,7 +437,7 @@ function updatePerformanceGraph(nodes, options = {}) { .text('NaN'); g.append('rect') .attr('class', 'nan-box') - .attr('x', margin.left) + .attr('x', nanBoxLeft) .attr('y', boxTop) .attr('width', undefinedBoxWidth) .attr('height', boxBottom - boxTop) @@ -385,29 +445,70 @@ function updatePerformanceGraph(nodes, options = {}) { .attr('stroke', '#bbb') .attr('stroke-width', 1.5) .attr('rx', 12); + // Assign x offset for each NaN node (spread only in the center half of the box) + undefinedNodes.forEach(n => { + const key = `${n.generation}|${showIslands ? n.island : ''}`; + const group = nanGroups[key]; + if (!group) return; + if (group.length === 1) { + n._nanX = nanBoxLeft + undefinedBoxWidth/2; + } else { + const idx = group.indexOf(n); + const innerSpread = spreadWidth / 2; // only use half the box for node spread + const innerStart = nanBoxLeft + (undefinedBoxWidth - innerSpread) / 2; + n._nanX = innerStart + innerSpread * (idx + 0.5) / group.length; + } + }); } // Data join for edges const nodeById = Object.fromEntries(nodes.map(n => [n.id, n])); const edges = nodes.filter(n => n.parent_id && nodeById[n.parent_id]).map(n => ({ source: nodeById[n.parent_id], target: n })); - const edgeSel = g.selectAll('line.performance-edge') - .data(edges, d => d.target.id); - edgeSel.enter() + // Remove all old edges before re-adding (fixes missing/incorrect edges after metric change) + g.selectAll('line.performance-edge').remove(); + // Helper to get x/y for a node (handles NaN and valid nodes) + function getNodeXY(node, x, yScales, showIslands, metric) { + // Returns [x, y] for a node, handling both valid and NaN nodes + if (!node) return [null, null]; + const y = yScales[showIslands ? node.island : null](node.generation); + if (node.metrics && typeof node.metrics[metric] === 'number') { + return [x(node.metrics[metric]), y]; + } else if (typeof node._nanX === 'number') { + return [node._nanX, y]; + } else { + // fallback: center of NaN box if _nanX not set + // This should not happen, but fallback for safety + return [x.range()[0] - 100, y]; + } + } + g.selectAll('line.performance-edge') + .data(edges, d => d.target.id) + .enter() .append('line') .attr('class', 'performance-edge') .attr('stroke', '#888') .attr('stroke-width', 1.5) .attr('opacity', 0.5) - .attr('x1', d => x(d.source.metrics && typeof d.source.metrics[metric] === 'number' ? d.source.metrics[metric] : null) || (margin.left + undefinedBoxWidth/2)) - .attr('y1', d => yScales[showIslands ? d.source.island : null](d.source.generation)) - .attr('x2', d => x(d.target.metrics && typeof d.target.metrics[metric] === 'number' ? d.target.metrics[metric] : null) || (margin.left + undefinedBoxWidth/2)) - .attr('y2', d => yScales[showIslands ? d.target.island : null](d.target.generation)) - .merge(edgeSel) - .transition().duration(500) - .attr('x1', d => x(d.source.metrics && typeof d.source.metrics[metric] === 'number' ? d.source.metrics[metric] : null) || (margin.left + undefinedBoxWidth/2)) - .attr('y1', d => yScales[showIslands ? d.source.island : null](d.source.generation)) - .attr('x2', d => x(d.target.metrics && typeof d.target.metrics[metric] === 'number' ? d.target.metrics[metric] : null) || (margin.left + undefinedBoxWidth/2)) - .attr('y2', d => yScales[showIslands ? d.target.island : null](d.target.generation)); - edgeSel.exit().transition().duration(300).attr('opacity', 0).remove(); + .attr('x1', d => getNodeXY(d.source, x, yScales, showIslands, metric)[0]) + .attr('y1', d => getNodeXY(d.source, x, yScales, showIslands, metric)[1]) + .attr('x2', d => getNodeXY(d.target, x, yScales, showIslands, metric)[0]) + .attr('y2', d => getNodeXY(d.target, x, yScales, showIslands, metric)[1]) + .attr('stroke', d => { + if (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) { + return 'red'; + } + return '#888'; + }) + .attr('stroke-width', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 3 : 1.5) + .attr('opacity', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 0.9 : 0.5); + // --- Ensure edge highlighting updates after node selection --- + function updateEdgeHighlighting() { + g.selectAll('line.performance-edge') + .attr('stroke', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 'red' : '#888') + .attr('stroke-width', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 3 : 1.5) + .attr('opacity', d => (selectedProgramId && (d.source.id === selectedProgramId || d.target.id === selectedProgramId)) ? 0.9 : 0.5); + } + updateEdgeHighlighting(); + // Data join for nodes const highlightFilter = document.getElementById('highlight-select').value; const highlightNodes = getHighlightNodes(nodes, highlightFilter, metric); @@ -459,6 +560,7 @@ function updatePerformanceGraph(nodes, options = {}) { showSidebarContent(d, false); showSidebar(); selectProgram(selectedProgramId); + updateEdgeHighlighting(); }) .merge(nodeSel) .transition().duration(500) @@ -483,7 +585,7 @@ function updatePerformanceGraph(nodes, options = {}) { nanSel.enter() .append('circle') .attr('class', 'performance-nan') - .attr('cx', margin.left + undefinedBoxWidth/2) + .attr('cx', d => d._nanX) .attr('cy', d => yScales[showIslands ? d.island : null](d.generation)) .attr('r', d => getNodeRadius(d)) .attr('fill', d => getNodeColor(d)) @@ -525,10 +627,11 @@ function updatePerformanceGraph(nodes, options = {}) { showSidebarContent(d, false); showSidebar(); selectProgram(selectedProgramId); + updateEdgeHighlighting(); }) .merge(nanSel) .transition().duration(500) - .attr('cx', margin.left + undefinedBoxWidth/2) + .attr('cx', d => d._nanX) .attr('cy', d => yScales[showIslands ? d.island : null](d.generation)) .attr('r', d => getNodeRadius(d)) .attr('fill', d => getNodeColor(d)) @@ -542,4 +645,44 @@ function updatePerformanceGraph(nodes, options = {}) { .classed('node-selected', selectedProgramId === d.id); }); nanSel.exit().transition().duration(300).attr('opacity', 0).remove(); + // Auto-zoom to fit on initial render or when requested + if (options.autoZoom || (!lastTransform && nodes.length)) { + autoZoomPerformanceGraph(nodes, x, yScales, islands, graphHeight, margin, undefinedBoxWidth, width, svg, g); + } +} + +// --- Zoom-to-fit helper --- +function zoomPerformanceGraphToFit() { + if (!svg || !g) return; + // Get all node positions (valid and NaN) + const nodeCircles = g.selectAll('circle.performance-node, circle.performance-nan').nodes(); + if (!nodeCircles.length) return; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodeCircles.forEach(node => { + const bbox = node.getBBox(); + minX = Math.min(minX, bbox.x); + minY = Math.min(minY, bbox.y); + maxX = Math.max(maxX, bbox.x + bbox.width); + maxY = Math.max(maxY, bbox.y + bbox.height); + }); + // Also include the NaN box if present + const nanBox = g.select('rect.nan-box').node(); + if (nanBox) { + const bbox = nanBox.getBBox(); + minX = Math.min(minX, bbox.x); + minY = Math.min(minY, bbox.y); + maxX = Math.max(maxX, bbox.x + bbox.width); + maxY = Math.max(maxY, bbox.y + bbox.height); + } + // Add some padding + const pad = 32; + minX -= pad; minY -= pad; maxX += pad; maxY += pad; + const graphW = svg.attr('width'); + const graphH = svg.attr('height'); + const scale = Math.min(graphW / (maxX - minX), graphH / (maxY - minY), 1.5); + const tx = graphW/2 - scale * (minX + (maxX-minX)/2); + const ty = graphH/2 - scale * (minY + (maxY-minY)/2); + const t = d3.zoomIdentity.translate(tx, ty).scale(scale); + svg.transition().duration(400).call(zoomBehavior.transform, t); + lastTransform = t; } diff --git a/scripts/static/js/sidebar.js b/scripts/static/js/sidebar.js index 85eaa5f38..da9f6ca2b 100644 --- a/scripts/static/js/sidebar.js +++ b/scripts/static/js/sidebar.js @@ -3,6 +3,7 @@ import { scrollAndSelectNodeById } from './graph.js'; const sidebar = document.getElementById('sidebar'); export let sidebarSticky = false; +let lastSidebarTab = null; export function showSidebar() { sidebar.style.transform = 'translateX(0)'; @@ -24,7 +25,6 @@ export function showSidebarContent(d, fromHover = false) { if (archiveProgramIds && archiveProgramIds.includes(d.id)) { starHtml = '★'; } - // Locator icon button (left of close X) let locatorBtn = ''; let closeBtn = ''; let openLink = '
[open in new window]
'; @@ -33,17 +33,45 @@ export function showSidebarContent(d, fromHover = false) { let tabNames = []; if (d.code && typeof d.code === 'string' && d.code.trim() !== '') tabNames.push('Code'); if (d.prompts && typeof d.prompts === 'object' && Object.keys(d.prompts).length > 0) tabNames.push('Prompts'); - if (tabNames.length > 0) { - tabHtml = ''; - tabContentHtml = ''; + if (tabName === 'Children') { + const metric = (document.getElementById('metric-select') && document.getElementById('metric-select').value) || 'combined_score'; + let min = 0, max = 1; + const vals = children.map(child => (child.metrics && typeof child.metrics[metric] === 'number') ? child.metrics[metric] : null).filter(x => x !== null); + if (vals.length > 0) { + min = Math.min(...vals); + max = Math.max(...vals); + } + return `
`; + } + return ''; + } + + if (tabNames.length > 0) { + tabHtml = ''; + tabContentHtml = ``; } let parentIslandHtml = ''; if (d.parent_id && d.parent_id !== 'None') { @@ -72,18 +100,55 @@ export function showSidebarContent(d, fromHover = false) { Array.from(tabBar.children).forEach(e => e.classList.remove('active')); tabEl.classList.add('active'); const tabName = tabEl.dataset.tab; + lastSidebarTab = tabName; const tabContent = document.getElementById('sidebar-tab-content'); - if (tabName === 'Code') tabContent.innerHTML = ``; - if (tabName === 'Prompts') { - let html = ''; - for (const [k, v] of Object.entries(d.prompts)) { - html += `
${k}:
`; + tabContent.innerHTML = renderSidebarTabContent(tabName, d, children); + setTimeout(() => { + document.querySelectorAll('.child-link').forEach(link => { + link.onclick = function(e) { + e.preventDefault(); + const childNode = allNodeData.find(n => n.id == link.dataset.child); + if (childNode) { + window._lastSelectedNodeData = childNode; + const perfTabBtn = document.getElementById('tab-performance'); + const perfTabView = document.getElementById('view-performance'); + if ((perfTabBtn && perfTabBtn.classList.contains('active')) || (perfTabView && perfTabView.classList.contains('active'))) { + import('./performance.js').then(mod => { + mod.selectPerformanceNodeById(childNode.id); + showSidebar(); + }); + } else { + scrollAndSelectNodeById(childNode.id); + } + } + }; + }); + }, 0); + }; + }); + } + setTimeout(() => { + document.querySelectorAll('.child-link').forEach(link => { + link.onclick = function(e) { + e.preventDefault(); + const childNode = allNodeData.find(n => n.id == link.dataset.child); + if (childNode) { + window._lastSelectedNodeData = childNode; + // Check if performance tab is active + const perfTabBtn = document.getElementById('tab-performance'); + const perfTabView = document.getElementById('view-performance'); + if ((perfTabBtn && perfTabBtn.classList.contains('active')) || (perfTabView && perfTabView.classList.contains('active'))) { + import('./performance.js').then(mod => { + mod.selectPerformanceNodeById(childNode.id); + showSidebar(); + }); + } else { + scrollAndSelectNodeById(childNode.id); } - tabContent.innerHTML = html; } }; }); - } + }, 0); const closeBtnEl = document.getElementById('sidebar-close-btn'); if (closeBtnEl) closeBtnEl.onclick = function() { setSelectedProgramId(null);