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
+

### 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 ``;
}
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 = '';
@@ -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 `` +
+ children.map(child => {
+ let val = (child.metrics && typeof child.metrics[metric] === 'number') ? child.metrics[metric].toFixed(4) : '(no value)';
+ let bar = (child.metrics && typeof child.metrics[metric] === 'number') ? renderMetricBar(child.metrics[metric], min, max) : '';
+ return `- ${child.id}
${val} ${bar} `;
+ }).join('') +
+ `
`;
+ }
+ 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);